diff --git a/CHANGELOG.md b/CHANGELOG.md index 5912a4d..d2e112e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,94 @@ # Changelog +## 0.9.4 + +### Added + +- **Compact expanded list rows** — Expanding a row in By Activity / By Area now shows only the chart. The gear icon and a real toggle-pill (arm-protected by the + slide-to-confirm) moved onto the always-visible list row so expanding no longer duplicates information above the chart. +- **Configurable list view columns** — 1 / 2 / 3 column grid for By Activity and By Area, set in Graph Settings → List View Columns. Persisted in localStorage + as a browser-wide preference (single key, not scoped per device). Narrow viewports (< 600px) force single-column regardless. Expanded charts stay in their own + column so row-to-chart association stays clear. +- **Favorites per-panel status grid** — The Favorites view now renders a responsive grid of per-contributing-panel status cards (Site / Grid / Upstream / + Downstream / Solar / Battery) below the slider + W/A row. One card per panel that contributes to the Favorites set; live values update on each tick. +- **Slide-to-arm in Favorites** — Favorites view header now hosts the slide-confirm control so tappable ON/OFF toggles in list rows can actually fire. The + non-Favorites list views (By Activity / By Area on real panels) also gain a working slide-to-arm (previously the slider rendered but drag handlers were never + bound). +- **Panel tabs inline with dropdown** — The panel tab bar (By Panel / By Activity / By Area / Monitoring) now sits on the same row as the panel-selector + dropdown in the toolbar. + +### Fixed + +- **Favorites utilization % now renders** — The list view was passing `null` monitoring status for Favorites, leaving utilization badges blank. Added per-entry + fetch + merge (`mergeMonitoringStatuses`) with a keyed 30s cache so cross-panel favorites show the same utilization data the single-panel views show. +- **List / panel view flashing on tab, W/A, and panel switches** — Event handlers were both mutating `@state` and explicitly scheduling renders, so every + interaction fired two concurrent re-renders. Fixed by moving to the reactive-only path and adding a render coalescer plus a supersession-token guard so + superseded renders bail out at each async boundary. +- **Amps chart didn't redraw on unit switch** — `powerHistory` merged new-metric points into the existing Watts map under the same UUID key. Now cleared before + every full re-render. +- **`[data-uuid]` selector shadowing** — `dom-updater` scoped to `.circuit-slot[data-uuid]` so the expanded chart-only slot is targeted instead of the list row + (which now also carries `data-uuid`). +- **ON/OFF badge no longer silently non-functional** — The tappable badge now routes through the real toggle pipeline with the slide-confirm gate; list views + previously dropped clicks because no `.slide-confirm` element existed in their header. +- **Favorites view now shows offline banner per contributing panel** — Previously the Favorites view silently hid the red "SPAN Panel unreachable" banner even + when a contributing panel was offline. The view now renders one banner row per offline panel, labeled with the panel's name (e.g. "Span Panel 2 unreachable"), + so users mixing favorites from multiple panels can see which one is down. +- **Graph Settings → List View Columns selector now shows the current setting** — the 1/2/3 segmented control rendered without any styling in the side-panel's + shadow DOM, so the active option was invisible. The side-panel now ships its own `.unit-toggle` styles and highlights the current column count. + +### Changed + +- **Utilization % moved next to breaker badge** — In both the list rows and the By Panel breaker-grid slots, the utilization percent now sits immediately after + the breaker badge rather than alongside the battery shedding icon, where it competed visually with battery SoC. +- **Favorites header simplified** — Removed the redundant "Favorites · N favorites" text (the dropdown already says "Favorites"). The header row now holds just + the slide-to-arm and W/A unit toggle. +- **`buildListRowHTML` replaced the static ON/OFF badge with a real toggle-pill** for controllable circuits. Non-controllable circuits (PV, no switch entity) + keep a static text badge so they can't be accidentally toggled. + +### Architecture + +- Extracted `buildPanelStatsHTML` / `updatePanelStatsBlock` so stats-block render + update are shared between the persistent panel header and the Favorites + per-panel grid. +- Extracted `mergeMonitoringStatuses` pure helper plus a new `MonitoringStatusMultiCache` per-entry keyed cache; + `DashboardController.fetchMergedMonitoringStatus` now reuses cached results within the 30s TTL instead of fanning out WS calls on every render. +- Extracted `FavoritesViewState` persistence (`src/panel/favorites-view-state.ts`) and the render coalescing / token helpers (`src/panel/coalesce.ts`) out of + the ~1000-line panel element. Both are pure and now tested. +- `CARD_STYLES` emitted once via Lit `static styles` rather than `insertAdjacentHTML`'d per tab render. +- `CSS.escape` wrapper applied to every identifier-interpolated `querySelector`. +- Each circuit in a list view now wraps its row + optional expansion in a `.list-cell` container so the expansion stays in the same CSS-grid column as its row + in multi-column mode. +- Debounced Favorites view-state localStorage persistence (250ms) so long search queries don't thrash storage. +- Tests grew from 109 → 132 (new coverage: `getCircuitStateClasses`, `mergeMonitoringStatuses`, `FavoritesController.build` composite-id construction, and + render-coalescing / supersession-token contracts). + +## 0.9.3 + +### Added + +- **Cross-panel Favorites view** — A synthetic "Favorites" entry appears in the dashboard panel dropdown (only when at least one favorite is configured) and + aggregates favorited circuits and sub-devices (BESS, EVSE) from every configured SPAN panel into a single workspace. + - Heart toggles in the Graph Settings side panel and per-circuit / per-sub-device side panels (dashboard mode only — never in the standalone Lovelace card). + - Favorites view shows By Activity / By Area / Monitoring tabs (no By Panel). When more than one panel contributes, circuit and sub-device names are prefixed + with their panel name. Sub-device tiles render above the circuit list. Monitoring stacks per-panel blocks. + - Stateful: active tab, expanded rows, and search query persist via localStorage and restore on return. +- **Persistent panel-stats header** — Site / Grid / Upstream / Downstream / Solar / Battery stats now stay visible across all tabs (By Panel, By Activity, By + Area, Monitoring) on real panels. Lifted out of the By-Panel grid into the wrapper. Favorites pseudo-panel shows a count summary instead. + +### Fixed + +- **Sub-device per-target horizon override** — Setting an individual BESS or EVSE horizon from the sub-device side panel had no effect when more than one SPAN + panel was configured: the service call omitted `config_entry_id`, so the backend wrote the override to the first loaded entry's manager (wrong panel). The + per-circuit side panel and the panel-mode (Graph Settings) list already threaded it; the sub-device side panel now does too. +- **`` first-render crash** — The dashboard panel no longer creates `ha-menu-button` until Home Assistant has assigned `hass`; HA's component + reads `this.hass.kioskMode` in `willUpdate` and would throw before the property was set. + +### Changed + +- **Side-panel domain service calls thread `config_entry_id`** — Circuit horizon, sub-device horizon, and circuit threshold service calls now route to the + originating panel's config entry. Required for cross-panel Favorites edits to target the right panel and fixes the same-panel bug above. +- **W/A unit toggle moved to the persistent header** — The duplicate toggle below the search bar was removed since the persistent panel-stats header now owns + it. The Favorites pseudo-panel's summary strip carries its own toggle. + ## 0.9.2 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..62d0a77 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +# CLAUDE.md + +## Doc Artifacts + +Design docs, specs, plans, brainstorming output, and superpowers artifacts for this repo live in the SpanPanel_Docs workspace, NOT in this repo: + +- `/Users/bflood/projects/HA/SpanPanel_Docs/span/docs/superpowers/plans/` — superpowers plan artifacts +- `/Users/bflood/projects/HA/SpanPanel_Docs/span/docs/superpowers/specs/` — superpowers spec artifacts +- `/Users/bflood/projects/HA/SpanPanel_Docs/span/docs/dev/` — developer plans, specs, design documents + +When invoking the brainstorming or writing-plans skill, override its default location (which is `docs/superpowers/...` in the current repo) with the correct +path in SpanPanel_Docs. Never write `.md` design artifacts into this repo. + +If a doc was already written to the wrong location, copy it to the correct location before removing from this repo. Do not auto-commit in SpanPanel_Docs since +that repo may have unrelated pending changes — leave new files as untracked for the user to commit. + +This matches the AGENTS.md convention in the sibling `span` integration repo (`/Users/bflood/projects/HA/span/AGENTS.md`) — the two repos are maintained +together and share the same doc-artifact rule. + +## Attribution + +Never include references to AI models, AI assistants, or AI-generated content in any code, comments, commit messages, PR descriptions, documentation, or other +output. This includes "Co-Authored-By" tags, "Generated with" footers, and any similar attribution. + +## Related + +- Sibling integration repo: `/Users/bflood/projects/HA/span` (has its own AGENTS.md with the integration-specific conventions) +- After any change under `src/`, the integration's bundled JS must be rebuilt and copied via the `sync-frontend` skill or + `/Users/bflood/projects/HA/span/scripts/build-frontend.sh`. diff --git a/dist/span-panel-card.js b/dist/span-panel-card.js index 9d806e7..39b2709 100644 --- a/dist/span-panel-card.js +++ b/dist/span-panel-card.js @@ -1,61 +1,116 @@ -let e="en";const t={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","sidepanel.graph_horizon_failed":"Graph horizon update failed:","sidepanel.clear_graph_horizon_failed":"Clear graph horizon failed:","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.global_default":"Global Default","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.relay_failed":"Relay toggle failed:","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.shedding_failed":"Shedding update failed:","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.monitoring_toggle_failed":"Monitoring toggle failed:","sidepanel.clear_monitoring_failed":"Clear monitoring failed:","sidepanel.save_threshold_failed":"Save threshold failed:","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.loading":"Loading...","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","sidepanel.graph_horizon_failed":"Graph horizon update failed:","sidepanel.clear_graph_horizon_failed":"Clear graph horizon failed:","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.global_default":"Predeterminado Global","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.relay_failed":"Error al cambiar relé:","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.shedding_failed":"Error al actualizar desconexción:","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.monitoring_toggle_failed":"Error al cambiar monitoreo:","sidepanel.clear_monitoring_failed":"Error al limpiar monitoreo:","sidepanel.save_threshold_failed":"Error al guardar umbral:","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.loading":"Cargando...","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","sidepanel.graph_horizon_failed":"Graph horizon update failed:","sidepanel.clear_graph_horizon_failed":"Clear graph horizon failed:","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.global_default":"Défaut Global","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.relay_failed":"Échec du basculement du relais :","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.shedding_failed":"Échec de la mise à jour du délestage :","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.monitoring_toggle_failed":"Échec du basculement de surveillance :","sidepanel.clear_monitoring_failed":"Échec de l'effacement de surveillance :","sidepanel.save_threshold_failed":"Échec de la sauvegarde du seuil :","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.loading":"Chargement...","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","sidepanel.graph_horizon_failed":"Graph horizon update failed:","sidepanel.clear_graph_horizon_failed":"Clear graph horizon failed:","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.global_default":"グローバルデフォルト","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.relay_failed":"リレー切り替え失敗:","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.shedding_failed":"シェディング更新失敗:","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.monitoring_toggle_failed":"モニタリング切り替え失敗:","sidepanel.clear_monitoring_failed":"モニタリングクリア失敗:","sidepanel.save_threshold_failed":"しきい値保存失敗:","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.loading":"読み込み中...","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","sidepanel.graph_horizon_failed":"Graph horizon update failed:","sidepanel.clear_graph_horizon_failed":"Clear graph horizon failed:","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.global_default":"Padrão Global","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.relay_failed":"Falha ao alternar relé:","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.shedding_failed":"Falha ao atualizar desligamento:","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.monitoring_toggle_failed":"Falha ao alternar monitoramento:","sidepanel.clear_monitoring_failed":"Falha ao limpar monitoramento:","sidepanel.save_threshold_failed":"Falha ao salvar limite:","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.loading":"Carregando...","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){e=n&&t[n]?n:"en"}function i(n){return t[e]?.[n]??t.en?.[n]??n}const s="power",o="5m",a={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},r="span_panel",c="CLOSED",l="pv",d="bess",h="evse",p="sub_",u=500,g={power:{entityRole:"power",label:()=>i("metric.power"),unit:e=>Math.abs(e)>=1e3?"kW":"W",format:e=>{const t=Math.abs(e);return t>=1e3?(t/1e3).toFixed(1):t<10&&t>0?t.toFixed(1):String(Math.round(t))}},current:{entityRole:"current",label:()=>i("metric.current"),unit:()=>"A",format:e=>Math.abs(e).toFixed(1)}},_={soc:{entityRole:"soc",label:()=>i("metric.soc"),unit:()=>"%",format:e=>String(Math.round(e)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>i("metric.soe"),unit:()=>"kWh",format:e=>e.toFixed(1)},power:g.power},f={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>i("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>i("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>i("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>i("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>i("shedding.unknown")}},m="#ff9800";function v(e,t,n,i){var s,o=arguments.length,a=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(e,t,n,i);else for(var r=e.length-1;r>=0;r--)(s=e[r])&&(a=(o<3?s(a):o>3?s(t,n,a):s(t,n))||a);return o>3&&a&&Object.defineProperty(t,n,a),a}"function"==typeof SuppressedError&&SuppressedError; +let e="en";const t={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){e=n&&t[n]?n:"en"}function i(n){return t[e]?.[n]??t.en?.[n]??n}function s(n,i){return(t[e]?.[n]??t.en?.[n]??n).replace(/\{(\w+)\}/g,(e,t)=>Object.prototype.hasOwnProperty.call(i,t)?i[t]:`{${t}}`)}const o="power",r="5m",a={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},l="span_panel",c="CLOSED",d="pv",h="bess",p="evse",u="sub_",g=500,_={power:{entityRole:"power",label:()=>i("metric.power"),unit:e=>Math.abs(e)>=1e3?"kW":"W",format:e=>{const t=Math.abs(e);return t>=1e3?(t/1e3).toFixed(1):t<10&&t>0?t.toFixed(1):String(Math.round(t))}},current:{entityRole:"current",label:()=>i("metric.current"),unit:()=>"A",format:e=>Math.abs(e).toFixed(1)}},f={soc:{entityRole:"soc",label:()=>i("metric.soc"),unit:()=>"%",format:e=>String(Math.round(e)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>i("metric.soe"),unit:()=>"kWh",format:e=>e.toFixed(1)},power:_.power},m={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>i("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>i("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>i("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>i("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>i("shedding.unknown")}},v="#ff9800";function b(e,t,n,i){var s,o=arguments.length,r=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(e,t,n,i);else for(var a=e.length-1;a>=0;a--)(s=e[a])&&(r=(o<3?s(r):o>3?s(t,n,r):s(t,n))||r);return o>3&&r&&Object.defineProperty(t,n,r),r}"function"==typeof SuppressedError&&SuppressedError; /** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const b=globalThis,y=b.ShadowRoot&&(void 0===b.ShadyCSS||b.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,w=Symbol(),x=new WeakMap;let S=class{constructor(e,t,n){if(this._$cssResult$=!0,n!==w)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o;const t=this.t;if(y&&void 0===e){const n=void 0!==t&&1===t.length;n&&(e=x.get(t)),void 0===e&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),n&&x.set(t,e))}return e}toString(){return this.cssText}};const C=e=>new S("string"==typeof e?e:e+"",void 0,w),$=y?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t="";for(const n of e.cssRules)t+=n.cssText;return C(t)})(e):e,{is:E,defineProperty:z,getOwnPropertyDescriptor:k,getOwnPropertyNames:A,getOwnPropertySymbols:M,getPrototypeOf:P}=Object,L=globalThis,N=L.trustedTypes,T=N?N.emptyScript:"",D=L.reactiveElementPolyfillSupport,H=(e,t)=>e,O={toAttribute(e,t){switch(t){case Boolean:e=e?T:null;break;case Object:case Array:e=null==e?e:JSON.stringify(e)}return e},fromAttribute(e,t){let n=e;switch(t){case Boolean:n=null!==e;break;case Number:n=null===e?null:Number(e);break;case Object:case Array:try{n=JSON.parse(e)}catch(e){n=null}}return n}},R=(e,t)=>!E(e,t),I={attribute:!0,type:String,converter:O,reflect:!1,useDefault:!1,hasChanged:R}; +const y=globalThis,w=y.ShadowRoot&&(void 0===y.ShadyCSS||y.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,x=Symbol(),S=new WeakMap;let C=class{constructor(e,t,n){if(this._$cssResult$=!0,n!==x)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o;const t=this.t;if(w&&void 0===e){const n=void 0!==t&&1===t.length;n&&(e=S.get(t)),void 0===e&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),n&&S.set(t,e))}return e}toString(){return this.cssText}};const $=e=>new C("string"==typeof e?e:e+"",void 0,x),E=w?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t="";for(const n of e.cssRules)t+=n.cssText;return $(t)})(e):e,{is:k,defineProperty:z,getOwnPropertyDescriptor:P,getOwnPropertyNames:A,getOwnPropertySymbols:N,getPrototypeOf:M}=Object,L=globalThis,I=L.trustedTypes,D=I?I.emptyScript:"",T=L.reactiveElementPolyfillSupport,H=(e,t)=>e,O={toAttribute(e,t){switch(t){case Boolean:e=e?D:null;break;case Object:case Array:e=null==e?e:JSON.stringify(e)}return e},fromAttribute(e,t){let n=e;switch(t){case Boolean:n=null!==e;break;case Number:n=null===e?null:Number(e);break;case Object:case Array:try{n=JSON.parse(e)}catch(e){n=null}}return n}},F=(e,t)=>!k(e,t),R={attribute:!0,type:String,converter:O,reflect:!1,useDefault:!1,hasChanged:F}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */Symbol.metadata??=Symbol("metadata"),L.litPropertyMetadata??=new WeakMap;let j=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=I){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(e,n,t);void 0!==i&&z(this.prototype,e,i)}}static getPropertyDescriptor(e,t,n){const{get:i,set:s}=k(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:i,set(t){const o=i?.call(this);s?.call(this,t),this.requestUpdate(e,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??I}static _$Ei(){if(this.hasOwnProperty(H("elementProperties")))return;const e=P(this);e.finalize(),void 0!==e.l&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(H("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(H("properties"))){const e=this.properties,t=[...A(e),...M(e)];for(const n of t)this.createProperty(n,e[n])}const e=this[Symbol.metadata];if(null!==e){const t=litPropertyMetadata.get(e);if(void 0!==t)for(const[e,n]of t)this.elementProperties.set(e,n)}this._$Eh=new Map;for(const[e,t]of this.elementProperties){const n=this._$Eu(e,t);void 0!==n&&this._$Eh.set(n,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){const t=[];if(Array.isArray(e)){const n=new Set(e.flat(1/0).reverse());for(const e of n)t.unshift($(e))}else void 0!==e&&t.push($(e));return t}static _$Eu(e,t){const n=t.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof e?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),void 0!==this.renderRoot&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){const e=new Map,t=this.constructor.elementProperties;for(const n of t.keys())this.hasOwnProperty(n)&&(e.set(n,this[n]),delete this[n]);e.size>0&&(this._$Ep=e)}createRenderRoot(){const e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((e,t)=>{if(y)e.adoptedStyleSheets=t.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(const n of t){const t=document.createElement("style"),i=b.litNonce;void 0!==i&&t.setAttribute("nonce",i),t.textContent=n.cssText,e.appendChild(t)}})(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,n){this._$AK(e,n)}_$ET(e,t){const n=this.constructor.elementProperties.get(e),i=this.constructor._$Eu(e,n);if(void 0!==i&&!0===n.reflect){const s=(void 0!==n.converter?.toAttribute?n.converter:O).toAttribute(t,n.type);this._$Em=e,null==s?this.removeAttribute(i):this.setAttribute(i,s),this._$Em=null}}_$AK(e,t){const n=this.constructor,i=n._$Eh.get(e);if(void 0!==i&&this._$Em!==i){const e=n.getPropertyOptions(i),s="function"==typeof e.converter?{fromAttribute:e.converter}:void 0!==e.converter?.fromAttribute?e.converter:O;this._$Em=i;const o=s.fromAttribute(t,e.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(e,t,n,i=!1,s){if(void 0!==e){const o=this.constructor;if(!1===i&&(s=this[e]),n??=o.getPropertyOptions(e),!((n.hasChanged??R)(s,t)||n.useDefault&&n.reflect&&s===this._$Ej?.get(e)&&!this.hasAttribute(o._$Eu(e,n))))return;this.C(e,t,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:n,reflect:i,wrapped:s},o){n&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,o??t??this[e]),!0!==s||void 0!==o)||(this._$AL.has(e)||(this.hasUpdated||n||(t=void 0),this._$AL.set(e,t)),!0===i&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}const e=this.scheduleUpdate();return null!=e&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}const e=this.constructor.elementProperties;if(e.size>0)for(const[t,n]of e){const{wrapped:e}=n,i=this[t];!0!==e||this._$AL.has(t)||void 0===i||this.C(t,void 0,n,i)}}let e=!1;const t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(t)):this._$EM()}catch(t){throw e=!1,this._$EM(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};j.elementStyles=[],j.shadowRootOptions={mode:"open"},j[H("elementProperties")]=new Map,j[H("finalized")]=new Map,D?.({ReactiveElement:j}),(L.reactiveElementVersions??=[]).push("2.1.2"); + */Symbol.metadata??=Symbol("metadata"),L.litPropertyMetadata??=new WeakMap;let j=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=R){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(e,n,t);void 0!==i&&z(this.prototype,e,i)}}static getPropertyDescriptor(e,t,n){const{get:i,set:s}=P(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:i,set(t){const o=i?.call(this);s?.call(this,t),this.requestUpdate(e,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??R}static _$Ei(){if(this.hasOwnProperty(H("elementProperties")))return;const e=M(this);e.finalize(),void 0!==e.l&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(H("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(H("properties"))){const e=this.properties,t=[...A(e),...N(e)];for(const n of t)this.createProperty(n,e[n])}const e=this[Symbol.metadata];if(null!==e){const t=litPropertyMetadata.get(e);if(void 0!==t)for(const[e,n]of t)this.elementProperties.set(e,n)}this._$Eh=new Map;for(const[e,t]of this.elementProperties){const n=this._$Eu(e,t);void 0!==n&&this._$Eh.set(n,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){const t=[];if(Array.isArray(e)){const n=new Set(e.flat(1/0).reverse());for(const e of n)t.unshift(E(e))}else void 0!==e&&t.push(E(e));return t}static _$Eu(e,t){const n=t.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof e?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),void 0!==this.renderRoot&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){const e=new Map,t=this.constructor.elementProperties;for(const n of t.keys())this.hasOwnProperty(n)&&(e.set(n,this[n]),delete this[n]);e.size>0&&(this._$Ep=e)}createRenderRoot(){const e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((e,t)=>{if(w)e.adoptedStyleSheets=t.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(const n of t){const t=document.createElement("style"),i=y.litNonce;void 0!==i&&t.setAttribute("nonce",i),t.textContent=n.cssText,e.appendChild(t)}})(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,n){this._$AK(e,n)}_$ET(e,t){const n=this.constructor.elementProperties.get(e),i=this.constructor._$Eu(e,n);if(void 0!==i&&!0===n.reflect){const s=(void 0!==n.converter?.toAttribute?n.converter:O).toAttribute(t,n.type);this._$Em=e,null==s?this.removeAttribute(i):this.setAttribute(i,s),this._$Em=null}}_$AK(e,t){const n=this.constructor,i=n._$Eh.get(e);if(void 0!==i&&this._$Em!==i){const e=n.getPropertyOptions(i),s="function"==typeof e.converter?{fromAttribute:e.converter}:void 0!==e.converter?.fromAttribute?e.converter:O;this._$Em=i;const o=s.fromAttribute(t,e.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(e,t,n,i=!1,s){if(void 0!==e){const o=this.constructor;if(!1===i&&(s=this[e]),n??=o.getPropertyOptions(e),!((n.hasChanged??F)(s,t)||n.useDefault&&n.reflect&&s===this._$Ej?.get(e)&&!this.hasAttribute(o._$Eu(e,n))))return;this.C(e,t,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:n,reflect:i,wrapped:s},o){n&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,o??t??this[e]),!0!==s||void 0!==o)||(this._$AL.has(e)||(this.hasUpdated||n||(t=void 0),this._$AL.set(e,t)),!0===i&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}const e=this.scheduleUpdate();return null!=e&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}const e=this.constructor.elementProperties;if(e.size>0)for(const[t,n]of e){const{wrapped:e}=n,i=this[t];!0!==e||this._$AL.has(t)||void 0===i||this.C(t,void 0,n,i)}}let e=!1;const t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(t)):this._$EM()}catch(t){throw e=!1,this._$EM(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};j.elementStyles=[],j.shadowRootOptions={mode:"open"},j[H("elementProperties")]=new Map,j[H("finalized")]=new Map,T?.({ReactiveElement:j}),(L.reactiveElementVersions??=[]).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const q=globalThis,F=e=>e,W=q.trustedTypes,U=W?W.createPolicy("lit-html",{createHTML:e=>e}):void 0,B="$lit$",G=`lit$${Math.random().toFixed(9).slice(2)}$`,V="?"+G,Q=`<${V}>`,J=document,X=()=>J.createComment(""),K=e=>null===e||"object"!=typeof e&&"function"!=typeof e,Z=Array.isArray,Y="[ \t\n\f\r]",ee=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,te=/-->/g,ne=/>/g,ie=RegExp(`>|${Y}(?:([^\\s"'>=/]+)(${Y}*=${Y}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),se=/'/g,oe=/"/g,ae=/^(?:script|style|textarea|title)$/i,re=(e=>(t,...n)=>({_$litType$:e,strings:t,values:n}))(1),ce=Symbol.for("lit-noChange"),le=Symbol.for("lit-nothing"),de=new WeakMap,he=J.createTreeWalker(J,129);function pe(e,t){if(!Z(e)||!e.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==U?U.createHTML(t):t}const ue=(e,t)=>{const n=e.length-1,i=[];let s,o=2===t?"":3===t?"":"",a=ee;for(let t=0;t"===c[0]?(a=s??ee,l=-1):void 0===c[1]?l=-2:(l=a.lastIndex-c[2].length,r=c[1],a=void 0===c[3]?ie:'"'===c[3]?oe:se):a===oe||a===se?a=ie:a===te||a===ne?a=ee:(a=ie,s=void 0);const h=a===ie&&e[t+1].startsWith("/>")?" ":"";o+=a===ee?n+Q:l>=0?(i.push(r),n.slice(0,l)+B+n.slice(l)+G+h):n+G+(-2===l?t:h)}return[pe(e,o+(e[n]||"")+(2===t?"":3===t?"":"")),i]};class ge{constructor({strings:e,_$litType$:t},n){let i;this.parts=[];let s=0,o=0;const a=e.length-1,r=this.parts,[c,l]=ue(e,t);if(this.el=ge.createElement(c,n),he.currentNode=this.el.content,2===t||3===t){const e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;null!==(i=he.nextNode())&&r.length0){i.textContent=W?W.emptyScript:"";for(let n=0;nZ(e)||"function"==typeof e?.[Symbol.iterator])(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==le&&K(this._$AH)?this._$AA.nextSibling.data=e:this.T(J.createTextNode(e)),this._$AH=e}$(e){const{values:t,_$litType$:n}=e,i="number"==typeof n?this._$AC(e):(void 0===n.el&&(n.el=ge.createElement(pe(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(t);else{const e=new fe(i,this),n=e.u(this.options);e.p(t),this.T(n),this._$AH=e}}_$AC(e){let t=de.get(e.strings);return void 0===t&&de.set(e.strings,t=new ge(e)),t}k(e){Z(this._$AH)||(this._$AH=[],this._$AR());const t=this._$AH;let n,i=0;for(const s of e)i===t.length?t.push(n=new me(this.O(X()),this.O(X()),this,this.options)):n=t[i],n._$AI(s),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=le}_$AI(e,t=this,n,i){const s=this.strings;let o=!1;if(void 0===s)e=_e(this,e,t,0),o=!K(e)||e!==this._$AH&&e!==ce,o&&(this._$AH=e);else{const i=e;let a,r;for(e=s[0],a=0;ae,W=U.trustedTypes,B=W?W.createPolicy("lit-html",{createHTML:e=>e}):void 0,G="$lit$",V=`lit$${Math.random().toFixed(9).slice(2)}$`,Q="?"+V,K=`<${Q}>`,J=document,X=()=>J.createComment(""),Z=e=>null===e||"object"!=typeof e&&"function"!=typeof e,Y=Array.isArray,ee="[ \t\n\f\r]",te=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,ne=/-->/g,ie=/>/g,se=RegExp(`>|${ee}(?:([^\\s"'>=/]+)(${ee}*=${ee}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),oe=/'/g,re=/"/g,ae=/^(?:script|style|textarea|title)$/i,le=(e=>(t,...n)=>({_$litType$:e,strings:t,values:n}))(1),ce=Symbol.for("lit-noChange"),de=Symbol.for("lit-nothing"),he=new WeakMap,pe=J.createTreeWalker(J,129);function ue(e,t){if(!Y(e)||!e.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==B?B.createHTML(t):t}const ge=(e,t)=>{const n=e.length-1,i=[];let s,o=2===t?"":3===t?"":"",r=te;for(let t=0;t"===l[0]?(r=s??te,c=-1):void 0===l[1]?c=-2:(c=r.lastIndex-l[2].length,a=l[1],r=void 0===l[3]?se:'"'===l[3]?re:oe):r===re||r===oe?r=se:r===ne||r===ie?r=te:(r=se,s=void 0);const h=r===se&&e[t+1].startsWith("/>")?" ":"";o+=r===te?n+K:c>=0?(i.push(a),n.slice(0,c)+G+n.slice(c)+V+h):n+V+(-2===c?t:h)}return[ue(e,o+(e[n]||"")+(2===t?"":3===t?"":"")),i]};class _e{constructor({strings:e,_$litType$:t},n){let i;this.parts=[];let s=0,o=0;const r=e.length-1,a=this.parts,[l,c]=ge(e,t);if(this.el=_e.createElement(l,n),pe.currentNode=this.el.content,2===t||3===t){const e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;null!==(i=pe.nextNode())&&a.length0){i.textContent=W?W.emptyScript:"";for(let n=0;nY(e)||"function"==typeof e?.[Symbol.iterator])(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==de&&Z(this._$AH)?this._$AA.nextSibling.data=e:this.T(J.createTextNode(e)),this._$AH=e}$(e){const{values:t,_$litType$:n}=e,i="number"==typeof n?this._$AC(e):(void 0===n.el&&(n.el=_e.createElement(ue(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(t);else{const e=new me(i,this),n=e.u(this.options);e.p(t),this.T(n),this._$AH=e}}_$AC(e){let t=he.get(e.strings);return void 0===t&&he.set(e.strings,t=new _e(e)),t}k(e){Y(this._$AH)||(this._$AH=[],this._$AR());const t=this._$AH;let n,i=0;for(const s of e)i===t.length?t.push(n=new ve(this.O(X()),this.O(X()),this,this.options)):n=t[i],n._$AI(s),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=de}_$AI(e,t=this,n,i){const s=this.strings;let o=!1;if(void 0===s)e=fe(this,e,t,0),o=!Z(e)||e!==this._$AH&&e!==ce,o&&(this._$AH=e);else{const i=e;let r,a;for(e=s[0],r=0;r{const i=n?.renderBefore??t;let s=i._$litPart$;if(void 0===s){const e=n?.renderBefore??null;i._$litPart$=s=new me(t.insertBefore(X(),e),e,void 0,n??{})}return s._$AI(e),s})(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return ce}}$e._$litElement$=!0,$e.finalized=!0,Ce.litElementHydrateSupport?.({LitElement:$e});const Ee=Ce.litElementPolyfillSupport;Ee?.({LitElement:$e}),(Ce.litElementVersions??=[]).push("4.2.2"); + */class Ee extends j{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const e=super.createRenderRoot();return this.renderOptions.renderBefore??=e.firstChild,e}update(e){const t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=((e,t,n)=>{const i=n?.renderBefore??t;let s=i._$litPart$;if(void 0===s){const e=n?.renderBefore??null;i._$litPart$=s=new ve(t.insertBefore(X(),e),e,void 0,n??{})}return s._$AI(e),s})(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return ce}}Ee._$litElement$=!0,Ee.finalized=!0,$e.litElementHydrateSupport?.({LitElement:Ee});const ke=$e.litElementPolyfillSupport;ke?.({LitElement:Ee}),($e.litElementVersions??=[]).push("4.2.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const ze={attribute:!0,type:String,converter:O,reflect:!1,hasChanged:R},ke=(e=ze,t,n)=>{const{kind:i,metadata:s}=n;let o=globalThis.litPropertyMetadata.get(s);if(void 0===o&&globalThis.litPropertyMetadata.set(s,o=new Map),"setter"===i&&((e=Object.create(e)).wrapped=!0),o.set(n.name,e),"accessor"===i){const{name:i}=n;return{set(n){const s=t.get.call(this);t.set.call(this,n),this.requestUpdate(i,s,e,!0,n)},init(t){return void 0!==t&&this.C(i,void 0,e,t),t}}}if("setter"===i){const{name:i}=n;return function(n){const s=this[i];t.call(this,n),this.requestUpdate(i,s,e,!0,n)}}throw Error("Unsupported decorator location: "+i)}; +const ze=e=>(t,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(e,t)}):customElements.define(e,t)},Pe={attribute:!0,type:String,converter:O,reflect:!1,hasChanged:F},Ae=(e=Pe,t,n)=>{const{kind:i,metadata:s}=n;let o=globalThis.litPropertyMetadata.get(s);if(void 0===o&&globalThis.litPropertyMetadata.set(s,o=new Map),"setter"===i&&((e=Object.create(e)).wrapped=!0),o.set(n.name,e),"accessor"===i){const{name:i}=n;return{set(n){const s=t.get.call(this);t.set.call(this,n),this.requestUpdate(i,s,e,!0,n)},init(t){return void 0!==t&&this.C(i,void 0,e,t),t}}}if("setter"===i){const{name:i}=n;return function(n){const s=this[i];t.call(this,n),this.requestUpdate(i,s,e,!0,n)}}throw Error("Unsupported decorator location: "+i)}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function Ae(e){return(t,n)=>"object"==typeof n?ke(e,t,n):((e,t,n)=>{const i=t.hasOwnProperty(n);return t.constructor.createProperty(n,e),i?Object.getOwnPropertyDescriptor(t,n):void 0})(e,t,n)} + */function Ne(e){return(t,n)=>"object"==typeof n?Ae(e,t,n):((e,t,n)=>{const i=t.hasOwnProperty(n);return t.constructor.createProperty(n,e),i?Object.getOwnPropertyDescriptor(t,n):void 0})(e,t,n)} /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function Me(e){return Ae({...e,state:!0,attribute:!1})}const Pe={"&":"&","<":"<",">":">",'"':""","'":"'"};function Le(e){return String(e).replace(/[&<>"']/g,e=>Pe[e]??e)}const Ne=g.power;function Te(e){return Ne.unit(e)}function De(e){return(e<0?"-":"")+Ne.format(e)}function He(e){return(Math.abs(e)/1e3).toFixed(1)}function Oe(e){return Math.ceil(e/2)}function Re(e){return e%2==0?1:0}function Ie(e){if(2!==e.length)return null;const[t,n]=[Math.min(...e),Math.max(...e)];return Oe(t)===Oe(n)?"row-span":Re(t)===Re(n)?"col-span":"row-span"}function je(e){const t=e.chart_metric??s;return g[t]??g[s]}function qe(e,t){const n=function(e){return je(e).entityRole}(t);return e.entities?.[n]??e.entities?.power??null}class Fe{constructor(){this._status=null,this._lastFetch=0,this._fetching=!1}async fetch(e,t){const n=Date.now();if(this._fetching)return this._status;if(this._status&&n-this._lastFetch<3e4)return this._status;this._fetching=!0;try{const i={};t&&(i.config_entry_id=t);const s=await e.callWS({type:"call_service",domain:r,service:"get_monitoring_status",service_data:i,return_response:!0});this._status=s?.response??null,this._lastFetch=n}catch{this._status=null}finally{this._fetching=!1}return this._status}invalidate(){this._lastFetch=0}get status(){return this._status}clear(){this._status=null,this._lastFetch=0}}function We(e,t){return e?.circuits?e.circuits[t]??null:null}function Ue(e){if(!e?.utilization_pct)return"";const t=e.utilization_pct;return t>=100?"utilization-alert":t>=80?"utilization-warning":"utilization-normal"}function Be(e,t,n,s,o,a,r,d,h,p=!1){const u=t.entities?.power,g=u?a.states[u]:null,_=g&&parseFloat(g.state)||0,v=t.device_type===l||_<0,b=t.entities?.switch,y=b?a.states[b]:null,w=y?"on"===y.state:(g?.attributes?.relay_state||t.relay_state)===c,x=t.breaker_rating_a,S=x?`${Math.round(x)}A`:"",C=Le(t.name||i("grid.unknown")),$=je(r);let E;if("current"===$.entityRole){const e=t.entities?.current,n=e?a.states[e]:null,i=n&&parseFloat(n.state)||0;E=`${$.format(i)}A`}else E=`${De(_)}${Te(_)}`;const z=h||"unknown";let k="";if("unknown"!==z){const e=f[z]??f.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};k=e.icon2?`\n \n \n `:e.textLabel?`\n \n ${e.textLabel}\n `:``}const A=d&&function(e){return!!e&&void 0!==e.continuous_threshold_pct}(d),M=A?m:"#555",P=``;let L="";if(null!=d?.utilization_pct){const e=d.utilization_pct;L=`${Math.round(e)}%`}const N=function(e){return!!e&&null!=e.over_threshold_since}(d);return`\n
\n
\n
\n ${S?`${S}`:""}\n ${C}\n
\n
\n \n ${E}\n \n ${!1!==t.is_user_controllable&&t.entities?.switch?`\n
\n ${i(w?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${k}\n ${L}\n ${P}\n
\n
\n
\n `}function Ge(e,t){return`\n
\n \n
\n `}const Ve={names:["power","battery power"],suffixes:["_power"]},Qe={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},Je={names:["state of energy"],suffixes:["_soe_kwh"]},Xe={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function Ke(e,t){if(!e.entities)return null;for(const[n,i]of Object.entries(e.entities)){if("sensor"!==i.domain)continue;const e=(i.original_name??"").toLowerCase();if(t.names.some(t=>e===t))return n;if(i.unique_id&&t.suffixes.some(e=>i.unique_id.endsWith(e)))return n}return null}function Ze(e){return Ke(e,Ve)}function Ye(e){return Ke(e,Qe)}function et(e){return Ke(e,Je)}function tt(e){return Ke(e,Xe)}function nt(e,t,n,i){const s=n.visible_sub_entities||{};let o="";if(!e.entities)return o;for(const[n,a]of Object.entries(e.entities)){if(i.has(n))continue;if(!0!==s[n])continue;const r=t.states[n];if(!r)continue;let c=a.original_name||r.attributes.friendly_name||n;const l=e.name||"";let d;if(c.startsWith(l+" ")&&(c=c.slice(l.length+1)),t.formatEntityState)d=t.formatEntityState(r);else{d=r.state;const e=r.attributes.unit_of_measurement||"";e&&(d+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(d=(e/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Le(c)}:\n ${Le(d)}\n
\n `}return o}function it(e,t,n,s,o,a){if(n){const t=[{key:`${p}${e}_soc`,title:i("subdevice.soc"),available:!!o},{key:`${p}${e}_soe`,title:i("subdevice.soe"),available:!!a},{key:`${p}${e}_power`,title:i("subdevice.power"),available:!!s}].filter(e=>e.available);return`\n
\n ${t.map(e=>`\n
\n
${Le(e.title)}
\n
\n
\n `).join("")}\n
\n `}return s?`
`:""}function st(e){const t=void 0!==e.history_days||void 0!==e.history_hours||void 0!==e.history_minutes,n=60*(60*(24*(t&&parseInt(String(e.history_days))||0)+(t&&parseInt(String(e.history_hours))||0))+(t?parseInt(String(e.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function ot(e){const t=a[e];return t?t.ms:a[o].ms}function at(e){const t=e/1e3;return t<=600?Math.ceil(t):Math.min(5e3,Math.ceil(t/5))}function rt(e){return Math.max(500,Math.floor(e/5e3))}function ct(e,t,n,i,s,o){e.has(t)||e.set(t,[]);const a=e.get(t);a.push({time:i,value:n});const r=a.findIndex(e=>e.time>=s);r>0?a.splice(0,r):-1===r&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function lt(e,t,n=500){if(0===e.length)return e;e.sort((e,t)=>e.time-t.time);const i=[e[0]];for(let t=1;t=n&&i.push(e[t]);return i.length>t&&i.splice(0,i.length-t),i}async function dt(e,t,n,i,s){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",r=await e.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:t,period:a,types:["mean"]});for(const[e,t]of Object.entries(r)){const i=n.get(e);if(!i||!t)continue;const o=[];for(const e of t){const t=e.mean;if(null==t||!Number.isFinite(t))continue;const n=e.start;n>0&&o.push({time:n,value:t})}if(o.length>0){const e=s.get(i)||[],t=[...o,...e];t.sort((e,t)=>e.time-t.time),s.set(i,t)}}}async function ht(e,t,n,i,s){const o=new Date(Date.now()-i).toISOString(),a=await e.callWS({type:"history/history_during_period",start_time:o,entity_ids:t,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),r=at(i),c=rt(i);for(const[e,t]of Object.entries(a)){const i=n.get(e);if(!i||!t)continue;const o=[];for(const e of t){const t=parseFloat(e.s);if(!Number.isFinite(t))continue;const n=1e3*(e.lu||e.lc||0);n>0&&o.push({time:n,value:t})}if(o.length>0){const e=s.get(i)||[],t=[...o,...e];s.set(i,lt(t,r,c))}}}function pt(e){if(!e.sub_devices)return[];const t=[];for(const[n,i]of Object.entries(e.sub_devices)){const e={power:Ze(i)};i.type===d&&(e.soc=Ye(i),e.soe=et(i));for(const[i,s]of Object.entries(e))s&&t.push({entityId:s,key:`${p}${n}_${i}`,devId:n})}return t}async function ut(e,t,n,i,s,o){if(!t||!e)return;const a=new Map;for(const[e,i]of Object.entries(t.circuits)){const t=qe(i,n);if(!t)continue;let o;o=s&&s.has(e)?ot(s.get(e)):st(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const r=a.get(o);r.entityIds.push(t),r.uuidByEntity.set(t,e)}for(const{entityId:e,key:i,devId:s}of pt(t)){let t;t=o&&o.has(s)?ot(o.get(s)):st(n),a.has(t)||a.set(t,{entityIds:[],uuidByEntity:new Map});const r=a.get(t);r.entityIds.push(e),r.uuidByEntity.set(e,i)}const r=[];for(const[t,n]of a){if(0===n.entityIds.length)continue;t>2592e5?r.push(dt(e,n.entityIds,n.uuidByEntity,t,i)):r.push(ht(e,n.entityIds,n.uuidByEntity,t,i))}await Promise.all(r)}function gt(e,t,n,i,o,a,r,c,l){const{options:d,series:h}=function(e,t,n,i,o,a=!1){n||(n=g[s]);const r=i?"140, 160, 220":"77, 217, 175",c=`rgb(${r})`,l=Date.now(),d=l-t,h=void 0!==n.fixedMin&&void 0!==n.fixedMax,p=(e??[]).filter(e=>e.time>=d).map(e=>[e.time,Math.abs(e.value)]),u=[{type:"line",data:p,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:c},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${r}, 0.35)`},{offset:1,color:`rgba(${r}, 0.02)`}]}},itemStyle:{color:c}}],_=p.length>0?function(e){let t=0;for(const n of e)n[1]>t&&(t=n[1]);return t}(p):0,f={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:_<10?e=>0===e?"0":e.toFixed(1):e=>n.format(e)},splitLine:{lineStyle:{opacity:.15}}};h?(f.min=n.fixedMin,f.max=n.fixedMax):_<1&&(f.min=0,f.max=1),o&&"current"===n.entityRole&&(f.min=0,f.max=Math.ceil(1.25*o),u.push({type:"line",data:[[d,.8*o],[l,.8*o]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),u.push({type:"line",data:[[d,o],[l,o]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:d,max:l,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:f,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:e=>{if(!e||0===e.length)return"";const t=e[0],i=new Date(t.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),s=parseFloat(t.value[1].toFixed(2));return`
${i}
${n.format(s)} ${n.unit(s)}
`}},animation:!1};return{options:m,series:u}}(n,i,o,a,c,l),p=r??120;e.style.minHeight=p+"px";let u=e.querySelector("ha-chart-base");u||(u=document.createElement("ha-chart-base"),u.style.display="block",u.style.width="100%",u.hass=t,e.innerHTML="",e.appendChild(u));const _=e.clientHeight;u.height=(_>0?_:p)+"px",u.hass=t,u.options=d,u.data=h}function _t(e,t,n,s,o,a){if(!e||!n||!t)return;const r=st(s);let d=0;for(const[,e]of Object.entries(n.circuits)){const n=e.entities?.power;if(!n)continue;const i=t.states[n],s=i&&parseFloat(i.state)||0;e.device_type!==l&&(d+=Math.abs(s))}!function(e,t,n,i,s){const o="current"===(i.chart_metric||"power"),a=e.querySelector(".stat-consumption .stat-value"),r=e.querySelector(".stat-consumption .stat-unit");if(o){const e=n.panel_entities?.site_power,i=e?t.states[e]:null,s=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(s)?Math.abs(s).toFixed(1):"--"),r&&(r.textContent="A")}else{const e=n.panel_entities?.site_power;if(e){const n=t.states[e];n&&(s=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=He(s)),r&&(r.textContent="kW")}const c=e.querySelector(".stat-upstream .stat-value"),l=e.querySelector(".stat-upstream .stat-unit");if(c){const e=n.panel_entities?.current_power,i=e?t.states[e]:null;if(o){const e=i?parseFloat(i.attributes?.amperage):NaN;c.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",l&&(l.textContent="A")}else{const e=i?Math.abs(parseFloat(i.state)||0):0;c.textContent=He(e),l&&(l.textContent="kW")}}const d=e.querySelector(".stat-downstream .stat-value"),h=e.querySelector(".stat-downstream .stat-unit");if(d){const e=n.panel_entities?.feedthrough_power,i=e?t.states[e]:null;if(o){const e=i?parseFloat(i.attributes?.amperage):NaN;d.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",h&&(h.textContent="A")}else{const e=i?Math.abs(parseFloat(i.state)||0):0;d.textContent=He(e),h&&(h.textContent="kW")}}const p=e.querySelector(".stat-solar .stat-value"),u=e.querySelector(".stat-solar .stat-unit");if(p){const e=n.panel_entities?.pv_power,i=e?t.states[e]:null;if(o){const e=i?parseFloat(i.attributes?.amperage):NaN;p.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",u&&(u.textContent="A")}else{if(i){const e=Math.abs(parseFloat(i.state)||0);p.textContent=He(e)}else p.textContent="--";u&&(u.textContent="kW")}}const g=e.querySelector(".stat-battery .stat-value");if(g){const e=n.panel_entities?.battery_level,i=e?t.states[e]:null;i&&(g.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const _=e.querySelector(".stat-grid-state .stat-value");if(_){const e=n.panel_entities?.dsm_state,i=e?t.states[e]:null;_.textContent=i?t.formatEntityState?.(i)||i.state:"--"}}(e,t,n,s,d);const h=je(s),p="current"===h.entityRole;for(const[s,d]of Object.entries(n.circuits)){const n=e.querySelector(`[data-uuid="${s}"]`);if(!n)continue;const u=d.entities?.power,g=u?t.states[u]:null,_=g&&parseFloat(g.state)||0,m=d.device_type===l||_<0,v=d.entities?.switch,b=v?t.states[v]:null,y=b?"on"===b.state:(g?.attributes?.relay_state||d.relay_state)===c,w=n.querySelector(".power-value");if(w)if(p){const e=d.entities?.current,n=e?t.states[e]:null,i=n&&parseFloat(n.state)||0;w.innerHTML=`${h.format(i)}A`}else w.innerHTML=`${De(_)}${Te(_)}`;const x=n.querySelector(".toggle-pill");if(x){x.className="toggle-pill "+(y?"toggle-on":"toggle-off");const e=x.querySelector(".toggle-label");e&&(e.textContent=i(y?"grid.on":"grid.off"))}let S;if(n.classList.toggle("circuit-off",!y),n.classList.toggle("circuit-producer",m),d.always_on)S="always_on";else{const e=d.entities?.select,n=e?t.states[e]:null;S=n?n.state:"unknown"}const C=f[S]??f.unknown,$=n.querySelector(".shedding-icon");$&&($.setAttribute("icon",C.icon),$.style.color=C.color,$.title=C.label());const E=n.querySelector(".shedding-icon-secondary");E&&(C.icon2?(E.setAttribute("icon",C.icon2),E.style.color=C.color,E.style.display=""):E.style.display="none");const z=n.querySelector(".shedding-label");z&&(C.textLabel?(z.textContent=C.textLabel,z.style.color=C.color,z.style.display=""):z.style.display="none");const k=n.querySelector(".chart-container");if(k){const e=o.get(s)||[],i=n.classList.contains("circuit-col-span")?200:100,c=a?.has(s)?ot(a.get(s)):r,p=d.device_type===l;gt(k,t,e,c,h,m,i,d.breaker_rating_a??void 0,p)}}}class ft{constructor(){this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(e,t){const n=Date.now();if(this._fetching)return this._settings;if(this._settings&&n-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const i={};t&&(i.config_entry_id=t);const s=await e.callWS({type:"call_service",domain:r,service:"get_graph_settings",service_data:i,return_response:!0});this._settings=s?.response??null,this._lastFetch=n}catch{this._settings=null}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function mt(e,t){if(!e)return o;const n=e.circuits?.[t];return n?.has_override?n.horizon:e.global_horizon??o}function vt(e,t){if(!e)return o;const n=e.sub_devices?.[t];return n?.has_override?n.horizon:e.global_horizon??o}class bt{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new Fe,this.graphSettingsCache=new ft,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get hass(){return this._hass}set hass(e){this._hass=e}get topology(){return this._topology}get config(){return this._config}set showMonitoring(e){this._showMonitoring=e}init(e,t,n,i){this._topology=e,this._config=t,this._hass=n,this._configEntryId=i}setConfig(e){this._config=e}buildHorizonMaps(e){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),e&&this._topology?.circuits)for(const t of Object.keys(this._topology.circuits))this.horizonMap.set(t,mt(e,t));if(e&&this._topology?.sub_devices)for(const t of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(t,vt(e,t))}async fetchAndBuildHorizonMaps(){try{await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)}catch{}}async loadHistory(){await ut(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const e=Date.now();for(const[t,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(t)??o;if(!a[i]?.useRealtime)continue;const s=qe(n,this._config);if(!s)continue;const r=this._hass.states[s];if(!r)continue;const c=parseFloat(r.state);if(isNaN(c))continue;const l=ot(i),d=at(l),h=rt(l),p=e-l,u=this.powerHistory.get(t)??[];u.length>0&&e-u[u.length-1].time0&&e-u[u.length-1].time0&&this._topology)for(const{key:e,devId:t}of pt(this._topology))n.has(t)&&i.add(e);const s=new Map;try{await ut(this._hass,this._topology,this._config,s,t,n);for(const e of t.keys()){const t=s.get(e);t?this.powerHistory.set(e,t):this.powerHistory.delete(e)}for(const e of i){const t=s.get(e);t?this.powerHistory.set(e,t):this.powerHistory.delete(e)}this.updateDOM(e)}catch{}}updateDOM(e){this._hass&&this._topology&&this._config&&(_t(e,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(e,t,n,i,s,o){if(!n.sub_devices)return;const a=st(i);for(const[i,r]of Object.entries(n.sub_devices)){const n=e.querySelector(`[data-subdev="${i}"]`);if(!n)continue;const c=Ze(r);if(c){const e=t.states[c],i=e&&parseFloat(e.state)||0,s=n.querySelector(".sub-power-value");s&&(s.innerHTML=`${De(i)} ${Te(i)}`)}const l=n.querySelectorAll("[data-chart-key]");for(const e of l){const n=e.dataset.chartKey;if(!n)continue;const r=s.get(n)||[];let c=_.power;n.endsWith("_soc")?c=_.soc:n.endsWith("_soe")&&(c=_.soe);const l=!!e.closest(".bess-chart-col");gt(e,t,r,o?.has(i)?ot(o.get(i)):a,c,!1,l?120:150,void 0,n.endsWith("_soc")||n.endsWith("_soe"))}for(const e of Object.keys(r.entities||{})){const i=n.querySelector(`[data-eid="${e}"]`);if(!i)continue;const s=t.states[e];if(s){let e;if(t.formatEntityState)e=t.formatEntityState(s);else{e=s.state;const t=s.attributes.unit_of_measurement||"";t&&(e+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(e=(t/1e3).toFixed(1)+" kWh")}i.textContent=e}}}}(e,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(e){if(this._hass){this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(e)}}onToggleClick(e,t){const n=e.target,i=n?.closest(".toggle-pill");if(!i)return;const s=t.querySelector(".slide-confirm");if(!s||!s.classList.contains("confirmed"))return;e.stopPropagation(),e.preventDefault();const o=i.closest("[data-uuid]");if(!o||!this._topology||!this._hass)return;const a=o.dataset.uuid;if(!a)return;const r=this._topology.circuits[a];if(!r)return;const c=r.entities?.switch;if(!c)return;const l=this._hass.states[c];if(!l)return void console.warn("SPAN Panel: switch entity not found:",c);const d="on"===l.state?"turn_off":"turn_on";this._hass.callService("switch",d,{},{entity_id:c}).catch(e=>{console.error("SPAN Panel: switch service call failed:",e)})}async onGearClick(e,t){const n=e.target,i=n?.closest(".gear-icon");if(!i)return;const s=t.querySelector("span-side-panel");if(!s||!this._hass)return;if(s.hass=this._hass,i.classList.contains("panel-gear"))return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void s.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings});const a=i.dataset.uuid;if(a&&this._topology){const e=this._topology.circuits[a];if(e){await this.monitoringCache.fetch(this._hass,this._configEntryId);const t=e.entities?.current??e.entities?.power,n=t?this.monitoringCache.status?.circuits?.[t]??null:null;await this.graphSettingsCache.fetch(this._hass,this._configEntryId);const i=this.graphSettingsCache.settings,r=i?.global_horizon??o,c=i?.circuits?.[a],l=c?{...c,globalHorizon:r}:{horizon:r,has_override:!1,globalHorizon:r};return void s.open({...e,uuid:a,monitoringInfo:n,showMonitoring:this._showMonitoring,graphHorizonInfo:l})}}const r=i.dataset.subdevId;if(r&&this._topology?.sub_devices?.[r]){const e=this._topology.sub_devices[r];await this.graphSettingsCache.fetch(this._hass,this._configEntryId);const t=this.graphSettingsCache.settings,n=t?.global_horizon??o,i=t?.sub_devices?.[r],a=i?{...i,globalHorizon:n}:{horizon:n,has_override:!1,globalHorizon:n};s.open({subDeviceMode:!0,subDeviceId:r,name:e.name??r,deviceType:e.type??"",graphHorizonInfo:a})}}bindSlideConfirm(e,t){const n=e.querySelector(".slide-confirm-knob"),i=e.querySelector(".slide-confirm-text");if(!n||!i)return;let s=!1,o=0,a=0;const r=t=>{e.classList.contains("confirmed")||(s=!0,o=t-n.offsetLeft,a=e.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},c=e=>{if(!s)return;const t=Math.max(2,Math.min(e-o,a));n.style.left=t+"px"},l=()=>{if(!s)return;s=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",e.classList.add("confirmed"),n.querySelector("ha-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=e.dataset.textOn??"",t&&t.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",e=>{e.preventDefault(),r(e.clientX)}),e.addEventListener("mousemove",e=>c(e.clientX)),e.addEventListener("mouseup",l),e.addEventListener("mouseleave",l),n.addEventListener("touchstart",e=>{e.preventDefault(),r(e.touches[0].clientX)},{passive:!1}),e.addEventListener("touchmove",e=>c(e.touches[0].clientX),{passive:!0}),e.addEventListener("touchend",l),e.addEventListener("touchcancel",l),e.addEventListener("click",()=>{e.classList.contains("confirmed")&&(e.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("ha-icon")?.setAttribute("icon","mdi:lock"),i.textContent=e.dataset.textOff??"",t&&t.classList.add("switches-disabled"))})}startIntervals(e,t){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(e),t&&t()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(e)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(e,t){this.cleanupResizeObserver(),t&&(this._lastWidth=t.clientWidth,this._resizeObserver=new ResizeObserver(t=>{const n=t[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const t of e.querySelectorAll(".chart-container")){const e=t.querySelector("ha-chart-base");e&&e.remove()}this.updateDOM(e)},150))}),this._resizeObserver.observe(t))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.graphSettingsCache.clear()}}function yt(e=""){const t=e?` value="${Le(e)}"`:"",n=e?"":"display:none;";return`\n
\n \n \n
\n `}function wt(e){const t="current"===(e.chart_metric||"power");return`\n
\n \n \n
\n `}function xt(e,t,n,s,o,a,r){const l=t.entities?.power,d=l?n.states[l]:null,h=d&&parseFloat(d.state)||0,p=t.entities?.switch,u=p?n.states[p]:null,g=u?"on"===u.state:(d?.attributes?.relay_state||t.relay_state)===c,_=t.breaker_rating_a,m=_?`${Math.round(_)}A`:"",v=Le(t.name||i("grid.unknown")),b=je(s),y="current"===b.entityRole;let w;if(g)if(y){const e=t.entities?.current,i=e?n.states[e]:null,s=i&&parseFloat(i.state)||0;w=`${b.format(s)}A`}else w=`${De(h)}${Te(h)}`;else w="";const x=a||"unknown";let S="";if("unknown"!==x){const e=f[x]??f.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};S=e.icon2?`\n \n \n `:e.textLabel?`\n \n ${e.textLabel}\n `:``}let C="";if(null!=o?.utilization_pct){const e=o.utilization_pct;C=`${Math.round(e)}%`}const $=g?'ON':'OFF';return`\n
\n ${m?`${m}`:""}\n ${v}\n ${S}\n ${C}\n ${$}\n \n ${w}\n \n \n
\n `}function St(e,t,n,i,s,o){const a=Be(e,t,0,"1","single",n,i,s,o,!0);return`
${a}
`}function Ct(e){return`
${Le(e)}
`}function $t(e,t,n){const i=e.entities?.switch,s=i?t.states[i]:null,o=e.entities?.power,a=o?t.states[o]:null,r=s?"on"===s.state:(a?.attributes?.relay_state||e.relay_state)===c;let l;if("current"===(n.chart_metric||"power")){const n=e.entities?.current,i=n?t.states[n]:null;l=i?Math.abs(parseFloat(i.state)||0):0}else l=a?Math.abs(parseFloat(a.state)||0):0;return{isOn:r,value:l}}function Et(e,t){if(e.always_on)return"always_on";const n=e.entities?.select,i=n?t.states[n]:null;return i?i.state:"unknown"}function zt(e,t,n){return e.sort((e,i)=>{const s=$t(e[1],t,n),o=$t(i[1],t,n);return s.isOn&&!o.isOn?-1:!s.isOn&&o.isOn?1:o.value-s.value})}function kt(e){return e.entities?.current??e.entities?.power??""}class At{constructor(e){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._ctrl=e}renderActivityView(e,t,n,i,s){this._unbindEvents(),this._hass=t,this._topology=n,this._config=i,this._monitoringStatus=s;const o=zt(Object.entries(n.circuits),t,i);let a=yt(this._searchQuery)+wt(i);a+='
';for(const[e,n]of o){const o=We(s,kt(n)),r=Et(n,t),c=this._expandedUuids.has(e);a+=xt(e,n,t,i,o,r,c),c&&(a+=St(e,n,t,i,o,r))}a+="
",a+="",e.innerHTML=a;const r=e.querySelector("span-side-panel");r&&(r.hass=t),this._bindEvents(e),this._searchQuery&&this._applyFilter(e),this._ctrl.updateDOM(e)}renderAreaView(e,t,n,s,o){this._unbindEvents(),this._hass=t,this._topology=n,this._config=s,this._monitoringStatus=o;const a=i("list.unassigned_area"),r=new Map;for(const[e,t]of Object.entries(n.circuits)){const n=t.area??a,i=r.get(n);i?i.push([e,t]):r.set(n,[[e,t]])}const c=[...r.keys()].sort((e,t)=>e===a?1:t===a?-1:e.localeCompare(t));let l=yt(this._searchQuery)+wt(s);l+='
';for(const e of c){const n=r.get(e);if(!n)continue;const i=zt(n,t,s);l+=Ct(e);for(const[e,n]of i){const i=We(o,kt(n)),a=Et(n,t),r=this._expandedUuids.has(e);l+=xt(e,n,t,s,i,a,r),r&&(l+=St(e,n,t,s,i,a))}}l+="
",l+="",e.innerHTML=l;const d=e.querySelector("span-side-panel");d&&(d.hass=t),this._bindEvents(e),this._searchQuery&&this._applyFilter(e),this._ctrl.updateDOM(e)}updateCollapsedRows(e,t,n,i){const s=je(i),o="current"===s.entityRole,a=e.querySelectorAll(".list-row[data-row-uuid]");for(const e of a){const a=e.dataset.rowUuid;if(!a)continue;const r=n.circuits[a];if(!r)continue;const{isOn:c,value:l}=$t(r,t,i),d=e.querySelector(".list-power-value");if(d)if(c)if(o)d.innerHTML=`${s.format(l)}A`;else{const e=r.entities?.power,n=e?t.states[e]:null,i=n&&parseFloat(n.state)||0;d.innerHTML=`${De(i)}${Te(i)}`}else d.innerHTML="";const h=e.querySelector(".list-status-badge");h&&(h.textContent=c?"ON":"OFF",h.classList.toggle("list-status-on",c),h.classList.toggle("list-status-off",!c)),e.classList.toggle("circuit-off",!c)}}stop(){this._unbindEvents(),this._expandedUuids.clear(),this._searchQuery="",this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_bindEvents(e){this._container=e,this._clickHandler=t=>{const n=t.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const e=i.dataset.expandUuid;return void(e&&this._toggleExpand(e))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(t,e);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(t,e);if(n.closest(".list-search-clear")){const t=e.querySelector(".list-search");return void(t&&(t.value="",t.dispatchEvent(new Event("input",{bubbles:!0}))))}const s=n.closest(".unit-btn");if(s){const t=s.dataset.unit;t&&e.dispatchEvent(new CustomEvent("unit-changed",{detail:t,bubbles:!0,composed:!0}))}},this._inputHandler=t=>{const n=t.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(e))},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(e).then(()=>{this._ctrl.updateDOM(e)}).catch(()=>{})},e.addEventListener("click",this._clickHandler),e.addEventListener("input",this._inputHandler),e.addEventListener("graph-settings-changed",this._graphSettingsHandler)}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_applyFilter(e){const t=e.querySelector(".list-search-clear");t&&(t.style.display=this._searchQuery?"":"none");const n=e.querySelectorAll(".list-row[data-row-uuid]");for(const t of n){const n=t.querySelector(".list-circuit-name"),i=(n?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=i?"":"none";const s=t.dataset.rowUuid;if(s){const t=e.querySelector(`.list-expanded-content[data-expanded-uuid="${s}"]`);t&&(t.style.display=i?"":"none")}}const i=e.querySelectorAll(".area-header");for(const e of i){let t=!1,n=e.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-row")&&"none"!==n.style.display){t=!0;break}n=n.nextElementSibling}e.style.display=t?"":"none"}}_toggleExpand(e){if(!(this._container&&this._hass&&this._topology&&this._config))return;const t=this._container.querySelector(`.list-row[data-row-uuid="${e}"]`),n=this._container.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(t)if(this._expandedUuids.has(e)){this._expandedUuids.delete(e);const i=this._container.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);i&&i.remove(),n&&n.classList.remove("expanded"),t.classList.remove("list-row-expanded")}else{this._expandedUuids.add(e);const i=this._topology.circuits[e];if(!i)return;const s=We(this._monitoringStatus,kt(i)),o=Et(i,this._hass),a=St(e,i,this._hass,this._config,s,o);t.insertAdjacentHTML("afterend",a),n&&n.classList.add("expanded"),t.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}}}async function Mt(e,t){const[n,i,s]=await Promise.all([e.callWS({type:"config/area_registry/list"}),e.callWS({type:"config/entity_registry/list"}),e.callWS({type:"config/device_registry/list"})]),o=new Map;for(const e of n)o.set(e.area_id,e.name);const a=new Map;for(const e of i)e.area_id&&a.set(e.entity_id,e.area_id);const r=new Map;for(const e of s)r.set(e.id,e.area_id);let c;if(t.device_id){const e=r.get(t.device_id);e&&(c=o.get(e))}for(const e of Object.values(t.circuits)){let t;for(const n of Object.values(e.entities)){if(!n)continue;const e=a.get(n);if(e){t=o.get(e);break}}t||(t=c),e.area=t}}function Pt(e){let t=0;for(const n of Object.values(e))if(n)for(const e of n.tabs)e>t&&(t=e);return t>0?t+t%2:0}function Lt(e){return e?{id:e.id,name:e.name,name_by_user:e.name_by_user,config_entries:e.config_entries,identifiers:e.identifiers,via_device_id:e.via_device_id,sw_version:e.sw_version,model:e.model}:null}const Nt=Object.keys(f).filter(e=>"unknown"!==e&&"always_on"!==e);class Tt extends HTMLElement{constructor(){super(),this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(e){this._hass=e,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}open(e){this._config=e,this._render(),this.offsetHeight,this.setAttribute("open","")}close(){this.removeAttribute("open"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_render(){const e=this._config;if(!e)return;const t=this.shadowRoot;if(!t)return;t.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n .error-msg {\n color: var(--error-color, #f44336);\n font-size: 0.8em;\n padding: 8px;\n margin: 8px 0;\n background: rgba(244, 67, 54, 0.1);\n border-radius: 4px;\n }\n',t.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),t.appendChild(i);const s=document.createElement("div");s.className="panel",t.appendChild(s),e.panelMode?this._renderPanelMode(s):e.subDeviceMode?this._renderSubDeviceMode(s,e):this._renderCircuitMode(s,e)}_renderPanelMode(e){const t=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.global_defaults"));e.appendChild(n);const s=document.createElement("div");s.className="panel-body";const r=document.createElement("div");r.className="error-msg",r.id="error-msg",r.style.display="none",s.appendChild(r);const c=t.graphSettings,l=t.topology,d=c?.global_horizon??o,h=c?.circuits??{},p=document.createElement("div");p.className="section";const g=document.createElement("div");g.className="section-label",g.textContent=i("sidepanel.graph_horizon"),p.appendChild(g);const _=document.createElement("div");_.className="field-row";const f=document.createElement("span");f.className="field-label",f.textContent=i("sidepanel.global_default"),_.appendChild(f);const m=document.createElement("select");for(const e of Object.keys(a)){const t=document.createElement("option");t.value=e;const n=`horizon.${e}`,s=i(n);t.textContent=s!==n?s:e,e===d&&(t.selected=!0),m.appendChild(t)}if(m.addEventListener("change",()=>{this._callDomainService("set_graph_time_horizon",{horizon:m.value}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${e.message??e}`))}),_.appendChild(m),p.appendChild(_),s.appendChild(p),l?.circuits){const e=document.createElement("div");e.className="section";const t=document.createElement("div");t.className="section-label",t.textContent=i("sidepanel.circuit_scales"),e.appendChild(t);const n=Object.entries(l.circuits).sort(([,e],[,t])=>(e.name||"").localeCompare(t.name||""));for(const[t,s]of n){const n=document.createElement("div");n.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=s.name||t,o.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",n.appendChild(o);const r=h[t]||{horizon:d,has_override:!1},c=r.has_override?r.horizon:d,l=document.createElement("select");l.dataset.uuid=t;for(const e of Object.keys(a)){const t=document.createElement("option");t.value=e;const n=`horizon.${e}`,s=i(n);t.textContent=s!==n?s:e,e===c&&(t.selected=!0),l.appendChild(t)}if(l.addEventListener("change",()=>{this._debounce(`circuit-${t}`,u,()=>{this._callDomainService("set_circuit_graph_horizon",{circuit_id:t,horizon:l.value}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${e.message??e}`))})}),n.appendChild(l),r.has_override){const e=document.createElement("button");e.textContent="↺",e.title=i("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{this._callDomainService("clear_circuit_graph_horizon",{circuit_id:t}).then(()=>{l.value=d,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${e.message??e}`))}),n.appendChild(e)}e.appendChild(n)}s.appendChild(e)}const v=c?.sub_devices??{};if(l?.sub_devices){const e=document.createElement("div");e.className="section";const t=document.createElement("div");t.className="section-label",t.textContent=i("sidepanel.subdevice_scales"),e.appendChild(t);const n=Object.entries(l.sub_devices).sort(([,e],[,t])=>(e.name||"").localeCompare(t.name||""));for(const[t,s]of n){const n=document.createElement("div");n.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=s.name||t,o.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",n.appendChild(o);const r=v[t]||{horizon:d,has_override:!1},c=r.has_override?r.horizon:d,l=document.createElement("select");l.dataset.subdevId=t;for(const e of Object.keys(a)){const t=document.createElement("option");t.value=e;const n=`horizon.${e}`,s=i(n);t.textContent=s!==n?s:e,e===c&&(t.selected=!0),l.appendChild(t)}if(l.addEventListener("change",()=>{this._debounce(`subdev-${t}`,u,()=>{this._callDomainService("set_subdevice_graph_horizon",{subdevice_id:t,horizon:l.value}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${e.message??e}`))})}),n.appendChild(l),r.has_override){const e=document.createElement("button");e.textContent="↺",e.title=i("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{this._callDomainService("clear_subdevice_graph_horizon",{subdevice_id:t}).then(()=>{l.value=d,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${e.message??e}`))}),n.appendChild(e)}e.appendChild(n)}s.appendChild(e)}e.appendChild(s)}_renderCircuitMode(e,t){const n=`${Le(String(t.breaker_rating_a))}A · ${Le(String(t.voltage))}V · Tabs [${Le(String(t.tabs))}]`,i=this._createHeader(Le(t.name),n);e.appendChild(i);const s=document.createElement("div");s.className="panel-body",e.appendChild(s);const o=document.createElement("div");o.className="error-msg",o.id="error-msg",o.style.display="none",s.appendChild(o),this._renderRelaySection(s,t),this._renderSheddingSection(s,t),this._renderGraphHorizonSection(s,t),t.showMonitoring&&this._renderMonitoringSection(s,t)}_renderSubDeviceMode(e,t){const n=this._createHeader(Le(t.name),Le(t.deviceType));e.appendChild(n);const i=document.createElement("div");i.className="panel-body",e.appendChild(i);const s=document.createElement("div");s.className="error-msg",s.id="error-msg",s.style.display="none",i.appendChild(s),this._renderSubDeviceHorizonSection(i,t)}_renderSubDeviceHorizonSection(e,t){const n=document.createElement("div");n.className="section";const s=document.createElement("div");s.className="section-label",s.textContent=i("sidepanel.graph_horizon"),n.appendChild(s);const r=t.graphHorizonInfo,c=!0===r?.has_override,l=r?.horizon||o,d=r?.globalHorizon||o,h=document.createElement("div");h.className="horizon-bar";const p=[{key:"global",label:i("sidepanel.global")}];for(const e of Object.keys(a))p.push({key:e,label:e});const u=c?l:"global",g=e=>{for(const t of h.querySelectorAll(".horizon-segment")){const n=t.dataset.horizon;t.classList.toggle("active",n===e),t.classList.toggle("referenced","global"===e&&n===d)}};for(const{key:e,label:n}of p){const s=document.createElement("button");s.type="button",s.className="horizon-segment",s.dataset.horizon=e,s.textContent=n,s.classList.toggle("active",e===u),s.classList.toggle("referenced","global"===u&&e===d),s.addEventListener("click",()=>{if(s.classList.contains("active"))return;const n=t.subDeviceId;"global"===e?(g("global"),this._callDomainService("clear_subdevice_graph_horizon",{subdevice_id:n}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${i("sidepanel.clear_graph_horizon_failed")} ${e.message??e}`))):(g(e),this._callDomainService("set_subdevice_graph_horizon",{subdevice_id:n,horizon:e}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${i("sidepanel.graph_horizon_failed")} ${e.message??e}`)))}),h.appendChild(s)}n.appendChild(h),e.appendChild(n)}_createHeader(e,t){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),s=Le(e),o=Le(t);i.innerHTML=`
${s}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(e,t){if(!1===t.is_user_controllable||!t.entities?.switch)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const s=document.createElement("div");s.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.breaker");const a=document.createElement("ha-switch");a.dataset.role="relay-toggle";const r=t.entities.switch,c=this._hass?.states?.[r]?.state;"on"===c&&a.setAttribute("checked",""),a.addEventListener("change",()=>{const e=a.hasAttribute("checked")||a.checked;this._callService("switch",e?"turn_on":"turn_off",{entity_id:r}).catch(e=>this._showError(`${i("sidepanel.relay_failed")} ${e.message??e}`))}),s.appendChild(o),s.appendChild(a),n.appendChild(s),e.appendChild(n)}_renderSheddingSection(e,t){if(!t.entities?.select)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const s=document.createElement("div");s.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const r=t.entities.select,c=this._hass?.states?.[r]?.state||"";for(const e of Nt){const t=f[e];if(!t)continue;const n=document.createElement("option");n.value=e,n.textContent=i(`shedding.select.${e}`)||t.label(),e===c&&(n.selected=!0),a.appendChild(n)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:r,option:a.value}).catch(e=>this._showError(`${i("sidepanel.shedding_failed")} ${e.message??e}`))}),s.appendChild(o),s.appendChild(a),n.appendChild(s),e.appendChild(n)}_renderGraphHorizonSection(e,t){const n=document.createElement("div");n.className="section";const s=document.createElement("div");s.className="section-label",s.textContent=i("sidepanel.graph_horizon"),n.appendChild(s);const r=t.graphHorizonInfo,c=!0===r?.has_override,l=r?.horizon||o,d=r?.globalHorizon||o,h=document.createElement("div");h.className="horizon-bar";const p=[{key:"global",label:i("sidepanel.global")}];for(const e of Object.keys(a))p.push({key:e,label:e});const u=c?l:"global",g=e=>{for(const t of h.querySelectorAll(".horizon-segment")){const n=t.dataset.horizon;t.classList.toggle("active",n===e),t.classList.toggle("referenced","global"===e&&n===d)}};for(const{key:e,label:n}of p){const s=document.createElement("button");s.type="button",s.className="horizon-segment",s.dataset.horizon=e,s.textContent=n,s.classList.toggle("active",e===u),s.classList.toggle("referenced","global"===u&&e===d),s.addEventListener("click",()=>{if(s.classList.contains("active"))return;const n=t.uuid;"global"===e?(g("global"),this._callDomainService("clear_circuit_graph_horizon",{circuit_id:n}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${i("sidepanel.clear_graph_horizon_failed")} ${e.message??e}`))):(g(e),this._callDomainService("set_circuit_graph_horizon",{circuit_id:n,horizon:e}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${i("sidepanel.graph_horizon_failed")} ${e.message??e}`)))}),h.appendChild(s)}n.appendChild(h),e.appendChild(n)}_renderMonitoringSection(e,t){const n=document.createElement("div");n.className="section";const s=document.createElement("div");s.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=i("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("ha-switch");a.dataset.role="monitoring-toggle";const r=t.monitoringInfo,c=null!=r&&!1!==r.monitoring_enabled;c&&a.setAttribute("checked",""),s.appendChild(o),s.appendChild(a),n.appendChild(s);const l=document.createElement("div");l.dataset.role="monitoring-details",l.style.display=c?"block":"none",n.appendChild(l);const d=!0===r?.has_override,h=document.createElement("div");h.className="radio-group",h.innerHTML=`\n \n \n `,l.appendChild(h);const p=document.createElement("div");p.dataset.role="threshold-fields",p.style.display=d?"block":"none";const u=r?.continuous_threshold_pct??80,g=r?.spike_threshold_pct??100,_=r?.window_duration_m??15,f=r?.cooldown_duration_m??15;p.appendChild(this._createThresholdRow(i("sidepanel.continuous_pct"),"continuous",u,t)),p.appendChild(this._createThresholdRow(i("sidepanel.spike_pct"),"spike",g,t)),p.appendChild(this._createDurationRow(i("sidepanel.window_duration"),"window-m",_,1,180,"m",t)),p.appendChild(this._createDurationRow(i("sidepanel.cooldown"),"cooldown-m",f,1,180,"m",t)),l.appendChild(p),a.addEventListener("change",()=>{const e=a.checked;l.style.display=e?"block":"none";const n=t.entities?.power||t.uuid;this._callDomainService("set_circuit_threshold",{circuit_id:n,monitoring_enabled:e}).catch(e=>this._showError(`${i("sidepanel.monitoring_toggle_failed")} ${e.message??e}`))});const m=h.querySelectorAll('input[type="radio"]');for(const e of m)e.addEventListener("change",()=>{const n="custom"===e.value&&e.checked;if(p.style.display=n?"block":"none",!n&&e.checked){const e=t.entities?.power||t.uuid;this._callDomainService("clear_circuit_threshold",{circuit_id:e}).catch(e=>this._showError(`${i("sidepanel.clear_monitoring_failed")} ${e.message??e}`))}});e.appendChild(n)}_createThresholdRow(e,t,n,s){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=e;const r=document.createElement("input");return r.type="number",r.min="0",r.max="200",r.value=String(n),r.dataset.role=`threshold-${t}`,r.addEventListener("input",()=>{this._debounce(`threshold-${t}`,u,()=>{const e=this.shadowRoot;if(!e)return;const t=e.querySelector('[data-role="threshold-continuous"]'),n=e.querySelector('[data-role="threshold-spike"]'),o=e.querySelector('[data-role="threshold-window-m"]'),a=e.querySelector('[data-role="threshold-cooldown-m"]'),r=s.entities?.power||s.uuid;this._callDomainService("set_circuit_threshold",{circuit_id:r,continuous_threshold_pct:t?Number(t.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0}).catch(e=>this._showError(`${i("sidepanel.save_threshold_failed")} ${e.message??e}`))})}),o.appendChild(a),o.appendChild(r),o}_createDurationRow(e,t,n,s,o,a,r,c=!1){const l=document.createElement("div");l.className="field-row";const d=document.createElement("span");d.className="field-label",d.textContent=e;const h=document.createElement("div"),p=document.createElement("input");p.type="number",p.min=String(s),p.max=String(o),p.value=String(n),p.dataset.role=`threshold-${t}`,c&&(p.disabled=!0);const g=document.createElement("span");return g.textContent=a,h.appendChild(p),h.appendChild(g),c||p.addEventListener("input",()=>{this._debounce(`threshold-${t}`,u,()=>{const e=this.shadowRoot;if(!e)return;const t=e.querySelector('[data-role="threshold-continuous"]'),n=e.querySelector('[data-role="threshold-spike"]'),s=e.querySelector('[data-role="threshold-window-m"]');this._callDomainService("set_circuit_threshold",{circuit_id:r.uuid,continuous_threshold_pct:t?Number(t.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:s?Number(s.value):void 0}).catch(e=>this._showError(`${i("sidepanel.save_threshold_failed")} ${e.message??e}`))})}),l.appendChild(d),l.appendChild(h),l}_updateLiveState(){if(!this._config||this._config.panelMode)return;const e=this._config;if(!e.subDeviceMode){if(e.entities?.switch){const t=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(t){const n=this._hass?.states?.[e.entities.switch]?.state;"on"===n?t.setAttribute("checked",""):t.removeAttribute("checked")}}if(e.entities?.select){const t=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(t){const n=this._hass?.states?.[e.entities.select]?.state||"";t.value=n}}}}_callService(e,t,n){return this._hass?Promise.resolve(this._hass.callService(e,t,n)):Promise.resolve()}_callDomainService(e,t){return this._hass?this._hass.callWS({type:"call_service",domain:r,service:e,service_data:t}):Promise.resolve()}_showError(e){const t=this.shadowRoot?.getElementById("error-msg");t&&(t.textContent=e,t.style.display="block",setTimeout(()=>{t.style.display="none"},5e3))}_debounce(e,t,n){this._debounceTimers[e]&&clearTimeout(this._debounceTimers[e]),this._debounceTimers[e]=setTimeout(()=>{delete this._debounceTimers[e],n()},t)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",Tt)}catch{}const Dt=[{name:"Kitchen",watts:"120",path:"M0,28 L8,26 L16,24 L24,22 L32,25 L40,20 L48,18 L56,22 L64,19 L72,16 L80,18 L88,15 L96,17 L104,14 L112,16 L120,13"},{name:"Living Room",watts:"85",path:"M0,22 L8,24 L16,20 L24,26 L32,18 L40,22 L48,16 L56,20 L64,24 L72,18 L80,22 L88,20 L96,16 L104,22 L112,18 L120,20"},{name:"Master Bed",watts:"193",path:"M0,8 L8,10 L16,8 L24,12 L32,10 L40,8 L48,10 L56,8 L64,10 L72,8 L80,12 L88,10 L96,8 L104,10 L112,8 L120,10"},{name:"HVAC",watts:"64",path:"M0,30 L8,28 L16,26 L24,22 L32,18 L40,14 L48,18 L56,22 L64,26 L72,22 L80,18 L88,22 L96,26 L104,22 L112,18 L120,22"}];let Ht=class extends $e{constructor(){super(...arguments),this._config={},this._discovered=!1,this._discovering=!1,this._discoveryError=null,this._topology=null,this._activeTab="panel",this._panelDevice=null,this._panelSize=0,this._historyLoaded=!1,this._ctrl=new bt,this._listCtrl=new At(this._ctrl),this._areaUnsub=null,this._tabBarCleanup=null,this._onVisibilityChange=null}get _configEntryId(){return this._panelDevice?.config_entries?.[0]??null}connectedCallback(){super.connectedCallback(),this._ctrl.startIntervals(this.shadowRoot),this._onVisibilityChange=()=>{"visible"===document.visibilityState&&this._discovered&&this.hass&&(this._ctrl.recordSamples(),this._ctrl.updateDOM(this.shadowRoot))},document.addEventListener("visibilitychange",this._onVisibilityChange)}disconnectedCallback(){this._ctrl.stopIntervals(),this._listCtrl.stop(),this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._onVisibilityChange&&(document.removeEventListener("visibilitychange",this._onVisibilityChange),this._onVisibilityChange=null),super.disconnectedCallback()}setConfig(e){this._config=e,this._discovered=!1,this._discovering=!1,this._historyLoaded=!1,this._discoveryError=null,this._topology=null,this._panelDevice=null,this._panelSize=0,this._activeTab="panel",this._ctrl.reset(),this._ctrl.setConfig(e)}getCardSize(){return Math.ceil(this._panelSize/2)+3}static getConfigElement(){return document.createElement("span-panel-card-editor")}static getStubConfig(){return{device_id:"",history_days:0,history_hours:0,history_minutes:5,chart_metric:s,show_panel:!0,show_battery:!0,show_evse:!0}}render(){return n(this.hass?.language),this._config.device_id?this._discovered?re` + */function Me(e){return Ne({...e,state:!0,attribute:!1})}const Le={"&":"&","<":"<",">":">",'"':""","'":"'"};function Ie(e){return String(e).replace(/[&<>"']/g,e=>Le[e]??e)}const De="span_panel_list_columns";function Te(){try{const e=localStorage.getItem(De);if(!e)return 1;const t=parseInt(e,10);return 1===t||2===t||3===t?t:1}catch{return 1}}function He(e){try{localStorage.setItem(De,String(e))}catch{}}function Oe(e,t,n={}){const s=Ie(e.device_name||i("header.default_name")),o=Ie(e.serial||""),r=Ie(e.firmware||""),a="current"===(t.chart_metric||"power"),l=!1!==n.showSwitches;return`\n
\n
\n
\n

${s}

\n ${o}\n \n ${l?`
\n ${Ie(i("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${function(e,t){const n="current"===(t.chart_metric||"power"),s=!!e.panel_entities?.site_power,o=!!e.panel_entities?.dsm_state,r=!!e.panel_entities?.current_power,a=!!e.panel_entities?.feedthrough_power,l=!!e.panel_entities?.pv_power,c=!!e.panel_entities?.battery_level;return`\n
\n ${s?`\n
\n ${i("header.site")}\n
\n 0\n ${n?"A":"kW"}\n
\n
`:""}\n ${o?`\n
\n ${i("header.grid")}\n
\n --\n
\n
`:""}\n ${r?`\n
\n ${i("header.upstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${a?`\n
\n ${i("header.downstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${i("header.solar")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${i("header.battery")}\n
\n \n %\n
\n
`:""}\n
\n `}(e,t)}\n
\n
\n
\n ${r}\n
\n \n \n
\n
\n
\n ${Object.entries(m).filter(([e])=>"unknown"!==e).map(([,e])=>{const t=Ie(e.icon),n=Ie(e.color),i=Ie(e.label());let s;return s=e.icon2?``:e.textLabel?`${Ie(e.textLabel)}`:``,`
${s}${i}
`}).join("")}\n
\n
\n
\n `}const Fe=_.power;function Re(e){return Fe.unit(e)}function je(e){return(e<0?"-":"")+Fe.format(e)}function Ue(e){return(Math.abs(e)/1e3).toFixed(1)}function qe(e){return Math.ceil(e/2)}function We(e){return e%2==0?1:0}function Be(e){if(2!==e.length)return null;const[t,n]=[Math.min(...e),Math.max(...e)];return qe(t)===qe(n)?"row-span":We(t)===We(n)?"col-span":"row-span"}function Ge(e){const t=e.chart_metric??o;return _[t]??_[o]}function Ve(e,t){const n=function(e){return Ge(e).entityRole}(t);return e.entities?.[n]??e.entities?.power??null}function Qe(e){return new Promise(t=>setTimeout(t,e))}class Ke{constructor(e){this._store=e}async callWS(e,t,n){const i=n?.retries??3,s=n?.errorId??`ws:${String(t.type??"unknown")}`;return this._withRetry(()=>e.callWS(t),i,s,n?.errorMessage)}async callService(e,t,n,i,s,o){const r=o?.retries??3,a=o?.errorId??`svc:${t}.${n}`;return this._withRetry(()=>e.callService(t,n,i,s),r,a,o?.errorMessage)}async _withRetry(e,t,n,s){if(this._store.hasAnyPanelOffline())try{const t=await e();return this._store.remove(n),t}catch(e){const t=e instanceof Error?e:new Error(String(e));throw this._store.add({key:n,level:"error",message:s??i("error.panel_offline"),persistent:!1}),t}let o;for(let i=0;i<=t;i++)try{const t=await e();return this._store.remove(n),t}catch(e){if(o=e instanceof Error?e:new Error(String(e)),i{try{const n={};t&&(n.config_entry_id=t);const o={type:"call_service",domain:l,service:"get_monitoring_status",service_data:n,return_response:!0},r=this._retry?await this._retry.callWS(e,o,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await e.callWS(o),a=r?.response??null;return s===this._generation&&(this._status=a,this._lastFetch=Date.now()),a}catch(e){return console.warn("SPAN Panel: monitoring status fetch failed",e),s===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:i("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===s&&(this._inflight=null)}})();return this._inflight={gen:s,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class Xe{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(e){this._errorStore=e;for(const t of this._caches.values())t.errorStore=e}async fetchOne(e,t){let n=this._caches.get(t);return n||(n=new Je,n.errorStore=this._errorStore,this._caches.set(t,n)),n.fetch(e,t)}invalidate(){for(const e of this._caches.values())e.invalidate()}clear(){this._caches.clear()}}function Ze(e,t){return e?.circuits?e.circuits[t]??null:null}function Ye(e){return!!e&&void 0!==e.continuous_threshold_pct}function et(e,t,n,i){const s=[];return n||s.push("circuit-off"),i&&s.push("circuit-producer"),function(e){return!!e&&null!=e.over_threshold_since}(t)&&s.push("circuit-alert"),Ye(t)&&s.push("circuit-custom-monitoring"),s.join(" ")}function tt(e,t,n,s,o,r,a,l,h,p=!1){const u=t.entities?.power,g=u?r.states[u]:null,_=g&&parseFloat(g.state)||0,f=t.device_type===d||_<0,b=t.entities?.switch,y=b?r.states[b]:null,w=y?"on"===y.state:(g?.attributes?.relay_state||t.relay_state)===c,x=t.breaker_rating_a,S=x?`${Math.round(x)}A`:"",C=Ie(t.name||i("grid.unknown")),$=Ge(a);let E;if("current"===$.entityRole){const e=t.entities?.current,n=e?r.states[e]:null,i=n&&parseFloat(n.state)||0;E=`${$.format(i)}A`}else E=`${je(_)}${Re(_)}`;const k=h||"unknown";let z="";if("unknown"!==k){const e=m[k]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},t=Ie(e.label()),n=Ie(e.icon),i=Ie(e.color);if(e.icon2){z=`\n \n \n `}else if(e.textLabel){z=`\n \n ${Ie(e.textLabel)}\n `}else z=``}const P=l&&Ye(l)?v:"#555",A=``;let N="",M=l?.utilization_pct??null;if(null==M&&t.breaker_rating_a){const e=t.entities?.current,n=e?r.states[e]:null,i=n?Math.abs(parseFloat(n.state)||0):0;M=Math.round(i/t.breaker_rating_a*1e3)/10}if(null!=M){N=`=80?"utilization-warning":"utilization-normal"}">${Math.round(M)}%`}return`\n
\n
\n
\n ${S?`${S}`:""}\n ${N}\n ${C}\n
\n
\n \n ${E}\n \n ${!1!==t.is_user_controllable&&t.entities?.switch?`\n
\n ${i(w?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${z}\n ${A}\n
\n
\n
\n `}function nt(e,t){return`\n
\n \n
\n `}const it={names:["power","battery power"],suffixes:["_power"]},st={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},ot={names:["state of energy"],suffixes:["_soe_kwh"]},rt={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function at(e,t){if(!e.entities)return null;for(const[n,i]of Object.entries(e.entities)){if("sensor"!==i.domain)continue;const e=(i.original_name??"").toLowerCase();if(t.names.some(t=>e===t))return n;if(i.unique_id&&t.suffixes.some(e=>i.unique_id.endsWith(e)))return n}return null}function lt(e){return at(e,it)}function ct(e){return at(e,st)}function dt(e){return at(e,ot)}function ht(e){return at(e,rt)}function pt(e,t,n,i){const s=n.visible_sub_entities||{};let o="";if(!e.entities)return o;for(const[n,r]of Object.entries(e.entities)){if(i.has(n))continue;if(!0!==s[n])continue;const a=t.states[n];if(!a)continue;let l=r.original_name||a.attributes.friendly_name||n;const c=e.name||"";let d;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),t.formatEntityState)d=t.formatEntityState(a);else{d=a.state;const e=a.attributes.unit_of_measurement||"";e&&(d+=" "+e)}if("Wh"===(a.attributes.unit_of_measurement||"")){const e=parseFloat(a.state);isNaN(e)||(d=(e/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Ie(l)}:\n ${Ie(d)}\n
\n `}return o}function ut(e,t,n,s,o,r){if(n){const t=[{key:`${u}${e}_soc`,title:i("subdevice.soc"),available:!!o},{key:`${u}${e}_soe`,title:i("subdevice.soe"),available:!!r},{key:`${u}${e}_power`,title:i("subdevice.power"),available:!!s}].filter(e=>e.available);return`\n
\n ${t.map(e=>`\n
\n
${Ie(e.title)}
\n
\n
\n `).join("")}\n
\n `}return s?`
`:""}function gt(e){const t=void 0!==e.history_days||void 0!==e.history_hours||void 0!==e.history_minutes,n=60*(60*(24*(t&&parseInt(String(e.history_days))||0)+(t&&parseInt(String(e.history_hours))||0))+(t?parseInt(String(e.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function _t(e){const t=a[e];return t?t.ms:a[r].ms}function ft(e){const t=e/1e3;return t<=600?Math.ceil(t):Math.min(5e3,Math.ceil(t/5))}function mt(e){return Math.max(500,Math.floor(e/5e3))}function vt(e,t,n,i,s,o){e.has(t)||e.set(t,[]);const r=e.get(t);r.push({time:i,value:n});const a=r.findIndex(e=>e.time>=s);a>0?r.splice(0,a):-1===a&&(r.length=0),r.length>o&&r.splice(0,r.length-o)}function bt(e,t,n=500){if(0===e.length)return e;e.sort((e,t)=>e.time-t.time);const i=[e[0]];for(let t=1;t=n&&i.push(e[t]);return i.length>t&&i.splice(0,i.length-t),i}async function yt(e,t,n,i,s){const o=new Date(Date.now()-i).toISOString(),r=i/36e5>72?"hour":"5minute",a=await e.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:t,period:r,types:["mean"]});for(const[e,t]of Object.entries(a)){const i=n.get(e);if(!i||!t)continue;const o=[];for(const e of t){const t=e.mean;if(null==t||!Number.isFinite(t))continue;const n=e.start;n>0&&o.push({time:n,value:t})}if(o.length>0){const e=s.get(i)||[],t=[...o,...e];t.sort((e,t)=>e.time-t.time),s.set(i,t)}}}async function wt(e,t,n,i,s){const o=new Date(Date.now()-i).toISOString(),r=await e.callWS({type:"history/history_during_period",start_time:o,entity_ids:t,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),a=ft(i),l=mt(i);for(const[e,t]of Object.entries(r)){const i=n.get(e);if(!i||!t)continue;const o=[];for(const e of t){const t=parseFloat(e.s);if(!Number.isFinite(t))continue;const n=1e3*(e.lu||e.lc||0);n>0&&o.push({time:n,value:t})}if(o.length>0){const e=s.get(i)||[],t=[...o,...e];s.set(i,bt(t,a,l))}}}function xt(e){if(!e.sub_devices)return[];const t=[];for(const[n,i]of Object.entries(e.sub_devices)){const e={power:lt(i)};i.type===h&&(e.soc=ct(i),e.soe=dt(i));for(const[i,s]of Object.entries(e))s&&t.push({entityId:s,key:`${u}${n}_${i}`,devId:n})}return t}async function St(e,t,n,i,s,o){if(!t||!e)return;const r=new Map;for(const[e,i]of Object.entries(t.circuits)){const t=Ve(i,n);if(!t)continue;let o;o=s&&s.has(e)?_t(s.get(e)):gt(n),r.has(o)||r.set(o,{entityIds:[],uuidByEntity:new Map});const a=r.get(o);a.entityIds.push(t),a.uuidByEntity.set(t,e)}for(const{entityId:e,key:i,devId:s}of xt(t)){let t;t=o&&o.has(s)?_t(o.get(s)):gt(n),r.has(t)||r.set(t,{entityIds:[],uuidByEntity:new Map});const a=r.get(t);a.entityIds.push(e),a.uuidByEntity.set(e,i)}const a=[];for(const[t,n]of r){if(0===n.entityIds.length)continue;t>2592e5?a.push(yt(e,n.entityIds,n.uuidByEntity,t,i)):a.push(wt(e,n.entityIds,n.uuidByEntity,t,i))}await Promise.all(a)}function Ct(e,t,n,i,s,r,a,l,c){const{options:d,series:h}=function(e,t,n,i,s,r=!1){n||(n=_[o]);const a=i?"140, 160, 220":"77, 217, 175",l=`rgb(${a})`,c=Date.now(),d=c-t,h=void 0!==n.fixedMin&&void 0!==n.fixedMax,p=(e??[]).filter(e=>e.time>=d).map(e=>[e.time,Math.abs(e.value)]),u=[{type:"line",data:p,showSymbol:!1,smooth:!1,...r?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${a}, 0.18)`},{offset:1,color:`rgba(${a}, 0.18)`}]}},itemStyle:{color:l}}],g=p.length>0?function(e){let t=0;for(const n of e)n[1]>t&&(t=n[1]);return t}(p):0,f={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:g<10?e=>0===e?"0":e.toFixed(1):e=>n.format(e)},splitLine:{lineStyle:{opacity:.15}}};h?(f.min=n.fixedMin,f.max=n.fixedMax):g<1&&(f.min=0,f.max=1),s&&"current"===n.entityRole&&(f.min=0,f.max=Math.ceil(1.25*s),u.push({type:"line",data:[[d,.8*s],[c,.8*s]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),u.push({type:"line",data:[[d,s],[c,s]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:d,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:f,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:e=>{if(!e||0===e.length)return"";const t=e[0],i=new Date(t.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),s=parseFloat(t.value[1].toFixed(2));return`
${i}
${n.format(s)} ${n.unit(s)}
`}},animation:!1};return{options:m,series:u}}(n,i,s,r,l,c),p=a??120;e.style.minHeight=p+"px";let u=e.querySelector("ha-chart-base");u||(u=document.createElement("ha-chart-base"),u.style.display="block",u.style.width="100%",u.hass=t,e.innerHTML="",e.appendChild(u));const g=e.clientHeight;u.height=(g>0?g:p)+"px",u.hass=t,u.options=d,u.data=h}function $t(e){return"function"==typeof globalThis.CSS?.escape?CSS.escape(e):e.replace(/["\\]/g,"\\$&")}function Et(e,t,n,i,s){const o=e.querySelector(".panel-stats");o&&function(e,t,n,i,s){const o="current"===(i.chart_metric||"power"),r=e.querySelector(".stat-consumption .stat-value"),a=e.querySelector(".stat-consumption .stat-unit");if(o){const e=n.panel_entities?.site_power,i=e?t.states[e]:null,s=i?parseFloat(i.attributes?.amperage):NaN;r&&(r.textContent=Number.isFinite(s)?Math.abs(s).toFixed(1):"--"),a&&(a.textContent="A")}else{let e=s;const i=n.panel_entities?.site_power;if(i){const n=t.states[i];n&&(e=Math.abs(parseFloat(n.state)||0))}r&&(r.textContent=Ue(e)),a&&(a.textContent="kW")}const l=e.querySelector(".stat-upstream .stat-value"),c=e.querySelector(".stat-upstream .stat-unit");if(l){const e=n.panel_entities?.current_power,i=e?t.states[e]:null;if(o){const e=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",c&&(c.textContent="A")}else{const e=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=Ue(e),c&&(c.textContent="kW")}}const d=e.querySelector(".stat-downstream .stat-value"),h=e.querySelector(".stat-downstream .stat-unit");if(d){const e=n.panel_entities?.feedthrough_power,i=e?t.states[e]:null;if(o){const e=i?parseFloat(i.attributes?.amperage):NaN;d.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",h&&(h.textContent="A")}else{const e=i?Math.abs(parseFloat(i.state)||0):0;d.textContent=Ue(e),h&&(h.textContent="kW")}}const p=e.querySelector(".stat-solar .stat-value"),u=e.querySelector(".stat-solar .stat-unit");if(p){const e=n.panel_entities?.pv_power,i=e?t.states[e]:null;if(o){const e=i?parseFloat(i.attributes?.amperage):NaN;p.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",u&&(u.textContent="A")}else{if(i){const e=Math.abs(parseFloat(i.state)||0);p.textContent=Ue(e)}else p.textContent="--";u&&(u.textContent="kW")}}const g=e.querySelector(".stat-battery .stat-value");if(g){const e=n.panel_entities?.battery_level,i=e?t.states[e]:null;i&&(g.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const _=e.querySelector(".stat-grid-state .stat-value");if(_){const e=n.panel_entities?.dsm_state,i=e?t.states[e]:null;_.textContent=i?t.formatEntityState?.(i)||i.state:"--"}}(o,t,n,i,s)}class kt{get errorStore(){return this._errorStore}set errorStore(e){this._errorStore=e,this._retry=e?new Ke(e):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(e,t){const n=Date.now();if(this._fetching)return this._settings;if(this._settings&&n-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const n={};t&&(n.config_entry_id=t);const s={type:"call_service",domain:l,service:"get_graph_settings",service_data:n,return_response:!0},o=this._retry?await this._retry.callWS(e,s,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await e.callWS(s);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(e){console.warn("SPAN Panel: graph settings fetch failed",e),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function zt(e,t){if(!e)return r;const n=e.circuits?.[t];return n?.has_override?n.horizon:e.global_horizon??r}function Pt(e,t){if(!e)return r;const n=e.sub_devices?.[t];return n?.has_override?n.horizon:e.global_horizon??r}class At{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new Je,this.monitoringMultiCache=new Xe,this.graphSettingsCache=new kt,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(e){this._errorStore=e,this.monitoringCache.errorStore=e,this.graphSettingsCache.errorStore=e,this.monitoringMultiCache.errorStore=e}get hass(){return this._hass}set hass(e){this._hass=e}get topology(){return this._topology}get config(){return this._config}set showMonitoring(e){this._showMonitoring=e}init(e,t,n,i){this._topology=e,this._config=t,this._hass=n,this._configEntryId=i}setFavoriteRefs(e){this._favRefs=e}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(e){this._panelFavorites=e}setFavoritesPerPanelInfo(e){this._perPanelInfo=e??new Map}get _inFavoritesView(){return null!==this._favRefs}setConfig(e){this._config=e}buildHorizonMaps(e){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),e&&this._topology?.circuits)for(const t of Object.keys(this._topology.circuits))this.horizonMap.set(t,zt(e,t));if(e&&this._topology?.sub_devices)for(const t of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(t,Pt(e,t))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(e){console.warn("SPAN Panel: graph settings fetch failed",e),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(e){if(!this._hass||0===e.length)return null;const t=this._hass;return function(e){let t=!1;const n={},i={};for(const s of e)s&&(t=!0,s.circuits&&Object.assign(n,s.circuits),s.mains&&Object.assign(i,s.mains));return t?{circuits:n,mains:i}:null}(await Promise.all(e.map(e=>this.monitoringMultiCache.fetchOne(t,e))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const e=new Set;for(const t of Object.values(this._favRefs))t.configEntryId&&e.add(t.configEntryId);const t=new Map;await Promise.all(Array.from(e).map(async e=>{t.set(e,await this._fetchGraphSettingsFresh(e))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const e of Object.keys(this._topology.circuits)){const n=this._favRefs[e],i=n?.configEntryId?t.get(n.configEntryId)??null:null,s=n?.targetId??e;this.horizonMap.set(e,zt(i,s))}if(this._topology.sub_devices)for(const e of Object.keys(this._topology.sub_devices)){const n=this._favRefs[e],i=n?.configEntryId?t.get(n.configEntryId)??null:null,s=n?.targetId??e;this.subDeviceHorizonMap.set(e,Pt(i,s))}}async loadHistory(){await St(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const e=Date.now();for(const[t,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(t)??r;if(!a[i]?.useRealtime)continue;const s=Ve(n,this._config);if(!s)continue;const o=this._hass.states[s];if(!o)continue;const l=parseFloat(o.state);if(isNaN(l))continue;const c=_t(i),d=ft(c),h=mt(c),p=e-c,u=this.powerHistory.get(t)??[];u.length>0&&e-u[u.length-1].time0&&e-u[u.length-1].time0&&this._topology)for(const{key:e,devId:t}of xt(this._topology))n.has(t)&&s.add(e);const o=new Map;try{await St(this._hass,this._topology,this._config,o,t,n);for(const e of t.keys()){const t=o.get(e);t?this.powerHistory.set(e,t):this.powerHistory.delete(e)}for(const e of s){const t=o.get(e);t?this.powerHistory.set(e,t):this.powerHistory.delete(e)}this.updateDOM(e)}catch(e){console.warn("SPAN Panel: history refresh failed",e),this._errorStore?.add({key:"fetch:history",level:"warning",message:i("error.history_failed"),persistent:!1})}}updateDOM(e){this._hass&&this._topology&&this._config&&(function(e,t,n,s,o,r){if(!e||!n||!t)return;const a=gt(s);let l=0;for(const[,e]of Object.entries(n.circuits)){const n=e.entities?.power;if(!n)continue;const i=t.states[n],s=i&&parseFloat(i.state)||0;e.device_type!==d&&(l+=Math.abs(s))}Et(e,t,n,s,l);const h=Ge(s),p="current"===h.entityRole;for(const[s,l]of Object.entries(n.circuits)){const n=e.querySelector(`.circuit-slot[data-uuid="${$t(s)}"]`);if(!n)continue;const u=l.entities?.power,g=u?t.states[u]:null,_=g&&parseFloat(g.state)||0,f=l.device_type===d||_<0,v=l.entities?.switch,b=v?t.states[v]:null,y=b?"on"===b.state:(g?.attributes?.relay_state||l.relay_state)===c,w=n.querySelector(".power-value");if(w)if(p){const e=l.entities?.current,n=e?t.states[e]:null,i=n&&parseFloat(n.state)||0;w.innerHTML=`${h.format(i)}A`}else w.innerHTML=`${je(_)}${Re(_)}`;const x=n.querySelector(".toggle-pill");if(x){x.className="toggle-pill "+(y?"toggle-on":"toggle-off");const e=x.querySelector(".toggle-label");e&&(e.textContent=i(y?"grid.on":"grid.off"))}let S;if(n.classList.toggle("circuit-off",!y),n.classList.toggle("circuit-producer",f),l.always_on)S="always_on";else{const e=l.entities?.select,n=e?t.states[e]:null;S=n?n.state:"unknown"}const C=m[S]??m.unknown,$=n.querySelector(".shedding-icon");$&&($.setAttribute("icon",C.icon),$.style.color=C.color,$.title=C.label());const E=n.querySelector(".shedding-icon-secondary");E&&(C.icon2?(E.setAttribute("icon",C.icon2),E.style.color=C.color,E.style.display=""):E.style.display="none");const k=n.querySelector(".shedding-label");k&&(C.textLabel?(k.textContent=C.textLabel,k.style.color=C.color,k.style.display=""):k.style.display="none");const z=n.querySelector(".chart-container");if(z){const e=o.get(s)||[],i=n.classList.contains("circuit-col-span")?200:100,c=r?.has(s)?_t(r.get(s)):a,p=l.device_type===d;Ct(z,t,e,c,h,f,i,l.breaker_rating_a??void 0,p)}}}(e,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(e,t,n,i,s,o){if(!n.sub_devices)return;const r=gt(i);for(const[i,a]of Object.entries(n.sub_devices)){const n=e.querySelector(`[data-subdev="${$t(i)}"]`);if(!n)continue;const l=lt(a);if(l){const e=t.states[l],i=e&&parseFloat(e.state)||0,s=n.querySelector(".sub-power-value");s&&(s.innerHTML=`${je(i)} ${Re(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const e of c){const n=e.dataset.chartKey;if(!n)continue;const a=s.get(n)||[];let l=f.power;n.endsWith("_soc")?l=f.soc:n.endsWith("_soe")&&(l=f.soe);const c=!!e.closest(".bess-chart-col");Ct(e,t,a,o?.has(i)?_t(o.get(i)):r,l,!1,c?120:150,void 0,n.endsWith("_soc")||n.endsWith("_soe"))}for(const e of Object.keys(a.entities||{})){const i=n.querySelector(`[data-eid="${$t(e)}"]`);if(!i)continue;const s=t.states[e];if(s){let e;if(t.formatEntityState)e=t.formatEntityState(s);else{e=s.state;const t=s.attributes.unit_of_measurement||"";t&&(e+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(e=(t/1e3).toFixed(1)+" kWh")}i.textContent=e}}}}(e,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(e){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(e)}}onToggleClick(e,t){const n=e.target,s=n?.closest(".toggle-pill");if(!s)return;const o=t.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;e.stopPropagation(),e.preventDefault();const r=s.closest("[data-uuid]");if(!r||!this._topology||!this._hass)return;const a=r.dataset.uuid;if(!a)return;const l=this._topology.circuits[a];if(!l)return;const c=l.entities?.switch;if(!c)return;const d=this._hass.states[c];if(!d)return void console.warn("SPAN Panel: switch entity not found:",c);const h="on"===d.state?"turn_off":"turn_on";this._hass.callService("switch",h,{},{entity_id:c}).catch(e=>{console.warn("SPAN Panel: switch service call failed",e),this._errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}async onGearClick(e,t){const n=e.target,i=n?.closest(".gear-icon");if(!i)return;const s=t.querySelector("span-side-panel");if(!s||!this._hass)return;if(s.hass=this._hass,s.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const e=await this._buildFavoritesSections();if(0===e.length)return;return void s.open({favoritesMode:!0,perPanelSections:e})}return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void s.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const o=i.dataset.uuid;if(o&&this._topology){const e=this._topology.circuits[o];if(e){const t=this._favRefs?.[o]??null,n=t&&"circuit"===t.kind?t.targetId:o,i=t?.configEntryId??this._configEntryId;let a,l;t?[a,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),a=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=e.entities?.current??e.entities?.power,d=c?l?.circuits?.[c]??null:null,h=a?.global_horizon??r,p=a?.circuits?.[n],u=p?{...p,globalHorizon:h}:{horizon:h,has_override:!1,globalHorizon:h},g=t?.panelDeviceId??this._panelFavorites?.panelDeviceId,_=null!==t||(this._panelFavorites?.circuitUuids.has(n)??!1),f=this._inFavoritesView||null!==this._panelFavorites;return void s.open({...e,uuid:n,monitoringInfo:d,showMonitoring:this._showMonitoring,graphHorizonInfo:u,showFavorites:f,favoritePanelDeviceId:g,isFavorite:_,configEntryId:i})}}const a=i.dataset.subdevId;if(a&&this._topology?.sub_devices?.[a]){const e=this._topology.sub_devices[a],t=this._favRefs?.[a]??null,n=t&&"sub_device"===t.kind?t.targetId:a,i=t?.configEntryId??this._configEntryId;let o;t?o=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),o=this.graphSettingsCache.settings);const l=o?.global_horizon??r,c=o?.sub_devices?.[n],d=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},h=t?.panelDeviceId??this._panelFavorites?.panelDeviceId,p=null!==t||(this._panelFavorites?.subDeviceIds.has(n)??!1),u=this._inFavoritesView||null!==this._panelFavorites;s.open({subDeviceMode:!0,subDeviceId:n,name:e.name??n,deviceType:e.type??"",entities:e.entities,graphHorizonInfo:d,showFavorites:u,favoritePanelDeviceId:h,isFavorite:p,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const e=function(e,t){const n=new Map;for(const i of Object.values(e)){if("circuit"!==i.kind)continue;const e=t.get(i.panelDeviceId);if(void 0===e)continue;let s=n.get(i.panelDeviceId);void 0===s&&(s={panelDeviceId:i.panelDeviceId,panelName:e.panelName,topology:e.topology,configEntryId:e.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,s)),s.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((e,t)=>e.panelName.localeCompare(t.panelName))}(this._favRefs,this._perPanelInfo);if(0===e.length)return[];return await Promise.all(e.map(async e=>({panelDeviceId:e.panelDeviceId,panelName:e.panelName,topology:e.topology,graphSettings:await this._fetchGraphSettingsFresh(e.configEntryId),favoriteCircuitUuids:e.favoriteCircuitUuids,configEntryId:e.configEntryId})))}async _fetchGraphSettingsFresh(e){if(!this._hass)return null;try{const t={};e&&(t.config_entry_id=e);const n={type:"call_service",domain:l,service:"get_graph_settings",service_data:t,return_response:!0},s=this._errorStore?new Ke(this._errorStore):null,o=s?await s.callWS(this._hass,n,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await this._hass.callWS(n);return o?.response??null}catch(e){return console.warn("SPAN Panel: fresh graph settings fetch failed",e),null}}async _fetchMonitoringStatusFresh(e){if(!this._hass)return null;try{const t={};e&&(t.config_entry_id=e);const n={type:"call_service",domain:l,service:"get_monitoring_status",service_data:t,return_response:!0},s=this._errorStore?new Ke(this._errorStore):null,o=s?await s.callWS(this._hass,n,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await this._hass.callWS(n),r=o?.response;return r?{circuits:r.circuits,mains:r.mains}:null}catch(e){return console.warn("SPAN Panel: fresh monitoring status fetch failed",e),null}}bindSlideConfirm(e,t){const n=e.querySelector(".slide-confirm-knob"),i=e.querySelector(".slide-confirm-text");if(!n||!i)return;let s=!1,o=0,r=0;const a=t=>{e.classList.contains("confirmed")||(s=!0,o=t-n.offsetLeft,r=e.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=e=>{if(!s)return;const t=Math.max(2,Math.min(e-o,r));n.style.left=t+"px"},c=()=>{if(!s)return;s=!1;(n.offsetLeft-2)/r>=.9?(n.style.left=r+"px",e.classList.add("confirmed"),n.querySelector("ha-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=e.dataset.textOn??"",t&&t.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",e=>{e.preventDefault(),a(e.clientX)}),e.addEventListener("mousemove",e=>l(e.clientX)),e.addEventListener("mouseup",c),e.addEventListener("mouseleave",c),n.addEventListener("touchstart",e=>{e.preventDefault(),a(e.touches[0].clientX)},{passive:!1}),e.addEventListener("touchmove",e=>l(e.touches[0].clientX),{passive:!0}),e.addEventListener("touchend",c),e.addEventListener("touchcancel",c),e.addEventListener("click",()=>{e.classList.contains("confirmed")&&(e.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("ha-icon")?.setAttribute("icon","mdi:lock"),i.textContent=e.dataset.textOff??"",t&&t.classList.add("switches-disabled"))})}startIntervals(e,t){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(e),t&&t()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(e)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(e,t){this.cleanupResizeObserver(),t&&(this._lastWidth=t.clientWidth,this._resizeObserver=new ResizeObserver(t=>{const n=t[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const t of e.querySelectorAll(".chart-container")){const e=t.querySelector("ha-chart-base");e&&e.remove()}this.updateDOM(e)},150))}),this._resizeObserver.observe(t))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function Nt(e=""){const t=e?` value="${Ie(e)}"`:"",n=e?"":"display:none;";return`\n
\n \n \n
\n `}function Mt(e,t,n,s,o,r,a){const l=t.entities?.power,d=l?n.states[l]:null,h=d&&parseFloat(d.state)||0,p=t.entities?.switch,u=p?n.states[p]:null,g=u?"on"===u.state:(d?.attributes?.relay_state||t.relay_state)===c,_=t.breaker_rating_a,f=_?`${Math.round(_)}A`:"",b=Ie(t.name||i("grid.unknown")),y=Ge(s),w="current"===y.entityRole;let x;if(g)if(w){const e=t.entities?.current,i=e?n.states[e]:null,s=i&&parseFloat(i.state)||0;x=`${y.format(s)}A`}else x=`${je(h)}${Re(h)}`;else x="";const S=r||"unknown";let C="";if("unknown"!==S){const e=m[S]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};C=e.icon2?`\n \n \n `:e.textLabel?`\n \n ${e.textLabel}\n `:``}let $="",E=o?.utilization_pct??null;if(null==E&&t.breaker_rating_a){const e=t.entities?.current,i=e?n.states[e]:null,s=i?Math.abs(parseFloat(i.state)||0):0;E=Math.round(s/t.breaker_rating_a*1e3)/10}if(null!=E){$=`=80?"utilization-warning":"utilization-normal"}">${Math.round(E)}%`}const k=!!o&&Ye(o)?v:"#555",z=``,P=!1!==t.is_user_controllable&&!!t.entities?.switch?`
\n ${i(g?"grid.on":"grid.off")}\n \n
`:`${g?"ON":"OFF"}`;return`\n
\n ${f?`${f}`:""}\n ${$}\n ${b}\n ${C}\n ${P}\n \n ${x}\n \n ${z}\n \n
\n `}function Lt(e,t,n,i,s){const o=t.entities?.power,r=o?n.states[o]:null,a=r&&parseFloat(r.state)||0,l=t.device_type===d||a<0,h=t.entities?.switch,p=h?n.states[h]:null,u=et(0,s,p?"on"===p.state:(r?.attributes?.relay_state||t.relay_state)===c,l),g=Ie(e);return`\n
\n
\n
\n
\n
\n `}function It(e){return`
${Ie(e)}
`}function Dt(e,t,n){const i=e.entities?.switch,s=i?t.states[i]:null,o=e.entities?.power,r=o?t.states[o]:null,a=s?"on"===s.state:(r?.attributes?.relay_state||e.relay_state)===c;let l;if("current"===(n.chart_metric||"power")){const n=e.entities?.current,i=n?t.states[n]:null;l=i?Math.abs(parseFloat(i.state)||0):0}else l=r?Math.abs(parseFloat(r.state)||0):0;return{isOn:a,value:l}}function Tt(e,t){if(e.always_on)return"always_on";const n=e.entities?.select,i=n?t.states[n]:null;return i?i.state:"unknown"}function Ht(e,t,n,i){const s=Dt(e,n,i),o=Dt(t,n,i);return s.isOn&&!o.isOn?-1:!s.isOn&&o.isOn?1:o.value-s.value}function Ot(e,t,n){return e.sort((e,i)=>Ht(e[1],i[1],t,n))}function Ft(e){return e.entities?.current??e.entities?.power??""}class Rt{constructor(e){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._ctrl=e}setColumns(e){const t=Math.max(1,Math.min(3,Math.floor(e)));this._columns=t}setInitialExpansion(e){this._expandedUuids=new Set(e)}setInitialSearchQuery(e){this._searchQuery=e}setViewName(e){this._viewName=e}renderActivityView(e,t,n,i,s,o){this._unbindEvents(),this._hass=t,this._topology=n,this._config=i,this._monitoringStatus=s;const r=Ot(Object.entries(n.circuits),t,i);let a=o+Nt(this._searchQuery);a+=`
`;for(const[e,n]of r){const o=Ze(s,Ft(n)),r=Tt(n,t),l=this._expandedUuids.has(e);a+=`
`,a+=Mt(e,n,t,i,o,r,l),l&&(a+=Lt(e,n,t,0,o)),a+="
"}a+="
",a+="",e.innerHTML=a;const l=e.querySelector("span-side-panel");l&&(l.hass=t,l.errorStore=this._ctrl.errorStore),this._bindEvents(e),this._searchQuery&&this._applyFilter(e),this._ctrl.updateDOM(e)}renderAreaView(e,t,n,s,o,r){this._unbindEvents(),this._hass=t,this._topology=n,this._config=s,this._monitoringStatus=o;const a=i("list.unassigned_area"),l=new Map;for(const[e,t]of Object.entries(n.circuits)){const n=t.area??a,i=l.get(n);i?i.push([e,t]):l.set(n,[[e,t]])}const c=[...l.keys()].sort((e,t)=>e===a?1:t===a?-1:e.localeCompare(t));let d=r+Nt(this._searchQuery);d+=`
`;for(const e of c){const n=l.get(e);if(!n)continue;const i=Ot(n,t,s);d+=It(e);for(const[e,n]of i){const i=Ze(o,Ft(n)),r=Tt(n,t),a=this._expandedUuids.has(e);d+=`
`,d+=Mt(e,n,t,s,i,r,a),a&&(d+=Lt(e,n,t,0,i)),d+="
"}}d+="
",d+="",e.innerHTML=d;const h=e.querySelector("span-side-panel");h&&(h.hass=t,h.errorStore=this._ctrl.errorStore),this._bindEvents(e),this._searchQuery&&this._applyFilter(e),this._ctrl.updateDOM(e)}updateCollapsedRows(e,t,n,s){const o=Ge(s),r="current"===o.entityRole,a=e.querySelectorAll(".list-row[data-row-uuid]");for(const e of a){const a=e.dataset.rowUuid;if(!a)continue;const l=n.circuits[a];if(!l)continue;const{isOn:c,value:d}=Dt(l,t,s),h=e.querySelector(".list-power-value");if(h)if(c)if(r)h.innerHTML=`${o.format(d)}A`;else{const e=l.entities?.power,n=e?t.states[e]:null,i=n&&parseFloat(n.state)||0;h.innerHTML=`${je(i)}${Re(i)}`}else h.innerHTML="";const p=e.querySelector(".toggle-pill");if(p){p.classList.toggle("toggle-on",c),p.classList.toggle("toggle-off",!c);const e=p.querySelector(".toggle-label");e&&(e.textContent=i(c?"grid.on":"grid.off"))}const u=e.querySelector(".list-status-badge");u&&(u.textContent=c?"ON":"OFF",u.classList.toggle("list-status-on",c),u.classList.toggle("list-status-off",!c)),e.classList.toggle("circuit-off",!c)}!function(e,t,n,i){const s=e.querySelector(".list-view");if(s)for(const e of function(e,t){let n={anchor:null,units:[]};const i=[n];for(const s of[...e.children])if(s.classList.contains("area-header"))n={anchor:s,units:[]},i.push(n);else if(s.classList.contains("list-cell")){const e=s.dataset.cellUuid,i=e?t.circuits[e]:void 0;e&&i&&n.units.push({cell:s,uuid:e,circuit:i})}return i}(s,n)){if(e.units.length<2)continue;const n=[...e.units].sort((e,n)=>Ht(e.circuit,n.circuit,t,i));if(!n.some((t,n)=>t.uuid!==e.units[n].uuid))continue;let o=e.anchor;for(const e of n)o?o.after(e.cell):s.prepend(e.cell),o=e.cell}}(e,t,n,s)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const e={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:e,bubbles:!0,composed:!0}))}_bindEvents(e){this._container=e,this._clickHandler=t=>{const n=t.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const e=i.dataset.expandUuid;return void(e&&this._toggleExpand(e))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(t,e);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(t,e);if(n.closest(".list-search-clear")){const t=e.querySelector(".list-search");return void(t&&(t.value="",t.dispatchEvent(new Event("input",{bubbles:!0}))))}const s=n.closest(".unit-btn");if(s){const t=s.dataset.unit;t&&e.dispatchEvent(new CustomEvent("unit-changed",{detail:t,bubbles:!0,composed:!0}))}},this._inputHandler=t=>{const n=t.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(e),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(e).then(()=>{this._ctrl.updateDOM(e)}).catch(()=>{})},e.addEventListener("click",this._clickHandler),e.addEventListener("input",this._inputHandler),e.addEventListener("graph-settings-changed",this._graphSettingsHandler);const t=e.querySelector(".slide-confirm");t&&(this._ctrl.bindSlideConfirm(t,e),e.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_applyFilter(e){const t=e.querySelector(".list-search-clear");t&&(t.style.display=this._searchQuery?"":"none");const n=e.querySelectorAll(".list-cell[data-cell-uuid]");for(const e of n){const t=e.querySelector(".list-circuit-name"),n=(t?.textContent?.toLowerCase()??"").includes(this._searchQuery);e.style.display=n?"":"none"}const i=e.querySelectorAll(".area-header");for(const e of i){let t=!1,n=e.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){t=!0;break}n=n.nextElementSibling}e.style.display=t?"":"none"}}_toggleExpand(e){if(!(this._container&&this._hass&&this._topology&&this._config))return;const t=$t(e),n=this._container.querySelector(`.list-cell[data-cell-uuid="${t}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${t}"]`),s=n.querySelector(`.list-expand-toggle[data-expand-uuid="${t}"]`);if(i){if(this._expandedUuids.has(e)){this._expandedUuids.delete(e);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${t}"]`);o&&o.remove(),s&&s.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(e);const t=this._topology.circuits[e];if(!t)return;const n=Ze(this._monitoringStatus,Ft(t)),o=Lt(e,t,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),s&&s.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}async function jt(e,t){const[n,i,s]=await Promise.all([e.callWS({type:"config/area_registry/list"}),e.callWS({type:"config/entity_registry/list"}),e.callWS({type:"config/device_registry/list"})]),o=new Map;for(const e of n)o.set(e.area_id,e.name);const r=new Map;for(const e of i)e.area_id&&r.set(e.entity_id,e.area_id);const a=new Map;for(const e of s)a.set(e.id,e.area_id);let l;if(t.device_id){const e=a.get(t.device_id);e&&(l=o.get(e))}for(const e of Object.values(t.circuits)){let t;for(const n of Object.values(e.entities)){if(!n)continue;const e=r.get(n);if(e){t=o.get(e);break}}t||(t=l),e.area=t}}class Ut{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(e){const t={...e,timestamp:Date.now()};if(t.persistent)this._persistent.set(t.key,t);else{this._clearTransient(),this._transient=t;const e=t.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},e)}this._notify()}remove(e){if(this._persistent.has(e))return this._persistent.delete(e),void this._notify();this._transient?.key===e&&(this._clearTransient(),this._notify())}clear(e){void 0===e?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===e.persistent?this._persistent.clear():!1===e.persistent&&this._clearTransient(),this._notify()}get active(){const e=[...this._persistent.values()];return null!==this._transient&&e.push(this._transient),e}hasPersistent(e){return this._persistent.has(e)}hasAnyPanelOffline(){for(const e of this._persistent.keys())if("panel-offline"===e||e.startsWith("panel-offline:"))return!0;return!1}subscribe(e){return this._subscribers.add(e),()=>{this._subscribers.delete(e)}}watchPanelStatus(e){this.watchPanelStatuses([{entityId:e,panelName:null}])}watchPanelStatuses(e){const t=this._watchedPanels,n=new Map;for(const i of e){const e=t.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:e?.wasOffline??!1})}const i=this._isSingleUnnamed(t),s=this._isSingleUnnamed(n);for(const e of t.keys()){n.has(e)&&i===s||this._persistent.delete(this._offlineKey(e,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const t of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(t,e));this._watchedPanels.clear(),this._notify()}updateHass(e){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const[n,o]of this._watchedPanels){const r=e.states[n]?.state,a="on"===r,l=this._offlineKey(n,t),c=this._reconnectKey(n,t);if(a){const e=o.wasOffline;o.wasOffline=!1,this.remove(l),e&&this.add({key:c,level:"info",message:null===o.panelName?i("error.panel_reconnected"):s("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?i("error.panel_offline"):s("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(e){if(1!==e.size)return!1;for(const t of e.values())return null===t.panelName;return!1}_offlineKey(e,t){return t?"panel-offline":`panel-offline:${e}`}_reconnectKey(e,t){return t?"panel-reconnected":`panel-reconnected:${e}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const e of this._subscribers)try{e()}catch(e){console.warn("SPAN Panel: error-store subscriber threw",e)}}}function qt(e){let t=0;for(const n of Object.values(e))if(n)for(const e of n.tabs)e>t&&(t=e);return t>0?t+t%2:0}function Wt(e){return e?{id:e.id,name:e.name,name_by_user:e.name_by_user,config_entries:e.config_entries,identifiers:e.identifiers,via_device_id:e.via_device_id,sw_version:e.sw_version,model:e.model}:null}const Bt="favorites-changed";async function Gt(e,t,n={}){const i=await e.callWS({type:"call_service",domain:l,service:t,service_data:n,return_response:!0});return i?.response??null}const Vt=Object.keys(m).filter(e=>"unknown"!==e&&"always_on"!==e);class Qt extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(e){this._hass=e,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(e){this._config=e,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(e))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const e of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[e]);this._debounceTimers={}}_modeFor(e){return e.favoritesMode?"favorites":e.panelMode?"panel":e.subDeviceMode?"subDevice":"circuit"}_render(){const e=this._config;if(!e)return;const t=this.shadowRoot;if(!t)return;t.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart ha-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',t.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),t.appendChild(i);const s=document.createElement("div");s.className="panel",t.appendChild(s),e.favoritesMode?this._renderFavoritesMode(s):e.panelMode?this._renderPanelMode(s):e.subDeviceMode?this._renderSubDeviceMode(s,e):this._renderCircuitMode(s,e)}_renderPanelMode(e){const t=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.global_defaults"));e.appendChild(n);const s=document.createElement("div");s.className="panel-body";const o=t.graphSettings,l=t.topology,c=o?.global_horizon??r,d=o?.circuits??{};s.appendChild(this._buildListColumnsSection());const h=document.createElement("div");h.className="section";const p=document.createElement("div");p.className="section-label",p.textContent=i("sidepanel.graph_horizon"),h.appendChild(p);const u=document.createElement("div");u.className="field-row";const _=document.createElement("span");_.className="field-label",_.textContent=i("sidepanel.global_default"),u.appendChild(_);const f=document.createElement("select");for(const e of Object.keys(a)){const t=document.createElement("option");t.value=e;const n=`horizon.${e}`,s=i(n);t.textContent=s!==n?s:e,e===c&&(t.selected=!0),f.appendChild(t)}if(f.addEventListener("change",()=>{const e={horizon:f.value};t.configEntryId&&(e.config_entry_id=t.configEntryId),this._callDomainService("set_graph_time_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),u.appendChild(f),h.appendChild(u),s.appendChild(h),l?.circuits){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.circuit_scales"),e.appendChild(n);const o=Object.entries(l.circuits).sort(([,e],[,t])=>(e.name||"").localeCompare(t.name||""));for(const[n,i]of o){const s=this._buildPanelModeCircuitRow(n,i,d[n],c,t.configEntryId??null,t.showFavorites??!1,t.favoritePanelDeviceId,t.favoriteCircuitUuids);e.appendChild(s)}s.appendChild(e)}const m=o?.sub_devices??{};if(l?.sub_devices){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.subdevice_scales"),e.appendChild(n);const o=Object.entries(l.sub_devices).sort(([,e],[,t])=>(e.name||"").localeCompare(t.name||""));for(const[n,s]of o){const o=document.createElement("div");o.className="field-row";const r=document.createElement("span");if(r.className="field-label",r.textContent=s.name||n,r.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",o.appendChild(r),t.showFavorites&&t.favoritePanelDeviceId){const e=this._buildSubDeviceFavoriteHeart(s.entities,t.favoriteSubDeviceIds?.has(n)??!1);e&&o.appendChild(e)}const l=m[n]||{horizon:c,has_override:!1},d=l.has_override?l.horizon:c,h=document.createElement("select");h.dataset.subdevId=n;for(const e of Object.keys(a)){const t=document.createElement("option");t.value=e;const n=`horizon.${e}`,s=i(n);t.textContent=s!==n?s:e,e===d&&(t.selected=!0),h.appendChild(t)}if(h.addEventListener("change",()=>{this._debounce(`subdev-${n}`,g,()=>{const e={subdevice_id:n,horizon:h.value};t.configEntryId&&(e.config_entry_id=t.configEntryId),this._callDomainService("set_subdevice_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),o.appendChild(h),l.has_override){const e=document.createElement("button");e.textContent="↺",e.title=i("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const s={subdevice_id:n};t.configEntryId&&(s.config_entry_id=t.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",s).then(()=>{h.value=c,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),o.appendChild(e)}e.appendChild(o)}s.appendChild(e)}e.appendChild(s)}_buildPanelModeCircuitRow(e,t,n,s,o,r,l,c){const d=document.createElement("div");d.className="field-row";const h=document.createElement("span");if(h.className="field-label",h.textContent=t.name||e,h.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",d.appendChild(h),r&&l){const n=this._buildFavoriteHeart(t.entities,c?.has(e)??!1);n&&d.appendChild(n)}const p=n||{horizon:s,has_override:!1},u=p.has_override?p.horizon:s,_=document.createElement("select");_.dataset.uuid=e;for(const e of Object.keys(a)){const t=document.createElement("option");t.value=e;const n=`horizon.${e}`,s=i(n);t.textContent=s!==n?s:e,e===u&&(t.selected=!0),_.appendChild(t)}if(_.addEventListener("change",()=>{this._debounce(`circuit-${e}`,g,()=>{const t={circuit_id:e,horizon:_.value};o&&(t.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),d.appendChild(_),p.has_override){const t=document.createElement("button");t.textContent="↺",t.title=i("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const n={circuit_id:e};o&&(n.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{_.value=s,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),d.appendChild(t)}return d}_renderFavoritesMode(e){const t=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.favorites_subtitle"));e.appendChild(n);const s=document.createElement("div");s.className="panel-body",s.appendChild(this._buildListColumnsSection());for(const e of t.perPanelSections)s.appendChild(this._buildFavoritesPanelSection(e));e.appendChild(s)}_buildFavoritesPanelSection(e){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=e.panelName,t.appendChild(n);const i=e.graphSettings?.global_horizon??r,s=e.graphSettings?.circuits??{},o=function(e){const t=e.circuits??{};return Object.entries(t).map(([e,t])=>({uuid:e,circuit:t})).sort((e,t)=>(e.circuit.name||"").localeCompare(t.circuit.name||""))}(e.topology);for(const{uuid:n,circuit:r}of o){const o=this._buildPanelModeCircuitRow(n,r,s[n],i,e.configEntryId,!0,e.panelDeviceId,e.favoriteCircuitUuids);t.appendChild(o)}return t}_renderCircuitMode(e,t){const n=`${Ie(String(t.breaker_rating_a))}A · ${Ie(String(t.voltage))}V · Tabs [${Ie(String(t.tabs))}]`,i=this._createHeader(Ie(t.name),n);e.appendChild(i);const s=document.createElement("div");s.className="panel-body",e.appendChild(s),this._renderRelaySection(s,t),t.showFavorites&&this._renderFavoriteSection(s,t),this._renderSheddingSection(s,t),this._renderGraphHorizonSection(s,t),t.showMonitoring&&this._renderMonitoringSection(s,t)}_favoriteEntityId(e){return e?.current??e?.power??null}_subDeviceFavoriteEntityId(e){if(!e)return null;let t=null;for(const[n,i]of Object.entries(e)){if("sensor"===i.domain)return n;t||(t=n)}return t}_buildSubDeviceFavoriteHeart(e,t){const n=this._subDeviceFavoriteEntityId(e);return n?this._buildHeartButton(n,t):null}_buildListColumnsSection(){const e=document.createElement("div");e.className="section";const t=document.createElement("div");t.className="section-label",t.textContent=i("sidepanel.list_view_columns"),e.appendChild(t);const n=document.createElement("div");n.className="field-row";const s=document.createElement("span");s.className="field-label",s.textContent=i("sidepanel.columns"),n.appendChild(s);const o=Te(),r=document.createElement("div");r.className="unit-toggle";for(const e of[1,2,3]){const t=document.createElement("button");t.type="button",t.className="unit-btn"+(e===o?" unit-active":""),t.dataset.columns=String(e),t.textContent=String(e),t.addEventListener("click",()=>{He(e);for(const e of r.querySelectorAll(".unit-btn"))e.classList.toggle("unit-active",e===t);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:e,bubbles:!0,composed:!0}))}),r.appendChild(t)}return n.appendChild(r),e.appendChild(n),e}_buildFavoriteHeart(e,t){const n=this._favoriteEntityId(e);return n?this._buildHeartButton(n,t):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(e,t){const n=document.createElement("button");n.type="button",n.className=t?"fav-heart active":"fav-heart",n.dataset.role="fav-heart",n.title=i("sidepanel.save_to_favorites"),n.setAttribute("role","switch"),n.setAttribute("aria-checked",String(t)),n.setAttribute("aria-label",i("sidepanel.save_to_favorites"));const s=document.createElement("ha-icon");return s.setAttribute("icon",t?"mdi:heart":"mdi:heart-outline"),n.appendChild(s),n.addEventListener("click",t=>{t.stopPropagation(),this._toggleFavoriteEntity(n,s,e).catch(()=>{})}),n}async _toggleFavoriteEntity(e,t,n){if(!this._hass)return;const s=e.classList.contains("active"),o=!s;e.classList.toggle("active",o),t.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),e.setAttribute("aria-checked",String(o));try{o?await async function(e,t){const n=await Gt(e,"add_favorite",{entity_id:t});return document.dispatchEvent(new CustomEvent(Bt)),n?.favorites??{}}(this._hass,n):await async function(e,t){const n=await Gt(e,"remove_favorite",{entity_id:t});return document.dispatchEvent(new CustomEvent(Bt)),n?.favorites??{}}(this._hass,n)}catch(n){throw e.classList.toggle("active",s),t.setAttribute("icon",s?"mdi:heart":"mdi:heart-outline"),e.setAttribute("aria-checked",String(s)),console.warn("SPAN Panel: favorite toggle failed",n),this.errorStore?.add({key:"service:favorites",level:"error",message:i("error.favorites_toggle_failed"),persistent:!1}),n}}_renderFavoriteSection(e,t){const n=this._favoriteEntityId(t.entities);n&&this._appendFavoriteHeartSection(e,n,!0===t.isFavorite)}_appendFavoriteHeartSection(e,t,n){const s=document.createElement("div");s.className="section",s.innerHTML=``;const o=document.createElement("div");o.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=i("sidepanel.save_to_favorites"),o.appendChild(r),o.appendChild(this._buildHeartButton(t,n)),s.appendChild(o),e.appendChild(s)}_renderSubDeviceMode(e,t){const n=this._createHeader(Ie(t.name),Ie(t.deviceType));e.appendChild(n);const i=document.createElement("div");i.className="panel-body",e.appendChild(i),t.showFavorites&&this._renderSubDeviceFavoriteSection(i,t),this._renderSubDeviceHorizonSection(i,t)}_renderSubDeviceFavoriteSection(e,t){const n=this._subDeviceFavoriteEntityId(t.entities);n&&this._appendFavoriteHeartSection(e,n,!0===t.isFavorite)}_renderSubDeviceHorizonSection(e,t){const n=document.createElement("div");n.className="section";const s=document.createElement("div");s.className="section-label",s.textContent=i("sidepanel.graph_horizon"),n.appendChild(s);const o=t.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||r,d=o?.globalHorizon||r,h=document.createElement("div");h.className="horizon-bar";const p=[{key:"global",label:i("sidepanel.global")}];for(const e of Object.keys(a))p.push({key:e,label:e});const u=l?c:"global",g=e=>{for(const t of h.querySelectorAll(".horizon-segment")){const n=t.dataset.horizon;t.classList.toggle("active",n===e),t.classList.toggle("referenced","global"===e&&n===d)}};for(const{key:e,label:n}of p){const s=document.createElement("button");s.type="button",s.className="horizon-segment",s.dataset.horizon=e,s.textContent=n,s.classList.toggle("active",e===u),s.classList.toggle("referenced","global"===u&&e===d),s.addEventListener("click",()=>{if(s.classList.contains("active"))return;const n={subdevice_id:t.subDeviceId};t.configEntryId&&(n.config_entry_id=t.configEntryId),"global"===e?(g("global"),this._callDomainService("clear_subdevice_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(g(e),this._callDomainService("set_subdevice_graph_horizon",{...n,horizon:e}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(s)}n.appendChild(h),e.appendChild(n)}_createHeader(e,t){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),s=Ie(e),o=Ie(t);i.innerHTML=`
${s}
`+(o?`
${o}
`:"");const r=document.createElement("button");return r.className="close-btn",r.innerHTML="✕",r.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(r),n}_renderRelaySection(e,t){if(!1===t.is_user_controllable||!t.entities?.switch)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const s=document.createElement("div");s.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.breaker");const r=document.createElement("ha-switch");r.dataset.role="relay-toggle";const a=t.entities.switch,l=this._hass?.states?.[a]?.state;"on"===l&&r.setAttribute("checked",""),r.addEventListener("change",()=>{const e=r.hasAttribute("checked")||r.checked;this._callService("switch",e?"turn_on":"turn_off",{entity_id:a}).catch(e=>{console.warn("SPAN Panel: relay toggle failed",e),this.errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}),s.appendChild(o),s.appendChild(r),n.appendChild(s),e.appendChild(n)}_renderSheddingSection(e,t){if(!t.entities?.select)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const s=document.createElement("div");s.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.priority_label");const r=document.createElement("select");r.dataset.role="shedding-select";const a=t.entities.select,l=this._hass?.states?.[a]?.state||"";for(const e of Vt){const t=m[e];if(!t)continue;const n=document.createElement("option");n.value=e,n.textContent=i(`shedding.select.${e}`)||t.label(),e===l&&(n.selected=!0),r.appendChild(n)}r.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:a,option:r.value}).catch(e=>{console.warn("SPAN Panel: shedding update failed",e),this.errorStore?.add({key:"service:shedding",level:"error",message:i("error.shedding_failed"),persistent:!1})})}),s.appendChild(o),s.appendChild(r),n.appendChild(s),e.appendChild(n)}_renderGraphHorizonSection(e,t){const n=document.createElement("div");n.className="section";const s=document.createElement("div");s.className="section-label",s.textContent=i("sidepanel.graph_horizon"),n.appendChild(s);const o=t.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||r,d=o?.globalHorizon||r,h=document.createElement("div");h.className="horizon-bar";const p=[{key:"global",label:i("sidepanel.global")}];for(const e of Object.keys(a))p.push({key:e,label:e});const u=l?c:"global",g=e=>{for(const t of h.querySelectorAll(".horizon-segment")){const n=t.dataset.horizon;t.classList.toggle("active",n===e),t.classList.toggle("referenced","global"===e&&n===d)}};for(const{key:e,label:n}of p){const s=document.createElement("button");s.type="button",s.className="horizon-segment",s.dataset.horizon=e,s.textContent=n,s.classList.toggle("active",e===u),s.classList.toggle("referenced","global"===u&&e===d),s.addEventListener("click",()=>{if(s.classList.contains("active"))return;const n={circuit_id:t.uuid};t.configEntryId&&(n.config_entry_id=t.configEntryId),"global"===e?(g("global"),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(g(e),this._callDomainService("set_circuit_graph_horizon",{...n,horizon:e}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(s)}n.appendChild(h),e.appendChild(n)}_renderMonitoringSection(e,t){const n=document.createElement("div");n.className="section";const s=document.createElement("div");s.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=i("sidepanel.monitoring"),o.style.margin="0";const r=document.createElement("ha-switch");r.dataset.role="monitoring-toggle";const a=t.monitoringInfo,l=null!=a&&!1!==a.monitoring_enabled;l&&r.setAttribute("checked",""),s.appendChild(o),s.appendChild(r),n.appendChild(s);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",n.appendChild(c);const d=!0===a?.has_override,h=document.createElement("div");h.className="radio-group",h.innerHTML=`\n \n \n `,c.appendChild(h);const p=document.createElement("div");p.dataset.role="threshold-fields",p.style.display=d?"block":"none";const u=a?.continuous_threshold_pct??80,g=a?.spike_threshold_pct??100,_=a?.window_duration_m??15,f=a?.cooldown_duration_m??15;p.appendChild(this._createThresholdRow(i("sidepanel.continuous_pct"),"continuous",u,t)),p.appendChild(this._createThresholdRow(i("sidepanel.spike_pct"),"spike",g,t)),p.appendChild(this._createDurationRow(i("sidepanel.window_duration"),"window-m",_,1,180,"m",t)),p.appendChild(this._createDurationRow(i("sidepanel.cooldown"),"cooldown-m",f,1,180,"m",t)),c.appendChild(p),r.addEventListener("change",()=>{const e=r.checked;c.style.display=e?"block":"none";const n={circuit_id:t.entities?.power||t.uuid,monitoring_enabled:e};t.configEntryId&&(n.config_entry_id=t.configEntryId),this._callDomainService("set_circuit_threshold",n).catch(e=>{console.warn("SPAN Panel: monitoring update failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})});const m=h.querySelectorAll('input[type="radio"]');for(const e of m)e.addEventListener("change",()=>{const n="custom"===e.value&&e.checked;if(p.style.display=n?"block":"none",!n&&e.checked){const e={circuit_id:t.entities?.power||t.uuid};t.configEntryId&&(e.config_entry_id=t.configEntryId),this._callDomainService("clear_circuit_threshold",e).catch(e=>{console.warn("SPAN Panel: monitoring update failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})}});e.appendChild(n)}_createThresholdRow(e,t,n,s){const o=document.createElement("div");o.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=e;const a=document.createElement("input");return a.type="number",a.min="0",a.max="200",a.value=String(n),a.dataset.role=`threshold-${t}`,a.addEventListener("input",()=>{this._debounce(`threshold-${t}`,g,()=>{const e=this.shadowRoot;if(!e)return;const t=e.querySelector('[data-role="threshold-continuous"]'),n=e.querySelector('[data-role="threshold-spike"]'),o=e.querySelector('[data-role="threshold-window-m"]'),r=e.querySelector('[data-role="threshold-cooldown-m"]'),a={circuit_id:s.entities?.power||s.uuid,continuous_threshold_pct:t?Number(t.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:r?Number(r.value):void 0};s.configEntryId&&(a.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",a).catch(e=>{console.warn("SPAN Panel: monitoring update failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),o.appendChild(r),o.appendChild(a),o}_createDurationRow(e,t,n,s,o,r,a,l=!1){const c=document.createElement("div");c.className="field-row";const d=document.createElement("span");d.className="field-label",d.textContent=e;const h=document.createElement("div"),p=document.createElement("input");p.type="number",p.min=String(s),p.max=String(o),p.value=String(n),p.dataset.role=`threshold-${t}`,l&&(p.disabled=!0);const u=document.createElement("span");return u.textContent=r,h.appendChild(p),h.appendChild(u),l||p.addEventListener("input",()=>{this._debounce(`threshold-${t}`,g,()=>{const e=this.shadowRoot;if(!e)return;const t=e.querySelector('[data-role="threshold-continuous"]'),n=e.querySelector('[data-role="threshold-spike"]'),s=e.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:a.uuid,continuous_threshold_pct:t?Number(t.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:s?Number(s.value):void 0};a.configEntryId&&(o.config_entry_id=a.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(e=>{console.warn("SPAN Panel: monitoring update failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),c.appendChild(d),c.appendChild(h),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const e=this._config;if(!e.subDeviceMode&&!e.favoritesMode){if(e.entities?.switch){const t=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(t){const n=this._hass?.states?.[e.entities.switch]?.state;"on"===n?t.setAttribute("checked",""):t.removeAttribute("checked")}}if(e.entities?.select){const t=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(t){const n=this._hass?.states?.[e.entities.select]?.state||"";t.value=n}}}}_callService(e,t,n){return this._hass?Promise.resolve(this._hass.callService(e,t,n)):Promise.resolve()}_callDomainService(e,t){return this._hass?this._hass.callWS({type:"call_service",domain:l,service:e,service_data:t}):Promise.resolve()}_debounce(e,t,n){this._debounceTimers[e]&&clearTimeout(this._debounceTimers[e]),this._debounceTimers[e]=setTimeout(()=>{delete this._debounceTimers[e],n()},t)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",Qt)}catch{}let Kt=class extends Ee{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(e){if(this._store===e)return;this._unsub?.(),this._unsub=null,this._store=e,this._errors=e.active;const t=e;this._unsub=e.subscribe(()=>{this._errors=t.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const e=this._store;this._errors=e.active,this._unsub=e.subscribe(()=>{this._errors=e.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?de:le`${this._errors.map(e=>le` + + `)}`}_iconForLevel(e){switch(e){case"error":return"mdi:alert-circle";case"warning":return"mdi:alert";default:return"mdi:information"}}};Kt.styles=((e,...t)=>{const n=1===e.length?e[0]:t.reduce((t,n,i)=>t+(e=>{if(!0===e._$cssResult$)return e.cssText;if("number"==typeof e)return e;throw Error("Value passed to 'css' function must be a 'css' function result: "+e+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+e[i+1],e[0]);return new C(n,e,x)})` + :host { + display: block; + } + .banner-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 13px; + line-height: 1.4; + } + .banner-row + .banner-row { + border-top: 1px solid rgba(128, 128, 128, 0.2); + } + .banner-row.level-error { + background: color-mix(in srgb, var(--error-color, #db4437) 15%, transparent); + color: var(--error-color, #db4437); + } + .banner-row.level-warning { + background: color-mix(in srgb, var(--warning-color, #ff9800) 15%, transparent); + color: var(--warning-color, #ff9800); + } + .banner-row.level-info { + background: color-mix(in srgb, var(--info-color, #4285f4) 15%, transparent); + color: var(--info-color, #4285f4); + } + .icon { + flex-shrink: 0; + width: 18px; + height: 18px; + --mdc-icon-size: 18px; + } + .message { + flex: 1; + min-width: 0; + } + .retry-btn { + flex-shrink: 0; + background: none; + border: 1px solid currentColor; + border-radius: 4px; + color: inherit; + cursor: pointer; + font-size: 12px; + padding: 2px 8px; + } + .retry-btn:hover { + opacity: 0.8; + } + `,b([Me()],Kt.prototype,"_errors",void 0),Kt=b([ze("span-error-banner")],Kt);const Jt=[{name:"Kitchen",watts:"120",path:"M0,28 L8,26 L16,24 L24,22 L32,25 L40,20 L48,18 L56,22 L64,19 L72,16 L80,18 L88,15 L96,17 L104,14 L112,16 L120,13"},{name:"Living Room",watts:"85",path:"M0,22 L8,24 L16,20 L24,26 L32,18 L40,22 L48,16 L56,20 L64,24 L72,18 L80,22 L88,20 L96,16 L104,22 L112,18 L120,20"},{name:"Master Bed",watts:"193",path:"M0,8 L8,10 L16,8 L24,12 L32,10 L40,8 L48,10 L56,8 L64,10 L72,8 L80,12 L88,10 L96,8 L104,10 L112,8 L120,10"},{name:"HVAC",watts:"64",path:"M0,30 L8,28 L16,26 L24,22 L32,18 L40,14 L48,18 L56,22 L64,26 L72,22 L80,18 L88,22 L96,26 L104,22 L112,18 L120,22"}];let Xt=class extends Ee{constructor(){super(...arguments),this._config={},this._discovered=!1,this._discovering=!1,this._topology=null,this._activeTab="panel",this._panelDevice=null,this._panelSize=0,this._historyLoaded=!1,this._ctrl=new At,this._listCtrl=new Rt(this._ctrl),this._errorStore=new Ut,this._areaUnsub=null,this._areaSubscribing=!1,this._tabBarCleanup=null,this._onVisibilityChange=null}get _configEntryId(){return this._panelDevice?.config_entries?.[0]??null}get _root(){const e=this.shadowRoot;if(!e)throw new Error("span-panel-card: shadow root is not available");return e}connectedCallback(){super.connectedCallback(),this._ctrl.startIntervals(this._root),this._onVisibilityChange=()=>{"visible"===document.visibilityState&&this._discovered&&this.hass&&(this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root))},document.addEventListener("visibilitychange",this._onVisibilityChange)}disconnectedCallback(){this._ctrl.stopIntervals(),this._listCtrl.stop(),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._onVisibilityChange&&(document.removeEventListener("visibilitychange",this._onVisibilityChange),this._onVisibilityChange=null),this._errorStore.dispose(),super.disconnectedCallback()}setConfig(e){this._errorStore.clear(),this._config=e,this._discovered=!1,this._discovering=!1,this._historyLoaded=!1,this._topology=null,this._panelDevice=null,this._panelSize=0,this._activeTab="panel",this._ctrl.reset(),this._ctrl.setConfig(e),this._ctrl.errorStore=this._errorStore}getCardSize(){return Math.ceil(this._panelSize/2)+3}static getConfigElement(){return document.createElement("span-panel-card-editor")}static getStubConfig(){return{device_id:"",history_days:0,history_hours:0,history_minutes:5,chart_metric:o,show_panel:!0,show_battery:!0,show_evse:!0}}render(){if(n(this.hass?.language),!this._config.device_id)return this._renderPreview();if(!this._discovered){const e=this._errorStore.hasPersistent("discovery-failed");return le` + + + ${e?de:le`
${Ie(i("card.connecting"))}
`} +
+ `}return le` +
- `:this._discoveryError?re` - -
${Le(this._discoveryError)}
-
- `:re` - -
${Le(i("card.loading"))}
-
- `:this._renderPreview()}updated(e){if(e.has("hass")&&this.hass&&(n(this.hass.language),this._ctrl.hass=this.hass,this._config.device_id))if(this._discovered||this._discovering){if(this._discovered){this._ctrl.recordSamples(),this._ctrl.updateDOM(this.shadowRoot);const e=this.shadowRoot.querySelector("span-side-panel");e&&(e.hass=this.hass)}this._discovered&&"panel"!==this._activeTab&&this._topology&&this._listCtrl.updateCollapsedRows(this.shadowRoot,this.hass,this._topology,this._config)}else this._startDiscovery()}async _startDiscovery(){this._discovering=!0,await this._discoverTopology(),this._discoveryError?this._discovering=!1:(this._discovered=!0,this._discovering=!1,this._ctrl.init(this._topology,this._config,this.hass,this._configEntryId),this._topology&&async function(e,t,n){if(!e.connection)return()=>{};const i=async()=>{try{const i=new Map;for(const[e,n]of Object.entries(t.circuits))i.set(e,n.area);await Mt(e,t);for(const[e,s]of Object.entries(t.circuits))if(s.area!==i.get(e))return void n()}catch(e){console.warn("[span-panel] area registry update failed:",e)}},[s,o]=await Promise.all([e.connection.subscribeEvents(i,"entity_registry_updated"),e.connection.subscribeEvents(i,"area_registry_updated")]);return()=>{s(),o()}}(this.hass,this._topology,()=>{"area"===this._activeTab&&this._discovered&&this._populateCardContent()}).then(e=>{this._areaUnsub=e}).catch(()=>{}),await this.updateComplete,this._populateCardContent(),this._loadHistory(),this._ctrl.monitoringCache.fetch(this.hass,this._configEntryId).then(()=>{this._discovered&&this._ctrl.updateDOM(this.shadowRoot)}))}async _discoverTopology(){if(this.hass)try{const e=await async function(e,t){if(!t)throw new Error(i("card.device_not_found"));const n=await e.callWS({type:`${r}/panel_topology`,device_id:t}),s=n.panel_size??Pt(n.circuits);if(!s)throw new Error(i("card.topology_error"));const o=Lt((await e.callWS({type:"config/device_registry/list"})).find(e=>e.id===t));return await Mt(e,n),{topology:n,panelDevice:o,panelSize:s}}(this.hass,this._config.device_id);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(e){console.error("SPAN Panel: topology fetch failed, falling back to entity discovery",e);try{const e=await async function(e,t){const[n,s]=await Promise.all([e.callWS({type:"config/device_registry/list"}),e.callWS({type:"config/entity_registry/list"})]),o=Lt(n.find(e=>e.id===t));if(!o)return{topology:null,panelDevice:null,panelSize:0};const a=s.filter(e=>e.device_id===t),c=n.filter(e=>e.via_device_id===t),l=new Set(c.map(e=>e.id)),d=s.filter(e=>void 0!==e.device_id&&l.has(e.device_id)),h={},p=o.name_by_user??o.name??"";for(const t of[...a,...d]){const n=e.states[t.entity_id];if(!n)continue;const i=n.attributes,s=i.tabs;if("string"!=typeof s||!s.startsWith("tabs ["))continue;const o=s.slice(6,-1);let a;if(a=o.includes(":")?o.split(":").map(Number):[Number(o)],!a.every(Number.isFinite))continue;const r=t.unique_id.split("_");let c=null;for(let e=2;e=16&&/^[a-f0-9]+$/i.test(t)){c=t;break}}if(!c)continue;let l=("string"==typeof i.friendly_name?i.friendly_name:void 0)??t.entity_id;for(const e of[" Power"," Consumed Energy"," Produced Energy"])if(l.endsWith(e)){l=l.slice(0,-e.length);break}p&&l.startsWith(p+" ")&&(l=l.slice(p.length+1));const d=t.entity_id.replace(/^sensor\./,"").replace(/_power$/,""),u="number"==typeof i.voltage?i.voltage:2===a.length?240:120,g={power:t.entity_id,switch:`switch.${d}_breaker`,breaker_rating:`sensor.${d}_breaker_rating`};h[c]={tabs:a,name:l,voltage:u,device_type:"string"==typeof i.device_type?i.device_type:"circuit",relay_state:"string"==typeof i.relay_state?i.relay_state:"UNKNOWN",is_user_controllable:!0,breaker_rating_a:null,entities:g}}let u="";if(o.identifiers)for(const e of o.identifiers)e[0]===r&&(u=e[1]);let g=0;for(const t of a){const n=e.states[t.entity_id];if(n&&"number"==typeof n.attributes.panel_size){g=n.attributes.panel_size;break}}if(g||(g=Pt(h)),!g)throw new Error(i("card.panel_size_error"));const _={};for(const t of c){const n=s.filter(e=>e.device_id===t.id),i=(t.model??"").toLowerCase(),o=i.includes("battery")||(t.identifiers??[]).some(e=>e[1].toLowerCase().includes("bess")),a=i.includes("drive")||(t.identifiers??[]).some(e=>e[1].toLowerCase().includes("evse")),r={};for(const t of n){const n=t.entity_id.split(".")[0],i=e.states[t.entity_id],s=i?.attributes?.friendly_name;r[t.entity_id]={domain:n??"",original_name:"string"==typeof s?s:t.entity_id}}_[t.id]={name:t.name_by_user??t.name??"",type:o?"bess":a?"evse":"unknown",entities:r}}const f={serial:u,firmware:o.sw_version??"",panel_size:g,device_id:t,device_name:o.name_by_user??o.name??i("header.default_name"),circuits:h,sub_devices:_};return await Mt(e,f),{topology:f,panelDevice:o,panelSize:g}}(this.hass,this._config.device_id);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(e){console.error("SPAN Panel: fallback discovery also failed",e),this._discoveryError=e.message}}}async _loadHistory(){if(!this._historyLoaded&&this._topology&&this.hass){this._historyLoaded=!0,await this._ctrl.fetchAndBuildHorizonMaps();try{await this._ctrl.loadHistory(),this._ctrl.updateDOM(this.shadowRoot)}catch(e){console.warn("SPAN Panel: history fetch failed, charts will populate live",e)}}}_populateCardContent(){const e=this.shadowRoot.querySelector("#card-content");if(!(e&&this.hass&&this._topology&&this._panelSize))return;const t=this.shadowRoot.querySelector("#card-tabs");if(t){const e=[{id:"panel",label:i("tab.by_panel"),icon:"mdi:view-dashboard"},{id:"activity",label:i("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:i("tab.by_area"),icon:"mdi:home-group"}];t.innerHTML=(n=e,s=this._activeTab,o=this._config.tab_style??"text",`
${n.map(e=>{const t=e.id===s?" active":"",n=Le(e.id);return"icon"===o?``:``}).join("")}
`),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._tabBarCleanup=function(e,t){const n=e=>{const n=e.target.closest(".shared-tab");if(n){const e=n.dataset.tab;e&&t(e)}};return e.addEventListener("click",n),()=>{e.removeEventListener("click",n)}}(t,e=>{["panel","activity","area"].includes(e)&&(this._activeTab=e,this._listCtrl.stop(),this._populateCardContent())})}var n,s,o;if("panel"===this._activeTab){const t=Math.ceil(this._panelSize/2),n=function(e,t){const n=Le(e.device_name||i("header.default_name")),s=Le(e.serial||""),o=Le(e.firmware||""),a="current"===(t.chart_metric||"power"),r=!!e.panel_entities?.site_power,c=!!e.panel_entities?.dsm_state,l=!!e.panel_entities?.current_power,d=!!e.panel_entities?.feedthrough_power,h=!!e.panel_entities?.pv_power,p=!!e.panel_entities?.battery_level;return`\n
\n
\n
\n

${n}

\n ${s}\n \n
\n ${i("header.enable_switches")}\n
\n \n
\n
\n
\n
\n ${r?`\n
\n ${i("header.site")}\n
\n 0\n ${a?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${i("header.grid")}\n
\n --\n
\n
`:""}\n ${l?`\n
\n ${i("header.upstream")}\n
\n --\n ${a?"A":"kW"}\n
\n
`:""}\n ${d?`\n
\n ${i("header.downstream")}\n
\n --\n ${a?"A":"kW"}\n
\n
`:""}\n ${h?`\n
\n ${i("header.solar")}\n
\n --\n ${a?"A":"kW"}\n
\n
`:""}\n ${p?`\n
\n ${i("header.battery")}\n
\n \n %\n
\n
`:""}\n
\n
\n
\n
\n ${o}\n
\n \n \n
\n
\n
\n ${Object.entries(f).filter(([e])=>"unknown"!==e).map(([,e])=>{let t;return t=e.icon2?``:e.textLabel?`${e.textLabel}`:``,`
${t}${e.label()}
`}).join("")}\n
\n
\n
\n `}(this._topology,this._config),s=this._ctrl.monitoringCache.status,o=function(e){if(!e)return"";const t=Object.values(e.circuits??{}),n=Object.values(e.mains??{}),s=[...t,...n],o=s.filter(e=>void 0!==e.utilization_pct&&e.utilization_pct>=80&&e.utilization_pct<100).length,a=s.filter(e=>void 0!==e.utilization_pct&&e.utilization_pct>=100).length,r=s.filter(e=>e.has_override).length;return`\n
\n ✓ ${i("status.monitoring")} · ${t.length} ${i("status.circuits")} · ${n.length} ${i("status.mains")}\n \n ${o>0?`${o} ${i(o>1?"status.warnings":"status.warning")}`:""}\n ${a>0?`${a} ${i(a>1?"status.alerts":"status.alert")}`:""}\n ${r>0?`${r} ${i(r>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(s),a=function(e,t,n,i,s){const o=new Map,a=new Set;for(const[t,n]of Object.entries(e.circuits)){const e=n.tabs;if(!e||0===e.length)continue;const i=Math.min(...e),s=1===e.length?"single":Ie(e)??"single";o.set(i,{uuid:t,circuit:n,layout:s});for(const t of e)a.add(t)}const r=new Set,c=new Set;for(const[e,t]of o)if("col-span"===t.layout){const n=t.circuit.tabs,i=Oe(Math.max(...n));0===Re(e)?r.add(i):c.add(i)}function l(e){const t=e.circuit.entities?.current??e.circuit.entities?.power,i=s?We(s,t??""):null;let o;if(e.circuit.always_on)o="always_on";else{const t=e.circuit.entities?.select;o=t&&n.states[t]?n.states[t].state:"unknown"}return{monInfo:i,sheddingPriority:o}}let d="";for(let e=1;e<=t;e++){const t=2*e-1,s=2*e,h=o.get(t),p=o.get(s);if(d+=`
${t}
`,h&&"row-span"===h.layout){const{monInfo:t,sheddingPriority:o}=l(h);d+=Be(h.uuid,h.circuit,e,"2 / 4","row-span",n,i,t,o),d+=`
${s}
`;continue}if(!r.has(e))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(t)||(d+=Ge(e,"2"));else{const{monInfo:t,sheddingPriority:s}=l(h);d+=Be(h.uuid,h.circuit,e,"2",h.layout,n,i,t,s)}if(!c.has(e))if(!p||"col-span"!==p.layout&&"single"!==p.layout)a.has(s)||(d+=Ge(e,"3"));else{const{monInfo:t,sheddingPriority:s}=l(p);d+=Be(p.uuid,p.circuit,e,"3",p.layout,n,i,t,s)}d+=`
${s}
`}return d}(this._topology,t,this.hass,this._config,s),r=function(e,t,n){const s=!1!==n.show_battery,o=!1!==n.show_evse;if(!e.sub_devices)return"";const a=Object.entries(e.sub_devices).filter(([,e])=>!(e.type===d&&!s||e.type===h&&!o));if(0===a.length)return"";const r=a.filter(([,e])=>e.type===h).length;let c=0,l="";for(const[e,s]of a){const o=s.type===h?i("subdevice.ev_charger"):s.type===d?i("subdevice.battery"):i("subdevice.fallback"),a=Ze(s),p=a?t.states[a]:void 0,u=p&&parseFloat(p.state)||0,g=s.type===d,_=s.type===h,f=g?Ye(s):null,m=g?et(s):null,v=g?tt(s):null,b=nt(s,t,n,new Set([a,f,m,v].filter(e=>null!==e))),y=it(e,0,g,a,f,m);let w="";g?w="sub-device-bess":_&&(c++,c===r&&r%2==1&&(w="sub-device-full")),l+=`\n
\n
\n ${Le(o)}\n ${Le(s.name||"")}\n ${a?`${De(u)} ${Te(u)}`:""}\n \n
\n ${y}\n ${b}\n
\n `}return l}(this._topology,this.hass,this._config);e.innerHTML=`\n ${n}\n ${o}\n ${r?`
${r}
`:""}\n ${!1!==this._config.show_panel?`
${a}
`:""}\n `;const c=e.querySelector(".slide-confirm");if(c){const e=this.shadowRoot.querySelector("ha-card");this._ctrl.bindSlideConfirm(c,e),e&&e.classList.add("switches-disabled")}const l=this.shadowRoot.querySelector("span-side-panel");l&&(l.hass=this.hass),this._ctrl.recordSamples(),this._ctrl.updateDOM(this.shadowRoot),this._ctrl.setupResizeObserver(this.shadowRoot,this.shadowRoot.querySelector("ha-card"))}else"activity"===this._activeTab?(e.innerHTML="",this._listCtrl.renderActivityView(e,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status),this._ctrl.updateDOM(this.shadowRoot)):"area"===this._activeTab&&(e.innerHTML="",this._listCtrl.renderAreaView(e,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status),this._ctrl.updateDOM(this.shadowRoot))}_onCardClick(e){if("panel"!==this._activeTab)return;const t=e.target;if(!t)return;const n=t.closest(".unit-btn");if(n)return void this._onUnitToggle(n);if(t.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,this.shadowRoot);t.closest(".gear-icon")&&this._ctrl.onGearClick(e,this.shadowRoot)}async _onUnitToggle(e){const t=e.dataset.unit;t&&t!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:t},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this.shadowRoot))}async _onListUnitChanged(e){const t=e.detail;t&&t!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:t},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this.shadowRoot))}_onGraphSettingsChanged(){this._ctrl.onGraphSettingsChanged(this.shadowRoot)}_onSidePanelClosed(){this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()}_renderPreview(){const e=Dt.map(e=>re` + `}updated(e){if(e.has("hass")&&this.hass&&(n(this.hass.language),this._ctrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._config.device_id))if(this._discovered||this._discovering){if(this._discovered){this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root);const e=this._root.querySelector("span-side-panel");e&&(e.hass=this.hass,e.errorStore=this._errorStore)}this._discovered&&"panel"!==this._activeTab&&this._topology&&this._listCtrl.updateCollapsedRows(this._root,this.hass,this._topology,this._config)}else this._startDiscovery()}async _startDiscovery(){this._discovering||(this._discovering=!0,await this._discoverTopology(),this._errorStore.hasPersistent("discovery-failed")?this._discovering=!1:(this._discovered=!0,this._discovering=!1,this._ctrl.init(this._topology,this._config,this.hass,this._configEntryId),this._topology?.panel_entities?.panel_status&&(this._errorStore.watchPanelStatus(this._topology.panel_entities.panel_status),this._errorStore.updateHass(this.hass)),this._topology&&(this._areaSubscribing=!0,async function(e,t,n,s){if(!e.connection)return()=>{};const o=async()=>{try{const i=new Map;for(const[e,n]of Object.entries(t.circuits))i.set(e,n.area);await jt(e,t);for(const[e,s]of Object.entries(t.circuits))if(s.area!==i.get(e))return void n()}catch(e){console.warn("[span-panel] area registry update failed:",e),s?.add({key:"fetch:areas",level:"warning",message:i("error.areas_failed"),persistent:!1})}},[r,a]=await Promise.all([e.connection.subscribeEvents(o,"entity_registry_updated"),e.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{r(),a()}}(this.hass,this._topology,()=>{"area"===this._activeTab&&this._discovered&&this._populateCardContent()},this._errorStore).then(e=>{this._areaSubscribing?this._areaUnsub=e:e()}).catch(e=>{this._areaSubscribing=!1,console.warn("SPAN Panel: area subscription failed",e),this._errorStore.add({key:"subscribe:area",level:"warning",message:i("error.areas_failed"),persistent:!1})})),await this.updateComplete,this._populateCardContent(),this._loadHistory(),this._ctrl.monitoringCache.fetch(this.hass,this._configEntryId).then(()=>{this._discovered&&this._ctrl.updateDOM(this._root)})))}async _discoverTopology(){if(!this.hass)return;const e=new Ke(this._errorStore);try{const t=await async function(e,t,n){if(!t)throw new Error(i("card.device_not_found"));const s={type:`${l}/panel_topology`,device_id:t},o=n?await n.callWS(e,s,{errorId:"fetch:topology"}):await e.callWS(s),r=o.panel_size??qt(o.circuits);if(!r)throw new Error(i("card.topology_error"));const a={type:"config/device_registry/list"},c=Wt((n?await n.callWS(e,a,{errorId:"fetch:topology"}):await e.callWS(a)).find(e=>e.id===t));return await jt(e,o),{topology:o,panelDevice:c,panelSize:r}}(this.hass,this._config.device_id,e);this._topology=t.topology,this._panelDevice=t.panelDevice,this._panelSize=t.panelSize}catch(t){console.error("SPAN Panel: topology fetch failed, falling back to entity discovery",t);try{const t=await async function(e,t,n){const s={type:"config/device_registry/list"},o={type:"config/entity_registry/list"},[r,a]=await Promise.all([n?n.callWS(e,s,{errorId:"fetch:topology"}):e.callWS(s),n?n.callWS(e,o,{errorId:"fetch:topology"}):e.callWS(o)]),c=Wt(r.find(e=>e.id===t));if(!c)return{topology:null,panelDevice:null,panelSize:0};const d=a.filter(e=>e.device_id===t),h=r.filter(e=>e.via_device_id===t),p=new Set(h.map(e=>e.id)),u=a.filter(e=>void 0!==e.device_id&&p.has(e.device_id)),g={},_=c.name_by_user??c.name??"";for(const t of[...d,...u]){const n=e.states[t.entity_id];if(!n)continue;const i=n.attributes,s=i.tabs;if("string"!=typeof s||!s.startsWith("tabs ["))continue;const o=s.slice(6,-1);let r;if(r=o.includes(":")?o.split(":").map(Number):[Number(o)],!r.every(Number.isFinite))continue;const a=t.unique_id.split("_");let l=null;for(let e=2;e=16&&/^[a-f0-9]+$/i.test(t)){l=t;break}}if(!l)continue;let c=("string"==typeof i.friendly_name?i.friendly_name:void 0)??t.entity_id;for(const e of[" Power"," Consumed Energy"," Produced Energy"])if(c.endsWith(e)){c=c.slice(0,-e.length);break}_&&c.startsWith(_+" ")&&(c=c.slice(_.length+1));const d=t.entity_id.replace(/^sensor\./,"").replace(/_power$/,""),h="number"==typeof i.voltage?i.voltage:2===r.length?240:120,p={power:t.entity_id,switch:`switch.${d}_breaker`,breaker_rating:`sensor.${d}_breaker_rating`};g[l]={tabs:r,name:c,voltage:h,device_type:"string"==typeof i.device_type?i.device_type:"circuit",relay_state:"string"==typeof i.relay_state?i.relay_state:"UNKNOWN",is_user_controllable:!0,breaker_rating_a:null,entities:p}}let f="";if(c.identifiers)for(const e of c.identifiers){if(!Array.isArray(e)||e.length<2)continue;const[t,n]=e;t===l&&"string"==typeof n&&(f=n)}let m=0;for(const t of d){const n=e.states[t.entity_id];if(n&&"number"==typeof n.attributes.panel_size){m=n.attributes.panel_size;break}}if(m||(m=qt(g)),!m)throw new Error(i("card.panel_size_error"));const v={};for(const t of h){const n=a.filter(e=>e.device_id===t.id),i=(t.model??"").toLowerCase(),s=i.includes("battery")||(t.identifiers??[]).some(e=>e[1].toLowerCase().includes("bess")),o=i.includes("drive")||(t.identifiers??[]).some(e=>e[1].toLowerCase().includes("evse")),r={};for(const t of n){const n=t.entity_id.split(".")[0],i=e.states[t.entity_id],s=i?.attributes?.friendly_name;r[t.entity_id]={domain:n??"",original_name:"string"==typeof s?s:t.entity_id}}v[t.id]={name:t.name_by_user??t.name??"",type:s?"bess":o?"evse":"unknown",entities:r}}const b={serial:f,firmware:c.sw_version??"",panel_size:m,device_id:t,device_name:c.name_by_user??c.name??i("header.default_name"),circuits:g,sub_devices:v};return await jt(e,b),{topology:b,panelDevice:c,panelSize:m}}(this.hass,this._config.device_id,e);this._topology=t.topology,this._panelDevice=t.panelDevice,this._panelSize=t.panelSize}catch(e){console.error("SPAN Panel: fallback discovery also failed",e),this._errorStore.add({key:"discovery-failed",level:"error",message:i("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._startDiscovery()}})}}}async _loadHistory(){if(!this._historyLoaded&&this._topology&&this.hass){this._historyLoaded=!0,await this._ctrl.fetchAndBuildHorizonMaps();try{await this._ctrl.loadHistory(),this._ctrl.updateDOM(this._root)}catch(e){console.warn("SPAN Panel: history fetch failed, charts will populate live",e)}}}_populateCardContent(){const e=this._root.querySelector("#card-content");if(!(e&&this.hass&&this._topology&&this._panelSize))return;const t=this._root.querySelector("#card-tabs");if(t){const e=[{id:"panel",label:i("tab.by_panel"),icon:"mdi:view-dashboard"},{id:"activity",label:i("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:i("tab.by_area"),icon:"mdi:home-group"}];t.innerHTML=(n=e,s=this._activeTab,o=this._config.tab_style??"text",`
${n.map(e=>{const t=e.id===s?" active":"",n=Ie(e.id);return"icon"===o?``:``}).join("")}
`),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._tabBarCleanup=function(e,t){const n=e=>{const n=e.target.closest(".shared-tab");if(n){const e=n.dataset.tab;e&&t(e)}};return e.addEventListener("click",n),()=>{e.removeEventListener("click",n)}}(t,e=>{["panel","activity","area"].includes(e)&&(this._activeTab=e,this._listCtrl.stop(),this._populateCardContent())})}var n,s,o;if("panel"===this._activeTab){const t=Math.ceil(this._panelSize/2),n=Oe(this._topology,this._config),s=this._ctrl.monitoringCache.status,o=function(e){if(!e)return"";const t=Object.values(e.circuits??{}),n=Object.values(e.mains??{}),s=[...t,...n],o=s.filter(e=>void 0!==e.utilization_pct&&e.utilization_pct>=80&&e.utilization_pct<100).length,r=s.filter(e=>void 0!==e.utilization_pct&&e.utilization_pct>=100).length,a=s.filter(e=>e.has_override).length;return`\n
\n ✓ ${i("status.monitoring")} · ${t.length} ${i("status.circuits")} · ${n.length} ${i("status.mains")}\n \n ${o>0?`${o} ${i(o>1?"status.warnings":"status.warning")}`:""}\n ${r>0?`${r} ${i(r>1?"status.alerts":"status.alert")}`:""}\n ${a>0?`${a} ${i(a>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(s),r=function(e,t,n,i,s){const o=new Map,r=new Set;for(const[t,n]of Object.entries(e.circuits)){const e=n.tabs;if(!e||0===e.length)continue;const i=Math.min(...e),s=1===e.length?"single":Be(e)??"single";o.set(i,{uuid:t,circuit:n,layout:s});for(const t of e)r.add(t)}const a=new Set,l=new Set;for(const[e,t]of o)if("col-span"===t.layout){const n=t.circuit.tabs,i=qe(Math.max(...n));0===We(e)?a.add(i):l.add(i)}function c(e){const t=e.circuit.entities?.current??e.circuit.entities?.power,i=s?Ze(s,t??""):null;let o;if(e.circuit.always_on)o="always_on";else{const t=e.circuit.entities?.select;o=t&&n.states[t]?n.states[t].state:"unknown"}return{monInfo:i,sheddingPriority:o}}let d="";for(let e=1;e<=t;e++){const t=2*e-1,s=2*e,h=o.get(t),p=o.get(s);if(d+=`
${t}
`,h&&"row-span"===h.layout){const{monInfo:t,sheddingPriority:o}=c(h);d+=tt(h.uuid,h.circuit,e,"2 / 4","row-span",n,i,t,o),d+=`
${s}
`;continue}if(!a.has(e))if(!h||"col-span"!==h.layout&&"single"!==h.layout)r.has(t)||(d+=nt(e,"2"));else{const{monInfo:t,sheddingPriority:s}=c(h);d+=tt(h.uuid,h.circuit,e,"2",h.layout,n,i,t,s)}if(!l.has(e))if(!p||"col-span"!==p.layout&&"single"!==p.layout)r.has(s)||(d+=nt(e,"3"));else{const{monInfo:t,sheddingPriority:s}=c(p);d+=tt(p.uuid,p.circuit,e,"3",p.layout,n,i,t,s)}d+=`
${s}
`}return d}(this._topology,t,this.hass,this._config,s),a=function(e,t,n){const s=!1!==n.show_battery,o=!1!==n.show_evse;if(!e.sub_devices)return"";const r=Object.entries(e.sub_devices).filter(([,e])=>!(e.type===h&&!s||e.type===p&&!o));if(0===r.length)return"";const a=r.filter(([,e])=>e.type===p).length;let l=0,c="";for(const[e,s]of r){const o=s.type===p?i("subdevice.ev_charger"):s.type===h?i("subdevice.battery"):i("subdevice.fallback"),r=lt(s),d=r?t.states[r]:void 0,u=d&&parseFloat(d.state)||0,g=s.type===h,_=s.type===p,f=g?ct(s):null,m=g?dt(s):null,v=g?ht(s):null,b=pt(s,t,n,new Set([r,f,m,v].filter(e=>null!==e))),y=ut(e,0,g,r,f,m);let w="";g?w="sub-device-bess":_&&(l++,l===a&&a%2==1&&(w="sub-device-full")),c+=`\n
\n
\n ${Ie(o)}\n ${Ie(s.name||"")}\n ${r?`${je(u)} ${Re(u)}`:""}\n \n
\n ${y}\n ${b}\n
\n `}return c}(this._topology,this.hass,this._config);e.innerHTML=`\n ${n}\n ${o}\n ${a?`
${a}
`:""}\n ${!1!==this._config.show_panel?`
${r}
`:""}\n `;const l=e.querySelector(".slide-confirm");if(l){const e=this._root.querySelector("ha-card");this._ctrl.bindSlideConfirm(l,e),e&&e.classList.add("switches-disabled")}const c=this._root.querySelector("span-side-panel");c&&(c.hass=this.hass,c.errorStore=this._errorStore),this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root),this._ctrl.setupResizeObserver(this._root,this._root.querySelector("ha-card"))}else if("activity"===this._activeTab){e.innerHTML="";const t=Oe(this._topology,this._config);this._listCtrl.setColumns(Te()),this._listCtrl.renderActivityView(e,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,t),this._ctrl.updateDOM(this._root)}else if("area"===this._activeTab){e.innerHTML="";const t=Oe(this._topology,this._config);this._listCtrl.setColumns(Te()),this._listCtrl.renderAreaView(e,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,t),this._ctrl.updateDOM(this._root)}}_onCardClick(e){if("panel"!==this._activeTab)return;const t=e.target;if(!t)return;const n=t.closest(".unit-btn");if(n)return void this._onUnitToggle(n);if(t.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,this._root);t.closest(".gear-icon")&&this._ctrl.onGearClick(e,this._root)}async _onUnitToggle(e){const t=e.dataset.unit;t&&t!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:t},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}async _onListUnitChanged(e){const t=e.detail;t&&t!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:t},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}_onGraphSettingsChanged(){this._ctrl.onGraphSettingsChanged(this._root)}_onListColumnsChanged(e){const t=e.detail;"number"!=typeof t||1!==t&&2!==t&&3!==t||"activity"!==this._activeTab&&"area"!==this._activeTab||this._populateCardContent()}_onSidePanelClosed(){this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()}_renderPreview(){const e=Jt.map(e=>le`
${e.name} @@ -67,7 +122,7 @@ const ze={attribute:!0,type:String,converter:O,reflect:!1,hasChanged:R},ke=(e=ze
- `);return re` + `);return le`
@@ -78,4 +133,4 @@ const ze={attribute:!0,type:String,converter:O,reflect:!1,hasChanged:R},ke=(e=ze
${i("card.no_device")}
- `}};Ht.styles=C("\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n ha-card {\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item ha-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob ha-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n grid-template-columns: 28px 1fr 1fr 28px;\n gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 7px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .circuit-custom-monitoring {\n border-left: 3px solid #ff9800;\n }\n\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n min-width: 70px;\n text-align: right;\n flex-shrink: 0;\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 12px;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .list-expanded-content .circuit-slot {\n border: none;\n margin: 0;\n background: none;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n"),v([Ae({attribute:!1})],Ht.prototype,"hass",void 0),v([Me()],Ht.prototype,"_config",void 0),v([Me()],Ht.prototype,"_discovered",void 0),v([Me()],Ht.prototype,"_discovering",void 0),v([Me()],Ht.prototype,"_discoveryError",void 0),v([Me()],Ht.prototype,"_topology",void 0),v([Me()],Ht.prototype,"_activeTab",void 0),Ht=v([(e=>(t,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(e,t)}):customElements.define(e,t)})("span-panel-card")],Ht);class Ot extends HTMLElement{constructor(){super(...arguments),this._config={},this._hass=null,this._panels=null,this._availableRoles=null,this._built=!1,this._panelSelect=null,this._daysInput=null,this._hoursInput=null,this._minsInput=null,this._metricSelect=null,this._checkboxes={},this._entityContainers={},this._tabStyleSelect=null}setConfig(e){this._config={...e},this._updateControls()}set hass(e){this._hass=e,this._panels?this._built||this._buildEditor():this._discoverPanels()}async _discoverPanels(){if(!this._hass)return;const e=await this._hass.callWS({type:"config/device_registry/list"});this._panels=e.filter(e=>(e.identifiers??[]).some(e=>e[0]===r)&&!e.via_device_id).map(e=>{const t=(e.identifiers??[]).find(e=>e[0]===r)?.[1]??"",n=e.name_by_user??e.name??i("editor.panel_label");return{device_id:e.id,label:`${n} (${t})`}}),this._buildEditor()}_buildEditor(){this.innerHTML="",this._built=!0;const e=document.createElement("div");e.style.padding="16px";const t="\n width: 100%;\n padding: 10px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--card-background-color, var(--secondary-background-color, #1c1c1c));\n color: var(--primary-text-color, #e0e0e0);\n font-size: 1em;\n cursor: pointer;\n appearance: auto;\n box-sizing: border-box;\n ",n="display: block; font-weight: 500; margin-bottom: 8px; color: var(--primary-text-color);",i="margin-bottom: 16px;";this._buildPanelSelector(e,t,n,i),this._buildTimeWindow(e,t,n,i),this._buildMetricSelector(e,t,n,i),this._buildTabStyleSelector(e,t,n,i),this._buildSectionCheckboxes(e,n,i),this.appendChild(e),this._populateMetricSelect(),this._config.device_id&&this._discoverAvailableRoles(this._config.device_id)}_buildPanelSelector(e,t,n,s){const o=document.createElement("div");o.style.cssText=s;const a=document.createElement("label");a.textContent=i("editor.panel_label"),a.style.cssText=n;const r=document.createElement("select");r.style.cssText=t;const c=document.createElement("option");if(c.value="",c.textContent=i("editor.select_panel"),r.appendChild(c),this._panels)for(const e of this._panels){const t=document.createElement("option");t.value=e.device_id,t.textContent=e.label,e.device_id===this._config.device_id&&(t.selected=!0),r.appendChild(t)}r.addEventListener("change",()=>{this._config={...this._config,device_id:r.value},this._fireConfigChanged(),this._discoverAvailableRoles(r.value)}),o.appendChild(a),o.appendChild(r),e.appendChild(o),this._panelSelect=r}_buildTimeWindow(e,t,n,s){const o=document.createElement("div");o.style.cssText=s;const a=document.createElement("label");a.textContent=i("editor.chart_window"),a.style.cssText=n;const r=document.createElement("div");r.style.cssText="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;";const c=t+"width: 70px; cursor: text;",l=(e,t,n,i)=>{const s=document.createElement("div");s.style.cssText="display: flex; align-items: center; gap: 6px;";const o=document.createElement("input");o.type="number",o.min=t,o.max=n,o.value=String(e),o.style.cssText=c;const a=document.createElement("span");return a.textContent=i,a.style.cssText="font-size: 0.9em; color: var(--secondary-text-color);",s.appendChild(o),s.appendChild(a),{wrap:s,input:o}},d=parseInt(String(this._config.history_days))||0,h=parseInt(String(this._config.history_hours))||0,p=parseInt(String(this._config.history_minutes))||0,u=l(d,"0","30",i("editor.days")),g=l(h,"0","23",i("editor.hours")),_=l(p,"0","59",i("editor.minutes")),f=()=>{this._config={...this._config,history_days:parseInt(u.input.value)||0,history_hours:parseInt(g.input.value)||0,history_minutes:parseInt(_.input.value)||0},this._fireConfigChanged()};u.input.addEventListener("change",f),g.input.addEventListener("change",f),_.input.addEventListener("change",f),r.appendChild(u.wrap),r.appendChild(g.wrap),r.appendChild(_.wrap),o.appendChild(a),o.appendChild(r),e.appendChild(o),this._daysInput=u.input,this._hoursInput=g.input,this._minsInput=_.input}_buildMetricSelector(e,t,n,s){const o=document.createElement("div");o.style.cssText=s;const a=document.createElement("label");a.textContent=i("editor.chart_metric"),a.style.cssText=n;const r=document.createElement("select");r.style.cssText=t,r.addEventListener("change",()=>{this._config={...this._config,chart_metric:r.value},this._fireConfigChanged()}),o.appendChild(a),o.appendChild(r),e.appendChild(o),this._metricSelect=r}_buildTabStyleSelector(e,t,n,s){const o=document.createElement("div");o.style.cssText=s;const a=document.createElement("label");a.textContent=i("editor.tab_style"),a.style.cssText=n;const r=document.createElement("select");r.style.cssText=t;const c=[{value:"text",text:i("editor.tab_style_text")},{value:"icon",text:i("editor.tab_style_icon")}];for(const e of c){const t=document.createElement("option");t.value=e.value,t.textContent=e.text,e.value===(this._config.tab_style??"text")&&(t.selected=!0),r.appendChild(t)}r.addEventListener("change",()=>{this._config={...this._config,tab_style:r.value},this._fireConfigChanged()}),o.appendChild(a),o.appendChild(r),e.appendChild(o),this._tabStyleSelect=r}_buildSectionCheckboxes(e,t,n){const s=document.createElement("div");s.style.cssText=n;const o=document.createElement("label");o.textContent=i("editor.visible_sections"),o.style.cssText=t,s.appendChild(o);const a=[{key:"show_panel",label:i("editor.panel_circuits"),subDeviceType:null},{key:"show_battery",label:i("editor.battery_bess"),subDeviceType:"bess"},{key:"show_evse",label:i("editor.ev_charger_evse"),subDeviceType:"evse"}];this._checkboxes={},this._entityContainers={};for(const e of a){const t=document.createElement("div");t.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 6px; cursor: pointer;";const n=document.createElement("input");n.type="checkbox",n.checked=!1!==this._config[e.key],n.style.cssText="width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary-color);";const i=document.createElement("span");i.textContent=e.label,i.style.cssText="font-size: 0.9em; color: var(--primary-text-color); cursor: pointer;",t.appendChild(n),t.appendChild(i),s.appendChild(t),this._checkboxes[e.key]=n;let o=null;e.subDeviceType&&(o=document.createElement("div"),o.style.cssText="padding-left: 26px;",o.style.display=n.checked?"block":"none",s.appendChild(o),this._entityContainers[e.subDeviceType]=o),n.addEventListener("change",()=>{this._config={...this._config,[e.key]:n.checked},o&&(o.style.display=n.checked?"block":"none"),this._fireConfigChanged()})}e.appendChild(s)}_isChartEntity(e,t,n){const i=(t.original_name??"").toLowerCase(),s=t.unique_id??"";if("power"===i||"battery power"===i||s.endsWith("_power"))return!0;if("bess"===n){if("battery level"===i||"battery percentage"===i||s.endsWith("_battery_level")||s.endsWith("_battery_percentage"))return!0;if("state of energy"===i||s.endsWith("_soe_kwh"))return!0;if("nameplate capacity"===i||s.endsWith("_nameplate_capacity"))return!0}return!1}_populateEntityCheckboxes(e){const t=this._config.visible_sub_entities??{};for(const[,n]of Object.entries(e)){const e=n.type?this._entityContainers[n.type]:void 0;if(e&&(e.innerHTML="",n.entities))for(const[i,s]of Object.entries(n.entities)){if("sensor"===s.domain&&this._isChartEntity(i,s,n.type??""))continue;const o=document.createElement("div");o.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 5px; cursor: pointer;";const a=document.createElement("input");a.type="checkbox",a.checked=!0===t[i],a.style.cssText="width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary-color);";const r=document.createElement("span");let c=s.original_name??i;const l=n.name??"";c.startsWith(l+" ")&&(c=c.slice(l.length+1)),r.textContent=c,r.style.cssText="font-size: 0.85em; color: var(--primary-text-color); cursor: pointer;",o.appendChild(a),o.appendChild(r),e.appendChild(o),a.addEventListener("change",()=>{const e={...this._config.visible_sub_entities??{}};a.checked?e[i]=!0:delete e[i],this._config={...this._config,visible_sub_entities:e},this._fireConfigChanged()})}}}async _discoverAvailableRoles(e){if(this._hass&&e)try{const t=await this._hass.callWS({type:`${r}/panel_topology`,device_id:e}),n=new Set;for(const e of Object.values(t.circuits??{}))for(const t of Object.keys(e.entities??{}))n.add(t);this._availableRoles=n,this._populateMetricSelect(),t.sub_devices&&this._populateEntityCheckboxes(t.sub_devices)}catch{this._availableRoles=null,this._populateMetricSelect()}}_populateMetricSelect(){const e=this._metricSelect;if(!e)return;const t=this._config.chart_metric??s;e.innerHTML="";for(const[n,i]of Object.entries(g)){if(this._availableRoles&&!this._availableRoles.has(i.entityRole))continue;const s=document.createElement("option");s.value=n,s.textContent=i.label(),n===t&&(s.selected=!0),e.appendChild(s)}}_updateControls(){if(this._panelSelect&&(this._panelSelect.value=this._config.device_id??""),this._daysInput&&(this._daysInput.value=String(parseInt(String(this._config.history_days))||0)),this._hoursInput&&(this._hoursInput.value=String(parseInt(String(this._config.history_hours))||0)),this._minsInput&&(this._minsInput.value=String(parseInt(String(this._config.history_minutes))||0)),this._metricSelect&&(this._metricSelect.value=this._config.chart_metric??s),this._checkboxes)for(const[e,t]of Object.entries(this._checkboxes))t.checked=!1!==this._config[e];this._tabStyleSelect&&(this._tabStyleSelect.value=this._config.tab_style??"text")}_fireConfigChanged(){this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}}try{customElements.get("span-panel-card-editor")||customElements.define("span-panel-card-editor",Ot)}catch{}window.customCards=window.customCards??[],window.customCards.push({type:"span-panel-card",name:"SPAN Panel",description:"Physical panel layout with live power charts matching the SPAN frontend",preview:!0}),console.warn("%c SPAN-PANEL-CARD %c v0.9.2 ","background: var(--primary-color, #4dd9af); color: var(--text-primary-color, #000); font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;"); + `}};Xt.styles=$('\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n ha-card {\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */\n .favorites-summary {\n padding: 8px 24px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n align-items: center;\n gap: 12px;\n }\n /* Override the generic .gear-icon { margin-left: auto } rule so the\n favorites gear stays flush-left instead of floating to the right of\n the flex row (same idea as .panel-identity .panel-gear does for\n real-panel headers). */\n .favorites-summary .favorites-gear {\n margin-left: 0;\n }\n /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle.\n margin-left:auto moved here from .favorites-summary-unit-toggle so the\n legend and toggle cluster together, matching the real-panel header\n layout. */\n .favorites-summary-right {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .favorites-subdevices-section {\n padding: 8px 16px 0;\n }\n\n /* Favorites view: responsive grid of per-contributing-panel status cards. */\n .favorites-panel-stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n gap: 12px;\n padding: 12px 24px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .favorites-panel-card {\n background: var(--secondary-background-color, rgba(255, 255, 255, 0.04));\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n padding: 10px 14px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .favorites-panel-card-title {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--primary-text-color);\n opacity: 0.85;\n }\n .favorites-panel-card .panel-stats {\n gap: 10px 20px;\n }\n .favorites-panel-card .stat-value {\n font-size: 1.15em;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item ha-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob ha-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n grid-template-columns: 28px 1fr 1fr 28px;\n gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 7px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .circuit-custom-monitoring {\n border-left: 3px solid #ff9800;\n }\n\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n /* Each circuit is wrapped in a .list-cell so the row + its optional\n expanded chart stay together. In single-column flex mode the cell\n just stacks naturally. In multi-column grid mode the cell becomes\n one grid item, so the chart is always in the same column as its\n row. Area headers (rendered as siblings, not inside a cell) span\n all columns via their inline "grid-column: 1 / -1". */\n .list-cell {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: grid;\n grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr));\n gap: 6px 8px;\n flex-direction: initial;\n }\n /* On narrow viewports a 2/3-column list would squeeze rows into an\n unreadable shape, so force stacking regardless of user preference. */\n @media (max-width: 599px) {\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: flex;\n flex-direction: column;\n }\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n min-width: 70px;\n text-align: right;\n flex-shrink: 0;\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n .list-row .gear-icon {\n background: transparent;\n border: none;\n padding: 2px;\n cursor: pointer;\n color: #555;\n display: inline-flex;\n align-items: center;\n }\n .list-row .gear-icon:hover {\n color: var(--primary-text-color);\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .circuit-slot.circuit-chart-only {\n border: none;\n margin: 0;\n background: none;\n padding: 8px 12px;\n min-height: 0;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n'),b([Ne({attribute:!1})],Xt.prototype,"hass",void 0),b([Me()],Xt.prototype,"_config",void 0),b([Me()],Xt.prototype,"_discovered",void 0),b([Me()],Xt.prototype,"_discovering",void 0),b([Me()],Xt.prototype,"_topology",void 0),b([Me()],Xt.prototype,"_activeTab",void 0),Xt=b([ze("span-panel-card")],Xt);class Zt extends HTMLElement{constructor(){super(...arguments),this._config={},this._hass=null,this._panels=null,this._availableRoles=null,this._built=!1,this._panelSelect=null,this._daysInput=null,this._hoursInput=null,this._minsInput=null,this._metricSelect=null,this._checkboxes={},this._entityContainers={},this._tabStyleSelect=null}setConfig(e){this._config={...e},this._updateControls()}set hass(e){this._hass=e,this._panels?this._built||this._buildEditor():this._discoverPanels()}async _discoverPanels(){if(!this._hass)return;const e=await this._hass.callWS({type:"config/device_registry/list"});this._panels=e.filter(e=>(e.identifiers??[]).some(e=>e[0]===l)&&!e.via_device_id).map(e=>{const t=(e.identifiers??[]).find(e=>e[0]===l)?.[1]??"",n=e.name_by_user??e.name??i("editor.panel_label");return{device_id:e.id,label:`${n} (${t})`}}),this._buildEditor()}_buildEditor(){this.innerHTML="",this._built=!0;const e=document.createElement("div");e.style.padding="16px";const t="\n width: 100%;\n padding: 10px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--card-background-color, var(--secondary-background-color, #1c1c1c));\n color: var(--primary-text-color, #e0e0e0);\n font-size: 1em;\n cursor: pointer;\n appearance: auto;\n box-sizing: border-box;\n ",n="display: block; font-weight: 500; margin-bottom: 8px; color: var(--primary-text-color);",i="margin-bottom: 16px;";this._buildPanelSelector(e,t,n,i),this._buildTimeWindow(e,t,n,i),this._buildMetricSelector(e,t,n,i),this._buildTabStyleSelector(e,t,n,i),this._buildSectionCheckboxes(e,n,i),this.appendChild(e),this._populateMetricSelect(),this._config.device_id&&this._discoverAvailableRoles(this._config.device_id)}_buildPanelSelector(e,t,n,s){const o=document.createElement("div");o.style.cssText=s;const r=document.createElement("label");r.textContent=i("editor.panel_label"),r.style.cssText=n;const a=document.createElement("select");a.style.cssText=t;const l=document.createElement("option");if(l.value="",l.textContent=i("editor.select_panel"),a.appendChild(l),this._panels)for(const e of this._panels){const t=document.createElement("option");t.value=e.device_id,t.textContent=e.label,e.device_id===this._config.device_id&&(t.selected=!0),a.appendChild(t)}a.addEventListener("change",()=>{this._config={...this._config,device_id:a.value},this._fireConfigChanged(),this._discoverAvailableRoles(a.value)}),o.appendChild(r),o.appendChild(a),e.appendChild(o),this._panelSelect=a}_buildTimeWindow(e,t,n,s){const o=document.createElement("div");o.style.cssText=s;const r=document.createElement("label");r.textContent=i("editor.chart_window"),r.style.cssText=n;const a=document.createElement("div");a.style.cssText="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;";const l=t+"width: 70px; cursor: text;",c=(e,t,n,i)=>{const s=document.createElement("div");s.style.cssText="display: flex; align-items: center; gap: 6px;";const o=document.createElement("input");o.type="number",o.min=t,o.max=n,o.value=String(e),o.style.cssText=l;const r=document.createElement("span");return r.textContent=i,r.style.cssText="font-size: 0.9em; color: var(--secondary-text-color);",s.appendChild(o),s.appendChild(r),{wrap:s,input:o}},d=parseInt(String(this._config.history_days))||0,h=parseInt(String(this._config.history_hours))||0,p=parseInt(String(this._config.history_minutes))||0,u=c(d,"0","30",i("editor.days")),g=c(h,"0","23",i("editor.hours")),_=c(p,"0","59",i("editor.minutes")),f=()=>{this._config={...this._config,history_days:parseInt(u.input.value)||0,history_hours:parseInt(g.input.value)||0,history_minutes:parseInt(_.input.value)||0},this._fireConfigChanged()};u.input.addEventListener("change",f),g.input.addEventListener("change",f),_.input.addEventListener("change",f),a.appendChild(u.wrap),a.appendChild(g.wrap),a.appendChild(_.wrap),o.appendChild(r),o.appendChild(a),e.appendChild(o),this._daysInput=u.input,this._hoursInput=g.input,this._minsInput=_.input}_buildMetricSelector(e,t,n,s){const o=document.createElement("div");o.style.cssText=s;const r=document.createElement("label");r.textContent=i("editor.chart_metric"),r.style.cssText=n;const a=document.createElement("select");a.style.cssText=t,a.addEventListener("change",()=>{this._config={...this._config,chart_metric:a.value},this._fireConfigChanged()}),o.appendChild(r),o.appendChild(a),e.appendChild(o),this._metricSelect=a}_buildTabStyleSelector(e,t,n,s){const o=document.createElement("div");o.style.cssText=s;const r=document.createElement("label");r.textContent=i("editor.tab_style"),r.style.cssText=n;const a=document.createElement("select");a.style.cssText=t;const l=[{value:"text",text:i("editor.tab_style_text")},{value:"icon",text:i("editor.tab_style_icon")}];for(const e of l){const t=document.createElement("option");t.value=e.value,t.textContent=e.text,e.value===(this._config.tab_style??"text")&&(t.selected=!0),a.appendChild(t)}a.addEventListener("change",()=>{this._config={...this._config,tab_style:a.value},this._fireConfigChanged()}),o.appendChild(r),o.appendChild(a),e.appendChild(o),this._tabStyleSelect=a}_buildSectionCheckboxes(e,t,n){const s=document.createElement("div");s.style.cssText=n;const o=document.createElement("label");o.textContent=i("editor.visible_sections"),o.style.cssText=t,s.appendChild(o);const r=[{key:"show_panel",label:i("editor.panel_circuits"),subDeviceType:null},{key:"show_battery",label:i("editor.battery_bess"),subDeviceType:"bess"},{key:"show_evse",label:i("editor.ev_charger_evse"),subDeviceType:"evse"}];this._checkboxes={},this._entityContainers={};for(const e of r){const t=document.createElement("div");t.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 6px; cursor: pointer;";const n=document.createElement("input");n.type="checkbox",n.checked=!1!==this._config[e.key],n.style.cssText="width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary-color);";const i=document.createElement("span");i.textContent=e.label,i.style.cssText="font-size: 0.9em; color: var(--primary-text-color); cursor: pointer;",t.appendChild(n),t.appendChild(i),s.appendChild(t),this._checkboxes[e.key]=n;let o=null;e.subDeviceType&&(o=document.createElement("div"),o.style.cssText="padding-left: 26px;",o.style.display=n.checked?"block":"none",s.appendChild(o),this._entityContainers[e.subDeviceType]=o),n.addEventListener("change",()=>{this._config={...this._config,[e.key]:n.checked},o&&(o.style.display=n.checked?"block":"none"),this._fireConfigChanged()})}e.appendChild(s)}_isChartEntity(e,t,n){const i=(t.original_name??"").toLowerCase(),s=t.unique_id??"";if("power"===i||"battery power"===i||s.endsWith("_power"))return!0;if("bess"===n){if("battery level"===i||"battery percentage"===i||s.endsWith("_battery_level")||s.endsWith("_battery_percentage"))return!0;if("state of energy"===i||s.endsWith("_soe_kwh"))return!0;if("nameplate capacity"===i||s.endsWith("_nameplate_capacity"))return!0}return!1}_populateEntityCheckboxes(e){const t=this._config.visible_sub_entities??{};for(const[,n]of Object.entries(e)){const e=n.type?this._entityContainers[n.type]:void 0;if(e&&(e.innerHTML="",n.entities))for(const[i,s]of Object.entries(n.entities)){if("sensor"===s.domain&&this._isChartEntity(i,s,n.type??""))continue;const o=document.createElement("div");o.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 5px; cursor: pointer;";const r=document.createElement("input");r.type="checkbox",r.checked=!0===t[i],r.style.cssText="width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary-color);";const a=document.createElement("span");let l=s.original_name??i;const c=n.name??"";l.startsWith(c+" ")&&(l=l.slice(c.length+1)),a.textContent=l,a.style.cssText="font-size: 0.85em; color: var(--primary-text-color); cursor: pointer;",o.appendChild(r),o.appendChild(a),e.appendChild(o),r.addEventListener("change",()=>{const e={...this._config.visible_sub_entities??{}};r.checked?e[i]=!0:delete e[i],this._config={...this._config,visible_sub_entities:e},this._fireConfigChanged()})}}}async _discoverAvailableRoles(e){if(this._hass&&e)try{const t=await this._hass.callWS({type:`${l}/panel_topology`,device_id:e}),n=new Set;for(const e of Object.values(t.circuits??{}))for(const t of Object.keys(e.entities??{}))n.add(t);this._availableRoles=n,this._populateMetricSelect(),t.sub_devices&&this._populateEntityCheckboxes(t.sub_devices)}catch{this._availableRoles=null,this._populateMetricSelect()}}_populateMetricSelect(){const e=this._metricSelect;if(!e)return;const t=this._config.chart_metric??o;e.innerHTML="";for(const[n,i]of Object.entries(_)){if(this._availableRoles&&!this._availableRoles.has(i.entityRole))continue;const s=document.createElement("option");s.value=n,s.textContent=i.label(),n===t&&(s.selected=!0),e.appendChild(s)}}_updateControls(){if(this._panelSelect&&(this._panelSelect.value=this._config.device_id??""),this._daysInput&&(this._daysInput.value=String(parseInt(String(this._config.history_days))||0)),this._hoursInput&&(this._hoursInput.value=String(parseInt(String(this._config.history_hours))||0)),this._minsInput&&(this._minsInput.value=String(parseInt(String(this._config.history_minutes))||0)),this._metricSelect&&(this._metricSelect.value=this._config.chart_metric??o),this._checkboxes)for(const[e,t]of Object.entries(this._checkboxes))t.checked=!1!==this._config[e];this._tabStyleSelect&&(this._tabStyleSelect.value=this._config.tab_style??"text")}_fireConfigChanged(){this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}}try{customElements.get("span-panel-card-editor")||customElements.define("span-panel-card-editor",Zt)}catch{}window.customCards=window.customCards??[],window.customCards.push({type:"span-panel-card",name:"SPAN Panel",description:"Physical panel layout with live power charts matching the SPAN frontend",preview:!0}),console.warn("%c SPAN-PANEL-CARD %c v0.9.4 ","background: var(--primary-color, #4dd9af); color: var(--text-primary-color, #000); font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;"); diff --git a/dist/span-panel.js b/dist/span-panel.js index 2717bb6..c5e5b8b 100644 --- a/dist/span-panel.js +++ b/dist/span-panel.js @@ -1,70 +1,144 @@ -let e="en";const t={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","sidepanel.graph_horizon_failed":"Graph horizon update failed:","sidepanel.clear_graph_horizon_failed":"Clear graph horizon failed:","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.global_default":"Global Default","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.relay_failed":"Relay toggle failed:","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.shedding_failed":"Shedding update failed:","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.monitoring_toggle_failed":"Monitoring toggle failed:","sidepanel.clear_monitoring_failed":"Clear monitoring failed:","sidepanel.save_threshold_failed":"Save threshold failed:","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.loading":"Loading...","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","sidepanel.graph_horizon_failed":"Graph horizon update failed:","sidepanel.clear_graph_horizon_failed":"Clear graph horizon failed:","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.global_default":"Predeterminado Global","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.relay_failed":"Error al cambiar relé:","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.shedding_failed":"Error al actualizar desconexción:","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.monitoring_toggle_failed":"Error al cambiar monitoreo:","sidepanel.clear_monitoring_failed":"Error al limpiar monitoreo:","sidepanel.save_threshold_failed":"Error al guardar umbral:","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.loading":"Cargando...","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","sidepanel.graph_horizon_failed":"Graph horizon update failed:","sidepanel.clear_graph_horizon_failed":"Clear graph horizon failed:","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.global_default":"Défaut Global","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.relay_failed":"Échec du basculement du relais :","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.shedding_failed":"Échec de la mise à jour du délestage :","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.monitoring_toggle_failed":"Échec du basculement de surveillance :","sidepanel.clear_monitoring_failed":"Échec de l'effacement de surveillance :","sidepanel.save_threshold_failed":"Échec de la sauvegarde du seuil :","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.loading":"Chargement...","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","sidepanel.graph_horizon_failed":"Graph horizon update failed:","sidepanel.clear_graph_horizon_failed":"Clear graph horizon failed:","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.global_default":"グローバルデフォルト","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.relay_failed":"リレー切り替え失敗:","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.shedding_failed":"シェディング更新失敗:","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.monitoring_toggle_failed":"モニタリング切り替え失敗:","sidepanel.clear_monitoring_failed":"モニタリングクリア失敗:","sidepanel.save_threshold_failed":"しきい値保存失敗:","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.loading":"読み込み中...","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","sidepanel.graph_horizon_failed":"Graph horizon update failed:","sidepanel.clear_graph_horizon_failed":"Clear graph horizon failed:","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.global_default":"Padrão Global","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.relay_failed":"Falha ao alternar relé:","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.shedding_failed":"Falha ao atualizar desligamento:","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.monitoring_toggle_failed":"Falha ao alternar monitoramento:","sidepanel.clear_monitoring_failed":"Falha ao limpar monitoramento:","sidepanel.save_threshold_failed":"Falha ao salvar limite:","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.loading":"Carregando...","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){return t[e]?.[n]??t.en?.[n]??n}const i="power",o="5m",s={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},a="span_panel",r="CLOSED",l="pv",c="bess",d="evse",h="sub_",p=500,u={power:{entityRole:"power",label:()=>n("metric.power"),unit:e=>Math.abs(e)>=1e3?"kW":"W",format:e=>{const t=Math.abs(e);return t>=1e3?(t/1e3).toFixed(1):t<10&&t>0?t.toFixed(1):String(Math.round(t))}},current:{entityRole:"current",label:()=>n("metric.current"),unit:()=>"A",format:e=>Math.abs(e).toFixed(1)}},g={soc:{entityRole:"soc",label:()=>n("metric.soc"),unit:()=>"%",format:e=>String(Math.round(e)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>n("metric.soe"),unit:()=>"kWh",format:e=>e.toFixed(1)},power:u.power},_={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>n("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>n("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>n("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>n("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>n("shedding.unknown")}},f="#ff9800";function m(e,t,n,i){var o,s=arguments.length,a=s<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(e,t,n,i);else for(var r=e.length-1;r>=0;r--)(o=e[r])&&(a=(s<3?o(a):s>3?o(t,n,a):o(t,n))||a);return s>3&&a&&Object.defineProperty(t,n,a),a}"function"==typeof SuppressedError&&SuppressedError; +let e="en";const t={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){return t[e]?.[n]??t.en?.[n]??n}function i(n,i){return(t[e]?.[n]??t.en?.[n]??n).replace(/\{(\w+)\}/g,(e,t)=>Object.prototype.hasOwnProperty.call(i,t)?i[t]:`{${t}}`)}const s="power",o="5m",r={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},a="span_panel",l="CLOSED",c="pv",d="bess",h="evse",p="sub_",u=500,g={power:{entityRole:"power",label:()=>n("metric.power"),unit:e=>Math.abs(e)>=1e3?"kW":"W",format:e=>{const t=Math.abs(e);return t>=1e3?(t/1e3).toFixed(1):t<10&&t>0?t.toFixed(1):String(Math.round(t))}},current:{entityRole:"current",label:()=>n("metric.current"),unit:()=>"A",format:e=>Math.abs(e).toFixed(1)}},_={soc:{entityRole:"soc",label:()=>n("metric.soc"),unit:()=>"%",format:e=>String(Math.round(e)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>n("metric.soe"),unit:()=>"kWh",format:e=>e.toFixed(1)},power:g.power},f={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>n("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>n("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>n("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>n("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>n("shedding.unknown")}},v="#ff9800";function m(e,t,n,i){var s,o=arguments.length,r=o<3?t:null===i?i=Object.getOwnPropertyDescriptor(t,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)r=Reflect.decorate(e,t,n,i);else for(var a=e.length-1;a>=0;a--)(s=e[a])&&(r=(o<3?s(r):o>3?s(t,n,r):s(t,n))||r);return o>3&&r&&Object.defineProperty(t,n,r),r}"function"==typeof SuppressedError&&SuppressedError; /** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const b=globalThis,v=b.ShadowRoot&&(void 0===b.ShadyCSS||b.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,y=Symbol(),w=new WeakMap;let x=class{constructor(e,t,n){if(this._$cssResult$=!0,n!==y)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o;const t=this.t;if(v&&void 0===e){const n=void 0!==t&&1===t.length;n&&(e=w.get(t)),void 0===e&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),n&&w.set(t,e))}return e}toString(){return this.cssText}};const $=v?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t="";for(const n of e.cssRules)t+=n.cssText;return(e=>new x("string"==typeof e?e:e+"",void 0,y))(t)})(e):e,{is:S,defineProperty:C,getOwnPropertyDescriptor:E,getOwnPropertyNames:k,getOwnPropertySymbols:z,getPrototypeOf:A}=Object,P=globalThis,M=P.trustedTypes,T=M?M.emptyScript:"",N=P.reactiveElementPolyfillSupport,D=(e,t)=>e,L={toAttribute(e,t){switch(t){case Boolean:e=e?T:null;break;case Object:case Array:e=null==e?e:JSON.stringify(e)}return e},fromAttribute(e,t){let n=e;switch(t){case Boolean:n=null!==e;break;case Number:n=null===e?null:Number(e);break;case Object:case Array:try{n=JSON.parse(e)}catch(e){n=null}}return n}},H=(e,t)=>!S(e,t),I={attribute:!0,type:String,converter:L,reflect:!1,useDefault:!1,hasChanged:H}; +const b=globalThis,y=b.ShadowRoot&&(void 0===b.ShadyCSS||b.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,w=Symbol(),x=new WeakMap;let S=class{constructor(e,t,n){if(this._$cssResult$=!0,n!==w)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o;const t=this.t;if(y&&void 0===e){const n=void 0!==t&&1===t.length;n&&(e=x.get(t)),void 0===e&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),n&&x.set(t,e))}return e}toString(){return this.cssText}};const $=e=>new S("string"==typeof e?e:e+"",void 0,w),C=(e,...t)=>{const n=1===e.length?e[0]:t.reduce((t,n,i)=>t+(e=>{if(!0===e._$cssResult$)return e.cssText;if("number"==typeof e)return e;throw Error("Value passed to 'css' function must be a 'css' function result: "+e+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+e[i+1],e[0]);return new S(n,e,w)},P=y?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t="";for(const n of e.cssRules)t+=n.cssText;return $(t)})(e):e,{is:k,defineProperty:E,getOwnPropertyDescriptor:z,getOwnPropertyNames:A,getOwnPropertySymbols:N,getPrototypeOf:M}=Object,I=globalThis,T=I.trustedTypes,D=T?T.emptyScript:"",F=I.reactiveElementPolyfillSupport,L=(e,t)=>e,H={toAttribute(e,t){switch(t){case Boolean:e=e?D:null;break;case Object:case Array:e=null==e?e:JSON.stringify(e)}return e},fromAttribute(e,t){let n=e;switch(t){case Boolean:n=null!==e;break;case Number:n=null===e?null:Number(e);break;case Object:case Array:try{n=JSON.parse(e)}catch(e){n=null}}return n}},O=(e,t)=>!k(e,t),R={attribute:!0,type:String,converter:H,reflect:!1,useDefault:!1,hasChanged:O}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */Symbol.metadata??=Symbol("metadata"),P.litPropertyMetadata??=new WeakMap;let O=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=I){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(e,n,t);void 0!==i&&C(this.prototype,e,i)}}static getPropertyDescriptor(e,t,n){const{get:i,set:o}=E(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:i,set(t){const s=i?.call(this);o?.call(this,t),this.requestUpdate(e,s,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??I}static _$Ei(){if(this.hasOwnProperty(D("elementProperties")))return;const e=A(this);e.finalize(),void 0!==e.l&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(D("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(D("properties"))){const e=this.properties,t=[...k(e),...z(e)];for(const n of t)this.createProperty(n,e[n])}const e=this[Symbol.metadata];if(null!==e){const t=litPropertyMetadata.get(e);if(void 0!==t)for(const[e,n]of t)this.elementProperties.set(e,n)}this._$Eh=new Map;for(const[e,t]of this.elementProperties){const n=this._$Eu(e,t);void 0!==n&&this._$Eh.set(n,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){const t=[];if(Array.isArray(e)){const n=new Set(e.flat(1/0).reverse());for(const e of n)t.unshift($(e))}else void 0!==e&&t.push($(e));return t}static _$Eu(e,t){const n=t.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof e?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),void 0!==this.renderRoot&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){const e=new Map,t=this.constructor.elementProperties;for(const n of t.keys())this.hasOwnProperty(n)&&(e.set(n,this[n]),delete this[n]);e.size>0&&(this._$Ep=e)}createRenderRoot(){const e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((e,t)=>{if(v)e.adoptedStyleSheets=t.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(const n of t){const t=document.createElement("style"),i=b.litNonce;void 0!==i&&t.setAttribute("nonce",i),t.textContent=n.cssText,e.appendChild(t)}})(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,n){this._$AK(e,n)}_$ET(e,t){const n=this.constructor.elementProperties.get(e),i=this.constructor._$Eu(e,n);if(void 0!==i&&!0===n.reflect){const o=(void 0!==n.converter?.toAttribute?n.converter:L).toAttribute(t,n.type);this._$Em=e,null==o?this.removeAttribute(i):this.setAttribute(i,o),this._$Em=null}}_$AK(e,t){const n=this.constructor,i=n._$Eh.get(e);if(void 0!==i&&this._$Em!==i){const e=n.getPropertyOptions(i),o="function"==typeof e.converter?{fromAttribute:e.converter}:void 0!==e.converter?.fromAttribute?e.converter:L;this._$Em=i;const s=o.fromAttribute(t,e.type);this[i]=s??this._$Ej?.get(i)??s,this._$Em=null}}requestUpdate(e,t,n,i=!1,o){if(void 0!==e){const s=this.constructor;if(!1===i&&(o=this[e]),n??=s.getPropertyOptions(e),!((n.hasChanged??H)(o,t)||n.useDefault&&n.reflect&&o===this._$Ej?.get(e)&&!this.hasAttribute(s._$Eu(e,n))))return;this.C(e,t,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:n,reflect:i,wrapped:o},s){n&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,s??t??this[e]),!0!==o||void 0!==s)||(this._$AL.has(e)||(this.hasUpdated||n||(t=void 0),this._$AL.set(e,t)),!0===i&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}const e=this.scheduleUpdate();return null!=e&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}const e=this.constructor.elementProperties;if(e.size>0)for(const[t,n]of e){const{wrapped:e}=n,i=this[t];!0!==e||this._$AL.has(t)||void 0===i||this.C(t,void 0,n,i)}}let e=!1;const t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(t)):this._$EM()}catch(t){throw e=!1,this._$EM(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};O.elementStyles=[],O.shadowRootOptions={mode:"open"},O[D("elementProperties")]=new Map,O[D("finalized")]=new Map,N?.({ReactiveElement:O}),(P.reactiveElementVersions??=[]).push("2.1.2"); + */Symbol.metadata??=Symbol("metadata"),I.litPropertyMetadata??=new WeakMap;let q=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=R){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(e,n,t);void 0!==i&&E(this.prototype,e,i)}}static getPropertyDescriptor(e,t,n){const{get:i,set:s}=z(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:i,set(t){const o=i?.call(this);s?.call(this,t),this.requestUpdate(e,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??R}static _$Ei(){if(this.hasOwnProperty(L("elementProperties")))return;const e=M(this);e.finalize(),void 0!==e.l&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(L("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(L("properties"))){const e=this.properties,t=[...A(e),...N(e)];for(const n of t)this.createProperty(n,e[n])}const e=this[Symbol.metadata];if(null!==e){const t=litPropertyMetadata.get(e);if(void 0!==t)for(const[e,n]of t)this.elementProperties.set(e,n)}this._$Eh=new Map;for(const[e,t]of this.elementProperties){const n=this._$Eu(e,t);void 0!==n&&this._$Eh.set(n,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){const t=[];if(Array.isArray(e)){const n=new Set(e.flat(1/0).reverse());for(const e of n)t.unshift(P(e))}else void 0!==e&&t.push(P(e));return t}static _$Eu(e,t){const n=t.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof e?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),void 0!==this.renderRoot&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){const e=new Map,t=this.constructor.elementProperties;for(const n of t.keys())this.hasOwnProperty(n)&&(e.set(n,this[n]),delete this[n]);e.size>0&&(this._$Ep=e)}createRenderRoot(){const e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((e,t)=>{if(y)e.adoptedStyleSheets=t.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(const n of t){const t=document.createElement("style"),i=b.litNonce;void 0!==i&&t.setAttribute("nonce",i),t.textContent=n.cssText,e.appendChild(t)}})(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,n){this._$AK(e,n)}_$ET(e,t){const n=this.constructor.elementProperties.get(e),i=this.constructor._$Eu(e,n);if(void 0!==i&&!0===n.reflect){const s=(void 0!==n.converter?.toAttribute?n.converter:H).toAttribute(t,n.type);this._$Em=e,null==s?this.removeAttribute(i):this.setAttribute(i,s),this._$Em=null}}_$AK(e,t){const n=this.constructor,i=n._$Eh.get(e);if(void 0!==i&&this._$Em!==i){const e=n.getPropertyOptions(i),s="function"==typeof e.converter?{fromAttribute:e.converter}:void 0!==e.converter?.fromAttribute?e.converter:H;this._$Em=i;const o=s.fromAttribute(t,e.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(e,t,n,i=!1,s){if(void 0!==e){const o=this.constructor;if(!1===i&&(s=this[e]),n??=o.getPropertyOptions(e),!((n.hasChanged??O)(s,t)||n.useDefault&&n.reflect&&s===this._$Ej?.get(e)&&!this.hasAttribute(o._$Eu(e,n))))return;this.C(e,t,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:n,reflect:i,wrapped:s},o){n&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,o??t??this[e]),!0!==s||void 0!==o)||(this._$AL.has(e)||(this.hasUpdated||n||(t=void 0),this._$AL.set(e,t)),!0===i&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}const e=this.scheduleUpdate();return null!=e&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}const e=this.constructor.elementProperties;if(e.size>0)for(const[t,n]of e){const{wrapped:e}=n,i=this[t];!0!==e||this._$AL.has(t)||void 0===i||this.C(t,void 0,n,i)}}let e=!1;const t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(t)):this._$EM()}catch(t){throw e=!1,this._$EM(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};q.elementStyles=[],q.shadowRootOptions={mode:"open"},q[L("elementProperties")]=new Map,q[L("finalized")]=new Map,F?.({ReactiveElement:q}),(I.reactiveElementVersions??=[]).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const R=globalThis,q=e=>e,j=R.trustedTypes,U=j?j.createPolicy("lit-html",{createHTML:e=>e}):void 0,G="$lit$",F=`lit$${Math.random().toFixed(9).slice(2)}$`,W="?"+F,B=`<${W}>`,V=document,Q=()=>V.createComment(""),J=e=>null===e||"object"!=typeof e&&"function"!=typeof e,X=Array.isArray,K="[ \t\n\f\r]",Z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,Y=/-->/g,ee=/>/g,te=RegExp(`>|${K}(?:([^\\s"'>=/]+)(${K}*=${K}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),ne=/'/g,ie=/"/g,oe=/^(?:script|style|textarea|title)$/i,se=(e=>(t,...n)=>({_$litType$:e,strings:t,values:n}))(1),ae=Symbol.for("lit-noChange"),re=Symbol.for("lit-nothing"),le=new WeakMap,ce=V.createTreeWalker(V,129);function de(e,t){if(!X(e)||!e.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==U?U.createHTML(t):t}const he=(e,t)=>{const n=e.length-1,i=[];let o,s=2===t?"":3===t?"":"",a=Z;for(let t=0;t"===l[0]?(a=o??Z,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,r=l[1],a=void 0===l[3]?te:'"'===l[3]?ie:ne):a===ie||a===ne?a=te:a===Y||a===ee?a=Z:(a=te,o=void 0);const h=a===te&&e[t+1].startsWith("/>")?" ":"";s+=a===Z?n+B:c>=0?(i.push(r),n.slice(0,c)+G+n.slice(c)+F+h):n+F+(-2===c?t:h)}return[de(e,s+(e[n]||"")+(2===t?"":3===t?"":"")),i]};class pe{constructor({strings:e,_$litType$:t},n){let i;this.parts=[];let o=0,s=0;const a=e.length-1,r=this.parts,[l,c]=he(e,t);if(this.el=pe.createElement(l,n),ce.currentNode=this.el.content,2===t||3===t){const e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;null!==(i=ce.nextNode())&&r.length0){i.textContent=j?j.emptyScript:"";for(let n=0;nX(e)||"function"==typeof e?.[Symbol.iterator])(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==re&&J(this._$AH)?this._$AA.nextSibling.data=e:this.T(V.createTextNode(e)),this._$AH=e}$(e){const{values:t,_$litType$:n}=e,i="number"==typeof n?this._$AC(e):(void 0===n.el&&(n.el=pe.createElement(de(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(t);else{const e=new ge(i,this),n=e.u(this.options);e.p(t),this.T(n),this._$AH=e}}_$AC(e){let t=le.get(e.strings);return void 0===t&&le.set(e.strings,t=new pe(e)),t}k(e){X(this._$AH)||(this._$AH=[],this._$AR());const t=this._$AH;let n,i=0;for(const o of e)i===t.length?t.push(n=new _e(this.O(Q()),this.O(Q()),this,this.options)):n=t[i],n._$AI(o),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=re}_$AI(e,t=this,n,i){const o=this.strings;let s=!1;if(void 0===o)e=ue(this,e,t,0),s=!J(e)||e!==this._$AH&&e!==ae,s&&(this._$AH=e);else{const i=e;let a,r;for(e=o[0],a=0;ae,W=j.trustedTypes,G=W?W.createPolicy("lit-html",{createHTML:e=>e}):void 0,V="$lit$",B=`lit$${Math.random().toFixed(9).slice(2)}$`,Q="?"+B,K=`<${Q}>`,J=document,X=()=>J.createComment(""),Z=e=>null===e||"object"!=typeof e&&"function"!=typeof e,Y=Array.isArray,ee="[ \t\n\f\r]",te=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,ne=/-->/g,ie=/>/g,se=RegExp(`>|${ee}(?:([^\\s"'>=/]+)(${ee}*=${ee}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),oe=/'/g,re=/"/g,ae=/^(?:script|style|textarea|title)$/i,le=(e=>(t,...n)=>({_$litType$:e,strings:t,values:n}))(1),ce=Symbol.for("lit-noChange"),de=Symbol.for("lit-nothing"),he=new WeakMap,pe=J.createTreeWalker(J,129);function ue(e,t){if(!Y(e)||!e.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==G?G.createHTML(t):t}const ge=(e,t)=>{const n=e.length-1,i=[];let s,o=2===t?"":3===t?"":"",r=te;for(let t=0;t"===l[0]?(r=s??te,c=-1):void 0===l[1]?c=-2:(c=r.lastIndex-l[2].length,a=l[1],r=void 0===l[3]?se:'"'===l[3]?re:oe):r===re||r===oe?r=se:r===ne||r===ie?r=te:(r=se,s=void 0);const h=r===se&&e[t+1].startsWith("/>")?" ":"";o+=r===te?n+K:c>=0?(i.push(a),n.slice(0,c)+V+n.slice(c)+B+h):n+B+(-2===c?t:h)}return[ue(e,o+(e[n]||"")+(2===t?"":3===t?"":"")),i]};class _e{constructor({strings:e,_$litType$:t},n){let i;this.parts=[];let s=0,o=0;const r=e.length-1,a=this.parts,[l,c]=ge(e,t);if(this.el=_e.createElement(l,n),pe.currentNode=this.el.content,2===t||3===t){const e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;null!==(i=pe.nextNode())&&a.length0){i.textContent=W?W.emptyScript:"";for(let n=0;nY(e)||"function"==typeof e?.[Symbol.iterator])(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==de&&Z(this._$AH)?this._$AA.nextSibling.data=e:this.T(J.createTextNode(e)),this._$AH=e}$(e){const{values:t,_$litType$:n}=e,i="number"==typeof n?this._$AC(e):(void 0===n.el&&(n.el=_e.createElement(ue(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(t);else{const e=new ve(i,this),n=e.u(this.options);e.p(t),this.T(n),this._$AH=e}}_$AC(e){let t=he.get(e.strings);return void 0===t&&he.set(e.strings,t=new _e(e)),t}k(e){Y(this._$AH)||(this._$AH=[],this._$AR());const t=this._$AH;let n,i=0;for(const s of e)i===t.length?t.push(n=new me(this.O(X()),this.O(X()),this,this.options)):n=t[i],n._$AI(s),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=de}_$AI(e,t=this,n,i){const s=this.strings;let o=!1;if(void 0===s)e=fe(this,e,t,0),o=!Z(e)||e!==this._$AH&&e!==ce,o&&(this._$AH=e);else{const i=e;let r,a;for(e=s[0],r=0;r{const i=n?.renderBefore??t;let o=i._$litPart$;if(void 0===o){const e=n?.renderBefore??null;i._$litPart$=o=new _e(t.insertBefore(Q(),e),e,void 0,n??{})}return o._$AI(e),o})(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return ae}};$e._$litElement$=!0,$e.finalized=!0,xe.litElementHydrateSupport?.({LitElement:$e});const Se=xe.litElementPolyfillSupport;Se?.({LitElement:$e}),(xe.litElementVersions??=[]).push("4.2.2"); + */let Pe=class extends q{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const e=super.createRenderRoot();return this.renderOptions.renderBefore??=e.firstChild,e}update(e){const t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=((e,t,n)=>{const i=n?.renderBefore??t;let s=i._$litPart$;if(void 0===s){const e=n?.renderBefore??null;i._$litPart$=s=new me(t.insertBefore(X(),e),e,void 0,n??{})}return s._$AI(e),s})(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return ce}};Pe._$litElement$=!0,Pe.finalized=!0,Ce.litElementHydrateSupport?.({LitElement:Pe});const ke=Ce.litElementPolyfillSupport;ke?.({LitElement:Pe}),(Ce.litElementVersions??=[]).push("4.2.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const Ce={attribute:!0,type:String,converter:L,reflect:!1,hasChanged:H},Ee=(e=Ce,t,n)=>{const{kind:i,metadata:o}=n;let s=globalThis.litPropertyMetadata.get(o);if(void 0===s&&globalThis.litPropertyMetadata.set(o,s=new Map),"setter"===i&&((e=Object.create(e)).wrapped=!0),s.set(n.name,e),"accessor"===i){const{name:i}=n;return{set(n){const o=t.get.call(this);t.set.call(this,n),this.requestUpdate(i,o,e,!0,n)},init(t){return void 0!==t&&this.C(i,void 0,e,t),t}}}if("setter"===i){const{name:i}=n;return function(n){const o=this[i];t.call(this,n),this.requestUpdate(i,o,e,!0,n)}}throw Error("Unsupported decorator location: "+i)}; +const Ee=e=>(t,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(e,t)}):customElements.define(e,t)},ze={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:O},Ae=(e=ze,t,n)=>{const{kind:i,metadata:s}=n;let o=globalThis.litPropertyMetadata.get(s);if(void 0===o&&globalThis.litPropertyMetadata.set(s,o=new Map),"setter"===i&&((e=Object.create(e)).wrapped=!0),o.set(n.name,e),"accessor"===i){const{name:i}=n;return{set(n){const s=t.get.call(this);t.set.call(this,n),this.requestUpdate(i,s,e,!0,n)},init(t){return void 0!==t&&this.C(i,void 0,e,t),t}}}if("setter"===i){const{name:i}=n;return function(n){const s=this[i];t.call(this,n),this.requestUpdate(i,s,e,!0,n)}}throw Error("Unsupported decorator location: "+i)}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function ke(e){return(t,n)=>"object"==typeof n?Ee(e,t,n):((e,t,n)=>{const i=t.hasOwnProperty(n);return t.constructor.createProperty(n,e),i?Object.getOwnPropertyDescriptor(t,n):void 0})(e,t,n)} + */function Ne(e){return(t,n)=>"object"==typeof n?Ae(e,t,n):((e,t,n)=>{const i=t.hasOwnProperty(n);return t.constructor.createProperty(n,e),i?Object.getOwnPropertyDescriptor(t,n):void 0})(e,t,n)} /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function ze(e){return ke({...e,state:!0,attribute:!1})} + */function Me(e){return Ne({...e,state:!0,attribute:!1})} /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */const Ae=2;class Pe{constructor(e){}get _$AU(){return this._$AM._$AU}_$AT(e,t,n){this._$Ct=e,this._$AM=t,this._$Ci=n}_$AS(e,t){return this.update(e,t)}update(e,t){return this.render(...t)}} + */const Ie=2;class Te{constructor(e){}get _$AU(){return this._$AM._$AU}_$AT(e,t,n){this._$Ct=e,this._$AM=t,this._$Ci=n}_$AS(e,t){return this.update(e,t)}update(e,t){return this.render(...t)}} /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */class Me extends Pe{constructor(e){if(super(e),this.it=re,e.type!==Ae)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(e){if(e===re||null==e)return this._t=void 0,this.it=e;if(e===ae)return e;if("string"!=typeof e)throw Error(this.constructor.directiveName+"() called with a non-string value");if(e===this.it)return this._t;this.it=e;const t=[e];return t.raw=t,this._t={_$litType$:this.constructor.resultType,strings:t,values:[]}}}Me.directiveName="unsafeHTML",Me.resultType=1;const Te=(e=>(...t)=>({_$litDirective$:e,values:t}))(Me),Ne={"&":"&","<":"<",">":">",'"':""","'":"'"};function De(e){return String(e).replace(/[&<>"']/g,e=>Ne[e]??e)}const Le=Object.keys(_).filter(e=>"unknown"!==e&&"always_on"!==e);class He extends HTMLElement{constructor(){super(),this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(e){this._hass=e,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}open(e){this._config=e,this._render(),this.offsetHeight,this.setAttribute("open","")}close(){this.removeAttribute("open"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_render(){const e=this._config;if(!e)return;const t=this.shadowRoot;if(!t)return;t.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n .error-msg {\n color: var(--error-color, #f44336);\n font-size: 0.8em;\n padding: 8px;\n margin: 8px 0;\n background: rgba(244, 67, 54, 0.1);\n border-radius: 4px;\n }\n',t.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),t.appendChild(i);const o=document.createElement("div");o.className="panel",t.appendChild(o),e.panelMode?this._renderPanelMode(o):e.subDeviceMode?this._renderSubDeviceMode(o,e):this._renderCircuitMode(o,e)}_renderPanelMode(e){const t=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.global_defaults"));e.appendChild(i);const a=document.createElement("div");a.className="panel-body";const r=document.createElement("div");r.className="error-msg",r.id="error-msg",r.style.display="none",a.appendChild(r);const l=t.graphSettings,c=t.topology,d=l?.global_horizon??o,h=l?.circuits??{},u=document.createElement("div");u.className="section";const g=document.createElement("div");g.className="section-label",g.textContent=n("sidepanel.graph_horizon"),u.appendChild(g);const _=document.createElement("div");_.className="field-row";const f=document.createElement("span");f.className="field-label",f.textContent=n("sidepanel.global_default"),_.appendChild(f);const m=document.createElement("select");for(const e of Object.keys(s)){const t=document.createElement("option");t.value=e;const i=`horizon.${e}`,o=n(i);t.textContent=o!==i?o:e,e===d&&(t.selected=!0),m.appendChild(t)}if(m.addEventListener("change",()=>{this._callDomainService("set_graph_time_horizon",{horizon:m.value}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${e.message??e}`))}),_.appendChild(m),u.appendChild(_),a.appendChild(u),c?.circuits){const e=document.createElement("div");e.className="section";const t=document.createElement("div");t.className="section-label",t.textContent=n("sidepanel.circuit_scales"),e.appendChild(t);const i=Object.entries(c.circuits).sort(([,e],[,t])=>(e.name||"").localeCompare(t.name||""));for(const[t,o]of i){const i=document.createElement("div");i.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=o.name||t,a.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",i.appendChild(a);const r=h[t]||{horizon:d,has_override:!1},l=r.has_override?r.horizon:d,c=document.createElement("select");c.dataset.uuid=t;for(const e of Object.keys(s)){const t=document.createElement("option");t.value=e;const i=`horizon.${e}`,o=n(i);t.textContent=o!==i?o:e,e===l&&(t.selected=!0),c.appendChild(t)}if(c.addEventListener("change",()=>{this._debounce(`circuit-${t}`,p,()=>{this._callDomainService("set_circuit_graph_horizon",{circuit_id:t,horizon:c.value}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${e.message??e}`))})}),i.appendChild(c),r.has_override){const e=document.createElement("button");e.textContent="↺",e.title=n("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{this._callDomainService("clear_circuit_graph_horizon",{circuit_id:t}).then(()=>{c.value=d,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${e.message??e}`))}),i.appendChild(e)}e.appendChild(i)}a.appendChild(e)}const b=l?.sub_devices??{};if(c?.sub_devices){const e=document.createElement("div");e.className="section";const t=document.createElement("div");t.className="section-label",t.textContent=n("sidepanel.subdevice_scales"),e.appendChild(t);const i=Object.entries(c.sub_devices).sort(([,e],[,t])=>(e.name||"").localeCompare(t.name||""));for(const[t,o]of i){const i=document.createElement("div");i.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=o.name||t,a.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",i.appendChild(a);const r=b[t]||{horizon:d,has_override:!1},l=r.has_override?r.horizon:d,c=document.createElement("select");c.dataset.subdevId=t;for(const e of Object.keys(s)){const t=document.createElement("option");t.value=e;const i=`horizon.${e}`,o=n(i);t.textContent=o!==i?o:e,e===l&&(t.selected=!0),c.appendChild(t)}if(c.addEventListener("change",()=>{this._debounce(`subdev-${t}`,p,()=>{this._callDomainService("set_subdevice_graph_horizon",{subdevice_id:t,horizon:c.value}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${e.message??e}`))})}),i.appendChild(c),r.has_override){const e=document.createElement("button");e.textContent="↺",e.title=n("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{this._callDomainService("clear_subdevice_graph_horizon",{subdevice_id:t}).then(()=>{c.value=d,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${e.message??e}`))}),i.appendChild(e)}e.appendChild(i)}a.appendChild(e)}e.appendChild(a)}_renderCircuitMode(e,t){const n=`${De(String(t.breaker_rating_a))}A · ${De(String(t.voltage))}V · Tabs [${De(String(t.tabs))}]`,i=this._createHeader(De(t.name),n);e.appendChild(i);const o=document.createElement("div");o.className="panel-body",e.appendChild(o);const s=document.createElement("div");s.className="error-msg",s.id="error-msg",s.style.display="none",o.appendChild(s),this._renderRelaySection(o,t),this._renderSheddingSection(o,t),this._renderGraphHorizonSection(o,t),t.showMonitoring&&this._renderMonitoringSection(o,t)}_renderSubDeviceMode(e,t){const n=this._createHeader(De(t.name),De(t.deviceType));e.appendChild(n);const i=document.createElement("div");i.className="panel-body",e.appendChild(i);const o=document.createElement("div");o.className="error-msg",o.id="error-msg",o.style.display="none",i.appendChild(o),this._renderSubDeviceHorizonSection(i,t)}_renderSubDeviceHorizonSection(e,t){const i=document.createElement("div");i.className="section";const a=document.createElement("div");a.className="section-label",a.textContent=n("sidepanel.graph_horizon"),i.appendChild(a);const r=t.graphHorizonInfo,l=!0===r?.has_override,c=r?.horizon||o,d=r?.globalHorizon||o,h=document.createElement("div");h.className="horizon-bar";const p=[{key:"global",label:n("sidepanel.global")}];for(const e of Object.keys(s))p.push({key:e,label:e});const u=l?c:"global",g=e=>{for(const t of h.querySelectorAll(".horizon-segment")){const n=t.dataset.horizon;t.classList.toggle("active",n===e),t.classList.toggle("referenced","global"===e&&n===d)}};for(const{key:e,label:i}of p){const o=document.createElement("button");o.type="button",o.className="horizon-segment",o.dataset.horizon=e,o.textContent=i,o.classList.toggle("active",e===u),o.classList.toggle("referenced","global"===u&&e===d),o.addEventListener("click",()=>{if(o.classList.contains("active"))return;const i=t.subDeviceId;"global"===e?(g("global"),this._callDomainService("clear_subdevice_graph_horizon",{subdevice_id:i}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${n("sidepanel.clear_graph_horizon_failed")} ${e.message??e}`))):(g(e),this._callDomainService("set_subdevice_graph_horizon",{subdevice_id:i,horizon:e}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${n("sidepanel.graph_horizon_failed")} ${e.message??e}`)))}),h.appendChild(o)}i.appendChild(h),e.appendChild(i)}_createHeader(e,t){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),o=De(e),s=De(t);i.innerHTML=`
${o}
`+(s?`
${s}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(e,t){if(!1===t.is_user_controllable||!t.entities?.switch)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const o=document.createElement("div");o.className="field-row";const s=document.createElement("span");s.className="field-label",s.textContent=n("sidepanel.breaker");const a=document.createElement("ha-switch");a.dataset.role="relay-toggle";const r=t.entities.switch,l=this._hass?.states?.[r]?.state;"on"===l&&a.setAttribute("checked",""),a.addEventListener("change",()=>{const e=a.hasAttribute("checked")||a.checked;this._callService("switch",e?"turn_on":"turn_off",{entity_id:r}).catch(e=>this._showError(`${n("sidepanel.relay_failed")} ${e.message??e}`))}),o.appendChild(s),o.appendChild(a),i.appendChild(o),e.appendChild(i)}_renderSheddingSection(e,t){if(!t.entities?.select)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const o=document.createElement("div");o.className="field-row";const s=document.createElement("span");s.className="field-label",s.textContent=n("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const r=t.entities.select,l=this._hass?.states?.[r]?.state||"";for(const e of Le){const t=_[e];if(!t)continue;const i=document.createElement("option");i.value=e,i.textContent=n(`shedding.select.${e}`)||t.label(),e===l&&(i.selected=!0),a.appendChild(i)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:r,option:a.value}).catch(e=>this._showError(`${n("sidepanel.shedding_failed")} ${e.message??e}`))}),o.appendChild(s),o.appendChild(a),i.appendChild(o),e.appendChild(i)}_renderGraphHorizonSection(e,t){const i=document.createElement("div");i.className="section";const a=document.createElement("div");a.className="section-label",a.textContent=n("sidepanel.graph_horizon"),i.appendChild(a);const r=t.graphHorizonInfo,l=!0===r?.has_override,c=r?.horizon||o,d=r?.globalHorizon||o,h=document.createElement("div");h.className="horizon-bar";const p=[{key:"global",label:n("sidepanel.global")}];for(const e of Object.keys(s))p.push({key:e,label:e});const u=l?c:"global",g=e=>{for(const t of h.querySelectorAll(".horizon-segment")){const n=t.dataset.horizon;t.classList.toggle("active",n===e),t.classList.toggle("referenced","global"===e&&n===d)}};for(const{key:e,label:i}of p){const o=document.createElement("button");o.type="button",o.className="horizon-segment",o.dataset.horizon=e,o.textContent=i,o.classList.toggle("active",e===u),o.classList.toggle("referenced","global"===u&&e===d),o.addEventListener("click",()=>{if(o.classList.contains("active"))return;const i=t.uuid;"global"===e?(g("global"),this._callDomainService("clear_circuit_graph_horizon",{circuit_id:i}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${n("sidepanel.clear_graph_horizon_failed")} ${e.message??e}`))):(g(e),this._callDomainService("set_circuit_graph_horizon",{circuit_id:i,horizon:e}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${n("sidepanel.graph_horizon_failed")} ${e.message??e}`)))}),h.appendChild(o)}i.appendChild(h),e.appendChild(i)}_renderMonitoringSection(e,t){const i=document.createElement("div");i.className="section";const o=document.createElement("div");o.className="monitoring-header";const s=document.createElement("div");s.className="section-label",s.textContent=n("sidepanel.monitoring"),s.style.margin="0";const a=document.createElement("ha-switch");a.dataset.role="monitoring-toggle";const r=t.monitoringInfo,l=null!=r&&!1!==r.monitoring_enabled;l&&a.setAttribute("checked",""),o.appendChild(s),o.appendChild(a),i.appendChild(o);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",i.appendChild(c);const d=!0===r?.has_override,h=document.createElement("div");h.className="radio-group",h.innerHTML=`\n \n \n `,c.appendChild(h);const p=document.createElement("div");p.dataset.role="threshold-fields",p.style.display=d?"block":"none";const u=r?.continuous_threshold_pct??80,g=r?.spike_threshold_pct??100,_=r?.window_duration_m??15,f=r?.cooldown_duration_m??15;p.appendChild(this._createThresholdRow(n("sidepanel.continuous_pct"),"continuous",u,t)),p.appendChild(this._createThresholdRow(n("sidepanel.spike_pct"),"spike",g,t)),p.appendChild(this._createDurationRow(n("sidepanel.window_duration"),"window-m",_,1,180,"m",t)),p.appendChild(this._createDurationRow(n("sidepanel.cooldown"),"cooldown-m",f,1,180,"m",t)),c.appendChild(p),a.addEventListener("change",()=>{const e=a.checked;c.style.display=e?"block":"none";const i=t.entities?.power||t.uuid;this._callDomainService("set_circuit_threshold",{circuit_id:i,monitoring_enabled:e}).catch(e=>this._showError(`${n("sidepanel.monitoring_toggle_failed")} ${e.message??e}`))});const m=h.querySelectorAll('input[type="radio"]');for(const e of m)e.addEventListener("change",()=>{const i="custom"===e.value&&e.checked;if(p.style.display=i?"block":"none",!i&&e.checked){const e=t.entities?.power||t.uuid;this._callDomainService("clear_circuit_threshold",{circuit_id:e}).catch(e=>this._showError(`${n("sidepanel.clear_monitoring_failed")} ${e.message??e}`))}});e.appendChild(i)}_createThresholdRow(e,t,i,o){const s=document.createElement("div");s.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=e;const r=document.createElement("input");return r.type="number",r.min="0",r.max="200",r.value=String(i),r.dataset.role=`threshold-${t}`,r.addEventListener("input",()=>{this._debounce(`threshold-${t}`,p,()=>{const e=this.shadowRoot;if(!e)return;const t=e.querySelector('[data-role="threshold-continuous"]'),i=e.querySelector('[data-role="threshold-spike"]'),s=e.querySelector('[data-role="threshold-window-m"]'),a=e.querySelector('[data-role="threshold-cooldown-m"]'),r=o.entities?.power||o.uuid;this._callDomainService("set_circuit_threshold",{circuit_id:r,continuous_threshold_pct:t?Number(t.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:s?Number(s.value):void 0,cooldown_duration_m:a?Number(a.value):void 0}).catch(e=>this._showError(`${n("sidepanel.save_threshold_failed")} ${e.message??e}`))})}),s.appendChild(a),s.appendChild(r),s}_createDurationRow(e,t,i,o,s,a,r,l=!1){const c=document.createElement("div");c.className="field-row";const d=document.createElement("span");d.className="field-label",d.textContent=e;const h=document.createElement("div"),u=document.createElement("input");u.type="number",u.min=String(o),u.max=String(s),u.value=String(i),u.dataset.role=`threshold-${t}`,l&&(u.disabled=!0);const g=document.createElement("span");return g.textContent=a,h.appendChild(u),h.appendChild(g),l||u.addEventListener("input",()=>{this._debounce(`threshold-${t}`,p,()=>{const e=this.shadowRoot;if(!e)return;const t=e.querySelector('[data-role="threshold-continuous"]'),i=e.querySelector('[data-role="threshold-spike"]'),o=e.querySelector('[data-role="threshold-window-m"]');this._callDomainService("set_circuit_threshold",{circuit_id:r.uuid,continuous_threshold_pct:t?Number(t.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:o?Number(o.value):void 0}).catch(e=>this._showError(`${n("sidepanel.save_threshold_failed")} ${e.message??e}`))})}),c.appendChild(d),c.appendChild(h),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const e=this._config;if(!e.subDeviceMode){if(e.entities?.switch){const t=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(t){const n=this._hass?.states?.[e.entities.switch]?.state;"on"===n?t.setAttribute("checked",""):t.removeAttribute("checked")}}if(e.entities?.select){const t=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(t){const n=this._hass?.states?.[e.entities.select]?.state||"";t.value=n}}}}_callService(e,t,n){return this._hass?Promise.resolve(this._hass.callService(e,t,n)):Promise.resolve()}_callDomainService(e,t){return this._hass?this._hass.callWS({type:"call_service",domain:a,service:e,service_data:t}):Promise.resolve()}_showError(e){const t=this.shadowRoot?.getElementById("error-msg");t&&(t.textContent=e,t.style.display="block",setTimeout(()=>{t.style.display="none"},5e3))}_debounce(e,t,n){this._debounceTimers[e]&&clearTimeout(this._debounceTimers[e]),this._debounceTimers[e]=setTimeout(()=>{delete this._debounceTimers[e],n()},t)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",He)}catch{}async function Ie(e,t){const[n,i,o]=await Promise.all([e.callWS({type:"config/area_registry/list"}),e.callWS({type:"config/entity_registry/list"}),e.callWS({type:"config/device_registry/list"})]),s=new Map;for(const e of n)s.set(e.area_id,e.name);const a=new Map;for(const e of i)e.area_id&&a.set(e.entity_id,e.area_id);const r=new Map;for(const e of o)r.set(e.id,e.area_id);let l;if(t.device_id){const e=r.get(t.device_id);e&&(l=s.get(e))}for(const e of Object.values(t.circuits)){let t;for(const n of Object.values(e.entities)){if(!n)continue;const e=a.get(n);if(e){t=s.get(e);break}}t||(t=l),e.area=t}}async function Oe(e,t){if(!t)throw new Error(n("card.device_not_found"));const i=await e.callWS({type:`${a}/panel_topology`,device_id:t}),o=i.panel_size??function(e){let t=0;for(const n of Object.values(e))if(n)for(const e of n.tabs)e>t&&(t=e);return t>0?t+t%2:0}(i.circuits);if(!o)throw new Error(n("card.topology_error"));const s=await e.callWS({type:"config/device_registry/list"}),r=(l=s.find(e=>e.id===t),l?{id:l.id,name:l.name,name_by_user:l.name_by_user,config_entries:l.config_entries,identifiers:l.identifiers,via_device_id:l.via_device_id,sw_version:l.sw_version,model:l.model}:null);var l;return await Ie(e,i),{topology:i,panelDevice:r,panelSize:o}}const Re=u.power;function qe(e){return Re.unit(e)}function je(e){return(e<0?"-":"")+Re.format(e)}function Ue(e){return(Math.abs(e)/1e3).toFixed(1)}function Ge(e){return Math.ceil(e/2)}function Fe(e){return e%2==0?1:0}function We(e){if(2!==e.length)return null;const[t,n]=[Math.min(...e),Math.max(...e)];return Ge(t)===Ge(n)?"row-span":Fe(t)===Fe(n)?"col-span":"row-span"}function Be(e){const t=e.chart_metric??i;return u[t]??u[i]}function Ve(e,t){const n=function(e){return Be(e).entityRole}(t);return e.entities?.[n]??e.entities?.power??null}class Qe{constructor(){this._status=null,this._lastFetch=0,this._fetching=!1}async fetch(e,t){const n=Date.now();if(this._fetching)return this._status;if(this._status&&n-this._lastFetch<3e4)return this._status;this._fetching=!0;try{const i={};t&&(i.config_entry_id=t);const o=await e.callWS({type:"call_service",domain:a,service:"get_monitoring_status",service_data:i,return_response:!0});this._status=o?.response??null,this._lastFetch=n}catch{this._status=null}finally{this._fetching=!1}return this._status}invalidate(){this._lastFetch=0}get status(){return this._status}clear(){this._status=null,this._lastFetch=0}}function Je(e,t){return e?.circuits?e.circuits[t]??null:null}function Xe(e){if(!e?.utilization_pct)return"";const t=e.utilization_pct;return t>=100?"utilization-alert":t>=80?"utilization-warning":"utilization-normal"}function Ke(e,t,i,o,s,a,c,d,h,p=!1){const u=t.entities?.power,g=u?a.states[u]:null,m=g&&parseFloat(g.state)||0,b=t.device_type===l||m<0,v=t.entities?.switch,y=v?a.states[v]:null,w=y?"on"===y.state:(g?.attributes?.relay_state||t.relay_state)===r,x=t.breaker_rating_a,$=x?`${Math.round(x)}A`:"",S=De(t.name||n("grid.unknown")),C=Be(c);let E;if("current"===C.entityRole){const e=t.entities?.current,n=e?a.states[e]:null,i=n&&parseFloat(n.state)||0;E=`${C.format(i)}A`}else E=`${je(m)}${qe(m)}`;const k=h||"unknown";let z="";if("unknown"!==k){const e=_[k]??_.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};z=e.icon2?`\n \n \n `:e.textLabel?`\n \n ${e.textLabel}\n `:``}const A=d&&function(e){return!!e&&void 0!==e.continuous_threshold_pct}(d),P=A?f:"#555",M=``;let T="";if(null!=d?.utilization_pct){const e=d.utilization_pct;T=`${Math.round(e)}%`}const N=function(e){return!!e&&null!=e.over_threshold_since}(d);return`\n
\n
\n
\n ${$?`${$}`:""}\n ${S}\n
\n
\n \n ${E}\n \n ${!1!==t.is_user_controllable&&t.entities?.switch?`\n
\n ${n(w?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${z}\n ${T}\n ${M}\n
\n
\n
\n `}function Ze(e,t){return`\n
\n \n
\n `}const Ye={names:["power","battery power"],suffixes:["_power"]},et={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},tt={names:["state of energy"],suffixes:["_soe_kwh"]},nt={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function it(e,t){if(!e.entities)return null;for(const[n,i]of Object.entries(e.entities)){if("sensor"!==i.domain)continue;const e=(i.original_name??"").toLowerCase();if(t.names.some(t=>e===t))return n;if(i.unique_id&&t.suffixes.some(e=>i.unique_id.endsWith(e)))return n}return null}function ot(e){return it(e,Ye)}function st(e){return it(e,et)}function at(e){return it(e,tt)}function rt(e){return it(e,nt)}function lt(e,t,n,i){const o=n.visible_sub_entities||{};let s="";if(!e.entities)return s;for(const[n,a]of Object.entries(e.entities)){if(i.has(n))continue;if(!0!==o[n])continue;const r=t.states[n];if(!r)continue;let l=a.original_name||r.attributes.friendly_name||n;const c=e.name||"";let d;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),t.formatEntityState)d=t.formatEntityState(r);else{d=r.state;const e=r.attributes.unit_of_measurement||"";e&&(d+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(d=(e/1e3).toFixed(1)+" kWh")}s+=`\n
\n ${De(l)}:\n ${De(d)}\n
\n `}return s}function ct(e,t,i,o,s,a){if(i){const t=[{key:`${h}${e}_soc`,title:n("subdevice.soc"),available:!!s},{key:`${h}${e}_soe`,title:n("subdevice.soe"),available:!!a},{key:`${h}${e}_power`,title:n("subdevice.power"),available:!!o}].filter(e=>e.available);return`\n
\n ${t.map(e=>`\n
\n
${De(e.title)}
\n
\n
\n `).join("")}\n
\n `}return o?`
`:""}function dt(e){const t=void 0!==e.history_days||void 0!==e.history_hours||void 0!==e.history_minutes,n=60*(60*(24*(t&&parseInt(String(e.history_days))||0)+(t&&parseInt(String(e.history_hours))||0))+(t?parseInt(String(e.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function ht(e){const t=s[e];return t?t.ms:s[o].ms}function pt(e){const t=e/1e3;return t<=600?Math.ceil(t):Math.min(5e3,Math.ceil(t/5))}function ut(e){return Math.max(500,Math.floor(e/5e3))}function gt(e,t,n,i,o,s){e.has(t)||e.set(t,[]);const a=e.get(t);a.push({time:i,value:n});const r=a.findIndex(e=>e.time>=o);r>0?a.splice(0,r):-1===r&&(a.length=0),a.length>s&&a.splice(0,a.length-s)}function _t(e,t,n=500){if(0===e.length)return e;e.sort((e,t)=>e.time-t.time);const i=[e[0]];for(let t=1;t=n&&i.push(e[t]);return i.length>t&&i.splice(0,i.length-t),i}async function ft(e,t,n,i,o){const s=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",r=await e.callWS({type:"recorder/statistics_during_period",start_time:s,statistic_ids:t,period:a,types:["mean"]});for(const[e,t]of Object.entries(r)){const i=n.get(e);if(!i||!t)continue;const s=[];for(const e of t){const t=e.mean;if(null==t||!Number.isFinite(t))continue;const n=e.start;n>0&&s.push({time:n,value:t})}if(s.length>0){const e=o.get(i)||[],t=[...s,...e];t.sort((e,t)=>e.time-t.time),o.set(i,t)}}}async function mt(e,t,n,i,o){const s=new Date(Date.now()-i).toISOString(),a=await e.callWS({type:"history/history_during_period",start_time:s,entity_ids:t,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),r=pt(i),l=ut(i);for(const[e,t]of Object.entries(a)){const i=n.get(e);if(!i||!t)continue;const s=[];for(const e of t){const t=parseFloat(e.s);if(!Number.isFinite(t))continue;const n=1e3*(e.lu||e.lc||0);n>0&&s.push({time:n,value:t})}if(s.length>0){const e=o.get(i)||[],t=[...s,...e];o.set(i,_t(t,r,l))}}}function bt(e){if(!e.sub_devices)return[];const t=[];for(const[n,i]of Object.entries(e.sub_devices)){const e={power:ot(i)};i.type===c&&(e.soc=st(i),e.soe=at(i));for(const[i,o]of Object.entries(e))o&&t.push({entityId:o,key:`${h}${n}_${i}`,devId:n})}return t}async function vt(e,t,n,i,o,s){if(!t||!e)return;const a=new Map;for(const[e,i]of Object.entries(t.circuits)){const t=Ve(i,n);if(!t)continue;let s;s=o&&o.has(e)?ht(o.get(e)):dt(n),a.has(s)||a.set(s,{entityIds:[],uuidByEntity:new Map});const r=a.get(s);r.entityIds.push(t),r.uuidByEntity.set(t,e)}for(const{entityId:e,key:i,devId:o}of bt(t)){let t;t=s&&s.has(o)?ht(s.get(o)):dt(n),a.has(t)||a.set(t,{entityIds:[],uuidByEntity:new Map});const r=a.get(t);r.entityIds.push(e),r.uuidByEntity.set(e,i)}const r=[];for(const[t,n]of a){if(0===n.entityIds.length)continue;t>2592e5?r.push(ft(e,n.entityIds,n.uuidByEntity,t,i)):r.push(mt(e,n.entityIds,n.uuidByEntity,t,i))}await Promise.all(r)}function yt(e,t,n,o,s,a,r,l,c){const{options:d,series:h}=function(e,t,n,o,s,a=!1){n||(n=u[i]);const r=o?"140, 160, 220":"77, 217, 175",l=`rgb(${r})`,c=Date.now(),d=c-t,h=void 0!==n.fixedMin&&void 0!==n.fixedMax,p=(e??[]).filter(e=>e.time>=d).map(e=>[e.time,Math.abs(e.value)]),g=[{type:"line",data:p,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${r}, 0.35)`},{offset:1,color:`rgba(${r}, 0.02)`}]}},itemStyle:{color:l}}],_=p.length>0?function(e){let t=0;for(const n of e)n[1]>t&&(t=n[1]);return t}(p):0,f={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:_<10?e=>0===e?"0":e.toFixed(1):e=>n.format(e)},splitLine:{lineStyle:{opacity:.15}}};h?(f.min=n.fixedMin,f.max=n.fixedMax):_<1&&(f.min=0,f.max=1),s&&"current"===n.entityRole&&(f.min=0,f.max=Math.ceil(1.25*s),g.push({type:"line",data:[[d,.8*s],[c,.8*s]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),g.push({type:"line",data:[[d,s],[c,s]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:d,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:f,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:e=>{if(!e||0===e.length)return"";const t=e[0],i=new Date(t.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),o=parseFloat(t.value[1].toFixed(2));return`
${i}
${n.format(o)} ${n.unit(o)}
`}},animation:!1};return{options:m,series:g}}(n,o,s,a,l,c),p=r??120;e.style.minHeight=p+"px";let g=e.querySelector("ha-chart-base");g||(g=document.createElement("ha-chart-base"),g.style.display="block",g.style.width="100%",g.hass=t,e.innerHTML="",e.appendChild(g));const _=e.clientHeight;g.height=(_>0?_:p)+"px",g.hass=t,g.options=d,g.data=h}function wt(e,t,i,o,s,a){if(!e||!i||!t)return;const c=dt(o);let d=0;for(const[,e]of Object.entries(i.circuits)){const n=e.entities?.power;if(!n)continue;const i=t.states[n],o=i&&parseFloat(i.state)||0;e.device_type!==l&&(d+=Math.abs(o))}!function(e,t,n,i,o){const s="current"===(i.chart_metric||"power"),a=e.querySelector(".stat-consumption .stat-value"),r=e.querySelector(".stat-consumption .stat-unit");if(s){const e=n.panel_entities?.site_power,i=e?t.states[e]:null,o=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(o)?Math.abs(o).toFixed(1):"--"),r&&(r.textContent="A")}else{const e=n.panel_entities?.site_power;if(e){const n=t.states[e];n&&(o=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=Ue(o)),r&&(r.textContent="kW")}const l=e.querySelector(".stat-upstream .stat-value"),c=e.querySelector(".stat-upstream .stat-unit");if(l){const e=n.panel_entities?.current_power,i=e?t.states[e]:null;if(s){const e=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",c&&(c.textContent="A")}else{const e=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=Ue(e),c&&(c.textContent="kW")}}const d=e.querySelector(".stat-downstream .stat-value"),h=e.querySelector(".stat-downstream .stat-unit");if(d){const e=n.panel_entities?.feedthrough_power,i=e?t.states[e]:null;if(s){const e=i?parseFloat(i.attributes?.amperage):NaN;d.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",h&&(h.textContent="A")}else{const e=i?Math.abs(parseFloat(i.state)||0):0;d.textContent=Ue(e),h&&(h.textContent="kW")}}const p=e.querySelector(".stat-solar .stat-value"),u=e.querySelector(".stat-solar .stat-unit");if(p){const e=n.panel_entities?.pv_power,i=e?t.states[e]:null;if(s){const e=i?parseFloat(i.attributes?.amperage):NaN;p.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",u&&(u.textContent="A")}else{if(i){const e=Math.abs(parseFloat(i.state)||0);p.textContent=Ue(e)}else p.textContent="--";u&&(u.textContent="kW")}}const g=e.querySelector(".stat-battery .stat-value");if(g){const e=n.panel_entities?.battery_level,i=e?t.states[e]:null;i&&(g.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const _=e.querySelector(".stat-grid-state .stat-value");if(_){const e=n.panel_entities?.dsm_state,i=e?t.states[e]:null;_.textContent=i?t.formatEntityState?.(i)||i.state:"--"}}(e,t,i,o,d);const h=Be(o),p="current"===h.entityRole;for(const[o,d]of Object.entries(i.circuits)){const i=e.querySelector(`[data-uuid="${o}"]`);if(!i)continue;const u=d.entities?.power,g=u?t.states[u]:null,f=g&&parseFloat(g.state)||0,m=d.device_type===l||f<0,b=d.entities?.switch,v=b?t.states[b]:null,y=v?"on"===v.state:(g?.attributes?.relay_state||d.relay_state)===r,w=i.querySelector(".power-value");if(w)if(p){const e=d.entities?.current,n=e?t.states[e]:null,i=n&&parseFloat(n.state)||0;w.innerHTML=`${h.format(i)}A`}else w.innerHTML=`${je(f)}${qe(f)}`;const x=i.querySelector(".toggle-pill");if(x){x.className="toggle-pill "+(y?"toggle-on":"toggle-off");const e=x.querySelector(".toggle-label");e&&(e.textContent=n(y?"grid.on":"grid.off"))}let $;if(i.classList.toggle("circuit-off",!y),i.classList.toggle("circuit-producer",m),d.always_on)$="always_on";else{const e=d.entities?.select,n=e?t.states[e]:null;$=n?n.state:"unknown"}const S=_[$]??_.unknown,C=i.querySelector(".shedding-icon");C&&(C.setAttribute("icon",S.icon),C.style.color=S.color,C.title=S.label());const E=i.querySelector(".shedding-icon-secondary");E&&(S.icon2?(E.setAttribute("icon",S.icon2),E.style.color=S.color,E.style.display=""):E.style.display="none");const k=i.querySelector(".shedding-label");k&&(S.textLabel?(k.textContent=S.textLabel,k.style.color=S.color,k.style.display=""):k.style.display="none");const z=i.querySelector(".chart-container");if(z){const e=s.get(o)||[],n=i.classList.contains("circuit-col-span")?200:100,r=a?.has(o)?ht(a.get(o)):c,p=d.device_type===l;yt(z,t,e,r,h,m,n,d.breaker_rating_a??void 0,p)}}}class xt{constructor(){this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(e,t){const n=Date.now();if(this._fetching)return this._settings;if(this._settings&&n-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const i={};t&&(i.config_entry_id=t);const o=await e.callWS({type:"call_service",domain:a,service:"get_graph_settings",service_data:i,return_response:!0});this._settings=o?.response??null,this._lastFetch=n}catch{this._settings=null}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function $t(e,t){if(!e)return o;const n=e.circuits?.[t];return n?.has_override?n.horizon:e.global_horizon??o}function St(e,t){if(!e)return o;const n=e.sub_devices?.[t];return n?.has_override?n.horizon:e.global_horizon??o}class Ct{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new Qe,this.graphSettingsCache=new xt,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get hass(){return this._hass}set hass(e){this._hass=e}get topology(){return this._topology}get config(){return this._config}set showMonitoring(e){this._showMonitoring=e}init(e,t,n,i){this._topology=e,this._config=t,this._hass=n,this._configEntryId=i}setConfig(e){this._config=e}buildHorizonMaps(e){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),e&&this._topology?.circuits)for(const t of Object.keys(this._topology.circuits))this.horizonMap.set(t,$t(e,t));if(e&&this._topology?.sub_devices)for(const t of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(t,St(e,t))}async fetchAndBuildHorizonMaps(){try{await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)}catch{}}async loadHistory(){await vt(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const e=Date.now();for(const[t,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(t)??o;if(!s[i]?.useRealtime)continue;const a=Ve(n,this._config);if(!a)continue;const r=this._hass.states[a];if(!r)continue;const l=parseFloat(r.state);if(isNaN(l))continue;const c=ht(i),d=pt(c),h=ut(c),p=e-c,u=this.powerHistory.get(t)??[];u.length>0&&e-u[u.length-1].time0&&e-u[u.length-1].time0&&this._topology)for(const{key:e,devId:t}of bt(this._topology))n.has(t)&&i.add(e);const o=new Map;try{await vt(this._hass,this._topology,this._config,o,t,n);for(const e of t.keys()){const t=o.get(e);t?this.powerHistory.set(e,t):this.powerHistory.delete(e)}for(const e of i){const t=o.get(e);t?this.powerHistory.set(e,t):this.powerHistory.delete(e)}this.updateDOM(e)}catch{}}updateDOM(e){this._hass&&this._topology&&this._config&&(wt(e,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(e,t,n,i,o,s){if(!n.sub_devices)return;const a=dt(i);for(const[i,r]of Object.entries(n.sub_devices)){const n=e.querySelector(`[data-subdev="${i}"]`);if(!n)continue;const l=ot(r);if(l){const e=t.states[l],i=e&&parseFloat(e.state)||0,o=n.querySelector(".sub-power-value");o&&(o.innerHTML=`${je(i)} ${qe(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const e of c){const n=e.dataset.chartKey;if(!n)continue;const r=o.get(n)||[];let l=g.power;n.endsWith("_soc")?l=g.soc:n.endsWith("_soe")&&(l=g.soe);const c=!!e.closest(".bess-chart-col");yt(e,t,r,s?.has(i)?ht(s.get(i)):a,l,!1,c?120:150,void 0,n.endsWith("_soc")||n.endsWith("_soe"))}for(const e of Object.keys(r.entities||{})){const i=n.querySelector(`[data-eid="${e}"]`);if(!i)continue;const o=t.states[e];if(o){let e;if(t.formatEntityState)e=t.formatEntityState(o);else{e=o.state;const t=o.attributes.unit_of_measurement||"";t&&(e+=" "+t)}if("Wh"===(o.attributes.unit_of_measurement||"")){const t=parseFloat(o.state);isNaN(t)||(e=(t/1e3).toFixed(1)+" kWh")}i.textContent=e}}}}(e,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(e){if(this._hass){this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(e)}}onToggleClick(e,t){const n=e.target,i=n?.closest(".toggle-pill");if(!i)return;const o=t.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;e.stopPropagation(),e.preventDefault();const s=i.closest("[data-uuid]");if(!s||!this._topology||!this._hass)return;const a=s.dataset.uuid;if(!a)return;const r=this._topology.circuits[a];if(!r)return;const l=r.entities?.switch;if(!l)return;const c=this._hass.states[l];if(!c)return void console.warn("SPAN Panel: switch entity not found:",l);const d="on"===c.state?"turn_off":"turn_on";this._hass.callService("switch",d,{},{entity_id:l}).catch(e=>{console.error("SPAN Panel: switch service call failed:",e)})}async onGearClick(e,t){const n=e.target,i=n?.closest(".gear-icon");if(!i)return;const s=t.querySelector("span-side-panel");if(!s||!this._hass)return;if(s.hass=this._hass,i.classList.contains("panel-gear"))return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void s.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings});const a=i.dataset.uuid;if(a&&this._topology){const e=this._topology.circuits[a];if(e){await this.monitoringCache.fetch(this._hass,this._configEntryId);const t=e.entities?.current??e.entities?.power,n=t?this.monitoringCache.status?.circuits?.[t]??null:null;await this.graphSettingsCache.fetch(this._hass,this._configEntryId);const i=this.graphSettingsCache.settings,r=i?.global_horizon??o,l=i?.circuits?.[a],c=l?{...l,globalHorizon:r}:{horizon:r,has_override:!1,globalHorizon:r};return void s.open({...e,uuid:a,monitoringInfo:n,showMonitoring:this._showMonitoring,graphHorizonInfo:c})}}const r=i.dataset.subdevId;if(r&&this._topology?.sub_devices?.[r]){const e=this._topology.sub_devices[r];await this.graphSettingsCache.fetch(this._hass,this._configEntryId);const t=this.graphSettingsCache.settings,n=t?.global_horizon??o,i=t?.sub_devices?.[r],a=i?{...i,globalHorizon:n}:{horizon:n,has_override:!1,globalHorizon:n};s.open({subDeviceMode:!0,subDeviceId:r,name:e.name??r,deviceType:e.type??"",graphHorizonInfo:a})}}bindSlideConfirm(e,t){const n=e.querySelector(".slide-confirm-knob"),i=e.querySelector(".slide-confirm-text");if(!n||!i)return;let o=!1,s=0,a=0;const r=t=>{e.classList.contains("confirmed")||(o=!0,s=t-n.offsetLeft,a=e.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=e=>{if(!o)return;const t=Math.max(2,Math.min(e-s,a));n.style.left=t+"px"},c=()=>{if(!o)return;o=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",e.classList.add("confirmed"),n.querySelector("ha-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=e.dataset.textOn??"",t&&t.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",e=>{e.preventDefault(),r(e.clientX)}),e.addEventListener("mousemove",e=>l(e.clientX)),e.addEventListener("mouseup",c),e.addEventListener("mouseleave",c),n.addEventListener("touchstart",e=>{e.preventDefault(),r(e.touches[0].clientX)},{passive:!1}),e.addEventListener("touchmove",e=>l(e.touches[0].clientX),{passive:!0}),e.addEventListener("touchend",c),e.addEventListener("touchcancel",c),e.addEventListener("click",()=>{e.classList.contains("confirmed")&&(e.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("ha-icon")?.setAttribute("icon","mdi:lock"),i.textContent=e.dataset.textOff??"",t&&t.classList.add("switches-disabled"))})}startIntervals(e,t){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(e),t&&t()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(e)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(e,t){this.cleanupResizeObserver(),t&&(this._lastWidth=t.clientWidth,this._resizeObserver=new ResizeObserver(t=>{const n=t[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const t of e.querySelectorAll(".chart-container")){const e=t.querySelector("ha-chart-base");e&&e.remove()}this.updateDOM(e)},150))}),this._resizeObserver.observe(t))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.graphSettingsCache.clear()}}const Et="\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n ha-card {\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item ha-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob ha-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n grid-template-columns: 28px 1fr 1fr 28px;\n gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 7px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .circuit-custom-monitoring {\n border-left: 3px solid #ff9800;\n }\n\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n min-width: 70px;\n text-align: right;\n flex-shrink: 0;\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 12px;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .list-expanded-content .circuit-slot {\n border: none;\n margin: 0;\n background: none;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n";class kt{constructor(){this._ctrl=new Ct,this._container=null,this._onGearClick=null,this._onToggleClick=null,this._onSidePanelClosed=null,this._onGraphSettingsChanged=null}get hass(){return this._ctrl.hass}set hass(e){this._ctrl.hass=e}async render(e,t,i,o,s){let a,r;this.stop(),this._ctrl.reset(),this._ctrl.showMonitoring=!0,this._container=e,this._ctrl.hass=t;try{const e=await Oe(t,i);a=e.topology,r=e.panelSize}catch(t){return void(e.innerHTML=`

${De(t.message)}

`)}this._ctrl.init(a,o,t,s??null),await this._ctrl.monitoringCache.fetch(t,s??null),await this._ctrl.fetchAndBuildHorizonMaps();const l=Math.ceil(r/2),h=this._ctrl.monitoringCache.status,p=function(e,t){const i=De(e.device_name||n("header.default_name")),o=De(e.serial||""),s=De(e.firmware||""),a="current"===(t.chart_metric||"power"),r=!!e.panel_entities?.site_power,l=!!e.panel_entities?.dsm_state,c=!!e.panel_entities?.current_power,d=!!e.panel_entities?.feedthrough_power,h=!!e.panel_entities?.pv_power,p=!!e.panel_entities?.battery_level;return`\n
\n
\n
\n

${i}

\n ${o}\n \n
\n ${n("header.enable_switches")}\n
\n \n
\n
\n
\n
\n ${r?`\n
\n ${n("header.site")}\n
\n 0\n ${a?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${n("header.grid")}\n
\n --\n
\n
`:""}\n ${c?`\n
\n ${n("header.upstream")}\n
\n --\n ${a?"A":"kW"}\n
\n
`:""}\n ${d?`\n
\n ${n("header.downstream")}\n
\n --\n ${a?"A":"kW"}\n
\n
`:""}\n ${h?`\n
\n ${n("header.solar")}\n
\n --\n ${a?"A":"kW"}\n
\n
`:""}\n ${p?`\n
\n ${n("header.battery")}\n
\n \n %\n
\n
`:""}\n
\n
\n
\n
\n ${s}\n
\n \n \n
\n
\n
\n ${Object.entries(_).filter(([e])=>"unknown"!==e).map(([,e])=>{let t;return t=e.icon2?``:e.textLabel?`${e.textLabel}`:``,`
${t}${e.label()}
`}).join("")}\n
\n
\n
\n `}(a,o),u=function(e){if(!e)return"";const t=Object.values(e.circuits??{}),i=Object.values(e.mains??{}),o=[...t,...i],s=o.filter(e=>void 0!==e.utilization_pct&&e.utilization_pct>=80&&e.utilization_pct<100).length,a=o.filter(e=>void 0!==e.utilization_pct&&e.utilization_pct>=100).length,r=o.filter(e=>e.has_override).length;return`\n
\n ✓ ${n("status.monitoring")} · ${t.length} ${n("status.circuits")} · ${i.length} ${n("status.mains")}\n \n ${s>0?`${s} ${n(s>1?"status.warnings":"status.warning")}`:""}\n ${a>0?`${a} ${n(a>1?"status.alerts":"status.alert")}`:""}\n ${r>0?`${r} ${n(r>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(h),g=function(e,t,n,i,o){const s=new Map,a=new Set;for(const[t,n]of Object.entries(e.circuits)){const e=n.tabs;if(!e||0===e.length)continue;const i=Math.min(...e),o=1===e.length?"single":We(e)??"single";s.set(i,{uuid:t,circuit:n,layout:o});for(const t of e)a.add(t)}const r=new Set,l=new Set;for(const[e,t]of s)if("col-span"===t.layout){const n=t.circuit.tabs,i=Ge(Math.max(...n));0===Fe(e)?r.add(i):l.add(i)}function c(e){const t=e.circuit.entities?.current??e.circuit.entities?.power,i=o?Je(o,t??""):null;let s;if(e.circuit.always_on)s="always_on";else{const t=e.circuit.entities?.select;s=t&&n.states[t]?n.states[t].state:"unknown"}return{monInfo:i,sheddingPriority:s}}let d="";for(let e=1;e<=t;e++){const t=2*e-1,o=2*e,h=s.get(t),p=s.get(o);if(d+=`
${t}
`,h&&"row-span"===h.layout){const{monInfo:t,sheddingPriority:s}=c(h);d+=Ke(h.uuid,h.circuit,e,"2 / 4","row-span",n,i,t,s),d+=`
${o}
`;continue}if(!r.has(e))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(t)||(d+=Ze(e,"2"));else{const{monInfo:t,sheddingPriority:o}=c(h);d+=Ke(h.uuid,h.circuit,e,"2",h.layout,n,i,t,o)}if(!l.has(e))if(!p||"col-span"!==p.layout&&"single"!==p.layout)a.has(o)||(d+=Ze(e,"3"));else{const{monInfo:t,sheddingPriority:o}=c(p);d+=Ke(p.uuid,p.circuit,e,"3",p.layout,n,i,t,o)}d+=`
${o}
`}return d}(a,l,t,o,h),f=function(e,t,i){const o=!1!==i.show_battery,s=!1!==i.show_evse;if(!e.sub_devices)return"";const a=Object.entries(e.sub_devices).filter(([,e])=>!(e.type===c&&!o||e.type===d&&!s));if(0===a.length)return"";const r=a.filter(([,e])=>e.type===d).length;let l=0,h="";for(const[e,o]of a){const s=o.type===d?n("subdevice.ev_charger"):o.type===c?n("subdevice.battery"):n("subdevice.fallback"),a=ot(o),p=a?t.states[a]:void 0,u=p&&parseFloat(p.state)||0,g=o.type===c,_=o.type===d,f=g?st(o):null,m=g?at(o):null,b=g?rt(o):null,v=lt(o,t,i,new Set([a,f,m,b].filter(e=>null!==e))),y=ct(e,0,g,a,f,m);let w="";g?w="sub-device-bess":_&&(l++,l===r&&r%2==1&&(w="sub-device-full")),h+=`\n
\n
\n ${De(s)}\n ${De(o.name||"")}\n ${a?`${je(u)} ${qe(u)}`:""}\n \n
\n ${y}\n ${v}\n
\n `}return h}(a,t,o);e.innerHTML=`\n \n ${p}\n ${u}\n ${f?`
${f}
`:""}\n ${!1!==o.show_panel?`\n
\n ${g}\n
\n `:""}\n \n `,this._onGearClick=t=>{this._ctrl.onGearClick(t,e)},this._onToggleClick=t=>{this._ctrl.onToggleClick(t,e)},e.addEventListener("click",this._onGearClick),e.addEventListener("click",this._onToggleClick),this._onSidePanelClosed=()=>{this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()},e.addEventListener("side-panel-closed",this._onSidePanelClosed),this._onGraphSettingsChanged=()=>this._ctrl.onGraphSettingsChanged(e),e.addEventListener("graph-settings-changed",this._onGraphSettingsChanged);try{await this._ctrl.loadHistory()}catch{}this._ctrl.updateDOM(e);const m=e.querySelector(".slide-confirm");m&&(this._ctrl.bindSlideConfirm(m,e),e.classList.add("switches-disabled")),this._ctrl.setupResizeObserver(e,e),this._ctrl.startIntervals(e)}stop(){this._ctrl.stopIntervals(),this._container&&(this._onGearClick&&(this._container.removeEventListener("click",this._onGearClick),this._onGearClick=null),this._onToggleClick&&(this._container.removeEventListener("click",this._onToggleClick),this._onToggleClick=null),this._onSidePanelClosed&&(this._container.removeEventListener("side-panel-closed",this._onSidePanelClosed),this._onSidePanelClosed=null),this._onGraphSettingsChanged&&(this._container.removeEventListener("graph-settings-changed",this._onGraphSettingsChanged),this._onGraphSettingsChanged=null))}}const zt="\n display:flex;align-items:center;gap:8px;margin-bottom:8px;\n",At="\n background:var(--secondary-background-color,#333);\n border:1px solid var(--divider-color);\n color:var(--primary-text-color);\n border-radius:4px;padding:6px 10px;width:80px;font-size:0.85em;\n",Pt="\n min-width:130px;font-size:0.85em;color:var(--secondary-text-color);\n",Mt="\n min-width:160px;font-size:0.85em;color:var(--secondary-text-color);\n",Tt="\n background:var(--secondary-background-color,#333);\n border:1px solid var(--divider-color);\n color:var(--primary-text-color);\n border-radius:4px;padding:6px 10px;flex:1;font-size:0.85em;\n font-family:monospace;\n";function Nt(e,t,n,i,o){return`\n ${i}\n `}class Dt{constructor(){this._debounceTimer=null,this._configEntryId=null,this._notifyCloseHandler=null}stop(){this._notifyCloseHandler&&(document.removeEventListener("click",this._notifyCloseHandler),this._notifyCloseHandler=null),this._debounceTimer&&(clearTimeout(this._debounceTimer),this._debounceTimer=null)}async render(e,t,i){let o;void 0!==i&&(this._configEntryId=i),this._notifyCloseHandler&&(document.removeEventListener("click",this._notifyCloseHandler),this._notifyCloseHandler=null);try{const e={};this._configEntryId&&(e.config_entry_id=this._configEntryId);const n=await t.callWS({type:"call_service",domain:a,service:"get_monitoring_status",service_data:e,return_response:!0});o=n?.response||null}catch{o=null}const s=o?.global_settings??{},r=!0===o?.enabled,l=o?.circuits??{},c=o?.mains??{},d=new Set;for(const e of Object.keys(t.states||{}))e.startsWith("notify.")&&d.add(e);const h=new Set(["notify","send_message"]);for(const e of Object.keys(t.services?.notify||{}))h.has(e)||d.add(`notify.${e}`);d.add("event_bus");const p=[...d].sort(),u=s.notify_targets??"",g=("string"==typeof u?u.split(","):u).map(e=>e.trim()).filter(Boolean),_=p.length>0&&p.every(e=>g.includes(e)),f=s.notification_title_template??"SPAN: {name} {alert_type}",m=s.notification_message_template??"{name} at {current_a}A ({utilization_pct}% of {breaker_rating_a}A rating)",b=s.notification_priority??"default",v=Object.entries(l).sort(([,e],[,t])=>(e.name??"").localeCompare(t.name??"")),y=Object.entries(c),w=[...v,...y],x=w.length>0&&w.every(([,e])=>!1!==e.monitoring_enabled),$=w.some(([,e])=>!1!==e.monitoring_enabled),S=v.map(([e,t])=>{const i=De(t.name??e),o=!1!==t.monitoring_enabled,s=!0===t.has_override,a=o?"":"opacity:0.4;",r=De(e);return`\n \n \n \n \n ${Nt(r,"continuous_threshold_pct",t.continuous_threshold_pct,"%","circuit")}\n ${Nt(r,"spike_threshold_pct",t.spike_threshold_pct,"%","circuit")}\n ${Nt(r,"window_duration_m",t.window_duration_m,"m","circuit")}\n ${Nt(r,"cooldown_duration_m",t.cooldown_duration_m,"m","circuit")}\n \n ${s?``:""}\n \n \n `}).join(""),C=Object.entries(c).map(([e,t])=>{const i=De(t.name??e),o=!1!==t.monitoring_enabled,s=!0===t.has_override,a=o?"":"opacity:0.4;",r=De(e);return`\n \n \n \n \n ${Nt(r,"continuous_threshold_pct",t.continuous_threshold_pct,"%","mains")}\n ${Nt(r,"spike_threshold_pct",t.spike_threshold_pct,"%","mains")}\n ${Nt(r,"window_duration_m",t.window_duration_m,"m","mains")}\n ${Nt(r,"cooldown_duration_m",t.cooldown_duration_m,"m","mains")}\n \n ${s?``:""}\n \n \n `}).join("");e.innerHTML=`\n
\n

${n("monitoring.heading")}

\n\n
\n
\n

${n("monitoring.global_settings")}

\n \n
\n\n
\n
\n ${n("monitoring.continuous")}\n \n
\n
\n ${n("monitoring.spike")}\n \n
\n
\n ${n("monitoring.window")}\n \n
\n
\n ${n("monitoring.cooldown")}\n \n
\n\n
\n

${n("notification.heading")}

\n\n
\n ${n("notification.targets")}\n \n
\n \n
\n ${0===p.length?`
${n("notification.no_targets")}
`:p.map(e=>{const i=g.includes(e),o="event_bus"===e,s=o?null:t.states[e],a=s?.attributes?.friendly_name,r=o?n("notification.event_bus_target"):a?`${De(a)} (${De(e)})`:De(e);return``}).join("")}\n
\n
\n
\n\n
\n ${n("notification.priority")}\n \n \n ${"critical"===b?n("notification.hint.critical"):"time-sensitive"===b?n("notification.hint.time_sensitive"):"passive"===b?n("notification.hint.passive"):"active"===b?n("notification.hint.active"):""}\n \n
\n\n
\n ${n("notification.title_template")}\n \n
\n\n
\n ${n("notification.message_template")}\n \n
\n\n
\n ${n("notification.placeholders")} {name} {entity_id} {alert_type}\n {current_a} {breaker_rating_a} {threshold_pct}\n {utilization_pct} {window_m} {local_time}\n
\n
\n ${n("notification.event_bus_help")} span_panel_current_alert\n ${n("notification.event_bus_payload")} alert_source alert_id\n alert_name alert_type current_a\n breaker_rating_a threshold_pct utilization_pct\n panel_serial window_duration_s local_time\n
\n\n
\n ${n("notification.test_label")}\n \n \n
\n
\n\n
\n
\n\n

${n("monitoring.monitored_points")}

\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n ${C}\n ${S}\n \n
${n("monitoring.col.name")}${n("monitoring.col.continuous")}${n("monitoring.col.spike")}${n("monitoring.col.window")}${n("monitoring.col.cooldown")}
\n \n
\n
\n `;const E=e.querySelector("#toggle-all-circuits");E&&!x&&$&&(E.indeterminate=!0);const k=e.querySelector("#notify-all-targets");if(k&&p.length>0){const e=g.length>0;!_&&e&&(k.indeterminate=!0)}this._bindGlobalControls(e,t),this._bindNotifyTargetSelect(e,t),this._bindNotificationSettings(e,t),this._bindToggleAll(e,t,l,c),this._bindCircuitToggles(e,t),this._bindMainsToggles(e,t),this._bindThresholdInputs(e,t),this._bindResetButtons(e,t)}_serviceData(e){return this._configEntryId&&(e.config_entry_id=this._configEntryId),e}_callSetGlobal(e,t){return e.callWS({type:"call_service",domain:a,service:"set_global_monitoring",service_data:this._serviceData({...t})})}_bindGlobalControls(e,t){const i=e.querySelector("#monitoring-enabled"),o=e.querySelector("#global-fields"),s=e.querySelector("#global-status"),a=()=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{const i={continuous_threshold_pct:parseInt(e.querySelector("#g-continuous").value,10),spike_threshold_pct:parseInt(e.querySelector("#g-spike").value,10),window_duration_m:parseInt(e.querySelector("#g-window").value,10),cooldown_duration_m:parseInt(e.querySelector("#g-cooldown").value,10)};try{await this._callSetGlobal(t,i),await this.render(e,t)}catch(e){if(s){const t=e instanceof Error?e.message:n("error.failed_save");s.textContent=`${n("error.prefix")} ${t}`,s.style.color="var(--error-color, #f44336)"}}},p)};i&&i.addEventListener("change",async()=>{const s=i.checked;o&&(o.style.opacity=s?"":"0.4",o.style.pointerEvents=s?"":"none");const a=e.querySelector("#global-status");try{if(s){const n={continuous_threshold_pct:parseInt(e.querySelector("#g-continuous").value,10),spike_threshold_pct:parseInt(e.querySelector("#g-spike").value,10),window_duration_m:parseInt(e.querySelector("#g-window").value,10),cooldown_duration_m:parseInt(e.querySelector("#g-cooldown").value,10)};await this._callSetGlobal(t,n)}else await this._callSetGlobal(t,{enabled:!1})}catch(e){if(a){const t=e instanceof Error?e.message:n("error.failed");a.textContent=`${n("error.prefix")} ${t}`,a.style.color="var(--error-color, #f44336)"}return}await this.render(e,t)});for(const t of e.querySelectorAll("#global-fields input[type=number]"))t.addEventListener("input",a)}_bindNotifyTargetSelect(e,t){const i=e.querySelector("#notify-target-btn"),o=e.querySelector("#notify-target-dropdown"),s=e.querySelector("#notify-target-label");if(!i||!o)return;i.addEventListener("click",e=>{e.stopPropagation();const t="none"!==o.style.display;o.style.display=t?"none":"block"});const a=t=>{const n=e.querySelector("#notify-target-select");n&&!n.contains(t.target)&&(o.style.display="none")};document.addEventListener("click",a),this._notifyCloseHandler=a;const r=()=>{const i=[...e.querySelectorAll(".notify-target-cb:checked")].map(e=>e.value);if(s){const e=i.map(e=>"event_bus"===e?n("notification.event_bus_target"):e);s.textContent=e.length?e.join(", "):n("notification.none_selected")}const o=e.querySelector("#notify-all-targets");if(o){const t=[...e.querySelectorAll(".notify-target-cb")];o.checked=t.length>0&&t.every(e=>e.checked),o.indeterminate=!o.checked&&t.some(e=>e.checked)}this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(t,{notify_targets:i.join(", ")})}catch{}},p)},l=e.querySelector("#notify-all-targets");l&&l.addEventListener("change",()=>{for(const t of e.querySelectorAll(".notify-target-cb"))t.checked=l.checked;const t=e.querySelector("#notify-target-btn");t&&(t.style.opacity=l.checked?"0.4":"",t.style.pointerEvents=l.checked?"none":""),l.checked&&(o.style.display="none"),r()});for(const t of e.querySelectorAll(".notify-target-cb"))t.addEventListener("change",()=>{r()})}_bindNotificationSettings(e,t){const i=e.querySelector("#g-priority"),o=e.querySelector("#g-title-template"),s=e.querySelector("#g-message-template"),r=(e,n)=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(t,{[e]:n})}catch{}},p)};i&&i.addEventListener("change",async()=>{try{await this._callSetGlobal(t,{notification_priority:i.value}),await this.render(e,t)}catch{}}),o&&o.addEventListener("input",()=>{r("notification_title_template",o.value)}),s&&s.addEventListener("input",()=>{r("notification_message_template",s.value)});const l=e.querySelector("#test-notification-btn"),c=e.querySelector("#test-notification-status");l&&l.addEventListener("click",async()=>{l.disabled=!0,c&&(c.textContent=n("notification.test_sending"),c.style.color="var(--secondary-text-color)");try{this._debounceTimer&&(clearTimeout(this._debounceTimer),this._debounceTimer=null);const i=[...e.querySelectorAll(".notify-target-cb:checked")].map(e=>e.value).join(", ");await this._callSetGlobal(t,{notify_targets:i});const o={};this._configEntryId&&(o.config_entry_id=this._configEntryId),await t.callWS({type:"call_service",domain:a,service:"test_notification",service_data:o}),c&&(c.textContent=n("notification.test_sent"),c.style.color="var(--success-color, #4caf50)")}catch(e){if(c){const t=e instanceof Error?e.message:n("error.failed");c.textContent=`${n("error.prefix")} ${t}`,c.style.color="var(--error-color, #f44336)"}}finally{l.disabled=!1}})}_bindToggleAll(e,t,n,i){const o=e.querySelector("#toggle-all-circuits");o&&o.addEventListener("change",async()=>{const s=o.checked,r=[...Object.keys(n).map(e=>t.callWS({type:"call_service",domain:a,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:e,monitoring_enabled:s})}).catch(()=>{})),...Object.keys(i).map(e=>t.callWS({type:"call_service",domain:a,service:"set_mains_threshold",service_data:this._serviceData({leg:e,monitoring_enabled:s})}).catch(()=>{}))];await Promise.all(r),await this.render(e,t)})}_bindMainsToggles(e,t){for(const n of e.querySelectorAll(".mains-toggle"))n.addEventListener("change",async()=>{const i=n.dataset.entity,o=n.checked;try{await t.callWS({type:"call_service",domain:a,service:"set_mains_threshold",service_data:this._serviceData({leg:i,monitoring_enabled:o})})}catch{return void(n.checked=!o)}await this.render(e,t)})}_bindCircuitToggles(e,t){for(const n of e.querySelectorAll(".circuit-toggle"))n.addEventListener("change",async()=>{const i=n.dataset.entity,o=n.checked;try{await t.callWS({type:"call_service",domain:a,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:i,monitoring_enabled:o})})}catch{return void(n.checked=!o)}await this.render(e,t)})}_bindThresholdInputs(e,t){const n=new Map;for(const i of e.querySelectorAll(".threshold-input"))i.addEventListener("input",()=>{const o=`${i.dataset.entity}-${i.dataset.field}`,s=n.get(o);s&&clearTimeout(s),n.set(o,setTimeout(async()=>{const n=parseInt(i.value,10);if(!n||n<1)return;const o=i.dataset.entity,s=i.dataset.field,r=i.dataset.type,l="mains"===r?"set_mains_threshold":"set_circuit_threshold",c="mains"===r?"leg":"circuit_id";try{await t.callWS({type:"call_service",domain:a,service:l,service_data:this._serviceData({[c]:o,[s]:n})}),await this.render(e,t)}catch{i.style.borderColor="var(--error-color, #f44336)"}},800))})}_bindResetButtons(e,t){for(const n of e.querySelectorAll(".reset-btn"))n.addEventListener("click",async()=>{const i=n.dataset.entity;if(!i)return;const o=n.dataset.type,s="mains"===o?"clear_mains_threshold":"clear_circuit_threshold",r=this._serviceData("mains"===o?{leg:i}:{circuit_id:i});await t.callService(a,s,r),await this.render(e,t)})}}function Lt(e=""){const t=e?` value="${De(e)}"`:"",i=e?"":"display:none;";return`\n
\n \n \n
\n `}function Ht(e){const t="current"===(e.chart_metric||"power");return`\n
\n \n \n
\n `}function It(e,t,i,o,s,a,l){const c=t.entities?.power,d=c?i.states[c]:null,h=d&&parseFloat(d.state)||0,p=t.entities?.switch,u=p?i.states[p]:null,g=u?"on"===u.state:(d?.attributes?.relay_state||t.relay_state)===r,f=t.breaker_rating_a,m=f?`${Math.round(f)}A`:"",b=De(t.name||n("grid.unknown")),v=Be(o),y="current"===v.entityRole;let w;if(g)if(y){const e=t.entities?.current,n=e?i.states[e]:null,o=n&&parseFloat(n.state)||0;w=`${v.format(o)}A`}else w=`${je(h)}${qe(h)}`;else w="";const x=a||"unknown";let $="";if("unknown"!==x){const e=_[x]??_.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};$=e.icon2?`\n \n \n `:e.textLabel?`\n \n ${e.textLabel}\n `:``}let S="";if(null!=s?.utilization_pct){const e=s.utilization_pct;S=`${Math.round(e)}%`}const C=g?'ON':'OFF';return`\n
\n ${m?`${m}`:""}\n ${b}\n ${$}\n ${S}\n ${C}\n \n ${w}\n \n \n
\n `}function Ot(e,t,n,i,o,s){const a=Ke(e,t,0,"1","single",n,i,o,s,!0);return`
${a}
`}function Rt(e){return`
${De(e)}
`}function qt(e,t,n){const i=e.entities?.switch,o=i?t.states[i]:null,s=e.entities?.power,a=s?t.states[s]:null,l=o?"on"===o.state:(a?.attributes?.relay_state||e.relay_state)===r;let c;if("current"===(n.chart_metric||"power")){const n=e.entities?.current,i=n?t.states[n]:null;c=i?Math.abs(parseFloat(i.state)||0):0}else c=a?Math.abs(parseFloat(a.state)||0):0;return{isOn:l,value:c}}function jt(e,t){if(e.always_on)return"always_on";const n=e.entities?.select,i=n?t.states[n]:null;return i?i.state:"unknown"}function Ut(e,t,n){return e.sort((e,i)=>{const o=qt(e[1],t,n),s=qt(i[1],t,n);return o.isOn&&!s.isOn?-1:!o.isOn&&s.isOn?1:s.value-o.value})}function Gt(e){return e.entities?.current??e.entities?.power??""}class Ft{constructor(e){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._ctrl=e}renderActivityView(e,t,n,i,o){this._unbindEvents(),this._hass=t,this._topology=n,this._config=i,this._monitoringStatus=o;const s=Ut(Object.entries(n.circuits),t,i);let a=Lt(this._searchQuery)+Ht(i);a+='
';for(const[e,n]of s){const s=Je(o,Gt(n)),r=jt(n,t),l=this._expandedUuids.has(e);a+=It(e,n,t,i,s,r,l),l&&(a+=Ot(e,n,t,i,s,r))}a+="
",a+="",e.innerHTML=a;const r=e.querySelector("span-side-panel");r&&(r.hass=t),this._bindEvents(e),this._searchQuery&&this._applyFilter(e),this._ctrl.updateDOM(e)}renderAreaView(e,t,i,o,s){this._unbindEvents(),this._hass=t,this._topology=i,this._config=o,this._monitoringStatus=s;const a=n("list.unassigned_area"),r=new Map;for(const[e,t]of Object.entries(i.circuits)){const n=t.area??a,i=r.get(n);i?i.push([e,t]):r.set(n,[[e,t]])}const l=[...r.keys()].sort((e,t)=>e===a?1:t===a?-1:e.localeCompare(t));let c=Lt(this._searchQuery)+Ht(o);c+='
';for(const e of l){const n=r.get(e);if(!n)continue;const i=Ut(n,t,o);c+=Rt(e);for(const[e,n]of i){const i=Je(s,Gt(n)),a=jt(n,t),r=this._expandedUuids.has(e);c+=It(e,n,t,o,i,a,r),r&&(c+=Ot(e,n,t,o,i,a))}}c+="
",c+="",e.innerHTML=c;const d=e.querySelector("span-side-panel");d&&(d.hass=t),this._bindEvents(e),this._searchQuery&&this._applyFilter(e),this._ctrl.updateDOM(e)}updateCollapsedRows(e,t,n,i){const o=Be(i),s="current"===o.entityRole,a=e.querySelectorAll(".list-row[data-row-uuid]");for(const e of a){const a=e.dataset.rowUuid;if(!a)continue;const r=n.circuits[a];if(!r)continue;const{isOn:l,value:c}=qt(r,t,i),d=e.querySelector(".list-power-value");if(d)if(l)if(s)d.innerHTML=`${o.format(c)}A`;else{const e=r.entities?.power,n=e?t.states[e]:null,i=n&&parseFloat(n.state)||0;d.innerHTML=`${je(i)}${qe(i)}`}else d.innerHTML="";const h=e.querySelector(".list-status-badge");h&&(h.textContent=l?"ON":"OFF",h.classList.toggle("list-status-on",l),h.classList.toggle("list-status-off",!l)),e.classList.toggle("circuit-off",!l)}}stop(){this._unbindEvents(),this._expandedUuids.clear(),this._searchQuery="",this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_bindEvents(e){this._container=e,this._clickHandler=t=>{const n=t.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const e=i.dataset.expandUuid;return void(e&&this._toggleExpand(e))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(t,e);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(t,e);if(n.closest(".list-search-clear")){const t=e.querySelector(".list-search");return void(t&&(t.value="",t.dispatchEvent(new Event("input",{bubbles:!0}))))}const o=n.closest(".unit-btn");if(o){const t=o.dataset.unit;t&&e.dispatchEvent(new CustomEvent("unit-changed",{detail:t,bubbles:!0,composed:!0}))}},this._inputHandler=t=>{const n=t.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(e))},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(e).then(()=>{this._ctrl.updateDOM(e)}).catch(()=>{})},e.addEventListener("click",this._clickHandler),e.addEventListener("input",this._inputHandler),e.addEventListener("graph-settings-changed",this._graphSettingsHandler)}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_applyFilter(e){const t=e.querySelector(".list-search-clear");t&&(t.style.display=this._searchQuery?"":"none");const n=e.querySelectorAll(".list-row[data-row-uuid]");for(const t of n){const n=t.querySelector(".list-circuit-name"),i=(n?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=i?"":"none";const o=t.dataset.rowUuid;if(o){const t=e.querySelector(`.list-expanded-content[data-expanded-uuid="${o}"]`);t&&(t.style.display=i?"":"none")}}const i=e.querySelectorAll(".area-header");for(const e of i){let t=!1,n=e.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-row")&&"none"!==n.style.display){t=!0;break}n=n.nextElementSibling}e.style.display=t?"":"none"}}_toggleExpand(e){if(!(this._container&&this._hass&&this._topology&&this._config))return;const t=this._container.querySelector(`.list-row[data-row-uuid="${e}"]`),n=this._container.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(t)if(this._expandedUuids.has(e)){this._expandedUuids.delete(e);const i=this._container.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);i&&i.remove(),n&&n.classList.remove("expanded"),t.classList.remove("list-row-expanded")}else{this._expandedUuids.add(e);const i=this._topology.circuits[e];if(!i)return;const o=Je(this._monitoringStatus,Gt(i)),s=jt(i,this._hass),a=Ot(e,i,this._hass,this._config,o,s);t.insertAdjacentHTML("afterend",a),n&&n.classList.add("expanded"),t.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}}}let Wt=class extends $e{constructor(){super(...arguments),this.narrow=!1,this._panels=[],this._selectedPanelId=null,this._activeTab="dashboard",this._discovered=!1,this._discoveryError=null,this._dashboardTab=new kt,this._monitoringTab=new Dt,this._listDashCtrl=new Ct,this._listCtrl=new Ft(this._listDashCtrl),this._areaUnsub=null,this._onVisibilityChange=null,this._deviceRegistryUnsub=null}connectedCallback(){super.connectedCallback(),this._onVisibilityChange=()=>{"visible"===document.visibilityState&&this._discovered&&this.hass&&this._scheduleTabRender()},document.addEventListener("visibilitychange",this._onVisibilityChange),this._subscribeDeviceRegistry()}disconnectedCallback(){this._dashboardTab.stop(),this._monitoringTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals(),this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._onVisibilityChange&&(document.removeEventListener("visibilitychange",this._onVisibilityChange),this._onVisibilityChange=null),this._unsubscribeDeviceRegistry(),super.disconnectedCallback()}firstUpdated(){this.hass&&!this._discovered&&this._discoverPanels()}updated(e){if(e.has("hass")){const t=e.get("hass");this._dashboardTab.hass=this.hass,this._listDashCtrl.hass=this.hass;const n=this.renderRoot.querySelector("ha-menu-button");n&&(n.hass=this.hass,n.narrow=this.narrow),this._discovered?this.shadowRoot.getElementById("tab-content")||this._scheduleTabRender():this._discoverPanels(),!t&&this.hass&&this._subscribeDeviceRegistry()}if(e.has("narrow")){const e=this.renderRoot.querySelector("ha-menu-button");e&&(e.narrow=this.narrow)}if(this._discovered&&(e.has("_discovered")||e.has("_activeTab")||e.has("_selectedPanelId")||e.has("_chartMetric"))&&this._scheduleTabRender(),e.has("hass")&&this._discovered&&("activity"===this._activeTab||"area"===this._activeTab)){const e=this.shadowRoot.getElementById("tab-content"),t=this._listDashCtrl.topology;if(e&&t){this._listCtrl.updateCollapsedRows(e,this.hass,t,this._buildDashboardConfig());const n=e.querySelector("span-side-panel");n&&(n.hass=this.hass)}}}setConfig(e){}render(){var i,o,s;return i=this.hass?.language,e=i&&t[i]?i:"en",this._discovered?se` + */class De extends Te{constructor(e){if(super(e),this.it=de,e.type!==Ie)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(e){if(e===de||null==e)return this._t=void 0,this.it=e;if(e===ce)return e;if("string"!=typeof e)throw Error(this.constructor.directiveName+"() called with a non-string value");if(e===this.it)return this._t;this.it=e;const t=[e];return t.raw=t,this._t={_$litType$:this.constructor.resultType,strings:t,values:[]}}}De.directiveName="unsafeHTML",De.resultType=1;const Fe=(e=>(...t)=>({_$litDirective$:e,values:t}))(De);class Le{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(e){const t={...e,timestamp:Date.now()};if(t.persistent)this._persistent.set(t.key,t);else{this._clearTransient(),this._transient=t;const e=t.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},e)}this._notify()}remove(e){if(this._persistent.has(e))return this._persistent.delete(e),void this._notify();this._transient?.key===e&&(this._clearTransient(),this._notify())}clear(e){void 0===e?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===e.persistent?this._persistent.clear():!1===e.persistent&&this._clearTransient(),this._notify()}get active(){const e=[...this._persistent.values()];return null!==this._transient&&e.push(this._transient),e}hasPersistent(e){return this._persistent.has(e)}hasAnyPanelOffline(){for(const e of this._persistent.keys())if("panel-offline"===e||e.startsWith("panel-offline:"))return!0;return!1}subscribe(e){return this._subscribers.add(e),()=>{this._subscribers.delete(e)}}watchPanelStatus(e){this.watchPanelStatuses([{entityId:e,panelName:null}])}watchPanelStatuses(e){const t=this._watchedPanels,n=new Map;for(const i of e){const e=t.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:e?.wasOffline??!1})}const i=this._isSingleUnnamed(t),s=this._isSingleUnnamed(n);for(const e of t.keys()){n.has(e)&&i===s||this._persistent.delete(this._offlineKey(e,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const t of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(t,e));this._watchedPanels.clear(),this._notify()}updateHass(e){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const[s,o]of this._watchedPanels){const r=e.states[s]?.state,a="on"===r,l=this._offlineKey(s,t),c=this._reconnectKey(s,t);if(a){const e=o.wasOffline;o.wasOffline=!1,this.remove(l),e&&this.add({key:c,level:"info",message:null===o.panelName?n("error.panel_reconnected"):i("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?n("error.panel_offline"):i("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(e){if(1!==e.size)return!1;for(const t of e.values())return null===t.panelName;return!1}_offlineKey(e,t){return t?"panel-offline":`panel-offline:${e}`}_reconnectKey(e,t){return t?"panel-reconnected":`panel-reconnected:${e}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const e of this._subscribers)try{e()}catch(e){console.warn("SPAN Panel: error-store subscriber threw",e)}}}const He={"&":"&","<":"<",">":">",'"':""","'":"'"};function Oe(e){return String(e).replace(/[&<>"']/g,e=>He[e]??e)}const Re="span_panel_list_columns";function qe(){try{const e=localStorage.getItem(Re);if(!e)return 1;const t=parseInt(e,10);return 1===t||2===t||3===t?t:1}catch{return 1}}function je(e){try{localStorage.setItem(Re,String(e))}catch{}}function Ue(e){return new Promise(t=>setTimeout(t,e))}class We{constructor(e){this._store=e}async callWS(e,t,n){const i=n?.retries??3,s=n?.errorId??`ws:${String(t.type??"unknown")}`;return this._withRetry(()=>e.callWS(t),i,s,n?.errorMessage)}async callService(e,t,n,i,s,o){const r=o?.retries??3,a=o?.errorId??`svc:${t}.${n}`;return this._withRetry(()=>e.callService(t,n,i,s),r,a,o?.errorMessage)}async _withRetry(e,t,i,s){if(this._store.hasAnyPanelOffline())try{const t=await e();return this._store.remove(i),t}catch(e){const t=e instanceof Error?e:new Error(String(e));throw this._store.add({key:i,level:"error",message:s??n("error.panel_offline"),persistent:!1}),t}let o;for(let n=0;n<=t;n++)try{const t=await e();return this._store.remove(i),t}catch(e){if(o=e instanceof Error?e:new Error(String(e)),n{try{const t={type:"call_service",domain:a,service:"get_favorites",service_data:{},return_response:!0},s=this._retry?await this._retry.callWS(e,t,{errorId:"fetch:favorites",errorMessage:n("error.favorites_fetch_failed")}):await e.callWS(t),o=s?.response?.favorites??{};return i===this._generation&&(this._map=o,this._lastFetch=Date.now()),o}catch(e){return console.warn("SPAN Panel: favorites fetch failed",e),this._retry||this._errorStore?.add({key:"fetch:favorites",level:"warning",message:n("error.favorites_fetch_failed"),persistent:!1}),this._map??{}}finally{this._inflight?.gen===i&&(this._inflight=null)}})();return this._inflight={gen:i,promise:s},s}invalidate(){this._lastFetch=0,this._generation++}clear(){this._map=null,this._lastFetch=0,this._generation++}get map(){return this._map??{}}}function Qe(e){for(const t of Object.values(e)){if((t.circuits?.length??0)>0)return!0;if((t.sub_devices?.length??0)>0)return!0}return!1}const Ke=Object.keys(f).filter(e=>"unknown"!==e&&"always_on"!==e);class Je extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(e){this._hass=e,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(e){this._config=e,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(e))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const e of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[e]);this._debounceTimers={}}_modeFor(e){return e.favoritesMode?"favorites":e.panelMode?"panel":e.subDeviceMode?"subDevice":"circuit"}_render(){const e=this._config;if(!e)return;const t=this.shadowRoot;if(!t)return;t.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart ha-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',t.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),t.appendChild(i);const s=document.createElement("div");s.className="panel",t.appendChild(s),e.favoritesMode?this._renderFavoritesMode(s):e.panelMode?this._renderPanelMode(s):e.subDeviceMode?this._renderSubDeviceMode(s,e):this._renderCircuitMode(s,e)}_renderPanelMode(e){const t=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.global_defaults"));e.appendChild(i);const s=document.createElement("div");s.className="panel-body";const a=t.graphSettings,l=t.topology,c=a?.global_horizon??o,d=a?.circuits??{};s.appendChild(this._buildListColumnsSection());const h=document.createElement("div");h.className="section";const p=document.createElement("div");p.className="section-label",p.textContent=n("sidepanel.graph_horizon"),h.appendChild(p);const g=document.createElement("div");g.className="field-row";const _=document.createElement("span");_.className="field-label",_.textContent=n("sidepanel.global_default"),g.appendChild(_);const f=document.createElement("select");for(const e of Object.keys(r)){const t=document.createElement("option");t.value=e;const i=`horizon.${e}`,s=n(i);t.textContent=s!==i?s:e,e===c&&(t.selected=!0),f.appendChild(t)}if(f.addEventListener("change",()=>{const e={horizon:f.value};t.configEntryId&&(e.config_entry_id=t.configEntryId),this._callDomainService("set_graph_time_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),g.appendChild(f),h.appendChild(g),s.appendChild(h),l?.circuits){const e=document.createElement("div");e.className="section";const i=document.createElement("div");i.className="section-label",i.textContent=n("sidepanel.circuit_scales"),e.appendChild(i);const o=Object.entries(l.circuits).sort(([,e],[,t])=>(e.name||"").localeCompare(t.name||""));for(const[n,i]of o){const s=this._buildPanelModeCircuitRow(n,i,d[n],c,t.configEntryId??null,t.showFavorites??!1,t.favoritePanelDeviceId,t.favoriteCircuitUuids);e.appendChild(s)}s.appendChild(e)}const v=a?.sub_devices??{};if(l?.sub_devices){const e=document.createElement("div");e.className="section";const i=document.createElement("div");i.className="section-label",i.textContent=n("sidepanel.subdevice_scales"),e.appendChild(i);const o=Object.entries(l.sub_devices).sort(([,e],[,t])=>(e.name||"").localeCompare(t.name||""));for(const[i,s]of o){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");if(a.className="field-label",a.textContent=s.name||i,a.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",o.appendChild(a),t.showFavorites&&t.favoritePanelDeviceId){const e=this._buildSubDeviceFavoriteHeart(s.entities,t.favoriteSubDeviceIds?.has(i)??!1);e&&o.appendChild(e)}const l=v[i]||{horizon:c,has_override:!1},d=l.has_override?l.horizon:c,h=document.createElement("select");h.dataset.subdevId=i;for(const e of Object.keys(r)){const t=document.createElement("option");t.value=e;const i=`horizon.${e}`,s=n(i);t.textContent=s!==i?s:e,e===d&&(t.selected=!0),h.appendChild(t)}if(h.addEventListener("change",()=>{this._debounce(`subdev-${i}`,u,()=>{const e={subdevice_id:i,horizon:h.value};t.configEntryId&&(e.config_entry_id=t.configEntryId),this._callDomainService("set_subdevice_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})})}),o.appendChild(h),l.has_override){const e=document.createElement("button");e.textContent="↺",e.title=n("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const s={subdevice_id:i};t.configEntryId&&(s.config_entry_id=t.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",s).then(()=>{h.value=c,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),o.appendChild(e)}e.appendChild(o)}s.appendChild(e)}e.appendChild(s)}_buildPanelModeCircuitRow(e,t,i,s,o,a,l,c){const d=document.createElement("div");d.className="field-row";const h=document.createElement("span");if(h.className="field-label",h.textContent=t.name||e,h.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",d.appendChild(h),a&&l){const n=this._buildFavoriteHeart(t.entities,c?.has(e)??!1);n&&d.appendChild(n)}const p=i||{horizon:s,has_override:!1},g=p.has_override?p.horizon:s,_=document.createElement("select");_.dataset.uuid=e;for(const e of Object.keys(r)){const t=document.createElement("option");t.value=e;const i=`horizon.${e}`,s=n(i);t.textContent=s!==i?s:e,e===g&&(t.selected=!0),_.appendChild(t)}if(_.addEventListener("change",()=>{this._debounce(`circuit-${e}`,u,()=>{const t={circuit_id:e,horizon:_.value};o&&(t.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})})}),d.appendChild(_),p.has_override){const t=document.createElement("button");t.textContent="↺",t.title=n("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const i={circuit_id:e};o&&(i.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",i).then(()=>{_.value=s,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),d.appendChild(t)}return d}_renderFavoritesMode(e){const t=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.favorites_subtitle"));e.appendChild(i);const s=document.createElement("div");s.className="panel-body",s.appendChild(this._buildListColumnsSection());for(const e of t.perPanelSections)s.appendChild(this._buildFavoritesPanelSection(e));e.appendChild(s)}_buildFavoritesPanelSection(e){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=e.panelName,t.appendChild(n);const i=e.graphSettings?.global_horizon??o,s=e.graphSettings?.circuits??{},r=function(e){const t=e.circuits??{};return Object.entries(t).map(([e,t])=>({uuid:e,circuit:t})).sort((e,t)=>(e.circuit.name||"").localeCompare(t.circuit.name||""))}(e.topology);for(const{uuid:n,circuit:o}of r){const r=this._buildPanelModeCircuitRow(n,o,s[n],i,e.configEntryId,!0,e.panelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}return t}_renderCircuitMode(e,t){const n=`${Oe(String(t.breaker_rating_a))}A · ${Oe(String(t.voltage))}V · Tabs [${Oe(String(t.tabs))}]`,i=this._createHeader(Oe(t.name),n);e.appendChild(i);const s=document.createElement("div");s.className="panel-body",e.appendChild(s),this._renderRelaySection(s,t),t.showFavorites&&this._renderFavoriteSection(s,t),this._renderSheddingSection(s,t),this._renderGraphHorizonSection(s,t),t.showMonitoring&&this._renderMonitoringSection(s,t)}_favoriteEntityId(e){return e?.current??e?.power??null}_subDeviceFavoriteEntityId(e){if(!e)return null;let t=null;for(const[n,i]of Object.entries(e)){if("sensor"===i.domain)return n;t||(t=n)}return t}_buildSubDeviceFavoriteHeart(e,t){const n=this._subDeviceFavoriteEntityId(e);return n?this._buildHeartButton(n,t):null}_buildListColumnsSection(){const e=document.createElement("div");e.className="section";const t=document.createElement("div");t.className="section-label",t.textContent=n("sidepanel.list_view_columns"),e.appendChild(t);const i=document.createElement("div");i.className="field-row";const s=document.createElement("span");s.className="field-label",s.textContent=n("sidepanel.columns"),i.appendChild(s);const o=qe(),r=document.createElement("div");r.className="unit-toggle";for(const e of[1,2,3]){const t=document.createElement("button");t.type="button",t.className="unit-btn"+(e===o?" unit-active":""),t.dataset.columns=String(e),t.textContent=String(e),t.addEventListener("click",()=>{je(e);for(const e of r.querySelectorAll(".unit-btn"))e.classList.toggle("unit-active",e===t);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:e,bubbles:!0,composed:!0}))}),r.appendChild(t)}return i.appendChild(r),e.appendChild(i),e}_buildFavoriteHeart(e,t){const n=this._favoriteEntityId(e);return n?this._buildHeartButton(n,t):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(e,t){const i=document.createElement("button");i.type="button",i.className=t?"fav-heart active":"fav-heart",i.dataset.role="fav-heart",i.title=n("sidepanel.save_to_favorites"),i.setAttribute("role","switch"),i.setAttribute("aria-checked",String(t)),i.setAttribute("aria-label",n("sidepanel.save_to_favorites"));const s=document.createElement("ha-icon");return s.setAttribute("icon",t?"mdi:heart":"mdi:heart-outline"),i.appendChild(s),i.addEventListener("click",t=>{t.stopPropagation(),this._toggleFavoriteEntity(i,s,e).catch(()=>{})}),i}async _toggleFavoriteEntity(e,t,i){if(!this._hass)return;const s=e.classList.contains("active"),o=!s;e.classList.toggle("active",o),t.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),e.setAttribute("aria-checked",String(o));try{o?await async function(e,t){const n=await Ve(e,"add_favorite",{entity_id:t});return document.dispatchEvent(new CustomEvent(Ge)),n?.favorites??{}}(this._hass,i):await async function(e,t){const n=await Ve(e,"remove_favorite",{entity_id:t});return document.dispatchEvent(new CustomEvent(Ge)),n?.favorites??{}}(this._hass,i)}catch(i){throw e.classList.toggle("active",s),t.setAttribute("icon",s?"mdi:heart":"mdi:heart-outline"),e.setAttribute("aria-checked",String(s)),console.warn("SPAN Panel: favorite toggle failed",i),this.errorStore?.add({key:"service:favorites",level:"error",message:n("error.favorites_toggle_failed"),persistent:!1}),i}}_renderFavoriteSection(e,t){const n=this._favoriteEntityId(t.entities);n&&this._appendFavoriteHeartSection(e,n,!0===t.isFavorite)}_appendFavoriteHeartSection(e,t,i){const s=document.createElement("div");s.className="section",s.innerHTML=``;const o=document.createElement("div");o.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=n("sidepanel.save_to_favorites"),o.appendChild(r),o.appendChild(this._buildHeartButton(t,i)),s.appendChild(o),e.appendChild(s)}_renderSubDeviceMode(e,t){const n=this._createHeader(Oe(t.name),Oe(t.deviceType));e.appendChild(n);const i=document.createElement("div");i.className="panel-body",e.appendChild(i),t.showFavorites&&this._renderSubDeviceFavoriteSection(i,t),this._renderSubDeviceHorizonSection(i,t)}_renderSubDeviceFavoriteSection(e,t){const n=this._subDeviceFavoriteEntityId(t.entities);n&&this._appendFavoriteHeartSection(e,n,!0===t.isFavorite)}_renderSubDeviceHorizonSection(e,t){const i=document.createElement("div");i.className="section";const s=document.createElement("div");s.className="section-label",s.textContent=n("sidepanel.graph_horizon"),i.appendChild(s);const a=t.graphHorizonInfo,l=!0===a?.has_override,c=a?.horizon||o,d=a?.globalHorizon||o,h=document.createElement("div");h.className="horizon-bar";const p=[{key:"global",label:n("sidepanel.global")}];for(const e of Object.keys(r))p.push({key:e,label:e});const u=l?c:"global",g=e=>{for(const t of h.querySelectorAll(".horizon-segment")){const n=t.dataset.horizon;t.classList.toggle("active",n===e),t.classList.toggle("referenced","global"===e&&n===d)}};for(const{key:e,label:i}of p){const s=document.createElement("button");s.type="button",s.className="horizon-segment",s.dataset.horizon=e,s.textContent=i,s.classList.toggle("active",e===u),s.classList.toggle("referenced","global"===u&&e===d),s.addEventListener("click",()=>{if(s.classList.contains("active"))return;const i={subdevice_id:t.subDeviceId};t.configEntryId&&(i.config_entry_id=t.configEntryId),"global"===e?(g("global"),this._callDomainService("clear_subdevice_graph_horizon",i).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})):(g(e),this._callDomainService("set_subdevice_graph_horizon",{...i,horizon:e}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(s)}i.appendChild(h),e.appendChild(i)}_createHeader(e,t){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),s=Oe(e),o=Oe(t);i.innerHTML=`
${s}
`+(o?`
${o}
`:"");const r=document.createElement("button");return r.className="close-btn",r.innerHTML="✕",r.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(r),n}_renderRelaySection(e,t){if(!1===t.is_user_controllable||!t.entities?.switch)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const s=document.createElement("div");s.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=n("sidepanel.breaker");const r=document.createElement("ha-switch");r.dataset.role="relay-toggle";const a=t.entities.switch,l=this._hass?.states?.[a]?.state;"on"===l&&r.setAttribute("checked",""),r.addEventListener("change",()=>{const e=r.hasAttribute("checked")||r.checked;this._callService("switch",e?"turn_on":"turn_off",{entity_id:a}).catch(e=>{console.warn("SPAN Panel: relay toggle failed",e),this.errorStore?.add({key:"service:relay",level:"error",message:n("error.relay_failed"),persistent:!1})})}),s.appendChild(o),s.appendChild(r),i.appendChild(s),e.appendChild(i)}_renderSheddingSection(e,t){if(!t.entities?.select)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const s=document.createElement("div");s.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=n("sidepanel.priority_label");const r=document.createElement("select");r.dataset.role="shedding-select";const a=t.entities.select,l=this._hass?.states?.[a]?.state||"";for(const e of Ke){const t=f[e];if(!t)continue;const i=document.createElement("option");i.value=e,i.textContent=n(`shedding.select.${e}`)||t.label(),e===l&&(i.selected=!0),r.appendChild(i)}r.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:a,option:r.value}).catch(e=>{console.warn("SPAN Panel: shedding update failed",e),this.errorStore?.add({key:"service:shedding",level:"error",message:n("error.shedding_failed"),persistent:!1})})}),s.appendChild(o),s.appendChild(r),i.appendChild(s),e.appendChild(i)}_renderGraphHorizonSection(e,t){const i=document.createElement("div");i.className="section";const s=document.createElement("div");s.className="section-label",s.textContent=n("sidepanel.graph_horizon"),i.appendChild(s);const a=t.graphHorizonInfo,l=!0===a?.has_override,c=a?.horizon||o,d=a?.globalHorizon||o,h=document.createElement("div");h.className="horizon-bar";const p=[{key:"global",label:n("sidepanel.global")}];for(const e of Object.keys(r))p.push({key:e,label:e});const u=l?c:"global",g=e=>{for(const t of h.querySelectorAll(".horizon-segment")){const n=t.dataset.horizon;t.classList.toggle("active",n===e),t.classList.toggle("referenced","global"===e&&n===d)}};for(const{key:e,label:i}of p){const s=document.createElement("button");s.type="button",s.className="horizon-segment",s.dataset.horizon=e,s.textContent=i,s.classList.toggle("active",e===u),s.classList.toggle("referenced","global"===u&&e===d),s.addEventListener("click",()=>{if(s.classList.contains("active"))return;const i={circuit_id:t.uuid};t.configEntryId&&(i.config_entry_id=t.configEntryId),"global"===e?(g("global"),this._callDomainService("clear_circuit_graph_horizon",i).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})):(g(e),this._callDomainService("set_circuit_graph_horizon",{...i,horizon:e}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>{console.warn("SPAN Panel: graph horizon service failed",e),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(s)}i.appendChild(h),e.appendChild(i)}_renderMonitoringSection(e,t){const i=document.createElement("div");i.className="section";const s=document.createElement("div");s.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=n("sidepanel.monitoring"),o.style.margin="0";const r=document.createElement("ha-switch");r.dataset.role="monitoring-toggle";const a=t.monitoringInfo,l=null!=a&&!1!==a.monitoring_enabled;l&&r.setAttribute("checked",""),s.appendChild(o),s.appendChild(r),i.appendChild(s);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",i.appendChild(c);const d=!0===a?.has_override,h=document.createElement("div");h.className="radio-group",h.innerHTML=`\n \n \n `,c.appendChild(h);const p=document.createElement("div");p.dataset.role="threshold-fields",p.style.display=d?"block":"none";const u=a?.continuous_threshold_pct??80,g=a?.spike_threshold_pct??100,_=a?.window_duration_m??15,f=a?.cooldown_duration_m??15;p.appendChild(this._createThresholdRow(n("sidepanel.continuous_pct"),"continuous",u,t)),p.appendChild(this._createThresholdRow(n("sidepanel.spike_pct"),"spike",g,t)),p.appendChild(this._createDurationRow(n("sidepanel.window_duration"),"window-m",_,1,180,"m",t)),p.appendChild(this._createDurationRow(n("sidepanel.cooldown"),"cooldown-m",f,1,180,"m",t)),c.appendChild(p),r.addEventListener("change",()=>{const e=r.checked;c.style.display=e?"block":"none";const i={circuit_id:t.entities?.power||t.uuid,monitoring_enabled:e};t.configEntryId&&(i.config_entry_id=t.configEntryId),this._callDomainService("set_circuit_threshold",i).catch(e=>{console.warn("SPAN Panel: monitoring update failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})});const v=h.querySelectorAll('input[type="radio"]');for(const e of v)e.addEventListener("change",()=>{const i="custom"===e.value&&e.checked;if(p.style.display=i?"block":"none",!i&&e.checked){const e={circuit_id:t.entities?.power||t.uuid};t.configEntryId&&(e.config_entry_id=t.configEntryId),this._callDomainService("clear_circuit_threshold",e).catch(e=>{console.warn("SPAN Panel: monitoring update failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})}});e.appendChild(i)}_createThresholdRow(e,t,i,s){const o=document.createElement("div");o.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=e;const a=document.createElement("input");return a.type="number",a.min="0",a.max="200",a.value=String(i),a.dataset.role=`threshold-${t}`,a.addEventListener("input",()=>{this._debounce(`threshold-${t}`,u,()=>{const e=this.shadowRoot;if(!e)return;const t=e.querySelector('[data-role="threshold-continuous"]'),i=e.querySelector('[data-role="threshold-spike"]'),o=e.querySelector('[data-role="threshold-window-m"]'),r=e.querySelector('[data-role="threshold-cooldown-m"]'),a={circuit_id:s.entities?.power||s.uuid,continuous_threshold_pct:t?Number(t.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:r?Number(r.value):void 0};s.configEntryId&&(a.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",a).catch(e=>{console.warn("SPAN Panel: monitoring update failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})})}),o.appendChild(r),o.appendChild(a),o}_createDurationRow(e,t,i,s,o,r,a,l=!1){const c=document.createElement("div");c.className="field-row";const d=document.createElement("span");d.className="field-label",d.textContent=e;const h=document.createElement("div"),p=document.createElement("input");p.type="number",p.min=String(s),p.max=String(o),p.value=String(i),p.dataset.role=`threshold-${t}`,l&&(p.disabled=!0);const g=document.createElement("span");return g.textContent=r,h.appendChild(p),h.appendChild(g),l||p.addEventListener("input",()=>{this._debounce(`threshold-${t}`,u,()=>{const e=this.shadowRoot;if(!e)return;const t=e.querySelector('[data-role="threshold-continuous"]'),i=e.querySelector('[data-role="threshold-spike"]'),s=e.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:a.uuid,continuous_threshold_pct:t?Number(t.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:s?Number(s.value):void 0};a.configEntryId&&(o.config_entry_id=a.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(e=>{console.warn("SPAN Panel: monitoring update failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})})}),c.appendChild(d),c.appendChild(h),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const e=this._config;if(!e.subDeviceMode&&!e.favoritesMode){if(e.entities?.switch){const t=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(t){const n=this._hass?.states?.[e.entities.switch]?.state;"on"===n?t.setAttribute("checked",""):t.removeAttribute("checked")}}if(e.entities?.select){const t=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(t){const n=this._hass?.states?.[e.entities.select]?.state||"";t.value=n}}}}_callService(e,t,n){return this._hass?Promise.resolve(this._hass.callService(e,t,n)):Promise.resolve()}_callDomainService(e,t){return this._hass?this._hass.callWS({type:"call_service",domain:a,service:e,service_data:t}):Promise.resolve()}_debounce(e,t,n){this._debounceTimers[e]&&clearTimeout(this._debounceTimers[e]),this._debounceTimers[e]=setTimeout(()=>{delete this._debounceTimers[e],n()},t)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",Je)}catch{}let Xe=class extends Pe{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(e){if(this._store===e)return;this._unsub?.(),this._unsub=null,this._store=e,this._errors=e.active;const t=e;this._unsub=e.subscribe(()=>{this._errors=t.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const e=this._store;this._errors=e.active,this._unsub=e.subscribe(()=>{this._errors=e.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?de:le`${this._errors.map(e=>le` + + `)}`}_iconForLevel(e){switch(e){case"error":return"mdi:alert-circle";case"warning":return"mdi:alert";default:return"mdi:information"}}};async function Ze(e,t){const[n,i,s]=await Promise.all([e.callWS({type:"config/area_registry/list"}),e.callWS({type:"config/entity_registry/list"}),e.callWS({type:"config/device_registry/list"})]),o=new Map;for(const e of n)o.set(e.area_id,e.name);const r=new Map;for(const e of i)e.area_id&&r.set(e.entity_id,e.area_id);const a=new Map;for(const e of s)a.set(e.id,e.area_id);let l;if(t.device_id){const e=a.get(t.device_id);e&&(l=o.get(e))}for(const e of Object.values(t.circuits)){let t;for(const n of Object.values(e.entities)){if(!n)continue;const e=r.get(n);if(e){t=o.get(e);break}}t||(t=l),e.area=t}}async function Ye(e,t,i){if(!t)throw new Error(n("card.device_not_found"));const s={type:`${a}/panel_topology`,device_id:t},o=i?await i.callWS(e,s,{errorId:"fetch:topology"}):await e.callWS(s),r=o.panel_size??function(e){let t=0;for(const n of Object.values(e))if(n)for(const e of n.tabs)e>t&&(t=e);return t>0?t+t%2:0}(o.circuits);if(!r)throw new Error(n("card.topology_error"));const l={type:"config/device_registry/list"},c=i?await i.callWS(e,l,{errorId:"fetch:topology"}):await e.callWS(l),d=(h=c.find(e=>e.id===t),h?{id:h.id,name:h.name,name_by_user:h.name_by_user,config_entries:h.config_entries,identifiers:h.identifiers,via_device_id:h.via_device_id,sw_version:h.sw_version,model:h.model}:null);var h;return await Ze(e,o),{topology:o,panelDevice:d,panelSize:r}}function et(){return`
\n ${Object.entries(f).filter(([e])=>"unknown"!==e).map(([,e])=>{const t=Oe(e.icon),n=Oe(e.color),i=Oe(e.label());let s;if(e.icon2){s=``}else if(e.textLabel){s=`${Oe(e.textLabel)}`}else s=``;return`
${s}${i}
`}).join("")}\n
`}function tt(e,t,i){const s="current"===(t.chart_metric||"power"),o=!!e.panel_entities?.site_power,r=!!e.panel_entities?.dsm_state,a=!!e.panel_entities?.current_power,l=!!e.panel_entities?.feedthrough_power,c=!!e.panel_entities?.pv_power,d=!!e.panel_entities?.battery_level;return`\n
\n ${o?`\n
\n ${n("header.site")}\n
\n 0\n ${s?"A":"kW"}\n
\n
`:""}\n ${r?`\n
\n ${n("header.grid")}\n
\n --\n
\n
`:""}\n ${a?`\n
\n ${n("header.upstream")}\n
\n --\n ${s?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${n("header.downstream")}\n
\n --\n ${s?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${n("header.solar")}\n
\n --\n ${s?"A":"kW"}\n
\n
`:""}\n ${d?`\n
\n ${n("header.battery")}\n
\n \n %\n
\n
`:""}\n
\n `}function nt(e,t,i={}){const s=Oe(e.device_name||n("header.default_name")),o=Oe(e.serial||""),r=Oe(e.firmware||""),a="current"===(t.chart_metric||"power"),l=!1!==i.showSwitches;return`\n
\n
\n
\n

${s}

\n ${o}\n \n ${l?`
\n ${Oe(n("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${tt(e,t)}\n
\n
\n
\n ${r}\n
\n \n \n
\n
\n ${et()}\n
\n
\n `}Xe.styles=C` + :host { + display: block; + } + .banner-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 13px; + line-height: 1.4; + } + .banner-row + .banner-row { + border-top: 1px solid rgba(128, 128, 128, 0.2); + } + .banner-row.level-error { + background: color-mix(in srgb, var(--error-color, #db4437) 15%, transparent); + color: var(--error-color, #db4437); + } + .banner-row.level-warning { + background: color-mix(in srgb, var(--warning-color, #ff9800) 15%, transparent); + color: var(--warning-color, #ff9800); + } + .banner-row.level-info { + background: color-mix(in srgb, var(--info-color, #4285f4) 15%, transparent); + color: var(--info-color, #4285f4); + } + .icon { + flex-shrink: 0; + width: 18px; + height: 18px; + --mdc-icon-size: 18px; + } + .message { + flex: 1; + min-width: 0; + } + .retry-btn { + flex-shrink: 0; + background: none; + border: 1px solid currentColor; + border-radius: 4px; + color: inherit; + cursor: pointer; + font-size: 12px; + padding: 2px 8px; + } + .retry-btn:hover { + opacity: 0.8; + } + `,m([Me()],Xe.prototype,"_errors",void 0),Xe=m([Ee("span-error-banner")],Xe);const it=g.power;function st(e){return it.unit(e)}function ot(e){return(e<0?"-":"")+it.format(e)}function rt(e){return(Math.abs(e)/1e3).toFixed(1)}function at(e){return Math.ceil(e/2)}function lt(e){return e%2==0?1:0}function ct(e){if(2!==e.length)return null;const[t,n]=[Math.min(...e),Math.max(...e)];return at(t)===at(n)?"row-span":lt(t)===lt(n)?"col-span":"row-span"}function dt(e){const t=e.chart_metric??s;return g[t]??g[s]}function ht(e,t){const n=function(e){return dt(e).entityRole}(t);return e.entities?.[n]??e.entities?.power??null}class pt{constructor(){this._status=null,this._lastFetch=0,this._inflight=null,this._generation=0,this._errorStore=null,this._retry=null}get errorStore(){return this._errorStore}set errorStore(e){this._errorStore=e,this._retry=e?new We(e):null}async fetch(e,t){const i=Date.now();if(this._inflight&&this._inflight.gen===this._generation)return this._inflight.promise;if(this._status&&i-this._lastFetch<3e4)return this._status;const s=this._generation,o=(async()=>{try{const i={};t&&(i.config_entry_id=t);const o={type:"call_service",domain:a,service:"get_monitoring_status",service_data:i,return_response:!0},r=this._retry?await this._retry.callWS(e,o,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await e.callWS(o),l=r?.response??null;return s===this._generation&&(this._status=l,this._lastFetch=Date.now()),l}catch(e){return console.warn("SPAN Panel: monitoring status fetch failed",e),s===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:n("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===s&&(this._inflight=null)}})();return this._inflight={gen:s,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class ut{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(e){this._errorStore=e;for(const t of this._caches.values())t.errorStore=e}async fetchOne(e,t){let n=this._caches.get(t);return n||(n=new pt,n.errorStore=this._errorStore,this._caches.set(t,n)),n.fetch(e,t)}invalidate(){for(const e of this._caches.values())e.invalidate()}clear(){this._caches.clear()}}function gt(e,t){return e?.circuits?e.circuits[t]??null:null}function _t(e){return!!e&&void 0!==e.continuous_threshold_pct}function ft(e,t,n,i){const s=[];return n||s.push("circuit-off"),i&&s.push("circuit-producer"),function(e){return!!e&&null!=e.over_threshold_since}(t)&&s.push("circuit-alert"),_t(t)&&s.push("circuit-custom-monitoring"),s.join(" ")}function vt(e,t,i,s,o,r,a,d,h,p=!1){const u=t.entities?.power,g=u?r.states[u]:null,_=g&&parseFloat(g.state)||0,m=t.device_type===c||_<0,b=t.entities?.switch,y=b?r.states[b]:null,w=y?"on"===y.state:(g?.attributes?.relay_state||t.relay_state)===l,x=t.breaker_rating_a,S=x?`${Math.round(x)}A`:"",$=Oe(t.name||n("grid.unknown")),C=dt(a);let P;if("current"===C.entityRole){const e=t.entities?.current,n=e?r.states[e]:null,i=n&&parseFloat(n.state)||0;P=`${C.format(i)}A`}else P=`${ot(_)}${st(_)}`;const k=h||"unknown";let E="";if("unknown"!==k){const e=f[k]??f.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},t=Oe(e.label()),n=Oe(e.icon),i=Oe(e.color);if(e.icon2){E=`\n \n \n `}else if(e.textLabel){E=`\n \n ${Oe(e.textLabel)}\n `}else E=``}const z=d&&_t(d)?v:"#555",A=``;let N="",M=d?.utilization_pct??null;if(null==M&&t.breaker_rating_a){const e=t.entities?.current,n=e?r.states[e]:null,i=n?Math.abs(parseFloat(n.state)||0):0;M=Math.round(i/t.breaker_rating_a*1e3)/10}if(null!=M){N=`=80?"utilization-warning":"utilization-normal"}">${Math.round(M)}%`}return`\n
\n
\n
\n ${S?`${S}`:""}\n ${N}\n ${$}\n
\n
\n \n ${P}\n \n ${!1!==t.is_user_controllable&&t.entities?.switch?`\n
\n ${n(w?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${E}\n ${A}\n
\n
\n
\n `}function mt(e,t){return`\n
\n \n
\n `}const bt={names:["power","battery power"],suffixes:["_power"]},yt={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},wt={names:["state of energy"],suffixes:["_soe_kwh"]},xt={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function St(e,t){if(!e.entities)return null;for(const[n,i]of Object.entries(e.entities)){if("sensor"!==i.domain)continue;const e=(i.original_name??"").toLowerCase();if(t.names.some(t=>e===t))return n;if(i.unique_id&&t.suffixes.some(e=>i.unique_id.endsWith(e)))return n}return null}function $t(e){return St(e,bt)}function Ct(e){return St(e,yt)}function Pt(e){return St(e,wt)}function kt(e){return St(e,xt)}function Et(e,t,i){const s=!1!==i.show_battery,o=!1!==i.show_evse;if(!e.sub_devices)return"";const r=Object.entries(e.sub_devices).filter(([,e])=>!(e.type===d&&!s)&&!(e.type===h&&!o));if(0===r.length)return"";const a=r.filter(([,e])=>e.type===h).length;let l=0,c="";for(const[e,s]of r){const o=s.type===h?n("subdevice.ev_charger"):s.type===d?n("subdevice.battery"):n("subdevice.fallback"),r=$t(s),p=r?t.states[r]:void 0,u=p&&parseFloat(p.state)||0,g=s.type===d,_=s.type===h,f=g?Ct(s):null,v=g?Pt(s):null,m=g?kt(s):null,b=zt(s,t,i,new Set([r,f,v,m].filter(e=>null!==e))),y=At(e,s,g,r,f,v);let w="";g?w="sub-device-bess":_&&(l++,l===a&&a%2==1&&(w="sub-device-full")),c+=`\n
\n
\n ${Oe(o)}\n ${Oe(s.name||"")}\n ${r?`${ot(u)} ${st(u)}`:""}\n \n
\n ${y}\n ${b}\n
\n `}return c}function zt(e,t,n,i){const s=n.visible_sub_entities||{};let o="";if(!e.entities)return o;for(const[n,r]of Object.entries(e.entities)){if(i.has(n))continue;if(!0!==s[n])continue;const a=t.states[n];if(!a)continue;let l=r.original_name||a.attributes.friendly_name||n;const c=e.name||"";let d;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),t.formatEntityState)d=t.formatEntityState(a);else{d=a.state;const e=a.attributes.unit_of_measurement||"";e&&(d+=" "+e)}if("Wh"===(a.attributes.unit_of_measurement||"")){const e=parseFloat(a.state);isNaN(e)||(d=(e/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Oe(l)}:\n ${Oe(d)}\n
\n `}return o}function At(e,t,i,s,o,r){if(i){const t=[{key:`${p}${e}_soc`,title:n("subdevice.soc"),available:!!o},{key:`${p}${e}_soe`,title:n("subdevice.soe"),available:!!r},{key:`${p}${e}_power`,title:n("subdevice.power"),available:!!s}].filter(e=>e.available);return`\n
\n ${t.map(e=>`\n
\n
${Oe(e.title)}
\n
\n
\n `).join("")}\n
\n `}return s?`
`:""}function Nt(e){const t=void 0!==e.history_days||void 0!==e.history_hours||void 0!==e.history_minutes,n=60*(60*(24*(t&&parseInt(String(e.history_days))||0)+(t&&parseInt(String(e.history_hours))||0))+(t?parseInt(String(e.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function Mt(e){const t=r[e];return t?t.ms:r[o].ms}function It(e){const t=e/1e3;return t<=600?Math.ceil(t):Math.min(5e3,Math.ceil(t/5))}function Tt(e){return Math.max(500,Math.floor(e/5e3))}function Dt(e,t,n,i,s,o){e.has(t)||e.set(t,[]);const r=e.get(t);r.push({time:i,value:n});const a=r.findIndex(e=>e.time>=s);a>0?r.splice(0,a):-1===a&&(r.length=0),r.length>o&&r.splice(0,r.length-o)}function Ft(e,t,n=500){if(0===e.length)return e;e.sort((e,t)=>e.time-t.time);const i=[e[0]];for(let t=1;t=n&&i.push(e[t]);return i.length>t&&i.splice(0,i.length-t),i}async function Lt(e,t,n,i,s){const o=new Date(Date.now()-i).toISOString(),r=i/36e5>72?"hour":"5minute",a=await e.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:t,period:r,types:["mean"]});for(const[e,t]of Object.entries(a)){const i=n.get(e);if(!i||!t)continue;const o=[];for(const e of t){const t=e.mean;if(null==t||!Number.isFinite(t))continue;const n=e.start;n>0&&o.push({time:n,value:t})}if(o.length>0){const e=s.get(i)||[],t=[...o,...e];t.sort((e,t)=>e.time-t.time),s.set(i,t)}}}async function Ht(e,t,n,i,s){const o=new Date(Date.now()-i).toISOString(),r=await e.callWS({type:"history/history_during_period",start_time:o,entity_ids:t,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),a=It(i),l=Tt(i);for(const[e,t]of Object.entries(r)){const i=n.get(e);if(!i||!t)continue;const o=[];for(const e of t){const t=parseFloat(e.s);if(!Number.isFinite(t))continue;const n=1e3*(e.lu||e.lc||0);n>0&&o.push({time:n,value:t})}if(o.length>0){const e=s.get(i)||[],t=[...o,...e];s.set(i,Ft(t,a,l))}}}function Ot(e){if(!e.sub_devices)return[];const t=[];for(const[n,i]of Object.entries(e.sub_devices)){const e={power:$t(i)};i.type===d&&(e.soc=Ct(i),e.soe=Pt(i));for(const[i,s]of Object.entries(e))s&&t.push({entityId:s,key:`${p}${n}_${i}`,devId:n})}return t}async function Rt(e,t,n,i,s,o){if(!t||!e)return;const r=new Map;for(const[e,i]of Object.entries(t.circuits)){const t=ht(i,n);if(!t)continue;let o;o=s&&s.has(e)?Mt(s.get(e)):Nt(n),r.has(o)||r.set(o,{entityIds:[],uuidByEntity:new Map});const a=r.get(o);a.entityIds.push(t),a.uuidByEntity.set(t,e)}for(const{entityId:e,key:i,devId:s}of Ot(t)){let t;t=o&&o.has(s)?Mt(o.get(s)):Nt(n),r.has(t)||r.set(t,{entityIds:[],uuidByEntity:new Map});const a=r.get(t);a.entityIds.push(e),a.uuidByEntity.set(e,i)}const a=[];for(const[t,n]of r){if(0===n.entityIds.length)continue;t>2592e5?a.push(Lt(e,n.entityIds,n.uuidByEntity,t,i)):a.push(Ht(e,n.entityIds,n.uuidByEntity,t,i))}await Promise.all(a)}function qt(e,t,n,i,o,r,a,l,c){const{options:d,series:h}=function(e,t,n,i,o,r=!1){n||(n=g[s]);const a=i?"140, 160, 220":"77, 217, 175",l=`rgb(${a})`,c=Date.now(),d=c-t,h=void 0!==n.fixedMin&&void 0!==n.fixedMax,p=(e??[]).filter(e=>e.time>=d).map(e=>[e.time,Math.abs(e.value)]),u=[{type:"line",data:p,showSymbol:!1,smooth:!1,...r?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${a}, 0.18)`},{offset:1,color:`rgba(${a}, 0.18)`}]}},itemStyle:{color:l}}],_=p.length>0?function(e){let t=0;for(const n of e)n[1]>t&&(t=n[1]);return t}(p):0,f={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:_<10?e=>0===e?"0":e.toFixed(1):e=>n.format(e)},splitLine:{lineStyle:{opacity:.15}}};h?(f.min=n.fixedMin,f.max=n.fixedMax):_<1&&(f.min=0,f.max=1),o&&"current"===n.entityRole&&(f.min=0,f.max=Math.ceil(1.25*o),u.push({type:"line",data:[[d,.8*o],[c,.8*o]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),u.push({type:"line",data:[[d,o],[c,o]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const v={xAxis:{type:"time",min:d,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:f,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:e=>{if(!e||0===e.length)return"";const t=e[0],i=new Date(t.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),s=parseFloat(t.value[1].toFixed(2));return`
${i}
${n.format(s)} ${n.unit(s)}
`}},animation:!1};return{options:v,series:u}}(n,i,o,r,l,c),p=a??120;e.style.minHeight=p+"px";let u=e.querySelector("ha-chart-base");u||(u=document.createElement("ha-chart-base"),u.style.display="block",u.style.width="100%",u.hass=t,e.innerHTML="",e.appendChild(u));const _=e.clientHeight;u.height=(_>0?_:p)+"px",u.hass=t,u.options=d,u.data=h}function jt(e){return"function"==typeof globalThis.CSS?.escape?CSS.escape(e):e.replace(/["\\]/g,"\\$&")}function Ut(e,t,n,i,s){const o="current"===(i.chart_metric||"power"),r=e.querySelector(".stat-consumption .stat-value"),a=e.querySelector(".stat-consumption .stat-unit");if(o){const e=n.panel_entities?.site_power,i=e?t.states[e]:null,s=i?parseFloat(i.attributes?.amperage):NaN;r&&(r.textContent=Number.isFinite(s)?Math.abs(s).toFixed(1):"--"),a&&(a.textContent="A")}else{let e=s;const i=n.panel_entities?.site_power;if(i){const n=t.states[i];n&&(e=Math.abs(parseFloat(n.state)||0))}r&&(r.textContent=rt(e)),a&&(a.textContent="kW")}const l=e.querySelector(".stat-upstream .stat-value"),c=e.querySelector(".stat-upstream .stat-unit");if(l){const e=n.panel_entities?.current_power,i=e?t.states[e]:null;if(o){const e=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",c&&(c.textContent="A")}else{const e=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=rt(e),c&&(c.textContent="kW")}}const d=e.querySelector(".stat-downstream .stat-value"),h=e.querySelector(".stat-downstream .stat-unit");if(d){const e=n.panel_entities?.feedthrough_power,i=e?t.states[e]:null;if(o){const e=i?parseFloat(i.attributes?.amperage):NaN;d.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",h&&(h.textContent="A")}else{const e=i?Math.abs(parseFloat(i.state)||0):0;d.textContent=rt(e),h&&(h.textContent="kW")}}const p=e.querySelector(".stat-solar .stat-value"),u=e.querySelector(".stat-solar .stat-unit");if(p){const e=n.panel_entities?.pv_power,i=e?t.states[e]:null;if(o){const e=i?parseFloat(i.attributes?.amperage):NaN;p.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",u&&(u.textContent="A")}else{if(i){const e=Math.abs(parseFloat(i.state)||0);p.textContent=rt(e)}else p.textContent="--";u&&(u.textContent="kW")}}const g=e.querySelector(".stat-battery .stat-value");if(g){const e=n.panel_entities?.battery_level,i=e?t.states[e]:null;i&&(g.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const _=e.querySelector(".stat-grid-state .stat-value");if(_){const e=n.panel_entities?.dsm_state,i=e?t.states[e]:null;_.textContent=i?t.formatEntityState?.(i)||i.state:"--"}}function Wt(e,t,i,s,o,r){if(!e||!i||!t)return;const a=Nt(s);let d=0;for(const[,e]of Object.entries(i.circuits)){const n=e.entities?.power;if(!n)continue;const i=t.states[n],s=i&&parseFloat(i.state)||0;e.device_type!==c&&(d+=Math.abs(s))}!function(e,t,n,i,s){const o=e.querySelector(".panel-stats");o&&Ut(o,t,n,i,s)}(e,t,i,s,d);const h=dt(s),p="current"===h.entityRole;for(const[s,d]of Object.entries(i.circuits)){const i=e.querySelector(`.circuit-slot[data-uuid="${jt(s)}"]`);if(!i)continue;const u=d.entities?.power,g=u?t.states[u]:null,_=g&&parseFloat(g.state)||0,v=d.device_type===c||_<0,m=d.entities?.switch,b=m?t.states[m]:null,y=b?"on"===b.state:(g?.attributes?.relay_state||d.relay_state)===l,w=i.querySelector(".power-value");if(w)if(p){const e=d.entities?.current,n=e?t.states[e]:null,i=n&&parseFloat(n.state)||0;w.innerHTML=`${h.format(i)}A`}else w.innerHTML=`${ot(_)}${st(_)}`;const x=i.querySelector(".toggle-pill");if(x){x.className="toggle-pill "+(y?"toggle-on":"toggle-off");const e=x.querySelector(".toggle-label");e&&(e.textContent=n(y?"grid.on":"grid.off"))}let S;if(i.classList.toggle("circuit-off",!y),i.classList.toggle("circuit-producer",v),d.always_on)S="always_on";else{const e=d.entities?.select,n=e?t.states[e]:null;S=n?n.state:"unknown"}const $=f[S]??f.unknown,C=i.querySelector(".shedding-icon");C&&(C.setAttribute("icon",$.icon),C.style.color=$.color,C.title=$.label());const P=i.querySelector(".shedding-icon-secondary");P&&($.icon2?(P.setAttribute("icon",$.icon2),P.style.color=$.color,P.style.display=""):P.style.display="none");const k=i.querySelector(".shedding-label");k&&($.textLabel?(k.textContent=$.textLabel,k.style.color=$.color,k.style.display=""):k.style.display="none");const E=i.querySelector(".chart-container");if(E){const e=o.get(s)||[],n=i.classList.contains("circuit-col-span")?200:100,l=r?.has(s)?Mt(r.get(s)):a,p=d.device_type===c;qt(E,t,e,l,h,v,n,d.breaker_rating_a??void 0,p)}}}class Gt{get errorStore(){return this._errorStore}set errorStore(e){this._errorStore=e,this._retry=e?new We(e):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(e,t){const i=Date.now();if(this._fetching)return this._settings;if(this._settings&&i-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const i={};t&&(i.config_entry_id=t);const s={type:"call_service",domain:a,service:"get_graph_settings",service_data:i,return_response:!0},o=this._retry?await this._retry.callWS(e,s,{errorId:"fetch:graph_settings",errorMessage:n("error.graph_settings_failed")}):await e.callWS(s);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(e){console.warn("SPAN Panel: graph settings fetch failed",e),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:n("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function Vt(e,t){if(!e)return o;const n=e.circuits?.[t];return n?.has_override?n.horizon:e.global_horizon??o}function Bt(e,t){if(!e)return o;const n=e.sub_devices?.[t];return n?.has_override?n.horizon:e.global_horizon??o}class Qt{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new pt,this.monitoringMultiCache=new ut,this.graphSettingsCache=new Gt,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(e){this._errorStore=e,this.monitoringCache.errorStore=e,this.graphSettingsCache.errorStore=e,this.monitoringMultiCache.errorStore=e}get hass(){return this._hass}set hass(e){this._hass=e}get topology(){return this._topology}get config(){return this._config}set showMonitoring(e){this._showMonitoring=e}init(e,t,n,i){this._topology=e,this._config=t,this._hass=n,this._configEntryId=i}setFavoriteRefs(e){this._favRefs=e}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(e){this._panelFavorites=e}setFavoritesPerPanelInfo(e){this._perPanelInfo=e??new Map}get _inFavoritesView(){return null!==this._favRefs}setConfig(e){this._config=e}buildHorizonMaps(e){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),e&&this._topology?.circuits)for(const t of Object.keys(this._topology.circuits))this.horizonMap.set(t,Vt(e,t));if(e&&this._topology?.sub_devices)for(const t of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(t,Bt(e,t))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(e){console.warn("SPAN Panel: graph settings fetch failed",e),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:n("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(e){if(!this._hass||0===e.length)return null;const t=this._hass;return function(e){let t=!1;const n={},i={};for(const s of e)s&&(t=!0,s.circuits&&Object.assign(n,s.circuits),s.mains&&Object.assign(i,s.mains));return t?{circuits:n,mains:i}:null}(await Promise.all(e.map(e=>this.monitoringMultiCache.fetchOne(t,e))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const e=new Set;for(const t of Object.values(this._favRefs))t.configEntryId&&e.add(t.configEntryId);const t=new Map;await Promise.all(Array.from(e).map(async e=>{t.set(e,await this._fetchGraphSettingsFresh(e))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const e of Object.keys(this._topology.circuits)){const n=this._favRefs[e],i=n?.configEntryId?t.get(n.configEntryId)??null:null,s=n?.targetId??e;this.horizonMap.set(e,Vt(i,s))}if(this._topology.sub_devices)for(const e of Object.keys(this._topology.sub_devices)){const n=this._favRefs[e],i=n?.configEntryId?t.get(n.configEntryId)??null:null,s=n?.targetId??e;this.subDeviceHorizonMap.set(e,Bt(i,s))}}async loadHistory(){await Rt(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const e=Date.now();for(const[t,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(t)??o;if(!r[i]?.useRealtime)continue;const s=ht(n,this._config);if(!s)continue;const a=this._hass.states[s];if(!a)continue;const l=parseFloat(a.state);if(isNaN(l))continue;const c=Mt(i),d=It(c),h=Tt(c),p=e-c,u=this.powerHistory.get(t)??[];u.length>0&&e-u[u.length-1].time0&&e-u[u.length-1].time0&&this._topology)for(const{key:e,devId:t}of Ot(this._topology))i.has(t)&&s.add(e);const o=new Map;try{await Rt(this._hass,this._topology,this._config,o,t,i);for(const e of t.keys()){const t=o.get(e);t?this.powerHistory.set(e,t):this.powerHistory.delete(e)}for(const e of s){const t=o.get(e);t?this.powerHistory.set(e,t):this.powerHistory.delete(e)}this.updateDOM(e)}catch(e){console.warn("SPAN Panel: history refresh failed",e),this._errorStore?.add({key:"fetch:history",level:"warning",message:n("error.history_failed"),persistent:!1})}}updateDOM(e){this._hass&&this._topology&&this._config&&(Wt(e,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(e,t,n,i,s,o){if(!n.sub_devices)return;const r=Nt(i);for(const[i,a]of Object.entries(n.sub_devices)){const n=e.querySelector(`[data-subdev="${jt(i)}"]`);if(!n)continue;const l=$t(a);if(l){const e=t.states[l],i=e&&parseFloat(e.state)||0,s=n.querySelector(".sub-power-value");s&&(s.innerHTML=`${ot(i)} ${st(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const e of c){const n=e.dataset.chartKey;if(!n)continue;const a=s.get(n)||[];let l=_.power;n.endsWith("_soc")?l=_.soc:n.endsWith("_soe")&&(l=_.soe);const c=!!e.closest(".bess-chart-col");qt(e,t,a,o?.has(i)?Mt(o.get(i)):r,l,!1,c?120:150,void 0,n.endsWith("_soc")||n.endsWith("_soe"))}for(const e of Object.keys(a.entities||{})){const i=n.querySelector(`[data-eid="${jt(e)}"]`);if(!i)continue;const s=t.states[e];if(s){let e;if(t.formatEntityState)e=t.formatEntityState(s);else{e=s.state;const t=s.attributes.unit_of_measurement||"";t&&(e+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(e=(t/1e3).toFixed(1)+" kWh")}i.textContent=e}}}}(e,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(e){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(e)}}onToggleClick(e,t){const i=e.target,s=i?.closest(".toggle-pill");if(!s)return;const o=t.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;e.stopPropagation(),e.preventDefault();const r=s.closest("[data-uuid]");if(!r||!this._topology||!this._hass)return;const a=r.dataset.uuid;if(!a)return;const l=this._topology.circuits[a];if(!l)return;const c=l.entities?.switch;if(!c)return;const d=this._hass.states[c];if(!d)return void console.warn("SPAN Panel: switch entity not found:",c);const h="on"===d.state?"turn_off":"turn_on";this._hass.callService("switch",h,{},{entity_id:c}).catch(e=>{console.warn("SPAN Panel: switch service call failed",e),this._errorStore?.add({key:"service:relay",level:"error",message:n("error.relay_failed"),persistent:!1})})}async onGearClick(e,t){const n=e.target,i=n?.closest(".gear-icon");if(!i)return;const s=t.querySelector("span-side-panel");if(!s||!this._hass)return;if(s.hass=this._hass,s.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const e=await this._buildFavoritesSections();if(0===e.length)return;return void s.open({favoritesMode:!0,perPanelSections:e})}return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void s.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const r=i.dataset.uuid;if(r&&this._topology){const e=this._topology.circuits[r];if(e){const t=this._favRefs?.[r]??null,n=t&&"circuit"===t.kind?t.targetId:r,i=t?.configEntryId??this._configEntryId;let a,l;t?[a,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),a=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=e.entities?.current??e.entities?.power,d=c?l?.circuits?.[c]??null:null,h=a?.global_horizon??o,p=a?.circuits?.[n],u=p?{...p,globalHorizon:h}:{horizon:h,has_override:!1,globalHorizon:h},g=t?.panelDeviceId??this._panelFavorites?.panelDeviceId,_=null!==t||(this._panelFavorites?.circuitUuids.has(n)??!1),f=this._inFavoritesView||null!==this._panelFavorites;return void s.open({...e,uuid:n,monitoringInfo:d,showMonitoring:this._showMonitoring,graphHorizonInfo:u,showFavorites:f,favoritePanelDeviceId:g,isFavorite:_,configEntryId:i})}}const a=i.dataset.subdevId;if(a&&this._topology?.sub_devices?.[a]){const e=this._topology.sub_devices[a],t=this._favRefs?.[a]??null,n=t&&"sub_device"===t.kind?t.targetId:a,i=t?.configEntryId??this._configEntryId;let r;t?r=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),r=this.graphSettingsCache.settings);const l=r?.global_horizon??o,c=r?.sub_devices?.[n],d=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},h=t?.panelDeviceId??this._panelFavorites?.panelDeviceId,p=null!==t||(this._panelFavorites?.subDeviceIds.has(n)??!1),u=this._inFavoritesView||null!==this._panelFavorites;s.open({subDeviceMode:!0,subDeviceId:n,name:e.name??n,deviceType:e.type??"",entities:e.entities,graphHorizonInfo:d,showFavorites:u,favoritePanelDeviceId:h,isFavorite:p,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const e=function(e,t){const n=new Map;for(const i of Object.values(e)){if("circuit"!==i.kind)continue;const e=t.get(i.panelDeviceId);if(void 0===e)continue;let s=n.get(i.panelDeviceId);void 0===s&&(s={panelDeviceId:i.panelDeviceId,panelName:e.panelName,topology:e.topology,configEntryId:e.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,s)),s.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((e,t)=>e.panelName.localeCompare(t.panelName))}(this._favRefs,this._perPanelInfo);if(0===e.length)return[];return await Promise.all(e.map(async e=>({panelDeviceId:e.panelDeviceId,panelName:e.panelName,topology:e.topology,graphSettings:await this._fetchGraphSettingsFresh(e.configEntryId),favoriteCircuitUuids:e.favoriteCircuitUuids,configEntryId:e.configEntryId})))}async _fetchGraphSettingsFresh(e){if(!this._hass)return null;try{const t={};e&&(t.config_entry_id=e);const i={type:"call_service",domain:a,service:"get_graph_settings",service_data:t,return_response:!0},s=this._errorStore?new We(this._errorStore):null,o=s?await s.callWS(this._hass,i,{errorId:"fetch:graph_settings",errorMessage:n("error.graph_settings_failed")}):await this._hass.callWS(i);return o?.response??null}catch(e){return console.warn("SPAN Panel: fresh graph settings fetch failed",e),null}}async _fetchMonitoringStatusFresh(e){if(!this._hass)return null;try{const t={};e&&(t.config_entry_id=e);const i={type:"call_service",domain:a,service:"get_monitoring_status",service_data:t,return_response:!0},s=this._errorStore?new We(this._errorStore):null,o=s?await s.callWS(this._hass,i,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await this._hass.callWS(i),r=o?.response;return r?{circuits:r.circuits,mains:r.mains}:null}catch(e){return console.warn("SPAN Panel: fresh monitoring status fetch failed",e),null}}bindSlideConfirm(e,t){const n=e.querySelector(".slide-confirm-knob"),i=e.querySelector(".slide-confirm-text");if(!n||!i)return;let s=!1,o=0,r=0;const a=t=>{e.classList.contains("confirmed")||(s=!0,o=t-n.offsetLeft,r=e.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=e=>{if(!s)return;const t=Math.max(2,Math.min(e-o,r));n.style.left=t+"px"},c=()=>{if(!s)return;s=!1;(n.offsetLeft-2)/r>=.9?(n.style.left=r+"px",e.classList.add("confirmed"),n.querySelector("ha-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=e.dataset.textOn??"",t&&t.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",e=>{e.preventDefault(),a(e.clientX)}),e.addEventListener("mousemove",e=>l(e.clientX)),e.addEventListener("mouseup",c),e.addEventListener("mouseleave",c),n.addEventListener("touchstart",e=>{e.preventDefault(),a(e.touches[0].clientX)},{passive:!1}),e.addEventListener("touchmove",e=>l(e.touches[0].clientX),{passive:!0}),e.addEventListener("touchend",c),e.addEventListener("touchcancel",c),e.addEventListener("click",()=>{e.classList.contains("confirmed")&&(e.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("ha-icon")?.setAttribute("icon","mdi:lock"),i.textContent=e.dataset.textOff??"",t&&t.classList.add("switches-disabled"))})}startIntervals(e,t){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(e),t&&t()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(e)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(e,t){this.cleanupResizeObserver(),t&&(this._lastWidth=t.clientWidth,this._resizeObserver=new ResizeObserver(t=>{const n=t[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const t of e.querySelectorAll(".chart-container")){const e=t.querySelector("ha-chart-base");e&&e.remove()}this.updateDOM(e)},150))}),this._resizeObserver.observe(t))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}const Kt='\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n ha-card {\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */\n .favorites-summary {\n padding: 8px 24px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n align-items: center;\n gap: 12px;\n }\n /* Override the generic .gear-icon { margin-left: auto } rule so the\n favorites gear stays flush-left instead of floating to the right of\n the flex row (same idea as .panel-identity .panel-gear does for\n real-panel headers). */\n .favorites-summary .favorites-gear {\n margin-left: 0;\n }\n /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle.\n margin-left:auto moved here from .favorites-summary-unit-toggle so the\n legend and toggle cluster together, matching the real-panel header\n layout. */\n .favorites-summary-right {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .favorites-subdevices-section {\n padding: 8px 16px 0;\n }\n\n /* Favorites view: responsive grid of per-contributing-panel status cards. */\n .favorites-panel-stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n gap: 12px;\n padding: 12px 24px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .favorites-panel-card {\n background: var(--secondary-background-color, rgba(255, 255, 255, 0.04));\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n padding: 10px 14px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .favorites-panel-card-title {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--primary-text-color);\n opacity: 0.85;\n }\n .favorites-panel-card .panel-stats {\n gap: 10px 20px;\n }\n .favorites-panel-card .stat-value {\n font-size: 1.15em;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item ha-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob ha-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n grid-template-columns: 28px 1fr 1fr 28px;\n gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 7px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .circuit-custom-monitoring {\n border-left: 3px solid #ff9800;\n }\n\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n /* Each circuit is wrapped in a .list-cell so the row + its optional\n expanded chart stay together. In single-column flex mode the cell\n just stacks naturally. In multi-column grid mode the cell becomes\n one grid item, so the chart is always in the same column as its\n row. Area headers (rendered as siblings, not inside a cell) span\n all columns via their inline "grid-column: 1 / -1". */\n .list-cell {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: grid;\n grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr));\n gap: 6px 8px;\n flex-direction: initial;\n }\n /* On narrow viewports a 2/3-column list would squeeze rows into an\n unreadable shape, so force stacking regardless of user preference. */\n @media (max-width: 599px) {\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: flex;\n flex-direction: column;\n }\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n min-width: 70px;\n text-align: right;\n flex-shrink: 0;\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n .list-row .gear-icon {\n background: transparent;\n border: none;\n padding: 2px;\n cursor: pointer;\n color: #555;\n display: inline-flex;\n align-items: center;\n }\n .list-row .gear-icon:hover {\n color: var(--primary-text-color);\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .circuit-slot.circuit-chart-only {\n border: none;\n margin: 0;\n background: none;\n padding: 8px 12px;\n min-height: 0;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n';class Jt{constructor(){this._ctrl=new Qt,this._container=null,this._onGearClick=null,this._onToggleClick=null,this._onSidePanelClosed=null,this._onGraphSettingsChanged=null}get hass(){return this._ctrl.hass}set hass(e){this._ctrl.hass=e}set errorStore(e){this._ctrl.errorStore=e}setPanelFavorites(e){this._ctrl.setPanelFavorites(e)}async render(e,t,i,s,o){let r,a;this.stop(),this._ctrl.reset(),this._ctrl.showMonitoring=!0,this._container=e,this._ctrl.hass=t;try{const e=await Ye(t,i);r=e.topology,a=e.panelSize}catch(t){return void(e.innerHTML=`

${Oe(t.message)}

`)}this._ctrl.init(r,s,t,o??null),await this._ctrl.monitoringCache.fetch(t,o??null),await this._ctrl.fetchAndBuildHorizonMaps();const l=Math.ceil(a/2),c=this._ctrl.monitoringCache.status,d=nt(r,s),h=function(e){if(!e)return"";const t=Object.values(e.circuits??{}),i=Object.values(e.mains??{}),s=[...t,...i],o=s.filter(e=>void 0!==e.utilization_pct&&e.utilization_pct>=80&&e.utilization_pct<100).length,r=s.filter(e=>void 0!==e.utilization_pct&&e.utilization_pct>=100).length,a=s.filter(e=>e.has_override).length;return`\n
\n ✓ ${n("status.monitoring")} · ${t.length} ${n("status.circuits")} · ${i.length} ${n("status.mains")}\n \n ${o>0?`${o} ${n(o>1?"status.warnings":"status.warning")}`:""}\n ${r>0?`${r} ${n(r>1?"status.alerts":"status.alert")}`:""}\n ${a>0?`${a} ${n(a>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(c),p=function(e,t,n,i,s){const o=new Map,r=new Set;for(const[t,n]of Object.entries(e.circuits)){const e=n.tabs;if(!e||0===e.length)continue;const i=Math.min(...e),s=1===e.length?"single":ct(e)??"single";o.set(i,{uuid:t,circuit:n,layout:s});for(const t of e)r.add(t)}const a=new Set,l=new Set;for(const[e,t]of o)if("col-span"===t.layout){const n=t.circuit.tabs,i=at(Math.max(...n));0===lt(e)?a.add(i):l.add(i)}function c(e){const t=e.circuit.entities?.current??e.circuit.entities?.power,i=s?gt(s,t??""):null;let o;if(e.circuit.always_on)o="always_on";else{const t=e.circuit.entities?.select;o=t&&n.states[t]?n.states[t].state:"unknown"}return{monInfo:i,sheddingPriority:o}}let d="";for(let e=1;e<=t;e++){const t=2*e-1,s=2*e,h=o.get(t),p=o.get(s);if(d+=`
${t}
`,h&&"row-span"===h.layout){const{monInfo:t,sheddingPriority:o}=c(h);d+=vt(h.uuid,h.circuit,e,"2 / 4","row-span",n,i,t,o),d+=`
${s}
`;continue}if(!a.has(e))if(!h||"col-span"!==h.layout&&"single"!==h.layout)r.has(t)||(d+=mt(e,"2"));else{const{monInfo:t,sheddingPriority:s}=c(h);d+=vt(h.uuid,h.circuit,e,"2",h.layout,n,i,t,s)}if(!l.has(e))if(!p||"col-span"!==p.layout&&"single"!==p.layout)r.has(s)||(d+=mt(e,"3"));else{const{monInfo:t,sheddingPriority:s}=c(p);d+=vt(p.uuid,p.circuit,e,"3",p.layout,n,i,t,s)}d+=`
${s}
`}return d}(r,l,t,s,c),u=Et(r,t,s);e.innerHTML=`\n \n ${d}\n ${h}\n ${u?`
${u}
`:""}\n ${!1!==s.show_panel?`\n
\n ${p}\n
\n `:""}\n \n `,this._onGearClick=t=>{this._ctrl.onGearClick(t,e)},this._onToggleClick=t=>{this._ctrl.onToggleClick(t,e)},e.addEventListener("click",this._onGearClick),e.addEventListener("click",this._onToggleClick),this._onSidePanelClosed=()=>{this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()},e.addEventListener("side-panel-closed",this._onSidePanelClosed),this._onGraphSettingsChanged=()=>this._ctrl.onGraphSettingsChanged(e),e.addEventListener("graph-settings-changed",this._onGraphSettingsChanged);try{await this._ctrl.loadHistory()}catch{}this._ctrl.updateDOM(e);const g=e.querySelector(".slide-confirm");g&&(this._ctrl.bindSlideConfirm(g,e),e.classList.add("switches-disabled")),this._ctrl.setupResizeObserver(e,e),this._ctrl.startIntervals(e)}stop(){this._ctrl.stopIntervals(),this._container&&(this._onGearClick&&(this._container.removeEventListener("click",this._onGearClick),this._onGearClick=null),this._onToggleClick&&(this._container.removeEventListener("click",this._onToggleClick),this._onToggleClick=null),this._onSidePanelClosed&&(this._container.removeEventListener("side-panel-closed",this._onSidePanelClosed),this._onSidePanelClosed=null),this._onGraphSettingsChanged&&(this._container.removeEventListener("graph-settings-changed",this._onGraphSettingsChanged),this._onGraphSettingsChanged=null))}}const Xt="\n display:flex;align-items:center;gap:8px;margin-bottom:8px;\n",Zt="\n background:var(--secondary-background-color,#333);\n border:1px solid var(--divider-color);\n color:var(--primary-text-color);\n border-radius:4px;padding:6px 10px;width:80px;font-size:0.85em;\n",Yt="\n min-width:130px;font-size:0.85em;color:var(--secondary-text-color);\n",en="\n min-width:160px;font-size:0.85em;color:var(--secondary-text-color);\n",tn="\n background:var(--secondary-background-color,#333);\n border:1px solid var(--divider-color);\n color:var(--primary-text-color);\n border-radius:4px;padding:6px 10px;flex:1;font-size:0.85em;\n font-family:monospace;\n";function nn(e,t,n,i,s){return`\n ${i}\n `}class sn{constructor(){this.errorStore=null,this._debounceTimer=null,this._configEntryId=null,this._notifyCloseHandler=null,this._headerHTML=""}stop(){this._notifyCloseHandler&&(document.removeEventListener("click",this._notifyCloseHandler),this._notifyCloseHandler=null),this._debounceTimer&&(clearTimeout(this._debounceTimer),this._debounceTimer=null)}async render(e,t,i,s=""){let o;void 0!==i&&(this._configEntryId=i),this._headerHTML=s,this._notifyCloseHandler&&(document.removeEventListener("click",this._notifyCloseHandler),this._notifyCloseHandler=null);try{const e={};this._configEntryId&&(e.config_entry_id=this._configEntryId);const n=await t.callWS({type:"call_service",domain:a,service:"get_monitoring_status",service_data:e,return_response:!0});o=function(e){if(!e||"object"!=typeof e)return null;const t=e,n={};return"boolean"==typeof t.enabled&&(n.enabled=t.enabled),t.global_settings&&"object"==typeof t.global_settings&&(n.global_settings=t.global_settings),t.circuits&&"object"==typeof t.circuits&&(n.circuits=t.circuits),t.mains&&"object"==typeof t.mains&&(n.mains=t.mains),n}(n?.response)}catch(e){console.warn("SPAN Panel: monitoring status fetch failed",e),o=null}const r=o?.global_settings??{},l=!0===o?.enabled,c=o?.circuits??{},d=o?.mains??{},h=new Set;for(const e of Object.keys(t.states||{}))e.startsWith("notify.")&&h.add(e);const p=new Set(["notify","send_message"]);for(const e of Object.keys(t.services?.notify||{}))p.has(e)||h.add(`notify.${e}`);h.add("event_bus");const u=[...h].sort(),g=r.notify_targets??"",_=("string"==typeof g?g.split(","):g).map(e=>e.trim()).filter(Boolean),f=u.length>0&&u.every(e=>_.includes(e)),v=r.notification_title_template??"SPAN: {name} {alert_type}",m=r.notification_message_template??"{name} at {current_a}A ({utilization_pct}% of {breaker_rating_a}A rating)",b=r.notification_priority??"default",y=Object.entries(c).sort(([,e],[,t])=>(e.name??"").localeCompare(t.name??"")),w=Object.entries(d),x=[...y,...w],S=x.length>0&&x.every(([,e])=>!1!==e.monitoring_enabled),$=x.some(([,e])=>!1!==e.monitoring_enabled),C=y.map(([e,t])=>{const i=Oe(t.name??e),s=!1!==t.monitoring_enabled,o=!0===t.has_override,r=s?"":"opacity:0.4;",a=Oe(e);return`\n \n \n \n \n ${nn(a,"continuous_threshold_pct",t.continuous_threshold_pct,"%","circuit")}\n ${nn(a,"spike_threshold_pct",t.spike_threshold_pct,"%","circuit")}\n ${nn(a,"window_duration_m",t.window_duration_m,"m","circuit")}\n ${nn(a,"cooldown_duration_m",t.cooldown_duration_m,"m","circuit")}\n \n ${o?``:""}\n \n \n `}).join(""),P=Object.entries(d).map(([e,t])=>{const i=Oe(t.name??e),s=!1!==t.monitoring_enabled,o=!0===t.has_override,r=s?"":"opacity:0.4;",a=Oe(e);return`\n \n \n \n \n ${nn(a,"continuous_threshold_pct",t.continuous_threshold_pct,"%","mains")}\n ${nn(a,"spike_threshold_pct",t.spike_threshold_pct,"%","mains")}\n ${nn(a,"window_duration_m",t.window_duration_m,"m","mains")}\n ${nn(a,"cooldown_duration_m",t.cooldown_duration_m,"m","mains")}\n \n ${o?``:""}\n \n \n `}).join("");e.innerHTML=`\n ${this._headerHTML}\n
\n

${n("monitoring.heading")}

\n\n
\n
\n

${n("monitoring.global_settings")}

\n \n
\n\n
\n
\n ${n("monitoring.continuous")}\n \n
\n
\n ${n("monitoring.spike")}\n \n
\n
\n ${n("monitoring.window")}\n \n
\n
\n ${n("monitoring.cooldown")}\n \n
\n\n
\n

${n("notification.heading")}

\n\n
\n ${n("notification.targets")}\n \n
\n \n
\n ${0===u.length?`
${n("notification.no_targets")}
`:u.map(e=>{const i=_.includes(e),s="event_bus"===e,o=s?null:t.states[e],r=o?.attributes?.friendly_name,a=s?n("notification.event_bus_target"):r?`${Oe(r)} (${Oe(e)})`:Oe(e);return``}).join("")}\n
\n
\n
\n\n
\n ${n("notification.priority")}\n \n \n ${"critical"===b?n("notification.hint.critical"):"time-sensitive"===b?n("notification.hint.time_sensitive"):"passive"===b?n("notification.hint.passive"):"active"===b?n("notification.hint.active"):""}\n \n
\n\n
\n ${n("notification.title_template")}\n \n
\n\n
\n ${n("notification.message_template")}\n \n
\n\n
\n ${n("notification.placeholders")} {name} {entity_id} {alert_type}\n {current_a} {breaker_rating_a} {threshold_pct}\n {utilization_pct} {window_m} {local_time}\n
\n
\n ${n("notification.event_bus_help")} span_panel_current_alert\n ${n("notification.event_bus_payload")} alert_source alert_id\n alert_name alert_type current_a\n breaker_rating_a threshold_pct utilization_pct\n panel_serial window_duration_s local_time\n
\n\n
\n ${n("notification.test_label")}\n \n \n
\n
\n\n
\n
\n\n

${n("monitoring.monitored_points")}

\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n ${P}\n ${C}\n \n
${n("monitoring.col.name")}${n("monitoring.col.continuous")}${n("monitoring.col.spike")}${n("monitoring.col.window")}${n("monitoring.col.cooldown")}
\n \n
\n
\n `;const k=e.querySelector("#toggle-all-circuits");k&&!S&&$&&(k.indeterminate=!0);const E=e.querySelector("#notify-all-targets");if(E&&u.length>0){const e=_.length>0;!f&&e&&(E.indeterminate=!0)}this._bindGlobalControls(e,t),this._bindNotifyTargetSelect(e,t),this._bindNotificationSettings(e,t),this._bindToggleAll(e,t,c,d),this._bindCircuitToggles(e,t),this._bindMainsToggles(e,t),this._bindThresholdInputs(e,t),this._bindResetButtons(e,t)}_serviceData(e){return this._configEntryId&&(e.config_entry_id=this._configEntryId),e}_callSetGlobal(e,t){return e.callWS({type:"call_service",domain:a,service:"set_global_monitoring",service_data:this._serviceData({...t})})}_bindGlobalControls(e,t){const i=e.querySelector("#monitoring-enabled"),s=e.querySelector("#global-fields"),o=e.querySelector("#global-status"),r=()=>{const t=[["continuous_threshold_pct","#g-continuous"],["spike_threshold_pct","#g-spike"],["window_duration_m","#g-window"],["cooldown_duration_m","#g-cooldown"]],n={};for(const[i,s]of t){const t=e.querySelector(s);if(!t)return null;const o=parseInt(t.value,10);if(Number.isNaN(o))return null;n[i]=o}return n},a=(e,t,i)=>{if(!e)return;const s=t instanceof Error?t.message:i;e.textContent=`${n("error.prefix")} ${s}`,e.style.color="var(--error-color, #f44336)"},l=()=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{const i=r();if(i)try{await this._callSetGlobal(t,i),await this.render(e,t)}catch(e){a(o,e,n("error.failed_save"))}else a(o,null,n("error.failed_save"))},u)};i&&i.addEventListener("change",async()=>{const o=i.checked;s&&(s.style.opacity=o?"":"0.4",s.style.pointerEvents=o?"":"none");const l=e.querySelector("#global-status");try{if(o){const e=r();if(!e)return void a(l,null,n("error.failed"));await this._callSetGlobal(t,e)}else await this._callSetGlobal(t,{enabled:!1})}catch(e){return void a(l,e,n("error.failed"))}await this.render(e,t)});for(const t of e.querySelectorAll("#global-fields input[type=number]"))t.addEventListener("input",l)}_bindNotifyTargetSelect(e,t){const i=e.querySelector("#notify-target-btn"),s=e.querySelector("#notify-target-dropdown"),o=e.querySelector("#notify-target-label");if(!i||!s)return;i.addEventListener("click",e=>{e.stopPropagation();const t="none"!==s.style.display;s.style.display=t?"none":"block"});const r=t=>{const n=e.querySelector("#notify-target-select");n&&!n.contains(t.target)&&(s.style.display="none")};document.addEventListener("click",r),this._notifyCloseHandler=r;const a=()=>{const i=[...e.querySelectorAll(".notify-target-cb:checked")].map(e=>e.value);if(o){const e=i.map(e=>"event_bus"===e?n("notification.event_bus_target"):e);o.textContent=e.length?e.join(", "):n("notification.none_selected")}const s=e.querySelector("#notify-all-targets");if(s){const t=[...e.querySelectorAll(".notify-target-cb")];s.checked=t.length>0&&t.every(e=>e.checked),s.indeterminate=!s.checked&&t.some(e=>e.checked)}this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(t,{notify_targets:i.join(", ")})}catch(e){console.warn("SPAN Panel: notification targets save failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}},u)},l=e.querySelector("#notify-all-targets");l&&l.addEventListener("change",()=>{for(const t of e.querySelectorAll(".notify-target-cb"))t.checked=l.checked;const t=e.querySelector("#notify-target-btn");t&&(t.style.opacity=l.checked?"0.4":"",t.style.pointerEvents=l.checked?"none":""),l.checked&&(s.style.display="none"),a()});for(const t of e.querySelectorAll(".notify-target-cb"))t.addEventListener("change",()=>{a()})}_bindNotificationSettings(e,t){const i=e.querySelector("#g-priority"),s=e.querySelector("#g-title-template"),o=e.querySelector("#g-message-template"),r=(e,i)=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(t,{[e]:i})}catch(e){console.warn("SPAN Panel: notification settings save failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}},u)};i&&i.addEventListener("change",async()=>{try{await this._callSetGlobal(t,{notification_priority:i.value}),await this.render(e,t)}catch(e){console.warn("SPAN Panel: notification priority change failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}}),s&&s.addEventListener("input",()=>{r("notification_title_template",s.value)}),o&&o.addEventListener("input",()=>{r("notification_message_template",o.value)});const l=e.querySelector("#test-notification-btn"),c=e.querySelector("#test-notification-status");l&&l.addEventListener("click",async()=>{l.disabled=!0,c&&(c.textContent=n("notification.test_sending"),c.style.color="var(--secondary-text-color)");try{this._debounceTimer&&(clearTimeout(this._debounceTimer),this._debounceTimer=null);const i=[...e.querySelectorAll(".notify-target-cb:checked")].map(e=>e.value).join(", ");await this._callSetGlobal(t,{notify_targets:i});const s={};this._configEntryId&&(s.config_entry_id=this._configEntryId),await t.callWS({type:"call_service",domain:a,service:"test_notification",service_data:s}),c&&(c.textContent=n("notification.test_sent"),c.style.color="var(--success-color, #4caf50)")}catch(e){if(c){const t=e instanceof Error?e.message:n("error.failed");c.textContent=`${n("error.prefix")} ${t}`,c.style.color="var(--error-color, #f44336)"}}finally{l.disabled=!1}})}_bindToggleAll(e,t,i,s){const o=e.querySelector("#toggle-all-circuits");o&&o.addEventListener("change",async()=>{const r=o.checked,l=[...Object.keys(i).map(e=>t.callWS({type:"call_service",domain:a,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:e,monitoring_enabled:r})}).catch(e=>{console.warn("SPAN Panel: circuit monitoring toggle failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})),...Object.keys(s).map(e=>t.callWS({type:"call_service",domain:a,service:"set_mains_threshold",service_data:this._serviceData({leg:e,monitoring_enabled:r})}).catch(e=>{console.warn("SPAN Panel: mains monitoring toggle failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}))];await Promise.all(l),await this.render(e,t)})}_bindMainsToggles(e,t){for(const i of e.querySelectorAll(".mains-toggle"))i.addEventListener("change",async()=>{const s=i.dataset.entity,o=i.checked;try{await t.callWS({type:"call_service",domain:a,service:"set_mains_threshold",service_data:this._serviceData({leg:s,monitoring_enabled:o})})}catch(e){return console.warn("SPAN Panel: mains threshold toggle failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),void(i.checked=!o)}await this.render(e,t)})}_bindCircuitToggles(e,t){for(const i of e.querySelectorAll(".circuit-toggle"))i.addEventListener("change",async()=>{const s=i.dataset.entity,o=i.checked;try{await t.callWS({type:"call_service",domain:a,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:s,monitoring_enabled:o})})}catch(e){return console.warn("SPAN Panel: circuit threshold toggle failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),void(i.checked=!o)}await this.render(e,t)})}_bindThresholdInputs(e,t){const i=new Map;for(const s of e.querySelectorAll(".threshold-input"))s.addEventListener("input",()=>{const o=`${s.dataset.entity}-${s.dataset.field}`,r=i.get(o);r&&clearTimeout(r),i.set(o,setTimeout(async()=>{const i=parseInt(s.value,10);if(!i||i<1)return;const o=s.dataset.entity,r=s.dataset.field,l=s.dataset.type,c="mains"===l?"set_mains_threshold":"set_circuit_threshold",d="mains"===l?"leg":"circuit_id";try{await t.callWS({type:"call_service",domain:a,service:c,service_data:this._serviceData({[d]:o,[r]:i})}),await this.render(e,t)}catch(e){console.warn("SPAN Panel: threshold input save failed",e),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),s.style.borderColor="var(--error-color, #f44336)"}},800))})}_bindResetButtons(e,t){for(const n of e.querySelectorAll(".reset-btn"))n.addEventListener("click",async()=>{const i=n.dataset.entity;if(!i)return;const s=n.dataset.type,o="mains"===s?"clear_mains_threshold":"clear_circuit_threshold",r=this._serviceData("mains"===s?{leg:i}:{circuit_id:i});await t.callService(a,o,r),await this.render(e,t)})}}function on(e=""){const t=e?` value="${Oe(e)}"`:"",i=e?"":"display:none;";return`\n
\n \n \n
\n `}function rn(e,t,i,s,o,r,a){const c=t.entities?.power,d=c?i.states[c]:null,h=d&&parseFloat(d.state)||0,p=t.entities?.switch,u=p?i.states[p]:null,g=u?"on"===u.state:(d?.attributes?.relay_state||t.relay_state)===l,_=t.breaker_rating_a,m=_?`${Math.round(_)}A`:"",b=Oe(t.name||n("grid.unknown")),y=dt(s),w="current"===y.entityRole;let x;if(g)if(w){const e=t.entities?.current,n=e?i.states[e]:null,s=n&&parseFloat(n.state)||0;x=`${y.format(s)}A`}else x=`${ot(h)}${st(h)}`;else x="";const S=r||"unknown";let $="";if("unknown"!==S){const e=f[S]??f.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};$=e.icon2?`\n \n \n `:e.textLabel?`\n \n ${e.textLabel}\n `:``}let C="",P=o?.utilization_pct??null;if(null==P&&t.breaker_rating_a){const e=t.entities?.current,n=e?i.states[e]:null,s=n?Math.abs(parseFloat(n.state)||0):0;P=Math.round(s/t.breaker_rating_a*1e3)/10}if(null!=P){C=`=80?"utilization-warning":"utilization-normal"}">${Math.round(P)}%`}const k=!!o&&_t(o)?v:"#555",E=``,z=!1!==t.is_user_controllable&&!!t.entities?.switch?`
\n ${n(g?"grid.on":"grid.off")}\n \n
`:`${g?"ON":"OFF"}`;return`\n
\n ${m?`${m}`:""}\n ${C}\n ${b}\n ${$}\n ${z}\n \n ${x}\n \n ${E}\n \n
\n `}function an(e,t,n,i,s){const o=t.entities?.power,r=o?n.states[o]:null,a=r&&parseFloat(r.state)||0,d=t.device_type===c||a<0,h=t.entities?.switch,p=h?n.states[h]:null,u=ft(0,s,p?"on"===p.state:(r?.attributes?.relay_state||t.relay_state)===l,d),g=Oe(e);return`\n
\n
\n
\n
\n
\n `}function ln(e){return`
${Oe(e)}
`}function cn(e,t,n){const i=e.entities?.switch,s=i?t.states[i]:null,o=e.entities?.power,r=o?t.states[o]:null,a=s?"on"===s.state:(r?.attributes?.relay_state||e.relay_state)===l;let c;if("current"===(n.chart_metric||"power")){const n=e.entities?.current,i=n?t.states[n]:null;c=i?Math.abs(parseFloat(i.state)||0):0}else c=r?Math.abs(parseFloat(r.state)||0):0;return{isOn:a,value:c}}function dn(e,t){if(e.always_on)return"always_on";const n=e.entities?.select,i=n?t.states[n]:null;return i?i.state:"unknown"}function hn(e,t,n,i){const s=cn(e,n,i),o=cn(t,n,i);return s.isOn&&!o.isOn?-1:!s.isOn&&o.isOn?1:o.value-s.value}function pn(e,t,n){return e.sort((e,i)=>hn(e[1],i[1],t,n))}function un(e){return e.entities?.current??e.entities?.power??""}class gn{constructor(e){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._ctrl=e}setColumns(e){const t=Math.max(1,Math.min(3,Math.floor(e)));this._columns=t}setInitialExpansion(e){this._expandedUuids=new Set(e)}setInitialSearchQuery(e){this._searchQuery=e}setViewName(e){this._viewName=e}renderActivityView(e,t,n,i,s,o){this._unbindEvents(),this._hass=t,this._topology=n,this._config=i,this._monitoringStatus=s;const r=pn(Object.entries(n.circuits),t,i);let a=o+on(this._searchQuery);a+=`
`;for(const[e,n]of r){const o=gt(s,un(n)),r=dn(n,t),l=this._expandedUuids.has(e);a+=`
`,a+=rn(e,n,t,i,o,r,l),l&&(a+=an(e,n,t,0,o)),a+="
"}a+="
",a+="",e.innerHTML=a;const l=e.querySelector("span-side-panel");l&&(l.hass=t,l.errorStore=this._ctrl.errorStore),this._bindEvents(e),this._searchQuery&&this._applyFilter(e),this._ctrl.updateDOM(e)}renderAreaView(e,t,i,s,o,r){this._unbindEvents(),this._hass=t,this._topology=i,this._config=s,this._monitoringStatus=o;const a=n("list.unassigned_area"),l=new Map;for(const[e,t]of Object.entries(i.circuits)){const n=t.area??a,i=l.get(n);i?i.push([e,t]):l.set(n,[[e,t]])}const c=[...l.keys()].sort((e,t)=>e===a?1:t===a?-1:e.localeCompare(t));let d=r+on(this._searchQuery);d+=`
`;for(const e of c){const n=l.get(e);if(!n)continue;const i=pn(n,t,s);d+=ln(e);for(const[e,n]of i){const i=gt(o,un(n)),r=dn(n,t),a=this._expandedUuids.has(e);d+=`
`,d+=rn(e,n,t,s,i,r,a),a&&(d+=an(e,n,t,0,i)),d+="
"}}d+="
",d+="",e.innerHTML=d;const h=e.querySelector("span-side-panel");h&&(h.hass=t,h.errorStore=this._ctrl.errorStore),this._bindEvents(e),this._searchQuery&&this._applyFilter(e),this._ctrl.updateDOM(e)}updateCollapsedRows(e,t,i,s){const o=dt(s),r="current"===o.entityRole,a=e.querySelectorAll(".list-row[data-row-uuid]");for(const e of a){const a=e.dataset.rowUuid;if(!a)continue;const l=i.circuits[a];if(!l)continue;const{isOn:c,value:d}=cn(l,t,s),h=e.querySelector(".list-power-value");if(h)if(c)if(r)h.innerHTML=`${o.format(d)}A`;else{const e=l.entities?.power,n=e?t.states[e]:null,i=n&&parseFloat(n.state)||0;h.innerHTML=`${ot(i)}${st(i)}`}else h.innerHTML="";const p=e.querySelector(".toggle-pill");if(p){p.classList.toggle("toggle-on",c),p.classList.toggle("toggle-off",!c);const e=p.querySelector(".toggle-label");e&&(e.textContent=n(c?"grid.on":"grid.off"))}const u=e.querySelector(".list-status-badge");u&&(u.textContent=c?"ON":"OFF",u.classList.toggle("list-status-on",c),u.classList.toggle("list-status-off",!c)),e.classList.toggle("circuit-off",!c)}!function(e,t,n,i){const s=e.querySelector(".list-view");if(s)for(const e of function(e,t){let n={anchor:null,units:[]};const i=[n];for(const s of[...e.children])if(s.classList.contains("area-header"))n={anchor:s,units:[]},i.push(n);else if(s.classList.contains("list-cell")){const e=s.dataset.cellUuid,i=e?t.circuits[e]:void 0;e&&i&&n.units.push({cell:s,uuid:e,circuit:i})}return i}(s,n)){if(e.units.length<2)continue;const n=[...e.units].sort((e,n)=>hn(e.circuit,n.circuit,t,i));if(!n.some((t,n)=>t.uuid!==e.units[n].uuid))continue;let o=e.anchor;for(const e of n)o?o.after(e.cell):s.prepend(e.cell),o=e.cell}}(e,t,i,s)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const e={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:e,bubbles:!0,composed:!0}))}_bindEvents(e){this._container=e,this._clickHandler=t=>{const n=t.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const e=i.dataset.expandUuid;return void(e&&this._toggleExpand(e))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(t,e);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(t,e);if(n.closest(".list-search-clear")){const t=e.querySelector(".list-search");return void(t&&(t.value="",t.dispatchEvent(new Event("input",{bubbles:!0}))))}const s=n.closest(".unit-btn");if(s){const t=s.dataset.unit;t&&e.dispatchEvent(new CustomEvent("unit-changed",{detail:t,bubbles:!0,composed:!0}))}},this._inputHandler=t=>{const n=t.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(e),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(e).then(()=>{this._ctrl.updateDOM(e)}).catch(()=>{})},e.addEventListener("click",this._clickHandler),e.addEventListener("input",this._inputHandler),e.addEventListener("graph-settings-changed",this._graphSettingsHandler);const t=e.querySelector(".slide-confirm");t&&(this._ctrl.bindSlideConfirm(t,e),e.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_applyFilter(e){const t=e.querySelector(".list-search-clear");t&&(t.style.display=this._searchQuery?"":"none");const n=e.querySelectorAll(".list-cell[data-cell-uuid]");for(const e of n){const t=e.querySelector(".list-circuit-name"),n=(t?.textContent?.toLowerCase()??"").includes(this._searchQuery);e.style.display=n?"":"none"}const i=e.querySelectorAll(".area-header");for(const e of i){let t=!1,n=e.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){t=!0;break}n=n.nextElementSibling}e.style.display=t?"":"none"}}_toggleExpand(e){if(!(this._container&&this._hass&&this._topology&&this._config))return;const t=jt(e),n=this._container.querySelector(`.list-cell[data-cell-uuid="${t}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${t}"]`),s=n.querySelector(`.list-expand-toggle[data-expand-uuid="${t}"]`);if(i){if(this._expandedUuids.has(e)){this._expandedUuids.delete(e);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${t}"]`);o&&o.remove(),s&&s.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(e);const t=this._topology.circuits[e];if(!t)return;const n=gt(this._monitoringStatus,un(t)),o=an(e,t,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),s&&s.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}function _n(e,t){return`${e}|${t}`}class fn{async build(e,t,n,i){const s=new Map;for(const e of n)s.set(e.id,e);const o=i?new We(i):null,r=[];for(const[n,i]of Object.entries(t)){if(!((i?.circuits?.length??0)>0||(i?.sub_devices?.length??0)>0))continue;const t=s.get(n);t&&r.push((async()=>{try{const i=await Ye(e,n,o);return{panelDeviceId:n,panel:t,topology:i.topology}}catch(e){return console.warn("SPAN Panel: favorites topology fetch failed",n,e),{panelDeviceId:n,panel:t,topology:null}}})())}const a=(await Promise.all(r)).filter(e=>null!==e.topology),l=a.length>1,c={},d={},h={},p=new Set,u=[];for(const{panelDeviceId:e,panel:n,topology:i}of a){if(!i)continue;const s=n.config_entries?.[0]??null;s&&p.add(s);const o=n.name_by_user??n.name??i.device_name??"";u.push({panelDeviceId:e,panelName:o,topology:i});const r=t[e],a=r?.circuits??[],g=r?.sub_devices??[];for(const t of a){const n=i.circuits?.[t];if(!n)continue;const r=_n(e,t),a=l&&o?`${o} · ${n.name}`:n.name;c[r]={...n,name:a},h[r]={panelDeviceId:e,kind:"circuit",targetId:t,configEntryId:s}}for(const t of g){const n=i.sub_devices?.[t];if(!n)continue;const r=_n(e,t),a=l&&o&&n.name?`${o} · ${n.name}`:n.name??t;d[r]={...n,name:a},h[r]={panelDeviceId:e,kind:"sub_device",targetId:t,configEntryId:s}}}return{topology:{circuits:c,sub_devices:d,panel_entities:{},device_name:"",_favoriteRefs:h},entryIds:Array.from(p),perPanelStats:u}}}const vn="span_panel_favorites_view_state";function mn(e){try{localStorage.setItem(vn,JSON.stringify(e))}catch{}}var bn;const yn="favorites";let wn=bn=class extends Pe{constructor(){super(...arguments),this.narrow=!1,this._panels=[],this._selectedPanelId=null,this._activeTab="dashboard",this._discovered=!1,this._listColumns=qe(),this._favorites={},this._favoritesViewState={expanded:{activity:[],area:[]}},this._favoritesPanelStats=[],this._dashboardTab=new Jt,this._monitoringTab=new sn,this._listDashCtrl=new Qt,this._listCtrl=new gn(this._listDashCtrl),this._favCache=new Be,this._favCtrl=new fn,this._favoritesMonitoringTabs=new Map,this._errorStore=new Le,this._watchedPanelId=null,this._discovering=!1,this._refreshSeq=0,this._areaUnsub=null,this._areaSubscribing=!1,this._onVisibilityChange=null,this._onFavoritesChanged=null,this._deviceRegistryUnsub=null,this._pendingTabRender=!1,this._persistFavoritesViewStateTimer=null,this._tabRenderScheduler=function(e){let t=null,n=!1;return async function i(){if(t)return n=!0,void await t.catch(()=>{});const s=(async()=>{try{await e()}finally{t=null,n&&(n=!1,await i())}})();t=s,await s}}(async()=>this._renderTab()),this._beginRender=function(){let e=0;return()=>{e+=1;const t=e;return()=>e!==t}}()}get _root(){const e=this.shadowRoot;if(!e)throw new Error("span-panel: shadow root is not available");return e}connectedCallback(){super.connectedCallback(),this._dashboardTab.errorStore=this._errorStore,this._listDashCtrl.errorStore=this._errorStore,this._favCache.errorStore=this._errorStore,this._monitoringTab.errorStore=this._errorStore,this._onVisibilityChange=()=>{"visible"===document.visibilityState&&this._discovered&&this.hass&&this._scheduleTabRender()},document.addEventListener("visibilitychange",this._onVisibilityChange),this._onFavoritesChanged=()=>{this._refreshFavorites()},document.addEventListener(Ge,this._onFavoritesChanged),this._subscribeDeviceRegistry()}disconnectedCallback(){this._dashboardTab.stop(),this._monitoringTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();for(const e of this._favoritesMonitoringTabs.values())e.stop();this._favoritesMonitoringTabs.clear(),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._onVisibilityChange&&(document.removeEventListener("visibilitychange",this._onVisibilityChange),this._onVisibilityChange=null),this._onFavoritesChanged&&(document.removeEventListener(Ge,this._onFavoritesChanged),this._onFavoritesChanged=null),this._unsubscribeDeviceRegistry(),this._persistFavoritesViewStateTimer&&(clearTimeout(this._persistFavoritesViewStateTimer),this._persistFavoritesViewStateTimer=null),this._errorStore.dispose(),super.disconnectedCallback()}firstUpdated(){this.hass&&!this._discovered&&this._discoverPanels()}updated(e){if(e.has("hass")){const t=e.get("hass");this._dashboardTab.hass=this.hass,this._listDashCtrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._discovered?this._root.getElementById("tab-content")||this._scheduleTabRender():this._discoverPanels(),!t&&this.hass&&this._subscribeDeviceRegistry()}if(this._discovered&&(e.has("_discovered")||e.has("_activeTab")||e.has("_selectedPanelId")||e.has("_chartMetric")||e.has("_listColumns"))){if(this._isFavoritesView&&"dashboard"===this._activeTab)return void(this._activeTab="activity");this._scheduleTabRender()}if(e.has("_selectedPanelId")&&(this._selectedPanelId!==yn&&this._selectedPanelId?(this._updatePanelStatusWatch(),this._listDashCtrl.setFavoritesPerPanelInfo(null)):(this._errorStore.clearPanelStatusWatch(),this._watchedPanelId=null)),this._discovered&&(e.has("_panels")||e.has("_selectedPanelId"))){const e=this.shadowRoot?.getElementById("panel-select");e&&null!==this._selectedPanelId&&e.value!==this._selectedPanelId&&(e.value=this._selectedPanelId)}if(e.has("hass")&&this._discovered&&("activity"===this._activeTab||"area"===this._activeTab)){const e=this._root.getElementById("tab-content"),t=this._listDashCtrl.topology;if(e&&t){this._listCtrl.updateCollapsedRows(e,this.hass,t,this._buildDashboardConfig());const n=e.querySelector("span-side-panel");n&&(n.hass=this.hass,n.errorStore=this._errorStore)}}}setConfig(e){}render(){var i,s,o;if(i=this.hass?.language,e=i&&t[i]?i:"en",!this.hass)return le` +
+
+
Span Panel
+
+
+
+
${n("card.connecting")}
+
+ `;if(!this._discovered){const e=this._errorStore.hasPersistent("discovery-failed");return le` +
+
+ +
Span Panel
+
+
+
+ + ${e?de:le`
${n("card.connecting")}
`} +
+ `}return le`
- +
+
${Fe((s=this._buildTabList(),o=this._activeTab,`
${s.map(e=>``).join("")}
`))}
- -
- ${Te((o=[{id:"dashboard",label:n("tab.by_panel"),icon:"mdi:view-dashboard"},{id:"activity",label:n("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:n("tab.by_area"),icon:"mdi:home-group"},{id:"monitoring",label:n("tab.monitoring"),icon:"mdi:monitor-eye"}],s=this._activeTab,`
${o.map(e=>``).join("")}
`))} -
+
- `:se` -
-
- -
Span Panel
-
-
-
-
${this._discoveryError??"Loading…"}
-
- `}_onPanelChange(e){const t=e.target;this._selectedPanelId=t.value,localStorage.setItem("span_panel_selected",t.value),this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._scheduleTabRender()}_onTabClick(e){const t=e.target.closest(".shared-tab");if(!t)return;const n=t.dataset.tab;n&&n!==this._activeTab&&(this._activeTab=n,this._scheduleTabRender())}_onTabContentClick(e){const t=e.target.closest(".unit-btn");if(t){const e=t.dataset.unit;if(!e||e===this._chartMetric)return;return this._chartMetric=e,localStorage.setItem("span_panel_metric",e),void("dashboard"===this._activeTab&&this._scheduleTabRender())}}_onSidePanelClosed(){if("dashboard"===this._activeTab){const e=this._dashboardTab._ctrl;e.monitoringCache.invalidate(),e.graphSettingsCache.invalidate()}}_onUnitChanged(e){const t=e.detail;t&&t!==this._chartMetric&&(this._chartMetric=t,localStorage.setItem("span_panel_metric",t),this._scheduleTabRender())}_onGraphSettingsChanged(){if("dashboard"===this._activeTab){const e=this.shadowRoot.getElementById("tab-content");if(e){this._dashboardTab._ctrl.onGraphSettingsChanged(e)}}}_onNavigateTab(e){const t=e.detail;t&&(this._activeTab=t,this._scheduleTabRender())}_subscribeDeviceRegistry(){!this._deviceRegistryUnsub&&this.hass?.connection&&(this._deviceRegistryUnsub=this.hass.connection.subscribeEvents(()=>this._refreshPanels(),"device_registry_updated"))}_unsubscribeDeviceRegistry(){this._deviceRegistryUnsub&&(this._deviceRegistryUnsub.then(e=>e()),this._deviceRegistryUnsub=null)}async _refreshPanels(){if(!this.hass||!this._discovered)return;const e=(await this.hass.callWS({type:"config/device_registry/list"})).filter(e=>e.identifiers?.some(e=>e[0]===a)&&!e.via_device_id),t=new Set(this._panels.map(e=>e.id)),n=new Set(e.map(e=>e.id));t.size===n.size&&[...t].every(e=>n.has(e))||(this._panels=e,!this._panels.some(e=>e.id===this._selectedPanelId)&&this._panels.length>0&&(this._selectedPanelId=this._panels[0].id,localStorage.setItem("span_panel_selected",this._selectedPanelId)))}async _discoverPanels(){if(!this.hass)return;try{const e=await this.hass.callWS({type:"config/device_registry/list"});this._panels=e.filter(e=>e.identifiers?.some(e=>e[0]===a)&&!e.via_device_id)}catch(e){return console.error("SPAN Panel: device discovery failed",e),void(this._discoveryError=`Discovery failed: ${e.message??e}`)}this._discoveryError=null,this._discovered=!0;const e=localStorage.getItem("span_panel_selected");e&&this._panels.some(t=>t.id===e)?this._selectedPanelId=e:this._panels.length>0&&(this._selectedPanelId=this._panels[0].id),this._chartMetric=localStorage.getItem("span_panel_metric")||"power"}_buildDashboardConfig(){return{chart_metric:this._chartMetric,history_minutes:5,show_panel:!0,show_battery:!0,show_evse:!0}}async _scheduleTabRender(){await this.updateComplete,await this._renderTab()}async _renderTab(){this._dashboardTab.stop(),this._monitoringTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();const e=this.shadowRoot.getElementById("tab-content");if(e)switch(this._activeTab){case"dashboard":{e.innerHTML="";const t=this._buildDashboardConfig(),n=this._panels.find(e=>e.id===this._selectedPanelId),i=n?.config_entries?.[0]??null;await this._dashboardTab.render(e,this.hass,this._selectedPanelId??"",t,i);break}case"activity":{e.innerHTML="";const t=this._panels.find(e=>e.id===this._selectedPanelId),n=t?.config_entries?.[0]??null;try{const t=await Oe(this.hass,this._selectedPanelId??void 0),i=this._buildDashboardConfig();this._listDashCtrl.init(t.topology,i,this.hass,n),await this._listDashCtrl.monitoringCache.fetch(this.hass,n),await this._listDashCtrl.fetchAndBuildHorizonMaps(),this._listCtrl.renderActivityView(e,this.hass,t.topology,i,this._listDashCtrl.monitoringCache.status),e.insertAdjacentHTML("afterbegin",``),await this._listDashCtrl.loadHistory(),this._listDashCtrl.updateDOM(e),this._listDashCtrl.startIntervals(e)}catch(t){const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=t.message,e.appendChild(n)}break}case"area":{e.innerHTML="";const t=this._panels.find(e=>e.id===this._selectedPanelId),n=t?.config_entries?.[0]??null;try{const t=await Oe(this.hass,this._selectedPanelId??void 0),i=this._buildDashboardConfig();this._listDashCtrl.init(t.topology,i,this.hass,n),await this._listDashCtrl.monitoringCache.fetch(this.hass,n),await this._listDashCtrl.fetchAndBuildHorizonMaps(),this._listCtrl.renderAreaView(e,this.hass,t.topology,i,this._listDashCtrl.monitoringCache.status),e.insertAdjacentHTML("afterbegin",``),await this._listDashCtrl.loadHistory(),this._listDashCtrl.updateDOM(e),this._listDashCtrl.startIntervals(e),this._areaUnsub||async function(e,t,n){if(!e.connection)return()=>{};const i=async()=>{try{const i=new Map;for(const[e,n]of Object.entries(t.circuits))i.set(e,n.area);await Ie(e,t);for(const[e,o]of Object.entries(t.circuits))if(o.area!==i.get(e))return void n()}catch(e){console.warn("[span-panel] area registry update failed:",e)}},[o,s]=await Promise.all([e.connection.subscribeEvents(i,"entity_registry_updated"),e.connection.subscribeEvents(i,"area_registry_updated")]);return()=>{o(),s()}}(this.hass,t.topology,()=>{"area"===this._activeTab&&this._scheduleTabRender()}).then(e=>{this._areaUnsub=e}).catch(()=>{})}catch(t){const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=t.message,e.appendChild(n)}break}case"monitoring":{e.innerHTML="";const t=this._panels.find(e=>e.id===this._selectedPanelId),n=t?.config_entries?.[0]??null;await this._monitoringTab.render(e,this.hass,n??void 0);break}}}};Wt.styles=((e,...t)=>{const n=1===e.length?e[0]:t.reduce((t,n,i)=>t+(e=>{if(!0===e._$cssResult$)return e.cssText;if("number"==typeof e)return e;throw Error("Value passed to 'css' function must be a 'css' function result: "+e+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+e[i+1],e[0]);return new x(n,e,y)})` + `}_onPanelChange(e){const t=e.target;this._selectedPanelId=t.value,localStorage.setItem("span_panel_selected",t.value),this._isFavoritesView&&"dashboard"===this._activeTab&&(this._activeTab="activity"),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null)}get _isFavoritesView(){return this._selectedPanelId===yn}_onTabClick(e){const t=e.target.closest(".shared-tab");if(!t)return;const n=t.dataset.tab;n&&n!==this._activeTab&&(this._activeTab=n,this._isFavoritesView&&"dashboard"!==n&&(this._favoritesViewState.activeTab=n,mn(this._favoritesViewState)))}_onTabContentClick(e){const t=e.target.closest(".unit-btn");if(t){const e=t.dataset.unit;if(!e||e===this._chartMetric)return;return this._chartMetric=e,void localStorage.setItem("span_panel_metric",e)}}_onSidePanelClosed(){if("dashboard"===this._activeTab){const e=this._dashboardTab._ctrl;e.monitoringCache.invalidate(),e.graphSettingsCache.invalidate()}this._listDashCtrl.monitoringMultiCache.invalidate(),this._pendingTabRender&&(this._pendingTabRender=!1,this._scheduleTabRender())}_onUnitChanged(e){const t=e.detail;t&&t!==this._chartMetric&&(this._chartMetric=t,localStorage.setItem("span_panel_metric",t))}_onListColumnsChanged(e){const t=e.detail;"number"!=typeof t||1!==t&&2!==t&&3!==t||t===this._listColumns||(this._listColumns=t,je(t))}_onGraphSettingsChanged(){if("dashboard"===this._activeTab){const e=this._root.getElementById("tab-content");if(e){this._dashboardTab._ctrl.onGraphSettingsChanged(e)}}}_onNavigateTab(e){const t=e.detail;t&&(this._activeTab=t)}_onFavoritesViewStateChangedEvent(e){if(!this._isFavoritesView)return;const t=e.detail;if(!t)return;const n=this._favoritesViewState;n.activeTab=t.view;const i=this._listDashCtrl.topology,s=i?.circuits;s&&Object.keys(s).length>0?n.expanded[t.view]=t.expanded.filter(e=>e in s):n.expanded[t.view]=t.expanded,n.searchQuery=t.searchQuery,this._persistFavoritesViewStateTimer&&clearTimeout(this._persistFavoritesViewStateTimer),this._persistFavoritesViewStateTimer=setTimeout(()=>{this._persistFavoritesViewStateTimer=null,mn(n)},250)}_subscribeDeviceRegistry(){!this._deviceRegistryUnsub&&this.hass?.connection&&(this._deviceRegistryUnsub=this.hass.connection.subscribeEvents(()=>this._refreshPanels(),"device_registry_updated"))}_unsubscribeDeviceRegistry(){this._deviceRegistryUnsub&&(this._deviceRegistryUnsub.then(e=>e()),this._deviceRegistryUnsub=null)}async _refreshPanels(){if(!this.hass||!this._discovered)return;const e=(await this.hass.callWS({type:"config/device_registry/list"})).filter(e=>e.identifiers?.some(e=>e[0]===a)&&!e.via_device_id),t=this._panels.filter(e=>e.id!==yn),n=new Map(t.map(e=>[e.id,e])),i=new Set(e.map(e=>e.id)),s=n.size!==i.size||[...n.keys()].some(e=>!i.has(e)),o=!s&&e.some(e=>{const t=n.get(e.id);return!!t&&(t.name!==e.name||t.name_by_user!==e.name_by_user)});if((s||o)&&(this._panels=this._buildPanelList(e,this._favorites),!this._panels.some(e=>e.id===this._selectedPanelId)&&this._panels.length>0)){const t=e[0];t&&(this._selectedPanelId=t.id,localStorage.setItem("span_panel_selected",this._selectedPanelId))}}async _updatePanelStatusWatch(){if(!this.hass||!this._selectedPanelId)return;if(this._selectedPanelId===yn)return;if(this._watchedPanelId===this._selectedPanelId)return;const e=this._selectedPanelId;this._watchedPanelId=e;try{const t=new We(this._errorStore),n=await Ye(this.hass,e,t);if(this._selectedPanelId!==e)return;const i=n.topology?.panel_entities?.panel_status;i&&(this._errorStore.watchPanelStatus(i),this._errorStore.updateHass(this.hass))}catch(t){console.warn("SPAN Panel: unable to fetch topology for panel status watching",t),this._watchedPanelId===e&&(this._watchedPanelId=null)}}async _discoverPanels(){if(!this._discovering&&this.hass){this._discovering=!0;try{let e;try{const t=new We(this._errorStore);e=(await t.callWS(this.hass,{type:"config/device_registry/list"},{errorId:"fetch:topology"})).filter(e=>e.identifiers?.some(e=>e[0]===a)&&!e.via_device_id)}catch(e){return console.error("SPAN Panel: device discovery failed",e),void this._errorStore.add({key:"discovery-failed",level:"error",message:n("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._discoverPanels()}})}this._favorites=await this._loadFavorites(),this._panels=this._buildPanelList(e,this._favorites),this._favoritesViewState=function(){try{const e=localStorage.getItem(vn);if(!e)return{expanded:{activity:[],area:[]}};const t=JSON.parse(e);if(!t||"object"!=typeof t)return{expanded:{activity:[],area:[]}};const n=t.expanded??{activity:[],area:[]};return{activeTab:t.activeTab,expanded:{activity:Array.isArray(n.activity)?n.activity:[],area:Array.isArray(n.area)?n.area:[]},searchQuery:"string"==typeof t.searchQuery?t.searchQuery:void 0}}catch{return{expanded:{activity:[],area:[]}}}}(),this._discovered=!0;const t=localStorage.getItem("span_panel_selected");if(t&&this._panels.some(e=>e.id===t)?this._selectedPanelId=t:e.length>0&&(this._selectedPanelId=e[0].id),this._selectedPanelId===yn){const e=this._favoritesViewState.activeTab;"activity"===e||"area"===e||"monitoring"===e?this._activeTab=e:"dashboard"===this._activeTab&&(this._activeTab="activity")}this._chartMetric=localStorage.getItem("span_panel_metric")||"power"}finally{this._discovering=!1}}}_buildPanelList(e,t){if(!Qe(t))return e;return[{id:yn,name:n("panel.favorites"),model:"__favorites__"},...e]}async _loadFavorites(){return this.hass?this._favCache.fetch(this.hass):{}}async _refreshFavorites(){const e=++this._refreshSeq;this._favCache.invalidate();const t=await this._loadFavorites();if(e!==this._refreshSeq)return;const n=this._selectedPanelId===yn;this._favorites=t;const i=this._panels.filter(e=>e.id!==yn);if(this._panels=this._buildPanelList(i,t),n&&!Qe(t)){!function(){try{localStorage.removeItem(vn)}catch{}}(),this._favoritesViewState={expanded:{activity:[],area:[]}};const e=i[0];e?(this._selectedPanelId=e.id,localStorage.setItem("span_panel_selected",e.id)):this._selectedPanelId=null}else this._isFavoritesView?this._scheduleTabRender():this._applyPanelFavorites()}_buildTabList(){const e=[];return this._isFavoritesView||e.push({id:"dashboard",label:n("tab.by_panel"),icon:"mdi:view-dashboard"}),e.push({id:"activity",label:n("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:n("tab.by_area"),icon:"mdi:home-group"},{id:"monitoring",label:n("tab.monitoring"),icon:"mdi:monitor-eye"}),e}_buildFavoritesSummaryHTML(){return function(e){return`\n
\n \n
\n ${Oe(n("header.enable_switches"))}\n
\n \n
\n
\n
\n ${et()}\n
\n \n \n
\n
\n
\n `}("current"===(this._chartMetric||"power"))}_buildFavoritesPanelStatsGridHTML(e,t){if(0===e.length)return"";return`
${e.map(e=>`\n
\n
${Oe(e.panelName||e.topology.device_name||"")}
\n ${tt(e.topology,t,e.panelDeviceId)}\n
\n `).join("")}
`}_updateFavoritesPanelStats(e,t){if(this.hass&&0!==this._favoritesPanelStats.length)for(const n of this._favoritesPanelStats){const i=e.querySelector(`.panel-stats[data-stats-panel-id="${jt(n.panelDeviceId)}"]`);i&&Ut(i,this.hass,n.topology,t,0)}}_buildDashboardConfig(){return{chart_metric:this._chartMetric,history_minutes:5,show_panel:!0,show_battery:!0,show_evse:!0}}async _scheduleTabRender(){await this.updateComplete,this._sidePanelOpen()?this._pendingTabRender=!0:await this._tabRenderScheduler()}_sidePanelOpen(){const e=this.shadowRoot?.getElementById("tab-content");return!!e?.querySelector("span-side-panel[open]")}async _renderTab(){const e=this._beginRender();this._dashboardTab.stop(),this._monitoringTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();for(const e of this._favoritesMonitoringTabs.values())e.stop();this._favoritesMonitoringTabs.clear(),this._favoritesPanelStats=[];const t=this._root.getElementById("tab-content");if(t)if(this._isFavoritesView)await this._renderFavoritesTab(t,e);else switch(this._listDashCtrl.clearFavoriteRefs(),this._listCtrl.setViewName(null),this._applyPanelFavorites(),this._activeTab){case"dashboard":{t.innerHTML="";const e=this._buildDashboardConfig(),n=this._panels.find(e=>e.id===this._selectedPanelId),i=n?.config_entries?.[0]??null;await this._dashboardTab.render(t,this.hass,this._selectedPanelId??"",e,i);break}case"activity":{t.innerHTML="";const n=this._panels.find(e=>e.id===this._selectedPanelId),i=n?.config_entries?.[0]??null;try{const n=new We(this._errorStore),s=await Ye(this.hass,this._selectedPanelId??void 0,n);if(e())return;const o=this._buildDashboardConfig();if(this._listDashCtrl.init(s.topology,o,this.hass,i),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.monitoringCache.fetch(this.hass,i),e())return;if(await this._listDashCtrl.fetchAndBuildHorizonMaps(),e())return;const r=s.topology?nt(s.topology,o):"";if(this._listCtrl.setColumns(this._listColumns),this._listCtrl.renderActivityView(t,this.hass,s.topology,o,this._listDashCtrl.monitoringCache.status,r),await this._listDashCtrl.loadHistory(),e())return;this._listDashCtrl.updateDOM(t),this._listDashCtrl.startIntervals(t)}catch(n){if(e())return;const i=document.createElement("p");i.style.color="var(--error-color)",i.textContent=n.message,t.appendChild(i)}break}case"area":{t.innerHTML="";const i=this._panels.find(e=>e.id===this._selectedPanelId),s=i?.config_entries?.[0]??null;try{const i=new We(this._errorStore),o=await Ye(this.hass,this._selectedPanelId??void 0,i);if(e())return;const r=this._buildDashboardConfig();if(this._listDashCtrl.init(o.topology,r,this.hass,s),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.monitoringCache.fetch(this.hass,s),e())return;if(await this._listDashCtrl.fetchAndBuildHorizonMaps(),e())return;const a=o.topology?nt(o.topology,r):"";if(this._listCtrl.setColumns(this._listColumns),this._listCtrl.renderAreaView(t,this.hass,o.topology,r,this._listDashCtrl.monitoringCache.status,a),await this._listDashCtrl.loadHistory(),e())return;this._listDashCtrl.updateDOM(t),this._listDashCtrl.startIntervals(t),this._areaUnsub||this._areaSubscribing||(this._areaSubscribing=!0,async function(e,t,i,s){if(!e.connection)return()=>{};const o=async()=>{try{const n=new Map;for(const[e,i]of Object.entries(t.circuits))n.set(e,i.area);await Ze(e,t);for(const[e,s]of Object.entries(t.circuits))if(s.area!==n.get(e))return void i()}catch(e){console.warn("[span-panel] area registry update failed:",e),s?.add({key:"fetch:areas",level:"warning",message:n("error.areas_failed"),persistent:!1})}},[r,a]=await Promise.all([e.connection.subscribeEvents(o,"entity_registry_updated"),e.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{r(),a()}}(this.hass,o.topology,()=>{"area"===this._activeTab&&this._scheduleTabRender()},this._errorStore).then(e=>{this._areaSubscribing?this._areaUnsub=e:e()}).catch(e=>{this._areaSubscribing=!1,console.warn("SPAN Panel: area subscription failed",e),this._errorStore.add({key:"subscribe:area",level:"warning",message:n("error.areas_failed"),persistent:!1})}))}catch(e){const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=e instanceof Error?e.message:String(e),t.appendChild(n)}break}case"monitoring":{t.innerHTML="";const e=this._panels.find(e=>e.id===this._selectedPanelId),n=e?.config_entries?.[0]??null;await this._monitoringTab.render(t,this.hass,n??void 0);break}}}async _renderFavoritesTab(e,t){if(e.innerHTML="",!this.hass)return;const i=this._panels.filter(e=>e.id!==yn),s=await this._favCtrl.build(this.hass,this._favorites,i,this._errorStore);if(t())return;const o=s.perPanelStats.map(e=>{const t=e.topology.panel_entities?.panel_status;return"string"==typeof t?{entityId:t,panelName:e.panelName}:null}).filter(e=>null!==e);this._errorStore.watchPanelStatuses(o),this._errorStore.updateHass(this.hass);const r=new Map;for(const e of s.perPanelStats){const t=i.find(t=>t.id===e.panelDeviceId);r.set(e.panelDeviceId,{panelName:e.panelName,topology:e.topology,configEntryId:t?.config_entries?.[0]??null})}this._listDashCtrl.setFavoritesPerPanelInfo(r);const a=s.topology,l=s.entryIds[0]??null,c=Object.keys(a.circuits).length>0,d=Object.keys(a.sub_devices??{}).length>0;if(!c&&!d){const t=document.createElement("p");return t.style.color="var(--secondary-text-color)",t.style.padding="24px",t.textContent=n("list.no_results"),void e.appendChild(t)}if(this._listDashCtrl.setFavoriteRefs(a._favoriteRefs),this._listDashCtrl.setPanelFavorites(null),"monitoring"===this._activeTab)return this._listCtrl.setViewName(null),void await this._renderFavoritesMonitoring(e,s.entryIds,i);const h=this._activeTab,p=new Set(Object.keys(a.circuits)),u=this._favoritesViewState.expanded[h].filter(e=>p.has(e));this._listCtrl.setViewName(h),this._listCtrl.setInitialExpansion(u),this._listCtrl.setInitialSearchQuery(this._favoritesViewState.searchQuery??""),this._listCtrl.setColumns(this._listColumns);const g=this._buildDashboardConfig();if(this._listDashCtrl.init(a,g,this.hass,l),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.fetchAndBuildHorizonMaps(),t())return;const _=await this._listDashCtrl.fetchMergedMonitoringStatus(s.entryIds);if(!t()){this._favoritesPanelStats=s.perPanelStats;try{if(await this._listDashCtrl.loadHistory(),t())return;const n=this._buildFavoritesSummaryHTML(),i=this._buildFavoritesPanelStatsGridHTML(s.perPanelStats,g),o=n+i+(d?`
\n
${Et(a,this.hass,g)}
\n
`:"");"activity"===h?this._listCtrl.renderActivityView(e,this.hass,a,g,_,o):this._listCtrl.renderAreaView(e,this.hass,a,g,_,o),this._updateFavoritesPanelStats(e,g),this._listDashCtrl.setupResizeObserver(e,e),this._listDashCtrl.startIntervals(e,()=>{this._updateFavoritesPanelStats(e,g)})}catch(n){if(t())return;const i=document.createElement("p");i.style.color="var(--error-color)",i.textContent=n.message,e.appendChild(i)}}}async _renderFavoritesMonitoring(e,t,n){if(!this.hass)return;const i=document.createElement("div");i.className="favorites-monitoring-stack",e.appendChild(i);const s=new Map;for(const e of n){const t=e.config_entries?.[0];t&&s.set(t,e)}const o=new Map;for(const e of t){const t=s.get(e),n=document.createElement("div");n.className="favorites-monitoring-block",n.style.marginBottom="24px";const r=document.createElement("h2");r.style.margin="8px 0 12px",r.style.fontSize="1em",r.textContent=t?.name_by_user??t?.name??e,n.appendChild(r);const a=document.createElement("div");n.appendChild(a),i.appendChild(n);const l=new sn;l.errorStore=this._errorStore,o.set(e,l);try{await l.render(a,this.hass,e)}catch(t){console.warn("SPAN Panel: favorites monitoring render failed",e,t);const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=t.message??String(t),a.appendChild(n)}}this._favoritesMonitoringTabs=o}_applyPanelFavorites(){if(!this._selectedPanelId||this._isFavoritesView)return this._listDashCtrl.setPanelFavorites(null),void this._dashboardTab.setPanelFavorites(null);const e=this._favorites[this._selectedPanelId],t={panelDeviceId:this._selectedPanelId,circuitUuids:new Set(e?.circuits??[]),subDeviceIds:new Set(e?.sub_devices??[])};this._listDashCtrl.setPanelFavorites(t),this._dashboardTab.setPanelFavorites(t)}};wn._shellStyles=C` :host { color: var(--primary-text-color); } @@ -109,6 +175,10 @@ const Ce={attribute:!0,type:String,converter:L,reflect:!1,hasChanged:H},Ee=(e=Ce margin: 0 0 0 24px; line-height: 20px; flex-grow: 1; + display: flex; + align-items: center; + gap: 16px; + min-width: 0; } .panel-selector select { color: inherit; @@ -125,10 +195,13 @@ const Ce={attribute:!0,type:String,converter:L,reflect:!1,hasChanged:H},Ee=(e=Ce color: var(--primary-text-color); } .panel-tabs { - margin-left: max(env(safe-area-inset-left), 24px); - margin-right: max(env(safe-area-inset-right), 24px); display: flex; gap: 0; + overflow-x: auto; + scrollbar-width: none; + } + .panel-tabs::-webkit-scrollbar { + display: none; } .panel-tab { padding: 8px 20px; @@ -177,4 +250,4 @@ const Ce={attribute:!0,type:String,converter:L,reflect:!1,hasChanged:H},Ee=(e=Ce opacity: 1; border-bottom-color: var(--app-header-text-color, white); } - `,m([ke({attribute:!1})],Wt.prototype,"hass",void 0),m([ke({type:Boolean,reflect:!0})],Wt.prototype,"narrow",void 0),m([ze()],Wt.prototype,"_panels",void 0),m([ze()],Wt.prototype,"_selectedPanelId",void 0),m([ze()],Wt.prototype,"_activeTab",void 0),m([ze()],Wt.prototype,"_discovered",void 0),m([ze()],Wt.prototype,"_discoveryError",void 0),m([ze()],Wt.prototype,"_chartMetric",void 0),Wt=m([(e=>(t,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(e,t)}):customElements.define(e,t)})("span-panel")],Wt),console.warn("%c SPAN-PANEL %c v0.9.2 ","background: var(--primary-color, #4dd9af); color: #000; font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;");let Bt=!1;const Vt=Wt.prototype.connectedCallback;Wt.prototype.connectedCallback=function(){Bt=!0,Vt.call(this)};const Qt=Wt.prototype.disconnectedCallback;Wt.prototype.disconnectedCallback=function(){Bt=!1,Qt.call(this)},document.addEventListener("visibilitychange",()=>{"visible"===document.visibilityState&&(Bt||window.location.pathname.includes("span-panel")&&setTimeout(()=>{Bt||location.reload()},200))}); + `,wn.styles=[bn._shellStyles,$(Kt)],m([Ne({attribute:!1})],wn.prototype,"hass",void 0),m([Ne({type:Boolean,reflect:!0})],wn.prototype,"narrow",void 0),m([Me()],wn.prototype,"_panels",void 0),m([Me()],wn.prototype,"_selectedPanelId",void 0),m([Me()],wn.prototype,"_activeTab",void 0),m([Me()],wn.prototype,"_discovered",void 0),m([Me()],wn.prototype,"_chartMetric",void 0),m([Me()],wn.prototype,"_listColumns",void 0),m([Me()],wn.prototype,"_favorites",void 0),wn=bn=m([Ee("span-panel")],wn),console.warn("%c SPAN-PANEL %c v0.9.4 ","background: var(--primary-color, #4dd9af); color: #000; font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;");let xn=!1;const Sn=wn.prototype.connectedCallback;wn.prototype.connectedCallback=function(){xn=!0,Sn.call(this)};const $n=wn.prototype.disconnectedCallback;wn.prototype.disconnectedCallback=function(){xn=!1,$n.call(this)},document.addEventListener("visibilitychange",()=>{"visible"===document.visibilityState&&(xn||window.location.pathname.includes("span-panel")&&setTimeout(()=>{xn||location.reload()},200))}); diff --git a/package-lock.json b/package-lock.json index 0c5bf64..f57b9c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "span-panel-card", - "version": "0.9.2", + "version": "0.9.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "span-panel-card", - "version": "0.9.2", + "version": "0.9.4", "dependencies": { "lit": "^3.3.2" }, diff --git a/package.json b/package.json index 00cb3d2..8a4a07d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "span-panel-card", - "version": "0.9.2", + "version": "0.9.4", "private": true, "type": "module", "scripts": { diff --git a/src/card/card-discovery.ts b/src/card/card-discovery.ts index 9363256..fbe4c20 100644 --- a/src/card/card-discovery.ts +++ b/src/card/card-discovery.ts @@ -1,5 +1,6 @@ import { INTEGRATION_DOMAIN } from "../constants.js"; import { resolveAndAssignAreas } from "../core/area-resolver.js"; +import { RetryManager } from "../core/retry-manager.js"; import { t } from "../i18n.js"; import type { HomeAssistant, PanelTopology, PanelDevice, DiscoveryResult, Circuit, CircuitEntities } from "../types.js"; @@ -25,23 +26,23 @@ interface EntityRegistryEntry { // ── Primary discovery via custom WebSocket API ─────────────────────────────── -export async function discoverTopology(hass: HomeAssistant, deviceId: string | undefined): Promise { +export async function discoverTopology(hass: HomeAssistant, deviceId: string | undefined, retry?: RetryManager | null): Promise { if (!deviceId) { throw new Error(t("card.device_not_found")); } - const topology = await hass.callWS({ - type: `${INTEGRATION_DOMAIN}/panel_topology`, - device_id: deviceId, - }); + + const topologyMsg = { type: `${INTEGRATION_DOMAIN}/panel_topology`, device_id: deviceId }; + const topology = retry ? await retry.callWS(hass, topologyMsg, { errorId: "fetch:topology" }) : await hass.callWS(topologyMsg); const panelSize = topology.panel_size ?? panelSizeFromCircuits(topology.circuits); if (!panelSize) { throw new Error(t("card.topology_error")); } - const devices = await hass.callWS({ - type: "config/device_registry/list", - }); + const devicesMsg = { type: "config/device_registry/list" }; + const devices = retry + ? await retry.callWS(hass, devicesMsg, { errorId: "fetch:topology" }) + : await hass.callWS(devicesMsg); const panelDevice = deviceToPanelDevice(devices.find(d => d.id === deviceId)); await resolveAndAssignAreas(hass, topology); @@ -80,14 +81,12 @@ function deviceToPanelDevice(entry: DeviceRegistryEntry | undefined): PanelDevic // ── Fallback discovery from entity registry ────────────────────────────────── -export async function discoverEntitiesFallback(hass: HomeAssistant, deviceId: string | undefined): Promise { +export async function discoverEntitiesFallback(hass: HomeAssistant, deviceId: string | undefined, retry?: RetryManager | null): Promise { + const devicesMsg = { type: "config/device_registry/list" }; + const entitiesMsg = { type: "config/entity_registry/list" }; const [devices, entities] = await Promise.all([ - hass.callWS({ - type: "config/device_registry/list", - }), - hass.callWS({ - type: "config/entity_registry/list", - }), + retry ? retry.callWS(hass, devicesMsg, { errorId: "fetch:topology" }) : hass.callWS(devicesMsg), + retry ? retry.callWS(hass, entitiesMsg, { errorId: "fetch:topology" }) : hass.callWS(entitiesMsg), ]); const panelDevice = deviceToPanelDevice(devices.find(d => d.id === deviceId)); @@ -164,7 +163,13 @@ export async function discoverEntitiesFallback(hass: HomeAssistant, deviceId: st let serial = ""; if (panelDevice.identifiers) { for (const pair of panelDevice.identifiers) { - if (pair[0] === INTEGRATION_DOMAIN) serial = pair[1]; + // Identifier pairs are [domain, value]. Skip malformed shapes rather + // than silently indexing past the end. + if (!Array.isArray(pair) || pair.length < 2) continue; + const [domain, value] = pair; + if (domain === INTEGRATION_DOMAIN && typeof value === "string") { + serial = value; + } } } diff --git a/src/card/card-styles.ts b/src/card/card-styles.ts index 488344a..fe97ac4 100644 --- a/src/card/card-styles.ts +++ b/src/card/card-styles.ts @@ -53,6 +53,65 @@ export const CARD_STYLES: string = ` gap: 16px 32px; } + /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */ + .favorites-summary { + padding: 8px 24px; + border-bottom: 1px solid var(--divider-color, #e0e0e0); + display: flex; + align-items: center; + gap: 12px; + } + /* Override the generic .gear-icon { margin-left: auto } rule so the + favorites gear stays flush-left instead of floating to the right of + the flex row (same idea as .panel-identity .panel-gear does for + real-panel headers). */ + .favorites-summary .favorites-gear { + margin-left: 0; + } + /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle. + margin-left:auto moved here from .favorites-summary-unit-toggle so the + legend and toggle cluster together, matching the real-panel header + layout. */ + .favorites-summary-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 16px; + } + .favorites-subdevices-section { + padding: 8px 16px 0; + } + + /* Favorites view: responsive grid of per-contributing-panel status cards. */ + .favorites-panel-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 12px; + padding: 12px 24px; + border-bottom: 1px solid var(--divider-color, #333); + } + .favorites-panel-card { + background: var(--secondary-background-color, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--divider-color, #333); + border-radius: 8px; + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 6px; + } + .favorites-panel-card-title { + font-size: 0.85em; + font-weight: 600; + color: var(--primary-text-color); + opacity: 0.85; + } + .favorites-panel-card .panel-stats { + gap: 10px 20px; + } + .favorites-panel-card .stat-value { + font-size: 1.15em; + } + .stat { display: flex; flex-direction: column; } .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; } .stat-row { display: flex; align-items: baseline; gap: 2px; } @@ -494,6 +553,33 @@ export const CARD_STYLES: string = ` flex-direction: column; gap: 6px; } + /* Each circuit is wrapped in a .list-cell so the row + its optional + expanded chart stay together. In single-column flex mode the cell + just stacks naturally. In multi-column grid mode the cell becomes + one grid item, so the chart is always in the same column as its + row. Area headers (rendered as siblings, not inside a cell) span + all columns via their inline "grid-column: 1 / -1". */ + .list-cell { + display: flex; + flex-direction: column; + min-width: 0; + } + .list-view[data-columns="2"], + .list-view[data-columns="3"] { + display: grid; + grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr)); + gap: 6px 8px; + flex-direction: initial; + } + /* On narrow viewports a 2/3-column list would squeeze rows into an + unreadable shape, so force stacking regardless of user preference. */ + @media (max-width: 599px) { + .list-view[data-columns="2"], + .list-view[data-columns="3"] { + display: flex; + flex-direction: column; + } + } .list-row { display: flex; @@ -570,10 +656,23 @@ export const CARD_STYLES: string = ` transform: rotate(180deg); } + .list-row .gear-icon { + background: transparent; + border: none; + padding: 2px; + cursor: pointer; + color: #555; + display: inline-flex; + align-items: center; + } + .list-row .gear-icon:hover { + color: var(--primary-text-color); + } + /* ── Expanded circuit content ──────────────────────────── */ .list-expanded-content { - padding: 12px; + padding: 0; background: var(--card-background-color, #1c1c1c); border: 1px solid var(--divider-color, #333); border-top: none; @@ -582,10 +681,12 @@ export const CARD_STYLES: string = ` margin-bottom: 2px; } - .list-expanded-content .circuit-slot { + .circuit-slot.circuit-chart-only { border: none; margin: 0; background: none; + padding: 8px 12px; + min-height: 0; } /* ── Area headers ──────────────────────────────────────── */ diff --git a/src/card/span-panel-card.ts b/src/card/span-panel-card.ts index 1888d8c..244ee11 100644 --- a/src/card/span-panel-card.ts +++ b/src/card/span-panel-card.ts @@ -1,8 +1,9 @@ -import { LitElement, html, unsafeCSS } from "lit"; +import { LitElement, html, unsafeCSS, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { DEFAULT_CHART_METRIC } from "../constants.js"; import { setLanguage, t } from "../i18n.js"; import { escapeHtml } from "../helpers/sanitize.js"; +import { loadListColumns } from "../helpers/list-columns.js"; import { buildHeaderHTML } from "../core/header-renderer.js"; import { buildGridHTML } from "../core/grid-renderer.js"; import { buildSubDevicesHTML } from "../core/sub-device-renderer.js"; @@ -11,13 +12,17 @@ import { DashboardController } from "../core/dashboard-controller.js"; import { ListViewController } from "../core/list-view-controller.js"; import { buildTabBarHTML, bindTabBarEvents } from "../core/tab-bar-renderer.js"; import { subscribeAreaUpdates } from "../core/area-resolver.js"; +import { ErrorStore } from "../core/error-store.js"; import { discoverTopology, discoverEntitiesFallback } from "./card-discovery.js"; +import { RetryManager } from "../core/retry-manager.js"; import { CARD_STYLES } from "./card-styles.js"; import "../core/side-panel.js"; +import "../core/error-banner.js"; import type { HomeAssistant, PanelTopology, PanelDevice, CardConfig } from "../types.js"; interface SpanSidePanelElement extends HTMLElement { hass: HomeAssistant; + errorStore: ErrorStore | null; } const PREVIEW_CIRCUITS = [ @@ -51,7 +56,6 @@ export class SpanPanelCard extends LitElement { @state() private _config: CardConfig = {}; @state() private _discovered = false; @state() private _discovering = false; - @state() private _discoveryError: string | null = null; @state() private _topology: PanelTopology | null = null; @state() private _activeTab: "panel" | "activity" | "area" = "panel"; @@ -60,7 +64,15 @@ export class SpanPanelCard extends LitElement { private _historyLoaded = false; private readonly _ctrl = new DashboardController(); private readonly _listCtrl = new ListViewController(this._ctrl); + private readonly _errorStore = new ErrorStore(); private _areaUnsub: (() => void) | null = null; + /** + * Set while a ``subscribeAreaUpdates`` promise is in-flight. If the + * element disconnects before the promise resolves we flip this to + * ``false`` so the later ``.then`` callback can unsubscribe immediately + * instead of leaking the subscription. + */ + private _areaSubscribing = false; private _tabBarCleanup: (() => void) | null = null; private _onVisibilityChange: (() => void) | null = null; @@ -70,14 +82,26 @@ export class SpanPanelCard extends LitElement { return this._panelDevice?.config_entries?.[0] ?? null; } + /** + * Centralised accessor for the shadow root. LitElement guarantees this + * is set for a connected component; a null here means SSR or a teardown + * race, both of which we want to surface loudly rather than paper over + * with `!` assertions sprinkled at every use site. + */ + private get _root(): ShadowRoot { + const root = this.shadowRoot; + if (!root) throw new Error("span-panel-card: shadow root is not available"); + return root; + } + connectedCallback(): void { super.connectedCallback(); - this._ctrl.startIntervals(this.shadowRoot!); + this._ctrl.startIntervals(this._root); this._onVisibilityChange = () => { if (document.visibilityState !== "visible" || !this._discovered || !this.hass) return; this._ctrl.recordSamples(); - this._ctrl.updateDOM(this.shadowRoot!); + this._ctrl.updateDOM(this._root); }; document.addEventListener("visibilitychange", this._onVisibilityChange); } @@ -85,6 +109,7 @@ export class SpanPanelCard extends LitElement { disconnectedCallback(): void { this._ctrl.stopIntervals(); this._listCtrl.stop(); + this._areaSubscribing = false; if (this._areaUnsub) { this._areaUnsub(); this._areaUnsub = null; @@ -97,21 +122,23 @@ export class SpanPanelCard extends LitElement { document.removeEventListener("visibilitychange", this._onVisibilityChange); this._onVisibilityChange = null; } + this._errorStore.dispose(); super.disconnectedCallback(); } setConfig(config: CardConfig): void { + this._errorStore.clear(); this._config = config; this._discovered = false; this._discovering = false; this._historyLoaded = false; - this._discoveryError = null; this._topology = null; this._panelDevice = null; this._panelSize = 0; this._activeTab = "panel"; this._ctrl.reset(); this._ctrl.setConfig(config); + this._ctrl.errorStore = this._errorStore; } getCardSize(): number { @@ -143,18 +170,13 @@ export class SpanPanelCard extends LitElement { return this._renderPreview(); } - // State 2: Not yet discovered or error + // State 2: Not yet discovered if (!this._discovered) { - if (this._discoveryError) { - return html` - -
${escapeHtml(this._discoveryError)}
-
- `; - } + const hasError = this._errorStore.hasPersistent("discovery-failed"); return html` -
${escapeHtml(t("card.loading"))}
+ + ${hasError ? nothing : html`
${escapeHtml(t("card.connecting"))}
`}
`; } @@ -165,8 +187,10 @@ export class SpanPanelCard extends LitElement { @click=${this._onCardClick} @graph-settings-changed=${this._onGraphSettingsChanged} @unit-changed=${this._onListUnitChanged} + @list-columns-changed=${this._onListColumnsChanged} @side-panel-closed=${this._onSidePanelClosed} > +
@@ -179,6 +203,7 @@ export class SpanPanelCard extends LitElement { setLanguage(this.hass.language); this._ctrl.hass = this.hass; + this._errorStore.updateHass(this.hass); if (!this._config.device_id) return; @@ -189,25 +214,29 @@ export class SpanPanelCard extends LitElement { if (this._discovered) { this._ctrl.recordSamples(); - this._ctrl.updateDOM(this.shadowRoot!); + this._ctrl.updateDOM(this._root); - const sidePanel = this.shadowRoot!.querySelector("span-side-panel") as SpanSidePanelElement | null; - if (sidePanel) sidePanel.hass = this.hass; + const sidePanel = this._root.querySelector("span-side-panel") as SpanSidePanelElement | null; + if (sidePanel) { + sidePanel.hass = this.hass; + sidePanel.errorStore = this._errorStore; + } } if (this._discovered && this._activeTab !== "panel" && this._topology) { - this._listCtrl.updateCollapsedRows(this.shadowRoot!, this.hass, this._topology, this._config); + this._listCtrl.updateCollapsedRows(this._root, this.hass, this._topology, this._config); } } // ── Discovery ────────────────────────────────────────────────────────── private async _startDiscovery(): Promise { + if (this._discovering) return; this._discovering = true; await this._discoverTopology(); - if (this._discoveryError) { + if (this._errorStore.hasPersistent("discovery-failed")) { this._discovering = false; return; } @@ -216,17 +245,45 @@ export class SpanPanelCard extends LitElement { this._discovering = false; this._ctrl.init(this._topology, this._config, this.hass, this._configEntryId); - // Subscribe to area changes + // Start watching panel_status binary sensor for online/offline state + if (this._topology?.panel_entities?.panel_status) { + this._errorStore.watchPanelStatus(this._topology.panel_entities.panel_status); + this._errorStore.updateHass(this.hass); + } + + // Subscribe to area changes. The subscribe call is async, so guard + // against disconnect-before-resolve: if ``_areaSubscribing`` is cleared + // (disconnectedCallback), drop the unsubscribe on the floor — we'd + // otherwise store it on a detached element and never call it. if (this._topology) { - subscribeAreaUpdates(this.hass, this._topology, () => { - if (this._activeTab === "area" && this._discovered) { - this._populateCardContent(); - } - }) + this._areaSubscribing = true; + subscribeAreaUpdates( + this.hass, + this._topology, + () => { + if (this._activeTab === "area" && this._discovered) { + this._populateCardContent(); + } + }, + this._errorStore + ) .then(unsub => { - this._areaUnsub = unsub; + if (this._areaSubscribing) { + this._areaUnsub = unsub; + } else { + unsub(); + } }) - .catch(() => {}); + .catch((err: unknown) => { + this._areaSubscribing = false; + console.warn("SPAN Panel: area subscription failed", err); + this._errorStore.add({ + key: "subscribe:area", + level: "warning", + message: t("error.areas_failed"), + persistent: false, + }); + }); } // Wait for lit to render the card-content div @@ -236,27 +293,37 @@ export class SpanPanelCard extends LitElement { this._loadHistory(); this._ctrl.monitoringCache.fetch(this.hass, this._configEntryId).then(() => { - if (this._discovered) this._ctrl.updateDOM(this.shadowRoot!); + if (this._discovered) this._ctrl.updateDOM(this._root); }); } private async _discoverTopology(): Promise { if (!this.hass) return; + const retry = new RetryManager(this._errorStore); try { - const result = await discoverTopology(this.hass, this._config.device_id); + const result = await discoverTopology(this.hass, this._config.device_id, retry); this._topology = result.topology; this._panelDevice = result.panelDevice; this._panelSize = result.panelSize; } catch (err) { console.error("SPAN Panel: topology fetch failed, falling back to entity discovery", err); try { - const result = await discoverEntitiesFallback(this.hass, this._config.device_id); + const result = await discoverEntitiesFallback(this.hass, this._config.device_id, retry); this._topology = result.topology; this._panelDevice = result.panelDevice; this._panelSize = result.panelSize; } catch (fallbackErr) { console.error("SPAN Panel: fallback discovery also failed", fallbackErr); - this._discoveryError = (fallbackErr as Error).message; + this._errorStore.add({ + key: "discovery-failed", + level: "error", + message: t("error.discovery_failed"), + persistent: true, + retryFn: () => { + this._errorStore.remove("discovery-failed"); + this._startDiscovery(); + }, + }); } } } @@ -269,7 +336,7 @@ export class SpanPanelCard extends LitElement { try { await this._ctrl.loadHistory(); - this._ctrl.updateDOM(this.shadowRoot!); + this._ctrl.updateDOM(this._root); } catch (err) { console.warn("SPAN Panel: history fetch failed, charts will populate live", err); } @@ -278,11 +345,11 @@ export class SpanPanelCard extends LitElement { // ── Imperative card content ──────────────────────────────────────────── private _populateCardContent(): void { - const container = this.shadowRoot!.querySelector("#card-content"); + const container = this._root.querySelector("#card-content"); if (!container || !this.hass || !this._topology || !this._panelSize) return; // Populate tab bar - const tabsContainer = this.shadowRoot!.querySelector("#card-tabs"); + const tabsContainer = this._root.querySelector("#card-tabs"); if (tabsContainer) { const tabDefs = [ { id: "panel", label: t("tab.by_panel"), icon: "mdi:view-dashboard" }, @@ -325,25 +392,32 @@ export class SpanPanelCard extends LitElement { const slideEl = container.querySelector(".slide-confirm"); if (slideEl) { - const haCard = this.shadowRoot!.querySelector("ha-card"); + const haCard = this._root.querySelector("ha-card"); this._ctrl.bindSlideConfirm(slideEl, haCard); if (haCard) haCard.classList.add("switches-disabled"); } - const sidePanel = this.shadowRoot!.querySelector("span-side-panel") as SpanSidePanelElement | null; - if (sidePanel) sidePanel.hass = this.hass; + const sidePanel = this._root.querySelector("span-side-panel") as SpanSidePanelElement | null; + if (sidePanel) { + sidePanel.hass = this.hass; + sidePanel.errorStore = this._errorStore; + } this._ctrl.recordSamples(); - this._ctrl.updateDOM(this.shadowRoot!); - this._ctrl.setupResizeObserver(this.shadowRoot!, this.shadowRoot!.querySelector("ha-card")); + this._ctrl.updateDOM(this._root); + this._ctrl.setupResizeObserver(this._root, this._root.querySelector("ha-card")); } else if (this._activeTab === "activity") { container.innerHTML = ""; - this._listCtrl.renderActivityView(container as HTMLElement, this.hass, this._topology, this._config, this._ctrl.monitoringCache.status); - this._ctrl.updateDOM(this.shadowRoot!); + const listHeaderHTML = buildHeaderHTML(this._topology, this._config); + this._listCtrl.setColumns(loadListColumns()); + this._listCtrl.renderActivityView(container as HTMLElement, this.hass, this._topology, this._config, this._ctrl.monitoringCache.status, listHeaderHTML); + this._ctrl.updateDOM(this._root); } else if (this._activeTab === "area") { container.innerHTML = ""; - this._listCtrl.renderAreaView(container as HTMLElement, this.hass, this._topology, this._config, this._ctrl.monitoringCache.status); - this._ctrl.updateDOM(this.shadowRoot!); + const listHeaderHTML = buildHeaderHTML(this._topology, this._config); + this._listCtrl.setColumns(loadListColumns()); + this._listCtrl.renderAreaView(container as HTMLElement, this.hass, this._topology, this._config, this._ctrl.monitoringCache.status, listHeaderHTML); + this._ctrl.updateDOM(this._root); } } @@ -364,14 +438,14 @@ export class SpanPanelCard extends LitElement { // Toggle pill const togglePill = target.closest(".toggle-pill"); if (togglePill) { - this._ctrl.onToggleClick(ev, this.shadowRoot!); + this._ctrl.onToggleClick(ev, this._root); return; } // Gear icon const gearBtn = target.closest(".gear-icon") as HTMLElement | null; if (gearBtn) { - this._ctrl.onGearClick(ev, this.shadowRoot!); + this._ctrl.onGearClick(ev, this._root); return; } } @@ -394,7 +468,7 @@ export class SpanPanelCard extends LitElement { this._historyLoaded = false; this._populateCardContent(); await this._loadHistory(); - this._ctrl.updateDOM(this.shadowRoot!); + this._ctrl.updateDOM(this._root); } private async _onListUnitChanged(e: Event): Promise { @@ -415,11 +489,22 @@ export class SpanPanelCard extends LitElement { this._historyLoaded = false; this._populateCardContent(); await this._loadHistory(); - this._ctrl.updateDOM(this.shadowRoot!); + this._ctrl.updateDOM(this._root); } private _onGraphSettingsChanged(): void { - this._ctrl.onGraphSettingsChanged(this.shadowRoot!); + this._ctrl.onGraphSettingsChanged(this._root); + } + + private _onListColumnsChanged(e: Event): void { + const n = (e as CustomEvent).detail; + if (typeof n !== "number" || (n !== 1 && n !== 2 && n !== 3)) return; + // Re-render the active list view so the grid reflows. The setting + // is already persisted by the side panel; loadListColumns() reads + // the new value during _populateCardContent. + if (this._activeTab === "activity" || this._activeTab === "area") { + this._populateCardContent(); + } } private _onSidePanelClosed(): void { diff --git a/src/chart/chart-options.ts b/src/chart/chart-options.ts index 2e72a56..d81a3be 100644 --- a/src/chart/chart-options.ts +++ b/src/chart/chart-options.ts @@ -104,8 +104,8 @@ export function buildChartOptions( x2: 0, y2: 1, colorStops: [ - { offset: 0, color: `rgba(${accentRgb}, 0.35)` }, - { offset: 1, color: `rgba(${accentRgb}, 0.02)` }, + { offset: 0, color: `rgba(${accentRgb}, 0.18)` }, + { offset: 1, color: `rgba(${accentRgb}, 0.18)` }, ], }, }, diff --git a/src/constants.ts b/src/constants.ts index ffcb6c1..64d110c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,7 +1,7 @@ import { t } from "./i18n.js"; import type { ChartMetricDef, GraphHorizonPreset, SheddingPriorityDef } from "./types.js"; -export const CARD_VERSION = "0.9.2"; +export const CARD_VERSION = "0.9.4"; // -- Defaults -- @@ -53,7 +53,6 @@ export const MIN_HISTORY_DURATION_MS = 60_000; export const INPUT_DEBOUNCE_MS = 500; export const THRESHOLD_DEBOUNCE_MS = 800; -export const ERROR_DISPLAY_MS = 5_000; // -- Chart metric definitions -- diff --git a/src/core/area-resolver.ts b/src/core/area-resolver.ts index 4d29349..4c1b364 100644 --- a/src/core/area-resolver.ts +++ b/src/core/area-resolver.ts @@ -1,4 +1,6 @@ import type { HomeAssistant, PanelTopology } from "../types.js"; +import type { ErrorStore } from "./error-store.js"; +import { t } from "../i18n.js"; interface AreaRegistryEntry { area_id: string; @@ -89,7 +91,12 @@ export async function resolveAndAssignAreas(hass: HomeAssistant, topology: Panel * * Returns an unsubscribe function that tears down both listeners. */ -export async function subscribeAreaUpdates(hass: HomeAssistant, topology: PanelTopology, callback: () => void): Promise<() => void> { +export async function subscribeAreaUpdates( + hass: HomeAssistant, + topology: PanelTopology, + callback: () => void, + errorStore?: ErrorStore | null +): Promise<() => void> { if (!hass.connection) { return () => {}; } @@ -113,6 +120,12 @@ export async function subscribeAreaUpdates(hass: HomeAssistant, topology: PanelT } } catch (err) { console.warn("[span-panel] area registry update failed:", err); + errorStore?.add({ + key: "fetch:areas", + level: "warning", + message: t("error.areas_failed"), + persistent: false, + }); } }; diff --git a/src/core/circuit-state.ts b/src/core/circuit-state.ts new file mode 100644 index 0000000..20011ec --- /dev/null +++ b/src/core/circuit-state.ts @@ -0,0 +1,17 @@ +import { hasCustomOverrides, isAlertActive } from "./monitoring-status.js"; +import type { Circuit, MonitoringPointInfo } from "../types.js"; + +/** + * Build the set of state-visualization classes that apply to a circuit's + * rendered slot. Shared by the breaker grid and the list view's + * chart-only expanded slot so both render the same border/background + * signaling. + */ +export function getCircuitStateClasses(_circuit: Circuit, monitoringInfo: MonitoringPointInfo | null, isOn: boolean, isProducer: boolean): string { + const classes: string[] = []; + if (!isOn) classes.push("circuit-off"); + if (isProducer) classes.push("circuit-producer"); + if (isAlertActive(monitoringInfo)) classes.push("circuit-alert"); + if (hasCustomOverrides(monitoringInfo)) classes.push("circuit-custom-monitoring"); + return classes.join(" "); +} diff --git a/src/core/dashboard-controller.ts b/src/core/dashboard-controller.ts index 9e5436d..a2c38c2 100644 --- a/src/core/dashboard-controller.ts +++ b/src/core/dashboard-controller.ts @@ -1,12 +1,18 @@ -import { DEFAULT_GRAPH_HORIZON, GRAPH_HORIZONS, LIVE_SAMPLE_INTERVAL_MS } from "../constants.js"; +import { DEFAULT_GRAPH_HORIZON, GRAPH_HORIZONS, INTEGRATION_DOMAIN, LIVE_SAMPLE_INTERVAL_MS } from "../constants.js"; import { getCircuitChartEntity } from "../helpers/chart.js"; import { getHorizonDurationMs, getMaxHistoryPoints, getMinGapMs, recordSample } from "../helpers/history.js"; import { loadHistory, collectSubDeviceEntityIds } from "./history-loader.js"; import { updateCircuitDOM, updateSubDeviceDOM } from "./dom-updater.js"; import { getEffectiveHorizon, getEffectiveSubDeviceHorizon } from "./graph-settings.js"; -import { MonitoringStatusCache } from "./monitoring-status.js"; +import { MonitoringStatusCache, MonitoringStatusMultiCache, mergeMonitoringStatuses } from "./monitoring-status.js"; import { GraphSettingsCache } from "./graph-settings.js"; -import type { HomeAssistant, PanelTopology, CardConfig, HistoryMap, GraphSettings } from "../types.js"; +import { groupFavoritesByPanel } from "./favorites-sections.js"; +import type { FavoritesPanelInfo, FavoritesPanelGroup } from "./favorites-sections.js"; +import type { FavoritesPanelSection } from "./side-panel.js"; +import type { CardConfig, FavoriteRef, GraphSettings, HistoryMap, HomeAssistant, MonitoringStatus, MonitoringStatusResponse, PanelTopology } from "../types.js"; +import type { ErrorStore } from "./error-store.js"; +import { RetryManager } from "./retry-manager.js"; +import { t } from "../i18n.js"; const RECORDER_REFRESH_MS = 30_000; const RESIZE_THRESHOLD_PX = 5; @@ -17,6 +23,7 @@ type DOMRoot = Element | ShadowRoot; interface SpanSidePanelElement extends HTMLElement { hass: HomeAssistant; + errorStore: ErrorStore | null; open(config: Record): void; } @@ -29,13 +36,49 @@ export class DashboardController { readonly horizonMap: Map = new Map(); readonly subDeviceHorizonMap: Map = new Map(); readonly monitoringCache = new MonitoringStatusCache(); + readonly monitoringMultiCache = new MonitoringStatusMultiCache(); readonly graphSettingsCache = new GraphSettingsCache(); + private _errorStore: ErrorStore | null = null; + + get errorStore(): ErrorStore | null { + return this._errorStore; + } + + set errorStore(store: ErrorStore | null) { + this._errorStore = store; + this.monitoringCache.errorStore = store; + this.graphSettingsCache.errorStore = store; + this.monitoringMultiCache.errorStore = store; + } + private _hass: HomeAssistant | null = null; private _topology: PanelTopology | null = null; private _config: CardConfig | null = null; private _configEntryId: string | null = null; + /** + * Set when rendering the Favorites pseudo-panel. Composite circuit + * ids (``"{panelDeviceId}|{circuitUuid}"``) resolve through this map + * to the originating panel so side-panel edits target the correct + * config entry. ``null`` means normal single-panel mode. + */ + private _favRefs: Record | null = null; + + private _perPanelInfo: Map = new Map(); + + /** + * Context used when opening the panel-mode side panel (Graph Settings) + * on a single real panel: the panel's HA device id plus the subsets + * of circuit uuids and sub-device HA device ids the user has favorited. + * Populated by the dashboard wrapper before tab renders. + */ + private _panelFavorites: { + panelDeviceId: string; + circuitUuids: Set; + subDeviceIds: Set; + } | null = null; + private _showMonitoring = false; private _updateInterval: ReturnType | null = null; private _recorderRefreshInterval: ReturnType | null = null; @@ -70,6 +113,51 @@ export class DashboardController { this._configEntryId = configEntryId; } + /** + * Enter Favorites-view mode. ``refs`` maps the composite circuit ids + * present in the merged topology to their originating panel + circuit + * uuid + config entry. ``favoriteIds`` is the subset currently marked + * (effectively the keys of ``refs`` for this view, kept as a Set for + * fast heart-state lookups in panel-mode). + */ + setFavoriteRefs(refs: Record): void { + this._favRefs = refs; + } + + clearFavoriteRefs(): void { + this._favRefs = null; + } + + /** + * Provide the current panel's favorited circuit uuids and sub-device + * ids. Used only when opening the panel-mode (Graph Settings) side + * panel so its per-target list can render filled/outlined heart + * toggles. Pass ``null`` to disable hearts (e.g. standalone card). + */ + setPanelFavorites( + info: { + panelDeviceId: string; + circuitUuids: Set; + subDeviceIds: Set; + } | null + ): void { + this._panelFavorites = info; + } + + /** + * Register per-panel info for every panel that contributes to the + * Favorites view. Called from span-panel's `_renderFavoritesTab` + * after `FavoritesController.build` resolves. Cleared when the view + * moves off Favorites (via `setFavoritesPerPanelInfo(null)`). + */ + setFavoritesPerPanelInfo(info: Map | null): void { + this._perPanelInfo = info ?? new Map(); + } + + private get _inFavoritesView(): boolean { + return this._favRefs !== null; + } + setConfig(config: CardConfig): void { this._config = config; } @@ -91,10 +179,78 @@ export class DashboardController { async fetchAndBuildHorizonMaps(): Promise { try { - await this.graphSettingsCache.fetch(this._hass!, this._configEntryId); - this.buildHorizonMaps(this.graphSettingsCache.settings); - } catch { - // Graph settings unavailable -- use defaults + if (this._favRefs) { + await this._buildFavoritesHorizonMaps(); + } else { + await this.graphSettingsCache.fetch(this._hass!, this._configEntryId); + this.buildHorizonMaps(this.graphSettingsCache.settings); + } + } catch (err) { + console.warn("SPAN Panel: graph settings fetch failed", err); + // GraphSettingsCache dispatches to errorStore on exhaustion via RetryManager; + // only dispatch here when no retry was active (errorStore not set on cache). + if (!this.graphSettingsCache.errorStore) { + this._errorStore?.add({ + key: "fetch:graph_settings", + level: "warning", + message: t("error.graph_settings_failed"), + persistent: false, + }); + } + } + } + + /** + * Fetch monitoring status for each of the given config entries in parallel + * and merge the results. Used by the Favorites view, which can span + * multiple panels. Returns null if every per-entry fetch fails. + */ + async fetchMergedMonitoringStatus(entryIds: readonly string[]): Promise { + if (!this._hass || entryIds.length === 0) return null; + const hass = this._hass; + // Route through the keyed multi-cache so successive renders within + // the 30s TTL reuse the response instead of issuing N WS calls per + // render. When settings change, ``monitoringMultiCache.invalidate`` + // is called alongside the existing ``monitoringCache.invalidate``. + const statuses = await Promise.all(entryIds.map(eid => this.monitoringMultiCache.fetchOne(hass, eid))); + return mergeMonitoringStatuses(statuses); + } + + /** + * Build horizon maps for the Favorites pseudo-panel by fetching graph + * settings per contributing config entry in parallel, then routing + * each composite circuit/sub-device id through its ``FavoriteRef`` to + * the originating entry's settings. Without this, every favorited + * target would incorrectly resolve against the primary entry's + * settings, masking per-target overrides on non-primary panels. + */ + private async _buildFavoritesHorizonMaps(): Promise { + if (!this._hass || !this._favRefs || !this._topology) return; + const entryIds = new Set(); + for (const ref of Object.values(this._favRefs)) { + if (ref.configEntryId) entryIds.add(ref.configEntryId); + } + const settingsByEntry = new Map(); + await Promise.all( + Array.from(entryIds).map(async eid => { + settingsByEntry.set(eid, await this._fetchGraphSettingsFresh(eid)); + }) + ); + this.horizonMap.clear(); + this.subDeviceHorizonMap.clear(); + for (const compositeId of Object.keys(this._topology.circuits)) { + const ref = this._favRefs[compositeId]; + const settings = ref?.configEntryId ? (settingsByEntry.get(ref.configEntryId) ?? null) : null; + const realId = ref?.targetId ?? compositeId; + this.horizonMap.set(compositeId, getEffectiveHorizon(settings, realId)); + } + if (this._topology.sub_devices) { + for (const compositeId of Object.keys(this._topology.sub_devices)) { + const ref = this._favRefs[compositeId]; + const settings = ref?.configEntryId ? (settingsByEntry.get(ref.configEntryId) ?? null) : null; + const realId = ref?.targetId ?? compositeId; + this.subDeviceHorizonMap.set(compositeId, getEffectiveSubDeviceHorizon(settings, realId)); + } } } @@ -197,8 +353,14 @@ export class DashboardController { } } this.updateDOM(root); - } catch { - // Will refresh on next interval + } catch (err) { + console.warn("SPAN Panel: history refresh failed", err); + this._errorStore?.add({ + key: "fetch:history", + level: "warning", + message: t("error.history_failed"), + persistent: false, + }); } } @@ -210,9 +372,14 @@ export class DashboardController { async onGraphSettingsChanged(root: DOMRoot): Promise { if (!this._hass) return; - this.graphSettingsCache.invalidate(); - await this.graphSettingsCache.fetch(this._hass, this._configEntryId); - this.buildHorizonMaps(this.graphSettingsCache.settings); + if (this._favRefs) { + // Favorites view: per-entry fresh fetches, routed through refs. + await this._buildFavoritesHorizonMaps(); + } else { + this.graphSettingsCache.invalidate(); + await this.graphSettingsCache.fetch(this._hass, this._configEntryId); + this.buildHorizonMaps(this.graphSettingsCache.settings); + } this.powerHistory.clear(); try { @@ -246,7 +413,13 @@ export class DashboardController { } const service = switchState.state === "on" ? "turn_off" : "turn_on"; this._hass.callService("switch", service, {}, { entity_id: switchEntity }).catch(err => { - console.error("SPAN Panel: switch service call failed:", err); + console.warn("SPAN Panel: switch service call failed", err); + this._errorStore?.add({ + key: "service:relay", + level: "error", + message: t("error.relay_failed"), + persistent: false, + }); }); } @@ -258,13 +431,25 @@ export class DashboardController { const sidePanel = root.querySelector("span-side-panel") as SpanSidePanelElement | null; if (!sidePanel || !this._hass) return; sidePanel.hass = this._hass; + sidePanel.errorStore = this.errorStore; if (gearBtn.classList.contains("panel-gear")) { + if (this._inFavoritesView) { + const sections = await this._buildFavoritesSections(); + if (sections.length === 0) return; + sidePanel.open({ favoritesMode: true, perPanelSections: sections }); + return; + } await this.graphSettingsCache.fetch(this._hass, this._configEntryId); sidePanel.open({ panelMode: true, topology: this._topology, graphSettings: this.graphSettingsCache.settings, + showFavorites: this._panelFavorites !== null, + favoritePanelDeviceId: this._panelFavorites?.panelDeviceId, + favoriteCircuitUuids: this._panelFavorites?.circuitUuids, + favoriteSubDeviceIds: this._panelFavorites?.subDeviceIds, + configEntryId: this._configEntryId, }); return; } @@ -273,22 +458,47 @@ export class DashboardController { if (uuid && this._topology) { const circuit = this._topology.circuits[uuid]; if (circuit) { - await this.monitoringCache.fetch(this._hass, this._configEntryId); + const ref = this._favRefs?.[uuid] ?? null; + const realUuid = ref && ref.kind === "circuit" ? ref.targetId : uuid; + const entryId = ref?.configEntryId ?? this._configEntryId; + + // In favorites view, bypass the single-entry caches so we pick + // up the right panel's current graph/monitoring state. + let graphSettings: GraphSettings | null; + let monitoringStatus: MonitoringStatus | null; + if (ref) { + [graphSettings, monitoringStatus] = await Promise.all([this._fetchGraphSettingsFresh(entryId), this._fetchMonitoringStatusFresh(entryId)]); + } else { + await Promise.all([this.graphSettingsCache.fetch(this._hass, entryId), this.monitoringCache.fetch(this._hass, entryId)]); + graphSettings = this.graphSettingsCache.settings; + monitoringStatus = this.monitoringCache.status; + } + const monitoringEntity = circuit.entities?.current ?? circuit.entities?.power; - const monitoringInfo = monitoringEntity ? (this.monitoringCache.status?.circuits?.[monitoringEntity] ?? null) : null; + const monitoringInfo = monitoringEntity ? (monitoringStatus?.circuits?.[monitoringEntity] ?? null) : null; - await this.graphSettingsCache.fetch(this._hass, this._configEntryId); - const graphSettings = this.graphSettingsCache.settings; const globalHorizon = graphSettings?.global_horizon ?? DEFAULT_GRAPH_HORIZON; - const circuitOverride = graphSettings?.circuits?.[uuid]; + const circuitOverride = graphSettings?.circuits?.[realUuid]; const graphHorizonInfo = circuitOverride ? { ...circuitOverride, globalHorizon } : { horizon: globalHorizon, has_override: false, globalHorizon }; + // Heart section shows whenever we're in a dashboard context — either + // the Favorites pseudo-panel (always favorited) or a real panel with + // the per-panel favorites set supplied by span-panel.ts. Standalone + // omits both and hearts don't render. + const favoritePanelDeviceId = ref?.panelDeviceId ?? this._panelFavorites?.panelDeviceId; + const isFavorite = ref !== null || (this._panelFavorites?.circuitUuids.has(realUuid) ?? false); + const showFavorites = this._inFavoritesView || this._panelFavorites !== null; + sidePanel.open({ ...circuit, - uuid, + uuid: realUuid, monitoringInfo, showMonitoring: this._showMonitoring, graphHorizonInfo, + showFavorites, + favoritePanelDeviceId, + isFavorite, + configEntryId: entryId, } as Record); return; } @@ -297,23 +507,124 @@ export class DashboardController { const subDevId = gearBtn.dataset.subdevId; if (subDevId && this._topology?.sub_devices?.[subDevId]) { const sub = this._topology.sub_devices[subDevId]!; + const ref = this._favRefs?.[subDevId] ?? null; + const realSubDevId = ref && ref.kind === "sub_device" ? ref.targetId : subDevId; + const entryId = ref?.configEntryId ?? this._configEntryId; + + let graphSettings: GraphSettings | null; + if (ref) { + graphSettings = await this._fetchGraphSettingsFresh(entryId); + } else { + await this.graphSettingsCache.fetch(this._hass, entryId); + graphSettings = this.graphSettingsCache.settings; + } - await this.graphSettingsCache.fetch(this._hass, this._configEntryId); - const graphSettings = this.graphSettingsCache.settings; const globalHorizon = graphSettings?.global_horizon ?? DEFAULT_GRAPH_HORIZON; - const subOverride = graphSettings?.sub_devices?.[subDevId]; + const subOverride = graphSettings?.sub_devices?.[realSubDevId]; const graphHorizonInfo = subOverride ? { ...subOverride, globalHorizon } : { horizon: globalHorizon, has_override: false, globalHorizon }; + const favoritePanelDeviceId = ref?.panelDeviceId ?? this._panelFavorites?.panelDeviceId; + const isFavorite = ref !== null || (this._panelFavorites?.subDeviceIds.has(realSubDevId) ?? false); + const showFavorites = this._inFavoritesView || this._panelFavorites !== null; + sidePanel.open({ subDeviceMode: true, - subDeviceId: subDevId, - name: sub.name ?? subDevId, + subDeviceId: realSubDevId, + name: sub.name ?? realSubDevId, deviceType: sub.type ?? "", + entities: sub.entities, graphHorizonInfo, + showFavorites, + favoritePanelDeviceId, + isFavorite, + configEntryId: entryId, }); } } + /** + * Build the per-contributing-panel section array for the Favorites + * sidebar. Groups favorite refs by source panel (synchronous, pure), + * then fetches graph settings per unique config entry in parallel. + * Returns an array suitable for `sidePanel.open({ favoritesMode: true, + * perPanelSections })`. + */ + private async _buildFavoritesSections(): Promise { + if (!this._hass || !this._favRefs) return []; + const groups = groupFavoritesByPanel(this._favRefs, this._perPanelInfo); + if (groups.length === 0) return []; + const sections = await Promise.all( + groups.map(async (group: FavoritesPanelGroup) => ({ + panelDeviceId: group.panelDeviceId, + panelName: group.panelName, + topology: group.topology, + graphSettings: await this._fetchGraphSettingsFresh(group.configEntryId), + favoriteCircuitUuids: group.favoriteCircuitUuids, + configEntryId: group.configEntryId, + })) + ); + return sections; + } + + /** + * Uncached fetch of graph settings for a specific config entry. + * Used in Favorites view where the shared ``graphSettingsCache`` is + * keyed to a different (primary) entry. + */ + private async _fetchGraphSettingsFresh(entryId: string | null): Promise { + if (!this._hass) return null; + try { + const serviceData: Record = {}; + if (entryId) serviceData.config_entry_id = entryId; + const msg = { + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "get_graph_settings", + service_data: serviceData, + return_response: true, + }; + const retry = this._errorStore ? new RetryManager(this._errorStore) : null; + const resp = retry + ? await retry.callWS<{ response?: GraphSettings }>(this._hass, msg, { + errorId: "fetch:graph_settings", + errorMessage: t("error.graph_settings_failed"), + }) + : await this._hass.callWS<{ response?: GraphSettings }>(msg); + return resp?.response ?? null; + } catch (err) { + console.warn("SPAN Panel: fresh graph settings fetch failed", err); + return null; + } + } + + private async _fetchMonitoringStatusFresh(entryId: string | null): Promise { + if (!this._hass) return null; + try { + const serviceData: Record = {}; + if (entryId) serviceData.config_entry_id = entryId; + const msg = { + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "get_monitoring_status", + service_data: serviceData, + return_response: true, + }; + const retry = this._errorStore ? new RetryManager(this._errorStore) : null; + const resp = retry + ? await retry.callWS<{ response?: MonitoringStatusResponse }>(this._hass, msg, { + errorId: "fetch:monitoring", + errorMessage: t("error.monitoring_failed"), + }) + : await this._hass.callWS<{ response?: MonitoringStatusResponse }>(msg); + const response = resp?.response; + if (!response) return null; + return { circuits: response.circuits, mains: response.mains }; + } catch (err) { + console.warn("SPAN Panel: fresh monitoring status fetch failed", err); + return null; + } + } + bindSlideConfirm(slideEl: Element, parent: Element | null): void { const knob = slideEl.querySelector(".slide-confirm-knob") as HTMLElement | null; const textEl = slideEl.querySelector(".slide-confirm-text"); @@ -442,6 +753,7 @@ export class DashboardController { this.horizonMap.clear(); this.subDeviceHorizonMap.clear(); this.monitoringCache.clear(); + this.monitoringMultiCache.clear(); this.graphSettingsCache.clear(); } } diff --git a/src/core/dom-updater.ts b/src/core/dom-updater.ts index 18f071f..a9b87f1 100644 --- a/src/core/dom-updater.ts +++ b/src/core/dom-updater.ts @@ -14,16 +14,22 @@ import { getChartMetric } from "../helpers/chart.js"; import { findSubDevicePowerEntity } from "../helpers/entity-finder.js"; import { getHistoryDurationMs, getHorizonDurationMs } from "../helpers/history.js"; import { updateChart } from "../chart/chart-update.js"; +import { attrSelectorValue } from "../helpers/selector.js"; import type { HomeAssistant, PanelTopology, CardConfig, HistoryMap, ChartMetricDef } from "../types.js"; // ── Header stats ─────────────────────────────────────────────────────────── -function _updateHeaderStats(root: Element | ShadowRoot, hass: HomeAssistant, topology: PanelTopology, config: CardConfig, totalConsumption: number): void { +/** + * Update a single ``.panel-stats`` block in-place from a specific + * topology. Shared between the standard panel header (one block rooted + * at the document) and the Favorites view (multiple per-panel blocks). + */ +export function updatePanelStatsBlock(scope: Element, hass: HomeAssistant, topology: PanelTopology, config: CardConfig, siteConsumptionFallback: number): void { const isAmpsMode = (config.chart_metric || "power") === "current"; // Site / consumption stat - const consumptionEl = root.querySelector(".stat-consumption .stat-value"); - const consumptionUnitEl = root.querySelector(".stat-consumption .stat-unit"); + const consumptionEl = scope.querySelector(".stat-consumption .stat-value"); + const consumptionUnitEl = scope.querySelector(".stat-consumption .stat-unit"); if (isAmpsMode) { const siteEid = topology.panel_entities?.site_power; const siteState = siteEid ? hass.states[siteEid] : null; @@ -31,6 +37,7 @@ function _updateHeaderStats(root: Element | ShadowRoot, hass: HomeAssistant, top if (consumptionEl) consumptionEl.textContent = Number.isFinite(amps) ? Math.abs(amps).toFixed(1) : "--"; if (consumptionUnitEl) consumptionUnitEl.textContent = "A"; } else { + let totalConsumption = siteConsumptionFallback; const siteEid = topology.panel_entities?.site_power; if (siteEid) { const state = hass.states[siteEid]; @@ -41,8 +48,8 @@ function _updateHeaderStats(root: Element | ShadowRoot, hass: HomeAssistant, top } // Upstream stat - const upstreamEl = root.querySelector(".stat-upstream .stat-value"); - const upstreamUnitEl = root.querySelector(".stat-upstream .stat-unit"); + const upstreamEl = scope.querySelector(".stat-upstream .stat-value"); + const upstreamUnitEl = scope.querySelector(".stat-upstream .stat-unit"); if (upstreamEl) { const upEid = topology.panel_entities?.current_power; const upState = upEid ? hass.states[upEid] : null; @@ -58,8 +65,8 @@ function _updateHeaderStats(root: Element | ShadowRoot, hass: HomeAssistant, top } // Downstream stat - const downstreamEl = root.querySelector(".stat-downstream .stat-value"); - const downstreamUnitEl = root.querySelector(".stat-downstream .stat-unit"); + const downstreamEl = scope.querySelector(".stat-downstream .stat-value"); + const downstreamUnitEl = scope.querySelector(".stat-downstream .stat-unit"); if (downstreamEl) { const downEid = topology.panel_entities?.feedthrough_power; const downState = downEid ? hass.states[downEid] : null; @@ -75,8 +82,8 @@ function _updateHeaderStats(root: Element | ShadowRoot, hass: HomeAssistant, top } // Solar stat — always read from panel-level PV power entity - const solarEl = root.querySelector(".stat-solar .stat-value"); - const solarUnitEl = root.querySelector(".stat-solar .stat-unit"); + const solarEl = scope.querySelector(".stat-solar .stat-value"); + const solarUnitEl = scope.querySelector(".stat-solar .stat-unit"); if (solarEl) { const solarEid = topology.panel_entities?.pv_power; const solarState = solarEid ? hass.states[solarEid] : null; @@ -96,7 +103,7 @@ function _updateHeaderStats(root: Element | ShadowRoot, hass: HomeAssistant, top } // Battery SoC (always %) - const batteryEl = root.querySelector(".stat-battery .stat-value"); + const batteryEl = scope.querySelector(".stat-battery .stat-value"); if (batteryEl) { const battEid = topology.panel_entities?.battery_level; const battState = battEid ? hass.states[battEid] : null; @@ -104,7 +111,7 @@ function _updateHeaderStats(root: Element | ShadowRoot, hass: HomeAssistant, top } // Grid / DSM state - const gridStateEl = root.querySelector(".stat-grid-state .stat-value"); + const gridStateEl = scope.querySelector(".stat-grid-state .stat-value"); if (gridStateEl) { const gridEid = topology.panel_entities?.dsm_state; const gridState = gridEid ? hass.states[gridEid] : null; @@ -112,6 +119,12 @@ function _updateHeaderStats(root: Element | ShadowRoot, hass: HomeAssistant, top } } +function _updateHeaderStats(root: Element | ShadowRoot, hass: HomeAssistant, topology: PanelTopology, config: CardConfig, totalConsumption: number): void { + const scope = (root as ParentNode).querySelector(".panel-stats") as Element | null; + if (!scope) return; + updatePanelStatsBlock(scope, hass, topology, config, totalConsumption); +} + // ── Exported updaters ────────────────────────────────────────────────────── export function updateCircuitDOM( @@ -143,7 +156,7 @@ export function updateCircuitDOM( const showCurrent = chartMetric.entityRole === "current"; for (const [uuid, circuit] of Object.entries(topology.circuits)) { - const slot = root.querySelector(`[data-uuid="${uuid}"]`); + const slot = root.querySelector(`.circuit-slot[data-uuid="${attrSelectorValue(uuid)}"]`); if (!slot) continue; const entityId = circuit.entities?.power; @@ -242,7 +255,7 @@ export function updateSubDeviceDOM( const defaultDurationMs = getHistoryDurationMs(config); for (const [devId, sub] of Object.entries(topology.sub_devices)) { - const section = root.querySelector(`[data-subdev="${devId}"]`); + const section = root.querySelector(`[data-subdev="${attrSelectorValue(devId)}"]`); if (!section) continue; const powerEid = findSubDevicePowerEntity(sub); @@ -270,7 +283,7 @@ export function updateSubDeviceDOM( } for (const entityId of Object.keys(sub.entities || {})) { - const valEl = section.querySelector(`[data-eid="${entityId}"]`); + const valEl = section.querySelector(`[data-eid="${attrSelectorValue(entityId)}"]`); if (!valEl) continue; const state = hass.states[entityId]; if (state) { diff --git a/src/core/error-banner.ts b/src/core/error-banner.ts new file mode 100644 index 0000000..92374c4 --- /dev/null +++ b/src/core/error-banner.ts @@ -0,0 +1,119 @@ +import { LitElement, html, css, nothing } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { t } from "../i18n.js"; +import type { ErrorStore, ErrorEntry } from "./error-store.js"; + +@customElement("span-error-banner") +export class SpanErrorBanner extends LitElement { + private _store: ErrorStore | null = null; + private _unsub: (() => void) | null = null; + + @state() private _errors: ErrorEntry[] = []; + + set store(store: ErrorStore) { + if (this._store === store) return; + this._unsub?.(); + this._unsub = null; + this._store = store; + this._errors = store.active; + const current = store; + this._unsub = store.subscribe(() => { + this._errors = current.active; + }); + } + + override connectedCallback(): void { + super.connectedCallback(); + if (this._store && !this._unsub) { + const store = this._store; + this._errors = store.active; + this._unsub = store.subscribe(() => { + this._errors = store.active; + }); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this._unsub?.(); + this._unsub = null; + // Keep _store — we may reattach + } + + static override styles = css` + :host { + display: block; + } + .banner-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 13px; + line-height: 1.4; + } + .banner-row + .banner-row { + border-top: 1px solid rgba(128, 128, 128, 0.2); + } + .banner-row.level-error { + background: color-mix(in srgb, var(--error-color, #db4437) 15%, transparent); + color: var(--error-color, #db4437); + } + .banner-row.level-warning { + background: color-mix(in srgb, var(--warning-color, #ff9800) 15%, transparent); + color: var(--warning-color, #ff9800); + } + .banner-row.level-info { + background: color-mix(in srgb, var(--info-color, #4285f4) 15%, transparent); + color: var(--info-color, #4285f4); + } + .icon { + flex-shrink: 0; + width: 18px; + height: 18px; + --mdc-icon-size: 18px; + } + .message { + flex: 1; + min-width: 0; + } + .retry-btn { + flex-shrink: 0; + background: none; + border: 1px solid currentColor; + border-radius: 4px; + color: inherit; + cursor: pointer; + font-size: 12px; + padding: 2px 8px; + } + .retry-btn:hover { + opacity: 0.8; + } + `; + + protected override render(): unknown { + if (this._errors.length === 0) return nothing; + + return html`${this._errors.map( + entry => html` + + ` + )}`; + } + + private _iconForLevel(level: ErrorEntry["level"]): string { + switch (level) { + case "error": + return "mdi:alert-circle"; + case "warning": + return "mdi:alert"; + default: + return "mdi:information"; + } + } +} diff --git a/src/core/error-store.ts b/src/core/error-store.ts new file mode 100644 index 0000000..41bafe2 --- /dev/null +++ b/src/core/error-store.ts @@ -0,0 +1,329 @@ +import { t, tf } from "../i18n.js"; +import type { HomeAssistant } from "../types.js"; + +const DEFAULT_ERROR_TTL = 5_000; + +/** A single error or info entry managed by the store. */ +export interface ErrorEntry { + key: string; + level: "info" | "warning" | "error"; + message: string; + persistent: boolean; + ttl?: number; + retryFn?: () => void; + timestamp: number; +} + +/** Input shape for `add()` — everything except the auto-set timestamp. */ +export type AddInput = Omit; + +/** Optional filter for `clear()`. When omitted, everything is cleared. */ +interface ClearFilter { + persistent: boolean; +} + +/** + * Per-entity state for a watched panel_status entity. + * + * `panelName === null` marks the legacy single-panel case (per-panel view). + * In that case the persistent key and message omit the panel name so the + * banner reads "SPAN Panel unreachable" exactly as before. + * + * `panelName !== null` marks the multi-panel case (Favorites view). The + * persistent key is suffixed with the entity id and the message names the + * panel, so the banner reads e.g. "Span Panel 2 unreachable". + */ +interface WatchedPanelEntry { + panelName: string | null; + /** True once this entity has been observed off at least once. */ + wasOffline: boolean; +} + +/** + * Two-lane error store. + * + * - Persistent lane: `Map` — never auto-dismissed. + * - Transient lane: a single `ErrorEntry | null` — auto-dismissed after TTL. + * + * `active` always returns persistent entries first, transient (if any) last. + */ +export class ErrorStore { + private readonly _persistent = new Map(); + private _transient: ErrorEntry | null = null; + private _transientTimer: ReturnType | null = null; + private readonly _subscribers = new Set<() => void>(); + private _watchedPanels = new Map(); + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Add an error entry. Persistent entries go to the map. Transient entries + * replace the existing transient slot and reset the auto-dismiss timer. + */ + add(input: AddInput): void { + const entry: ErrorEntry = { ...input, timestamp: Date.now() }; + if (entry.persistent) { + this._persistent.set(entry.key, entry); + } else { + this._clearTransient(); + this._transient = entry; + const ttl = entry.ttl ?? DEFAULT_ERROR_TTL; + this._transientTimer = setTimeout(() => { + this._transient = null; + this._transientTimer = null; + this._notify(); + }, ttl); + } + this._notify(); + } + + /** + * Remove an entry by key. If the key matches the transient entry, the + * transient slot is cleared. No-op (no notification) for unknown keys. + */ + remove(key: string): void { + if (this._persistent.has(key)) { + this._persistent.delete(key); + this._notify(); + return; + } + if (this._transient?.key === key) { + this._clearTransient(); + this._notify(); + } + } + + /** + * Clear errors. + * + * - No argument: clear everything. + * - `{ persistent: true }`: clear only persistent errors. + * - `{ persistent: false }`: clear only the transient error. + */ + clear(filter?: ClearFilter): void { + if (filter === undefined) { + this._persistent.clear(); + this._clearTransient(); + this._watchedPanels.clear(); + } else if (filter.persistent === true) { + this._persistent.clear(); + } else if (filter.persistent === false) { + this._clearTransient(); + } + this._notify(); + } + + /** All active errors — persistent first, transient last (if present). */ + get active(): ErrorEntry[] { + const entries: ErrorEntry[] = [...this._persistent.values()]; + if (this._transient !== null) { + entries.push(this._transient); + } + return entries; + } + + /** True when the given key is in the persistent error map. */ + hasPersistent(key: string): boolean { + return this._persistent.has(key); + } + + /** + * True when any watched panel is currently marked offline. Covers both + * the legacy single-unnamed key (``panel-offline``) used by per-panel + * views and the per-entity keys (``panel-offline:``) used by + * the Favorites multi-panel watch. ``RetryManager`` uses this to + * short-circuit retries without needing to know the naming mode. + */ + hasAnyPanelOffline(): boolean { + for (const key of this._persistent.keys()) { + if (key === "panel-offline" || key.startsWith("panel-offline:")) return true; + } + return false; + } + + /** + * Subscribe to state changes. The callback is called after every `add`, + * `remove`, `clear`, or transient auto-dismiss. Returns an unsubscribe fn. + */ + subscribe(cb: () => void): () => void { + this._subscribers.add(cb); + return () => { + this._subscribers.delete(cb); + }; + } + + /** + * Register a single panel_status entity to watch. Per-panel views call + * this; the resulting banner is unnamed ("SPAN Panel unreachable") to + * match the title bar which already names the panel. + * + * Thin wrapper around `watchPanelStatuses`. + */ + watchPanelStatus(entityId: string): void { + this.watchPanelStatuses([{ entityId, panelName: null }]); + } + + /** + * Register 0+ panel_status entities to watch with optional panel names. + * Replaces the current watch set wholesale. + * + * Entities carried over from the previous watch set preserve their + * `wasOffline` flag so no spurious reconnect toast fires on re-registration. + * + * Any persistent `panel-offline*` keys for entities dropped from the + * watch set are removed. + */ + watchPanelStatuses(entries: ReadonlyArray<{ entityId: string; panelName?: string | null }>): void { + const prev = this._watchedPanels; + const next = new Map(); + for (const entry of entries) { + const carry = prev.get(entry.entityId); + next.set(entry.entityId, { + panelName: entry.panelName ?? null, + wasOffline: carry?.wasOffline ?? false, + }); + } + + // Drop stale persistent banners from the previous watch set. For each + // entity that was watched, remove its prev-mode key unless it is still + // watched in the same naming mode (single-unnamed vs multi-named). + // This one sweep covers both removals and naming-mode changes. + const prevIsSingleUnnamed = this._isSingleUnnamed(prev); + const nextIsSingleUnnamed = this._isSingleUnnamed(next); + for (const entityId of prev.keys()) { + const stillWatchedSameMode = next.has(entityId) && prevIsSingleUnnamed === nextIsSingleUnnamed; + if (stillWatchedSameMode) continue; + this._persistent.delete(this._offlineKey(entityId, prevIsSingleUnnamed)); + } + + this._watchedPanels = next; + this._notify(); + } + + /** + * Clear the panel status watch entirely (e.g. when switching panels and + * we want no banner until the new watch is set up). + */ + clearPanelStatusWatch(): void { + if (this._watchedPanels.size === 0) return; + const isSingleUnnamed = this._isSingleUnnamed(this._watchedPanels); + for (const entityId of this._watchedPanels.keys()) { + this._persistent.delete(this._offlineKey(entityId, isSingleUnnamed)); + } + this._watchedPanels.clear(); + this._notify(); + } + + /** + * Examine each watched panel_status entity in the current hass snapshot + * and add/remove `panel-offline*` persistent errors accordingly. + * + * Reconnection info is posted as a transient (per-entity key) — only + * after that entity was previously observed to be offline. + */ + updateHass(hass: HomeAssistant): void { + if (this._watchedPanels.size === 0) return; + + const isSingleUnnamed = this._isSingleUnnamed(this._watchedPanels); + + for (const [entityId, entry] of this._watchedPanels) { + const entityState = hass.states[entityId]?.state; + const isOnline = entityState === "on"; + + const offlineKey = this._offlineKey(entityId, isSingleUnnamed); + const reconnectKey = this._reconnectKey(entityId, isSingleUnnamed); + + if (!isOnline) { + entry.wasOffline = true; + if (!this.hasPersistent(offlineKey)) { + this.add({ + key: offlineKey, + level: "error", + message: entry.panelName === null ? t("error.panel_offline") : tf("error.panel_offline_named", { name: entry.panelName }), + persistent: true, + }); + } + } else { + const wasOffline = entry.wasOffline; + entry.wasOffline = false; + this.remove(offlineKey); + if (wasOffline) { + this.add({ + key: reconnectKey, + level: "info", + message: entry.panelName === null ? t("error.panel_reconnected") : tf("error.panel_reconnected_named", { name: entry.panelName }), + persistent: false, + }); + } + } + } + } + + /** + * Release all resources: cancel any pending timer and drop all state. + * Call in `disconnectedCallback` or test `afterEach`. + */ + dispose(): void { + this._clearTransient(); + this._persistent.clear(); + this._subscribers.clear(); + this._watchedPanels.clear(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * True when the map represents the legacy single-unnamed case: exactly + * one entry, with `panelName === null`. Used by `updateHass`, + * `watchPanelStatuses`, and `clearPanelStatusWatch` to pick the correct + * persistent-key and message form. + */ + private _isSingleUnnamed(map: ReadonlyMap): boolean { + if (map.size !== 1) return false; + for (const entry of map.values()) { + return entry.panelName === null; + } + return false; + } + + /** + * Persistent-key name for the offline banner scoped to a single entity. + * Single-unnamed mode (per-panel view) uses the legacy unsuffixed key. + */ + private _offlineKey(entityId: string, isSingleUnnamed: boolean): string { + return isSingleUnnamed ? "panel-offline" : `panel-offline:${entityId}`; + } + + /** + * Transient-key name for the reconnect toast scoped to a single entity. + * Mirror of `_offlineKey` for the recovery path. + */ + private _reconnectKey(entityId: string, isSingleUnnamed: boolean): string { + return isSingleUnnamed ? "panel-reconnected" : `panel-reconnected:${entityId}`; + } + + private _clearTransient(): void { + if (this._transientTimer !== null) { + clearTimeout(this._transientTimer); + this._transientTimer = null; + } + this._transient = null; + } + + private _notify(): void { + // Subscribers may be arbitrary renderers; an exception from one must + // not starve the others or leave the transient timer callback in a + // half-notified state. + for (const cb of this._subscribers) { + try { + cb(); + } catch (err) { + console.warn("SPAN Panel: error-store subscriber threw", err); + } + } + } +} diff --git a/src/core/favorites-controller.ts b/src/core/favorites-controller.ts new file mode 100644 index 0000000..41160a5 --- /dev/null +++ b/src/core/favorites-controller.ts @@ -0,0 +1,133 @@ +// src/core/favorites-controller.ts +import { discoverTopology } from "../card/card-discovery.js"; +import { RetryManager } from "./retry-manager.js"; +import type { ErrorStore } from "./error-store.js"; +import type { FavoriteRef, FavoritesMap, FavoritesTopology, HomeAssistant, PanelDevice, PanelTopology } from "../types.js"; + +const COMPOSITE_SEPARATOR = "|"; + +/** Build the composite circuit id used by the Favorites view. */ +export function buildCompositeId(panelDeviceId: string, circuitUuid: string): string { + return `${panelDeviceId}${COMPOSITE_SEPARATOR}${circuitUuid}`; +} + +export interface FavoritesPanelStatsInfo { + panelDeviceId: string; + panelName: string; + topology: PanelTopology; +} + +export interface FavoritesBuildResult { + topology: FavoritesTopology; + /** Unique contributing config entry ids (for monitoring tab stacking). */ + entryIds: string[]; + /** + * Per-contributing-panel info used to render the Favorites view's + * panel-status grid. Each entry carries the originating topology so + * ``updatePanelStatsBlock`` can pull values from the correct panel's + * entities without re-fetching. + */ + perPanelStats: FavoritesPanelStatsInfo[]; +} + +/** + * Aggregate the topologies of every panel that has at least one + * favorited circuit into a single ``FavoritesTopology``. Circuit keys in + * the merged topology are composite ids so uuids from different panels + * cannot collide; ``_favoriteRefs`` records the origin of each. + */ +export class FavoritesController { + async build(hass: HomeAssistant, favorites: FavoritesMap, panels: PanelDevice[], errorStore?: ErrorStore | null): Promise { + const panelsById = new Map(); + for (const p of panels) panelsById.set(p.id, p); + + const retry = errorStore ? new RetryManager(errorStore) : null; + + const fetches: Promise<{ + panelDeviceId: string; + panel: PanelDevice; + topology: PanelTopology | null; + }>[] = []; + for (const [panelDeviceId, entry] of Object.entries(favorites)) { + const hasAny = (entry?.circuits?.length ?? 0) > 0 || (entry?.sub_devices?.length ?? 0) > 0; + if (!hasAny) continue; + const panel = panelsById.get(panelDeviceId); + if (!panel) continue; + fetches.push( + (async () => { + try { + const result = await discoverTopology(hass, panelDeviceId, retry); + return { panelDeviceId, panel, topology: result.topology }; + } catch (err) { + console.warn("SPAN Panel: favorites topology fetch failed", panelDeviceId, err); + return { panelDeviceId, panel, topology: null }; + } + })() + ); + } + + const results = await Promise.all(fetches); + const contributing = results.filter(r => r.topology !== null); + const includePanelPrefix = contributing.length > 1; + + const mergedCircuits: FavoritesTopology["circuits"] = {}; + const mergedSubDevices: NonNullable = {}; + const refs: Record = {}; + const entryIds = new Set(); + const perPanelStats: FavoritesPanelStatsInfo[] = []; + + for (const { panelDeviceId, panel, topology } of contributing) { + if (!topology) continue; + const configEntryId = panel.config_entries?.[0] ?? null; + if (configEntryId) entryIds.add(configEntryId); + + const panelLabel = panel.name_by_user ?? panel.name ?? topology.device_name ?? ""; + perPanelStats.push({ panelDeviceId, panelName: panelLabel, topology }); + const entry = favorites[panelDeviceId]; + const favoriteCircuitUuids = entry?.circuits ?? []; + const favoriteSubDeviceIds = entry?.sub_devices ?? []; + + for (const uuid of favoriteCircuitUuids) { + const circuit = topology.circuits?.[uuid]; + if (!circuit) continue; + const compositeId = buildCompositeId(panelDeviceId, uuid); + const name = includePanelPrefix && panelLabel ? `${panelLabel} \u00b7 ${circuit.name}` : circuit.name; + mergedCircuits[compositeId] = { ...circuit, name }; + refs[compositeId] = { + panelDeviceId, + kind: "circuit", + targetId: uuid, + configEntryId, + }; + } + + for (const subDevId of favoriteSubDeviceIds) { + const sub = topology.sub_devices?.[subDevId]; + if (!sub) continue; + const compositeId = buildCompositeId(panelDeviceId, subDevId); + const name = includePanelPrefix && panelLabel && sub.name ? `${panelLabel} \u00b7 ${sub.name}` : (sub.name ?? subDevId); + mergedSubDevices[compositeId] = { ...sub, name }; + refs[compositeId] = { + panelDeviceId, + kind: "sub_device", + targetId: subDevId, + configEntryId, + }; + } + } + + const topology: FavoritesTopology = { + circuits: mergedCircuits, + sub_devices: mergedSubDevices, + panel_entities: {}, + device_name: "", + _favoriteRefs: refs, + }; + + return { + topology, + entryIds: Array.from(entryIds), + perPanelStats, + }; + } +} diff --git a/src/core/favorites-sections.ts b/src/core/favorites-sections.ts new file mode 100644 index 0000000..bf3b49f --- /dev/null +++ b/src/core/favorites-sections.ts @@ -0,0 +1,77 @@ +import type { Circuit, FavoriteRef, PanelTopology } from "../types.js"; + +/** + * Per-panel info needed to build a Favorites-mode sidebar section. Held + * in a map keyed by panelDeviceId. The configEntryId is carried here + * (rather than pulled from each FavoriteRef) so all favorites on the + * same panel route to the same entry without N identical reads. + */ +export interface FavoritesPanelInfo { + panelName: string; + topology: PanelTopology; + configEntryId: string | null; +} + +/** + * Intermediate grouping shape — one entry per contributing panel. The + * graph settings are attached by the caller after this pure step (fetch + * is async and involves hass). + */ +export interface FavoritesPanelGroup { + panelDeviceId: string; + panelName: string; + topology: PanelTopology; + configEntryId: string | null; + favoriteCircuitUuids: Set; +} + +/** + * Group favorited circuit refs by their source panel and sort groups + * alphabetically by panelName for a stable sidebar ordering. Sub-device + * refs are filtered out — the Favorites sidebar only lists circuits. + * Refs whose panelDeviceId isn't present in `perPanelInfo` are dropped + * (defensive — stale ref from a panel that's no longer loaded). + */ +export function groupFavoritesByPanel(favRefs: Record, perPanelInfo: ReadonlyMap): FavoritesPanelGroup[] { + const byPanel = new Map(); + for (const ref of Object.values(favRefs)) { + if (ref.kind !== "circuit") continue; + const info = perPanelInfo.get(ref.panelDeviceId); + if (info === undefined) continue; + let group = byPanel.get(ref.panelDeviceId); + if (group === undefined) { + group = { + panelDeviceId: ref.panelDeviceId, + panelName: info.panelName, + topology: info.topology, + configEntryId: info.configEntryId, + favoriteCircuitUuids: new Set(), + }; + byPanel.set(ref.panelDeviceId, group); + } + group.favoriteCircuitUuids.add(ref.targetId); + } + return Array.from(byPanel.values()).sort((a, b) => a.panelName.localeCompare(b.panelName)); +} + +/** + * Enumerate every circuit in a contributing panel's topology, sorted + * alphabetically by circuit name (stable, case-insensitive via + * `localeCompare`). Consumed by the Favorites-mode sidebar: each row + * shows a heart (filled or empty) so users can adjust their favorites + * for that panel without leaving the Favorites view — including adding + * new favorites for circuits that weren't previously favorited. + * + * This mirrors the per-panel circuit list rendered by the real-panel + * gear sidebar (`_renderPanelMode`), keeping the two modes structurally + * consistent: every circuit the user can address on that panel appears + * in the list; the heart state discriminates favorited vs. not. + * + * Returns an empty array when the topology has no circuits. + */ +export function sortedCircuitsForSection(topology: PanelTopology): Array<{ uuid: string; circuit: Circuit }> { + const circuits = topology.circuits ?? {}; + return Object.entries(circuits) + .map(([uuid, circuit]) => ({ uuid, circuit })) + .sort((a, b) => (a.circuit.name || "").localeCompare(b.circuit.name || "")); +} diff --git a/src/core/favorites-store.ts b/src/core/favorites-store.ts new file mode 100644 index 0000000..3d5bcda --- /dev/null +++ b/src/core/favorites-store.ts @@ -0,0 +1,200 @@ +// src/core/favorites-store.ts +import { INTEGRATION_DOMAIN } from "../constants.js"; +import { t } from "../i18n.js"; +import { RetryManager } from "./retry-manager.js"; +import type { FavoritesMap, HomeAssistant } from "../types.js"; +import type { ErrorStore } from "./error-store.js"; + +const FAVORITES_POLL_INTERVAL_MS = 30_000; + +/** + * Event dispatched on ``document`` when favorites have been mutated by + * any side-panel toggle. Consumers (e.g. the dashboard panel) listen to + * refresh their synthetic Favorites entry. + */ +export const FAVORITES_CHANGED_EVENT = "favorites-changed"; + +interface GetFavoritesResponse { + favorites?: FavoritesMap; +} + +interface CallServiceResponse { + response?: T; +} + +async function _callFavoritesService(hass: HomeAssistant, service: string, serviceData: Record = {}): Promise { + const resp = await hass.callWS>({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service, + service_data: serviceData, + return_response: true, + }); + return resp?.response ?? null; +} + +/** + * Fetch the current favorites map from the HA backend. Always hits the + * backend; callers should prefer ``FavoritesCache.fetch`` when they want + * request coalescing. + */ +export async function fetchFavorites(hass: HomeAssistant): Promise { + const resp = await _callFavoritesService(hass, "get_favorites"); + return resp?.favorites ?? {}; +} + +/** + * Mark the entity (a circuit current/power sensor or any sub-device + * sensor) as a favorite. The backend resolves the entity_id to its + * panel + (circuit_uuid | sub_device_id) tuple, so callers never need + * to know storage shapes or internal identifiers. + */ +export async function addFavorite(hass: HomeAssistant, entityId: string): Promise { + const resp = await _callFavoritesService(hass, "add_favorite", { + entity_id: entityId, + }); + document.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT)); + return resp?.favorites ?? {}; +} + +/** + * Remove the entity from the favorites map. See ``addFavorite`` for the + * reasoning behind the entity_id API. + */ +export async function removeFavorite(hass: HomeAssistant, entityId: string): Promise { + const resp = await _callFavoritesService(hass, "remove_favorite", { + entity_id: entityId, + }); + document.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT)); + return resp?.favorites ?? {}; +} + +/** + * Cached favorites map with a 30-second TTL and in-flight deduplication. + * Mirrors ``GraphSettingsCache``'s lifecycle so the dashboard panel can + * refresh on events (``invalidate()``) without thrashing the backend. + * + * A monotonically-increasing ``_generation`` counter lets ``invalidate()`` + * supersede an in-flight fetch: when an invalidate happens while a + * request is pending, that request's result is not committed to the + * cache, so the next ``fetch()`` caller re-queries the backend instead + * of reading stale pre-invalidate data as "fresh". + */ +export class FavoritesCache { + private _map: FavoritesMap | null; + private _lastFetch: number; + private _inflight: { gen: number; promise: Promise } | null; + private _generation: number; + private _errorStore: ErrorStore | null = null; + private _retry: RetryManager | null = null; + + get errorStore(): ErrorStore | null { + return this._errorStore; + } + + set errorStore(store: ErrorStore | null) { + this._errorStore = store; + this._retry = store ? new RetryManager(store) : null; + } + + constructor() { + this._map = null; + this._lastFetch = 0; + this._inflight = null; + this._generation = 0; + } + + async fetch(hass: HomeAssistant): Promise { + const now = Date.now(); + // Only dedupe onto an in-flight request from the current generation. + // Requests predating the last invalidate() must not be reused, or + // the caller would await a stale promise whose result is dropped. + if (this._inflight && this._inflight.gen === this._generation) return this._inflight.promise; + if (this._map && now - this._lastFetch < FAVORITES_POLL_INTERVAL_MS) { + return this._map; + } + + const requestGen = this._generation; + const promise = (async (): Promise => { + try { + const msg = { + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "get_favorites", + service_data: {}, + return_response: true, + }; + const resp = this._retry + ? await this._retry.callWS>(hass, msg, { + errorId: "fetch:favorites", + errorMessage: t("error.favorites_fetch_failed"), + }) + : await hass.callWS>(msg); + const next = resp?.response?.favorites ?? {}; + if (requestGen === this._generation) { + this._map = next; + this._lastFetch = Date.now(); + } + return next; + } catch (err) { + console.warn("SPAN Panel: favorites fetch failed", err); + if (!this._retry) { + this._errorStore?.add({ + key: "fetch:favorites", + level: "warning", + message: t("error.favorites_fetch_failed"), + persistent: false, + }); + } + return this._map ?? {}; + } finally { + // Only clear the slot if it still points at this request; a + // later fetch() that ran after invalidate() may have replaced + // it with a newer in-flight promise we must not clobber. + if (this._inflight?.gen === requestGen) { + this._inflight = null; + } + } + })(); + this._inflight = { gen: requestGen, promise }; + return promise; + } + + invalidate(): void { + this._lastFetch = 0; + this._generation++; + } + + clear(): void { + this._map = null; + this._lastFetch = 0; + this._generation++; + } + + get map(): FavoritesMap { + return this._map ?? {}; + } +} + +/** + * Count the total number of favorited targets (circuits + sub-devices) + * across all panels. + */ +export function countFavorites(map: FavoritesMap): number { + let n = 0; + for (const entry of Object.values(map)) { + n += (entry.circuits?.length ?? 0) + (entry.sub_devices?.length ?? 0); + } + return n; +} + +/** + * True when the user has at least one favorite configured (any kind). + */ +export function hasAnyFavorites(map: FavoritesMap): boolean { + for (const entry of Object.values(map)) { + if ((entry.circuits?.length ?? 0) > 0) return true; + if ((entry.sub_devices?.length ?? 0) > 0) return true; + } + return false; +} diff --git a/src/core/graph-settings.ts b/src/core/graph-settings.ts index b347158..76e3811 100644 --- a/src/core/graph-settings.ts +++ b/src/core/graph-settings.ts @@ -1,6 +1,9 @@ // src/core/graph-settings.ts import { INTEGRATION_DOMAIN, DEFAULT_GRAPH_HORIZON } from "../constants.js"; +import { t } from "../i18n.js"; +import { RetryManager } from "./retry-manager.js"; import type { HomeAssistant, GraphSettings } from "../types.js"; +import type { ErrorStore } from "./error-store.js"; const GRAPH_SETTINGS_POLL_INTERVAL_MS = 30_000; @@ -16,6 +19,17 @@ export class GraphSettingsCache { private _settings: GraphSettings | null; private _lastFetch: number; private _fetching: boolean; + private _errorStore: ErrorStore | null = null; + private _retry: RetryManager | null = null; + + get errorStore(): ErrorStore | null { + return this._errorStore; + } + + set errorStore(store: ErrorStore | null) { + this._errorStore = store; + this._retry = store ? new RetryManager(store) : null; + } constructor() { this._settings = null; @@ -37,17 +51,33 @@ export class GraphSettingsCache { try { const serviceData: Record = {}; if (configEntryId) serviceData.config_entry_id = configEntryId; - const resp = await hass.callWS({ + const msg = { type: "call_service", domain: INTEGRATION_DOMAIN, service: "get_graph_settings", service_data: serviceData, return_response: true, - }); + }; + const resp = this._retry + ? await this._retry.callWS(hass, msg, { + errorId: "fetch:graph_settings", + errorMessage: t("error.graph_settings_failed"), + }) + : await hass.callWS(msg); this._settings = resp?.response ?? null; - this._lastFetch = now; - } catch { + this._lastFetch = Date.now(); + } catch (err) { + console.warn("SPAN Panel: graph settings fetch failed", err); this._settings = null; + // RetryManager dispatches on exhaustion; only dispatch directly when no retry path ran + if (!this._retry) { + this._errorStore?.add({ + key: "fetch:graph_settings", + level: "warning", + message: t("error.graph_settings_failed"), + persistent: false, + }); + } } finally { this._fetching = false; } diff --git a/src/core/grid-renderer.ts b/src/core/grid-renderer.ts index 8a9e814..7d80d06 100644 --- a/src/core/grid-renderer.ts +++ b/src/core/grid-renderer.ts @@ -4,7 +4,8 @@ import { t } from "../i18n.js"; import { tabToRow, tabToCol, classifyDualTab } from "../helpers/layout.js"; import { getChartMetric } from "../helpers/chart.js"; import { DEVICE_TYPE_PV, RELAY_STATE_CLOSED, SHEDDING_PRIORITIES, MONITORING_COLORS } from "../constants.js"; -import { getCircuitMonitoringInfo, hasCustomOverrides, getUtilizationClass, isAlertActive } from "./monitoring-status.js"; +import { getCircuitMonitoringInfo, hasCustomOverrides } from "./monitoring-status.js"; +import { getCircuitStateClasses } from "./circuit-state.js"; import type { PanelTopology, Circuit, HomeAssistant, CardConfig, MonitoringStatus, MonitoringPointInfo, SheddingPriorityDef } from "../types.js"; type SlotLayout = "single" | "row-span" | "col-span"; @@ -155,20 +156,28 @@ export function renderCircuitSlot( if (priority !== "unknown") { const shedInfo: SheddingPriorityDef = SHEDDING_PRIORITIES[priority] ?? SHEDDING_PRIORITIES.unknown ?? { icon: "mdi:help", color: "#999", label: () => "Unknown" }; + // Escape every value that ends up inside an attribute. ``label()`` + // resolves through i18n so future translations may contain quotes, + // and inline-style injection breaks on a stray ``"`` or ``;``. + const safeLabel = escapeHtml(shedInfo.label()); + const safeIcon = escapeHtml(shedInfo.icon); + const safeColor = escapeHtml(shedInfo.color); if (shedInfo.icon2) { - sheddingHTML = ` - - + const safeIcon2 = escapeHtml(shedInfo.icon2); + sheddingHTML = ` + + `; } else if (shedInfo.textLabel) { - sheddingHTML = ` - - ${shedInfo.textLabel} + const safeTextLabel = escapeHtml(shedInfo.textLabel); + sheddingHTML = ` + + ${safeTextLabel} `; } else { - sheddingHTML = ``; + sheddingHTML = ``; } } @@ -177,34 +186,38 @@ export function renderCircuitSlot( const gearColor = hasOverridesFlag ? MONITORING_COLORS.custom : "#555"; const gearHTML = ``; - // Utilization (shown when monitoring is active) + // Utilization — prefer monitoring data, fall back to live current / breaker rating let utilizationHTML = ""; - if (monitoringInfo?.utilization_pct != null) { - const pct = monitoringInfo.utilization_pct; - const utilClass = getUtilizationClass(monitoringInfo); - utilizationHTML = `${Math.round(pct)}%`; + let utilizationPct = monitoringInfo?.utilization_pct ?? null; + if (utilizationPct == null && circuit.breaker_rating_a) { + const curEid = circuit.entities?.current; + const curState = curEid ? hass.states[curEid] : null; + const amps = curState ? Math.abs(parseFloat(curState.state) || 0) : 0; + utilizationPct = Math.round((amps / circuit.breaker_rating_a) * 1000) / 10; + } + if (utilizationPct != null) { + const utilClass = utilizationPct >= 100 ? "utilization-alert" : utilizationPct >= 80 ? "utilization-warning" : "utilization-normal"; + utilizationHTML = `${Math.round(utilizationPct)}%`; } - // Alert and custom monitoring classes - const alertActive = isAlertActive(monitoringInfo); - const alertClass = alertActive ? "circuit-alert" : ""; - const customClass = hasOverridesFlag ? "circuit-custom-monitoring" : ""; + const stateClasses = getCircuitStateClasses(circuit, monitoringInfo, isOn, isProducer); const rowSpan = layout === "col-span" ? `${row} / span 2` : `${row}`; const layoutClass = inline ? "" : layout === "row-span" ? "circuit-row-span" : layout === "col-span" ? "circuit-col-span" : ""; const gridStyle = inline ? "" : `style="grid-row: ${rowSpan}; grid-column: ${col};"`; return ` -
${breakerLabel ? `${breakerLabel}` : ""} + ${utilizationHTML} ${name}
@@ -225,7 +238,6 @@ export function renderCircuitSlot(
${sheddingHTML} - ${utilizationHTML} ${gearHTML}
diff --git a/src/core/header-renderer.ts b/src/core/header-renderer.ts index 0619e05..43c3286 100644 --- a/src/core/header-renderer.ts +++ b/src/core/header-renderer.ts @@ -3,15 +3,57 @@ import { t } from "../i18n.js"; import { SHEDDING_PRIORITIES } from "../constants.js"; import type { PanelTopology, CardConfig, SheddingPriorityDef } from "../types.js"; +export interface HeaderRenderOptions { + /** + * Include the slide-to-enable switches control. All tabs that expose + * toggleable circuit controls (the By Panel breaker grid and the By + * Activity / By Area list views with their tappable ON/OFF badges) + * should render this control so taps require explicit user arming. + * Set false only if the view has no toggleable circuit controls. + */ + showSwitches?: boolean; +} + /** - * Build the panel header HTML with stats, gear icon, and A/W toggle. + * Build the shedding-legend HTML block. One `.shedding-legend-item` per + * non-"unknown" entry in `SHEDDING_PRIORITIES`. Consumed by + * `buildHeaderHTML` (real-panel header) and by the Favorites summary + * strip in `src/panel/span-panel.ts`. Kept as a string-returning helper + * so both call sites consume the same DOM shape from one source. */ -export function buildHeaderHTML(topology: PanelTopology, config: CardConfig): string { - const panelName: string = escapeHtml(topology.device_name || t("header.default_name")); - const serial: string = escapeHtml(topology.serial || ""); - const firmware: string = escapeHtml(topology.firmware || ""); - const isAmpsMode: boolean = (config.chart_metric || "power") === "current"; +export function buildSheddingLegendHTML(): string { + return `
+ ${Object.entries(SHEDDING_PRIORITIES) + .filter(([key]: [string, SheddingPriorityDef]) => key !== "unknown") + .map(([, cfg]: [string, SheddingPriorityDef]) => { + const icon = escapeHtml(cfg.icon); + const color = escapeHtml(cfg.color); + const label = escapeHtml(cfg.label()); + let icons: string; + if (cfg.icon2) { + const icon2 = escapeHtml(cfg.icon2); + icons = ``; + } else if (cfg.textLabel) { + const textLabel = escapeHtml(cfg.textLabel); + icons = `${textLabel}`; + } else { + icons = ``; + } + return `
${icons}${label}
`; + }) + .join("")} +
`; +} +/** + * Build just the panel-stats block (Site / Grid / Upstream / Downstream / + * Solar / Battery) for one panel. Extracted so the Favorites view can + * render a per-panel grid of stats. The returned block is a standalone + * ``.panel-stats`` container with a ``data-stats-panel-id`` attribute so + * per-panel updates can address it individually. + */ +export function buildPanelStatsHTML(topology: PanelTopology, config: CardConfig, panelDeviceId?: string): string { + const isAmpsMode: boolean = (config.chart_metric || "power") === "current"; const hasSite: boolean = !!topology.panel_entities?.site_power; const hasGrid: boolean = !!topology.panel_entities?.dsm_state; const hasUpstream: boolean = !!topology.panel_entities?.current_power; @@ -19,120 +61,126 @@ export function buildHeaderHTML(topology: PanelTopology, config: CardConfig): st const hasSolar: boolean = !!topology.panel_entities?.pv_power; const hasBattery: boolean = !!topology.panel_entities?.battery_level; + const idAttr = panelDeviceId ? ` data-stats-panel-id="${escapeHtml(panelDeviceId)}"` : ""; + + return ` +
+ ${ + hasSite + ? ` +
+ ${t("header.site")} +
+ 0 + ${isAmpsMode ? "A" : "kW"} +
+
` + : "" + } + ${ + hasGrid + ? ` +
+ ${t("header.grid")} +
+ -- +
+
` + : "" + } + ${ + hasUpstream + ? ` +
+ ${t("header.upstream")} +
+ -- + ${isAmpsMode ? "A" : "kW"} +
+
` + : "" + } + ${ + hasDownstream + ? ` +
+ ${t("header.downstream")} +
+ -- + ${isAmpsMode ? "A" : "kW"} +
+
` + : "" + } + ${ + hasSolar + ? ` +
+ ${t("header.solar")} +
+ -- + ${isAmpsMode ? "A" : "kW"} +
+
` + : "" + } + ${ + hasBattery + ? ` +
+ ${t("header.battery")} +
+ + % +
+
` + : "" + } +
+ `; +} + +/** + * Build the panel header HTML with stats, gear icon, and A/W toggle. + */ +export function buildHeaderHTML(topology: PanelTopology, config: CardConfig, options: HeaderRenderOptions = {}): string { + const panelName: string = escapeHtml(topology.device_name || t("header.default_name")); + const serial: string = escapeHtml(topology.serial || ""); + const firmware: string = escapeHtml(topology.firmware || ""); + const isAmpsMode: boolean = (config.chart_metric || "power") === "current"; + const showSwitches = options.showSwitches !== false; + return `

${panelName}

${serial} - -
- ${t("header.enable_switches")} + ${ + showSwitches + ? `
+ ${escapeHtml(t("header.enable_switches"))}
-
-
-
- ${ - hasSite - ? ` -
- ${t("header.site")} -
- 0 - ${isAmpsMode ? "A" : "kW"} -
-
` - : "" - } - ${ - hasGrid - ? ` -
- ${t("header.grid")} -
- -- -
-
` - : "" - } - ${ - hasUpstream - ? ` -
- ${t("header.upstream")} -
- -- - ${isAmpsMode ? "A" : "kW"} -
-
` - : "" - } - ${ - hasDownstream - ? ` -
- ${t("header.downstream")} -
- -- - ${isAmpsMode ? "A" : "kW"} -
-
` - : "" - } - ${ - hasSolar - ? ` -
- ${t("header.solar")} -
- -- - ${isAmpsMode ? "A" : "kW"} -
-
` - : "" - } - ${ - hasBattery - ? ` -
- ${t("header.battery")} -
- - % -
` : "" }
+ ${buildPanelStatsHTML(topology, config)}
${firmware} -
+
-
- ${Object.entries(SHEDDING_PRIORITIES) - .filter(([key]: [string, SheddingPriorityDef]) => key !== "unknown") - .map(([, cfg]: [string, SheddingPriorityDef]) => { - let icons: string; - if (cfg.icon2) { - icons = ``; - } else if (cfg.textLabel) { - icons = `${cfg.textLabel}`; - } else { - icons = ``; - } - return `
${icons}${cfg.label()}
`; - }) - .join("")} -
+ ${buildSheddingLegendHTML()}
`; diff --git a/src/core/list-renderer.ts b/src/core/list-renderer.ts index 970343e..bc1e35b 100644 --- a/src/core/list-renderer.ts +++ b/src/core/list-renderer.ts @@ -2,9 +2,9 @@ import { escapeHtml } from "../helpers/sanitize.js"; import { formatPowerSigned, formatPowerUnit } from "../helpers/format.js"; import { t } from "../i18n.js"; import { getChartMetric } from "../helpers/chart.js"; -import { RELAY_STATE_CLOSED, SHEDDING_PRIORITIES } from "../constants.js"; -import { getUtilizationClass } from "./monitoring-status.js"; -import { renderCircuitSlot } from "./grid-renderer.js"; +import { RELAY_STATE_CLOSED, SHEDDING_PRIORITIES, MONITORING_COLORS, DEVICE_TYPE_PV } from "../constants.js"; +import { hasCustomOverrides } from "./monitoring-status.js"; +import { getCircuitStateClasses } from "./circuit-state.js"; import type { Circuit, HomeAssistant, CardConfig, MonitoringPointInfo, SheddingPriorityDef } from "../types.js"; /** @@ -101,27 +101,51 @@ export function buildListRowHTML( } } - // Utilization badge + // Utilization — prefer monitoring data, fall back to live current / breaker rating let utilizationHTML = ""; - if (monitoringInfo?.utilization_pct != null) { - const pct = monitoringInfo.utilization_pct; - const utilClass = getUtilizationClass(monitoringInfo); - utilizationHTML = `${Math.round(pct)}%`; + let utilizationPct = monitoringInfo?.utilization_pct ?? null; + if (utilizationPct == null && circuit.breaker_rating_a) { + const curEid = circuit.entities?.current; + const curState = curEid ? hass.states[curEid] : null; + const amps = curState ? Math.abs(parseFloat(curState.state) || 0) : 0; + utilizationPct = Math.round((amps / circuit.breaker_rating_a) * 1000) / 10; } + if (utilizationPct != null) { + const utilClass = utilizationPct >= 100 ? "utilization-alert" : utilizationPct >= 80 ? "utilization-warning" : "utilization-normal"; + utilizationHTML = `${Math.round(utilizationPct)}%`; + } + + // Gear — matches the breaker-grid's gear so onGearClick handles it unchanged. + const hasOverridesFlag = monitoringInfo ? hasCustomOverrides(monitoringInfo) : false; + const gearColor = hasOverridesFlag ? MONITORING_COLORS.custom : "#555"; + const gearHTML = ``; - // ON/OFF badge - const statusBadge = isOn ? `ON` : `OFF`; + // Controllable circuits get a real toggle-pill arm-protected by the + // header's slide-confirm; non-controllable circuits keep a static badge. + const isToggleable = circuit.is_user_controllable !== false && !!circuit.entities?.switch; + const statusControl = isToggleable + ? `
+ ${isOn ? t("grid.on") : t("grid.off")} + +
` + : `${isOn ? "ON" : "OFF"}`; return ` -
+
${breakerLabel ? `${breakerLabel}` : ""} + ${utilizationHTML} ${name} ${sheddingHTML} - ${utilizationHTML} - ${statusBadge} + ${statusControl} ${valueHTML} + ${gearHTML} @@ -130,23 +154,48 @@ export function buildListRowHTML( } /** - * Build the expanded detail view for a circuit (wraps renderCircuitSlot). + * Build the chart-only expanded content for a list row. The collapsed + * list row already shows breaker / utilization / name / shedding / status / + * power, so the expanded area only needs to surface the chart. State- + * visualization classes (off, producer, alert, custom monitoring) still + * apply to the wrapping slot so border/background signaling is preserved. */ -export function buildExpandedCircuitHTML( +export function buildExpandedChartHTML( uuid: string, circuit: Circuit, hass: HomeAssistant, - config: CardConfig, - monitoringInfo: MonitoringPointInfo | null, - sheddingPriority: string + _config: CardConfig, + monitoringInfo: MonitoringPointInfo | null ): string { - const slotHTML = renderCircuitSlot(uuid, circuit, 0, "1", "single", hass, config, monitoringInfo, sheddingPriority, true); - return `
${slotHTML}
`; + const powerEid = circuit.entities?.power; + const powerState = powerEid ? hass.states[powerEid] : null; + const powerW = powerState ? parseFloat(powerState.state) || 0 : 0; + const isProducer = circuit.device_type === DEVICE_TYPE_PV || powerW < 0; + + const switchEid = circuit.entities?.switch; + const switchState = switchEid ? hass.states[switchEid] : null; + const isOn = switchState + ? switchState.state === "on" + : ((powerState?.attributes?.relay_state as string | undefined) || circuit.relay_state) === RELAY_STATE_CLOSED; + + const stateClasses = getCircuitStateClasses(circuit, monitoringInfo, isOn, isProducer); + const safeUuid = escapeHtml(uuid); + + return ` +
+
+
+
+
+ `; } /** - * Build an area group header for the "By Area" list view. + * Build an area group header for the "By Area" list view. The inline + * ``grid-column: 1 / -1`` is harmless when the list view is in + * single-column (flex) mode and causes the header to span all columns + * when grid mode is active. */ export function buildAreaHeaderHTML(areaName: string): string { - return `
${escapeHtml(areaName)}
`; + return `
${escapeHtml(areaName)}
`; } diff --git a/src/core/list-view-controller.ts b/src/core/list-view-controller.ts index 0f151e5..2ad0565 100644 --- a/src/core/list-view-controller.ts +++ b/src/core/list-view-controller.ts @@ -1,14 +1,18 @@ +import { escapeHtml } from "../helpers/sanitize.js"; +import { attrSelectorValue } from "../helpers/selector.js"; import { RELAY_STATE_CLOSED } from "../constants.js"; import { formatPowerSigned, formatPowerUnit } from "../helpers/format.js"; import { getChartMetric } from "../helpers/chart.js"; import { t } from "../i18n.js"; import { getCircuitMonitoringInfo } from "./monitoring-status.js"; -import { buildSearchBarHTML, buildUnitToggleHTML, buildListRowHTML, buildExpandedCircuitHTML, buildAreaHeaderHTML } from "./list-renderer.js"; +import { buildSearchBarHTML, buildListRowHTML, buildExpandedChartHTML, buildAreaHeaderHTML } from "./list-renderer.js"; import type { DashboardController } from "./dashboard-controller.js"; import type { HomeAssistant, PanelTopology, CardConfig, Circuit, MonitoringStatus } from "../types.js"; +import type { ErrorStore } from "./error-store.js"; interface SpanSidePanelElement extends HTMLElement { hass: HomeAssistant; + errorStore: ErrorStore | null; } interface CircuitSortInfo { @@ -44,20 +48,92 @@ function getSheddingPriority(circuit: Circuit, hass: HomeAssistant): string { return selectState ? selectState.state : "unknown"; } +function compareCircuits(a: Circuit, b: Circuit, hass: HomeAssistant, config: CardConfig): number { + const infoA = getCircuitSortInfo(a, hass, config); + const infoB = getCircuitSortInfo(b, hass, config); + if (infoA.isOn && !infoB.isOn) return -1; + if (!infoA.isOn && infoB.isOn) return 1; + return infoB.value - infoA.value; +} + function sortCircuitEntries(entries: [string, Circuit][], hass: HomeAssistant, config: CardConfig): [string, Circuit][] { - return entries.sort((a, b) => { - const infoA = getCircuitSortInfo(a[1], hass, config); - const infoB = getCircuitSortInfo(b[1], hass, config); - if (infoA.isOn && !infoB.isOn) return -1; - if (!infoA.isOn && infoB.isOn) return 1; - return infoB.value - infoA.value; - }); + return entries.sort((a, b) => compareCircuits(a[1], b[1], hass, config)); +} + +interface CellUnit { + cell: HTMLElement; + uuid: string; + circuit: Circuit; +} + +interface CellGroup { + anchor: HTMLElement | null; + units: CellUnit[]; +} + +// Partition .list-view's direct children into groups. Activity view +// produces a single anchor-less group; area view produces one group per +// .area-header. Each circuit (row + optional expansion) lives inside a +// single ``.list-cell`` wrapper so multi-column grid mode keeps the +// expansion in the same column as its row. +function partitionCellGroups(listView: HTMLElement, topology: PanelTopology): CellGroup[] { + let current: CellGroup = { anchor: null, units: [] }; + const groups: CellGroup[] = [current]; + + for (const el of [...listView.children] as HTMLElement[]) { + if (el.classList.contains("area-header")) { + current = { anchor: el, units: [] }; + groups.push(current); + continue; + } + if (el.classList.contains("list-cell")) { + const uuid = el.dataset.cellUuid; + const circuit = uuid ? topology.circuits[uuid] : undefined; + if (uuid && circuit) { + current.units.push({ cell: el, uuid, circuit }); + } + } + } + + return groups; +} + +function reorderListRows(root: Element | ShadowRoot, hass: HomeAssistant, topology: PanelTopology, config: CardConfig): void { + const listView = root.querySelector(".list-view"); + if (!listView) return; + + for (const group of partitionCellGroups(listView, topology)) { + if (group.units.length < 2) continue; + + const sorted = [...group.units].sort((a, b) => compareCircuits(a.circuit, b.circuit, hass, config)); + + const changed = sorted.some((unit, j) => unit.uuid !== group.units[j]!.uuid); + if (!changed) continue; + + let after: Element | null = group.anchor; + for (const unit of sorted) { + if (after) { + after.after(unit.cell); + } else { + listView.prepend(unit.cell); + } + after = unit.cell; + } + } } function getCircuitEntityId(circuit: Circuit): string { return circuit.entities?.current ?? circuit.entities?.power ?? ""; } +export const FAVORITES_VIEW_STATE_CHANGED_EVENT = "favorites-view-state-changed"; + +export interface FavoritesViewStateDetail { + view: "activity" | "area"; + expanded: string[]; + searchQuery: string; +} + export class ListViewController { private _ctrl: DashboardController; private _expandedUuids = new Set(); @@ -73,16 +149,60 @@ export class ListViewController { private _config: CardConfig | null = null; private _monitoringStatus: MonitoringStatus | null = null; + /** + * When set to ``"activity"`` or ``"area"``, expansion and search-box + * mutations dispatch ``favorites-view-state-changed`` so span-panel.ts + * can persist the Favorites pseudo-panel's view state to localStorage. + * ``null`` (the default) disables persistence for real-panel renders. + */ + private _viewName: "activity" | "area" | null = null; + + /** + * Number of columns for the circuit list. 1 = flex/stack (default), + * 2-3 = CSS grid. Configurable via the Graph Settings side panel and + * persisted to localStorage by ``span-panel.ts``. + */ + private _columns = 1; + constructor(ctrl: DashboardController) { this._ctrl = ctrl; } + /** Set the column count (1-3) for the next render. */ + setColumns(n: number): void { + const clamped = Math.max(1, Math.min(3, Math.floor(n))); + this._columns = clamped; + } + + /** + * Seed the expansion set before the next render. Called by + * ``span-panel.ts`` when re-entering the Favorites view so the user's + * previously expanded rows come back. + */ + setInitialExpansion(ids: Iterable): void { + this._expandedUuids = new Set(ids); + } + + /** Seed the search query before the next render. */ + setInitialSearchQuery(query: string): void { + this._searchQuery = query; + } + + /** + * Mark the upcoming render as belonging to a Favorites-view tab so + * that expansion/search state persists across dropdown switches. + */ + setViewName(viewName: "activity" | "area" | null): void { + this._viewName = viewName; + } + renderActivityView( container: HTMLElement, hass: HomeAssistant, topology: PanelTopology, config: CardConfig, - monitoringStatus: MonitoringStatus | null + monitoringStatus: MonitoringStatus | null, + headerHTML: string ): void { this._unbindEvents(); this._hass = hass; @@ -93,30 +213,42 @@ export class ListViewController { const entries: [string, Circuit][] = Object.entries(topology.circuits); const sorted = sortCircuitEntries(entries, hass, config); - let html = buildSearchBarHTML(this._searchQuery) + buildUnitToggleHTML(config); - html += '
'; + let html = headerHTML + buildSearchBarHTML(this._searchQuery); + html += `
`; for (const [uuid, circuit] of sorted) { const monitoringInfo = getCircuitMonitoringInfo(monitoringStatus, getCircuitEntityId(circuit)); const sheddingPriority = getSheddingPriority(circuit, hass); const isExpanded = this._expandedUuids.has(uuid); + html += `
`; html += buildListRowHTML(uuid, circuit, hass, config, monitoringInfo, sheddingPriority, isExpanded); if (isExpanded) { - html += buildExpandedCircuitHTML(uuid, circuit, hass, config, monitoringInfo, sheddingPriority); + html += buildExpandedChartHTML(uuid, circuit, hass, config, monitoringInfo); } + html += "
"; } html += "
"; html += ""; container.innerHTML = html; const sidePanel = container.querySelector("span-side-panel") as SpanSidePanelElement | null; - if (sidePanel) sidePanel.hass = hass; + if (sidePanel) { + sidePanel.hass = hass; + sidePanel.errorStore = this._ctrl.errorStore; + } this._bindEvents(container); if (this._searchQuery) this._applyFilter(container); this._ctrl.updateDOM(container); } - renderAreaView(container: HTMLElement, hass: HomeAssistant, topology: PanelTopology, config: CardConfig, monitoringStatus: MonitoringStatus | null): void { + renderAreaView( + container: HTMLElement, + hass: HomeAssistant, + topology: PanelTopology, + config: CardConfig, + monitoringStatus: MonitoringStatus | null, + headerHTML: string + ): void { this._unbindEvents(); this._hass = hass; this._topology = topology; @@ -144,8 +276,8 @@ export class ListViewController { return a.localeCompare(b); }); - let html = buildSearchBarHTML(this._searchQuery) + buildUnitToggleHTML(config); - html += '
'; + let html = headerHTML + buildSearchBarHTML(this._searchQuery); + html += `
`; for (const areaName of areaNames) { const group = areaGroups.get(areaName); @@ -158,10 +290,12 @@ export class ListViewController { const monitoringInfo = getCircuitMonitoringInfo(monitoringStatus, getCircuitEntityId(circuit)); const sheddingPriority = getSheddingPriority(circuit, hass); const isExpanded = this._expandedUuids.has(uuid); + html += `
`; html += buildListRowHTML(uuid, circuit, hass, config, monitoringInfo, sheddingPriority, isExpanded); if (isExpanded) { - html += buildExpandedCircuitHTML(uuid, circuit, hass, config, monitoringInfo, sheddingPriority); + html += buildExpandedChartHTML(uuid, circuit, hass, config, monitoringInfo); } + html += "
"; } } @@ -169,7 +303,10 @@ export class ListViewController { html += ""; container.innerHTML = html; const areaSidePanel = container.querySelector("span-side-panel") as SpanSidePanelElement | null; - if (areaSidePanel) areaSidePanel.hass = hass; + if (areaSidePanel) { + areaSidePanel.hass = hass; + areaSidePanel.errorStore = this._ctrl.errorStore; + } this._bindEvents(container); if (this._searchQuery) this._applyFilter(container); this._ctrl.updateDOM(container); @@ -204,7 +341,16 @@ export class ListViewController { } } - // Update status badge + // Update the status control — real toggle-pill for controllable + // circuits, static text badge for the rest. Only one will be + // present in any given row. + const togglePill = row.querySelector(".toggle-pill") as HTMLElement | null; + if (togglePill) { + togglePill.classList.toggle("toggle-on", isOn); + togglePill.classList.toggle("toggle-off", !isOn); + const label = togglePill.querySelector(".toggle-label"); + if (label) label.textContent = isOn ? t("grid.on") : t("grid.off"); + } const statusBadge = row.querySelector(".list-status-badge") as HTMLElement | null; if (statusBadge) { statusBadge.textContent = isOn ? "ON" : "OFF"; @@ -215,18 +361,41 @@ export class ListViewController { // Toggle circuit-off class row.classList.toggle("circuit-off", !isOn); } + + reorderListRows(root, hass, topology, config); } stop(): void { this._unbindEvents(); - this._expandedUuids.clear(); - this._searchQuery = ""; + // Only reset user-visible view state for real-panel renders. In the + // Favorites view we preserve expansion/search across re-renders so + // switching tabs or reloading the page restores the user's layout. + if (this._viewName === null) { + this._expandedUuids.clear(); + this._searchQuery = ""; + } this._hass = null; this._topology = null; this._config = null; this._monitoringStatus = null; } + private _dispatchFavoritesViewState(): void { + if (!this._viewName || !this._container) return; + const detail: FavoritesViewStateDetail = { + view: this._viewName, + expanded: [...this._expandedUuids], + searchQuery: this._searchQuery, + }; + this._container.dispatchEvent( + new CustomEvent(FAVORITES_VIEW_STATE_CHANGED_EVENT, { + detail, + bubbles: true, + composed: true, + }) + ); + } + private _bindEvents(container: HTMLElement): void { this._container = container; @@ -291,6 +460,7 @@ export class ListViewController { this._searchQuery = input.value.toLowerCase(); this._applyFilter(container); + this._dispatchFavoritesViewState(); }; this._graphSettingsHandler = (): void => { @@ -305,6 +475,16 @@ export class ListViewController { container.addEventListener("click", this._clickHandler); container.addEventListener("input", this._inputHandler); container.addEventListener("graph-settings-changed", this._graphSettingsHandler); + + // Wire the slide-to-arm control rendered in the header so tappable + // toggle-pills on the list rows actually fire. Without this the + // slide-confirm element renders but never gets the `.confirmed` + // class, and onToggleClick silently no-ops. + const slideEl = container.querySelector(".slide-confirm"); + if (slideEl) { + this._ctrl.bindSlideConfirm(slideEl, container); + container.classList.add("switches-disabled"); + } } private _unbindEvents(): void { @@ -329,21 +509,12 @@ export class ListViewController { const clearBtn = container.querySelector(".list-search-clear"); if (clearBtn) clearBtn.style.display = this._searchQuery ? "" : "none"; - const rows = container.querySelectorAll(".list-row[data-row-uuid]"); - for (const row of rows) { - const nameEl = row.querySelector(".list-circuit-name"); + const cells = container.querySelectorAll(".list-cell[data-cell-uuid]"); + for (const cell of cells) { + const nameEl = cell.querySelector(".list-circuit-name"); const name = nameEl?.textContent?.toLowerCase() ?? ""; const matches = name.includes(this._searchQuery); - - row.style.display = matches ? "" : "none"; - - const uuid = row.dataset.rowUuid; - if (uuid) { - const expandedContent = container.querySelector(`.list-expanded-content[data-expanded-uuid="${uuid}"]`); - if (expandedContent) { - expandedContent.style.display = matches ? "" : "none"; - } - } + cell.style.display = matches ? "" : "none"; } const areaHeaders = container.querySelectorAll(".area-header"); @@ -351,7 +522,7 @@ export class ListViewController { let hasVisibleRow = false; let sibling = header.nextElementSibling; while (sibling && !sibling.classList.contains("area-header")) { - if (sibling.classList.contains("list-row") && (sibling as HTMLElement).style.display !== "none") { + if (sibling.classList.contains("list-cell") && (sibling as HTMLElement).style.display !== "none") { hasVisibleRow = true; break; } @@ -364,17 +535,18 @@ export class ListViewController { private _toggleExpand(uuid: string): void { if (!this._container || !this._hass || !this._topology || !this._config) return; - const row = this._container.querySelector(`.list-row[data-row-uuid="${uuid}"]`); - const chevron = this._container.querySelector(`.list-expand-toggle[data-expand-uuid="${uuid}"]`); + const safeUuid = attrSelectorValue(uuid); + const cell = this._container.querySelector(`.list-cell[data-cell-uuid="${safeUuid}"]`); + if (!cell) return; + const row = cell.querySelector(`.list-row[data-row-uuid="${safeUuid}"]`); + const chevron = cell.querySelector(`.list-expand-toggle[data-expand-uuid="${safeUuid}"]`); if (!row) return; if (this._expandedUuids.has(uuid)) { // Collapse this._expandedUuids.delete(uuid); - const expandedContent = this._container.querySelector(`.list-expanded-content[data-expanded-uuid="${uuid}"]`); - if (expandedContent) { - expandedContent.remove(); - } + const expandedContent = cell.querySelector(`.list-expanded-content[data-expanded-uuid="${safeUuid}"]`); + if (expandedContent) expandedContent.remove(); if (chevron) chevron.classList.remove("expanded"); row.classList.remove("list-row-expanded"); } else { @@ -385,13 +557,13 @@ export class ListViewController { if (!circuit) return; const monitoringInfo = getCircuitMonitoringInfo(this._monitoringStatus, getCircuitEntityId(circuit)); - const sheddingPriority = getSheddingPriority(circuit, this._hass); - const html = buildExpandedCircuitHTML(uuid, circuit, this._hass, this._config, monitoringInfo, sheddingPriority); - + const html = buildExpandedChartHTML(uuid, circuit, this._hass, this._config, monitoringInfo); row.insertAdjacentHTML("afterend", html); if (chevron) chevron.classList.add("expanded"); row.classList.add("list-row-expanded"); this._ctrl.updateDOM(this._container); } + + this._dispatchFavoritesViewState(); } } diff --git a/src/core/monitoring-status.ts b/src/core/monitoring-status.ts index d78e99c..b040105 100644 --- a/src/core/monitoring-status.ts +++ b/src/core/monitoring-status.ts @@ -1,6 +1,8 @@ import { INTEGRATION_DOMAIN } from "../constants.js"; import { t } from "../i18n.js"; +import { RetryManager } from "./retry-manager.js"; import type { HomeAssistant, MonitoringPointInfo, MonitoringStatus } from "../types.js"; +import type { ErrorStore } from "./error-store.js"; const MONITORING_POLL_INTERVAL_MS = 30_000; @@ -15,42 +17,93 @@ interface CallServiceResponse { export class MonitoringStatusCache { private _status: MonitoringStatus | null = null; private _lastFetch: number = 0; - private _fetching: boolean = false; + private _inflight: { gen: number; promise: Promise } | null = null; + private _generation: number = 0; + private _errorStore: ErrorStore | null = null; + private _retry: RetryManager | null = null; + + get errorStore(): ErrorStore | null { + return this._errorStore; + } + + set errorStore(store: ErrorStore | null) { + this._errorStore = store; + this._retry = store ? new RetryManager(store) : null; + } /** - * Fetch monitoring status, returning cached data if recent. + * Fetch monitoring status, returning cached data if recent. Concurrent + * callers coalesce onto the in-flight promise instead of reading a + * possibly-stale ``_status`` (the old implementation returned the + * pre-fetch value while another request was pending, which could + * surface null on cold starts). A monotonically-increasing generation + * counter lets ``invalidate()`` supersede in-flight results. */ async fetch(hass: HomeAssistant, configEntryId?: string | null): Promise { const now = Date.now(); - if (this._fetching) return this._status; + // Only dedupe onto an in-flight request from the current generation. + // Requests predating the last invalidate() must not be reused, or the + // caller would await a stale promise whose result is silently dropped. + if (this._inflight && this._inflight.gen === this._generation) return this._inflight.promise; if (this._status && now - this._lastFetch < MONITORING_POLL_INTERVAL_MS) { return this._status; } - this._fetching = true; - try { - const serviceData: Record = {}; - if (configEntryId) serviceData.config_entry_id = configEntryId; - const resp = await hass.callWS({ - type: "call_service", - domain: INTEGRATION_DOMAIN, - service: "get_monitoring_status", - service_data: serviceData, - return_response: true, - }); - this._status = resp?.response ?? null; - this._lastFetch = now; - } catch { - this._status = null; - } finally { - this._fetching = false; - } - return this._status; + const requestGen = this._generation; + const promise = (async (): Promise => { + try { + const serviceData: Record = {}; + if (configEntryId) serviceData.config_entry_id = configEntryId; + const msg = { + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "get_monitoring_status", + service_data: serviceData, + return_response: true, + }; + const resp = this._retry + ? await this._retry.callWS(hass, msg, { + errorId: "fetch:monitoring", + errorMessage: t("error.monitoring_failed"), + }) + : await hass.callWS(msg); + const next = resp?.response ?? null; + if (requestGen === this._generation) { + this._status = next; + this._lastFetch = Date.now(); + } + return next; + } catch (err) { + console.warn("SPAN Panel: monitoring status fetch failed", err); + if (requestGen === this._generation) { + this._status = null; + } + if (!this._retry) { + this._errorStore?.add({ + key: "fetch:monitoring", + level: "warning", + message: t("error.monitoring_failed"), + persistent: false, + }); + } + return null; + } finally { + // Only clear the slot if it still points at this request; a + // later fetch() that ran after invalidate() may have replaced + // it with a newer in-flight promise we must not clobber. + if (this._inflight?.gen === requestGen) { + this._inflight = null; + } + } + })(); + this._inflight = { gen: requestGen, promise }; + return promise; } /** Force the next fetch() call to re-query the backend. */ invalidate(): void { this._lastFetch = 0; + this._generation++; } /** Last fetched status. */ @@ -62,7 +115,70 @@ export class MonitoringStatusCache { clear(): void { this._status = null; this._lastFetch = 0; + this._generation++; + } +} + +/** + * Caches monitoring status per config entry. Used by the Favorites + * view which must fetch for multiple entries in parallel and would + * otherwise issue fresh WS calls on every render tick. + */ +export class MonitoringStatusMultiCache { + private _caches = new Map(); + private _errorStore: ErrorStore | null = null; + + get errorStore(): ErrorStore | null { + return this._errorStore; + } + + set errorStore(store: ErrorStore | null) { + this._errorStore = store; + for (const cache of this._caches.values()) { + cache.errorStore = store; + } + } + + /** Fetch monitoring status for a single entry, honoring the TTL. */ + async fetchOne(hass: HomeAssistant, entryId: string): Promise { + let cache = this._caches.get(entryId); + if (!cache) { + cache = new MonitoringStatusCache(); + cache.errorStore = this._errorStore; + this._caches.set(entryId, cache); + } + return cache.fetch(hass, entryId); + } + + /** Invalidate every cached entry. */ + invalidate(): void { + for (const cache of this._caches.values()) cache.invalidate(); + } + + /** Clear entries — used on panel membership changes. */ + clear(): void { + this._caches.clear(); + } +} + +/** + * Merge multiple MonitoringStatus results into one. Null entries are + * skipped. Returns null only if every input is null. Later entries + * overwrite earlier ones on key collision, which is fine because circuit + * and mains keys are globally-unique entity IDs across config entries. + */ +export function mergeMonitoringStatuses(statuses: readonly (MonitoringStatus | null | undefined)[]): MonitoringStatus | null { + let hasAny = false; + const circuits: Record = {}; + const mains: Record = {}; + for (const status of statuses) { + if (!status) continue; + hasAny = true; + if (status.circuits) Object.assign(circuits, status.circuits); + if (status.mains) Object.assign(mains, status.mains); } + if (!hasAny) return null; + return { circuits, mains }; } /** diff --git a/src/core/retry-manager.ts b/src/core/retry-manager.ts new file mode 100644 index 0000000..3b417cd --- /dev/null +++ b/src/core/retry-manager.ts @@ -0,0 +1,87 @@ +import type { ErrorStore } from "./error-store.js"; +import type { HomeAssistant } from "../types.js"; +import { t } from "../i18n.js"; + +const DEFAULT_RETRIES = 3; +const BACKOFF_BASE_MS = 1000; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export class RetryManager { + private _store: ErrorStore; + + constructor(store: ErrorStore) { + this._store = store; + } + + async callWS(hass: HomeAssistant, msg: Record, opts?: { errorId?: string; errorMessage?: string; retries?: number }): Promise { + const maxRetries = opts?.retries ?? DEFAULT_RETRIES; + const errorId = opts?.errorId ?? `ws:${String(msg.type ?? "unknown")}`; + + return this._withRetry(() => hass.callWS(msg), maxRetries, errorId, opts?.errorMessage); + } + + async callService( + hass: HomeAssistant, + domain: string, + service: string, + data?: Record, + target?: Record, + opts?: { errorId?: string; errorMessage?: string; retries?: number } + ): Promise { + const maxRetries = opts?.retries ?? DEFAULT_RETRIES; + const errorId = opts?.errorId ?? `svc:${domain}.${service}`; + + return this._withRetry(() => hass.callService(domain, service, data, target), maxRetries, errorId, opts?.errorMessage); + } + + private async _withRetry(fn: () => Promise, maxRetries: number, errorId: string, errorMessage?: string): Promise { + // Short-circuit if any watched panel is offline — single attempt, + // no retries. Uses ``hasAnyPanelOffline`` so it covers both the + // legacy single-unnamed key and the per-entity keys used by the + // Favorites multi-panel watch. + if (this._store.hasAnyPanelOffline()) { + try { + const result = await fn(); + this._store.remove(errorId); + return result; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this._store.add({ + key: errorId, + level: "error", + message: errorMessage ?? t("error.panel_offline"), + persistent: false, + }); + throw error; + } + } + + let lastError: Error | undefined; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const result = await fn(); + // Success after prior failure — clear the error + this._store.remove(errorId); + return result; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt < maxRetries) { + const delay = BACKOFF_BASE_MS * Math.pow(2, attempt); + await sleep(delay); + } + } + } + + // All retries exhausted — add error to store + this._store.add({ + key: errorId, + level: "error", + message: errorMessage ?? lastError?.message ?? "Operation failed", + persistent: false, + }); + throw lastError ?? new Error(errorMessage ?? "Operation failed"); + } +} diff --git a/src/core/side-panel.ts b/src/core/side-panel.ts index b90446d..20fc023 100644 --- a/src/core/side-panel.ts +++ b/src/core/side-panel.ts @@ -1,8 +1,12 @@ // src/core/side-panel.ts import { escapeHtml } from "../helpers/sanitize.js"; -import { INTEGRATION_DOMAIN, SHEDDING_PRIORITIES, GRAPH_HORIZONS, DEFAULT_GRAPH_HORIZON, ERROR_DISPLAY_MS, INPUT_DEBOUNCE_MS } from "../constants.js"; +import { loadListColumns, saveListColumns } from "../helpers/list-columns.js"; +import { INTEGRATION_DOMAIN, SHEDDING_PRIORITIES, GRAPH_HORIZONS, DEFAULT_GRAPH_HORIZON, INPUT_DEBOUNCE_MS } from "../constants.js"; import { t } from "../i18n.js"; +import { addFavorite, removeFavorite } from "./favorites-store.js"; +import { sortedCircuitsForSection } from "./favorites-sections.js"; import type { HomeAssistant, PanelTopology, GraphSettings, CircuitEntities, CircuitGraphOverride, MonitoringPointInfo } from "../types.js"; +import type { ErrorStore } from "./error-store.js"; const PRIORITY_OPTIONS: string[] = Object.keys(SHEDDING_PRIORITIES).filter(k => k !== "unknown" && k !== "always_on"); @@ -15,13 +19,36 @@ interface GraphHorizonInfo extends CircuitGraphOverride { interface PanelModeConfig { panelMode: true; subDeviceMode?: undefined; + favoritesMode?: undefined; topology: PanelTopology; graphSettings: GraphSettings | null; + /** + * When set, the per-target lists in panel mode render a heart button + * beside each horizon selector for toggling favorites. Only the + * dashboard (````) sets this — the standalone card leaves + * it undefined so hearts never appear there. + */ + showFavorites?: boolean; + /** HA device id of the panel whose side panel is open (source of favorites). */ + favoritePanelDeviceId?: string; + /** + * Circuit uuids favorited for this panel at the moment the side panel + * was opened. Snapshot — not live. Subsequent toggles update only the + * clicked heart's optimistic class via ``_toggleFavoriteEntity``; if + * the user closes and reopens the side panel, ``DashboardController.onGearClick`` + * rebuilds the config from the latest ``_panelFavorites``. + */ + favoriteCircuitUuids?: Set; + /** Sub-device HA device ids favorited for this panel — same snapshot semantics. */ + favoriteSubDeviceIds?: Set; + /** Override config entry id used for cross-panel service routing (favorites). */ + configEntryId?: string | null; } interface CircuitModeConfig { panelMode?: undefined; subDeviceMode?: undefined; + favoritesMode?: undefined; uuid: string; name: string; tabs: number[]; @@ -33,18 +60,53 @@ interface CircuitModeConfig { monitoringInfo: MonitoringPointInfo | null; showMonitoring?: boolean; graphHorizonInfo: GraphHorizonInfo; + /** Dashboard-only: render the Favorite section on this side panel. */ + showFavorites?: boolean; + /** HA device id of the panel that owns this circuit. */ + favoritePanelDeviceId?: string; + /** Initial favorite state; ``addFavorite``/``removeFavorite`` update live. */ + isFavorite?: boolean; + /** Route domain service calls to this config entry (favorites view). */ + configEntryId?: string | null; } interface SubDeviceModeConfig { panelMode?: undefined; subDeviceMode: true; + favoritesMode?: undefined; subDeviceId: string; name: string; deviceType: string; + /** Sub-device entity registry map from topology, used to pick a routable entity_id for favoriting. */ + entities?: Record; graphHorizonInfo: GraphHorizonInfo; + /** Dashboard-only: render the Favorite section on this side panel. */ + showFavorites?: boolean; + /** HA device id of the panel that owns this sub-device. */ + favoritePanelDeviceId?: string; + /** Initial favorite state. */ + isFavorite?: boolean; + /** Route domain service calls to this config entry (favorites view). */ + configEntryId?: string | null; } -type SidePanelConfig = PanelModeConfig | CircuitModeConfig | SubDeviceModeConfig; +export interface FavoritesPanelSection { + panelDeviceId: string; + panelName: string; + topology: PanelTopology; + graphSettings: GraphSettings | null; + favoriteCircuitUuids: Set; + configEntryId: string | null; +} + +interface FavoritesModeConfig { + favoritesMode: true; + panelMode?: undefined; + subDeviceMode?: undefined; + perPanelSections: FavoritesPanelSection[]; +} + +type SidePanelConfig = PanelModeConfig | CircuitModeConfig | SubDeviceModeConfig | FavoritesModeConfig; // ── Custom element interface for ha-switch ─────────────────────────────── @@ -226,12 +288,65 @@ const STYLES = ` box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4); } + .unit-toggle { + display: inline-flex; + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 6px; + overflow: hidden; + } + .unit-btn { + padding: 4px 10px; + border: none; + border-right: 1px solid var(--divider-color, #e0e0e0); + background: var(--card-background-color, #fff); + color: var(--primary-text-color, #212121); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .unit-btn:last-child { + border-right: none; + } + .unit-btn:hover:not(.unit-active) { + background: var(--secondary-background-color, #f5f5f5); + } + .unit-btn.unit-active { + background: var(--primary-color, #03a9f4); + color: #fff; + font-weight: 600; + } + .monitoring-header { display: flex; align-items: center; justify-content: space-between; } + .fav-heart { + background: none; + border: 1px solid var(--divider-color, #e0e0e0); + color: var(--secondary-text-color, #727272); + border-radius: 4px; + padding: 2px 6px; + cursor: pointer; + font-size: 0.9em; + margin-right: 6px; + line-height: 1; + display: inline-flex; + align-items: center; + } + .fav-heart.active { + color: var(--primary-color, #03a9f4); + border-color: var(--primary-color, #03a9f4); + } + .fav-heart:hover:not(.active) { + background: var(--secondary-background-color, #f5f5f5); + } + .fav-heart ha-icon { + --mdc-icon-size: 16px; + } + .panel-mode-info { font-size: 14px; color: var(--primary-text-color, #212121); @@ -241,23 +356,15 @@ const STYLES = ` margin: 0 0 12px 0; } - .error-msg { - color: var(--error-color, #f44336); - font-size: 0.8em; - padding: 8px; - margin: 8px 0; - background: rgba(244, 67, 54, 0.1); - border-radius: 4px; - } `; // ── Component ───────────────────────────────────────────────────────────── class SpanSidePanel extends HTMLElement { + errorStore: ErrorStore | null = null; private _hass: HomeAssistant | null; private _config: SidePanelConfig | null; private _debounceTimers: Record>; - constructor() { super(); this.attachShadow({ mode: "open" }); @@ -277,20 +384,47 @@ class SpanSidePanel extends HTMLElement { return this._hass; } + disconnectedCallback(): void { + this._clearDebounceTimers(); + this._config = null; + } + open(config: SidePanelConfig): void { this._config = config; this._render(); // Force reflow before adding attribute so the transition animates void this.offsetHeight; this.setAttribute("open", ""); + // Expose the active mode as a host attribute so parents can detect + // which sidebar variant is open (e.g. to defer tab re-renders while + // a favorites-mode sidebar is live). + this.setAttribute("data-mode", this._modeFor(config)); } close(): void { + // Cancel any still-pending debounced writes so they don't fire + // against a torn-down ``_config`` and leak a stale service call. + this._clearDebounceTimers(); this.removeAttribute("open"); + this.removeAttribute("data-mode"); this._config = null; this.dispatchEvent(new CustomEvent("side-panel-closed", { bubbles: true, composed: true })); } + private _clearDebounceTimers(): void { + for (const key of Object.keys(this._debounceTimers)) { + clearTimeout(this._debounceTimers[key]); + } + this._debounceTimers = {}; + } + + private _modeFor(cfg: SidePanelConfig): "favorites" | "panel" | "subDevice" | "circuit" { + if (cfg.favoritesMode) return "favorites"; + if (cfg.panelMode) return "panel"; + if (cfg.subDeviceMode) return "subDevice"; + return "circuit"; + } + // ── Rendering ───────────────────────────────────────────────────────── private _render(): void { @@ -314,7 +448,9 @@ class SpanSidePanel extends HTMLElement { panel.className = "panel"; shadow.appendChild(panel); - if (cfg.panelMode) { + if (cfg.favoritesMode) { + this._renderFavoritesMode(panel); + } else if (cfg.panelMode) { this._renderPanelMode(panel); } else if (cfg.subDeviceMode) { this._renderSubDeviceMode(panel, cfg); @@ -331,17 +467,16 @@ class SpanSidePanel extends HTMLElement { const body = document.createElement("div"); body.className = "panel-body"; - const errorEl = document.createElement("div"); - errorEl.className = "error-msg"; - errorEl.id = "error-msg"; - errorEl.style.display = "none"; - body.appendChild(errorEl); - const graphSettings = cfg.graphSettings; const topology = cfg.topology; const globalHorizon = graphSettings?.global_horizon ?? DEFAULT_GRAPH_HORIZON; const circuitSettings = graphSettings?.circuits ?? {}; + // ── List view columns ── + // Placed above the horizon sections so the horizon-related sections + // (global default + per-circuit scales) sit together below. + body.appendChild(this._buildListColumnsSection()); + // ── Global default horizon ── const globalSection = document.createElement("div"); globalSection.className = "section"; @@ -370,11 +505,21 @@ class SpanSidePanel extends HTMLElement { globalSelect.appendChild(opt); } globalSelect.addEventListener("change", () => { - this._callDomainService("set_graph_time_horizon", { horizon: globalSelect.value }) + const data: Record = { horizon: globalSelect.value }; + if (cfg.configEntryId) data.config_entry_id = cfg.configEntryId; + this._callDomainService("set_graph_time_horizon", data) .then(() => { this.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); }) - .catch((err: Error) => this._showError(`${err.message ?? err}`)); + .catch((err: Error) => { + console.warn("SPAN Panel: graph horizon service failed", err); + this.errorStore?.add({ + key: "service:graph_horizon", + level: "error", + message: t("error.graph_horizon_failed"), + persistent: false, + }); + }); }); globalRow.appendChild(globalSelect); globalSection.appendChild(globalRow); @@ -393,69 +538,16 @@ class SpanSidePanel extends HTMLElement { const circuits = Object.entries(topology.circuits).sort(([, a], [, b]) => (a.name || "").localeCompare(b.name || "")); for (const [uuid, circuit] of circuits) { - const row = document.createElement("div"); - row.className = "field-row"; - - const nameLabel = document.createElement("span"); - nameLabel.className = "field-label"; - nameLabel.textContent = circuit.name || uuid; - nameLabel.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;"; - row.appendChild(nameLabel); - - const circuitData = circuitSettings[uuid] || { horizon: globalHorizon, has_override: false }; - const effectiveHorizon = circuitData.has_override ? circuitData.horizon : globalHorizon; - - const select = document.createElement("select"); - select.dataset.uuid = uuid; - for (const key of Object.keys(GRAPH_HORIZONS)) { - const opt = document.createElement("option"); - opt.value = key; - const labelKey = `horizon.${key}`; - const translated = t(labelKey); - opt.textContent = translated !== labelKey ? translated : key; - if (key === effectiveHorizon) opt.selected = true; - select.appendChild(opt); - } - select.addEventListener("change", () => { - this._debounce(`circuit-${uuid}`, INPUT_DEBOUNCE_MS, () => { - this._callDomainService("set_circuit_graph_horizon", { - circuit_id: uuid, - horizon: select.value, - }) - .then(() => { - this.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); - }) - .catch((err: Error) => this._showError(`${err.message ?? err}`)); - }); - }); - row.appendChild(select); - - if (circuitData.has_override) { - const resetBtn = document.createElement("button"); - resetBtn.textContent = "\u21ba"; - resetBtn.title = t("sidepanel.reset_to_global"); - Object.assign(resetBtn.style, { - background: "none", - border: "1px solid var(--divider-color, #e0e0e0)", - color: "var(--primary-text-color)", - borderRadius: "4px", - padding: "3px 6px", - cursor: "pointer", - marginLeft: "4px", - fontSize: "0.85em", - }); - resetBtn.addEventListener("click", () => { - this._callDomainService("clear_circuit_graph_horizon", { circuit_id: uuid }) - .then(() => { - select.value = globalHorizon; - resetBtn.remove(); - this.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); - }) - .catch((err: Error) => this._showError(`${err.message ?? err}`)); - }); - row.appendChild(resetBtn); - } - + const row = this._buildPanelModeCircuitRow( + uuid, + circuit, + circuitSettings[uuid], + globalHorizon, + cfg.configEntryId ?? null, + cfg.showFavorites ?? false, + cfg.favoritePanelDeviceId, + cfg.favoriteCircuitUuids + ); circuitSection.appendChild(row); } @@ -485,6 +577,11 @@ class SpanSidePanel extends HTMLElement { nameLabel.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;"; row.appendChild(nameLabel); + if (cfg.showFavorites && cfg.favoritePanelDeviceId) { + const heart = this._buildSubDeviceFavoriteHeart(sub.entities, cfg.favoriteSubDeviceIds?.has(devId) ?? false); + if (heart) row.appendChild(heart); + } + const subDevData = subDeviceSettings[devId] || { horizon: globalHorizon, has_override: false }; const effectiveHorizon = subDevData.has_override ? subDevData.horizon : globalHorizon; @@ -501,14 +598,24 @@ class SpanSidePanel extends HTMLElement { } select.addEventListener("change", () => { this._debounce(`subdev-${devId}`, INPUT_DEBOUNCE_MS, () => { - this._callDomainService("set_subdevice_graph_horizon", { + const data: Record = { subdevice_id: devId, horizon: select.value, - }) + }; + if (cfg.configEntryId) data.config_entry_id = cfg.configEntryId; + this._callDomainService("set_subdevice_graph_horizon", data) .then(() => { this.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); }) - .catch((err: Error) => this._showError(`${err.message ?? err}`)); + .catch((err: Error) => { + console.warn("SPAN Panel: graph horizon service failed", err); + this.errorStore?.add({ + key: "service:graph_horizon", + level: "error", + message: t("error.graph_horizon_failed"), + persistent: false, + }); + }); }); }); row.appendChild(select); @@ -528,13 +635,23 @@ class SpanSidePanel extends HTMLElement { fontSize: "0.85em", }); resetBtn.addEventListener("click", () => { - this._callDomainService("clear_subdevice_graph_horizon", { subdevice_id: devId }) + const data: Record = { subdevice_id: devId }; + if (cfg.configEntryId) data.config_entry_id = cfg.configEntryId; + this._callDomainService("clear_subdevice_graph_horizon", data) .then(() => { select.value = globalHorizon; resetBtn.remove(); this.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); }) - .catch((err: Error) => this._showError(`${err.message ?? err}`)); + .catch((err: Error) => { + console.warn("SPAN Panel: graph horizon service failed", err); + this.errorStore?.add({ + key: "service:graph_horizon", + level: "error", + message: t("error.graph_horizon_failed"), + persistent: false, + }); + }); }); row.appendChild(resetBtn); } @@ -548,6 +665,164 @@ class SpanSidePanel extends HTMLElement { panel.appendChild(body); } + private _buildPanelModeCircuitRow( + uuid: string, + circuit: PanelTopology["circuits"][string], + circuitSetting: CircuitGraphOverride | undefined, + globalHorizon: string, + configEntryId: string | null, + showFavorites: boolean, + favoritePanelDeviceId: string | undefined, + favoriteCircuitUuids: Set | undefined + ): HTMLDivElement { + const row = document.createElement("div"); + row.className = "field-row"; + + const nameLabel = document.createElement("span"); + nameLabel.className = "field-label"; + nameLabel.textContent = circuit.name || uuid; + nameLabel.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;"; + row.appendChild(nameLabel); + + if (showFavorites && favoritePanelDeviceId) { + const heart = this._buildFavoriteHeart(circuit.entities, favoriteCircuitUuids?.has(uuid) ?? false); + if (heart) row.appendChild(heart); + } + + const circuitData = circuitSetting || { horizon: globalHorizon, has_override: false }; + const effectiveHorizon = circuitData.has_override ? circuitData.horizon : globalHorizon; + + const select = document.createElement("select"); + select.dataset.uuid = uuid; + for (const key of Object.keys(GRAPH_HORIZONS)) { + const opt = document.createElement("option"); + opt.value = key; + const labelKey = `horizon.${key}`; + const translated = t(labelKey); + opt.textContent = translated !== labelKey ? translated : key; + if (key === effectiveHorizon) opt.selected = true; + select.appendChild(opt); + } + select.addEventListener("change", () => { + this._debounce(`circuit-${uuid}`, INPUT_DEBOUNCE_MS, () => { + const data: Record = { + circuit_id: uuid, + horizon: select.value, + }; + if (configEntryId) data.config_entry_id = configEntryId; + this._callDomainService("set_circuit_graph_horizon", data) + .then(() => { + this.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); + }) + .catch((err: Error) => { + console.warn("SPAN Panel: graph horizon service failed", err); + this.errorStore?.add({ + key: "service:graph_horizon", + level: "error", + message: t("error.graph_horizon_failed"), + persistent: false, + }); + }); + }); + }); + row.appendChild(select); + + if (circuitData.has_override) { + const resetBtn = document.createElement("button"); + resetBtn.textContent = "\u21ba"; + resetBtn.title = t("sidepanel.reset_to_global"); + Object.assign(resetBtn.style, { + background: "none", + border: "1px solid var(--divider-color, #e0e0e0)", + color: "var(--primary-text-color)", + borderRadius: "4px", + padding: "3px 6px", + cursor: "pointer", + marginLeft: "4px", + fontSize: "0.85em", + }); + resetBtn.addEventListener("click", () => { + const data: Record = { circuit_id: uuid }; + if (configEntryId) data.config_entry_id = configEntryId; + this._callDomainService("clear_circuit_graph_horizon", data) + .then(() => { + select.value = globalHorizon; + resetBtn.remove(); + this.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); + }) + .catch((err: Error) => { + console.warn("SPAN Panel: graph horizon service failed", err); + this.errorStore?.add({ + key: "service:graph_horizon", + level: "error", + message: t("error.graph_horizon_failed"), + persistent: false, + }); + }); + }); + row.appendChild(resetBtn); + } + + return row; + } + + private _renderFavoritesMode(panel: HTMLDivElement): void { + const cfg = this._config as FavoritesModeConfig; + const header = this._createHeader(t("sidepanel.graph_settings"), t("sidepanel.favorites_subtitle")); + panel.appendChild(header); + + const body = document.createElement("div"); + body.className = "panel-body"; + + // List View Columns (frontend setting, view-agnostic) + body.appendChild(this._buildListColumnsSection()); + + // Per-contributing-panel sections: one passive-label section per panel + // that has any favorited circuit. No global default horizon section — + // per the spec, horizons are per-circuit in this mode. + for (const section of cfg.perPanelSections) { + body.appendChild(this._buildFavoritesPanelSection(section)); + } + + panel.appendChild(body); + } + + private _buildFavoritesPanelSection(section: FavoritesPanelSection): HTMLDivElement { + const div = document.createElement("div"); + div.className = "section"; + + const label = document.createElement("div"); + label.className = "section-label"; + label.textContent = section.panelName; + div.appendChild(label); + + const globalHorizon = section.graphSettings?.global_horizon ?? DEFAULT_GRAPH_HORIZON; + const circuitSettings = section.graphSettings?.circuits ?? {}; + + // Every circuit in this panel's topology — not just the favorited + // ones. Each row's heart discriminates active vs. inactive based on + // `section.favoriteCircuitUuids`, so users can un-favorite or newly + // favorite any circuit on this panel without leaving the Favorites + // view. Mirrors the real-panel gear sidebar (`_renderPanelMode`). + const rows = sortedCircuitsForSection(section.topology); + + for (const { uuid, circuit } of rows) { + const row = this._buildPanelModeCircuitRow( + uuid, + circuit, + circuitSettings[uuid], + globalHorizon, + section.configEntryId, + true, // showFavorites: always true inside favorites-mode rows + section.panelDeviceId, + section.favoriteCircuitUuids + ); + div.appendChild(row); + } + + return div; + } + private _renderCircuitMode(panel: HTMLDivElement, cfg: CircuitModeConfig): void { const subtitle = `${escapeHtml(String(cfg.breaker_rating_a))}A \u00b7 ${escapeHtml(String(cfg.voltage))}V \u00b7 Tabs [${escapeHtml(String(cfg.tabs))}]`; const header = this._createHeader(escapeHtml(cfg.name), subtitle); @@ -557,13 +832,10 @@ class SpanSidePanel extends HTMLElement { body.className = "panel-body"; panel.appendChild(body); - const errorEl = document.createElement("div"); - errorEl.className = "error-msg"; - errorEl.id = "error-msg"; - errorEl.style.display = "none"; - body.appendChild(errorEl); - this._renderRelaySection(body, cfg); + if (cfg.showFavorites) { + this._renderFavoriteSection(body, cfg); + } this._renderSheddingSection(body, cfg); this._renderGraphHorizonSection(body, cfg); if (cfg.showMonitoring) { @@ -571,6 +843,193 @@ class SpanSidePanel extends HTMLElement { } } + private _favoriteEntityId(entities: CircuitEntities | undefined): string | null { + return entities?.current ?? entities?.power ?? null; + } + + /** + * Pick any entity_id from a sub-device's entity map. The favorites + * service resolves the entity to its parent SPAN panel + sub-device + * id, so any sensor on the sub-device works. Prefers a sensor. + */ + private _subDeviceFavoriteEntityId(entities: Record | undefined): string | null { + if (!entities) return null; + let fallback: string | null = null; + for (const [entityId, info] of Object.entries(entities)) { + if (info.domain === "sensor") return entityId; + if (!fallback) fallback = entityId; + } + return fallback; + } + + /** + * Build a heart toggle for a sub-device row in panel-mode Graph + * Settings. Returns ``null`` when the sub-device has no entities to + * resolve (favorites services need an entity_id). + */ + private _buildSubDeviceFavoriteHeart(entities: Record | undefined, isFavorite: boolean): HTMLButtonElement | null { + const entityId = this._subDeviceFavoriteEntityId(entities); + if (!entityId) return null; + return this._buildHeartButton(entityId, isFavorite); + } + + /** + * Build the "List view columns" section for the Graph Settings + * panel — a segmented 1/2/3 control backed by localStorage. Clicking + * a button persists the choice and dispatches ``list-columns-changed`` + * up the DOM so ``span-panel.ts`` re-renders the active list view. + */ + private _buildListColumnsSection(): HTMLDivElement { + const section = document.createElement("div"); + section.className = "section"; + + const label = document.createElement("div"); + label.className = "section-label"; + label.textContent = t("sidepanel.list_view_columns"); + section.appendChild(label); + + const row = document.createElement("div"); + row.className = "field-row"; + + const fieldLabel = document.createElement("span"); + fieldLabel.className = "field-label"; + fieldLabel.textContent = t("sidepanel.columns"); + row.appendChild(fieldLabel); + + const current = loadListColumns(); + + const group = document.createElement("div"); + group.className = "unit-toggle"; + for (const n of [1, 2, 3]) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `unit-btn${n === current ? " unit-active" : ""}`; + btn.dataset.columns = String(n); + btn.textContent = String(n); + btn.addEventListener("click", () => { + saveListColumns(n); + for (const other of group.querySelectorAll(".unit-btn")) { + other.classList.toggle("unit-active", other === btn); + } + this.dispatchEvent( + new CustomEvent("list-columns-changed", { + detail: n, + bubbles: true, + composed: true, + }) + ); + }); + group.appendChild(btn); + } + row.appendChild(group); + section.appendChild(row); + return section; + } + + /** + * Build a heart toggle for a circuit row in panel-mode Graph Settings. + * Returns ``null`` when the circuit has no routable sensor entity + * (favorites services need an entity_id to resolve the target). + */ + private _buildFavoriteHeart(entities: CircuitEntities | undefined, isFavorite: boolean): HTMLButtonElement | null { + const entityId = this._favoriteEntityId(entities); + if (!entityId) { + console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"); + return null; + } + return this._buildHeartButton(entityId, isFavorite); + } + + /** + * Shared heart-button builder used by both circuit and sub-device + * panel-mode rows and by the per-target side-panel Favorite section. + * Renders an accessible toggle (``role=switch``, ``aria-checked``, + * ``aria-label``) so screen readers announce both the action and the + * current state — ``title`` alone isn't surfaced. + */ + private _buildHeartButton(entityId: string, isFavorite: boolean): HTMLButtonElement { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = isFavorite ? "fav-heart active" : "fav-heart"; + btn.dataset.role = "fav-heart"; + btn.title = t("sidepanel.save_to_favorites"); + btn.setAttribute("role", "switch"); + btn.setAttribute("aria-checked", String(isFavorite)); + btn.setAttribute("aria-label", t("sidepanel.save_to_favorites")); + + const icon = document.createElement("ha-icon"); + icon.setAttribute("icon", isFavorite ? "mdi:heart" : "mdi:heart-outline"); + btn.appendChild(icon); + + btn.addEventListener("click", (ev: Event) => { + ev.stopPropagation(); + this._toggleFavoriteEntity(btn, icon, entityId).catch(() => { + // error message shown inside _toggleFavoriteEntity + }); + }); + + return btn; + } + + private async _toggleFavoriteEntity(btn: HTMLButtonElement, icon: HTMLElement, entityId: string): Promise { + if (!this._hass) return; + const wasActive = btn.classList.contains("active"); + const nextActive = !wasActive; + // Optimistically flip class, icon, and aria-checked; roll back on error. + btn.classList.toggle("active", nextActive); + icon.setAttribute("icon", nextActive ? "mdi:heart" : "mdi:heart-outline"); + btn.setAttribute("aria-checked", String(nextActive)); + try { + if (nextActive) { + await addFavorite(this._hass, entityId); + } else { + await removeFavorite(this._hass, entityId); + } + } catch (err) { + btn.classList.toggle("active", wasActive); + icon.setAttribute("icon", wasActive ? "mdi:heart" : "mdi:heart-outline"); + btn.setAttribute("aria-checked", String(wasActive)); + console.warn("SPAN Panel: favorite toggle failed", err); + this.errorStore?.add({ + key: "service:favorites", + level: "error", + message: t("error.favorites_toggle_failed"), + persistent: false, + }); + throw err; + } + } + + private _renderFavoriteSection(body: HTMLDivElement, cfg: CircuitModeConfig): void { + const entityId = this._favoriteEntityId(cfg.entities); + if (!entityId) return; + this._appendFavoriteHeartSection(body, entityId, cfg.isFavorite === true); + } + + /** + * Build a Favorite section with a heart icon (filled = favorited, + * outlined = not). Used in both the per-circuit and per-sub-device + * side panels. A heart deliberately avoids the visual confusion of + * placing an ha-switch directly under the breaker relay switch. + */ + private _appendFavoriteHeartSection(body: HTMLDivElement, entityId: string, isFavorite: boolean): void { + const section = document.createElement("div"); + section.className = "section"; + section.innerHTML = ``; + + const row = document.createElement("div"); + row.className = "field-row"; + + const label = document.createElement("span"); + label.className = "field-label"; + label.textContent = t("sidepanel.save_to_favorites"); + + row.appendChild(label); + row.appendChild(this._buildHeartButton(entityId, isFavorite)); + section.appendChild(row); + body.appendChild(section); + } + private _renderSubDeviceMode(panel: HTMLDivElement, cfg: SubDeviceModeConfig): void { const header = this._createHeader(escapeHtml(cfg.name), escapeHtml(cfg.deviceType)); panel.appendChild(header); @@ -579,15 +1038,18 @@ class SpanSidePanel extends HTMLElement { body.className = "panel-body"; panel.appendChild(body); - const errorEl = document.createElement("div"); - errorEl.className = "error-msg"; - errorEl.id = "error-msg"; - errorEl.style.display = "none"; - body.appendChild(errorEl); - + if (cfg.showFavorites) { + this._renderSubDeviceFavoriteSection(body, cfg); + } this._renderSubDeviceHorizonSection(body, cfg); } + private _renderSubDeviceFavoriteSection(body: HTMLDivElement, cfg: SubDeviceModeConfig): void { + const entityId = this._subDeviceFavoriteEntityId(cfg.entities); + if (!entityId) return; + this._appendFavoriteHeartSection(body, entityId, cfg.isFavorite === true); + } + private _renderSubDeviceHorizonSection(body: HTMLDivElement, cfg: SubDeviceModeConfig): void { const section = document.createElement("div"); section.className = "section"; @@ -633,23 +1095,42 @@ class SpanSidePanel extends HTMLElement { if (btn.classList.contains("active")) return; const subDeviceId = cfg.subDeviceId; + // Without ``config_entry_id`` the backend's _get_horizon_manager + // falls back to the FIRST loaded SPAN entry's manager — wrong + // panel when more than one is configured. The panel-mode list + // and circuit-mode side panel both pass it; thread it here too. + const baseData: Record = { subdevice_id: subDeviceId }; + if (cfg.configEntryId) baseData.config_entry_id = cfg.configEntryId; if (key === "global") { updateSegmentStates("global"); - this._callDomainService("clear_subdevice_graph_horizon", { subdevice_id: subDeviceId }) + this._callDomainService("clear_subdevice_graph_horizon", baseData) .then(() => { this.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); }) - .catch((err: Error) => this._showError(`${t("sidepanel.clear_graph_horizon_failed")} ${err.message ?? err}`)); + .catch((err: Error) => { + console.warn("SPAN Panel: graph horizon service failed", err); + this.errorStore?.add({ + key: "service:graph_horizon", + level: "error", + message: t("error.graph_horizon_failed"), + persistent: false, + }); + }); } else { updateSegmentStates(key); - this._callDomainService("set_subdevice_graph_horizon", { - subdevice_id: subDeviceId, - horizon: key, - }) + this._callDomainService("set_subdevice_graph_horizon", { ...baseData, horizon: key }) .then(() => { this.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); }) - .catch((err: Error) => this._showError(`${t("sidepanel.graph_horizon_failed")} ${err.message ?? err}`)); + .catch((err: Error) => { + console.warn("SPAN Panel: graph horizon service failed", err); + this.errorStore?.add({ + key: "service:graph_horizon", + level: "error", + message: t("error.graph_horizon_failed"), + persistent: false, + }); + }); } }); @@ -705,9 +1186,15 @@ class SpanSidePanel extends HTMLElement { toggle.addEventListener("change", () => { const isOn = toggle.hasAttribute("checked") || toggle.checked; - this._callService("switch", isOn ? "turn_on" : "turn_off", { entity_id: entityId }).catch((err: Error) => - this._showError(`${t("sidepanel.relay_failed")} ${err.message ?? err}`) - ); + this._callService("switch", isOn ? "turn_on" : "turn_off", { entity_id: entityId }).catch((err: Error) => { + console.warn("SPAN Panel: relay toggle failed", err); + this.errorStore?.add({ + key: "service:relay", + level: "error", + message: t("error.relay_failed"), + persistent: false, + }); + }); }); row.appendChild(label); @@ -748,10 +1235,15 @@ class SpanSidePanel extends HTMLElement { } selectEl.addEventListener("change", () => { - this._callService("select", "select_option", { - entity_id: entityId, - option: selectEl.value, - }).catch((err: Error) => this._showError(`${t("sidepanel.shedding_failed")} ${err.message ?? err}`)); + this._callService("select", "select_option", { entity_id: entityId, option: selectEl.value }).catch((err: Error) => { + console.warn("SPAN Panel: shedding update failed", err); + this.errorStore?.add({ + key: "service:shedding", + level: "error", + message: t("error.shedding_failed"), + persistent: false, + }); + }); }); row.appendChild(label); @@ -808,23 +1300,41 @@ class SpanSidePanel extends HTMLElement { if (btn.classList.contains("active")) return; const circuitId = cfg.uuid; + const baseData: Record = { circuit_id: circuitId }; + if (cfg.configEntryId) baseData.config_entry_id = cfg.configEntryId; if (key === "global") { updateSegmentStates("global"); - this._callDomainService("clear_circuit_graph_horizon", { circuit_id: circuitId }) + this._callDomainService("clear_circuit_graph_horizon", baseData) .then(() => { this.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); }) - .catch((err: Error) => this._showError(`${t("sidepanel.clear_graph_horizon_failed")} ${err.message ?? err}`)); + .catch((err: Error) => { + console.warn("SPAN Panel: graph horizon service failed", err); + this.errorStore?.add({ + key: "service:graph_horizon", + level: "error", + message: t("error.graph_horizon_failed"), + persistent: false, + }); + }); } else { updateSegmentStates(key); this._callDomainService("set_circuit_graph_horizon", { - circuit_id: circuitId, + ...baseData, horizon: key, }) .then(() => { this.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); }) - .catch((err: Error) => this._showError(`${t("sidepanel.graph_horizon_failed")} ${err.message ?? err}`)); + .catch((err: Error) => { + console.warn("SPAN Panel: graph horizon service failed", err); + this.errorStore?.add({ + key: "service:graph_horizon", + level: "error", + message: t("error.graph_horizon_failed"), + persistent: false, + }); + }); } }); @@ -899,10 +1409,20 @@ class SpanSidePanel extends HTMLElement { const checked = enableToggle.checked; detailsWrap.style.display = checked ? "block" : "none"; const entityId = cfg.entities?.power || cfg.uuid; - this._callDomainService("set_circuit_threshold", { + const data: Record = { circuit_id: entityId, monitoring_enabled: checked, - }).catch((err: Error) => this._showError(`${t("sidepanel.monitoring_toggle_failed")} ${err.message ?? err}`)); + }; + if (cfg.configEntryId) data.config_entry_id = cfg.configEntryId; + this._callDomainService("set_circuit_threshold", data).catch((err: Error) => { + console.warn("SPAN Panel: monitoring update failed", err); + this.errorStore?.add({ + key: "service:monitoring", + level: "error", + message: t("error.threshold_failed"), + persistent: false, + }); + }); }); // Event: radio change @@ -913,9 +1433,17 @@ class SpanSidePanel extends HTMLElement { thresholdsWrap.style.display = isCustom ? "block" : "none"; if (!isCustom && radio.checked) { const entityId = cfg.entities?.power || cfg.uuid; - this._callDomainService("clear_circuit_threshold", { circuit_id: entityId }).catch((err: Error) => - this._showError(`${t("sidepanel.clear_monitoring_failed")} ${err.message ?? err}`) - ); + const data: Record = { circuit_id: entityId }; + if (cfg.configEntryId) data.config_entry_id = cfg.configEntryId; + this._callDomainService("clear_circuit_threshold", data).catch((err: Error) => { + console.warn("SPAN Panel: monitoring update failed", err); + this.errorStore?.add({ + key: "service:monitoring", + level: "error", + message: t("error.threshold_failed"), + persistent: false, + }); + }); } }); } @@ -947,13 +1475,23 @@ class SpanSidePanel extends HTMLElement { const windowM = shadow.querySelector('[data-role="threshold-window-m"]'); const cooldownM = shadow.querySelector('[data-role="threshold-cooldown-m"]'); const entityId = cfg.entities?.power || cfg.uuid; - this._callDomainService("set_circuit_threshold", { + const data: Record = { circuit_id: entityId, continuous_threshold_pct: continuous ? Number(continuous.value) : undefined, spike_threshold_pct: spike ? Number(spike.value) : undefined, window_duration_m: windowM ? Number(windowM.value) : undefined, cooldown_duration_m: cooldownM ? Number(cooldownM.value) : undefined, - }).catch((err: Error) => this._showError(`${t("sidepanel.save_threshold_failed")} ${err.message ?? err}`)); + }; + if (cfg.configEntryId) data.config_entry_id = cfg.configEntryId; + this._callDomainService("set_circuit_threshold", data).catch((err: Error) => { + console.warn("SPAN Panel: monitoring update failed", err); + this.errorStore?.add({ + key: "service:monitoring", + level: "error", + message: t("error.threshold_failed"), + persistent: false, + }); + }); }); }); @@ -1005,12 +1543,22 @@ class SpanSidePanel extends HTMLElement { const continuous = shadow.querySelector('[data-role="threshold-continuous"]'); const spike = shadow.querySelector('[data-role="threshold-spike"]'); const windowM = shadow.querySelector('[data-role="threshold-window-m"]'); - this._callDomainService("set_circuit_threshold", { + const data: Record = { circuit_id: cfg.uuid, continuous_threshold_pct: continuous ? Number(continuous.value) : undefined, spike_threshold_pct: spike ? Number(spike.value) : undefined, window_duration_m: windowM ? Number(windowM.value) : undefined, - }).catch((err: Error) => this._showError(`${t("sidepanel.save_threshold_failed")} ${err.message ?? err}`)); + }; + if (cfg.configEntryId) data.config_entry_id = cfg.configEntryId; + this._callDomainService("set_circuit_threshold", data).catch((err: Error) => { + console.warn("SPAN Panel: monitoring update failed", err); + this.errorStore?.add({ + key: "service:monitoring", + level: "error", + message: t("error.threshold_failed"), + persistent: false, + }); + }); }); }); } @@ -1026,8 +1574,8 @@ class SpanSidePanel extends HTMLElement { if (!this._config || this._config.panelMode) return; const cfg = this._config; - // Sub-device mode has no live-updating fields - if (cfg.subDeviceMode) return; + // Sub-device and favorites modes have no live-updating fields + if (cfg.subDeviceMode || cfg.favoritesMode) return; // Update relay toggle if (cfg.entities?.switch) { @@ -1069,19 +1617,6 @@ class SpanSidePanel extends HTMLElement { }); } - // ── Error display ─────────────────────────────────────────────────── - - private _showError(message: string): void { - const el = this.shadowRoot?.getElementById("error-msg"); - if (el) { - el.textContent = message; - el.style.display = "block"; - setTimeout(() => { - el.style.display = "none"; - }, ERROR_DISPLAY_MS); - } - } - // ── Debounce ──────────────────────────────────────────────────────── private _debounce(key: string, ms: number, fn: () => void): void { diff --git a/src/core/sub-device-renderer.ts b/src/core/sub-device-renderer.ts index 0d7bf0b..b79b907 100644 --- a/src/core/sub-device-renderer.ts +++ b/src/core/sub-device-renderer.ts @@ -66,7 +66,7 @@ export function buildSubDevicesHTML(topology: PanelTopology, hass: HomeAssistant ${escapeHtml(label)} ${escapeHtml(sub.name || "")} ${powerEid ? `${formatPowerSigned(powerW)} ${formatPowerUnit(powerW)}` : ""} -
diff --git a/src/helpers/list-columns.ts b/src/helpers/list-columns.ts new file mode 100644 index 0000000..802bb9c --- /dev/null +++ b/src/helpers/list-columns.ts @@ -0,0 +1,27 @@ +const LIST_COLUMNS_KEY = "span_panel_list_columns"; + +/** + * Read the user's preferred number of list-view columns from + * localStorage. Returns 1 when the setting is unset or invalid so the + * list falls back to the historical single-column stack. + */ +export function loadListColumns(): number { + try { + const raw = localStorage.getItem(LIST_COLUMNS_KEY); + if (!raw) return 1; + const n = parseInt(raw, 10); + if (n === 1 || n === 2 || n === 3) return n; + return 1; + } catch { + return 1; + } +} + +/** Persist the user's preferred number of list-view columns. */ +export function saveListColumns(n: number): void { + try { + localStorage.setItem(LIST_COLUMNS_KEY, String(n)); + } catch { + // non-fatal + } +} diff --git a/src/helpers/selector.ts b/src/helpers/selector.ts new file mode 100644 index 0000000..a3e0bc4 --- /dev/null +++ b/src/helpers/selector.ts @@ -0,0 +1,16 @@ +/** + * Escape a value for safe use inside a CSS attribute selector string. + * Circuit UUIDs and HA entity IDs are hex/alphanumeric in practice, but + * the Favorites view's composite ids use ``|`` as a separator and any + * user-surfaced identifier could in principle contain characters that + * need escaping. Using ``CSS.escape`` keeps ``querySelector`` calls + * correct regardless of what the identifier actually contains. + */ +export function attrSelectorValue(value: string): string { + if (typeof (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS?.escape === "function") { + return CSS.escape(value); + } + // Minimal fallback: escape the characters that are unsafe inside + // a double-quoted attribute selector. Production browsers ship CSS.escape. + return value.replace(/["\\]/g, "\\$&"); +} diff --git a/src/i18n.ts b/src/i18n.ts index cfe9673..53131d3 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -72,6 +72,23 @@ const translations: Record> = { "error.prefix": "Error:", "error.failed_save": "Failed to save", "error.failed": "Failed", + "error.panel_offline": "SPAN Panel unreachable", + "error.panel_reconnected": "SPAN Panel reconnected", + "error.panel_offline_named": "{name} unreachable", + "error.panel_reconnected_named": "{name} reconnected", + "error.discovery_failed": "Unable to connect to SPAN Panel", + "error.relay_failed": "Unable to toggle relay", + "error.shedding_failed": "Unable to update shedding priority", + "error.threshold_failed": "Unable to save threshold", + "error.graph_horizon_failed": "Unable to update graph time horizon", + "error.favorites_fetch_failed": "Unable to load favorites", + "error.favorites_toggle_failed": "Unable to update favorite", + "error.history_failed": "Unable to load historical data", + "error.monitoring_failed": "Unable to load monitoring status", + "error.graph_settings_failed": "Unable to load graph settings", + "error.areas_failed": "Area assignments may be out of sync", + "error.retry": "Retry", + "card.connecting": "Connecting to SPAN Panel...", // Settings tab "settings.heading": "Settings", @@ -92,8 +109,6 @@ const translations: Record> = { "settings.col.circuit": "Circuit", "settings.col.scale": "Scale", "sidepanel.graph_horizon": "Graph Time Horizon", - "sidepanel.graph_horizon_failed": "Graph horizon update failed:", - "sidepanel.clear_graph_horizon_failed": "Clear graph horizon failed:", // Header "header.default_name": "SPAN Panel", @@ -127,16 +142,17 @@ const translations: Record> = { // Side panel "sidepanel.graph_settings": "Graph Settings", "sidepanel.global_defaults": "Global defaults for all circuits", + "sidepanel.favorites_subtitle": "Favorites", "sidepanel.global_default": "Global Default", + "sidepanel.list_view_columns": "List View Columns", + "sidepanel.columns": "Columns", "sidepanel.circuit_scales": "Circuit Graph Scales", "sidepanel.subdevice_scales": "Sub-Device Graph Scales", "sidepanel.reset_to_global": "Reset to global default", "sidepanel.relay": "Relay", "sidepanel.breaker": "Breaker", - "sidepanel.relay_failed": "Relay toggle failed:", "sidepanel.shedding_priority": "Shedding Priority", "sidepanel.priority_label": "Priority", - "sidepanel.shedding_failed": "Shedding update failed:", "sidepanel.monitoring": "Monitoring", "sidepanel.global": "Global", "sidepanel.custom": "Custom", @@ -144,9 +160,11 @@ const translations: Record> = { "sidepanel.spike_pct": "Spike %", "sidepanel.window_duration": "Window duration", "sidepanel.cooldown": "Cooldown", - "sidepanel.monitoring_toggle_failed": "Monitoring toggle failed:", - "sidepanel.clear_monitoring_failed": "Clear monitoring failed:", - "sidepanel.save_threshold_failed": "Save threshold failed:", + "sidepanel.favorite": "Favorite", + "sidepanel.save_to_favorites": "Save to favorites", + + // Favorites pseudo-panel + "panel.favorites": "Favorites", // Monitoring status bar "status.monitoring": "Monitoring", @@ -162,7 +180,6 @@ const translations: Record> = { // Card "card.no_device": "Open the card editor and select your SPAN Panel device.", "card.device_not_found": "Panel device not found. Check device_id in card config.", - "card.loading": "Loading...", "card.topology_error": "Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.", "card.panel_size_error": "Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.", @@ -250,6 +267,23 @@ const translations: Record> = { "error.prefix": "Error:", "error.failed_save": "Error al guardar", "error.failed": "Fall\u00f3", + "error.panel_offline": "SPAN Panel inaccesible", + "error.panel_reconnected": "SPAN Panel reconectado", + "error.panel_offline_named": "{name} inaccesible", + "error.panel_reconnected_named": "{name} reconectado", + "error.discovery_failed": "No se puede conectar al SPAN Panel", + "error.relay_failed": "No se pudo cambiar el rel\u00e9", + "error.shedding_failed": "No se pudo actualizar la prioridad de desconexi\u00f3n", + "error.threshold_failed": "No se pudo guardar el umbral", + "error.graph_horizon_failed": "No se pudo actualizar el horizonte temporal del gr\u00e1fico", + "error.favorites_fetch_failed": "No se pudieron cargar los favoritos", + "error.favorites_toggle_failed": "No se pudo actualizar el favorito", + "error.history_failed": "No se pudieron cargar los datos hist\u00f3ricos", + "error.monitoring_failed": "No se pudo cargar el estado de monitoreo", + "error.graph_settings_failed": "No se pudo cargar la configuraci\u00f3n del gr\u00e1fico", + "error.areas_failed": "Las asignaciones de \u00e1reas pueden estar desincronizadas", + "error.retry": "Reintentar", + "card.connecting": "Conectando al SPAN Panel...", "settings.heading": "Configuraci\u00f3n", "settings.description": "La configuraci\u00f3n general de la integraci\u00f3n (nombres de entidades, prefijo de dispositivo, n\u00fameros de circuito) se administra a trav\u00e9s del flujo de opciones de la integraci\u00f3n.", @@ -267,8 +301,6 @@ const translations: Record> = { "settings.col.circuit": "Circuit", "settings.col.scale": "Scale", "sidepanel.graph_horizon": "Graph Time Horizon", - "sidepanel.graph_horizon_failed": "Graph horizon update failed:", - "sidepanel.clear_graph_horizon_failed": "Clear graph horizon failed:", "header.default_name": "SPAN Panel", "header.monitoring_settings": "Configuraci\u00f3n de monitoreo del panel", "header.graph_settings": "Configuraci\u00f3n del horizonte temporal del gr\u00e1fico", @@ -294,16 +326,17 @@ const translations: Record> = { "subdevice.power": "Potencia", "sidepanel.graph_settings": "Configuraci\u00f3n de Gr\u00e1ficos", "sidepanel.global_defaults": "Valores predeterminados globales para todos los circuitos", + "sidepanel.favorites_subtitle": "Favoritos", "sidepanel.global_default": "Predeterminado Global", + "sidepanel.list_view_columns": "Columnas de la lista", + "sidepanel.columns": "Columnas", "sidepanel.circuit_scales": "Escalas de Gr\u00e1ficos de Circuitos", "sidepanel.subdevice_scales": "Escalas de Gr\u00e1ficos de Sub-Dispositivos", "sidepanel.reset_to_global": "Restablecer al valor global", "sidepanel.relay": "Rel\u00e9", "sidepanel.breaker": "Interruptor", - "sidepanel.relay_failed": "Error al cambiar rel\u00e9:", "sidepanel.shedding_priority": "Prioridad de Desconexci\u00f3n", "sidepanel.priority_label": "Prioridad", - "sidepanel.shedding_failed": "Error al actualizar desconexci\u00f3n:", "sidepanel.monitoring": "Monitoreo", "sidepanel.global": "Global", "sidepanel.custom": "Personalizado", @@ -311,9 +344,9 @@ const translations: Record> = { "sidepanel.spike_pct": "Pico %", "sidepanel.window_duration": "Duraci\u00f3n de ventana", "sidepanel.cooldown": "Enfriamiento", - "sidepanel.monitoring_toggle_failed": "Error al cambiar monitoreo:", - "sidepanel.clear_monitoring_failed": "Error al limpiar monitoreo:", - "sidepanel.save_threshold_failed": "Error al guardar umbral:", + "sidepanel.favorite": "Favorito", + "sidepanel.save_to_favorites": "Guardar en favoritos", + "panel.favorites": "Favoritos", "status.monitoring": "Monitoreo", "status.circuits": "circuitos", "status.mains": "alimentaci\u00f3n", @@ -325,7 +358,6 @@ const translations: Record> = { "status.overrides": "anulaciones", "card.no_device": "Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.", "card.device_not_found": "Dispositivo de panel no encontrado. Verifique device_id en la configuraci\u00f3n de la tarjeta.", - "card.loading": "Cargando...", "card.topology_error": "La respuesta de topolog\u00eda no contiene panel_size y no se encontraron circuitos. Actualice la integraci\u00f3n SPAN Panel.", "card.panel_size_error": "No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integraci\u00f3n SPAN Panel.", "editor.panel_label": "SPAN Panel", @@ -409,6 +441,23 @@ const translations: Record> = { "error.prefix": "Erreur :", "error.failed_save": "\u00c9chec de la sauvegarde", "error.failed": "\u00c9chou\u00e9", + "error.panel_offline": "SPAN Panel inaccessible", + "error.panel_reconnected": "SPAN Panel reconnect\u00e9", + "error.panel_offline_named": "{name} inaccessible", + "error.panel_reconnected_named": "{name} reconnect\u00e9", + "error.discovery_failed": "Impossible de se connecter au SPAN Panel", + "error.relay_failed": "Impossible de basculer le relais", + "error.shedding_failed": "Impossible de mettre \u00e0 jour la priorit\u00e9 de d\u00e9lestage", + "error.threshold_failed": "Impossible d'enregistrer le seuil", + "error.graph_horizon_failed": "Impossible de mettre \u00e0 jour l'horizon temporel du graphique", + "error.favorites_fetch_failed": "Impossible de charger les favoris", + "error.favorites_toggle_failed": "Impossible de mettre \u00e0 jour le favori", + "error.history_failed": "Impossible de charger les donn\u00e9es historiques", + "error.monitoring_failed": "Impossible de charger l'\u00e9tat de surveillance", + "error.graph_settings_failed": "Impossible de charger les param\u00e8tres du graphique", + "error.areas_failed": "Les affectations de zones peuvent \u00eatre d\u00e9synchronis\u00e9es", + "error.retry": "R\u00e9essayer", + "card.connecting": "Connexion au SPAN Panel...", "settings.heading": "Param\u00e8tres", "settings.description": "Les param\u00e8tres g\u00e9n\u00e9raux de l'int\u00e9gration (noms d'entit\u00e9s, pr\u00e9fixe de l'appareil, num\u00e9ros de circuit) sont g\u00e9r\u00e9s via le flux d'options de l'int\u00e9gration.", @@ -426,8 +475,6 @@ const translations: Record> = { "settings.col.circuit": "Circuit", "settings.col.scale": "Scale", "sidepanel.graph_horizon": "Graph Time Horizon", - "sidepanel.graph_horizon_failed": "Graph horizon update failed:", - "sidepanel.clear_graph_horizon_failed": "Clear graph horizon failed:", "header.default_name": "SPAN Panel", "header.monitoring_settings": "Param\u00e8tres de surveillance du panneau", "header.graph_settings": "Param\u00e8tres d'horizon temporel du graphique", @@ -453,16 +500,17 @@ const translations: Record> = { "subdevice.power": "Puissance", "sidepanel.graph_settings": "Param\u00e8tres des Graphiques", "sidepanel.global_defaults": "Valeurs par d\u00e9faut globales pour tous les circuits", + "sidepanel.favorites_subtitle": "Favoris", "sidepanel.global_default": "D\u00e9faut Global", + "sidepanel.list_view_columns": "Colonnes de la liste", + "sidepanel.columns": "Colonnes", "sidepanel.circuit_scales": "\u00c9chelles des Graphiques de Circuits", "sidepanel.subdevice_scales": "\u00c9chelles des Graphiques de Sous-Appareils", "sidepanel.reset_to_global": "R\u00e9initialiser \u00e0 la valeur globale", "sidepanel.relay": "Relais", "sidepanel.breaker": "Disjoncteur", - "sidepanel.relay_failed": "\u00c9chec du basculement du relais :", "sidepanel.shedding_priority": "Priorit\u00e9 de D\u00e9lestage", "sidepanel.priority_label": "Priorit\u00e9", - "sidepanel.shedding_failed": "\u00c9chec de la mise \u00e0 jour du d\u00e9lestage :", "sidepanel.monitoring": "Surveillance", "sidepanel.global": "Global", "sidepanel.custom": "Personnalis\u00e9", @@ -470,9 +518,9 @@ const translations: Record> = { "sidepanel.spike_pct": "Pic %", "sidepanel.window_duration": "Dur\u00e9e de fen\u00eatre", "sidepanel.cooldown": "Refroidissement", - "sidepanel.monitoring_toggle_failed": "\u00c9chec du basculement de surveillance :", - "sidepanel.clear_monitoring_failed": "\u00c9chec de l'effacement de surveillance :", - "sidepanel.save_threshold_failed": "\u00c9chec de la sauvegarde du seuil :", + "sidepanel.favorite": "Favori", + "sidepanel.save_to_favorites": "Enregistrer dans les favoris", + "panel.favorites": "Favoris", "status.monitoring": "Surveillance", "status.circuits": "circuits", "status.mains": "alimentation", @@ -484,7 +532,6 @@ const translations: Record> = { "status.overrides": "remplacements", "card.no_device": "Ouvrez l'\u00e9diteur de carte et s\u00e9lectionnez votre appareil SPAN Panel.", "card.device_not_found": "Appareil de panneau introuvable. V\u00e9rifiez device_id dans la configuration de la carte.", - "card.loading": "Chargement...", "card.topology_error": "La r\u00e9ponse de topologie ne contient pas panel_size et aucun circuit trouv\u00e9. Mettez \u00e0 jour l'int\u00e9gration SPAN Panel.", "card.panel_size_error": @@ -570,6 +617,24 @@ const translations: Record> = { "error.prefix": "\u30a8\u30e9\u30fc:", "error.failed_save": "\u4fdd\u5b58\u306b\u5931\u6557", "error.failed": "\u5931\u6557", + "error.panel_offline": "SPAN\u30d1\u30cd\u30eb\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093", + "error.panel_reconnected": "SPAN\u30d1\u30cd\u30eb\u304c\u518d\u63a5\u7d9a\u3055\u308c\u307e\u3057\u305f", + "error.panel_offline_named": "{name}\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093", + "error.panel_reconnected_named": "{name}\u304c\u518d\u63a5\u7d9a\u3055\u308c\u307e\u3057\u305f", + "error.discovery_failed": "SPAN\u30d1\u30cd\u30eb\u3078\u306e\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "error.relay_failed": "\u30ea\u30ec\u30fc\u306e\u5207\u308a\u66ff\u3048\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "error.shedding_failed": "\u30b7\u30a7\u30c7\u30a3\u30f3\u30b0\u512a\u5148\u5ea6\u306e\u66f4\u65b0\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "error.threshold_failed": "\u3057\u304d\u3044\u5024\u306e\u4fdd\u5b58\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "error.graph_horizon_failed": "\u30b0\u30e9\u30d5\u306e\u6642\u9593\u7bc4\u56f2\u306e\u66f4\u65b0\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "error.favorites_fetch_failed": "\u304a\u6c17\u306b\u5165\u308a\u306e\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "error.favorites_toggle_failed": "\u304a\u6c17\u306b\u5165\u308a\u306e\u66f4\u65b0\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "error.history_failed": "\u5c65\u6b74\u30c7\u30fc\u30bf\u306e\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "error.monitoring_failed": "\u76e3\u8996\u30b9\u30c6\u30fc\u30bf\u30b9\u306e\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "error.graph_settings_failed": "\u30b0\u30e9\u30d5\u8a2d\u5b9a\u306e\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "error.areas_failed": + "\u30a8\u30ea\u30a2\u5272\u308a\u5f53\u3066\u304c\u540c\u671f\u3055\u308c\u3066\u3044\u306a\u3044\u53ef\u80fd\u6027\u304c\u3042\u308a\u307e\u3059", + "error.retry": "\u518d\u8a66\u884c", + "card.connecting": "SPAN\u30d1\u30cd\u30eb\u306b\u63a5\u7d9a\u4e2d...", "settings.heading": "\u8a2d\u5b9a", "settings.description": "\u7d71\u5408\u306e\u4e00\u822c\u8a2d\u5b9a\uff08\u30a8\u30f3\u30c6\u30a3\u30c6\u30a3\u540d\u3001\u30c7\u30d0\u30a4\u30b9\u30d7\u30ec\u30d5\u30a3\u30c3\u30af\u30b9\u3001\u56de\u8def\u756a\u53f7\uff09\u306f\u7d71\u5408\u306e\u30aa\u30d7\u30b7\u30e7\u30f3\u30d5\u30ed\u30fc\u3067\u7ba1\u7406\u3055\u308c\u307e\u3059\u3002", @@ -587,8 +652,6 @@ const translations: Record> = { "settings.col.circuit": "Circuit", "settings.col.scale": "Scale", "sidepanel.graph_horizon": "Graph Time Horizon", - "sidepanel.graph_horizon_failed": "Graph horizon update failed:", - "sidepanel.clear_graph_horizon_failed": "Clear graph horizon failed:", "header.default_name": "SPAN Panel", "header.monitoring_settings": "\u30d1\u30cd\u30eb\u30e2\u30cb\u30bf\u30ea\u30f3\u30b0\u8a2d\u5b9a", "header.graph_settings": "\u30b0\u30e9\u30d5\u6642\u9593\u7bc4\u56f2\u8a2d\u5b9a", @@ -614,16 +677,17 @@ const translations: Record> = { "subdevice.power": "\u96fb\u529b", "sidepanel.graph_settings": "\u30b0\u30e9\u30d5\u8a2d\u5b9a", "sidepanel.global_defaults": "\u5168\u56de\u8def\u306e\u30b0\u30ed\u30fc\u30d0\u30eb\u30c7\u30d5\u30a9\u30eb\u30c8", + "sidepanel.favorites_subtitle": "\u304a\u6c17\u306b\u5165\u308a", "sidepanel.global_default": "\u30b0\u30ed\u30fc\u30d0\u30eb\u30c7\u30d5\u30a9\u30eb\u30c8", + "sidepanel.list_view_columns": "\u30ea\u30b9\u30c8\u8868\u793a\u306e\u5217\u6570", + "sidepanel.columns": "\u5217", "sidepanel.circuit_scales": "\u56de\u8def\u30b0\u30e9\u30d5\u30b9\u30b1\u30fc\u30eb", "sidepanel.subdevice_scales": "\u30b5\u30d6\u30c7\u30d0\u30a4\u30b9\u30b0\u30e9\u30d5\u30b9\u30b1\u30fc\u30eb", "sidepanel.reset_to_global": "\u30b0\u30ed\u30fc\u30d0\u30eb\u306b\u30ea\u30bb\u30c3\u30c8", "sidepanel.relay": "\u30ea\u30ec\u30fc", "sidepanel.breaker": "\u30d6\u30ec\u30fc\u30ab\u30fc", - "sidepanel.relay_failed": "\u30ea\u30ec\u30fc\u5207\u308a\u66ff\u3048\u5931\u6557:", "sidepanel.shedding_priority": "\u30b7\u30a7\u30c7\u30a3\u30f3\u30b0\u512a\u5148\u5ea6", "sidepanel.priority_label": "\u512a\u5148\u5ea6", - "sidepanel.shedding_failed": "\u30b7\u30a7\u30c7\u30a3\u30f3\u30b0\u66f4\u65b0\u5931\u6557:", "sidepanel.monitoring": "\u30e2\u30cb\u30bf\u30ea\u30f3\u30b0", "sidepanel.global": "\u30b0\u30ed\u30fc\u30d0\u30eb", "sidepanel.custom": "\u30ab\u30b9\u30bf\u30e0", @@ -631,9 +695,9 @@ const translations: Record> = { "sidepanel.spike_pct": "\u30b9\u30d1\u30a4\u30af %", "sidepanel.window_duration": "\u30a6\u30a3\u30f3\u30c9\u30a6\u6642\u9593", "sidepanel.cooldown": "\u30af\u30fc\u30eb\u30c0\u30a6\u30f3", - "sidepanel.monitoring_toggle_failed": "\u30e2\u30cb\u30bf\u30ea\u30f3\u30b0\u5207\u308a\u66ff\u3048\u5931\u6557:", - "sidepanel.clear_monitoring_failed": "\u30e2\u30cb\u30bf\u30ea\u30f3\u30b0\u30af\u30ea\u30a2\u5931\u6557:", - "sidepanel.save_threshold_failed": "\u3057\u304d\u3044\u5024\u4fdd\u5b58\u5931\u6557:", + "sidepanel.favorite": "\u304a\u6c17\u306b\u5165\u308a", + "sidepanel.save_to_favorites": "\u304a\u6c17\u306b\u5165\u308a\u306b\u4fdd\u5b58", + "panel.favorites": "\u304a\u6c17\u306b\u5165\u308a", "status.monitoring": "\u30e2\u30cb\u30bf\u30ea\u30f3\u30b0", "status.circuits": "\u56de\u8def", "status.mains": "\u4e3b\u96fb\u6e90", @@ -647,7 +711,6 @@ const translations: Record> = { "\u30ab\u30fc\u30c9\u30a8\u30c7\u30a3\u30bf\u3092\u958b\u3044\u3066SPAN Panel\u30c7\u30d0\u30a4\u30b9\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "card.device_not_found": "\u30d1\u30cd\u30eb\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002\u30ab\u30fc\u30c9\u8a2d\u5b9a\u306edevice_id\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", - "card.loading": "\u8aad\u307f\u8fbc\u307f\u4e2d...", "card.topology_error": "\u30c8\u30dd\u30ed\u30b8\u30fc\u5fdc\u7b54\u306bpanel_size\u304c\u306a\u304f\u3001\u56de\u8def\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002SPAN Panel\u7d71\u5408\u3092\u66f4\u65b0\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "card.panel_size_error": @@ -733,6 +796,23 @@ const translations: Record> = { "error.prefix": "Erro:", "error.failed_save": "Falha ao salvar", "error.failed": "Falhou", + "error.panel_offline": "SPAN Panel inacess\u00edvel", + "error.panel_reconnected": "SPAN Panel reconectado", + "error.panel_offline_named": "{name} inacess\u00edvel", + "error.panel_reconnected_named": "{name} reconectado", + "error.discovery_failed": "N\u00e3o foi poss\u00edvel conectar ao SPAN Panel", + "error.relay_failed": "N\u00e3o foi poss\u00edvel alternar o rel\u00e9", + "error.shedding_failed": "N\u00e3o foi poss\u00edvel atualizar a prioridade de desligamento", + "error.threshold_failed": "N\u00e3o foi poss\u00edvel salvar o limite", + "error.graph_horizon_failed": "N\u00e3o foi poss\u00edvel atualizar o horizonte temporal do gr\u00e1fico", + "error.favorites_fetch_failed": "N\u00e3o foi poss\u00edvel carregar os favoritos", + "error.favorites_toggle_failed": "N\u00e3o foi poss\u00edvel atualizar o favorito", + "error.history_failed": "N\u00e3o foi poss\u00edvel carregar os dados hist\u00f3ricos", + "error.monitoring_failed": "N\u00e3o foi poss\u00edvel carregar o status de monitoramento", + "error.graph_settings_failed": "N\u00e3o foi poss\u00edvel carregar as configura\u00e7\u00f5es do gr\u00e1fico", + "error.areas_failed": "As atribui\u00e7\u00f5es de \u00e1reas podem estar fora de sincroniza\u00e7\u00e3o", + "error.retry": "Tentar novamente", + "card.connecting": "Conectando ao SPAN Panel...", "settings.heading": "Configura\u00e7\u00f5es", "settings.description": "As configura\u00e7\u00f5es gerais da integra\u00e7\u00e3o (nomes de entidades, prefixo do dispositivo, n\u00fameros de circuito) s\u00e3o gerenciadas atrav\u00e9s do fluxo de op\u00e7\u00f5es da integra\u00e7\u00e3o.", @@ -750,8 +830,6 @@ const translations: Record> = { "settings.col.circuit": "Circuit", "settings.col.scale": "Scale", "sidepanel.graph_horizon": "Graph Time Horizon", - "sidepanel.graph_horizon_failed": "Graph horizon update failed:", - "sidepanel.clear_graph_horizon_failed": "Clear graph horizon failed:", "header.default_name": "SPAN Panel", "header.monitoring_settings": "Configura\u00e7\u00f5es de monitoramento do painel", "header.graph_settings": "Configura\u00e7\u00f5es do horizonte temporal do gr\u00e1fico", @@ -777,16 +855,17 @@ const translations: Record> = { "subdevice.power": "Pot\u00eancia", "sidepanel.graph_settings": "Configura\u00e7\u00f5es de Gr\u00e1ficos", "sidepanel.global_defaults": "Padr\u00f5es globais para todos os circuitos", + "sidepanel.favorites_subtitle": "Favoritos", "sidepanel.global_default": "Padr\u00e3o Global", + "sidepanel.list_view_columns": "Colunas da Lista", + "sidepanel.columns": "Colunas", "sidepanel.circuit_scales": "Escalas de Gr\u00e1ficos de Circuitos", "sidepanel.subdevice_scales": "Escalas de Gr\u00e1ficos de Sub-Dispositivos", "sidepanel.reset_to_global": "Redefinir para o padr\u00e3o global", "sidepanel.relay": "Rel\u00e9", "sidepanel.breaker": "Disjuntor", - "sidepanel.relay_failed": "Falha ao alternar rel\u00e9:", "sidepanel.shedding_priority": "Prioridade de Desligamento", "sidepanel.priority_label": "Prioridade", - "sidepanel.shedding_failed": "Falha ao atualizar desligamento:", "sidepanel.monitoring": "Monitoramento", "sidepanel.global": "Global", "sidepanel.custom": "Personalizado", @@ -794,9 +873,9 @@ const translations: Record> = { "sidepanel.spike_pct": "Pico %", "sidepanel.window_duration": "Dura\u00e7\u00e3o da janela", "sidepanel.cooldown": "Resfriamento", - "sidepanel.monitoring_toggle_failed": "Falha ao alternar monitoramento:", - "sidepanel.clear_monitoring_failed": "Falha ao limpar monitoramento:", - "sidepanel.save_threshold_failed": "Falha ao salvar limite:", + "sidepanel.favorite": "Favorito", + "sidepanel.save_to_favorites": "Salvar nos favoritos", + "panel.favorites": "Favoritos", "status.monitoring": "Monitoramento", "status.circuits": "circuitos", "status.mains": "alimenta\u00e7\u00e3o", @@ -808,7 +887,6 @@ const translations: Record> = { "status.overrides": "substitui\u00e7\u00f5es", "card.no_device": "Abra o editor do cart\u00e3o e selecione seu dispositivo SPAN Panel.", "card.device_not_found": "Dispositivo do painel n\u00e3o encontrado. Verifique device_id na configura\u00e7\u00e3o do cart\u00e3o.", - "card.loading": "Carregando...", "card.topology_error": "A resposta de topologia n\u00e3o cont\u00e9m panel_size e nenhum circuito encontrado. Atualize a integra\u00e7\u00e3o SPAN Panel.", "card.panel_size_error": "N\u00e3o foi poss\u00edvel determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integra\u00e7\u00e3o SPAN Panel.", @@ -856,3 +934,14 @@ export function setLanguage(lang: string | undefined): void { export function t(key: string): string { return translations[_lang]?.[key] ?? translations.en?.[key] ?? key; } + +/** + * Look up a translation key and substitute `{name}`-style placeholders. + * Falls back to the English template when the key is missing in the + * active language. Missing variables render as their literal token + * (e.g. `{name}`) — loud in the UI, easy to spot in review. + */ +export function tf(key: string, vars: Record): string { + const template = translations[_lang]?.[key] ?? translations.en?.[key] ?? key; + return template.replace(/\{(\w+)\}/g, (_, k) => (Object.prototype.hasOwnProperty.call(vars, k) ? vars[k]! : `{${k}}`)); +} diff --git a/src/panel/coalesce.ts b/src/panel/coalesce.ts new file mode 100644 index 0000000..8c13ff8 --- /dev/null +++ b/src/panel/coalesce.ts @@ -0,0 +1,73 @@ +/** + * Returns a scheduler that coalesces concurrent async invocations. + * + * When the scheduler is called: + * - If nothing is running, it starts the work immediately. + * - If work is already in-flight, it records a single follow-up request + * and returns the in-flight promise so the caller can ``await`` the + * current run (additional requests while in-flight are collapsed into + * that same follow-up, not queued individually). + * - When the in-flight work finishes (whether it succeeds or throws), + * exactly one follow-up run is started if one was requested. + * + * This prevents two concurrent calls to ``work`` from racing with each + * other, while still guaranteeing that a request arriving after the + * in-flight run started is honoured. + */ +export function coalesceRuns(work: () => Promise): () => Promise { + let inFlight: Promise | null = null; + let followUpPending = false; + + async function schedule(): Promise { + if (inFlight) { + followUpPending = true; + // Wait for the in-flight run to settle so this caller doesn't + // return while work is still running. Swallow errors — the + // in-flight caller already surfaces them; we're just waiting + // for the lock to clear so the follow-up can start. + await inFlight.catch(() => {}); + return; + } + const run = (async (): Promise => { + try { + await work(); + } finally { + inFlight = null; + if (followUpPending) { + followUpPending = false; + await schedule(); + } + } + })(); + inFlight = run; + await run; + } + + return schedule; +} + +/** + * Returns a pair ``[beginRun, superseded]``. + * + * ``beginRun()`` increments an internal monotonic counter and returns + * a snapshot. ``superseded()`` returns ``true`` if another call to + * ``beginRun()`` has happened since the snapshot was taken, signalling + * that the current async branch should abort. + * + * Usage: + * ```ts + * const makeToken = makeRenderToken(); + * // inside async work: + * const superseded = makeToken(); + * await something(); + * if (superseded()) return; + * ``` + */ +export function makeRenderToken(): () => () => boolean { + let counter = 0; + return (): (() => boolean) => { + counter += 1; + const snapshot = counter; + return (): boolean => counter !== snapshot; + }; +} diff --git a/src/panel/favorites-summary.ts b/src/panel/favorites-summary.ts new file mode 100644 index 0000000..5cd0953 --- /dev/null +++ b/src/panel/favorites-summary.ts @@ -0,0 +1,36 @@ +import { escapeHtml } from "../helpers/sanitize.js"; +import { t } from "../i18n.js"; +import { buildSheddingLegendHTML } from "../core/header-renderer.js"; + +/** + * Build the Favorites view summary strip: gear icon, slide-to-arm, + * shedding legend, and W/A unit toggle. Legend and W/A cluster in a + * right-anchored `.favorites-summary-right` wrapper so the layout + * mirrors the real-panel header. + * + * Pure string helper — no DOM, no element state. The panel component + * invokes it from `_buildFavoritesSummaryHTML`, passing the current + * amps-mode flag. + */ +export function buildFavoritesSummaryHTML(isAmpsMode: boolean): string { + return ` +
+ +
+ ${escapeHtml(t("header.enable_switches"))} +
+ +
+
+
+ ${buildSheddingLegendHTML()} +
+ + +
+
+
+ `; +} diff --git a/src/panel/favorites-view-state.ts b/src/panel/favorites-view-state.ts new file mode 100644 index 0000000..3180530 --- /dev/null +++ b/src/panel/favorites-view-state.ts @@ -0,0 +1,52 @@ +const FAVORITES_VIEW_STATE_KEY = "span_panel_favorites_view_state"; + +export interface FavoritesViewState { + activeTab?: "activity" | "area" | "monitoring"; + expanded: { activity: string[]; area: string[] }; + searchQuery?: string; +} + +export function defaultFavoritesViewState(): FavoritesViewState { + return { expanded: { activity: [], area: [] } }; +} + +/** + * Load the Favorites view state from localStorage. Returns defaults + * when the slot is empty, unparseable, or shaped unexpectedly. Every + * access is wrapped so quota/storage errors degrade gracefully. + */ +export function loadFavoritesViewState(): FavoritesViewState { + try { + const raw = localStorage.getItem(FAVORITES_VIEW_STATE_KEY); + if (!raw) return defaultFavoritesViewState(); + const parsed = JSON.parse(raw) as Partial | null; + if (!parsed || typeof parsed !== "object") return defaultFavoritesViewState(); + const expanded = parsed.expanded ?? { activity: [], area: [] }; + return { + activeTab: parsed.activeTab, + expanded: { + activity: Array.isArray(expanded.activity) ? expanded.activity : [], + area: Array.isArray(expanded.area) ? expanded.area : [], + }, + searchQuery: typeof parsed.searchQuery === "string" ? parsed.searchQuery : undefined, + }; + } catch { + return defaultFavoritesViewState(); + } +} + +export function saveFavoritesViewState(viewState: FavoritesViewState): void { + try { + localStorage.setItem(FAVORITES_VIEW_STATE_KEY, JSON.stringify(viewState)); + } catch { + // LocalStorage quota or disabled — non-fatal; state doesn't persist. + } +} + +export function clearFavoritesViewState(): void { + try { + localStorage.removeItem(FAVORITES_VIEW_STATE_KEY); + } catch { + // non-fatal + } +} diff --git a/src/panel/span-panel.ts b/src/panel/span-panel.ts index bb9bb17..6d08916 100644 --- a/src/panel/span-panel.ts +++ b/src/panel/span-panel.ts @@ -1,23 +1,41 @@ -import { LitElement, html, css } from "lit"; +import { LitElement, html, css, nothing, unsafeCSS } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { INTEGRATION_DOMAIN } from "../constants.js"; import { setLanguage, t } from "../i18n.js"; +import { ErrorStore } from "../core/error-store.js"; import "../core/side-panel.js"; +import "../core/error-banner.js"; import { DashboardTab } from "./tab-dashboard.js"; import { MonitoringTab } from "./tab-monitoring.js"; -import { ListViewController } from "../core/list-view-controller.js"; +import { ListViewController, type FavoritesViewStateDetail } from "../core/list-view-controller.js"; import { DashboardController } from "../core/dashboard-controller.js"; import { buildTabBarHTML } from "../core/tab-bar-renderer.js"; import { subscribeAreaUpdates } from "../core/area-resolver.js"; import { discoverTopology } from "../card/card-discovery.js"; +import { RetryManager } from "../core/retry-manager.js"; +import { buildHeaderHTML, buildPanelStatsHTML } from "../core/header-renderer.js"; +import { updatePanelStatsBlock } from "../core/dom-updater.js"; +import { buildSubDevicesHTML } from "../core/sub-device-renderer.js"; +import { escapeHtml } from "../helpers/sanitize.js"; +import { loadListColumns, saveListColumns } from "../helpers/list-columns.js"; +import { attrSelectorValue } from "../helpers/selector.js"; import { CARD_STYLES } from "../card/card-styles.js"; -import type { HomeAssistant, PanelDevice, CardConfig } from "../types.js"; +import { FAVORITES_CHANGED_EVENT, FavoritesCache, hasAnyFavorites } from "../core/favorites-store.js"; +import { FavoritesController, type FavoritesPanelStatsInfo } from "../core/favorites-controller.js"; +import { + clearFavoritesViewState, + defaultFavoritesViewState, + loadFavoritesViewState, + saveFavoritesViewState, + type FavoritesViewState, +} from "./favorites-view-state.js"; +import { buildFavoritesSummaryHTML } from "./favorites-summary.js"; +import { coalesceRuns, makeRenderToken } from "./coalesce.js"; +import type { FavoritesPanelInfo } from "../core/favorites-sections.js"; +import type { CardConfig, FavoritesMap, FavoritesTopology, HomeAssistant, PanelDevice } from "../types.js"; -interface HaMenuButton extends HTMLElement { - hass: HomeAssistant; - narrow: boolean; -} +const FAVORITES_PANEL_ID = "favorites"; type TabName = "dashboard" | "activity" | "area" | "monitoring"; @@ -33,18 +51,57 @@ export class SpanPanelElement extends LitElement { @state() private _selectedPanelId: string | null = null; @state() private _activeTab: TabName = "dashboard"; @state() private _discovered = false; - @state() private _discoveryError: string | null = null; @state() private _chartMetric: string | undefined; + @state() private _listColumns: number = loadListColumns(); + @state() private _favorites: FavoritesMap = {}; + + private _favoritesViewState: FavoritesViewState = defaultFavoritesViewState(); + /** + * Per-contributing-panel stats snapshot for the active Favorites + * render. Populated from ``FavoritesController.build`` and consumed + * by ``_updateFavoritesPanelStats`` on each interval tick so every + * per-panel stats block can read from the correct panel's entities. + */ + private _favoritesPanelStats: FavoritesPanelStatsInfo[] = []; private _dashboardTab = new DashboardTab(); private _monitoringTab = new MonitoringTab(); private _listDashCtrl = new DashboardController(); private _listCtrl = new ListViewController(this._listDashCtrl); + private _favCache = new FavoritesCache(); + private _favCtrl = new FavoritesController(); + /** + * Per-panel monitoring tabs used when rendering the Favorites view's + * Monitoring tab — one block per contributing panel's config entry. + */ + private _favoritesMonitoringTabs: Map = new Map(); + private readonly _errorStore = new ErrorStore(); + private _watchedPanelId: string | null = null; + private _discovering = false; + /** + * Monotonic token incremented on each ``_refreshFavorites`` call. + * Concurrent invocations (rapid heart toggles → multiple + * ``favorites-changed`` events) compare their token against the latest + * after each await; superseded callbacks bail out without touching + * state or scheduling another tab render. + */ + private _refreshSeq = 0; private _areaUnsub: (() => void) | null = null; + /** Cleared on disconnect to tell a pending subscribe to self-cancel. */ + private _areaSubscribing = false; private _onVisibilityChange: (() => void) | null = null; + private _onFavoritesChanged: (() => void) | null = null; private _deviceRegistryUnsub: Promise<() => void> | null = null; + /** + * True when a tab re-render was requested while any sidebar (favorites + * mode, real-panel gear mode, per-circuit, or sub-device) was open. + * `_onSidePanelClosed` consumes the flag and fires the deferred render + * so the main view catches up to the changes (un-favorited rows, new + * column count, horizon edits) the user made inside the sidebar. + */ + private _pendingTabRender = false; - static styles = css` + private static _shellStyles = css` :host { color: var(--primary-text-color); } @@ -66,6 +123,10 @@ export class SpanPanelElement extends LitElement { margin: 0 0 0 24px; line-height: 20px; flex-grow: 1; + display: flex; + align-items: center; + gap: 16px; + min-width: 0; } .panel-selector select { color: inherit; @@ -82,10 +143,13 @@ export class SpanPanelElement extends LitElement { color: var(--primary-text-color); } .panel-tabs { - margin-left: max(env(safe-area-inset-left), 24px); - margin-right: max(env(safe-area-inset-right), 24px); display: flex; gap: 0; + overflow-x: auto; + scrollbar-width: none; + } + .panel-tabs::-webkit-scrollbar { + display: none; } .panel-tab { padding: 8px 20px; @@ -136,15 +200,45 @@ export class SpanPanelElement extends LitElement { } `; + /** + * CARD_STYLES carries the ~700-line stylesheet shared with the + * Lovelace card (list rows, breaker-grid slots, toggle pill, side + * panel, etc.). Emit it once via Lit's static styles (scoped to the + * shadow root) instead of re-injecting it per tab render. + */ + static override styles = [SpanPanelElement._shellStyles, unsafeCSS(CARD_STYLES)]; + + /** + * Centralised accessor for the shadow root. LitElement guarantees it + * for any connected component; if it's missing we've hit SSR or a + * teardown race, both of which should fail loudly rather than be + * silently bypassed with sprinkled ``!`` assertions. + */ + private get _root(): ShadowRoot { + const root = this.shadowRoot; + if (!root) throw new Error("span-panel: shadow root is not available"); + return root; + } + connectedCallback(): void { super.connectedCallback(); + this._dashboardTab.errorStore = this._errorStore; + this._listDashCtrl.errorStore = this._errorStore; + this._favCache.errorStore = this._errorStore; + this._monitoringTab.errorStore = this._errorStore; + this._onVisibilityChange = (): void => { if (document.visibilityState !== "visible" || !this._discovered || !this.hass) return; this._scheduleTabRender(); }; document.addEventListener("visibilitychange", this._onVisibilityChange); + this._onFavoritesChanged = (): void => { + this._refreshFavorites(); + }; + document.addEventListener(FAVORITES_CHANGED_EVENT, this._onFavoritesChanged); + this._subscribeDeviceRegistry(); } @@ -153,6 +247,9 @@ export class SpanPanelElement extends LitElement { this._monitoringTab.stop(); this._listCtrl.stop(); this._listDashCtrl.stopIntervals(); + for (const tab of this._favoritesMonitoringTabs.values()) tab.stop(); + this._favoritesMonitoringTabs.clear(); + this._areaSubscribing = false; if (this._areaUnsub) { this._areaUnsub(); this._areaUnsub = null; @@ -161,7 +258,16 @@ export class SpanPanelElement extends LitElement { document.removeEventListener("visibilitychange", this._onVisibilityChange); this._onVisibilityChange = null; } + if (this._onFavoritesChanged) { + document.removeEventListener(FAVORITES_CHANGED_EVENT, this._onFavoritesChanged); + this._onFavoritesChanged = null; + } this._unsubscribeDeviceRegistry(); + if (this._persistFavoritesViewStateTimer) { + clearTimeout(this._persistFavoritesViewStateTimer); + this._persistFavoritesViewStateTimer = null; + } + this._errorStore.dispose(); super.disconnectedCallback(); } @@ -176,17 +282,11 @@ export class SpanPanelElement extends LitElement { const oldHass = changedProps.get("hass") as HomeAssistant | undefined; this._dashboardTab.hass = this.hass; this._listDashCtrl.hass = this.hass; - - // Wire up ha-menu-button with current hass/narrow - const menuBtn = this.renderRoot.querySelector("ha-menu-button"); - if (menuBtn) { - menuBtn.hass = this.hass; - menuBtn.narrow = this.narrow; - } + this._errorStore.updateHass(this.hass); if (!this._discovered) { this._discoverPanels(); - } else if (!this.shadowRoot!.getElementById("tab-content")) { + } else if (!this._root.getElementById("tab-content")) { // Re-render only if the tab-content container was lost this._scheduleTabRender(); } @@ -196,27 +296,65 @@ export class SpanPanelElement extends LitElement { } } - if (changedProps.has("narrow")) { - const menuBtn = this.renderRoot.querySelector("ha-menu-button"); - if (menuBtn) menuBtn.narrow = this.narrow; - } - // Render tab content when discovery completes or active tab/panel/metric changes if ( this._discovered && - (changedProps.has("_discovered") || changedProps.has("_activeTab") || changedProps.has("_selectedPanelId") || changedProps.has("_chartMetric")) + (changedProps.has("_discovered") || + changedProps.has("_activeTab") || + changedProps.has("_selectedPanelId") || + changedProps.has("_chartMetric") || + changedProps.has("_listColumns")) ) { + // Defensive normalization: the Favorites pseudo-panel has no + // "By Panel" tab. ``_onPanelChange`` and ``_discoverPanels`` + // redirect when they switch into Favorites, but an external + // navigate-tab event or a stale state could still land here — + // coerce to the Activity tab and let the resulting state change + // schedule the single render. + if (this._isFavoritesView && this._activeTab === "dashboard") { + this._activeTab = "activity"; + return; + } this._scheduleTabRender(); } + if (changedProps.has("_selectedPanelId")) { + if (this._selectedPanelId === FAVORITES_PANEL_ID || !this._selectedPanelId) { + // Favorites pseudo-panel has no panel_status — clear the watch + this._errorStore.clearPanelStatusWatch(); + this._watchedPanelId = null; + } else { + this._updatePanelStatusWatch(); + // Leaving Favorites for a real panel — clear stale per-panel metadata + // so an accidental gear click before the new per-panel render finishes + // cannot open a favorites-mode sidebar with stale data. + this._listDashCtrl.setFavoritesPerPanelInfo(null); + } + } + + // Keep the ${this._panels.map(p => html` `)} +
${unsafeHTML(buildTabBarHTML(this._buildTabList(), this._activeTab, "text"))}
- -
- ${unsafeHTML( - buildTabBarHTML( - [ - { id: "dashboard", label: t("tab.by_panel"), icon: "mdi:view-dashboard" }, - { id: "activity", label: t("tab.by_activity"), icon: "mdi:sort-descending" }, - { id: "area", label: t("tab.by_area"), icon: "mdi:home-group" }, - { id: "monitoring", label: t("tab.monitoring"), icon: "mdi:monitor-eye" }, - ], - this._activeTab, - "text" - ) - )} -
+
@@ -293,11 +439,22 @@ export class SpanPanelElement extends LitElement { const select = e.target as HTMLSelectElement; this._selectedPanelId = select.value; localStorage.setItem("span_panel_selected", select.value); + // The Favorites pseudo-panel has no "By Panel" tab, so re-route away + // from it when the user lands on favorites with that tab active. + if (this._isFavoritesView && this._activeTab === "dashboard") { + this._activeTab = "activity"; + } + this._areaSubscribing = false; if (this._areaUnsub) { this._areaUnsub(); this._areaUnsub = null; } - this._scheduleTabRender(); + // Reactive updated() handles the re-render via _selectedPanelId + // (and possibly _activeTab) changes. + } + + private get _isFavoritesView(): boolean { + return this._selectedPanelId === FAVORITES_PANEL_ID; } private _onTabClick(e: Event): void { @@ -307,7 +464,13 @@ export class SpanPanelElement extends LitElement { const tab = btn.dataset.tab as TabName | undefined; if (!tab || tab === this._activeTab) return; this._activeTab = tab; - this._scheduleTabRender(); + if (this._isFavoritesView && tab !== "dashboard") { + this._favoritesViewState.activeTab = tab; + saveFavoritesViewState(this._favoritesViewState); + } + // No explicit _scheduleTabRender — Lit's updated() sees + // _activeTab change and schedules the render. Calling here too + // would kick off two concurrent renders and produce flashing. } private _onTabContentClick(e: Event): void { @@ -320,9 +483,7 @@ export class SpanPanelElement extends LitElement { if (!metric || metric === this._chartMetric) return; this._chartMetric = metric; localStorage.setItem("span_panel_metric", metric); - if (this._activeTab === "dashboard") { - this._scheduleTabRender(); - } + // Reactive updated() handles the re-render; see _onTabClick. return; } @@ -336,6 +497,15 @@ export class SpanPanelElement extends LitElement { ctrl.monitoringCache.invalidate(); ctrl.graphSettingsCache.invalidate(); } + // The Favorites view uses the multi-entry cache for monitoring; if + // the side panel adjusted any settings, freshen those too. + this._listDashCtrl.monitoringMultiCache.invalidate(); + // Replay any tab render that was deferred while the sidebar was open + // (heart toggle or list-columns change inside a favorites-mode sidebar). + if (this._pendingTabRender) { + this._pendingTabRender = false; + this._scheduleTabRender(); + } } private _onUnitChanged(e: Event): void { @@ -343,12 +513,20 @@ export class SpanPanelElement extends LitElement { if (!unit || unit === this._chartMetric) return; this._chartMetric = unit; localStorage.setItem("span_panel_metric", unit); - this._scheduleTabRender(); + // Reactive updated() handles the re-render. + } + + private _onListColumnsChanged(e: Event): void { + const n = (e as CustomEvent).detail; + if (typeof n !== "number" || (n !== 1 && n !== 2 && n !== 3) || n === this._listColumns) return; + this._listColumns = n; + saveListColumns(n); + // Reactive updated() handles the re-render. } private _onGraphSettingsChanged(): void { if (this._activeTab === "dashboard") { - const container = this.shadowRoot!.getElementById("tab-content"); + const container = this._root.getElementById("tab-content"); if (container) { const ctrl = this._dashboardTab["_ctrl"]; ctrl.onGraphSettingsChanged(container); @@ -360,7 +538,41 @@ export class SpanPanelElement extends LitElement { const tab = (e as CustomEvent).detail; if (!tab) return; this._activeTab = tab as TabName; - this._scheduleTabRender(); + // Reactive updated() handles the re-render. + } + + private _persistFavoritesViewStateTimer: ReturnType | null = null; + + private _onFavoritesViewStateChangedEvent(ev: Event): void { + if (!this._isFavoritesView) return; + const detail = (ev as CustomEvent).detail; + if (!detail) return; + const viewState = this._favoritesViewState; + viewState.activeTab = detail.view; + // Prune expansion ids to those still present in the merged topology, + // but only if we actually have a topology to prune against. An empty + // ``circuits`` map here usually means the Favorites tab is still + // re-rendering after a search keystroke; pruning against {} would + // drop every expansion and the user would lose their open rows. + const topology = this._listDashCtrl.topology; + const circuits = topology?.circuits; + if (circuits && Object.keys(circuits).length > 0) { + viewState.expanded[detail.view] = detail.expanded.filter(id => id in circuits); + } else { + viewState.expanded[detail.view] = detail.expanded; + } + viewState.searchQuery = detail.searchQuery; + + // Search-box updates fire on every keystroke; expansion and tab + // switches are discrete. Debounce localStorage writes so typing a + // long query doesn't thrash storage. + if (this._persistFavoritesViewStateTimer) { + clearTimeout(this._persistFavoritesViewStateTimer); + } + this._persistFavoritesViewStateTimer = setTimeout(() => { + this._persistFavoritesViewStateTimer = null; + saveFavoritesViewState(viewState); + }, 250); } // ── Internal helpers ──────────────────────────────────────────────── @@ -383,44 +595,256 @@ export class SpanPanelElement extends LitElement { const devices = await this.hass.callWS({ type: "config/device_registry/list", }); - const panels = devices.filter((d: PanelDevice) => d.identifiers?.some(id => id[0] === INTEGRATION_DOMAIN) && !d.via_device_id); + const realPanels = devices.filter((d: PanelDevice) => d.identifiers?.some(id => id[0] === INTEGRATION_DOMAIN) && !d.via_device_id); - const currentIds = new Set(this._panels.map(p => p.id)); - const newIds = new Set(panels.map(p => p.id)); - if (currentIds.size === newIds.size && [...currentIds].every(id => newIds.has(id))) return; + const prevPanels = this._panels.filter(p => p.id !== FAVORITES_PANEL_ID); + const prevById = new Map(prevPanels.map(p => [p.id, p])); + const newIds = new Set(realPanels.map(p => p.id)); + const idsChanged = prevById.size !== newIds.size || [...prevById.keys()].some(id => !newIds.has(id)); + // Also detect renames so the dropdown reflects user-facing panel + // renames without requiring a reload. We compare both ``name`` and + // ``name_by_user`` because HA surfaces either depending on setup. + const namesChanged = + !idsChanged && + realPanels.some(next => { + const prev = prevById.get(next.id); + if (!prev) return false; + return prev.name !== next.name || prev.name_by_user !== next.name_by_user; + }); + if (!idsChanged && !namesChanged) return; - this._panels = panels; + this._panels = this._buildPanelList(realPanels, this._favorites); if (!this._panels.some(p => p.id === this._selectedPanelId) && this._panels.length > 0) { - this._selectedPanelId = this._panels[0]!.id; - localStorage.setItem("span_panel_selected", this._selectedPanelId); + const firstReal = realPanels[0]; + if (firstReal) { + this._selectedPanelId = firstReal.id; + localStorage.setItem("span_panel_selected", this._selectedPanelId); + } } } - private async _discoverPanels(): Promise { - if (!this.hass) return; + private async _updatePanelStatusWatch(): Promise { + if (!this.hass || !this._selectedPanelId) return; + if (this._selectedPanelId === FAVORITES_PANEL_ID) return; + if (this._watchedPanelId === this._selectedPanelId) return; + const targetPanelId = this._selectedPanelId; + this._watchedPanelId = targetPanelId; try { - const devices = await this.hass.callWS({ - type: "config/device_registry/list", - }); - this._panels = devices.filter((d: PanelDevice) => d.identifiers?.some(id => id[0] === INTEGRATION_DOMAIN) && !d.via_device_id); + const retry = new RetryManager(this._errorStore); + const result = await discoverTopology(this.hass, targetPanelId, retry); + // Guard against supersession: user may have switched panels during fetch. + if (this._selectedPanelId !== targetPanelId) return; + const entityId = result.topology?.panel_entities?.panel_status; + if (entityId) { + this._errorStore.watchPanelStatus(entityId); + this._errorStore.updateHass(this.hass); + } } catch (err) { - console.error("SPAN Panel: device discovery failed", err); - this._discoveryError = `Discovery failed: ${(err as Error).message ?? err}`; - return; + console.warn("SPAN Panel: unable to fetch topology for panel status watching", err); + // Reset so a retry (e.g., user re-selects same panel) can attempt the fetch again. + if (this._watchedPanelId === targetPanelId) { + this._watchedPanelId = null; + } + } + } + + private async _discoverPanels(): Promise { + if (this._discovering) return; + if (!this.hass) return; + this._discovering = true; + try { + let realPanels: PanelDevice[]; + try { + const retry = new RetryManager(this._errorStore); + const devices = await retry.callWS( + this.hass, + { type: "config/device_registry/list" }, + { + errorId: "fetch:topology", + } + ); + realPanels = devices.filter((d: PanelDevice) => d.identifiers?.some(id => id[0] === INTEGRATION_DOMAIN) && !d.via_device_id); + } catch (err) { + console.error("SPAN Panel: device discovery failed", err); + this._errorStore.add({ + key: "discovery-failed", + level: "error", + message: t("error.discovery_failed"), + persistent: true, + retryFn: () => { + this._errorStore.remove("discovery-failed"); + this._discoverPanels(); + }, + }); + return; + } + + this._favorites = await this._loadFavorites(); + this._panels = this._buildPanelList(realPanels, this._favorites); + this._favoritesViewState = loadFavoritesViewState(); + + this._discovered = true; + + const stored = localStorage.getItem("span_panel_selected"); + if (stored && this._panels.some(p => p.id === stored)) { + this._selectedPanelId = stored; + } else if (realPanels.length > 0) { + this._selectedPanelId = realPanels[0]!.id; + } + + // Restore the user's favorites tab when re-entering the pseudo-panel. + if (this._selectedPanelId === FAVORITES_PANEL_ID) { + const restoredTab = this._favoritesViewState.activeTab; + if (restoredTab === "activity" || restoredTab === "area" || restoredTab === "monitoring") { + this._activeTab = restoredTab; + } else if (this._activeTab === "dashboard") { + this._activeTab = "activity"; + } + } + + this._chartMetric = localStorage.getItem("span_panel_metric") || "power"; + } finally { + this._discovering = false; } + } + + /** + * Build the dropdown list, optionally prepending a synthetic Favorites + * entry when at least one favorite is configured. + */ + private _buildPanelList(realPanels: PanelDevice[], favorites: FavoritesMap): PanelDevice[] { + if (!hasAnyFavorites(favorites)) return realPanels; + const favoritesEntry: PanelDevice = { + id: FAVORITES_PANEL_ID, + name: t("panel.favorites"), + model: "__favorites__", + }; + return [favoritesEntry, ...realPanels]; + } + + private async _loadFavorites(): Promise { + if (!this.hass) return {}; + return this._favCache.fetch(this.hass); + } + + /** + * React to a ``favorites-changed`` event dispatched by a heart toggle + * in the side panel. Re-fetches the favorites map and updates the + * dropdown entry. Re-renders the tab only when needed: + * + * - Favorites view: always re-render so removed targets disappear + * immediately. The open side panel is destroyed as a side effect, + * which is acceptable UX since the user just un-favorited the + * target they were inspecting. + * - Real panel view: skip the re-render so the open Graph Settings + * side panel stays interactive while the user toggles hearts on + * multiple targets in a row. + */ + private async _refreshFavorites(): Promise { + const myToken = ++this._refreshSeq; + this._favCache.invalidate(); + const favorites = await this._loadFavorites(); + // Bail out if a newer refresh has superseded us — its reload + render + // will land with the latest data; don't double-render or fight it. + if (myToken !== this._refreshSeq) return; + const wasOnFavorites = this._selectedPanelId === FAVORITES_PANEL_ID; + this._favorites = favorites; - this._discoveryError = null; - this._discovered = true; + const realPanels = this._panels.filter(p => p.id !== FAVORITES_PANEL_ID); + this._panels = this._buildPanelList(realPanels, favorites); - const stored = localStorage.getItem("span_panel_selected"); - if (stored && this._panels.some(p => p.id === stored)) { - this._selectedPanelId = stored; - } else if (this._panels.length > 0) { - this._selectedPanelId = this._panels[0]!.id; + if (wasOnFavorites && !hasAnyFavorites(favorites)) { + // Last favorite removed — fall back to the first real panel and + // drop the persisted Favorites view state so a fresh return opens + // with defaults. The selectedPanelId change reactively re-renders + // the tab content via ``updated()``. + clearFavoritesViewState(); + this._favoritesViewState = defaultFavoritesViewState(); + const fallback = realPanels[0]; + if (fallback) { + this._selectedPanelId = fallback.id; + localStorage.setItem("span_panel_selected", fallback.id); + } else { + this._selectedPanelId = null; + } + } else if (this._isFavoritesView) { + // Re-render the favorites view so newly un-favorited rows / + // sub-device tiles are removed from the list. + this._scheduleTabRender(); + } else { + // Keep per-panel favorites fresh so the next gear click (or a + // re-opened side panel) reflects the current heart state. + this._applyPanelFavorites(); + } + } + + /** + * Build the tab list for the current panel selection. The Favorites + * pseudo-panel drops "By Panel" because its merged topology has no + * physical breaker grid to render. + */ + private _buildTabList(): { id: string; label: string; icon: string }[] { + const tabs: { id: string; label: string; icon: string }[] = []; + if (!this._isFavoritesView) { + tabs.push({ id: "dashboard", label: t("tab.by_panel"), icon: "mdi:view-dashboard" }); } + tabs.push( + { id: "activity", label: t("tab.by_activity"), icon: "mdi:sort-descending" }, + { id: "area", label: t("tab.by_area"), icon: "mdi:home-group" }, + { id: "monitoring", label: t("tab.monitoring"), icon: "mdi:monitor-eye" } + ); + return tabs; + } + + /** + * Build the Favorites view summary strip — gear icon, slide-to-arm + * control, shedding legend, and W/A unit toggle. Styles live in + * ``CARD_STYLES`` under ``.favorites-summary`` + + * ``.favorites-summary-right``. The actual HTML is produced by the + * pure ``buildFavoritesSummaryHTML`` helper in + * ``src/panel/favorites-summary.ts`` so it can be unit-tested + * without a DOM; this wrapper just threads the current amps-mode + * flag through. + */ + private _buildFavoritesSummaryHTML(): string { + const isAmpsMode = (this._chartMetric || "power") === "current"; + return buildFavoritesSummaryHTML(isAmpsMode); + } + + /** + * Render a responsive grid of per-contributing-panel status cards + * (Site/Grid/Upstream/Downstream/Solar/Battery). Auto-fits 1-3 + * columns based on viewport width. Live values are filled in by + * ``_updateFavoritesPanelStats`` on each interval tick. + */ + private _buildFavoritesPanelStatsGridHTML(perPanelStats: FavoritesPanelStatsInfo[], config: CardConfig): string { + if (perPanelStats.length === 0) return ""; + const cards = perPanelStats + .map( + info => ` +
+
${escapeHtml(info.panelName || info.topology.device_name || "")}
+ ${buildPanelStatsHTML(info.topology, config, info.panelDeviceId)} +
+ ` + ) + .join(""); + return `
${cards}
`; + } - this._chartMetric = localStorage.getItem("span_panel_metric") || "power"; + /** + * Update each per-panel stats block in the Favorites view from its + * originating panel's entities. Runs on startIntervals ticks so the + * Site/Grid/Upstream/... values stay live. + */ + private _updateFavoritesPanelStats(container: HTMLElement, config: CardConfig): void { + if (!this.hass || this._favoritesPanelStats.length === 0) return; + for (const info of this._favoritesPanelStats) { + const block = container.querySelector(`.panel-stats[data-stats-panel-id="${attrSelectorValue(info.panelDeviceId)}"]`); + if (!block) continue; + updatePanelStatsBlock(block, this.hass, info.topology, config, 0); + } } private _buildDashboardConfig(): CardConfig { @@ -433,20 +857,71 @@ export class SpanPanelElement extends LitElement { }; } + /** + * Coalesces concurrent tab-render requests so at most one render is + * in-flight at a time, with exactly one follow-up if more requests + * arrived while the current one was running. + */ + private readonly _tabRenderScheduler = coalesceRuns(async () => this._renderTab()); + + /** + * Monotonic render-token factory. Each call to the returned function + * increments the counter and returns a ``superseded()`` predicate that + * tells a render branch whether it has been overtaken by a later render. + */ + private readonly _beginRender = makeRenderToken(); + + /** + * Coalesce tab-render requests. If a render is in-flight, remember + * that another was requested and run exactly one follow-up once the + * current render completes. Running two ``_renderTab`` calls + * concurrently causes the tab container to be cleared and rewritten + * twice in rapid succession, which is visible as flashing. + */ private async _scheduleTabRender(): Promise { await this.updateComplete; - await this._renderTab(); + // While any sidebar is open, a tab re-render would wipe + // `#tab-content` and destroy the live sidebar. Defer the render until + // the sidebar closes (handled by `_onSidePanelClosed`). A modal + // backdrop prevents tab/panel clicks while the sidebar is open, so + // only sidebar-originated state changes (heart toggle, list-columns + // change, horizon edit) take this path. Covers both favorites-mode + // and real-panel-mode sidebars — any open sidebar qualifies. + if (this._sidePanelOpen()) { + this._pendingTabRender = true; + return; + } + await this._tabRenderScheduler(); + } + + private _sidePanelOpen(): boolean { + const container = this.shadowRoot?.getElementById("tab-content"); + return !!container?.querySelector("span-side-panel[open]"); } private async _renderTab(): Promise { + const superseded = this._beginRender(); + this._dashboardTab.stop(); this._monitoringTab.stop(); this._listCtrl.stop(); this._listDashCtrl.stopIntervals(); + for (const tab of this._favoritesMonitoringTabs.values()) tab.stop(); + this._favoritesMonitoringTabs.clear(); + this._favoritesPanelStats = []; - const container = this.shadowRoot!.getElementById("tab-content"); + const container = this._root.getElementById("tab-content"); if (!container) return; + if (this._isFavoritesView) { + await this._renderFavoritesTab(container, superseded); + return; + } + + this._listDashCtrl.clearFavoriteRefs(); + this._listCtrl.setViewName(null); + this._applyPanelFavorites(); + switch (this._activeTab) { case "dashboard": { container.innerHTML = ""; @@ -461,17 +936,29 @@ export class SpanPanelElement extends LitElement { const device = this._panels.find(p => p.id === this._selectedPanelId); const entryId = device?.config_entries?.[0] ?? null; try { - const result = await discoverTopology(this.hass, this._selectedPanelId ?? undefined); + const retry = new RetryManager(this._errorStore); + const result = await discoverTopology(this.hass, this._selectedPanelId ?? undefined, retry); + if (superseded()) return; const config = this._buildDashboardConfig(); this._listDashCtrl.init(result.topology, config, this.hass, entryId); + // A full re-render (including on W/A switch) needs fresh + // history: ``loadHistory`` merges into ``powerHistory``, so + // leftover points from the previous metric would contaminate + // the new chart. + this._listDashCtrl.powerHistory.clear(); await this._listDashCtrl.monitoringCache.fetch(this.hass, entryId); + if (superseded()) return; await this._listDashCtrl.fetchAndBuildHorizonMaps(); - this._listCtrl.renderActivityView(container, this.hass, result.topology!, config, this._listDashCtrl.monitoringCache.status); - container.insertAdjacentHTML("afterbegin", ``); + if (superseded()) return; + const headerHTML = result.topology ? buildHeaderHTML(result.topology, config) : ""; + this._listCtrl.setColumns(this._listColumns); + this._listCtrl.renderActivityView(container, this.hass, result.topology!, config, this._listDashCtrl.monitoringCache.status, headerHTML); await this._listDashCtrl.loadHistory(); + if (superseded()) return; this._listDashCtrl.updateDOM(container); this._listDashCtrl.startIntervals(container); } catch (err) { + if (superseded()) return; const errEl = document.createElement("p"); errEl.style.color = "var(--error-color)"; errEl.textContent = (err as Error).message; @@ -484,32 +971,61 @@ export class SpanPanelElement extends LitElement { const areaDevice = this._panels.find(p => p.id === this._selectedPanelId); const areaEntryId = areaDevice?.config_entries?.[0] ?? null; try { - const result = await discoverTopology(this.hass, this._selectedPanelId ?? undefined); + const retry = new RetryManager(this._errorStore); + const result = await discoverTopology(this.hass, this._selectedPanelId ?? undefined, retry); + if (superseded()) return; const config = this._buildDashboardConfig(); this._listDashCtrl.init(result.topology, config, this.hass, areaEntryId); + this._listDashCtrl.powerHistory.clear(); await this._listDashCtrl.monitoringCache.fetch(this.hass, areaEntryId); + if (superseded()) return; await this._listDashCtrl.fetchAndBuildHorizonMaps(); - this._listCtrl.renderAreaView(container, this.hass, result.topology!, config, this._listDashCtrl.monitoringCache.status); - container.insertAdjacentHTML("afterbegin", ``); + if (superseded()) return; + const headerHTML = result.topology ? buildHeaderHTML(result.topology, config) : ""; + this._listCtrl.setColumns(this._listColumns); + this._listCtrl.renderAreaView(container, this.hass, result.topology!, config, this._listDashCtrl.monitoringCache.status, headerHTML); await this._listDashCtrl.loadHistory(); + if (superseded()) return; this._listDashCtrl.updateDOM(container); this._listDashCtrl.startIntervals(container); - if (!this._areaUnsub) { - subscribeAreaUpdates(this.hass, result.topology!, () => { - if (this._activeTab === "area") { - this._scheduleTabRender(); - } - }) + if (!this._areaUnsub && !this._areaSubscribing) { + this._areaSubscribing = true; + subscribeAreaUpdates( + this.hass, + result.topology!, + () => { + if (this._activeTab === "area") { + this._scheduleTabRender(); + } + }, + this._errorStore + ) .then(unsub => { - this._areaUnsub = unsub; + if (this._areaSubscribing) { + this._areaUnsub = unsub; + } else { + // Element disconnected or panel changed while + // subscribing — unsubscribe immediately so we don't + // leak the subscription. + unsub(); + } }) - .catch(() => {}); + .catch((err: unknown) => { + this._areaSubscribing = false; + console.warn("SPAN Panel: area subscription failed", err); + this._errorStore.add({ + key: "subscribe:area", + level: "warning", + message: t("error.areas_failed"), + persistent: false, + }); + }); } } catch (err) { const errEl = document.createElement("p"); errEl.style.color = "var(--error-color)"; - errEl.textContent = (err as Error).message; + errEl.textContent = err instanceof Error ? err.message : String(err); container.appendChild(errEl); } break; @@ -518,9 +1034,196 @@ export class SpanPanelElement extends LitElement { container.innerHTML = ""; const monDevice = this._panels.find(p => p.id === this._selectedPanelId); const monEntryId = monDevice?.config_entries?.[0] ?? null; + // Monitoring is a pure configuration view — no panel-stats header. await this._monitoringTab.render(container, this.hass, monEntryId ?? undefined); break; } } } + + /** + * Render the Favorites pseudo-panel for the current active tab. + * Skips the "By Panel" tab entirely — that tab is filtered out of the + * tab bar and ``_onPanelChange`` auto-reroutes to Activity when the + * user switches panels while it was active. + */ + private async _renderFavoritesTab(container: HTMLElement, superseded: () => boolean): Promise { + container.innerHTML = ""; + if (!this.hass) return; + + const realPanels = this._panels.filter(p => p.id !== FAVORITES_PANEL_ID); + const build = await this._favCtrl.build(this.hass, this._favorites, realPanels, this._errorStore); + if (superseded()) return; + + // Drive the offline-banner watch for every contributing panel whose + // topology resolved. Each row in is scoped per + // panel_status entity, so the Favorites view shows one banner row per + // offline contributing panel, labeled with the panel's name. + const panelStatusEntries = build.perPanelStats + .map(p => { + const entityId = p.topology.panel_entities?.panel_status; + return typeof entityId === "string" ? { entityId, panelName: p.panelName } : null; + }) + .filter((e): e is { entityId: string; panelName: string } => e !== null); + this._errorStore.watchPanelStatuses(panelStatusEntries); + this._errorStore.updateHass(this.hass); + + // Register per-panel metadata for the Favorites sidebar. The + // controller's `onGearClick` uses this to build one sidebar + // section per contributing panel. + const perPanelInfoMap = new Map(); + for (const p of build.perPanelStats) { + const realPanel = realPanels.find(r => r.id === p.panelDeviceId); + perPanelInfoMap.set(p.panelDeviceId, { + panelName: p.panelName, + topology: p.topology, + configEntryId: realPanel?.config_entries?.[0] ?? null, + }); + } + this._listDashCtrl.setFavoritesPerPanelInfo(perPanelInfoMap); + + const merged = build.topology; + const primaryEntryId = build.entryIds[0] ?? null; + + const hasCircuits = Object.keys(merged.circuits).length > 0; + const hasSubDevices = Object.keys(merged.sub_devices ?? {}).length > 0; + if (!hasCircuits && !hasSubDevices) { + const empty = document.createElement("p"); + empty.style.color = "var(--secondary-text-color)"; + empty.style.padding = "24px"; + empty.textContent = t("list.no_results"); + container.appendChild(empty); + return; + } + + this._listDashCtrl.setFavoriteRefs(merged._favoriteRefs); + this._listDashCtrl.setPanelFavorites(null); + + if (this._activeTab === "monitoring") { + this._listCtrl.setViewName(null); + await this._renderFavoritesMonitoring(container, build.entryIds, realPanels); + return; + } + + const viewName = this._activeTab as "activity" | "area"; + const validCircuitIds = new Set(Object.keys(merged.circuits)); + const storedExpanded = this._favoritesViewState.expanded[viewName].filter(id => validCircuitIds.has(id)); + this._listCtrl.setViewName(viewName); + this._listCtrl.setInitialExpansion(storedExpanded); + this._listCtrl.setInitialSearchQuery(this._favoritesViewState.searchQuery ?? ""); + this._listCtrl.setColumns(this._listColumns); + + const config = this._buildDashboardConfig(); + this._listDashCtrl.init(merged, config, this.hass, primaryEntryId); + this._listDashCtrl.powerHistory.clear(); + await this._listDashCtrl.fetchAndBuildHorizonMaps(); + if (superseded()) return; + const monitoringStatus = await this._listDashCtrl.fetchMergedMonitoringStatus(build.entryIds); + if (superseded()) return; + + this._favoritesPanelStats = build.perPanelStats; + try { + await this._listDashCtrl.loadHistory(); + if (superseded()) return; + const summaryHTML = this._buildFavoritesSummaryHTML(); + const panelStatsHTML = this._buildFavoritesPanelStatsGridHTML(build.perPanelStats, config); + const subDevicesHTML = hasSubDevices + ? `
+
${buildSubDevicesHTML(merged, this.hass, config)}
+
` + : ""; + const headerHTML = summaryHTML + panelStatsHTML + subDevicesHTML; + if (viewName === "activity") { + this._listCtrl.renderActivityView(container, this.hass, merged as FavoritesTopology, config, monitoringStatus, headerHTML); + } else { + this._listCtrl.renderAreaView(container, this.hass, merged as FavoritesTopology, config, monitoringStatus, headerHTML); + } + this._updateFavoritesPanelStats(container, config); + this._listDashCtrl.setupResizeObserver(container, container); + this._listDashCtrl.startIntervals(container, () => { + this._updateFavoritesPanelStats(container, config); + }); + } catch (err) { + if (superseded()) return; + const errEl = document.createElement("p"); + errEl.style.color = "var(--error-color)"; + errEl.textContent = (err as Error).message; + container.appendChild(errEl); + } + } + + private async _renderFavoritesMonitoring(container: HTMLElement, entryIds: string[], realPanels: PanelDevice[]): Promise { + if (!this.hass) return; + + // Monitoring is a pure configuration view — no panel-stats header, + // matching the real-panel Monitoring tab (see ``_renderTab`` case + // "monitoring"). The gear/slide-to-enable/legend/W-A summary belongs + // on the dashboard views (Activity, Area), not here. + const wrapper = document.createElement("div"); + wrapper.className = "favorites-monitoring-stack"; + container.appendChild(wrapper); + + const panelsByEntry = new Map(); + for (const panel of realPanels) { + const eid = panel.config_entries?.[0]; + if (eid) panelsByEntry.set(eid, panel); + } + + // Build into a local map and only assign to the instance field after + // every render attempts so a single failure can't orphan tabs that + // _renderTab's cleanup loop never sees. + const tabs = new Map(); + for (const entryId of entryIds) { + const panel = panelsByEntry.get(entryId); + const block = document.createElement("div"); + block.className = "favorites-monitoring-block"; + block.style.marginBottom = "24px"; + + const heading = document.createElement("h2"); + heading.style.margin = "8px 0 12px"; + heading.style.fontSize = "1em"; + heading.textContent = panel?.name_by_user ?? panel?.name ?? entryId; + block.appendChild(heading); + + const body = document.createElement("div"); + block.appendChild(body); + wrapper.appendChild(block); + + const tab = new MonitoringTab(); + tab.errorStore = this._errorStore; + tabs.set(entryId, tab); + try { + await tab.render(body, this.hass, entryId); + } catch (err) { + console.warn("SPAN Panel: favorites monitoring render failed", entryId, err); + const errEl = document.createElement("p"); + errEl.style.color = "var(--error-color)"; + errEl.textContent = (err as Error).message ?? String(err); + body.appendChild(errEl); + } + } + this._favoritesMonitoringTabs = tabs; + } + + /** + * For real-panel renders, push the current panel's favorited circuit + * uuids and sub-device ids into the shared list controller so hearts + * render with the right fill state when a side panel opens from any + * gear click on the dashboard. + */ + private _applyPanelFavorites(): void { + if (!this._selectedPanelId || this._isFavoritesView) { + this._listDashCtrl.setPanelFavorites(null); + this._dashboardTab.setPanelFavorites(null); + return; + } + const entry = this._favorites[this._selectedPanelId]; + const info = { + panelDeviceId: this._selectedPanelId, + circuitUuids: new Set(entry?.circuits ?? []), + subDeviceIds: new Set(entry?.sub_devices ?? []), + }; + this._listDashCtrl.setPanelFavorites(info); + this._dashboardTab.setPanelFavorites(info); + } } diff --git a/src/panel/tab-dashboard.ts b/src/panel/tab-dashboard.ts index 5da50f2..ac7f0fd 100644 --- a/src/panel/tab-dashboard.ts +++ b/src/panel/tab-dashboard.ts @@ -9,6 +9,12 @@ import { CARD_STYLES } from "../card/card-styles.js"; import "../core/side-panel.js"; import type { HomeAssistant, CardConfig } from "../types.js"; +export interface PanelFavoriteInfo { + panelDeviceId: string; + circuitUuids: Set; + subDeviceIds: Set; +} + export class DashboardTab { private readonly _ctrl = new DashboardController(); private _container: HTMLElement | null = null; @@ -25,6 +31,19 @@ export class DashboardTab { this._ctrl.hass = val; } + set errorStore(store: import("../core/error-store.js").ErrorStore | null) { + this._ctrl.errorStore = store; + } + + /** + * Provide the current panel's favorites (for heart toggles in the + * Graph Settings / circuit side panels). Pass ``null`` to hide hearts + * when the dashboard is not the active rendering context. + */ + setPanelFavorites(info: PanelFavoriteInfo | null): void { + this._ctrl.setPanelFavorites(info); + } + async render(container: HTMLElement, hass: HomeAssistant, deviceId: string, config: CardConfig, configEntryId?: string | null): Promise { this.stop(); this._ctrl.reset(); diff --git a/src/panel/tab-monitoring.ts b/src/panel/tab-monitoring.ts index 831fe29..13df2ca 100644 --- a/src/panel/tab-monitoring.ts +++ b/src/panel/tab-monitoring.ts @@ -1,6 +1,7 @@ import { INTEGRATION_DOMAIN, INPUT_DEBOUNCE_MS, THRESHOLD_DEBOUNCE_MS } from "../constants.js"; import { escapeHtml } from "../helpers/sanitize.js"; import { t } from "../i18n.js"; +import type { ErrorStore } from "../core/error-store.js"; import type { HomeAssistant, MonitoringPointInfo, MonitoringStatusResponse, CallServiceResponse } from "../types.js"; const FIELD_STYLE = ` @@ -33,6 +34,29 @@ const CELL_INPUT_STYLE = ` text-align:center; `; +/** + * Lightweight runtime-coerce for the ``get_monitoring_status`` response. + * The backend returns JSON whose shape is nominally ``MonitoringStatusResponse`` + * but we can't trust the WebSocket payload, so we narrow what we can and + * return ``null`` if the shape is unusable. Unknown fields are dropped. + */ +function coerceMonitoringStatusResponse(resp: unknown): MonitoringStatusResponse | null { + if (!resp || typeof resp !== "object") return null; + const r = resp as Record; + const out: MonitoringStatusResponse = {}; + if (typeof r.enabled === "boolean") out.enabled = r.enabled; + if (r.global_settings && typeof r.global_settings === "object") { + out.global_settings = r.global_settings as MonitoringStatusResponse["global_settings"]; + } + if (r.circuits && typeof r.circuits === "object") { + out.circuits = r.circuits as Record; + } + if (r.mains && typeof r.mains === "object") { + out.mains = r.mains as Record; + } + return out; +} + function thresholdCell(entityId: string, field: string, value: number | undefined, unit: string, type: string): string { return ` | null; private _configEntryId: string | null; private _notifyCloseHandler: ((e: MouseEvent) => void) | null; + private _headerHTML: string; constructor() { this._debounceTimer = null; this._configEntryId = null; this._notifyCloseHandler = null; + this._headerHTML = ""; } stop(): void { @@ -63,8 +90,9 @@ export class MonitoringTab { } } - async render(container: HTMLElement, hass: HomeAssistant, configEntryId?: string): Promise { + async render(container: HTMLElement, hass: HomeAssistant, configEntryId?: string, headerHTML: string = ""): Promise { if (configEntryId !== undefined) this._configEntryId = configEntryId; + this._headerHTML = headerHTML; if (this._notifyCloseHandler) { document.removeEventListener("click", this._notifyCloseHandler as EventListener); this._notifyCloseHandler = null; @@ -80,8 +108,9 @@ export class MonitoringTab { service_data: serviceData, return_response: true, }); - status = (resp?.response as MonitoringStatusResponse) || null; - } catch { + status = coerceMonitoringStatusResponse(resp?.response); + } catch (err) { + console.warn("SPAN Panel: monitoring status fetch failed", err); status = null; } @@ -191,6 +220,7 @@ export class MonitoringTab { .join(""); container.innerHTML = ` + ${this._headerHTML}

${t("monitoring.heading")}

@@ -429,24 +459,44 @@ export class MonitoringTab { const fieldsDiv = container.querySelector("#global-fields"); const statusEl = container.querySelector("#global-status"); + const readGlobalFields = (): Record | null => { + const fields: Array<[string, string]> = [ + ["continuous_threshold_pct", "#g-continuous"], + ["spike_threshold_pct", "#g-spike"], + ["window_duration_m", "#g-window"], + ["cooldown_duration_m", "#g-cooldown"], + ]; + const out: Record = {}; + for (const [key, sel] of fields) { + const input = container.querySelector(sel); + if (!input) return null; + const n = parseInt(input.value, 10); + if (Number.isNaN(n)) return null; + out[key] = n; + } + return out; + }; + + const reportFailure = (target: HTMLElement | null, err: unknown, fallback: string): void => { + if (!target) return; + const message = err instanceof Error ? err.message : fallback; + target.textContent = `${t("error.prefix")} ${message}`; + target.style.color = "var(--error-color, #f44336)"; + }; + const saveGlobal = (): void => { if (this._debounceTimer) clearTimeout(this._debounceTimer); this._debounceTimer = setTimeout(async () => { - const data: Record = { - continuous_threshold_pct: parseInt(container.querySelector("#g-continuous")!.value, 10), - spike_threshold_pct: parseInt(container.querySelector("#g-spike")!.value, 10), - window_duration_m: parseInt(container.querySelector("#g-window")!.value, 10), - cooldown_duration_m: parseInt(container.querySelector("#g-cooldown")!.value, 10), - }; + const data = readGlobalFields(); + if (!data) { + reportFailure(statusEl, null, t("error.failed_save")); + return; + } try { await this._callSetGlobal(hass, data); await this.render(container, hass); } catch (err: unknown) { - if (statusEl) { - const message = err instanceof Error ? err.message : t("error.failed_save"); - statusEl.textContent = `${t("error.prefix")} ${message}`; - statusEl.style.color = "var(--error-color, #f44336)"; - } + reportFailure(statusEl, err, t("error.failed_save")); } }, INPUT_DEBOUNCE_MS); }; @@ -461,22 +511,17 @@ export class MonitoringTab { const statusEl2 = container.querySelector("#global-status"); try { if (enabled) { - const data: Record = { - continuous_threshold_pct: parseInt(container.querySelector("#g-continuous")!.value, 10), - spike_threshold_pct: parseInt(container.querySelector("#g-spike")!.value, 10), - window_duration_m: parseInt(container.querySelector("#g-window")!.value, 10), - cooldown_duration_m: parseInt(container.querySelector("#g-cooldown")!.value, 10), - }; + const data = readGlobalFields(); + if (!data) { + reportFailure(statusEl2, null, t("error.failed")); + return; + } await this._callSetGlobal(hass, data); } else { await this._callSetGlobal(hass, { enabled: false }); } } catch (err: unknown) { - if (statusEl2) { - const message = err instanceof Error ? err.message : t("error.failed"); - statusEl2.textContent = `${t("error.prefix")} ${message}`; - statusEl2.style.color = "var(--error-color, #f44336)"; - } + reportFailure(statusEl2, err, t("error.failed")); return; } await this.render(container, hass); @@ -529,8 +574,14 @@ export class MonitoringTab { this._debounceTimer = setTimeout(async () => { try { await this._callSetGlobal(hass, { notify_targets: targets.join(", ") }); - } catch { - // will show on next render + } catch (err) { + console.warn("SPAN Panel: notification targets save failed", err); + this.errorStore?.add({ + key: "service:monitoring", + level: "error", + message: t("error.threshold_failed"), + persistent: false, + }); } }, INPUT_DEBOUNCE_MS); }; @@ -571,8 +622,14 @@ export class MonitoringTab { this._debounceTimer = setTimeout(async () => { try { await this._callSetGlobal(hass, { [field]: value }); - } catch { - // will show on next render + } catch (err) { + console.warn("SPAN Panel: notification settings save failed", err); + this.errorStore?.add({ + key: "service:monitoring", + level: "error", + message: t("error.threshold_failed"), + persistent: false, + }); } }, INPUT_DEBOUNCE_MS); }; @@ -582,8 +639,14 @@ export class MonitoringTab { try { await this._callSetGlobal(hass, { notification_priority: prioritySelect.value }); await this.render(container, hass); - } catch { - // will show on next render + } catch (err) { + console.warn("SPAN Panel: notification priority change failed", err); + this.errorStore?.add({ + key: "service:monitoring", + level: "error", + message: t("error.threshold_failed"), + persistent: false, + }); } }); } @@ -662,7 +725,15 @@ export class MonitoringTab { service: "set_circuit_threshold", service_data: this._serviceData({ circuit_id: entityId, monitoring_enabled: enabled }), }) - .catch(() => {}) + .catch(err => { + console.warn("SPAN Panel: circuit monitoring toggle failed", err); + this.errorStore?.add({ + key: "service:monitoring", + level: "error", + message: t("error.threshold_failed"), + persistent: false, + }); + }) ), ...Object.keys(mains).map(entityId => hass @@ -672,7 +743,15 @@ export class MonitoringTab { service: "set_mains_threshold", service_data: this._serviceData({ leg: entityId, monitoring_enabled: enabled }), }) - .catch(() => {}) + .catch(err => { + console.warn("SPAN Panel: mains monitoring toggle failed", err); + this.errorStore?.add({ + key: "service:monitoring", + level: "error", + message: t("error.threshold_failed"), + persistent: false, + }); + }) ), ]; await Promise.all(calls); @@ -692,7 +771,14 @@ export class MonitoringTab { service: "set_mains_threshold", service_data: this._serviceData({ leg: entityId, monitoring_enabled: enabled }), }); - } catch { + } catch (err) { + console.warn("SPAN Panel: mains threshold toggle failed", err); + this.errorStore?.add({ + key: "service:monitoring", + level: "error", + message: t("error.threshold_failed"), + persistent: false, + }); cb.checked = !enabled; return; } @@ -713,7 +799,14 @@ export class MonitoringTab { service: "set_circuit_threshold", service_data: this._serviceData({ circuit_id: entityId, monitoring_enabled: enabled }), }); - } catch { + } catch (err) { + console.warn("SPAN Panel: circuit threshold toggle failed", err); + this.errorStore?.add({ + key: "service:monitoring", + level: "error", + message: t("error.threshold_failed"), + persistent: false, + }); cb.checked = !enabled; return; } @@ -748,7 +841,14 @@ export class MonitoringTab { }); // Re-render to update Custom badge and Reset button await this.render(container, hass); - } catch { + } catch (err) { + console.warn("SPAN Panel: threshold input save failed", err); + this.errorStore?.add({ + key: "service:monitoring", + level: "error", + message: t("error.threshold_failed"), + persistent: false, + }); input.style.borderColor = "var(--error-color, #f44336)"; } }, THRESHOLD_DEBOUNCE_MS) diff --git a/src/panel/tab-settings.ts b/src/panel/tab-settings.ts index 766d5b9..e271e79 100644 --- a/src/panel/tab-settings.ts +++ b/src/panel/tab-settings.ts @@ -1,7 +1,27 @@ import { INTEGRATION_DOMAIN, GRAPH_HORIZONS, DEFAULT_GRAPH_HORIZON, INPUT_DEBOUNCE_MS } from "../constants.js"; import { escapeHtml } from "../helpers/sanitize.js"; import { t } from "../i18n.js"; -import type { HomeAssistant, PanelTopology, GraphSettings, CallServiceResponse } from "../types.js"; +import type { HomeAssistant, PanelTopology, GraphSettings, CircuitGraphOverride, CallServiceResponse } from "../types.js"; + +/** + * Narrow an unvalidated ``get_graph_settings`` response into a + * ``GraphSettings`` we can trust. Unknown fields are dropped; bad shape + * returns null so callers fall back to defaults instead of silently + * reading undefined properties. + */ +function coerceGraphSettingsResponse(resp: unknown): GraphSettings | null { + if (!resp || typeof resp !== "object") return null; + const r = resp as Record; + const out: GraphSettings = {}; + if (typeof r.global_horizon === "string") out.global_horizon = r.global_horizon; + if (r.circuits && typeof r.circuits === "object") { + out.circuits = r.circuits as Record; + } + if (r.sub_devices && typeof r.sub_devices === "object") { + out.sub_devices = r.sub_devices as Record; + } + return out; +} function horizonOptions(selectedKey: string): string { return Object.keys(GRAPH_HORIZONS) @@ -53,8 +73,9 @@ export class SettingsTab { service_data: serviceData, return_response: true, }); - graphSettings = (resp?.response as GraphSettings) || null; - } catch { + graphSettings = coerceGraphSettingsResponse(resp?.response); + } catch (err) { + console.warn("SPAN Panel: graph settings fetch failed", err); graphSettings = null; } diff --git a/src/types.ts b/src/types.ts index c2ff5cf..5f3276d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,6 +62,7 @@ export interface PanelEntities { pv_power?: string; battery_level?: string; dsm_state?: string; + panel_status?: string; // binary_sensor entity for online/offline state } export interface PanelTopology { @@ -125,6 +126,48 @@ export interface ChartMetricDef { fixedMax?: number; } +// -- Favorites -- + +export interface PanelFavoritesEntry { + circuits: string[]; + sub_devices: string[]; +} + +/** + * Cross-panel favorites, keyed by the main SPAN panel device id + * (HA device registry id) and grouping the panel-local circuit uuids + * and sub-device HA device ids the user has marked. + */ +export interface FavoritesMap { + [panelDeviceId: string]: PanelFavoritesEntry; +} + +export type FavoriteKind = "circuit" | "sub_device"; + +/** + * Origin metadata for a favorited circuit or sub-device, used by the + * Favorites pseudo-panel to route per-target service calls back to the + * correct SPAN panel/config entry after aggregating across panels. + */ +export interface FavoriteRef { + panelDeviceId: string; + kind: FavoriteKind; + /** Real circuit uuid (kind ``circuit``) or HA device id (kind ``sub_device``). */ + targetId: string; + configEntryId: string | null; +} + +/** + * Merged topology for the Favorites pseudo-panel. Circuits and + * sub-devices are keyed by composite ids ``"{panelDeviceId}|{targetId}"`` + * so identifiers from different panels can't collide. + * ``_favoriteRefs`` maps composite ids back to their origin for + * downstream service calls. + */ +export interface FavoritesTopology extends PanelTopology { + _favoriteRefs: Record; +} + // -- Graph settings -- export interface CircuitGraphOverride { diff --git a/tests/circuit-state.test.ts b/tests/circuit-state.test.ts new file mode 100644 index 0000000..09b2835 --- /dev/null +++ b/tests/circuit-state.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { getCircuitStateClasses } from "../src/core/circuit-state.js"; +import type { Circuit, MonitoringPointInfo } from "../src/types.js"; + +const baseCircuit = { name: "Test" } as Circuit; + +describe("getCircuitStateClasses", () => { + it("returns empty string when circuit is on, not producer, no monitoring info", () => { + expect(getCircuitStateClasses(baseCircuit, null, true, false)).toBe(""); + }); + + it("adds circuit-off when isOn is false", () => { + expect(getCircuitStateClasses(baseCircuit, null, false, false)).toBe("circuit-off"); + }); + + it("adds circuit-producer when isProducer is true", () => { + expect(getCircuitStateClasses(baseCircuit, null, true, true)).toBe("circuit-producer"); + }); + + it("adds both when off and producer", () => { + const result = getCircuitStateClasses(baseCircuit, null, false, true); + expect(result).toContain("circuit-off"); + expect(result).toContain("circuit-producer"); + }); + + it("adds circuit-alert when monitoringInfo indicates alert", () => { + const info: MonitoringPointInfo = { utilization_pct: 95, over_threshold_since: "2024-01-01T00:00:00Z" }; + const result = getCircuitStateClasses(baseCircuit, info, true, false); + expect(result).toContain("circuit-alert"); + }); + + it("adds circuit-custom-monitoring when continuous_threshold_pct is set", () => { + const info: MonitoringPointInfo = { continuous_threshold_pct: 80 }; + const result = getCircuitStateClasses(baseCircuit, info, true, false); + expect(result).toContain("circuit-custom-monitoring"); + }); + + it("handles all classes together", () => { + const info: MonitoringPointInfo = { + utilization_pct: 99, + over_threshold_since: "2024-01-01T00:00:00Z", + continuous_threshold_pct: 80, + }; + const result = getCircuitStateClasses(baseCircuit, info, false, true); + expect(result).toContain("circuit-off"); + expect(result).toContain("circuit-producer"); + expect(result).toContain("circuit-alert"); + expect(result).toContain("circuit-custom-monitoring"); + }); +}); diff --git a/tests/coalesce.test.ts b/tests/coalesce.test.ts new file mode 100644 index 0000000..6e73a2b --- /dev/null +++ b/tests/coalesce.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi } from "vitest"; +import { coalesceRuns, makeRenderToken } from "../src/panel/coalesce.js"; + +// --------------------------------------------------------------------------- +// coalesceRuns +// --------------------------------------------------------------------------- + +describe("coalesceRuns", () => { + it("single call → work runs exactly once", async () => { + const work = vi.fn().mockResolvedValue(undefined); + const schedule = coalesceRuns(work); + + await schedule(); + + expect(work).toHaveBeenCalledTimes(1); + }); + + it("two concurrent calls → work runs twice (once in-flight, one follow-up)", async () => { + let resolveFirst!: () => void; + const firstRunStarted = new Promise(res => { + resolveFirst = res; + }); + + let releaseFirst!: () => void; + const firstRunGate = new Promise(res => { + releaseFirst = res; + }); + + const callOrder: number[] = []; + let callCount = 0; + + const work = vi.fn().mockImplementation(async () => { + callCount++; + const thisCall = callCount; + callOrder.push(thisCall); + if (thisCall === 1) { + resolveFirst(); + await firstRunGate; + } + }); + + const schedule = coalesceRuns(work); + + // Start caller 1 — it will block until we release the gate + const p1 = schedule(); + + // Wait until work has actually started so the in-flight flag is set + await firstRunStarted; + + // Caller 2 arrives while caller 1 is in-flight + const p2 = schedule(); + // Caller 3 also arrives — should collapse into the same follow-up + const p3 = schedule(); + + // Release caller 1 + releaseFirst(); + + await Promise.all([p1, p2, p3]); + + // Work should have run exactly twice: the original + one follow-up + expect(work).toHaveBeenCalledTimes(2); + }); + + it("multiple concurrent arrivals → still only 2 total runs", async () => { + let releaseFirst!: () => void; + const firstRunGate = new Promise(res => { + releaseFirst = res; + }); + let firstStarted!: () => void; + const firstRunStarted = new Promise(res => { + firstStarted = res; + }); + + let runCount = 0; + const work = vi.fn().mockImplementation(async () => { + runCount++; + if (runCount === 1) { + firstStarted(); + await firstRunGate; + } + }); + + const schedule = coalesceRuns(work); + const p1 = schedule(); + + await firstRunStarted; + + // Fire five more while p1 is in-flight + const extras = Array.from({ length: 5 }, () => schedule()); + + releaseFirst(); + await Promise.all([p1, ...extras]); + + expect(work).toHaveBeenCalledTimes(2); + }); + + it("work throwing → in-flight flag clears, next call runs successfully", async () => { + let shouldThrow = true; + const work = vi.fn().mockImplementation(async () => { + if (shouldThrow) throw new Error("boom"); + }); + + const schedule = coalesceRuns(work); + + await expect(schedule()).rejects.toThrow("boom"); + + // After failure, the scheduler should be free to run again + shouldThrow = false; + await expect(schedule()).resolves.toBeUndefined(); + expect(work).toHaveBeenCalledTimes(2); + }); + + it("work throwing with a follow-up pending → follow-up still runs", async () => { + let resolveFirst!: () => void; + const firstRunGate = new Promise(res => { + resolveFirst = res; + }); + let firstStarted!: () => void; + const firstRunStarted = new Promise(res => { + firstStarted = res; + }); + + let callCount = 0; + const work = vi.fn().mockImplementation(async () => { + callCount++; + if (callCount === 1) { + firstStarted(); + await firstRunGate; + throw new Error("boom"); + } + }); + + const schedule = coalesceRuns(work); + const p1 = schedule(); + await firstRunStarted; + + // Caller 2 arrives while caller 1 is in-flight + const p2 = schedule(); + + // Release caller 1 — it will throw + resolveFirst(); + + // p1 rejects; p2 resolves (it awaited inFlight.catch()) + await expect(p1).rejects.toThrow("boom"); + await expect(p2).resolves.toBeUndefined(); + + // Work should have run twice: the original (threw) + one follow-up (succeeded) + expect(work).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// makeRenderToken +// --------------------------------------------------------------------------- + +describe("makeRenderToken", () => { + it("first call: superseded() returns false before another beginRun", () => { + const beginRun = makeRenderToken(); + const superseded = beginRun(); + expect(superseded()).toBe(false); + }); + + it("after a second beginRun, first superseded() returns true", () => { + const beginRun = makeRenderToken(); + const superseded1 = beginRun(); + beginRun(); // second render begins + expect(superseded1()).toBe(true); + }); + + it("second superseded() continues to return false", () => { + const beginRun = makeRenderToken(); + beginRun(); // first render + const superseded2 = beginRun(); // second render + expect(superseded2()).toBe(false); + }); + + it("each factory instance has independent counter state", () => { + const beginRunA = makeRenderToken(); + const beginRunB = makeRenderToken(); + + const supersededA = beginRunA(); + beginRunB(); // advances B's counter, not A's + beginRunB(); + + // A's token should still be valid + expect(supersededA()).toBe(false); + }); +}); diff --git a/tests/error-store.test.ts b/tests/error-store.test.ts new file mode 100644 index 0000000..3d18852 --- /dev/null +++ b/tests/error-store.test.ts @@ -0,0 +1,662 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { HomeAssistant } from "../src/types.js"; +import { ErrorStore } from "../src/core/error-store.js"; +import type { ErrorEntry } from "../src/core/error-store.js"; +import { tf, setLanguage } from "../src/i18n.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeHass(entityId: string, state: string): HomeAssistant { + return { + states: { + [entityId]: { + entity_id: entityId, + state, + attributes: {}, + last_changed: "", + last_updated: "", + }, + }, + services: {}, + language: "en", + callService: vi.fn(), + callWS: vi.fn(), + } as unknown as HomeAssistant; +} + +function makeEmptyHass(): HomeAssistant { + return { + states: {}, + services: {}, + language: "en", + callService: vi.fn(), + callWS: vi.fn(), + } as unknown as HomeAssistant; +} + +// --------------------------------------------------------------------------- +// add / remove / active +// --------------------------------------------------------------------------- + +describe("ErrorStore — add/remove/active", () => { + let store: ErrorStore; + + beforeEach(() => { + vi.useFakeTimers(); + store = new ErrorStore(); + }); + + afterEach(() => { + store.dispose(); + vi.useRealTimers(); + }); + + it("starts empty", () => { + expect(store.active).toHaveLength(0); + }); + + it("adds a persistent error", () => { + store.add({ key: "p1", level: "error", message: "Persistent", persistent: true }); + expect(store.active).toHaveLength(1); + expect(store.active[0]?.key).toBe("p1"); + expect(store.active[0]?.persistent).toBe(true); + }); + + it("adds a transient error with default TTL and auto-dismisses after 5000ms", () => { + store.add({ key: "t1", level: "warning", message: "Transient", persistent: false }); + expect(store.active).toHaveLength(1); + + vi.advanceTimersByTime(4999); + expect(store.active).toHaveLength(1); + + vi.advanceTimersByTime(1); + expect(store.active).toHaveLength(0); + }); + + it("persistent errors are never auto-dismissed", () => { + store.add({ key: "p1", level: "error", message: "Persistent", persistent: true }); + vi.advanceTimersByTime(60_000); + expect(store.active).toHaveLength(1); + }); + + it("removes a persistent error by key", () => { + store.add({ key: "p1", level: "error", message: "Persistent", persistent: true }); + store.remove("p1"); + expect(store.active).toHaveLength(0); + }); + + it("removes the transient error by key", () => { + store.add({ key: "t1", level: "info", message: "Transient", persistent: false }); + store.remove("t1"); + expect(store.active).toHaveLength(0); + }); + + it("remove is a no-op for unknown key", () => { + store.add({ key: "p1", level: "error", message: "Persistent", persistent: true }); + store.remove("unknown-key"); + expect(store.active).toHaveLength(1); + }); + + it("transient entry with custom TTL auto-dismisses after custom duration", () => { + store.add({ key: "t1", level: "info", message: "Short", persistent: false, ttl: 1000 }); + vi.advanceTimersByTime(999); + expect(store.active).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(store.active).toHaveLength(0); + }); + + it("active includes timestamp", () => { + const before = Date.now(); + store.add({ key: "p1", level: "error", message: "M", persistent: true }); + const entry = store.active[0] as ErrorEntry; + expect(entry.timestamp).toBeGreaterThanOrEqual(before); + }); +}); + +// --------------------------------------------------------------------------- +// Two-lane model +// --------------------------------------------------------------------------- + +describe("ErrorStore — two-lane model", () => { + let store: ErrorStore; + + beforeEach(() => { + vi.useFakeTimers(); + store = new ErrorStore(); + }); + + afterEach(() => { + store.dispose(); + vi.useRealTimers(); + }); + + it("persistent appears before transient", () => { + store.add({ key: "p1", level: "error", message: "Persistent", persistent: true }); + store.add({ key: "t1", level: "info", message: "Transient", persistent: false }); + const active = store.active; + expect(active).toHaveLength(2); + expect(active[0]?.key).toBe("p1"); + expect(active[1]?.key).toBe("t1"); + }); + + it("new transient replaces previous transient", () => { + store.add({ key: "t1", level: "info", message: "First", persistent: false }); + store.add({ key: "t2", level: "warning", message: "Second", persistent: false }); + const active = store.active; + expect(active).toHaveLength(1); + expect(active[0]?.key).toBe("t2"); + }); + + it("replacing transient does not affect persistent", () => { + store.add({ key: "p1", level: "error", message: "Persistent", persistent: true }); + store.add({ key: "t1", level: "info", message: "First transient", persistent: false }); + store.add({ key: "t2", level: "info", message: "Second transient", persistent: false }); + expect(store.active).toHaveLength(2); + expect(store.active[0]?.key).toBe("p1"); + expect(store.active[1]?.key).toBe("t2"); + }); + + it("multiple persistent can coexist", () => { + store.add({ key: "p1", level: "error", message: "P1", persistent: true }); + store.add({ key: "p2", level: "warning", message: "P2", persistent: true }); + store.add({ key: "p3", level: "error", message: "P3", persistent: true }); + expect(store.active).toHaveLength(3); + const keys = store.active.map(e => e.key); + expect(keys).toContain("p1"); + expect(keys).toContain("p2"); + expect(keys).toContain("p3"); + }); + + it("re-adding the same transient key resets TTL timer", () => { + store.add({ key: "t1", level: "info", message: "Transient", persistent: false, ttl: 5000 }); + + // Advance 4000ms — still alive + vi.advanceTimersByTime(4000); + expect(store.active).toHaveLength(1); + + // Re-add with same key — should reset timer + store.add({ key: "t1", level: "info", message: "Transient", persistent: false, ttl: 5000 }); + + // Another 4000ms — still alive because the timer was reset + vi.advanceTimersByTime(4000); + expect(store.active).toHaveLength(1); + + // The remaining 1000ms passes — now it dismisses + vi.advanceTimersByTime(1001); + expect(store.active).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// clear +// --------------------------------------------------------------------------- + +describe("ErrorStore — clear", () => { + let store: ErrorStore; + + beforeEach(() => { + vi.useFakeTimers(); + store = new ErrorStore(); + }); + + afterEach(() => { + store.dispose(); + vi.useRealTimers(); + }); + + it("clear with no filter clears everything", () => { + store.add({ key: "p1", level: "error", message: "P1", persistent: true }); + store.add({ key: "p2", level: "warning", message: "P2", persistent: true }); + store.add({ key: "t1", level: "info", message: "T1", persistent: false }); + store.clear(); + expect(store.active).toHaveLength(0); + }); + + it("clear({ persistent: true }) clears only persistent", () => { + store.add({ key: "p1", level: "error", message: "P1", persistent: true }); + store.add({ key: "t1", level: "info", message: "T1", persistent: false }); + store.clear({ persistent: true }); + expect(store.active).toHaveLength(1); + expect(store.active[0]?.key).toBe("t1"); + }); + + it("clear({ persistent: false }) clears only transient", () => { + store.add({ key: "p1", level: "error", message: "P1", persistent: true }); + store.add({ key: "t1", level: "info", message: "T1", persistent: false }); + store.clear({ persistent: false }); + expect(store.active).toHaveLength(1); + expect(store.active[0]?.key).toBe("p1"); + }); + + it("resets panel status watching state on full clear", () => { + store.watchPanelStatus("binary_sensor.panel_status"); + store.updateHass({ states: { "binary_sensor.panel_status": { state: "off" } } } as any); + expect(store.hasPersistent("panel-offline")).toBe(true); + + store.clear(); + + // After clear, updateHass should not re-add the panel-offline error + // because the watched entity ID was reset. + store.updateHass({ states: { "binary_sensor.panel_status": { state: "off" } } } as any); + expect(store.hasPersistent("panel-offline")).toBe(false); + }); + + it("does not reset panel status watching on filtered clear", () => { + store.watchPanelStatus("binary_sensor.panel_status"); + store.updateHass({ states: { "binary_sensor.panel_status": { state: "off" } } } as any); + + store.clear({ persistent: true }); + + // Watched entity is still set; re-firing updateHass re-adds panel-offline + store.updateHass({ states: { "binary_sensor.panel_status": { state: "off" } } } as any); + expect(store.hasPersistent("panel-offline")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// subscribe +// --------------------------------------------------------------------------- + +describe("ErrorStore — subscribe", () => { + let store: ErrorStore; + + beforeEach(() => { + vi.useFakeTimers(); + store = new ErrorStore(); + }); + + afterEach(() => { + store.dispose(); + vi.useRealTimers(); + }); + + it("notifies on add", () => { + const cb = vi.fn(); + store.subscribe(cb); + store.add({ key: "p1", level: "error", message: "M", persistent: true }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("notifies on remove", () => { + const cb = vi.fn(); + store.add({ key: "p1", level: "error", message: "M", persistent: true }); + store.subscribe(cb); + store.remove("p1"); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("notifies on auto-dismiss of transient", () => { + const cb = vi.fn(); + store.add({ key: "t1", level: "info", message: "T", persistent: false, ttl: 1000 }); + store.subscribe(cb); + vi.advanceTimersByTime(1000); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it("unsubscribe stops notifications", () => { + const cb = vi.fn(); + const unsub = store.subscribe(cb); + unsub(); + store.add({ key: "p1", level: "error", message: "M", persistent: true }); + expect(cb).not.toHaveBeenCalled(); + }); + + it("remove on unknown key does not notify", () => { + const cb = vi.fn(); + store.subscribe(cb); + store.remove("does-not-exist"); + expect(cb).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// hasPersistent +// --------------------------------------------------------------------------- + +describe("ErrorStore — hasPersistent", () => { + let store: ErrorStore; + + beforeEach(() => { + store = new ErrorStore(); + }); + + afterEach(() => { + store.dispose(); + }); + + it("returns false when persistent error is not present", () => { + expect(store.hasPersistent("panel-offline")).toBe(false); + }); + + it("returns true when persistent error is present", () => { + store.add({ key: "panel-offline", level: "error", message: "Offline", persistent: true }); + expect(store.hasPersistent("panel-offline")).toBe(true); + }); + + it("returns false after the error is removed", () => { + store.add({ key: "panel-offline", level: "error", message: "Offline", persistent: true }); + store.remove("panel-offline"); + expect(store.hasPersistent("panel-offline")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// watchPanelStatus / updateHass +// --------------------------------------------------------------------------- + +describe("ErrorStore — panel status watching", () => { + const ENTITY_ID = "binary_sensor.span_panel_door"; + let store: ErrorStore; + + beforeEach(() => { + vi.useFakeTimers(); + store = new ErrorStore(); + store.watchPanelStatus(ENTITY_ID); + }); + + afterEach(() => { + store.dispose(); + vi.useRealTimers(); + }); + + it("adds persistent panel-offline error when entity state is 'off'", () => { + store.updateHass(makeHass(ENTITY_ID, "off")); + expect(store.hasPersistent("panel-offline")).toBe(true); + }); + + it("removes panel-offline error when entity state becomes 'on'", () => { + store.updateHass(makeHass(ENTITY_ID, "off")); + expect(store.hasPersistent("panel-offline")).toBe(true); + store.updateHass(makeHass(ENTITY_ID, "on")); + expect(store.hasPersistent("panel-offline")).toBe(false); + }); + + it("treats 'unavailable' as offline", () => { + store.updateHass(makeHass(ENTITY_ID, "unavailable")); + expect(store.hasPersistent("panel-offline")).toBe(true); + }); + + it("treats 'unknown' as offline", () => { + store.updateHass(makeHass(ENTITY_ID, "unknown")); + expect(store.hasPersistent("panel-offline")).toBe(true); + }); + + it("adds transient reconnection info message on reconnect (but not on first online)", () => { + // First updateHass with "on" — should NOT add reconnection info + store.updateHass(makeHass(ENTITY_ID, "on")); + const afterFirstOnline = store.active.filter(e => e.key === "panel-reconnected"); + expect(afterFirstOnline).toHaveLength(0); + + // Now go offline and then online — reconnection info should appear + store.updateHass(makeHass(ENTITY_ID, "off")); + store.updateHass(makeHass(ENTITY_ID, "on")); + const afterReconnect = store.active.filter(e => e.key === "panel-reconnected"); + expect(afterReconnect).toHaveLength(1); + expect(afterReconnect[0]?.level).toBe("info"); + expect(afterReconnect[0]?.persistent).toBe(false); + }); + + it("handles missing entity gracefully (no error entity_id → treated as offline)", () => { + store.updateHass(makeEmptyHass()); + expect(store.hasPersistent("panel-offline")).toBe(true); + }); + + it("does not add reconnection info when online has always been online", () => { + store.updateHass(makeHass(ENTITY_ID, "on")); + store.updateHass(makeHass(ENTITY_ID, "on")); + const reconnected = store.active.filter(e => e.key === "panel-reconnected"); + expect(reconnected).toHaveLength(0); + }); + + it("resets was-offline state when switching watched entity", () => { + store.watchPanelStatus("binary_sensor.panel_a"); + store.updateHass({ states: { "binary_sensor.panel_a": { state: "off" } } } as any); + expect(store.hasPersistent("panel-offline")).toBe(true); + + // Switch to a different panel that is online + store.watchPanelStatus("binary_sensor.panel_b"); + store.updateHass({ states: { "binary_sensor.panel_b": { state: "on" } } } as any); + + // No spurious "reconnected" info should appear for panel B + expect(store.hasPersistent("panel-offline")).toBe(false); + expect(store.active.filter(e => e.level === "info")).toHaveLength(0); + }); + + it("clearPanelStatusWatch resets state", () => { + store.watchPanelStatus("binary_sensor.panel_a"); + store.updateHass({ states: { "binary_sensor.panel_a": { state: "off" } } } as any); + expect(store.hasPersistent("panel-offline")).toBe(true); + + store.clearPanelStatusWatch(); + expect(store.hasPersistent("panel-offline")).toBe(false); + + // Further updateHass should be a no-op + store.updateHass({ states: { "binary_sensor.panel_a": { state: "off" } } } as any); + expect(store.hasPersistent("panel-offline")).toBe(false); + }); + + it("clear() with no filter resets panel status watching state", () => { + store.watchPanelStatus("binary_sensor.panel_status"); + store.updateHass({ states: { "binary_sensor.panel_status": { state: "off" } } } as any); + expect(store.hasPersistent("panel-offline")).toBe(true); + + store.clear(); + + // After full clear, updateHass should be a no-op (no watched entity) + store.updateHass({ states: { "binary_sensor.panel_status": { state: "off" } } } as any); + expect(store.hasPersistent("panel-offline")).toBe(false); + }); + + it("clear({persistent: true}) preserves panel status watching state", () => { + store.watchPanelStatus("binary_sensor.panel_status"); + store.updateHass({ states: { "binary_sensor.panel_status": { state: "on" } } } as any); + + // Add a persistent error of a different key + store.add({ key: "discovery-failed", level: "error", message: "x", persistent: true }); + store.clear({ persistent: true }); + + // Watch is preserved — updateHass with off state re-adds panel-offline + store.updateHass({ states: { "binary_sensor.panel_status": { state: "off" } } } as any); + expect(store.hasPersistent("panel-offline")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// watchPanelStatuses — multi-panel status watching +// --------------------------------------------------------------------------- + +describe("ErrorStore — multi-panel status watching", () => { + const ENTITY_A = "binary_sensor.span_panel_1_panel_status"; + const ENTITY_B = "binary_sensor.span_panel_2_panel_status"; + let store: ErrorStore; + + function makeMultiHass(states: Record): HomeAssistant { + const built: Record = {}; + for (const [entityId, state] of Object.entries(states)) { + built[entityId] = { + entity_id: entityId, + state, + attributes: {}, + last_changed: "", + last_updated: "", + }; + } + return { + states: built, + services: {}, + language: "en", + callService: vi.fn(), + callWS: vi.fn(), + } as unknown as HomeAssistant; + } + + beforeEach(() => { + vi.useFakeTimers(); + store = new ErrorStore(); + }); + + afterEach(() => { + store.dispose(); + vi.useRealTimers(); + }); + + it("adds one named persistent row per offline entity", () => { + store.watchPanelStatuses([ + { entityId: ENTITY_A, panelName: "Panel A" }, + { entityId: ENTITY_B, panelName: "Panel B" }, + ]); + store.updateHass(makeMultiHass({ [ENTITY_A]: "off", [ENTITY_B]: "off" })); + + const rows = store.active.filter(e => e.key.startsWith("panel-offline")); + expect(rows).toHaveLength(2); + const messages = rows.map(r => r.message).sort(); + expect(messages).toEqual(["Panel A unreachable", "Panel B unreachable"]); + + // Legacy unnamed key must NOT be present when named entries are used. + expect(store.hasPersistent("panel-offline")).toBe(false); + expect(store.hasPersistent(`panel-offline:${ENTITY_A}`)).toBe(true); + expect(store.hasPersistent(`panel-offline:${ENTITY_B}`)).toBe(true); + }); + + it("transitioning single-unnamed → multi-named clears the legacy key", () => { + store.watchPanelStatus(ENTITY_A); + store.updateHass(makeMultiHass({ [ENTITY_A]: "off" })); + expect(store.hasPersistent("panel-offline")).toBe(true); + + store.watchPanelStatuses([{ entityId: ENTITY_A, panelName: "Panel A" }]); + store.updateHass(makeMultiHass({ [ENTITY_A]: "off" })); + + expect(store.hasPersistent("panel-offline")).toBe(false); + expect(store.hasPersistent(`panel-offline:${ENTITY_A}`)).toBe(true); + const row = store.active.find(e => e.key === `panel-offline:${ENTITY_A}`); + expect(row?.message).toBe("Panel A unreachable"); + }); + + it("transitioning multi-named → single-unnamed clears named keys", () => { + store.watchPanelStatuses([ + { entityId: ENTITY_A, panelName: "Panel A" }, + { entityId: ENTITY_B, panelName: "Panel B" }, + ]); + store.updateHass(makeMultiHass({ [ENTITY_A]: "off", [ENTITY_B]: "off" })); + expect(store.hasPersistent(`panel-offline:${ENTITY_A}`)).toBe(true); + expect(store.hasPersistent(`panel-offline:${ENTITY_B}`)).toBe(true); + + store.watchPanelStatus(ENTITY_A); + store.updateHass(makeMultiHass({ [ENTITY_A]: "off" })); + + expect(store.hasPersistent(`panel-offline:${ENTITY_A}`)).toBe(false); + expect(store.hasPersistent(`panel-offline:${ENTITY_B}`)).toBe(false); + expect(store.hasPersistent("panel-offline")).toBe(true); + const row = store.active.find(e => e.key === "panel-offline"); + expect(row?.message).toBe("SPAN Panel unreachable"); + }); + + it("named reconnect emits a scoped transient toast", () => { + store.watchPanelStatuses([ + { entityId: ENTITY_A, panelName: "Panel A" }, + { entityId: ENTITY_B, panelName: "Panel B" }, + ]); + store.updateHass(makeMultiHass({ [ENTITY_A]: "off", [ENTITY_B]: "off" })); + + // Panel A reconnects; Panel B still offline. + store.updateHass(makeMultiHass({ [ENTITY_A]: "on", [ENTITY_B]: "off" })); + + expect(store.hasPersistent(`panel-offline:${ENTITY_A}`)).toBe(false); + expect(store.hasPersistent(`panel-offline:${ENTITY_B}`)).toBe(true); + + const reconnect = store.active.find(e => e.key === `panel-reconnected:${ENTITY_A}`); + expect(reconnect).toBeDefined(); + expect(reconnect?.level).toBe("info"); + expect(reconnect?.persistent).toBe(false); + expect(reconnect?.message).toBe("Panel A reconnected"); + }); + + it("re-calling watchPanelStatuses with a carry-over entity preserves wasOffline", () => { + // First call: A is offline. + store.watchPanelStatuses([{ entityId: ENTITY_A, panelName: "Panel A" }]); + store.updateHass(makeMultiHass({ [ENTITY_A]: "off" })); + + // Second call: add B. A is still being watched; its wasOffline should carry. + store.watchPanelStatuses([ + { entityId: ENTITY_A, panelName: "Panel A" }, + { entityId: ENTITY_B, panelName: "Panel B" }, + ]); + + // Now A comes online. Because wasOffline carried, we expect a reconnect toast. + store.updateHass(makeMultiHass({ [ENTITY_A]: "on", [ENTITY_B]: "on" })); + + const reconnect = store.active.find(e => e.key === `panel-reconnected:${ENTITY_A}`); + expect(reconnect).toBeDefined(); + expect(reconnect?.message).toBe("Panel A reconnected"); + + // B was never offline, so no reconnect toast for B. + expect(store.active.find(e => e.key === `panel-reconnected:${ENTITY_B}`)).toBeUndefined(); + }); + + it("carries wasOffline across single-unnamed → multi-named transition for same entity", () => { + // Start with legacy single-unnamed watch; go offline. + store.watchPanelStatus(ENTITY_A); + store.updateHass(makeMultiHass({ [ENTITY_A]: "off" })); + expect(store.hasPersistent("panel-offline")).toBe(true); + + // Upgrade to named watch for the same entity id. The wasOffline flag + // must carry even across the naming-mode change. + store.watchPanelStatuses([{ entityId: ENTITY_A, panelName: "Panel A" }]); + + // Now come online. Because wasOffline carried, the named reconnect + // toast must fire. + store.updateHass(makeMultiHass({ [ENTITY_A]: "on" })); + const reconnect = store.active.find(e => e.key === `panel-reconnected:${ENTITY_A}`); + expect(reconnect).toBeDefined(); + expect(reconnect?.message).toBe("Panel A reconnected"); + }); + + it("clearPanelStatusWatch removes all watched entries (single and multi)", () => { + store.watchPanelStatuses([ + { entityId: ENTITY_A, panelName: "Panel A" }, + { entityId: ENTITY_B, panelName: "Panel B" }, + ]); + store.updateHass(makeMultiHass({ [ENTITY_A]: "off", [ENTITY_B]: "off" })); + expect(store.hasPersistent(`panel-offline:${ENTITY_A}`)).toBe(true); + expect(store.hasPersistent(`panel-offline:${ENTITY_B}`)).toBe(true); + + store.clearPanelStatusWatch(); + + expect(store.hasPersistent(`panel-offline:${ENTITY_A}`)).toBe(false); + expect(store.hasPersistent(`panel-offline:${ENTITY_B}`)).toBe(false); + // After clear, updateHass is a no-op + store.updateHass(makeMultiHass({ [ENTITY_A]: "off", [ENTITY_B]: "off" })); + expect(store.hasPersistent(`panel-offline:${ENTITY_A}`)).toBe(false); + expect(store.hasPersistent(`panel-offline:${ENTITY_B}`)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// tf() — translation with placeholder substitution +// --------------------------------------------------------------------------- + +describe("tf — translation with {placeholder} substitution", () => { + beforeEach(() => { + setLanguage("en"); + }); + + it("substitutes {name} in error.panel_offline_named", () => { + expect(tf("error.panel_offline_named", { name: "Span Panel 2" })).toBe("Span Panel 2 unreachable"); + }); + + it("renders {name} as a literal token when the variable is missing", () => { + expect(tf("error.panel_offline_named", {})).toBe("{name} unreachable"); + }); + + it("substitutes {name} in error.panel_reconnected_named", () => { + expect(tf("error.panel_reconnected_named", { name: "Span Panel 2" })).toBe("Span Panel 2 reconnected"); + }); + + it("falls back to English template when key is missing in active language", () => { + setLanguage("es"); + // Spanish template: "{name} inaccesible" + expect(tf("error.panel_offline_named", { name: "X" })).toBe("X inaccesible"); + }); +}); diff --git a/tests/favorites-cache.test.ts b/tests/favorites-cache.test.ts new file mode 100644 index 0000000..9ed2747 --- /dev/null +++ b/tests/favorites-cache.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { HomeAssistant } from "../src/types.js"; +import { FavoritesCache } from "../src/core/favorites-store.js"; + +function makeHass(responses: Array>): { hass: HomeAssistant; callCount: () => number } { + let i = 0; + const callWS = vi.fn(async () => { + const favorites = responses[i] ?? {}; + i += 1; + return { response: { favorites } }; + }); + const hass = { states: {}, services: {}, language: "en", callWS } as unknown as HomeAssistant; + return { hass, callCount: () => i }; +} + +describe("FavoritesCache", () => { + let cache: FavoritesCache; + + beforeEach(() => { + cache = new FavoritesCache(); + }); + + it("returns fresh data on first fetch", async () => { + const { hass } = makeHass([{ panelA: { circuits: ["c1"], sub_devices: [] } }]); + const map = await cache.fetch(hass); + expect(map).toEqual({ panelA: { circuits: ["c1"], sub_devices: [] } }); + }); + + it("deduplicates concurrent in-flight fetches", async () => { + const { hass, callCount } = makeHass([{ panelA: { circuits: ["c1"], sub_devices: [] } }]); + const [a, b] = await Promise.all([cache.fetch(hass), cache.fetch(hass)]); + expect(callCount()).toBe(1); + expect(a).toEqual(b); + }); + + it("invalidate() supersedes an in-flight fetch so stale data is not cached", async () => { + // Two different backend states: pre-toggle and post-toggle. + const { hass } = makeHass([{ panelA: { circuits: ["c1"], sub_devices: [] } }, { panelA: { circuits: ["c1", "c2"], sub_devices: [] } }]); + + const firstPromise = cache.fetch(hass); + // User toggles a favorite → event handler invalidates the cache while + // ``firstPromise`` is still pending. The first fetch's result must not + // be committed to the cache as "fresh". + cache.invalidate(); + await firstPromise; + + // A follow-up fetch must re-query the backend rather than returning + // the now-stale pre-invalidate map from cache. + const second = await cache.fetch(hass); + expect(second).toEqual({ panelA: { circuits: ["c1", "c2"], sub_devices: [] } }); + }); + + it("returns cached map within TTL when not invalidated", async () => { + const { hass, callCount } = makeHass([{ panelA: { circuits: ["c1"], sub_devices: [] } }]); + await cache.fetch(hass); + const second = await cache.fetch(hass); + expect(callCount()).toBe(1); + expect(second).toEqual({ panelA: { circuits: ["c1"], sub_devices: [] } }); + }); + + it("fetch() after invalidate() issues a fresh request even while an earlier fetch is pending", async () => { + // Resolve manually so we can control the ordering: pre-toggle response + // stays pending until we release it. + let resolveFirst!: (resp: { response: { favorites: Record } }) => void; + const firstPromise = new Promise<{ response: { favorites: Record } }>(resolve => { + resolveFirst = resolve; + }); + const secondResponse = { response: { favorites: { panelA: { circuits: ["c1", "c2"], sub_devices: [] } } } }; + + let i = 0; + const callWS = vi.fn(async () => { + const idx = i; + i += 1; + return idx === 0 ? firstPromise : secondResponse; + }); + const hass = { states: {}, services: {}, language: "en", callWS } as unknown as HomeAssistant; + + const first = cache.fetch(hass); + cache.invalidate(); + + // Second fetch arrives while first is still in flight but after + // invalidate(). It must not dedupe onto the stale request; it must + // issue a new backend call and return the post-invalidate data. + const second = cache.fetch(hass); + expect(callWS).toHaveBeenCalledTimes(2); + + resolveFirst({ response: { favorites: { panelA: { circuits: ["c1"], sub_devices: [] } } } }); + + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult).toEqual({ panelA: { circuits: ["c1"], sub_devices: [] } }); + expect(secondResult).toEqual({ panelA: { circuits: ["c1", "c2"], sub_devices: [] } }); + }); + + it("clear() drops the cached map and bumps generation", async () => { + const { hass } = makeHass([{ panelA: { circuits: ["c1"], sub_devices: [] } }, { panelA: { circuits: [], sub_devices: [] } }]); + await cache.fetch(hass); + cache.clear(); + const second = await cache.fetch(hass); + expect(second).toEqual({ panelA: { circuits: [], sub_devices: [] } }); + }); +}); diff --git a/tests/favorites-controller.test.ts b/tests/favorites-controller.test.ts new file mode 100644 index 0000000..564bf5e --- /dev/null +++ b/tests/favorites-controller.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { HomeAssistant, PanelDevice, PanelTopology } from "../src/types.js"; + +vi.mock("../src/card/card-discovery.js", () => ({ + discoverTopology: vi.fn(), +})); + +import { discoverTopology } from "../src/card/card-discovery.js"; +import { FavoritesController, buildCompositeId } from "../src/core/favorites-controller.js"; + +const mockDiscover = vi.mocked(discoverTopology); + +const hass = { states: {} } as unknown as HomeAssistant; + +function makePanel(id: string, name: string, entryId: string): PanelDevice { + return { + id, + name, + name_by_user: undefined, + config_entries: [entryId], + } as unknown as PanelDevice; +} + +function makeTopology(circuits: Record, deviceName = "SPAN Panel"): PanelTopology { + const fullCircuits = Object.fromEntries( + Object.entries(circuits).map(([uuid, c]) => [uuid, { name: c.name, tabs: [], entities: {} } as unknown as PanelTopology["circuits"][string]]) + ); + return { + circuits: fullCircuits, + device_name: deviceName, + panel_entities: {}, + }; +} + +describe("FavoritesController.build", () => { + let controller: FavoritesController; + + beforeEach(() => { + controller = new FavoritesController(); + mockDiscover.mockReset(); + }); + + it("empty favorites → empty topology, no entries, no stats", async () => { + const result = await controller.build(hass, {}, []); + expect(Object.keys(result.topology.circuits)).toHaveLength(0); + expect(result.topology.sub_devices).toBeDefined(); + expect(Object.keys(result.topology.sub_devices!)).toHaveLength(0); + expect(result.entryIds).toEqual([]); + expect(result.perPanelStats).toEqual([]); + }); + + it("single panel with 2 favorited circuits → 2 composite-id circuits, no name prefix", async () => { + const panel = makePanel("panel-1", "Home Panel", "entry-1"); + const topology = makeTopology({ "uuid-a": { name: "Kitchen" }, "uuid-b": { name: "Living Room" } }); + mockDiscover.mockResolvedValue({ topology, panelDevice: panel, panelSize: 200 }); + + const favorites = { + "panel-1": { circuits: ["uuid-a", "uuid-b"], sub_devices: [] }, + }; + + const result = await controller.build(hass, favorites, [panel]); + + const circuitKeys = Object.keys(result.topology.circuits); + expect(circuitKeys).toHaveLength(2); + expect(circuitKeys).toContain(buildCompositeId("panel-1", "uuid-a")); + expect(circuitKeys).toContain(buildCompositeId("panel-1", "uuid-b")); + + // No prefix when only one panel contributes + expect(result.topology.circuits[buildCompositeId("panel-1", "uuid-a")].name).toBe("Kitchen"); + expect(result.topology.circuits[buildCompositeId("panel-1", "uuid-b")].name).toBe("Living Room"); + + expect(result.entryIds).toEqual(["entry-1"]); + expect(result.perPanelStats).toHaveLength(1); + expect(result.perPanelStats[0].panelDeviceId).toBe("panel-1"); + expect(result.perPanelStats[0].topology).toBe(topology); + }); + + it("two panels each with 1 favorite → 2 circuits, names prefixed with panel label", async () => { + const panel1 = makePanel("panel-1", "Span Panel 1", "entry-1"); + const panel2 = makePanel("panel-2", "Span Panel 2", "entry-2"); + const topology1 = makeTopology({ "uuid-a": { name: "Garage" } }, "SPAN 1"); + const topology2 = makeTopology({ "uuid-b": { name: "Kitchen" } }, "SPAN 2"); + + mockDiscover.mockImplementation((_hass, deviceId) => { + if (deviceId === "panel-1") return Promise.resolve({ topology: topology1, panelDevice: panel1, panelSize: 200 }); + if (deviceId === "panel-2") return Promise.resolve({ topology: topology2, panelDevice: panel2, panelSize: 200 }); + return Promise.reject(new Error("unknown panel")); + }); + + const favorites = { + "panel-1": { circuits: ["uuid-a"], sub_devices: [] }, + "panel-2": { circuits: ["uuid-b"], sub_devices: [] }, + }; + + const result = await controller.build(hass, favorites, [panel1, panel2]); + + expect(Object.keys(result.topology.circuits)).toHaveLength(2); + + // Names should be prefixed when more than one panel contributes + const circuit1 = result.topology.circuits[buildCompositeId("panel-1", "uuid-a")]; + const circuit2 = result.topology.circuits[buildCompositeId("panel-2", "uuid-b")]; + expect(circuit1.name).toBe("Span Panel 1 · Garage"); + expect(circuit2.name).toBe("Span Panel 2 · Kitchen"); + + expect(result.entryIds.sort()).toEqual(["entry-1", "entry-2"].sort()); + expect(result.perPanelStats).toHaveLength(2); + }); + + it("panel missing from panels list → its favorites are dropped", async () => { + const panel = makePanel("panel-1", "Home Panel", "entry-1"); + // "panel-2" is NOT in the panels array + const topology = makeTopology({ "uuid-a": { name: "Kitchen" } }); + mockDiscover.mockResolvedValue({ topology, panelDevice: panel, panelSize: 200 }); + + const favorites = { + "panel-1": { circuits: ["uuid-a"], sub_devices: [] }, + "panel-2": { circuits: ["uuid-z"], sub_devices: [] }, + }; + + const result = await controller.build(hass, favorites, [panel]); + + // Only panel-1's circuit appears; panel-2 was dropped before fetch + expect(Object.keys(result.topology.circuits)).toHaveLength(1); + expect(result.topology.circuits[buildCompositeId("panel-1", "uuid-a")]).toBeDefined(); + expect(mockDiscover).toHaveBeenCalledTimes(1); + expect(mockDiscover).toHaveBeenCalledWith(hass, "panel-1", null); + }); + + it("discoverTopology rejection for one panel → that panel dropped, others still merge", async () => { + const panel1 = makePanel("panel-1", "Good Panel", "entry-1"); + const panel2 = makePanel("panel-2", "Bad Panel", "entry-2"); + const topology1 = makeTopology({ "uuid-a": { name: "Office" } }); + + mockDiscover.mockImplementation((_hass, deviceId) => { + if (deviceId === "panel-1") return Promise.resolve({ topology: topology1, panelDevice: panel1, panelSize: 200 }); + return Promise.reject(new Error("fetch failed")); + }); + + const favorites = { + "panel-1": { circuits: ["uuid-a"], sub_devices: [] }, + "panel-2": { circuits: ["uuid-b"], sub_devices: [] }, + }; + + const result = await controller.build(hass, favorites, [panel1, panel2]); + + // Only panel-1 contributes; panel-2 fetch rejected and was dropped + expect(Object.keys(result.topology.circuits)).toHaveLength(1); + expect(result.topology.circuits[buildCompositeId("panel-1", "uuid-a")]).toBeDefined(); + expect(result.entryIds).toEqual(["entry-1"]); + expect(result.perPanelStats).toHaveLength(1); + }); + + it("favorited uuid not present in topology → silently dropped", async () => { + const panel = makePanel("panel-1", "Home Panel", "entry-1"); + // Topology only has "uuid-a"; "uuid-missing" is not in it + const topology = makeTopology({ "uuid-a": { name: "Kitchen" } }); + mockDiscover.mockResolvedValue({ topology, panelDevice: panel, panelSize: 200 }); + + const favorites = { + "panel-1": { circuits: ["uuid-a", "uuid-missing"], sub_devices: [] }, + }; + + const result = await controller.build(hass, favorites, [panel]); + + expect(Object.keys(result.topology.circuits)).toHaveLength(1); + expect(result.topology.circuits[buildCompositeId("panel-1", "uuid-a")]).toBeDefined(); + expect(result.topology.circuits[buildCompositeId("panel-1", "uuid-missing")]).toBeUndefined(); + }); + + it("favorited sub_device → appears under composite id, refs populated correctly", async () => { + const panel = makePanel("panel-1", "Home Panel", "entry-1"); + const topology: PanelTopology = { + circuits: {}, + sub_devices: { + "sub-uuid-1": { name: "Solar Inverter", type: "solar" }, + }, + device_name: "SPAN Panel", + panel_entities: {}, + }; + mockDiscover.mockResolvedValue({ topology, panelDevice: panel, panelSize: 200 }); + + const favorites = { + "panel-1": { circuits: [], sub_devices: ["sub-uuid-1"] }, + }; + + const result = await controller.build(hass, favorites, [panel]); + + const compositeId = buildCompositeId("panel-1", "sub-uuid-1"); + expect(result.topology.sub_devices![compositeId]).toBeDefined(); + expect(result.topology.sub_devices![compositeId].name).toBe("Solar Inverter"); + expect(result.topology._favoriteRefs[compositeId]).toMatchObject({ + panelDeviceId: "panel-1", + kind: "sub_device", + targetId: "sub-uuid-1", + configEntryId: "entry-1", + }); + }); + + it("_favoriteRefs records origin for circuits", async () => { + const panel = makePanel("panel-1", "Home Panel", "entry-1"); + const topology = makeTopology({ "uuid-a": { name: "Kitchen" } }); + mockDiscover.mockResolvedValue({ topology, panelDevice: panel, panelSize: 200 }); + + const favorites = { + "panel-1": { circuits: ["uuid-a"], sub_devices: [] }, + }; + + const result = await controller.build(hass, favorites, [panel]); + + const compositeId = buildCompositeId("panel-1", "uuid-a"); + expect(result.topology._favoriteRefs[compositeId]).toMatchObject({ + panelDeviceId: "panel-1", + kind: "circuit", + targetId: "uuid-a", + configEntryId: "entry-1", + }); + }); +}); diff --git a/tests/favorites-sections.test.ts b/tests/favorites-sections.test.ts new file mode 100644 index 0000000..0d0f95b --- /dev/null +++ b/tests/favorites-sections.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { groupFavoritesByPanel, sortedCircuitsForSection } from "../src/core/favorites-sections.js"; +import type { FavoriteRef, PanelTopology } from "../src/types.js"; + +function topo(circuitNames: Record): PanelTopology { + return { + circuits: Object.fromEntries( + Object.entries(circuitNames).map(([uuid, name]) => [uuid, { name, tabs: [], entities: {} } as PanelTopology["circuits"][string]]) + ), + device_name: "", + panel_entities: {}, + } as PanelTopology; +} + +describe("groupFavoritesByPanel", () => { + it("returns an empty array when favRefs has no circuit entries", () => { + const favRefs: Record = {}; + const perPanelInfo = new Map(); + expect(groupFavoritesByPanel(favRefs, perPanelInfo)).toEqual([]); + }); + + it("filters out 'sub_device' kind refs — only circuits are included", () => { + const favRefs: Record = { + "p1|u1": { panelDeviceId: "p1", kind: "circuit", targetId: "u1", configEntryId: "e1" }, + "p1|sd1": { panelDeviceId: "p1", kind: "sub_device", targetId: "sd1", configEntryId: "e1" }, + }; + const perPanelInfo = new Map([["p1", { panelName: "Panel 1", topology: topo({ u1: "Kitchen" }), configEntryId: "e1" }]]); + const result = groupFavoritesByPanel(favRefs, perPanelInfo); + expect(result).toHaveLength(1); + expect(result[0]?.favoriteCircuitUuids.size).toBe(1); + expect(result[0]?.favoriteCircuitUuids.has("u1")).toBe(true); + expect(result[0]?.favoriteCircuitUuids.has("sd1")).toBe(false); + }); + + it("groups favorite circuits by their panelDeviceId", () => { + const favRefs: Record = { + "p1|u1": { panelDeviceId: "p1", kind: "circuit", targetId: "u1", configEntryId: "e1" }, + "p1|u2": { panelDeviceId: "p1", kind: "circuit", targetId: "u2", configEntryId: "e1" }, + "p2|u3": { panelDeviceId: "p2", kind: "circuit", targetId: "u3", configEntryId: "e2" }, + }; + const perPanelInfo = new Map([ + ["p1", { panelName: "Panel A", topology: topo({ u1: "Kitchen", u2: "Living" }), configEntryId: "e1" }], + ["p2", { panelName: "Panel B", topology: topo({ u3: "Garage" }), configEntryId: "e2" }], + ]); + const result = groupFavoritesByPanel(favRefs, perPanelInfo); + expect(result).toHaveLength(2); + const byId = new Map(result.map(s => [s.panelDeviceId, s])); + expect(byId.get("p1")?.favoriteCircuitUuids.size).toBe(2); + expect(byId.get("p2")?.favoriteCircuitUuids.size).toBe(1); + }); + + it("sorts sections alphabetically by panelName", () => { + const favRefs: Record = { + "pZ|u1": { panelDeviceId: "pZ", kind: "circuit", targetId: "u1", configEntryId: "eZ" }, + "pA|u2": { panelDeviceId: "pA", kind: "circuit", targetId: "u2", configEntryId: "eA" }, + }; + const perPanelInfo = new Map([ + ["pZ", { panelName: "Zeta Panel", topology: topo({ u1: "A" }), configEntryId: "eZ" }], + ["pA", { panelName: "Alpha Panel", topology: topo({ u2: "B" }), configEntryId: "eA" }], + ]); + const result = groupFavoritesByPanel(favRefs, perPanelInfo); + expect(result.map(s => s.panelName)).toEqual(["Alpha Panel", "Zeta Panel"]); + }); + + it("drops refs whose panelDeviceId is missing from perPanelInfo", () => { + const favRefs: Record = { + "known|u1": { panelDeviceId: "known", kind: "circuit", targetId: "u1", configEntryId: "e1" }, + "ghost|u2": { panelDeviceId: "ghost", kind: "circuit", targetId: "u2", configEntryId: "e2" }, + }; + const perPanelInfo = new Map([["known", { panelName: "Known", topology: topo({ u1: "K" }), configEntryId: "e1" }]]); + const result = groupFavoritesByPanel(favRefs, perPanelInfo); + expect(result).toHaveLength(1); + expect(result[0]?.panelDeviceId).toBe("known"); + }); +}); + +describe("sortedCircuitsForSection", () => { + it("returns an empty array when the topology has no circuits", () => { + const empty = { + circuits: {}, + device_name: "", + panel_entities: {}, + } as PanelTopology; + expect(sortedCircuitsForSection(empty)).toEqual([]); + }); + + it("returns every circuit in the topology (not filtered to favorites)", () => { + const t = topo({ u1: "Kitchen", u2: "Garage", u3: "Bedroom" }); + const result = sortedCircuitsForSection(t); + expect(result).toHaveLength(3); + expect(new Set(result.map(r => r.uuid))).toEqual(new Set(["u1", "u2", "u3"])); + }); + + it("sorts rows alphabetically by circuit name (case-aware localeCompare)", () => { + const t = topo({ u1: "Zebra", u2: "apple", u3: "Mango" }); + const result = sortedCircuitsForSection(t); + // localeCompare on most locales folds case, so "apple" < "Mango" < "Zebra". + expect(result.map(r => r.circuit.name)).toEqual(["apple", "Mango", "Zebra"]); + }); + + it("treats missing circuit names as the empty string (stable placement)", () => { + const t = { + circuits: { + u1: { name: "", tabs: [], entities: {} } as PanelTopology["circuits"][string], + u2: { name: "Kitchen", tabs: [], entities: {} } as PanelTopology["circuits"][string], + }, + device_name: "", + panel_entities: {}, + } as PanelTopology; + const result = sortedCircuitsForSection(t); + // Empty-string names sort before any non-empty name. + expect(result[0]?.uuid).toBe("u1"); + expect(result[1]?.uuid).toBe("u2"); + }); + + it("tolerates a topology with no circuits field (defensive null-safe default)", () => { + const malformed = { device_name: "", panel_entities: {} } as unknown as PanelTopology; + expect(sortedCircuitsForSection(malformed)).toEqual([]); + }); +}); diff --git a/tests/favorites-summary.test.ts b/tests/favorites-summary.test.ts new file mode 100644 index 0000000..1193a1f --- /dev/null +++ b/tests/favorites-summary.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; + +/** + * Test harness: import the same pure pieces the panel method uses so we + * can assert the summary strip's structural contract without needing a + * DOM or a Lit element instance. The panel method itself is a thin + * wrapper over `buildFavoritesSummaryHTML(isAmpsMode)` once refactored. + */ +import { buildFavoritesSummaryHTML } from "../src/panel/favorites-summary.js"; + +describe("buildFavoritesSummaryHTML", () => { + it("renders gear → slide-to-arm → right-cluster in DOM order", () => { + const html = buildFavoritesSummaryHTML(false); + const idxGear = html.indexOf('class="gear-icon panel-gear favorites-gear"'); + const idxSlide = html.indexOf('class="slide-confirm"'); + const idxRight = html.indexOf('class="favorites-summary-right"'); + expect(idxGear).toBeGreaterThanOrEqual(0); + expect(idxSlide).toBeGreaterThanOrEqual(0); + expect(idxRight).toBeGreaterThanOrEqual(0); + expect(idxGear).toBeLessThan(idxSlide); + expect(idxSlide).toBeLessThan(idxRight); + }); + + it("right cluster contains shedding-legend then unit-toggle in order", () => { + const html = buildFavoritesSummaryHTML(false); + const idxRight = html.indexOf('class="favorites-summary-right"'); + const rest = html.slice(idxRight); + const idxLegend = rest.indexOf('class="shedding-legend"'); + const idxToggle = rest.indexOf('class="unit-toggle favorites-summary-unit-toggle"'); + expect(idxLegend).toBeGreaterThanOrEqual(0); + expect(idxToggle).toBeGreaterThanOrEqual(0); + expect(idxLegend).toBeLessThan(idxToggle); + }); + + it("marks W active when isAmpsMode is false", () => { + const html = buildFavoritesSummaryHTML(false); + expect(html).toMatch(/