diff --git a/.github/instructions/*.instructions.md b/.github/instructions/*.instructions.md index 49e14ee..bb017a1 100644 --- a/.github/instructions/*.instructions.md +++ b/.github/instructions/*.instructions.md @@ -1,2 +1,5 @@ +# Copilot Review Instructions + - Before reviewing a PR examine any previous comments on the PR and the resolutions. -- Use those previous commments as the basis for the new review so that resolutions that were previously resolved and where code changes have not diverged are not repeated unecessarily +- Use those previous comments as the basis for the new review so that resolutions that were previously resolved and where code changes have not diverged are not + repeated unnecessarily. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da21431..e2bd873 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,9 @@ jobs: - run: npm ci - run: npx eslint src/ - - run: npx prettier --check "**/*.{js,mjs,json,md,yml,yaml}" + - run: npx prettier --check "**/*.{ts,js,mjs,json,md,yml,yaml}" + - run: npx tsc --noEmit + - run: npx vitest run - run: npx markdownlint-cli2 "**/*.md" build: @@ -34,12 +36,3 @@ jobs: - run: npm ci - run: npm run build - - hacs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: hacs/action@main - with: - category: plugin diff --git a/.gitignore b/.gitignore index fb8b536..33ad7ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,7 @@ # Dependencies node_modules/ -# Build output -dist/ +# Build output (dist/ is tracked for HACS and submodule consumers) # Claude .claude diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 79d70b9..e828278 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -1,7 +1,8 @@ { "globs": [ "*.md", - "docs/**/*.md" + "docs/**/*.md", + ".github/**/*.md" ], "ignores": [ ".venv/**", diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7364575 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +## 0.9.0 + +- Add integration panel with tab router and multi-panel selector +- Add monitoring tab with overrides table, summary bar, and notification settings +- Add settings tab with integration link and global monitoring configuration +- Add side panel for circuit and panel configuration +- Add A/W toggle switching all values and chart axes +- Add shedding icons, monitoring indicators, and gear icons to circuit cells +- Add i18n support with translations for en, es, fr, ja, pt +- Use topology panel_entities instead of pattern matching + +## 0.8.9 + +- Show amps instead of watts above circuit graphs when chart metric is set to `current` +- Fix Y-axis scale to 0–125% of breaker rating in current mode +- Add red horizontal line at 100% of breaker rating (NEC trip point) +- Add yellow dashed line at 80% of breaker rating (NEC continuous load limit) +- Add total current (amps) stat to panel header + +## 0.8.8 + +- Chart Y-axis formatting uses absolute values for power metric + +## 0.8.7 + +- Use dynamic `panel_size` from topology instead of hardcoded 32 + +## 0.8.6 + +- Fix editor storing default 5 minutes when user only changes days/hours +- Use statistics API for long-duration charts +- Fix 0-minute config bug + +## 0.8.5 + +- Add project tooling, CI, and HACS support + +## 0.8.4 + +- Initial SPAN Panel custom Lovelace card diff --git a/DEVELOPER.md b/DEVELOPER.md new file mode 100644 index 0000000..b6e766e --- /dev/null +++ b/DEVELOPER.md @@ -0,0 +1,221 @@ +# Developer Guide + +## Prerequisites + +- Node.js 20+ +- npm + +## Setup + +```bash +npm install +``` + +This installs all dev dependencies and sets up lefthook pre-commit hooks. + +## Scripts + +| Command | Description | +| -------------------- | -------------------------------------------------- | +| `npm run build` | Type-check and produce minified production bundles | +| `npm run dev` | Watch mode with hot-reload (no minification) | +| `npm run typecheck` | Type-check only (no output) | +| `npm run lint` | Run ESLint on `src/` | +| `npm test` | Run test suite | +| `npm run test:watch` | Run tests in watch mode | + +## Project Structure + +```text +src/ + types.ts # Shared TypeScript interfaces + constants.ts # Global constants, metric definitions, named magic numbers + i18n.ts # Internationalization (en, es, fr, ja, pt) + index.ts # Card entry point — registers custom elements + + card/ + span-panel-card.ts # Main Lovelace card (extends HTMLElement) + card-discovery.ts # WebSocket topology discovery + entity fallback + card-styles.ts # Card CSS + + core/ + dashboard-controller.ts # Shared controller (used by card + panel dashboard) + grid-renderer.ts # Breaker grid HTML builder + header-renderer.ts # Panel header HTML builder + sub-device-renderer.ts # BESS/EVSE sub-device HTML builder + dom-updater.ts # Incremental DOM updates for live data + history-loader.ts # Loads history from HA recorder (raw + statistics) + monitoring-status.ts # Monitoring status cache + utilization helpers + graph-settings.ts # Graph horizon settings cache + effective horizon lookup + side-panel.ts # Sliding configuration panel (custom element) + + panel/ + index.ts # Integration panel entry point + span-panel.ts # Panel container with tab navigation + tab-dashboard.ts # Dashboard tab (delegates to DashboardController) + tab-monitoring.ts # Monitoring configuration tab + tab-settings.ts # Settings tab (graph horizons, integration link) + + editor/ + span-panel-card-editor.ts # Visual card editor for Lovelace UI + + chart/ + chart-options.ts # ECharts configuration builder + chart-update.ts # Chart DOM creation/update + + helpers/ + sanitize.ts # HTML escaping (XSS prevention) + format.ts # Power/unit formatting + layout.ts # Tab-to-grid position calculations + chart.ts # Chart metric selection + history.ts # History duration, sampling, deduplication + entity-finder.ts # Sub-device entity discovery by name/suffix + +tests/ # Unit tests (vitest) +scripts/ + validate-i18n.mjs # Validates translation key consistency across languages + fix-markdown.sh # Markdown formatting helper +dist/ + span-panel-card.js # Card bundle (IIFE, minified) + span-panel.js # Integration panel bundle (IIFE, minified) +``` + +## Architecture + +The project produces two independent bundles from two entry points: + +1. **`src/index.ts`** → `dist/span-panel-card.js` — the Lovelace custom card +2. **`src/panel/index.ts`** → `dist/span-panel.js` — the integration panel + +Both share the same core modules. The `DashboardController` in `src/core/dashboard-controller.ts` encapsulates all shared dashboard behavior (live sampling, +history loading, horizon maps, slide-to-confirm, toggle/gear clicks, resize observation) so the card and panel dashboard tab delegate to it rather than +duplicating logic. + +### Data Flow + +```text +Home Assistant + ├─ Device Registry → panel discovery + ├─ Entity Registry → entity fallback discovery + ├─ WebSocket API → span_panel/panel_topology + ├─ Recorder (history) → raw history + statistics + └─ hass.states → live entity state + │ + ▼ + DashboardController + ├─ recordSamples() → live power sampling (1s interval) + ├─ refreshRecorderData() → periodic recorder refresh (30s) + ├─ buildHorizonMaps() → per-circuit/sub-device graph horizons + └─ updateDOM() → incremental DOM updates + │ + ▼ + Renderers (grid, header, sub-device, chart) +``` + +### Key Types + +All shared types live in `src/types.ts`: + +- `HomeAssistant` — subset of the HA frontend type used by this project +- `PanelTopology` — circuits, sub-devices, panel entities +- `Circuit` — name, tabs, entities, breaker rating, shedding info +- `CardConfig` — user-facing card configuration +- `HistoryMap` — `Map` for power/current history +- `ChartMetricDef` — defines how a metric is formatted and displayed +- `GraphSettings` — per-circuit and global graph horizon overrides +- `MonitoringStatus` — utilization, alerts, thresholds + +## TypeScript + +The project uses strict TypeScript with these compiler options: + +- `strict: true` +- `noUncheckedIndexedAccess: true` — forces guarding Record/array index access +- `noUnusedLocals: true` +- `noUnusedParameters: true` +- Target: ES2020, bundled as IIFE by Rollup + +Type-checking runs before every build (`tsc --noEmit && rollup -c`) and in the pre-commit hook. + +## Build + +Rollup bundles each entry point into a single IIFE file: + +- **Production** (`npm run build`): TypeScript plugin compiles `.ts`, then Terser minifies +- **Development** (`npm run dev`): TypeScript plugin compiles `.ts`, no minification, watch mode + +The TypeScript plugin handles compilation; `tsc --noEmit` is used only for type-checking. No `.js` output is produced by `tsc` directly. + +## Linting + +ESLint uses the flat config format with `typescript-eslint`: + +- `eslint:recommended` + `tseslint.configs.recommended` +- `eqeqeq` (strict equality) +- `no-var`, `prefer-const` +- `@typescript-eslint/no-shadow` +- `consistent-return` +- Unused vars with `argsIgnorePattern: ^_` + +Prettier handles formatting (160 char width, 2-space indent, double quotes). + +## Testing + +Tests use [Vitest](https://vitest.dev/) and live in `tests/`. They cover all pure helper functions and core utilities: + +```bash +npm test # single run +npm run test:watch # watch mode +``` + +To add a test, create `tests/.test.ts` and import from `../src/.js` (TypeScript resolves `.js` imports to `.ts` files). + +## Pre-commit Hooks + +Lefthook runs these checks in parallel on staged files: + +| Hook | Glob | Command | +| ------------- | -------------------------------- | -------------------------------- | +| prettier | `*.{ts,js,mjs,json,md,yml,yaml}` | `prettier --check` | +| eslint | `*.{ts,js,mjs}` | `eslint` | +| typecheck | `src/**/*.ts` | `tsc --noEmit` | +| markdownlint | `*.md` | `markdownlint-cli2` | +| i18n-validate | `src/**/*.ts` | `node scripts/validate-i18n.mjs` | + +## CI + +GitHub Actions (`.github/workflows/ci.yml`) runs on push/PR to `main`: + +- **lint job**: ESLint, Prettier, TypeScript type-check, Vitest, markdownlint +- **build job**: Full production build (`npm run build`) + +## Internationalization + +Translations live in `src/i18n.ts` with 5 languages: `en`, `es`, `fr`, `ja`, `pt`. + +The `scripts/validate-i18n.mjs` script checks: + +1. Every `t("key")` call in source has a matching English key +2. Every English key exists in all other languages +3. No orphaned keys in non-English languages + +Run manually: `node scripts/validate-i18n.mjs` + +## Adding a New Translation Key + +1. Add the key to the `en` block in `src/i18n.ts` +2. Add translations for `es`, `fr`, `ja`, `pt` +3. Run `node scripts/validate-i18n.mjs` to verify + +## Distribution + +The SPAN Panel integration serves the card automatically from its static path. No HACS or manual installation is required. The integration registers both +`dist/span-panel-card.js` and `dist/span-panel.js` as Lovelace resources. + +To update the distributed files after changes: + +```bash +npm run build +``` + +Then commit the updated `dist/` files. diff --git a/README.md b/README.md index a78ae30..66bf928 100644 --- a/README.md +++ b/README.md @@ -18,30 +18,23 @@ positions. ## Requirements -- [SPAN Panel integration](https://github.com/SpanPanel/span) installed and configured +- [SPAN Panel integration](https://github.com/SpanPanel/span) **v2.0.5 or later** installed and configured - Circuits must have `tabs` attributes (included in SPAN Panel integration v1.2+) ## Installation -### HACS (Custom Repository) - -1. Open HACS in Home Assistant -2. Click the three-dot menu in the top right and select **Custom repositories** -3. Add the repository URL and select **Dashboard** as the category -4. Click **Add** -5. Search for "SPAN Panel Card" in HACS and click **Install** -6. Restart Home Assistant - -### Manual - -1. Download `span-panel-card.js` from the [latest release](../../releases/latest) -2. Copy the file to your `config/www/` directory -3. Add the resource in Home Assistant: - - Go to **Settings > Dashboards > Resources** - - Click **Add Resource** - - URL: `/local/span-panel-card.js` - - Type: **JavaScript Module** -4. Refresh your browser +As of SPAN Panel integration **v2.0.5**, this card is automatically registered as a Lovelace resource by the integration itself — no separate installation is +required. The integration serves the card JS from its own static path and writes the resource entry on setup. + +> **Migrating from HACS or manual install?** If you previously installed this card via HACS or manually copied `span-panel-card.js` to your `www/` directory, +> you should: +> +> 1. Remove the HACS-managed dashboard resource (HACS > Dashboard > SPAN Panel Card > Remove) — or delete the manual `/local/span-panel-card.js` resource from +> **Settings > Dashboards > Resources** +> 2. Remove `span-panel-card.js` from your `config/www/` directory if present +> 3. Restart Home Assistant +> +> The integration will register its own copy of the card automatically on next startup. ## Configuration diff --git a/dist/span-panel-card.js b/dist/span-panel-card.js new file mode 100644 index 0000000..fbf5aea --- /dev/null +++ b/dist/span-panel-card.js @@ -0,0 +1 @@ +!function(){"use strict";let e="en";const t={en:{"tab.panel":"Panel","tab.monitoring":"Monitoring","tab.settings":"Settings","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)","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.monitoring":"Monitoreo","tab.settings":"Configuración","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)","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.monitoring":"Surveillance","tab.settings":"Paramètres","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)","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.monitoring":"モニタリング","tab.settings":"設定","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)","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.monitoring":"Monitoramento","tab.settings":"Configurações","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)","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",c="pv",l="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},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")}},_="#ff9800",m={"&":"&","<":"<",">":">",'"':""","'":"'"};function v(e){return String(e).replace(/[&<>"']/g,e=>m[e]??e)}const b=u.power;function y(e){return b.unit(e)}function w(e){return(e<0?"-":"")+b.format(e)}function x(e){return(Math.abs(e)/1e3).toFixed(1)}function C(e){return Math.ceil(e/2)}function S(e){return e%2==0?1:0}function z(e){if(2!==e.length)return null;const[t,n]=[Math.min(...e),Math.max(...e)];return C(t)===C(n)?"row-span":S(t)===S(n)?"col-span":"row-span"}function E(e){const t=e.chart_metric??i;return u[t]??u[i]}function k(e,t){const n=function(e){return E(e).entityRole}(t);return e.entities?.[n]??e.entities?.power??null}class ${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 L(e,t,i,o,s,a,l,d,h){const p=t.entities?.power,u=p?a.states[p]:null,g=u&&parseFloat(u.state)||0,m=t.device_type===c||g<0,b=t.entities?.switch,x=b?a.states[b]:null,C=x?"on"===x.state:(u?.attributes?.relay_state||t.relay_state)===r,S=t.breaker_rating_a,z=S?`${Math.round(S)}A`:"",k=v(t.name||n("grid.unknown")),$=E(l);let L;if("current"===$.entityRole){const e=t.entities?.current,n=e?a.states[e]:null,i=n&&parseFloat(n.state)||0;L=`${$.format(i)}A`}else L=`${w(g)}${y(g)}`;const M=f[h||"unknown"]??f.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};let P;P=M.icon2?`\n \n \n `:M.textLabel?`\n \n ${M.textLabel}\n `:``;const N=d&&function(e){return!!e&&void 0!==e.continuous_threshold_pct}(d),D=N?_:"#555",A=``;let T="";if(null!=d?.utilization_pct){const e=d.utilization_pct,t=function(e){if(!e?.utilization_pct)return"";const t=e.utilization_pct;return t>=100?"utilization-alert":t>=80?"utilization-warning":"utilization-normal"}(d);T=`${Math.round(e)}%`}const I=function(e){return!!e&&null!=e.over_threshold_since}(d);return`\n
\n
\n
\n ${z?`${z}`:""}\n ${k}\n
\n
\n \n ${L}\n \n ${!1!==t.is_user_controllable&&t.entities?.switch?`\n
\n ${n(C?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${P}\n ${T}\n ${A}\n
\n
\n
\n `}function M(e,t){return`\n
\n \n
\n `}const P={names:["power","battery power"],suffixes:["_power"]},N={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},D={names:["state of energy"],suffixes:["_soe_kwh"]},A={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function T(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 I(e){return T(e,P)}function H(e){return T(e,N)}function R(e){return T(e,D)}function O(e){return T(e,A)}function j(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 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")}s+=`\n
\n ${v(c)}:\n ${v(d)}\n
\n `}return s}function q(e,t,i,o,s,a){if(i){return`\n
\n ${[{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).map(e=>`\n
\n
${v(e.title)}
\n
\n
\n `).join("")}\n
\n `}return o?`
`:""}function F(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 G(e){const t=s[e];return t?t.ms:s[o].ms}function W(e){const t=e/1e3;return t<=600?Math.ceil(t):Math.min(5e3,Math.ceil(t/5))}function B(e){return Math.max(500,Math.floor(e/5e3))}function V(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 U(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 X(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 J(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=W(i),c=B(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,U(t,r,c))}}}function K(e){if(!e.sub_devices)return[];const t=[];for(const[n,i]of Object.entries(e.sub_devices)){const e={power:I(i)};i.type===l&&(e.soc=H(i),e.soe=R(i));for(const[i,o]of Object.entries(e))o&&t.push({entityId:o,key:`${h}${n}_${i}`,devId:n})}return t}async function Q(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=k(i,n);if(!t)continue;let s;s=o&&o.has(e)?G(o.get(e)):F(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 K(t)){let t;t=s&&s.has(o)?G(s.get(o)):F(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(X(e,n.entityIds,n.uuidByEntity,t,i)):r.push(J(e,n.entityIds,n.uuidByEntity,t,i))}await Promise.all(r)}function Y(e,t,n,o,s,a,r,c){const{options:l,series:d}=function(e,t,n,o,s){n||(n=u[i]);const a=o?"140, 160, 220":"77, 217, 175",r=`rgb(${a})`,c=Date.now(),l=c-t,d=void 0!==n.fixedMin&&void 0!==n.fixedMax,h=(e??[]).filter(e=>e.time>=l).map(e=>[e.time,Math.abs(e.value)]),p=[{type:"line",data:h,showSymbol:!1,smooth:!1,lineStyle:{width:1.5,color:r},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${a}, 0.35)`},{offset:1,color:`rgba(${a}, 0.02)`}]}},itemStyle:{color:r}}],g=h.length>0?function(e){let t=0;for(const n of e)n[1]>t&&(t=n[1]);return t}(h):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}}};return d?(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),p.push({type:"line",data:[[l,.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}}),p.push({type:"line",data:[[l,s],[c,s]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}})),{options:{xAxis:{type:"time",min:l,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},series:p}}(n,o,s,a,c),h=r??120;e.style.minHeight=h+"px";let p=e.querySelector("ha-chart-base");p||(p=document.createElement("ha-chart-base"),p.style.display="block",p.style.width="100%",e.innerHTML="",e.appendChild(p));const g=e.clientHeight;p.height=(g>0?g:h)+"px",p.hass=t,p.options=l,p.data=d}function Z(e,t,i,o,s,a){if(!e||!i||!t)return;const l=F(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!==c&&(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=x(o)),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(s){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=x(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(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=x(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=x(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 f=e.querySelector(".stat-grid-state .stat-value");if(f){const e=n.panel_entities?.dsm_state,i=e?t.states[e]:null;f.textContent=i?t.formatEntityState?.(i)||i.state:"--"}}(e,t,i,o,d);const h=E(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,_=g&&parseFloat(g.state)||0,m=d.device_type===c||_<0,v=d.entities?.switch,b=v?t.states[v]:null,x=b?"on"===b.state:(g?.attributes?.relay_state||d.relay_state)===r,C=i.querySelector(".power-value");if(C)if(p){const e=d.entities?.current,n=e?t.states[e]:null,i=n&&parseFloat(n.state)||0;C.innerHTML=`${h.format(i)}A`}else C.innerHTML=`${w(_)}${y(_)}`;const S=i.querySelector(".toggle-pill");if(S){S.className="toggle-pill "+(x?"toggle-on":"toggle-off");const e=S.querySelector(".toggle-label");e&&(e.textContent=n(x?"grid.on":"grid.off"))}let z;if(i.classList.toggle("circuit-off",!x),i.classList.toggle("circuit-producer",m),d.always_on)z="always_on";else{const e=d.entities?.select,n=e?t.states[e]:null;z=n?n.state:"unknown"}const E=f[z]??f.unknown,k=i.querySelector(".shedding-icon");k&&(k.setAttribute("icon",E.icon),k.style.color=E.color,k.title=E.label());const $=i.querySelector(".shedding-icon-secondary");$&&(E.icon2?($.setAttribute("icon",E.icon2),$.style.color=E.color,$.style.display=""):$.style.display="none");const L=i.querySelector(".shedding-label");L&&(E.textLabel?(L.textContent=E.textLabel,L.style.color=E.color,L.style.display=""):L.style.display="none");const M=i.querySelector(".chart-container");if(M){const e=s.get(o)||[],n=i.classList.contains("circuit-col-span")?200:100;Y(M,t,e,a?.has(o)?G(a.get(o)):l,h,m,n,d.breaker_rating_a??void 0)}}}class ee{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 te(e,t){if(!e)return o;const n=e.circuits?.[t];return n?.has_override?n.horizon:e.global_horizon??o}function ne(e,t){if(!e)return o;const n=e.sub_devices?.[t];return n?.has_override?n.horizon:e.global_horizon??o}class ie{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new $,this.graphSettingsCache=new ee,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,te(e,t));if(e&&this._topology?.sub_devices)for(const t of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(t,ne(e,t))}async fetchAndBuildHorizonMaps(){try{await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)}catch{}}async loadHistory(){await Q(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=k(n,this._config);if(!a)continue;const r=this._hass.states[a];if(!r)continue;const c=parseFloat(r.state);if(isNaN(c))continue;const l=G(i),d=W(l),h=B(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 K(this._topology))n.has(t)&&i.add(e);const o=new Map;try{await Q(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&&(Z(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=F(i);for(const[i,r]of Object.entries(n.sub_devices)){const n=e.querySelector(`[data-subdev="${i}"]`);if(!n)continue;const c=I(r);if(c){const e=t.states[c],i=e&&parseFloat(e.state)||0,o=n.querySelector(".sub-power-value");o&&(o.innerHTML=`${w(i)} ${y(i)}`)}const l=n.querySelectorAll("[data-chart-key]");for(const e of l){const n=e.dataset.chartKey;if(!n)continue;const r=o.get(n)||[];let c=g.power;n.endsWith("_soc")?c=g.soc:n.endsWith("_soe")&&(c=g.soe);const l=!!e.closest(".bess-chart-col");Y(e,t,r,s?.has(i)?G(s.get(i)):a,c,!1,l?120:150)}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 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 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"))},c=e=>{if(!o)return;const t=Math.max(2,Math.min(e-s,a));n.style.left=t+"px"},l=()=>{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=>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 oe(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 se(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 ae=Object.keys(f).filter(e=>"unknown"!==e&&"always_on"!==e);class re 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 c=t.graphSettings,l=t.topology,d=c?.global_horizon??o,h=c?.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 f=document.createElement("div");f.className="field-row";const _=document.createElement("span");_.className="field-label",_.textContent=n("sidepanel.global_default"),f.appendChild(_);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}`))}),f.appendChild(m),u.appendChild(f),a.appendChild(u),l?.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(l.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},c=r.has_override?r.horizon:d,l=document.createElement("select");l.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===c&&(t.selected=!0),l.appendChild(t)}if(l.addEventListener("change",()=>{this._debounce(`circuit-${t}`,p,()=>{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}`))})}),i.appendChild(l),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(()=>{l.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 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=n("sidepanel.subdevice_scales"),e.appendChild(t);const i=Object.entries(l.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=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(s)){const t=document.createElement("option");t.value=e;const i=`horizon.${e}`,o=n(i);t.textContent=o!==i?o:e,e===c&&(t.selected=!0),l.appendChild(t)}if(l.addEventListener("change",()=>{this._debounce(`subdev-${t}`,p,()=>{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}`))})}),i.appendChild(l),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(()=>{l.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=`${v(String(t.breaker_rating_a))}A · ${v(String(t.voltage))}V · Tabs [${v(String(t.tabs))}]`,i=this._createHeader(v(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(v(t.name),v(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,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:n("sidepanel.global")}];for(const e of Object.keys(s))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: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=v(e),s=v(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,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(`${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,c=this._hass?.states?.[r]?.state||"";for(const e of ae){const t=f[e];if(!t)continue;const i=document.createElement("option");i.value=e,i.textContent=n(`shedding.select.${e}`)||t.label(),e===c&&(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,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:n("sidepanel.global")}];for(const e of Object.keys(s))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: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,c=null!=r&&!1!==r.monitoring_enabled;c&&a.setAttribute("checked",""),o.appendChild(s),o.appendChild(a),i.appendChild(o);const l=document.createElement("div");l.dataset.role="monitoring-details",l.style.display=c?"block":"none",i.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,f=r?.window_duration_m??15,_=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",f,1,180,"m",t)),p.appendChild(this._createDurationRow(n("sidepanel.cooldown"),"cooldown-m",_,1,180,"m",t)),l.appendChild(p),a.addEventListener("change",()=>{const e=a.checked;l.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,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"),u=document.createElement("input");u.type="number",u.min=String(o),u.max=String(s),u.value=String(i),u.dataset.role=`threshold-${t}`,c&&(u.disabled=!0);const g=document.createElement("span");return g.textContent=a,h.appendChild(u),h.appendChild(g),c||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}`))})}),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: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",re)}catch{}class ce extends HTMLElement{constructor(){super(),this._hass=null,this._config={},this._discovered=!1,this._discovering=!1,this._discoveryError=null,this._topology=null,this._panelDevice=null,this._panelSize=0,this._historyLoaded=!1,this._rendered=!1,this._ctrl=new ie,this._onVisibilityChange=null,this.attachShadow({mode:"open"}),this._handleToggleClick=e=>this._ctrl.onToggleClick(e,this.shadowRoot),this._handleUnitToggle=this._onUnitToggle.bind(this),this._handleGearClick=e=>this._ctrl.onGearClick(e,this.shadowRoot),this._handleGraphSettingsChanged=()=>this._ctrl.onGraphSettingsChanged(this.shadowRoot)}connectedCallback(){this._ctrl.startIntervals(this.shadowRoot),this._discovered&&this._hass&&this._rendered&&this._ctrl.updateDOM(this.shadowRoot),this._onVisibilityChange=()=>{"visible"===document.visibilityState&&this._discovered&&this._hass&&this._ctrl.updateDOM(this.shadowRoot)},document.addEventListener("visibilitychange",this._onVisibilityChange)}disconnectedCallback(){this._ctrl.stopIntervals(),this._onVisibilityChange&&(document.removeEventListener("visibilitychange",this._onVisibilityChange),this._onVisibilityChange=null)}setConfig(e){this._config=e,this._discovered=!1,this._rendered=!1,this._historyLoaded=!1,this._discoveryError=null,this._ctrl.reset(),this._ctrl.setConfig(e)}get _configEntryId(){return this._panelDevice?.config_entries?.[0]??null}set hass(i){var o;if(this._hass=i,this._ctrl.hass=i,o=i?.language,e=o&&t[o]?o:"en",this._config.device_id)return this._discovered||this._discovering?void(this._discovered&&(this._ctrl.recordSamples(),this._ctrl.updateDOM(this.shadowRoot))):(this._discovering=!0,void this._discoverTopology().then(()=>{this._discovered=!0,this._discovering=!1,this._ctrl.init(this._topology,this._config,this._hass,this._configEntryId),this._render(),this._loadHistory(),this._ctrl.monitoringCache.fetch(i,this._configEntryId).then(()=>{this._rendered&&this._ctrl.updateDOM(this.shadowRoot)})}));this.shadowRoot.innerHTML=`\n \n
\n
\n SPAN Panel\n Live Power\n
\n
\n ${[{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"}].map(e=>`\n
\n
\n ${e.name}\n ${e.watts}W\n
\n \n \n \n
\n `).join("")}\n
\n
\n ${n("card.no_device")}\n
\n
\n
\n `}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:i,show_panel:!0,show_battery:!0,show_evse:!0}}async _discoverTopology(){if(this._hass)try{const e=await async function(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??oe(i.circuits);if(!o)throw new Error(n("card.topology_error"));return{topology:i,panelDevice:se((await e.callWS({type:"config/device_registry/list"})).find(e=>e.id===t)),panelSize:o}}(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[i,o]=await Promise.all([e.callWS({type:"config/device_registry/list"}),e.callWS({type:"config/entity_registry/list"})]),s=se(i.find(e=>e.id===t));if(!s)return{topology:null,panelDevice:null,panelSize:0};const r=o.filter(e=>e.device_id===t),c=i.filter(e=>e.via_device_id===t),l=new Set(c.map(e=>e.id)),d=o.filter(e=>void 0!==e.device_id&&l.has(e.device_id)),h={},p=s.name_by_user??s.name??"";for(const t of[...r,...d]){const n=e.states[t.entity_id];if(!n)continue;const i=n.attributes,o=i.tabs;if("string"!=typeof o||!o.startsWith("tabs ["))continue;const s=o.slice(6,-1);let a;if(a=s.includes(":")?s.split(":").map(Number):[Number(s)],!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(s.identifiers)for(const e of s.identifiers)e[0]===a&&(u=e[1]);let g=0;for(const t of r){const n=e.states[t.entity_id];if(n&&"number"==typeof n.attributes.panel_size){g=n.attributes.panel_size;break}}if(g||(g=oe(h)),!g)throw new Error(n("card.panel_size_error"));const f={};for(const t of c){const n=o.filter(e=>e.device_id===t.id),i=(t.model??"").toLowerCase(),s=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],o=i?.attributes?.friendly_name;r[t.entity_id]={domain:n??"",original_name:"string"==typeof o?o:t.entity_id}}f[t.id]={name:t.name_by_user??t.name??"",type:s?"bess":a?"evse":"unknown",entities:r}}return{topology:{serial:u,firmware:s.sw_version??"",panel_size:g,device_id:t,device_name:s.name_by_user??s.name??n("header.default_name"),circuits:h,sub_devices:f},panelDevice:s,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)}}}async _onUnitToggle(e){const t=e.target,n=t?.closest(".unit-btn");if(!n)return;const i=n.dataset.unit;i&&i!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:i},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._rendered=!1,this._render(),await this._loadHistory(),this._ctrl.updateDOM(this.shadowRoot))}_render(){const e=this._hass;if(!e||!this._topology||!this._panelSize){const e=this._discoveryError??(this._topology?n("card.loading"):n("card.device_not_found"));return void(this.shadowRoot.innerHTML=`\n \n
\n ${v(e)}\n
\n
\n `)}const t=this._topology,i=Math.ceil(this._panelSize/2),o=function(e,t){const i=v(e.device_name||n("header.default_name")),o=v(e.serial||""),s=v(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

${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 ${c?`\n
\n ${n("header.grid")}\n
\n --\n
\n
`:""}\n ${l?`\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(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 `}(t,this._config),s=this._ctrl.monitoringCache.status,a=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 `}(s),r=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":z(e)??"single";s.set(i,{uuid:t,circuit:n,layout:o});for(const t of e)a.add(t)}const r=new Set,c=new Set;for(const[e,t]of s)if("col-span"===t.layout){const n=t.circuit.tabs,i=C(Math.max(...n));0===S(e)?r.add(i):c.add(i)}function l(e){const t=e.circuit.entities?.current??e.circuit.entities?.power,i=o?(s=o,a=t??"",s?.circuits?s.circuits[a]??null:null):null;var s,a;let r;if(e.circuit.always_on)r="always_on";else{const t=e.circuit.entities?.select;r=t&&n.states[t]?n.states[t].state:"unknown"}return{monInfo:i,sheddingPriority:r}}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}=l(h);d+=L(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+=M(e,"2"));else{const{monInfo:t,sheddingPriority:o}=l(h);d+=L(h.uuid,h.circuit,e,"2",h.layout,n,i,t,o)}if(!c.has(e))if(!p||"col-span"!==p.layout&&"single"!==p.layout)a.has(o)||(d+=M(e,"3"));else{const{monInfo:t,sheddingPriority:o}=l(p);d+=L(p.uuid,p.circuit,e,"3",p.layout,n,i,t,o)}d+=`
${o}
`}return d}(t,i,e,this._config,s),c=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===l&&!o||e.type===d&&!s));if(0===a.length)return"";const r=a.filter(([,e])=>e.type===d).length;let c=0,h="";for(const[e,o]of a){const s=o.type===d?n("subdevice.ev_charger"):o.type===l?n("subdevice.battery"):n("subdevice.fallback"),a=I(o),p=a?t.states[a]:void 0,u=p&&parseFloat(p.state)||0,g=o.type===l,f=o.type===d,_=g?H(o):null,m=g?R(o):null,b=g?O(o):null,x=j(o,t,i,new Set([a,_,m,b].filter(e=>null!==e))),C=q(e,0,g,a,_,m);let S="";g?S="sub-device-bess":f&&(c++,c===r&&r%2==1&&(S="sub-device-full")),h+=`\n
\n
\n ${v(s)}\n ${v(o.name||"")}\n ${a?`${w(u)} ${y(u)}`:""}\n \n
\n ${C}\n ${x}\n
\n `}return h}(t,e,this._config),h=this.shadowRoot;h.removeEventListener("click",this._handleToggleClick),h.removeEventListener("click",this._handleUnitToggle),h.removeEventListener("click",this._handleGearClick),h.removeEventListener("graph-settings-changed",this._handleGraphSettingsChanged),h.innerHTML=`\n \n \n ${o}\n ${a}\n ${c?`
${c}
`:""}\n ${!1!==this._config.show_panel?`\n
\n ${r}\n
\n `:""}\n
\n \n `,h.addEventListener("click",this._handleToggleClick),h.addEventListener("click",this._handleUnitToggle),h.addEventListener("click",this._handleGearClick),h.addEventListener("graph-settings-changed",this._handleGraphSettingsChanged);const p=h.querySelector(".slide-confirm");if(p){this._ctrl.bindSlideConfirm(p,h.querySelector("ha-card"));const e=h.querySelector("ha-card");e&&e.classList.add("switches-disabled")}const u=h.querySelector("span-side-panel");u&&(u.hass=e),this._rendered=!0,this._ctrl.recordSamples(),this._ctrl.updateDOM(h),this._ctrl.setupResizeObserver(h,h.querySelector("ha-card"))}}class le 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={}}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]===a)&&!e.via_device_id).map(e=>{const t=(e.identifiers??[]).find(e=>e[0]===a)?.[1]??"",i=e.name_by_user??e.name??n("editor.panel_label");return{device_id:e.id,label:`${i} (${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._buildSectionCheckboxes(e,n,i),this.appendChild(e),this._populateMetricSelect(),this._config.device_id&&this._discoverAvailableRoles(this._config.device_id)}_buildPanelSelector(e,t,i,o){const s=document.createElement("div");s.style.cssText=o;const a=document.createElement("label");a.textContent=n("editor.panel_label"),a.style.cssText=i;const r=document.createElement("select");r.style.cssText=t;const c=document.createElement("option");if(c.value="",c.textContent=n("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)}),s.appendChild(a),s.appendChild(r),e.appendChild(s),this._panelSelect=r}_buildTimeWindow(e,t,i,o){const s=document.createElement("div");s.style.cssText=o;const a=document.createElement("label");a.textContent=n("editor.chart_window"),a.style.cssText=i;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 o=document.createElement("div");o.style.cssText="display: flex; align-items: center; gap: 6px;";const s=document.createElement("input");s.type="number",s.min=t,s.max=n,s.value=String(e),s.style.cssText=c;const a=document.createElement("span");return a.textContent=i,a.style.cssText="font-size: 0.9em; color: var(--secondary-text-color);",o.appendChild(s),o.appendChild(a),{wrap:o,input:s}},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",n("editor.days")),g=l(h,"0","23",n("editor.hours")),f=l(p,"0","59",n("editor.minutes")),_=()=>{this._config={...this._config,history_days:parseInt(u.input.value)||0,history_hours:parseInt(g.input.value)||0,history_minutes:parseInt(f.input.value)||0},this._fireConfigChanged()};u.input.addEventListener("change",_),g.input.addEventListener("change",_),f.input.addEventListener("change",_),r.appendChild(u.wrap),r.appendChild(g.wrap),r.appendChild(f.wrap),s.appendChild(a),s.appendChild(r),e.appendChild(s),this._daysInput=u.input,this._hoursInput=g.input,this._minsInput=f.input}_buildMetricSelector(e,t,i,o){const s=document.createElement("div");s.style.cssText=o;const a=document.createElement("label");a.textContent=n("editor.chart_metric"),a.style.cssText=i;const r=document.createElement("select");r.style.cssText=t,r.addEventListener("change",()=>{this._config={...this._config,chart_metric:r.value},this._fireConfigChanged()}),s.appendChild(a),s.appendChild(r),e.appendChild(s),this._metricSelect=r}_buildSectionCheckboxes(e,t,i){const o=document.createElement("div");o.style.cssText=i;const s=document.createElement("label");s.textContent=n("editor.visible_sections"),s.style.cssText=t,o.appendChild(s);const a=[{key:"show_panel",label:n("editor.panel_circuits"),subDeviceType:null},{key:"show_battery",label:n("editor.battery_bess"),subDeviceType:"bess"},{key:"show_evse",label:n("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),o.appendChild(t),this._checkboxes[e.key]=n;let s=null;e.subDeviceType&&(s=document.createElement("div"),s.style.cssText="padding-left: 26px;",s.style.display=n.checked?"block":"none",o.appendChild(s),this._entityContainers[e.subDeviceType]=s),n.addEventListener("change",()=>{this._config={...this._config,[e.key]:n.checked},s&&(s.style.display=n.checked?"block":"none"),this._fireConfigChanged()})}e.appendChild(o)}_isChartEntity(e,t,n){const i=(t.original_name??"").toLowerCase(),o=t.unique_id??"";if("power"===i||"battery power"===i||o.endsWith("_power"))return!0;if("bess"===n){if("battery level"===i||"battery percentage"===i||o.endsWith("_battery_level")||o.endsWith("_battery_percentage"))return!0;if("state of energy"===i||o.endsWith("_soe_kwh"))return!0;if("nameplate capacity"===i||o.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,o]of Object.entries(n.entities)){if("sensor"===o.domain&&this._isChartEntity(i,o,n.type??""))continue;const s=document.createElement("div");s.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=o.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;",s.appendChild(a),s.appendChild(r),e.appendChild(s),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:`${a}/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??i;e.innerHTML="";for(const[n,i]of Object.entries(u)){if(this._availableRoles&&!this._availableRoles.has(i.entityRole))continue;const o=document.createElement("option");o.value=n,o.textContent=i.label(),n===t&&(o.selected=!0),e.appendChild(o)}}_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??i),this._checkboxes)for(const[e,t]of Object.entries(this._checkboxes))t.checked=!1!==this._config[e]}_fireConfigChanged(){this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}}try{customElements.get("span-panel-card")||customElements.define("span-panel-card",ce),customElements.get("span-panel-card-editor")||customElements.define("span-panel-card-editor",le)}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.0 ","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 new file mode 100644 index 0000000..96a361a --- /dev/null +++ b/dist/span-panel.js @@ -0,0 +1 @@ +!function(){"use strict";let e="en";const t={en:{"tab.panel":"Panel","tab.monitoring":"Monitoring","tab.settings":"Settings","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)","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.monitoring":"Monitoreo","tab.settings":"Configuración","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)","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.monitoring":"Surveillance","tab.settings":"Paramètres","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)","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.monitoring":"モニタリング","tab.settings":"設定","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)","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.monitoring":"Monitoramento","tab.settings":"Configurações","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)","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",c="pv",l="bess",d="evse",p="sub_",h=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")}},m="#ff9800",f={"&":"&","<":"<",">":">",'"':""","'":"'"};function v(e){return String(e).replace(/[&<>"']/g,e=>f[e]??e)}const b=Object.keys(_).filter(e=>"unknown"!==e&&"always_on"!==e);class y 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 c=t.graphSettings,l=t.topology,d=c?.global_horizon??o,p=c?.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 m=document.createElement("span");m.className="field-label",m.textContent=n("sidepanel.global_default"),_.appendChild(m);const f=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),f.appendChild(t)}if(f.addEventListener("change",()=>{this._callDomainService("set_graph_time_horizon",{horizon:f.value}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(e=>this._showError(`${e.message??e}`))}),_.appendChild(f),u.appendChild(_),a.appendChild(u),l?.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(l.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=p[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(s)){const t=document.createElement("option");t.value=e;const i=`horizon.${e}`,o=n(i);t.textContent=o!==i?o:e,e===c&&(t.selected=!0),l.appendChild(t)}if(l.addEventListener("change",()=>{this._debounce(`circuit-${t}`,h,()=>{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}`))})}),i.appendChild(l),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(()=>{l.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 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=n("sidepanel.subdevice_scales"),e.appendChild(t);const i=Object.entries(l.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=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(s)){const t=document.createElement("option");t.value=e;const i=`horizon.${e}`,o=n(i);t.textContent=o!==i?o:e,e===c&&(t.selected=!0),l.appendChild(t)}if(l.addEventListener("change",()=>{this._debounce(`subdev-${t}`,h,()=>{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}`))})}),i.appendChild(l),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(()=>{l.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=`${v(String(t.breaker_rating_a))}A · ${v(String(t.voltage))}V · Tabs [${v(String(t.tabs))}]`,i=this._createHeader(v(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(v(t.name),v(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,c=!0===r?.has_override,l=r?.horizon||o,d=r?.globalHorizon||o,p=document.createElement("div");p.className="horizon-bar";const h=[{key:"global",label:n("sidepanel.global")}];for(const e of Object.keys(s))h.push({key:e,label:e});const u=c?l:"global",g=e=>{for(const t of p.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 h){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}`)))}),p.appendChild(o)}i.appendChild(p),e.appendChild(i)}_createHeader(e,t){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),o=v(e),s=v(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,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(`${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,c=this._hass?.states?.[r]?.state||"";for(const e of b){const t=_[e];if(!t)continue;const i=document.createElement("option");i.value=e,i.textContent=n(`shedding.select.${e}`)||t.label(),e===c&&(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,c=!0===r?.has_override,l=r?.horizon||o,d=r?.globalHorizon||o,p=document.createElement("div");p.className="horizon-bar";const h=[{key:"global",label:n("sidepanel.global")}];for(const e of Object.keys(s))h.push({key:e,label:e});const u=c?l:"global",g=e=>{for(const t of p.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 h){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}`)))}),p.appendChild(o)}i.appendChild(p),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,c=null!=r&&!1!==r.monitoring_enabled;c&&a.setAttribute("checked",""),o.appendChild(s),o.appendChild(a),i.appendChild(o);const l=document.createElement("div");l.dataset.role="monitoring-details",l.style.display=c?"block":"none",i.appendChild(l);const d=!0===r?.has_override,p=document.createElement("div");p.className="radio-group",p.innerHTML=`\n \n \n `,l.appendChild(p);const h=document.createElement("div");h.dataset.role="threshold-fields",h.style.display=d?"block":"none";const u=r?.continuous_threshold_pct??80,g=r?.spike_threshold_pct??100,_=r?.window_duration_m??15,m=r?.cooldown_duration_m??15;h.appendChild(this._createThresholdRow(n("sidepanel.continuous_pct"),"continuous",u,t)),h.appendChild(this._createThresholdRow(n("sidepanel.spike_pct"),"spike",g,t)),h.appendChild(this._createDurationRow(n("sidepanel.window_duration"),"window-m",_,1,180,"m",t)),h.appendChild(this._createDurationRow(n("sidepanel.cooldown"),"cooldown-m",m,1,180,"m",t)),l.appendChild(h),a.addEventListener("change",()=>{const e=a.checked;l.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 f=p.querySelectorAll('input[type="radio"]');for(const e of f)e.addEventListener("change",()=>{const i="custom"===e.value&&e.checked;if(h.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}`,h,()=>{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,c=!1){const l=document.createElement("div");l.className="field-row";const d=document.createElement("span");d.className="field-label",d.textContent=e;const p=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}`,c&&(u.disabled=!0);const g=document.createElement("span");return g.textContent=a,p.appendChild(u),p.appendChild(g),c||u.addEventListener("input",()=>{this._debounce(`threshold-${t}`,h,()=>{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}`))})}),l.appendChild(d),l.appendChild(p),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: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",y)}catch{}async function x(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"});var r;return{topology:i,panelDevice:(r=s.find(e=>e.id===t))?{id:r.id,name:r.name,name_by_user:r.name_by_user,config_entries:r.config_entries,identifiers:r.identifiers,via_device_id:r.via_device_id,sw_version:r.sw_version,model:r.model}:null,panelSize:o}}const w=u.power;function S(e){return w.unit(e)}function $(e){return(e<0?"-":"")+w.format(e)}function z(e){return(Math.abs(e)/1e3).toFixed(1)}function C(e){return Math.ceil(e/2)}function k(e){return e%2==0?1:0}function E(e){if(2!==e.length)return null;const[t,n]=[Math.min(...e),Math.max(...e)];return C(t)===C(n)?"row-span":k(t)===k(n)?"col-span":"row-span"}function M(e){const t=e.chart_metric??i;return u[t]??u[i]}function P(e,t){const n=function(e){return M(e).entityRole}(t);return e.entities?.[n]??e.entities?.power??null}class N{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 T(e,t,i,o,s,a,l,d,p){const h=t.entities?.power,u=h?a.states[h]:null,g=u&&parseFloat(u.state)||0,f=t.device_type===c||g<0,b=t.entities?.switch,y=b?a.states[b]:null,x=y?"on"===y.state:(u?.attributes?.relay_state||t.relay_state)===r,w=t.breaker_rating_a,z=w?`${Math.round(w)}A`:"",C=v(t.name||n("grid.unknown")),k=M(l);let E;if("current"===k.entityRole){const e=t.entities?.current,n=e?a.states[e]:null,i=n&&parseFloat(n.state)||0;E=`${k.format(i)}A`}else E=`${$(g)}${S(g)}`;const P=_[p||"unknown"]??_.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};let N;N=P.icon2?`\n \n \n `:P.textLabel?`\n \n ${P.textLabel}\n `:``;const T=d&&function(e){return!!e&&void 0!==e.continuous_threshold_pct}(d),L=T?m:"#555",A=``;let I="";if(null!=d?.utilization_pct){const e=d.utilization_pct,t=function(e){if(!e?.utilization_pct)return"";const t=e.utilization_pct;return t>=100?"utilization-alert":t>=80?"utilization-warning":"utilization-normal"}(d);I=`${Math.round(e)}%`}const D=function(e){return!!e&&null!=e.over_threshold_since}(d);return`\n
\n
\n
\n ${z?`${z}`:""}\n ${C}\n
\n
\n \n ${E}\n \n ${!1!==t.is_user_controllable&&t.entities?.switch?`\n
\n ${n(x?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${N}\n ${I}\n ${A}\n
\n
\n
\n `}function L(e,t){return`\n
\n \n
\n `}const A={names:["power","battery power"],suffixes:["_power"]},I={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},D={names:["state of energy"],suffixes:["_soe_kwh"]},q={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function H(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 R(e){return H(e,A)}function G(e){return H(e,I)}function j(e){return H(e,D)}function O(e){return H(e,q)}function F(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 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")}s+=`\n
\n ${v(c)}:\n ${v(d)}\n
\n `}return s}function W(e,t,i,o,s,a){if(i){return`\n
\n ${[{key:`${p}${e}_soc`,title:n("subdevice.soc"),available:!!s},{key:`${p}${e}_soe`,title:n("subdevice.soe"),available:!!a},{key:`${p}${e}_power`,title:n("subdevice.power"),available:!!o}].filter(e=>e.available).map(e=>`\n
\n
${v(e.title)}
\n
\n
\n `).join("")}\n
\n `}return o?`
`:""}function B(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 V(e){const t=s[e];return t?t.ms:s[o].ms}function U(e){const t=e/1e3;return t<=600?Math.ceil(t):Math.min(5e3,Math.ceil(t/5))}function X(e){return Math.max(500,Math.floor(e/5e3))}function J(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 K(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 Q(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 Y(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=U(i),c=X(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,K(t,r,c))}}}function Z(e){if(!e.sub_devices)return[];const t=[];for(const[n,i]of Object.entries(e.sub_devices)){const e={power:R(i)};i.type===l&&(e.soc=G(i),e.soe=j(i));for(const[i,o]of Object.entries(e))o&&t.push({entityId:o,key:`${p}${n}_${i}`,devId:n})}return t}async function ee(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=P(i,n);if(!t)continue;let s;s=o&&o.has(e)?V(o.get(e)):B(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 Z(t)){let t;t=s&&s.has(o)?V(s.get(o)):B(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(Q(e,n.entityIds,n.uuidByEntity,t,i)):r.push(Y(e,n.entityIds,n.uuidByEntity,t,i))}await Promise.all(r)}function te(e,t,n,o,s,a,r,c){const{options:l,series:d}=function(e,t,n,o,s){n||(n=u[i]);const a=o?"140, 160, 220":"77, 217, 175",r=`rgb(${a})`,c=Date.now(),l=c-t,d=void 0!==n.fixedMin&&void 0!==n.fixedMax,p=(e??[]).filter(e=>e.time>=l).map(e=>[e.time,Math.abs(e.value)]),h=[{type:"line",data:p,showSymbol:!1,smooth:!1,lineStyle:{width:1.5,color:r},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${a}, 0.35)`},{offset:1,color:`rgba(${a}, 0.02)`}]}},itemStyle:{color:r}}],g=p.length>0?function(e){let t=0;for(const n of e)n[1]>t&&(t=n[1]);return t}(p):0,_={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}}};return d?(_.min=n.fixedMin,_.max=n.fixedMax):g<1&&(_.min=0,_.max=1),s&&"current"===n.entityRole&&(_.min=0,_.max=Math.ceil(1.25*s),h.push({type:"line",data:[[l,.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}}),h.push({type:"line",data:[[l,s],[c,s]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}})),{options:{xAxis:{type:"time",min:l,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:_,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},series:h}}(n,o,s,a,c),p=r??120;e.style.minHeight=p+"px";let h=e.querySelector("ha-chart-base");h||(h=document.createElement("ha-chart-base"),h.style.display="block",h.style.width="100%",e.innerHTML="",e.appendChild(h));const g=e.clientHeight;h.height=(g>0?g:p)+"px",h.hass=t,h.options=l,h.data=d}function ne(e,t,i,o,s,a){if(!e||!i||!t)return;const l=B(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!==c&&(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=z(o)),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(s){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=z(e),l&&(l.textContent="kW")}}const d=e.querySelector(".stat-downstream .stat-value"),p=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):"--",p&&(p.textContent="A")}else{const e=i?Math.abs(parseFloat(i.state)||0):0;d.textContent=z(e),p&&(p.textContent="kW")}}const h=e.querySelector(".stat-solar .stat-value"),u=e.querySelector(".stat-solar .stat-unit");if(h){const e=n.panel_entities?.pv_power,i=e?t.states[e]:null;if(s){const e=i?parseFloat(i.attributes?.amperage):NaN;h.textContent=Number.isFinite(e)?Math.abs(e).toFixed(1):"--",u&&(u.textContent="A")}else{if(i){const e=Math.abs(parseFloat(i.state)||0);h.textContent=z(e)}else h.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 p=M(o),h="current"===p.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,m=g&&parseFloat(g.state)||0,f=d.device_type===c||m<0,v=d.entities?.switch,b=v?t.states[v]:null,y=b?"on"===b.state:(g?.attributes?.relay_state||d.relay_state)===r,x=i.querySelector(".power-value");if(x)if(h){const e=d.entities?.current,n=e?t.states[e]:null,i=n&&parseFloat(n.state)||0;x.innerHTML=`${p.format(i)}A`}else x.innerHTML=`${$(m)}${S(m)}`;const w=i.querySelector(".toggle-pill");if(w){w.className="toggle-pill "+(y?"toggle-on":"toggle-off");const e=w.querySelector(".toggle-label");e&&(e.textContent=n(y?"grid.on":"grid.off"))}let z;if(i.classList.toggle("circuit-off",!y),i.classList.toggle("circuit-producer",f),d.always_on)z="always_on";else{const e=d.entities?.select,n=e?t.states[e]:null;z=n?n.state:"unknown"}const C=_[z]??_.unknown,k=i.querySelector(".shedding-icon");k&&(k.setAttribute("icon",C.icon),k.style.color=C.color,k.title=C.label());const E=i.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 M=i.querySelector(".shedding-label");M&&(C.textLabel?(M.textContent=C.textLabel,M.style.color=C.color,M.style.display=""):M.style.display="none");const P=i.querySelector(".chart-container");if(P){const e=s.get(o)||[],n=i.classList.contains("circuit-col-span")?200:100;te(P,t,e,a?.has(o)?V(a.get(o)):l,p,f,n,d.breaker_rating_a??void 0)}}}class ie{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 oe(e,t){if(!e)return o;const n=e.circuits?.[t];return n?.has_override?n.horizon:e.global_horizon??o}function se(e,t){if(!e)return o;const n=e.sub_devices?.[t];return n?.has_override?n.horizon:e.global_horizon??o}class ae{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new N,this.graphSettingsCache=new ie,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,oe(e,t));if(e&&this._topology?.sub_devices)for(const t of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(t,se(e,t))}async fetchAndBuildHorizonMaps(){try{await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)}catch{}}async loadHistory(){await ee(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=P(n,this._config);if(!a)continue;const r=this._hass.states[a];if(!r)continue;const c=parseFloat(r.state);if(isNaN(c))continue;const l=V(i),d=U(l),p=X(l),h=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 Z(this._topology))n.has(t)&&i.add(e);const o=new Map;try{await ee(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&&(ne(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=B(i);for(const[i,r]of Object.entries(n.sub_devices)){const n=e.querySelector(`[data-subdev="${i}"]`);if(!n)continue;const c=R(r);if(c){const e=t.states[c],i=e&&parseFloat(e.state)||0,o=n.querySelector(".sub-power-value");o&&(o.innerHTML=`${$(i)} ${S(i)}`)}const l=n.querySelectorAll("[data-chart-key]");for(const e of l){const n=e.dataset.chartKey;if(!n)continue;const r=o.get(n)||[];let c=g.power;n.endsWith("_soc")?c=g.soc:n.endsWith("_soe")&&(c=g.soe);const l=!!e.closest(".bess-chart-col");te(e,t,r,s?.has(i)?V(s.get(i)):a,c,!1,l?120:150)}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 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 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"))},c=e=>{if(!o)return;const t=Math.max(2,Math.min(e-s,a));n.style.left=t+"px"},l=()=>{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=>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()}}class re{constructor(){this._ctrl=new ae,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 x(t,i);a=e.topology,r=e.panelSize}catch(t){return void(e.innerHTML=`

${v(t.message)}

`)}this._ctrl.init(a,o,t,s??null),await this._ctrl.monitoringCache.fetch(t,s??null),await this._ctrl.fetchAndBuildHorizonMaps();const c=Math.ceil(r/2),p=this._ctrl.monitoringCache.status,h=function(e,t){const i=v(e.device_name||n("header.default_name")),o=v(e.serial||""),s=v(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,p=!!e.panel_entities?.pv_power,h=!!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 ${c?`\n
\n ${n("header.grid")}\n
\n --\n
\n
`:""}\n ${l?`\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 ${p?`\n
\n ${n("header.solar")}\n
\n --\n ${a?"A":"kW"}\n
\n
`:""}\n ${h?`\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 `}(p),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":E(e)??"single";s.set(i,{uuid:t,circuit:n,layout:o});for(const t of e)a.add(t)}const r=new Set,c=new Set;for(const[e,t]of s)if("col-span"===t.layout){const n=t.circuit.tabs,i=C(Math.max(...n));0===k(e)?r.add(i):c.add(i)}function l(e){const t=e.circuit.entities?.current??e.circuit.entities?.power,i=o?(s=o,a=t??"",s?.circuits?s.circuits[a]??null:null):null;var s,a;let r;if(e.circuit.always_on)r="always_on";else{const t=e.circuit.entities?.select;r=t&&n.states[t]?n.states[t].state:"unknown"}return{monInfo:i,sheddingPriority:r}}let d="";for(let e=1;e<=t;e++){const t=2*e-1,o=2*e,p=s.get(t),h=s.get(o);if(d+=`
${t}
`,p&&"row-span"===p.layout){const{monInfo:t,sheddingPriority:s}=l(p);d+=T(p.uuid,p.circuit,e,"2 / 4","row-span",n,i,t,s),d+=`
${o}
`;continue}if(!r.has(e))if(!p||"col-span"!==p.layout&&"single"!==p.layout)a.has(t)||(d+=L(e,"2"));else{const{monInfo:t,sheddingPriority:o}=l(p);d+=T(p.uuid,p.circuit,e,"2",p.layout,n,i,t,o)}if(!c.has(e))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(o)||(d+=L(e,"3"));else{const{monInfo:t,sheddingPriority:o}=l(h);d+=T(h.uuid,h.circuit,e,"3",h.layout,n,i,t,o)}d+=`
${o}
`}return d}(a,c,t,o,p),m=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===l&&!o||e.type===d&&!s));if(0===a.length)return"";const r=a.filter(([,e])=>e.type===d).length;let c=0,p="";for(const[e,o]of a){const s=o.type===d?n("subdevice.ev_charger"):o.type===l?n("subdevice.battery"):n("subdevice.fallback"),a=R(o),h=a?t.states[a]:void 0,u=h&&parseFloat(h.state)||0,g=o.type===l,_=o.type===d,m=g?G(o):null,f=g?j(o):null,b=g?O(o):null,y=F(o,t,i,new Set([a,m,f,b].filter(e=>null!==e))),x=W(e,0,g,a,m,f);let w="";g?w="sub-device-bess":_&&(c++,c===r&&r%2==1&&(w="sub-device-full")),p+=`\n
\n
\n ${v(s)}\n ${v(o.name||"")}\n ${a?`${$(u)} ${S(u)}`:""}\n \n
\n ${x}\n ${y}\n
\n `}return p}(a,t,o);e.innerHTML=`\n \n ${h}\n ${u}\n ${m?`
${m}
`:""}\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 f=e.querySelector(".slide-confirm");f&&(this._ctrl.bindSlideConfirm(f,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 ce="\n display:flex;align-items:center;gap:8px;margin-bottom:8px;\n",le="\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",de="\n min-width:130px;font-size:0.85em;color:var(--secondary-text-color);\n",pe="\n min-width:160px;font-size:0.85em;color:var(--secondary-text-color);\n",he="\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 ue(e,t,n,i,o){return`\n ${i}\n `}class ge{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,c=o?.circuits??{},l=o?.mains??{},d=new Set;for(const e of Object.keys(t.states||{}))e.startsWith("notify.")&&d.add(e);const p=new Set(["notify","send_message"]);for(const e of Object.keys(t.services?.notify||{}))p.has(e)||d.add(`notify.${e}`);d.add("event_bus");const h=[...d].sort(),u=s.notify_targets??"",g=("string"==typeof u?u.split(","):u).map(e=>e.trim()).filter(Boolean),_=h.length>0&&h.every(e=>g.includes(e)),m=s.notification_title_template??"SPAN: {name} {alert_type}",f=s.notification_message_template??"{name} at {current_a}A ({utilization_pct}% of {breaker_rating_a}A rating)",b=s.notification_priority??"default",y=Object.entries(c).sort(([,e],[,t])=>(e.name??"").localeCompare(t.name??"")),x=Object.entries(l),w=[...y,...x],S=w.length>0&&w.every(([,e])=>!1!==e.monitoring_enabled),$=w.some(([,e])=>!1!==e.monitoring_enabled),z=y.map(([e,t])=>{const i=v(t.name??e),o=!1!==t.monitoring_enabled,s=!0===t.has_override,a=o?"":"opacity:0.4;",r=v(e);return`\n \n \n \n \n ${ue(r,"continuous_threshold_pct",t.continuous_threshold_pct,"%","circuit")}\n ${ue(r,"spike_threshold_pct",t.spike_threshold_pct,"%","circuit")}\n ${ue(r,"window_duration_m",t.window_duration_m,"m","circuit")}\n ${ue(r,"cooldown_duration_m",t.cooldown_duration_m,"m","circuit")}\n \n ${s?``:""}\n \n \n `}).join(""),C=Object.entries(l).map(([e,t])=>{const i=v(t.name??e),o=!1!==t.monitoring_enabled,s=!0===t.has_override,a=o?"":"opacity:0.4;",r=v(e);return`\n \n \n \n \n ${ue(r,"continuous_threshold_pct",t.continuous_threshold_pct,"%","mains")}\n ${ue(r,"spike_threshold_pct",t.spike_threshold_pct,"%","mains")}\n ${ue(r,"window_duration_m",t.window_duration_m,"m","mains")}\n ${ue(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===h.length?`
${n("notification.no_targets")}
`:h.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?`${v(a)} (${v(e)})`:v(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 ${z}\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&&h.length>0){const e=g.length>0;!_&&e&&(E.indeterminate=!0)}this._bindGlobalControls(e,t),this._bindNotifyTargetSelect(e,t),this._bindNotificationSettings(e,t),this._bindToggleAll(e,t,c,l),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)"}}},h)};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{}},h)},c=e.querySelector("#notify-all-targets");c&&c.addEventListener("change",()=>{for(const t of e.querySelectorAll(".notify-target-cb"))t.checked=c.checked;const t=e.querySelector("#notify-target-btn");t&&(t.style.opacity=c.checked?"0.4":"",t.style.pointerEvents=c.checked?"none":""),c.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{}},h)};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 c=e.querySelector("#test-notification-btn"),l=e.querySelector("#test-notification-status");c&&c.addEventListener("click",async()=>{c.disabled=!0,l&&(l.textContent=n("notification.test_sending"),l.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}),l&&(l.textContent=n("notification.test_sent"),l.style.color="var(--success-color, #4caf50)")}catch(e){if(l){const t=e instanceof Error?e.message:n("error.failed");l.textContent=`${n("error.prefix")} ${t}`,l.style.color="var(--error-color, #f44336)"}}finally{c.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,c="mains"===r?"set_mains_threshold":"set_circuit_threshold",l="mains"===r?"leg":"circuit_id";try{await t.callWS({type:"call_service",domain:a,service:c,service_data:this._serviceData({[l]: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 _e(e){return Object.keys(s).map(t=>{const i=`horizon.${t}`,o=n(i);return``}).join("")}const me="\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:4px 8px;font-size:0.85em;\n";class fe{constructor(){this._debounceTimers=new Map,this._configEntryId=null,this._deviceId=null}stop(){for(const e of this._debounceTimers.values())clearTimeout(e);this._debounceTimers.clear()}async render(e,t,i,s){let r;void 0!==i&&(this._configEntryId=i),void 0!==s&&(this._deviceId=s);try{const e={};this._configEntryId&&(e.config_entry_id=this._configEntryId);const n=await t.callWS({type:"call_service",domain:a,service:"get_graph_settings",service_data:e,return_response:!0});r=n?.response||null}catch{r=null}let c=null;try{this._deviceId&&(c=await t.callWS({type:`${a}/panel_topology`,device_id:this._deviceId}))}catch{c=null}const l=r?.global_horizon??o,d=r?.circuits??{},p=c?Object.entries(c.circuits||{}).sort(([,e],[,t])=>(e.name||"").localeCompare(t.name||"")):[],h=p.map(([e,t])=>{const i=v(t.name||e),o=d[e],s=o?.horizon??l,a=!0===o?.has_override,r=v(e);return`\n \n ${i}\n \n \n \n \n ${a?``:""}\n \n \n `}).join(""),u=this._configEntryId?`/config/integrations/integration/${a}#config_entry=${this._configEntryId}`:`/config/integrations/integration/${a}`;e.innerHTML=`\n
\n

${n("settings.heading")}

\n

\n ${n("settings.description")}\n

\n \n ${n("settings.open_link")} →\n \n\n
\n\n

${n("settings.graph_horizon_heading")}

\n\n
\n
\n ${n("settings.global_default")}\n \n
\n
\n\n ${p.length>0?`\n

${n("settings.circuit_graph_scales")}

\n \n \n \n \n \n \n \n \n \n ${h}\n \n
${n("settings.col.circuit")}${n("settings.col.scale")}
\n `:""}\n
\n `,this._bindGlobalHorizon(e,t),this._bindCircuitHorizons(e,t),this._bindResetButtons(e,t)}_serviceData(e){return this._configEntryId&&(e.config_entry_id=this._configEntryId),e}_bindGlobalHorizon(e,t){const n=e.querySelector("#global-horizon");n&&n.addEventListener("change",async()=>{await t.callWS({type:"call_service",domain:a,service:"set_graph_time_horizon",service_data:this._serviceData({horizon:n.value})}),e.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0})),await this.render(e,t)})}_bindCircuitHorizons(e,t){for(const n of e.querySelectorAll(".horizon-select"))n.addEventListener("change",()=>{const i=n.dataset.circuit;if(!i)return;const o=`circuit-${i}`,s=this._debounceTimers.get(o);s&&clearTimeout(s),this._debounceTimers.set(o,setTimeout(async()=>{await t.callWS({type:"call_service",domain:a,service:"set_circuit_graph_horizon",service_data:this._serviceData({circuit_id:i,horizon:n.value})}),e.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0})),await this.render(e,t)},h))})}_bindResetButtons(e,t){for(const n of e.querySelectorAll(".reset-btn"))n.addEventListener("click",async()=>{const i=n.dataset.circuit;i&&(await t.callWS({type:"call_service",domain:a,service:"clear_circuit_graph_horizon",service_data:this._serviceData({circuit_id:i})}),e.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0})),await this.render(e,t))})}}class ve extends HTMLElement{constructor(){super(),this.attachShadow({mode:"open"}),this._hass=null,this._panels=[],this._selectedPanelId=null,this._activeTab="dashboard",this._discovered=!1,this._narrow=!1,this._dashboardTab=new re,this._monitoringTab=new ge,this._settingsTab=new fe,this._onVisibilityChange=null,this._deviceRegistryUnsub=null,this._shadowListenersBound=!1}connectedCallback(){this._discovered&&this._hass&&this._render(),this._onVisibilityChange=()=>{"visible"===document.visibilityState&&this._discovered&&this._hass&&this._recoverIfNeeded()},document.addEventListener("visibilitychange",this._onVisibilityChange),this._subscribeDeviceRegistry()}disconnectedCallback(){this._dashboardTab.stop(),this._monitoringTab.stop(),this._settingsTab.stop(),this._onVisibilityChange&&(document.removeEventListener("visibilitychange",this._onVisibilityChange),this._onVisibilityChange=null),this._unsubscribeDeviceRegistry()}_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)),this._render())}set hass(e){const t=!this._hass&&e;this._hass=e,this._dashboardTab.hass=e;const n=this.shadowRoot.querySelector("ha-menu-button");n&&(n.hass=e),this._discovered?this.shadowRoot.getElementById("tab-content")||this._render():this._discoverPanels(),t&&this._subscribeDeviceRegistry()}set narrow(e){this._narrow=e;const t=this.shadowRoot.querySelector("ha-menu-button");t&&(t.narrow=e)}setConfig(e){}async _recoverIfNeeded(e=0){try{this.shadowRoot.getElementById("tab-content")?await this._renderTab():this._render()}catch{e<3&&setTimeout(()=>this._recoverIfNeeded(e+1),2e3*(e+1))}}async _discoverPanels(){if(!this._hass)return;this._discovered=!0;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);const t=localStorage.getItem("span_panel_selected");t&&this._panels.some(e=>e.id===t)?this._selectedPanelId=t:this._panels.length>0&&(this._selectedPanelId=this._panels[0].id),this._chartMetric=localStorage.getItem("span_panel_metric")||"power",this._render()}_render(){var i;i=this._hass?.language,e=i&&t[i]?i:"en",this.shadowRoot.innerHTML=`\n \n\n
\n
\n \n
\n \n \n \n
\n
\n\n
\n \n \n \n
\n
\n\n
\n
\n
\n
\n
\n `;const o=this.shadowRoot.querySelector("ha-menu-button");o&&(o.hass=this._hass,o.narrow=this._narrow);const s=this.shadowRoot.getElementById("panel-select");s&&s.addEventListener("change",()=>{this._selectedPanelId=s.value,localStorage.setItem("span_panel_selected",s.value),this._renderTab()});for(const e of this.shadowRoot.querySelectorAll(".panel-tab"))e.addEventListener("click",()=>{this._activeTab=e.dataset.tab;for(const e of this.shadowRoot.querySelectorAll(".panel-tab"))e.classList.toggle("active",e.dataset.tab===this._activeTab);this._renderTab()});this._shadowListenersBound||(this._bindUnitToggle(),this._bindTabNavigation(),this.shadowRoot.addEventListener("graph-settings-changed",()=>{"settings"===this._activeTab&&this._renderTab()}),this._shadowListenersBound=!0),this._renderTab()}_bindUnitToggle(){this.shadowRoot.addEventListener("click",e=>{const t=e.target.closest(".unit-btn");if(!t)return;const n=t.dataset.unit;n&&n!==this._chartMetric&&(this._chartMetric=n,localStorage.setItem("span_panel_metric",n),"dashboard"===this._activeTab&&this._renderTab())})}_bindTabNavigation(){this.shadowRoot.addEventListener("navigate-tab",e=>{const t=e.detail;if(t){this._activeTab=t;for(const e of this.shadowRoot.querySelectorAll(".panel-tab"))e.classList.toggle("active",e.dataset.tab===t);this._renderTab()}})}_buildDashboardConfig(){return{chart_metric:this._chartMetric,history_minutes:5,show_panel:!0,show_battery:!0,show_evse:!0}}async _renderTab(){this._dashboardTab.stop(),this._monitoringTab.stop(),this._settingsTab.stop();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"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}case"settings":{e.innerHTML="";const t=this._panels.find(e=>e.id===this._selectedPanelId),n=t?.config_entries?.[0]??null;await this._settingsTab.render(e,this._hass,n??void 0,this._selectedPanelId??void 0);break}}}}try{customElements.get("span-panel")||customElements.define("span-panel",ve)}catch{}console.warn("%c SPAN-PANEL %c v0.9.0 ","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;")}(); diff --git a/eslint.config.js b/eslint.config.js index e2dfda0..69f149b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,7 +1,9 @@ import js from "@eslint/js"; +import tseslint from "typescript-eslint"; -export default [ +export default tseslint.config( js.configs.recommended, + ...tseslint.configs.recommended, { languageOptions: { ecmaVersion: "latest", @@ -25,22 +27,33 @@ export default [ MutationObserver: "readonly", IntersectionObserver: "readonly", performance: "readonly", + localStorage: "readonly", }, }, rules: { - "no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "no-console": ["warn", { allow: ["warn", "error"] }], + eqeqeq: ["error", "always", { null: "ignore" }], + "no-var": "error", + "prefer-const": "error", + "no-shadow": "off", + "@typescript-eslint/no-shadow": "error", + "consistent-return": "error", }, }, { - files: ["rollup.config.mjs", "eslint.config.js"], + files: ["rollup.config.mjs", "eslint.config.js", "scripts/**/*.mjs"], languageOptions: { globals: { process: "readonly", }, }, + rules: { + "@typescript-eslint/no-require-imports": "off", + }, }, { - ignores: ["dist/", "node_modules/", "span-panel-card.js"], - }, -]; + ignores: ["dist/", "node_modules/", "span-panel-card.js", "tests/"], + } +); diff --git a/hacs.json b/hacs.json deleted file mode 100644 index d9caec0..0000000 --- a/hacs.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "SPAN Panel Card", - "render_readme": true, - "filename": "span-panel-card.js" -} diff --git a/images/config.png b/images/config.png index 1059656..220aeaa 100644 Binary files a/images/config.png and b/images/config.png differ diff --git a/lefthook.yml b/lefthook.yml index 64f7d9d..4df7c25 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,12 +2,18 @@ pre-commit: parallel: true jobs: - name: prettier - glob: "*.{js,mjs,json,md,yml,yaml}" + glob: "*.{ts,js,mjs,json,md,yml,yaml}" run: npx prettier --check {staged_files} stage_fixed: true - name: eslint - glob: "*.{js,mjs}" + glob: "*.{ts,js,mjs}" run: npx eslint {staged_files} + - name: typecheck + glob: "src/**/*.ts" + run: npx tsc --noEmit - name: markdownlint - glob: "*.md" + glob: "**/*.md" run: npx markdownlint-cli2 {staged_files} + - name: i18n-validate + glob: "src/**/*.ts" + run: node scripts/validate-i18n.mjs diff --git a/package-lock.json b/package-lock.json index d2c5d7f..0e7bb73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,62 @@ { "name": "span-panel-card", - "version": "0.8.5", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "span-panel-card", - "version": "0.8.5", + "version": "0.9.0", "devDependencies": { "@eslint/js": "^10.0.1", "@evilmartians/lefthook": "^2.1.3", "@rollup/plugin-terser": "^0.4.0", + "@rollup/plugin-typescript": "^12.3.0", "eslint": "^10.0.3", "markdownlint-cli2": "^0.21.0", "prettier": "^3.8.1", - "rollup": "^4.0.0" + "rollup": "^4.0.0", + "tslib": "^2.8.1", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.0", + "vitest": "^4.1.2" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@eslint-community/eslint-utils": { @@ -268,6 +310,25 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -306,47 +367,20 @@ "node": ">= 8" } }, - "node_modules/@rollup/plugin-terser": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", - "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", "dev": true, "license": "MIT", - "dependencies": { - "serialize-javascript": "^6.0.1", - "smob": "^1.0.0", - "terser": "^5.17.4" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", "cpu": [ "arm64" ], @@ -355,12 +389,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", "cpu": [ "arm64" ], @@ -369,12 +406,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", "cpu": [ "x64" ], @@ -383,26 +423,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", "cpu": [ "x64" ], @@ -411,26 +440,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", "cpu": [ "arm" ], @@ -439,166 +457,186 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" + "libc": [ + "musl" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", "cpu": [ - "ppc64" + "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", "cpu": [ - "riscv64" + "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", "cpu": [ - "riscv64" + "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", "cpu": [ - "s390x" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", "cpu": [ - "x64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", "cpu": [ "x64" ], @@ -606,8 +644,384 @@ "license": "MIT", "optional": true, "os": [ - "openbsd" - ] + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-terser": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-typescript": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz", + "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0||^4.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.59.0", @@ -692,6 +1106,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -702,6 +1145,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -744,6 +1194,349 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -802,7 +1595,17 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "Python-2.0" + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } }, "node_modules/balanced-match": { "version": "4.0.4", @@ -847,6 +1650,16 @@ "dev": true, "license": "MIT" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -887,6 +1700,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -951,6 +1771,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -978,6 +1808,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1156,6 +1993,13 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1166,6 +2010,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1293,6 +2147,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-east-asian-width": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", @@ -1340,6 +2204,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -1386,6 +2263,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -1553,6 +2446,279 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -1579,6 +2745,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/markdown-it": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", @@ -2249,6 +3425,25 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2256,6 +3451,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2346,6 +3552,27 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -2359,6 +3586,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2436,6 +3692,27 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -2447,6 +3724,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -2537,6 +3848,19 @@ ], "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -2570,6 +3894,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -2603,6 +3934,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -2614,6 +3955,20 @@ "source-map": "^0.6.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", @@ -2647,6 +4002,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/terser": { "version": "5.46.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", @@ -2666,6 +4034,81 @@ "node": ">=10" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2679,6 +4122,26 @@ "node": ">=8.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2692,6 +4155,44 @@ "node": ">= 0.8.0" } }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", @@ -2722,6 +4223,192 @@ "punycode": "^2.1.0" } }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2738,6 +4425,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 445226b..d796eda 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,28 @@ { "name": "span-panel-card", - "version": "0.8.8", + "version": "0.9.0", "private": true, "type": "module", "scripts": { - "build": "rollup -c", - "dev": "rollup -c -w" + "build": "tsc --noEmit && rollup -c", + "dev": "rollup -c -w", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@eslint/js": "^10.0.1", "@evilmartians/lefthook": "^2.1.3", "@rollup/plugin-terser": "^0.4.0", + "@rollup/plugin-typescript": "^12.3.0", "eslint": "^10.0.3", "markdownlint-cli2": "^0.21.0", "prettier": "^3.8.1", - "rollup": "^4.0.0" + "rollup": "^4.0.0", + "tslib": "^2.8.1", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.0", + "vitest": "^4.1.2" } } diff --git a/rollup.config.mjs b/rollup.config.mjs index e620a74..58a9079 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,13 +1,26 @@ import terser from "@rollup/plugin-terser"; +import typescript from "@rollup/plugin-typescript"; const dev = process.env.ROLLUP_WATCH === "true"; +const plugins = [typescript(), ...(dev ? [] : [terser()])]; -export default { - input: "src/index.js", - output: { - file: "dist/span-panel-card.js", - format: "iife", - sourcemap: false, +export default [ + { + input: "src/index.ts", + output: { + file: "dist/span-panel-card.js", + format: "iife", + sourcemap: false, + }, + plugins, }, - plugins: dev ? [] : [terser()], -}; + { + input: "src/panel/index.ts", + output: { + file: "dist/span-panel.js", + format: "iife", + sourcemap: false, + }, + plugins, + }, +]; diff --git a/scripts/validate-i18n.mjs b/scripts/validate-i18n.mjs new file mode 100755 index 0000000..3e4d4fd --- /dev/null +++ b/scripts/validate-i18n.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/* eslint-disable no-console, no-redeclare */ +/** + * Validate that every t() key used in source files exists in all translation + * languages defined in src/i18n.js, and that English has no orphaned keys. + * + * Exit 0 on success, 1 on validation failure. + */ + +import { readFileSync, readdirSync, statSync } from "fs"; +import { join, relative } from "path"; + +const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); +const I18N_PATH = join(ROOT, "src", "i18n.ts"); + +// ── Extract translation keys from i18n.js ────────────────────────────────── + +function extractTranslationKeys(source) { + // Match the top-level `const translations = { ... };` object. + // We parse it by importing as a module would be cleaner, but the file + // uses runtime imports (t depends on module state) so we regex-extract + // the key sets per language instead. + + const langBlocks = {}; + // Find each language block: ` en: {` ... ` },` + const langRe = /^\s{2}(\w+):\s*\{/gm; + let match; + while ((match = langRe.exec(source)) !== null) { + const lang = match[1]; + const startIdx = match.index + match[0].length; + // Walk forward counting braces to find the closing `}` + let depth = 1; + let i = startIdx; + while (i < source.length && depth > 0) { + if (source[i] === "{") depth++; + else if (source[i] === "}") depth--; + i++; + } + const block = source.slice(startIdx, i - 1); + // Extract all quoted keys: `"some.key":` + const keys = new Set(); + const keyRe = /"([^"]+)":/g; + let km; + while ((km = keyRe.exec(block)) !== null) { + keys.add(km[1]); + } + langBlocks[lang] = keys; + } + return langBlocks; +} + +// ── Scan source files for t() calls ──────────────────────────────────────── + +function collectSourceKeys(dir, results = new Map()) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + if (entry === "node_modules" || entry === "dist") continue; + collectSourceKeys(full, results); + } else if ((entry.endsWith(".ts") || entry.endsWith(".js")) && full !== I18N_PATH) { + const content = readFileSync(full, "utf8"); + // Match t("key") and t('key') — both template and regular strings + const re = /\bt\(\s*["']([^"']+)["']\s*\)/g; + let m; + while ((m = re.exec(content)) !== null) { + const key = m[1]; + if (!results.has(key)) results.set(key, []); + results.get(key).push(relative(ROOT, full)); + } + // Match t(`...`) with template literals containing i18n keys + const tmplRe = /\bt\(\s*`([^`]+)`\s*\)/g; + while ((m = tmplRe.exec(content)) !== null) { + // Template literals with ${} are dynamic — skip validation + if (!m[1].includes("${")) { + const key = m[1]; + if (!results.has(key)) results.set(key, []); + results.get(key).push(relative(ROOT, full)); + } + } + } + } + return results; +} + +// ── Main ─────────────────────────────────────────────────────────────────── + +const i18nSource = readFileSync(I18N_PATH, "utf8"); +const langKeys = extractTranslationKeys(i18nSource); +const languages = Object.keys(langKeys); +const enKeys = langKeys.en; + +if (!enKeys || enKeys.size === 0) { + console.error("ERROR: No English translation keys found in src/i18n.ts"); + process.exit(1); +} + +const srcDir = join(ROOT, "src"); +const usedKeys = collectSourceKeys(srcDir); +const errors = []; + +// 1. Every t() key must exist in English +for (const [key, files] of usedKeys) { + if (!enKeys.has(key)) { + errors.push(` Missing in en: "${key}" (used in ${files[0]})`); + } +} + +// 2. Every English key must exist in all other languages +for (const lang of languages) { + if (lang === "en") continue; + const langSet = langKeys[lang]; + const missing = []; + for (const key of enKeys) { + if (!langSet.has(key)) { + missing.push(key); + } + } + if (missing.length > 0) { + errors.push(` ${lang}: ${missing.length} missing key(s) from en:`); + for (const key of missing) { + errors.push(` - ${key}`); + } + } +} + +// 3. No orphaned keys in non-English languages +for (const lang of languages) { + if (lang === "en") continue; + const langSet = langKeys[lang]; + const orphaned = []; + for (const key of langSet) { + if (!enKeys.has(key)) { + orphaned.push(key); + } + } + if (orphaned.length > 0) { + errors.push(` ${lang}: ${orphaned.length} orphaned key(s) not in en:`); + for (const key of orphaned) { + errors.push(` - ${key}`); + } + } +} + +// 4. Warn about unused English keys (not an error, but informational) +const unusedKeys = []; +for (const key of enKeys) { + if (!usedKeys.has(key)) { + unusedKeys.push(key); + } +} + +if (errors.length > 0) { + console.error("i18n validation failed:"); + for (const e of errors) console.error(e); + process.exit(1); +} + +if (unusedKeys.length > 0) { + console.warn(`i18n: ${unusedKeys.length} unused key(s) in en (not an error):`); + for (const key of unusedKeys) console.warn(` - ${key}`); +} + +console.log("i18n validation OK"); diff --git a/src/card/card-discovery.js b/src/card/card-discovery.js deleted file mode 100644 index 3571d5d..0000000 --- a/src/card/card-discovery.js +++ /dev/null @@ -1,158 +0,0 @@ -import { INTEGRATION_DOMAIN } from "../constants.js"; - -// ── Primary discovery via custom WebSocket API ─────────────────────────────── - -export async function discoverTopology(hass, deviceId) { - const topology = await hass.callWS({ - type: `${INTEGRATION_DOMAIN}/panel_topology`, - device_id: deviceId, - }); - - const panelSize = topology.panel_size || panelSizeFromCircuits(topology.circuits); - if (!panelSize) { - throw new Error("Topology response missing panel_size and no circuits found. Update the SPAN Panel integration."); - } - - const devices = await hass.callWS({ type: "config/device_registry/list" }); - const panelDevice = devices.find(d => d.id === deviceId) || null; - - return { topology, panelDevice, panelSize }; -} - -// ── Backward-compatible panel size derivation ──────────────────────────────── - -function panelSizeFromCircuits(circuits) { - let maxTab = 0; - for (const circuit of Object.values(circuits || {})) { - for (const tab of circuit.tabs || []) { - if (tab > maxTab) maxTab = tab; - } - } - return maxTab > 0 ? maxTab + (maxTab % 2) : 0; -} - -// ── Fallback discovery from entity registry ────────────────────────────────── - -export async function discoverEntitiesFallback(hass, deviceId) { - const [devices, entities] = await Promise.all([hass.callWS({ type: "config/device_registry/list" }), hass.callWS({ type: "config/entity_registry/list" })]); - - const panelDevice = devices.find(d => d.id === deviceId) || null; - if (!panelDevice) return { topology: null, panelDevice: null, panelSize: 0 }; - - const allEntities = entities.filter(e => e.device_id === deviceId); - const subDevices = devices.filter(d => d.via_device_id === deviceId); - const subDeviceIds = new Set(subDevices.map(d => d.id)); - const subEntities = entities.filter(e => subDeviceIds.has(e.device_id)); - - const circuits = {}; - const devName = panelDevice.name_by_user || panelDevice.name || ""; - - for (const ent of [...allEntities, ...subEntities]) { - const state = hass.states[ent.entity_id]; - if (!state || !state.attributes || !state.attributes.tabs) continue; - - const tabsAttr = state.attributes.tabs; - if (!tabsAttr || !tabsAttr.startsWith("tabs [")) continue; - const content = tabsAttr.slice(6, -1); - let tabs; - if (content.includes(":")) { - tabs = content.split(":").map(Number); - } else { - tabs = [Number(content)]; - } - if (!tabs.every(Number.isFinite)) continue; - - const uidParts = ent.unique_id.split("_"); - let circuitUuid = null; - for (let i = 2; i < uidParts.length - 1; i++) { - if (uidParts[i].length >= 16 && /^[a-f0-9]+$/i.test(uidParts[i])) { - circuitUuid = uidParts[i]; - break; - } - } - if (!circuitUuid) continue; - - let displayName = state.attributes.friendly_name || ent.entity_id; - for (const suffix of [" Power", " Consumed Energy", " Produced Energy"]) { - if (displayName.endsWith(suffix)) { - displayName = displayName.slice(0, -suffix.length); - break; - } - } - if (devName && displayName.startsWith(devName + " ")) { - displayName = displayName.slice(devName.length + 1); - } - - const base = ent.entity_id.replace(/^sensor\./, "").replace(/_power$/, ""); - - circuits[circuitUuid] = { - tabs, - name: displayName, - voltage: state.attributes.voltage || (tabs.length === 2 ? 240 : 120), - device_type: state.attributes.device_type || "circuit", - relay_state: state.attributes.relay_state || "UNKNOWN", - is_user_controllable: true, - breaker_rating_a: null, - entities: { - power: ent.entity_id, - switch: `switch.${base}_breaker`, - breaker_rating: `sensor.${base}_breaker_rating`, - }, - }; - } - - let serial = ""; - if (panelDevice.identifiers) { - for (const pair of panelDevice.identifiers) { - if (pair[0] === INTEGRATION_DOMAIN) serial = pair[1]; - } - } - - let panelSize = 0; - for (const ent of allEntities) { - const state = hass.states[ent.entity_id]; - if (state && state.attributes && state.attributes.panel_size) { - panelSize = state.attributes.panel_size; - break; - } - } - if (!panelSize) { - panelSize = panelSizeFromCircuits(circuits); - } - if (!panelSize) { - throw new Error("Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration."); - } - - const subDeviceMap = {}; - for (const sub of subDevices) { - const subEnts = entities.filter(e => e.device_id === sub.id); - const isBess = (sub.model || "").toLowerCase().includes("battery") || (sub.identifiers || []).some(p => (p[1] || "").toLowerCase().includes("bess")); - const isEvse = (sub.model || "").toLowerCase().includes("drive") || (sub.identifiers || []).some(p => (p[1] || "").toLowerCase().includes("evse")); - - const entMap = {}; - for (const e of subEnts) { - entMap[e.entity_id] = { - domain: e.entity_id.split(".")[0], - original_name: hass.states[e.entity_id]?.attributes?.friendly_name || e.entity_id, - }; - } - - subDeviceMap[sub.id] = { - name: sub.name_by_user || sub.name || "", - type: isBess ? "bess" : isEvse ? "evse" : "unknown", - entities: entMap, - }; - } - - const topology = { - serial, - firmware: panelDevice.sw_version || "", - panel_size: panelSize, - device_id: deviceId, - device_name: panelDevice.name_by_user || panelDevice.name || "SPAN Panel", - circuits, - sub_devices: subDeviceMap, - }; - - return { topology, panelDevice, panelSize }; -} diff --git a/src/card/card-discovery.ts b/src/card/card-discovery.ts new file mode 100644 index 0000000..2251fe4 --- /dev/null +++ b/src/card/card-discovery.ts @@ -0,0 +1,220 @@ +import { INTEGRATION_DOMAIN } from "../constants.js"; +import { t } from "../i18n.js"; +import type { HomeAssistant, PanelTopology, PanelDevice, DiscoveryResult, Circuit, CircuitEntities } from "../types.js"; + +// ── HA registry response shapes (internal) ───────────────────────────────── + +interface DeviceRegistryEntry { + id: string; + name?: string; + name_by_user?: string; + config_entries?: string[]; + identifiers?: [string, string][]; + via_device_id?: string | null; + sw_version?: string; + model?: string; +} + +interface EntityRegistryEntry { + entity_id: string; + device_id?: string; + unique_id: string; + platform?: string; +} + +// ── Primary discovery via custom WebSocket API ─────────────────────────────── + +export async function discoverTopology(hass: HomeAssistant, deviceId: string | undefined): 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 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 panelDevice = deviceToPanelDevice(devices.find(d => d.id === deviceId)); + + return { topology, panelDevice, panelSize }; +} + +// ── Backward-compatible panel size derivation ──────────────────────────────── + +function panelSizeFromCircuits(circuits: Record): number { + let maxTab = 0; + for (const circuit of Object.values(circuits)) { + if (!circuit) continue; + for (const tab of circuit.tabs) { + if (tab > maxTab) maxTab = tab; + } + } + return maxTab > 0 ? maxTab + (maxTab % 2) : 0; +} + +// ── Map device registry entry to PanelDevice ───────────────────────────────── + +function deviceToPanelDevice(entry: DeviceRegistryEntry | undefined): PanelDevice | null { + if (!entry) return null; + return { + id: entry.id, + name: entry.name, + name_by_user: entry.name_by_user, + config_entries: entry.config_entries, + identifiers: entry.identifiers, + via_device_id: entry.via_device_id, + sw_version: entry.sw_version, + model: entry.model, + }; +} + +// ── Fallback discovery from entity registry ────────────────────────────────── + +export async function discoverEntitiesFallback(hass: HomeAssistant, deviceId: string | undefined): Promise { + const [devices, entities] = await Promise.all([ + hass.callWS({ + type: "config/device_registry/list", + }), + hass.callWS({ + type: "config/entity_registry/list", + }), + ]); + + const panelDevice = deviceToPanelDevice(devices.find(d => d.id === deviceId)); + if (!panelDevice) return { topology: null, panelDevice: null, panelSize: 0 }; + + const allEntities = entities.filter(e => e.device_id === deviceId); + const subDevices = devices.filter(d => d.via_device_id === deviceId); + const subDeviceIds = new Set(subDevices.map(d => d.id)); + const subEntities = entities.filter(e => e.device_id !== undefined && subDeviceIds.has(e.device_id)); + + const circuits: Record = {}; + const devName = panelDevice.name_by_user ?? panelDevice.name ?? ""; + + for (const ent of [...allEntities, ...subEntities]) { + const state = hass.states[ent.entity_id]; + if (!state) continue; + const attrs = state.attributes; + const tabsAttr = attrs.tabs; + if (typeof tabsAttr !== "string" || !tabsAttr.startsWith("tabs [")) continue; + + const content = tabsAttr.slice(6, -1); + let tabs: number[]; + if (content.includes(":")) { + tabs = content.split(":").map(Number); + } else { + tabs = [Number(content)]; + } + if (!tabs.every(Number.isFinite)) continue; + + const uidParts = ent.unique_id.split("_"); + let circuitUuid: string | null = null; + for (let i = 2; i < uidParts.length - 1; i++) { + const part = uidParts[i]; + if (part !== undefined && part.length >= 16 && /^[a-f0-9]+$/i.test(part)) { + circuitUuid = part; + break; + } + } + if (!circuitUuid) continue; + + let displayName = (typeof attrs.friendly_name === "string" ? attrs.friendly_name : undefined) ?? ent.entity_id; + for (const suffix of [" Power", " Consumed Energy", " Produced Energy"]) { + if (displayName.endsWith(suffix)) { + displayName = displayName.slice(0, -suffix.length); + break; + } + } + if (devName && displayName.startsWith(devName + " ")) { + displayName = displayName.slice(devName.length + 1); + } + + const base = ent.entity_id.replace(/^sensor\./, "").replace(/_power$/, ""); + + const voltage = typeof attrs.voltage === "number" ? attrs.voltage : tabs.length === 2 ? 240 : 120; + + const circuitEntities: CircuitEntities = { + power: ent.entity_id, + switch: `switch.${base}_breaker`, + breaker_rating: `sensor.${base}_breaker_rating`, + }; + + circuits[circuitUuid] = { + tabs, + name: displayName, + voltage, + device_type: typeof attrs.device_type === "string" ? attrs.device_type : "circuit", + relay_state: typeof attrs.relay_state === "string" ? attrs.relay_state : "UNKNOWN", + is_user_controllable: true, + breaker_rating_a: null, + entities: circuitEntities, + }; + } + + let serial = ""; + if (panelDevice.identifiers) { + for (const pair of panelDevice.identifiers) { + if (pair[0] === INTEGRATION_DOMAIN) serial = pair[1]; + } + } + + let panelSize = 0; + for (const ent of allEntities) { + const state = hass.states[ent.entity_id]; + if (state && typeof state.attributes.panel_size === "number") { + panelSize = state.attributes.panel_size; + break; + } + } + if (!panelSize) { + panelSize = panelSizeFromCircuits(circuits); + } + if (!panelSize) { + throw new Error(t("card.panel_size_error")); + } + + const subDeviceMap: Record }> = {}; + + for (const sub of subDevices) { + const subEnts = entities.filter(e => e.device_id === sub.id); + const modelLower = (sub.model ?? "").toLowerCase(); + const isBess = modelLower.includes("battery") || (sub.identifiers ?? []).some(p => p[1].toLowerCase().includes("bess")); + const isEvse = modelLower.includes("drive") || (sub.identifiers ?? []).some(p => p[1].toLowerCase().includes("evse")); + + const entMap: Record = {}; + for (const e of subEnts) { + const domainPart = e.entity_id.split(".")[0]; + const subState = hass.states[e.entity_id]; + const friendlyName = subState?.attributes?.friendly_name; + entMap[e.entity_id] = { + domain: domainPart ?? "", + original_name: typeof friendlyName === "string" ? friendlyName : e.entity_id, + }; + } + + subDeviceMap[sub.id] = { + name: sub.name_by_user ?? sub.name ?? "", + type: isBess ? "bess" : isEvse ? "evse" : "unknown", + entities: entMap, + }; + } + + const topology: PanelTopology = { + serial, + firmware: panelDevice.sw_version ?? "", + panel_size: panelSize, + device_id: deviceId, + device_name: panelDevice.name_by_user ?? panelDevice.name ?? t("header.default_name"), + circuits, + sub_devices: subDeviceMap, + }; + + return { topology, panelDevice, panelSize }; +} diff --git a/src/card/card-styles.js b/src/card/card-styles.ts similarity index 53% rename from src/card/card-styles.js rename to src/card/card-styles.ts index 024b638..225c09c 100644 --- a/src/card/card-styles.js +++ b/src/card/card-styles.ts @@ -1,4 +1,4 @@ -export const CARD_STYLES = ` +export const CARD_STYLES: string = ` :host { --span-accent: var(--primary-color, #4dd9af); } @@ -14,17 +14,23 @@ export const CARD_STYLES = ` .panel-header { display: flex; + flex-wrap: wrap; justify-content: space-between; align-items: flex-start; + gap: 8px 16px; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--divider-color, #333); } + .header-left { flex: 1 1 300px; min-width: 0; } + .header-center { flex: 0 0 auto; } + .header-right { flex: 0 1 auto; min-width: 0; } .panel-identity { display: flex; - align-items: baseline; - gap: 12px; + align-items: center; + flex-wrap: wrap; + gap: 8px 12px; margin-bottom: 12px; } @@ -43,7 +49,8 @@ export const CARD_STYLES = ` .panel-stats { display: flex; - gap: 32px; + flex-wrap: wrap; + gap: 16px 32px; } .stat { display: flex; flex-direction: column; } @@ -52,9 +59,132 @@ export const CARD_STYLES = ` .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); } .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); } - .header-right { display: flex; gap: 20px; align-items: center; padding-top: 8px; } + .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; } + .header-right-top { display: flex; gap: 20px; align-items: center; } .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); } + .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; } + .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; } + .shedding-legend-item ha-icon { --mdc-icon-size: 16px; } + .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; } + .shedding-legend-text { font-size: 9px; font-weight: 600; } + .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); } + + .panel-gear { + background: none; + border: none; + cursor: pointer; + color: var(--secondary-text-color); + opacity: 0.6; + padding: 4px; + margin-left: 8px; + vertical-align: middle; + } + .panel-gear:hover { opacity: 1; } + .header-center { + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 8px; + } + .panel-identity .panel-gear { + margin-left: 0; + } + .slide-confirm { + position: relative; + display: inline-flex; + align-items: center; + width: 160px; + height: 28px; + border-radius: 14px; + background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333)); + vertical-align: middle; + overflow: hidden; + user-select: none; + touch-action: none; + } + .slide-confirm-text { + position: absolute; + width: 100%; + text-align: center; + font-size: 0.65em; + font-weight: 600; + color: var(--secondary-text-color, #999); + pointer-events: none; + z-index: 0; + } + .slide-confirm-knob { + position: absolute; + left: 2px; + top: 2px; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--secondary-text-color, #666); + display: flex; + align-items: center; + justify-content: center; + cursor: grab; + z-index: 1; + transition: none; + } + .slide-confirm-knob ha-icon { + --mdc-icon-size: 14px; + color: var(--card-background-color, #1c1c1c); + } + .slide-confirm-knob.snapping { + transition: left 0.25s ease; + } + .slide-confirm.confirmed { + background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent); + } + .slide-confirm.confirmed .slide-confirm-text { + color: var(--state-active-color, var(--span-accent)); + } + .slide-confirm.confirmed .slide-confirm-knob { + background: var(--state-active-color, var(--span-accent)); + } + .switches-disabled .toggle-pill { + opacity: 0.3; + pointer-events: none; + } + .unit-toggle { + display: inline-flex; + background: var(--secondary-background-color, #333); + border-radius: 6px; + overflow: hidden; + margin-left: 8px; + } + .unit-btn { + padding: 4px 10px; + border: none; + background: none; + color: var(--secondary-text-color); + font-size: 0.75em; + font-weight: 600; + cursor: pointer; + } + .unit-btn.unit-active { + background: var(--primary-color, #4dd9af); + color: var(--text-primary-color, #000); + } + + .monitoring-summary { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 16px; + font-size: 0.8em; + background: rgba(76, 175, 80, 0.1); + border: 1px solid var(--divider-color, #333); + border-top: none; + } + .monitoring-active { color: #4caf50; } + .monitoring-counts { display: flex; gap: 12px; } + .count-warning { color: #ff9800; } + .count-alert { color: #f44336; } + .count-overrides { color: var(--secondary-text-color); } + .panel-grid { display: grid; grid-template-columns: 28px 1fr 1fr 28px; @@ -89,7 +219,9 @@ export const CARD_STYLES = ` .circuit-off .circuit-name, .circuit-off .breaker-badge, .circuit-off .power-value, - .circuit-off .chart-container { opacity: 0.45; } + .circuit-off .chart-container { opacity: 0.35; } + .circuit-off .toggle-pill, + .circuit-off .gear-icon { opacity: 1; } .circuit-empty { opacity: 0.2; @@ -178,31 +310,84 @@ export const CARD_STYLES = ` order: -1; } + .circuit-status { + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; + padding: 0 4px; + } + .shedding-icon { opacity: 0.8; cursor: default; } + .shedding-composite { + display: inline-flex; + align-items: center; + gap: 2px; + } + .shedding-icon-secondary { opacity: 0.8; } + .shedding-label { + font-size: 10px; + font-weight: 600; + opacity: 0.8; + } + .gear-icon { + background: none; + border: none; + cursor: pointer; + padding: 2px; + opacity: 0.6; + transition: opacity 0.2s; + margin-left: auto; + } + .gear-icon:hover { opacity: 1; } + .utilization { + font-size: 0.75em; + font-weight: 600; + } + .utilization-normal { color: #4caf50; } + .utilization-warning { color: #ff9800; } + .utilization-alert { color: #f44336; } + .circuit-alert { + border-color: #f44336 !important; + box-shadow: 0 0 8px rgba(244, 67, 54, 0.3); + } + .circuit-custom-monitoring { + border-left: 3px solid #ff9800; + } + .chart-container { width: 100%; + aspect-ratio: 4 / 1; margin-top: 4px; + overflow: hidden; + min-width: 0; } .sub-devices { - margin-top: 20px; - padding-top: 16px; - border-top: 1px solid var(--divider-color, #333); + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--divider-color, #333); } .sub-device { - margin-bottom: 12px; background: var(--secondary-background-color, var(--card-background-color, #2a2a2a)); border: 1px solid var(--divider-color, #333); border-radius: 12px; padding: 14px 16px; } + .sub-device-bess, + .sub-device-full { + grid-column: 1 / -1; + } .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; } .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); } .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; } .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; } .sub-power-value strong { font-weight: 700; font-size: 1.1em; } - .sub-device .chart-container { margin-bottom: 8px; } + .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; } .bess-charts { display: grid; @@ -219,21 +404,9 @@ export const CARD_STYLES = ` color: var(--secondary-text-color, #999); margin-bottom: 4px; } - .bess-chart-col .chart-container { } + .bess-chart-col .chart-container { aspect-ratio: auto; } .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; } .sub-entity-name { color: var(--secondary-text-color, #999); } .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); } - @media (max-width: 600px) { - ha-card { padding: 12px; } - .panel-header { flex-direction: column; } - .panel-identity { flex-direction: column; gap: 4px; } - .panel-title { font-size: 1.4em; } - .panel-stats { gap: 16px; flex-wrap: wrap; } - .header-right { margin-top: 8px; } - .circuit-slot { min-height: 100px; padding: 10px 12px 16px; } - .circuit-col-span { min-height: 200px; } - .chart-container { height: 60px; } - .circuit-col-span .chart-container { height: 140px; } - } `; diff --git a/src/card/span-panel-card.js b/src/card/span-panel-card.js deleted file mode 100644 index f5bd20e..0000000 --- a/src/card/span-panel-card.js +++ /dev/null @@ -1,752 +0,0 @@ -import { - CHART_METRICS, - BESS_CHART_METRICS, - DEFAULT_CHART_METRIC, - LIVE_SAMPLE_INTERVAL_MS, - DEVICE_TYPE_PV, - RELAY_STATE_CLOSED, - SUB_DEVICE_TYPE_BESS, - SUB_DEVICE_TYPE_EVSE, - SUB_DEVICE_KEY_PREFIX, -} from "../constants.js"; -import { escapeHtml } from "../helpers/sanitize.js"; -import { formatPowerSigned, formatPowerUnit, formatKw } from "../helpers/format.js"; -import { getHistoryDurationMs, getMaxHistoryPoints, getMinGapMs, recordSample, deduplicateAndTrim } from "../helpers/history.js"; -import { tabToRow, tabToCol, classifyDualTab } from "../helpers/layout.js"; -import { getChartMetric, getCircuitChartEntity } from "../helpers/chart.js"; -import { findSubDevicePowerEntity, findBatteryLevelEntity, findBatterySoeEntity, findBatteryCapacityEntity } from "../helpers/entity-finder.js"; -import { updateChart } from "../chart/chart-update.js"; -import { discoverTopology, discoverEntitiesFallback } from "./card-discovery.js"; -import { CARD_STYLES } from "./card-styles.js"; - -export class SpanPanelCard extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: "open" }); - this._hass = null; - this._config = {}; - this._discovered = false; - this._discovering = false; - - this._topology = null; - this._panelDevice = null; - this._panelSize = 0; - - this._powerHistory = new Map(); - this._historyLoaded = false; - - this._updateInterval = null; - this._rendered = false; - - this._handleToggleClick = this._onToggleClick.bind(this); - } - - connectedCallback() { - this._updateInterval = setInterval(() => { - if (this._discovered && this._hass) { - this._updateData(); - } - }, LIVE_SAMPLE_INTERVAL_MS); - } - - disconnectedCallback() { - if (this._updateInterval) { - clearInterval(this._updateInterval); - this._updateInterval = null; - } - } - - setConfig(config) { - this._config = config; - this._discovered = false; - this._rendered = false; - this._historyLoaded = false; - this._powerHistory.clear(); - } - - get _durationMs() { - return getHistoryDurationMs(this._config); - } - - set hass(hass) { - this._hass = hass; - if (!this._config.device_id) { - this.shadowRoot.innerHTML = ` - -
- Open the card editor and select your SPAN Panel device. -
-
- `; - return; - } - if (!this._discovered && !this._discovering) { - this._discovering = true; - this._discoverTopology().then(() => { - this._discovered = true; - this._discovering = false; - this._render(); - this._loadHistory(); - }); - return; - } - if (this._discovered) { - this._updateData(); - } - } - - 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: DEFAULT_CHART_METRIC, - show_panel: true, - show_battery: true, - show_evse: true, - }; - } - - // ── Discovery ────────────────────────────────────────────────────────────── - - async _discoverTopology() { - if (!this._hass) return; - try { - const result = await discoverTopology(this._hass, this._config.device_id); - 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); - 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.message; - } - } - } - - // ── History from HA recorder ─────────────────────────────────────────────── - - async _loadHistory() { - if (this._historyLoaded || !this._topology || !this._hass) return; - this._historyLoaded = true; - - const durationMs = this._durationMs; - const entityIds = []; - const uuidByEntity = new Map(); - - for (const [uuid, circuit] of Object.entries(this._topology.circuits)) { - const eid = getCircuitChartEntity(circuit, this._config); - if (eid) { - entityIds.push(eid); - uuidByEntity.set(eid, uuid); - } - } - - this._collectSubDeviceEntityIds(entityIds, uuidByEntity); - - if (entityIds.length === 0) return; - - const useStatistics = durationMs > 2 * 60 * 60 * 1000; - - try { - if (useStatistics) { - await this._loadStatisticsHistory(entityIds, uuidByEntity, durationMs); - } else { - await this._loadRawHistory(entityIds, uuidByEntity, durationMs); - } - this._updateDOM(); - } catch (err) { - console.warn("SPAN Panel: history fetch failed, charts will populate live", err); - } - } - - async _loadStatisticsHistory(entityIds, uuidByEntity, durationMs) { - const startTime = new Date(Date.now() - durationMs).toISOString(); - const durationHours = durationMs / (60 * 60 * 1000); - const period = durationHours > 72 ? "hour" : "5minute"; - - const result = await this._hass.callWS({ - type: "recorder/statistics_during_period", - start_time: startTime, - statistic_ids: entityIds, - period, - types: ["mean"], - }); - - for (const [entityId, stats] of Object.entries(result)) { - const uuid = uuidByEntity.get(entityId); - if (!uuid || !stats) continue; - - const hist = []; - for (const entry of stats) { - const val = entry.mean; - if (val == null || !Number.isFinite(val)) continue; - const time = entry.start; - if (time > 0) hist.push({ time, value: val }); - } - - if (hist.length > 0) { - const existing = this._powerHistory.get(uuid) || []; - const merged = [...hist, ...existing]; - merged.sort((a, b) => a.time - b.time); - this._powerHistory.set(uuid, merged); - } - } - } - - async _loadRawHistory(entityIds, uuidByEntity, durationMs) { - const startTime = new Date(Date.now() - durationMs).toISOString(); - const result = await this._hass.callWS({ - type: "history/history_during_period", - start_time: startTime, - entity_ids: entityIds, - minimal_response: true, - significant_changes_only: true, - no_attributes: true, - }); - - const maxPoints = getMaxHistoryPoints(durationMs); - const minGapMs = getMinGapMs(durationMs); - for (const [entityId, states] of Object.entries(result)) { - const uuid = uuidByEntity.get(entityId); - if (!uuid || !states) continue; - - const hist = []; - for (const entry of states) { - const val = parseFloat(entry.s); - if (!Number.isFinite(val)) continue; - const tsSec = entry.lu || entry.lc || 0; - const time = tsSec * 1000; - if (time > 0) hist.push({ time, value: val }); - } - - if (hist.length > 0) { - const existing = this._powerHistory.get(uuid) || []; - const merged = [...hist, ...existing]; - this._powerHistory.set(uuid, deduplicateAndTrim(merged, maxPoints, minGapMs)); - } - } - } - - // Collect entity IDs for sub-devices into the provided arrays. - _collectSubDeviceEntityIds(entityIds, uuidByEntity) { - if (!this._topology.sub_devices) return; - for (const [devId, sub] of Object.entries(this._topology.sub_devices)) { - const eidMap = { power: findSubDevicePowerEntity(sub) }; - if (sub.type === SUB_DEVICE_TYPE_BESS) { - eidMap.soc = findBatteryLevelEntity(sub); - eidMap.soe = findBatterySoeEntity(sub); - } - for (const [role, eid] of Object.entries(eidMap)) { - if (eid) { - entityIds.push(eid); - uuidByEntity.set(eid, `${SUB_DEVICE_KEY_PREFIX}${devId}_${role}`); - } - } - } - } - - // ── Record live power samples ────────────────────────────────────────────── - - _recordPowerHistory() { - if (!this._topology || !this._hass) return; - const now = Date.now(); - const cutoff = now - this._durationMs; - const maxPoints = getMaxHistoryPoints(this._durationMs); - - for (const [uuid, circuit] of Object.entries(this._topology.circuits)) { - const entityId = getCircuitChartEntity(circuit, this._config); - if (!entityId) continue; - const state = this._hass.states[entityId]; - const rawValue = state ? parseFloat(state.state) || 0 : 0; - recordSample(this._powerHistory, uuid, rawValue, now, cutoff, maxPoints); - } - - if (this._topology.sub_devices) { - for (const [devId, sub] of Object.entries(this._topology.sub_devices)) { - const eidMap = { power: findSubDevicePowerEntity(sub) }; - if (sub.type === SUB_DEVICE_TYPE_BESS) { - eidMap.soc = findBatteryLevelEntity(sub); - eidMap.soe = findBatterySoeEntity(sub); - } - for (const [role, entityId] of Object.entries(eidMap)) { - if (!entityId) continue; - const key = `${SUB_DEVICE_KEY_PREFIX}${devId}_${role}`; - const state = this._hass.states[entityId]; - const rawValue = state ? parseFloat(state.state) || 0 : 0; - recordSample(this._powerHistory, key, rawValue, now, cutoff, maxPoints); - } - } - } - } - - // ── Data update ──────────────────────────────────────────────────────────── - - _updateData() { - this._recordPowerHistory(); - this._updateDOM(); - } - - // ── DOM updates (incremental) ────────────────────────────────────────────── - - _updateDOM() { - const root = this.shadowRoot; - if (!root || !this._topology || !this._hass) return; - - const hass = this._hass; - const topo = this._topology; - const durationMs = this._durationMs; - - let totalConsumption = 0; - let solarProduction = 0; - - for (const [, circuit] of Object.entries(topo.circuits)) { - const entityId = circuit.entities?.power; - if (!entityId) continue; - const state = hass.states[entityId]; - const power = state ? parseFloat(state.state) || 0 : 0; - if (circuit.device_type === DEVICE_TYPE_PV) { - solarProduction += Math.abs(power); - } else { - totalConsumption += Math.abs(power); - } - } - - const panelPowerEntity = this._findPanelEntity("current_power"); - if (panelPowerEntity) { - const state = hass.states[panelPowerEntity]; - if (state) totalConsumption = Math.abs(parseFloat(state.state) || 0); - } - - const consumptionEl = root.querySelector(".stat-consumption .stat-value"); - if (consumptionEl) consumptionEl.textContent = formatKw(totalConsumption); - const solarEl = root.querySelector(".stat-solar .stat-value"); - if (solarEl) solarEl.textContent = solarProduction > 0 ? formatKw(solarProduction) : "--"; - - for (const [uuid, circuit] of Object.entries(topo.circuits)) { - const slot = root.querySelector(`[data-uuid="${uuid}"]`); - if (!slot) continue; - - const entityId = circuit.entities?.power; - const state = entityId ? hass.states[entityId] : null; - const powerW = state ? parseFloat(state.state) || 0 : 0; - const isProducer = circuit.device_type === DEVICE_TYPE_PV || powerW < 0; - - const switchEntityId = circuit.entities?.switch; - const switchState = switchEntityId ? hass.states[switchEntityId] : null; - const isOn = switchState ? switchState.state === "on" : (state?.attributes?.relay_state || circuit.relay_state) === RELAY_STATE_CLOSED; - - const powerVal = slot.querySelector(".power-value"); - if (powerVal) { - powerVal.innerHTML = `${formatPowerSigned(powerW)}${formatPowerUnit(powerW)}`; - } - - const toggle = slot.querySelector(".toggle-pill"); - if (toggle) { - toggle.className = `toggle-pill ${isOn ? "toggle-on" : "toggle-off"}`; - const label = toggle.querySelector(".toggle-label"); - if (label) label.textContent = isOn ? "On" : "Off"; - } - - slot.classList.toggle("circuit-off", !isOn); - slot.classList.toggle("circuit-producer", isProducer); - - const chartContainer = slot.querySelector(".chart-container"); - if (chartContainer) { - const history = this._powerHistory.get(uuid) || []; - const h = slot.classList.contains("circuit-col-span") ? 200 : 100; - updateChart(chartContainer, hass, history, durationMs, getChartMetric(this._config), isProducer, h); - } - } - - this._updateSubDeviceDOM(root, hass, topo, durationMs); - } - - _updateSubDeviceDOM(root, hass, topo, durationMs) { - if (!topo.sub_devices) return; - for (const [devId, sub] of Object.entries(topo.sub_devices)) { - const section = root.querySelector(`[data-subdev="${devId}"]`); - if (!section) continue; - - const powerEid = findSubDevicePowerEntity(sub); - if (powerEid) { - const state = hass.states[powerEid]; - const powerW = state ? parseFloat(state.state) || 0 : 0; - const powerEl = section.querySelector(".sub-power-value"); - if (powerEl) { - powerEl.innerHTML = `${formatPowerSigned(powerW)} ${formatPowerUnit(powerW)}`; - } - } - - const chartContainers = section.querySelectorAll("[data-chart-key]"); - for (const cc of chartContainers) { - const chartKey = cc.dataset.chartKey; - const history = this._powerHistory.get(chartKey) || []; - let metric = CHART_METRICS.power; - if (chartKey.endsWith("_soc")) metric = BESS_CHART_METRICS.soc; - else if (chartKey.endsWith("_soe")) metric = BESS_CHART_METRICS.soe; - const isBessCol = !!cc.closest(".bess-chart-col"); - updateChart(cc, hass, history, durationMs, metric, false, isBessCol ? 120 : 150); - } - - for (const entityId of Object.keys(sub.entities || {})) { - const valEl = section.querySelector(`[data-eid="${entityId}"]`); - if (!valEl) continue; - const state = hass.states[entityId]; - if (state) { - valEl.textContent = `${state.state}${state.attributes.unit_of_measurement ? " " + state.attributes.unit_of_measurement : ""}`; - } - } - } - } - - _findPanelEntity(suffix) { - if (!this._hass) return null; - for (const entityId of Object.keys(this._hass.states)) { - if (entityId.startsWith("sensor.span_panel_") && entityId.endsWith(`_${suffix}`)) { - return entityId; - } - } - return null; - } - - // ── Toggle click handler ─────────────────────────────────────────────────── - - _onToggleClick(ev) { - const pill = ev.target.closest(".toggle-pill"); - if (!pill) return; - ev.stopPropagation(); - ev.preventDefault(); - const slot = pill.closest("[data-uuid]"); - if (!slot || !this._topology || !this._hass) return; - const uuid = slot.dataset.uuid; - const circuit = this._topology.circuits[uuid]; - if (!circuit) return; - const switchEntity = circuit.entities?.switch; - if (!switchEntity) return; - const switchState = this._hass.states[switchEntity]; - if (!switchState) { - console.warn("SPAN Panel: switch entity not found:", switchEntity); - return; - } - 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); - }); - } - - // ── Full render ──────────────────────────────────────────────────────────── - - _render() { - const hass = this._hass; - if (!hass || !this._topology || !this._panelSize) { - const msg = this._discoveryError || (!this._topology ? "Panel device not found. Check device_id in card config." : "Loading..."); - this.shadowRoot.innerHTML = ` - -
- ${escapeHtml(msg)} -
-
- `; - return; - } - - const topo = this._topology; - const totalRows = Math.ceil(this._panelSize / 2); - const panelName = escapeHtml(topo.device_name || "SPAN Panel"); - const durationMs = this._durationMs; - - const gridHTML = this._buildGridHTML(topo, totalRows, durationMs); - const subDevHTML = this._buildSubDevicesHTML(topo, hass, durationMs); - - // Remove previous listener before replacing DOM - this.shadowRoot.removeEventListener("click", this._handleToggleClick); - - this.shadowRoot.innerHTML = ` - - -
-
-
-

${panelName}

- ${escapeHtml(topo.serial || "")} -
-
-
- Panel consumption -
0kW
-
-
- Solar production -
--kW
-
-
- Battery charge/discharge -
-
-
-
-
- Firmware: ${escapeHtml(topo.firmware || "")} -
-
- ${ - this._config.show_panel !== false - ? ` -
- ${gridHTML} -
- ` - : "" - } - ${subDevHTML ? `
${subDevHTML}
` : ""} -
- `; - - // Attach single delegated click listener - this.shadowRoot.addEventListener("click", this._handleToggleClick); - - this._rendered = true; - this._recordPowerHistory(); - this._updateDOM(); - } - - _buildGridHTML(topo, totalRows, durationMs) { - const tabMap = new Map(); - const occupiedTabs = new Set(); - - for (const [uuid, circuit] of Object.entries(topo.circuits)) { - const tabs = circuit.tabs; - if (!tabs || tabs.length === 0) continue; - const primaryTab = Math.min(...tabs); - const layout = tabs.length === 1 ? "single" : classifyDualTab(tabs); - tabMap.set(primaryTab, { uuid, circuit, layout }); - for (const t of tabs) occupiedTabs.add(t); - } - - const rowsToSkipLeft = new Set(); - const rowsToSkipRight = new Set(); - - for (const [primaryTab, entry] of tabMap) { - if (entry.layout === "col-span") { - const tabs = entry.circuit.tabs; - const secondaryTab = Math.max(...tabs); - const secondaryRow = tabToRow(secondaryTab); - const col = tabToCol(primaryTab); - if (col === 0) rowsToSkipLeft.add(secondaryRow); - else rowsToSkipRight.add(secondaryRow); - } - } - - let gridHTML = ""; - for (let row = 1; row <= totalRows; row++) { - const leftTab = row * 2 - 1; - const rightTab = row * 2; - const leftEntry = tabMap.get(leftTab); - const rightEntry = tabMap.get(rightTab); - - gridHTML += `
${leftTab}
`; - - if (leftEntry && leftEntry.layout === "row-span") { - gridHTML += this._renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, row, "2 / 4", "row-span", durationMs); - gridHTML += `
${rightTab}
`; - continue; - } - - if (!rowsToSkipLeft.has(row)) { - if (leftEntry && (leftEntry.layout === "col-span" || leftEntry.layout === "single")) { - gridHTML += this._renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, row, "2", leftEntry.layout, durationMs); - } else if (!occupiedTabs.has(leftTab)) { - gridHTML += this._renderEmptySlot(row, "2"); - } - } - - if (!rowsToSkipRight.has(row)) { - if (rightEntry && (rightEntry.layout === "col-span" || rightEntry.layout === "single")) { - gridHTML += this._renderCircuitSlot(rightEntry.uuid, rightEntry.circuit, row, "3", rightEntry.layout, durationMs); - } else if (!occupiedTabs.has(rightTab)) { - gridHTML += this._renderEmptySlot(row, "3"); - } - } - - gridHTML += `
${rightTab}
`; - } - return gridHTML; - } - - _buildSubDevicesHTML(topo, hass, _durationMs) { - const showBattery = this._config.show_battery !== false; - const showEvse = this._config.show_evse !== false; - let subDevHTML = ""; - - if (!topo.sub_devices) return subDevHTML; - - for (const [devId, sub] of Object.entries(topo.sub_devices)) { - if (sub.type === SUB_DEVICE_TYPE_BESS && !showBattery) continue; - if (sub.type === SUB_DEVICE_TYPE_EVSE && !showEvse) continue; - - const label = sub.type === SUB_DEVICE_TYPE_EVSE ? "EV Charger" : sub.type === SUB_DEVICE_TYPE_BESS ? "Battery" : "Sub-device"; - const powerEid = findSubDevicePowerEntity(sub); - const powerState = powerEid ? hass.states[powerEid] : null; - const powerW = powerState ? parseFloat(powerState.state) || 0 : 0; - - const isBess = sub.type === SUB_DEVICE_TYPE_BESS; - const battLevelEid = isBess ? findBatteryLevelEntity(sub) : null; - const battSoeEid = isBess ? findBatterySoeEntity(sub) : null; - const battCapEid = isBess ? findBatteryCapacityEntity(sub) : null; - - const hideEids = new Set([powerEid, battLevelEid, battSoeEid, battCapEid].filter(Boolean)); - const entHTML = this._buildSubEntityHTML(sub, hass, hideEids); - const chartsHTML = this._buildSubDeviceChartsHTML(devId, sub, isBess, powerEid, battLevelEid, battSoeEid); - - subDevHTML += ` -
-
- ${escapeHtml(label)} - ${escapeHtml(sub.name || "")} - ${powerEid ? `${formatPowerSigned(powerW)} ${formatPowerUnit(powerW)}` : ""} -
- ${chartsHTML} - ${entHTML} -
- `; - } - return subDevHTML; - } - - _buildSubEntityHTML(sub, hass, hideEids) { - const visibleEnts = this._config.visible_sub_entities || {}; - let entHTML = ""; - if (!sub.entities) return entHTML; - - for (const [entityId, info] of Object.entries(sub.entities)) { - if (hideEids.has(entityId)) continue; - if (visibleEnts[entityId] !== true) continue; - const state = hass.states[entityId]; - if (!state) continue; - let name = info.original_name || state.attributes.friendly_name || entityId; - const devName = sub.name || ""; - if (name.startsWith(devName + " ")) name = name.slice(devName.length + 1); - let displayValue; - if (hass.formatEntityState) { - displayValue = hass.formatEntityState(state); - } else { - displayValue = state.state; - const unit = state.attributes.unit_of_measurement || ""; - if (unit) displayValue += " " + unit; - } - const rawUnit = state.attributes.unit_of_measurement || ""; - if (rawUnit === "Wh") { - const wh = parseFloat(state.state); - if (!isNaN(wh)) displayValue = (wh / 1000).toFixed(1) + " kWh"; - } - entHTML += ` -
- ${escapeHtml(name)}: - ${escapeHtml(displayValue)} -
- `; - } - return entHTML; - } - - _buildSubDeviceChartsHTML(devId, sub, isBess, powerEid, battLevelEid, battSoeEid) { - if (isBess) { - const bessCharts = [ - { key: `${SUB_DEVICE_KEY_PREFIX}${devId}_soc`, title: "SoC", available: !!battLevelEid }, - { key: `${SUB_DEVICE_KEY_PREFIX}${devId}_soe`, title: "SoE", available: !!battSoeEid }, - { key: `${SUB_DEVICE_KEY_PREFIX}${devId}_power`, title: "Power", available: !!powerEid }, - ].filter(c => c.available); - - return ` -
- ${bessCharts - .map( - c => ` -
-
${escapeHtml(c.title)}
-
-
- ` - ) - .join("")} -
- `; - } - if (powerEid) { - return `
`; - } - return ""; - } - - _renderCircuitSlot(uuid, circuit, row, col, layout, _durationMs) { - const hass = this._hass; - const entityId = circuit.entities?.power; - const state = entityId ? hass.states[entityId] : null; - const powerW = state ? parseFloat(state.state) || 0 : 0; - const isProducer = circuit.device_type === DEVICE_TYPE_PV || powerW < 0; - - const switchEntityId = circuit.entities?.switch; - const switchState = switchEntityId ? hass.states[switchEntityId] : null; - const isOn = switchState ? switchState.state === "on" : (state?.attributes?.relay_state || circuit.relay_state) === RELAY_STATE_CLOSED; - - const breakerAmps = circuit.breaker_rating_a; - const breakerLabel = breakerAmps ? `${Math.round(breakerAmps)}A` : ""; - const name = escapeHtml(circuit.name || "Unknown"); - - const rowSpan = layout === "col-span" ? `${row} / span 2` : `${row}`; - const layoutClass = layout === "row-span" ? "circuit-row-span" : layout === "col-span" ? "circuit-col-span" : ""; - - return ` -
-
-
- ${breakerLabel ? `${breakerLabel}` : ""} - ${name} -
-
- - ${formatPowerSigned(powerW)}${formatPowerUnit(powerW)} - - ${ - circuit.is_user_controllable !== false && circuit.entities?.switch - ? ` -
- ${isOn ? "On" : "Off"} - -
- ` - : "" - } -
-
-
-
- `; - } - - _renderEmptySlot(row, col) { - return ` -
- -
- `; - } -} diff --git a/src/card/span-panel-card.ts b/src/card/span-panel-card.ts new file mode 100644 index 0000000..ff42359 --- /dev/null +++ b/src/card/span-panel-card.ts @@ -0,0 +1,311 @@ +import { DEFAULT_CHART_METRIC } from "../constants.js"; +import { setLanguage, t } from "../i18n.js"; +import { escapeHtml } from "../helpers/sanitize.js"; +import { buildHeaderHTML } from "../core/header-renderer.js"; +import { buildGridHTML } from "../core/grid-renderer.js"; +import { buildSubDevicesHTML } from "../core/sub-device-renderer.js"; +import { buildMonitoringSummaryHTML } from "../core/monitoring-status.js"; +import { DashboardController } from "../core/dashboard-controller.js"; +import { discoverTopology, discoverEntitiesFallback } from "./card-discovery.js"; +import { CARD_STYLES } from "./card-styles.js"; +import "../core/side-panel.js"; +import type { HomeAssistant, PanelTopology, PanelDevice, CardConfig } from "../types.js"; + +interface SpanSidePanelElement extends HTMLElement { + hass: HomeAssistant; +} + +export class SpanPanelCard extends HTMLElement { + private _hass: HomeAssistant | null = null; + private _config: CardConfig = {}; + private _discovered = false; + private _discovering = false; + private _discoveryError: string | null = null; + + private _topology: PanelTopology | null = null; + private _panelDevice: PanelDevice | null = null; + private _panelSize = 0; + + private _historyLoaded = false; + private _rendered = false; + + private readonly _ctrl = new DashboardController(); + + private readonly _handleToggleClick: (ev: Event) => void; + private readonly _handleUnitToggle: (ev: Event) => void; + private readonly _handleGearClick: (ev: Event) => void; + private readonly _handleGraphSettingsChanged: () => void; + private _onVisibilityChange: (() => void) | null = null; + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this._handleToggleClick = (ev: Event) => this._ctrl.onToggleClick(ev, this.shadowRoot!); + this._handleUnitToggle = this._onUnitToggle.bind(this); + this._handleGearClick = (ev: Event) => this._ctrl.onGearClick(ev, this.shadowRoot!); + this._handleGraphSettingsChanged = () => this._ctrl.onGraphSettingsChanged(this.shadowRoot!); + } + + connectedCallback(): void { + this._ctrl.startIntervals(this.shadowRoot!); + + if (this._discovered && this._hass && this._rendered) { + this._ctrl.updateDOM(this.shadowRoot!); + } + + this._onVisibilityChange = () => { + if (document.visibilityState === "visible" && this._discovered && this._hass) { + this._ctrl.updateDOM(this.shadowRoot!); + } + }; + document.addEventListener("visibilitychange", this._onVisibilityChange); + } + + disconnectedCallback(): void { + this._ctrl.stopIntervals(); + if (this._onVisibilityChange) { + document.removeEventListener("visibilitychange", this._onVisibilityChange); + this._onVisibilityChange = null; + } + } + + setConfig(config: CardConfig): void { + this._config = config; + this._discovered = false; + this._rendered = false; + this._historyLoaded = false; + this._discoveryError = null; + this._ctrl.reset(); + this._ctrl.setConfig(config); + } + + private get _configEntryId(): string | null { + return this._panelDevice?.config_entries?.[0] ?? null; + } + + set hass(hass: HomeAssistant) { + this._hass = hass; + this._ctrl.hass = hass; + setLanguage(hass?.language); + if (!this._config.device_id) { + this.shadowRoot!.innerHTML = ` + +
+
+ SPAN Panel + Live Power +
+
+ ${[ + { + 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", + }, + ] + .map( + c => ` +
+
+ ${c.name} + ${c.watts}W +
+ + + +
+ ` + ) + .join("")} +
+
+ ${t("card.no_device")} +
+
+
+ `; + return; + } + if (!this._discovered && !this._discovering) { + this._discovering = true; + this._discoverTopology().then(() => { + this._discovered = true; + this._discovering = false; + this._ctrl.init(this._topology, this._config, this._hass, this._configEntryId); + this._render(); + this._loadHistory(); + this._ctrl.monitoringCache.fetch(hass, this._configEntryId).then(() => { + if (this._rendered) this._ctrl.updateDOM(this.shadowRoot!); + }); + }); + return; + } + if (this._discovered) { + this._ctrl.recordSamples(); + this._ctrl.updateDOM(this.shadowRoot!); + } + } + + getCardSize(): number { + return Math.ceil(this._panelSize / 2) + 3; + } + + static getConfigElement(): HTMLElement { + return document.createElement("span-panel-card-editor"); + } + + static getStubConfig(): CardConfig { + return { + device_id: "", + history_days: 0, + history_hours: 0, + history_minutes: 5, + chart_metric: DEFAULT_CHART_METRIC, + show_panel: true, + show_battery: true, + show_evse: true, + }; + } + + private async _discoverTopology(): Promise { + if (!this._hass) return; + try { + const result = await discoverTopology(this._hass, this._config.device_id); + 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); + 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; + } + } + } + + private async _loadHistory(): Promise { + if (this._historyLoaded || !this._topology || !this._hass) return; + this._historyLoaded = true; + + await this._ctrl.fetchAndBuildHorizonMaps(); + + try { + await this._ctrl.loadHistory(); + this._ctrl.updateDOM(this.shadowRoot!); + } catch (err) { + console.warn("SPAN Panel: history fetch failed, charts will populate live", err); + } + } + + private async _onUnitToggle(event: Event): Promise { + const target = event.target as HTMLElement | null; + const btn = target?.closest(".unit-btn") as HTMLElement | null; + if (!btn) return; + const unit = btn.dataset.unit; + if (!unit || unit === (this._config.chart_metric ?? "power")) return; + this._config = { ...this._config, chart_metric: unit }; + this._ctrl.setConfig(this._config); + this.dispatchEvent( + new CustomEvent("config-changed", { + detail: { config: this._config }, + bubbles: true, + composed: true, + }) + ); + this._ctrl.powerHistory.clear(); + this._historyLoaded = false; + this._rendered = false; + this._render(); + await this._loadHistory(); + this._ctrl.updateDOM(this.shadowRoot!); + } + + private _render(): void { + const hass = this._hass; + if (!hass || !this._topology || !this._panelSize) { + const msg = this._discoveryError ?? (!this._topology ? t("card.device_not_found") : t("card.loading")); + this.shadowRoot!.innerHTML = ` + +
+ ${escapeHtml(msg)} +
+
+ `; + return; + } + + const topo = this._topology; + const totalRows = Math.ceil(this._panelSize / 2); + const headerHTML = buildHeaderHTML(topo, this._config); + const monitoringStatus = this._ctrl.monitoringCache.status; + const monitoringSummaryHTML = buildMonitoringSummaryHTML(monitoringStatus); + const gridHTML = buildGridHTML(topo, totalRows, hass, this._config, monitoringStatus); + const subDevHTML = buildSubDevicesHTML(topo, hass, this._config); + + const sr = this.shadowRoot!; + + sr.removeEventListener("click", this._handleToggleClick); + sr.removeEventListener("click", this._handleUnitToggle); + sr.removeEventListener("click", this._handleGearClick); + sr.removeEventListener("graph-settings-changed", this._handleGraphSettingsChanged); + + sr.innerHTML = ` + + + ${headerHTML} + ${monitoringSummaryHTML} + ${subDevHTML ? `
${subDevHTML}
` : ""} + ${ + this._config.show_panel !== false + ? ` +
+ ${gridHTML} +
+ ` + : "" + } +
+ + `; + + sr.addEventListener("click", this._handleToggleClick); + sr.addEventListener("click", this._handleUnitToggle); + sr.addEventListener("click", this._handleGearClick); + sr.addEventListener("graph-settings-changed", this._handleGraphSettingsChanged); + + const slideEl = sr.querySelector(".slide-confirm"); + if (slideEl) { + this._ctrl.bindSlideConfirm(slideEl, sr.querySelector("ha-card")); + const card = sr.querySelector("ha-card"); + if (card) card.classList.add("switches-disabled"); + } + + const sidePanel = sr.querySelector("span-side-panel") as SpanSidePanelElement | null; + if (sidePanel) sidePanel.hass = hass; + + this._rendered = true; + this._ctrl.recordSamples(); + this._ctrl.updateDOM(sr); + this._ctrl.setupResizeObserver(sr, sr.querySelector("ha-card")); + } +} diff --git a/src/chart/chart-options.js b/src/chart/chart-options.js deleted file mode 100644 index 410ff93..0000000 --- a/src/chart/chart-options.js +++ /dev/null @@ -1,82 +0,0 @@ -import { CHART_METRICS, DEFAULT_CHART_METRIC } from "../constants.js"; - -export function buildChartOptions(history, durationMs, metric, isProducer) { - if (!metric) metric = CHART_METRICS[DEFAULT_CHART_METRIC]; - const accentRgb = isProducer ? "140, 160, 220" : "77, 217, 175"; - const accentColor = `rgb(${accentRgb})`; - const now = Date.now(); - const startTime = now - durationMs; - - const hasFixedRange = metric.fixedMin !== undefined && metric.fixedMax !== undefined; - const unit = metric.unit(0); - - const data = (history || []).filter(p => p.time >= startTime).map(p => [p.time, Math.abs(p.value)]); - - const series = [ - { - type: "line", - data, - showSymbol: false, - smooth: false, - lineStyle: { width: 1.5, color: accentColor }, - areaStyle: { - color: { - type: "linear", - x: 0, - y: 0, - x2: 0, - y2: 1, - colorStops: [ - { offset: 0, color: `rgba(${accentRgb}, 0.35)` }, - { offset: 1, color: `rgba(${accentRgb}, 0.02)` }, - ], - }, - }, - itemStyle: { color: accentColor }, - }, - ]; - - const yAxis = { - type: "value", - splitNumber: 4, - axisLabel: { fontSize: 10, formatter: v => metric.format(v) }, - splitLine: { lineStyle: { opacity: 0.15 } }, - }; - if (hasFixedRange) { - yAxis.min = metric.fixedMin; - yAxis.max = metric.fixedMax; - } - - const options = { - xAxis: { - type: "time", - min: startTime, - max: now, - axisLabel: { fontSize: 10 }, - splitLine: { show: false }, - }, - yAxis, - grid: { top: 8, right: 4, bottom: 0, left: 0, containLabel: true }, - tooltip: { - trigger: "axis", - axisPointer: { type: "line", lineStyle: { type: "dashed" } }, - formatter: params => { - if (!params || !params.length) return ""; - const p = params[0]; - const date = new Date(p.value[0]); - const timeStr = date.toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - const val = parseFloat(p.value[1].toFixed(2)); - return `
${timeStr}
${val} ${unit}
`; - }, - }, - animation: false, - }; - - return { options, series }; -} diff --git a/src/chart/chart-options.ts b/src/chart/chart-options.ts new file mode 100644 index 0000000..cd6b99f --- /dev/null +++ b/src/chart/chart-options.ts @@ -0,0 +1,194 @@ +import { CHART_METRICS, DEFAULT_CHART_METRIC, NEC_CONTINUOUS_LOAD_FACTOR, NEC_TRIP_RATING_FACTOR } from "../constants.js"; +import type { HistoryPoint, ChartMetricDef } from "../types.js"; + +type DataPair = [number, number]; + +interface SeriesDef { + type: "line"; + data: DataPair[]; + showSymbol: boolean; + smooth?: boolean; + lineStyle: { width: number; color: string; type?: string }; + areaStyle?: { + color: { + type: "linear"; + x: number; + y: number; + x2: number; + y2: number; + colorStops: { offset: number; color: string }[]; + }; + }; + itemStyle: { color: string }; + tooltip?: { show: boolean }; +} + +interface YAxisDef { + type: "value"; + splitNumber: number; + axisLabel: { + fontSize: number; + formatter: (v: number) => string; + }; + splitLine: { lineStyle: { opacity: number } }; + min?: number; + max?: number; +} + +interface ChartOptionsDef { + xAxis: { + type: "time"; + min: number; + max: number; + axisLabel: { fontSize: number }; + splitLine: { show: boolean }; + }; + yAxis: YAxisDef; + grid: { top: number; right: number; bottom: number; left: number; containLabel: boolean }; + tooltip: { + trigger: "axis"; + axisPointer: { type: "line"; lineStyle: { type: "dashed" } }; + formatter: (params: { value: [number, number] }[]) => string; + }; + animation: boolean; +} + +export interface BuildChartResult { + options: ChartOptionsDef; + series: SeriesDef[]; +} + +function safeMax(data: DataPair[]): number { + let max = 0; + for (const pair of data) { + if (pair[1] > max) { + max = pair[1]; + } + } + return max; +} + +export function buildChartOptions( + history: HistoryPoint[] | undefined, + durationMs: number, + metric: ChartMetricDef | undefined, + isProducer: boolean, + breakerRatingA: number | undefined +): BuildChartResult { + if (!metric) metric = CHART_METRICS[DEFAULT_CHART_METRIC]!; + const accentRgb = isProducer ? "140, 160, 220" : "77, 217, 175"; + const accentColor = `rgb(${accentRgb})`; + const now = Date.now(); + const startTime = now - durationMs; + + const hasFixedRange = metric.fixedMin !== undefined && metric.fixedMax !== undefined; + + const data: DataPair[] = (history ?? []).filter(p => p.time >= startTime).map((p): DataPair => [p.time, Math.abs(p.value)]); + + const series: SeriesDef[] = [ + { + type: "line", + data, + showSymbol: false, + smooth: false, + lineStyle: { width: 1.5, color: accentColor }, + areaStyle: { + color: { + type: "linear", + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: `rgba(${accentRgb}, 0.35)` }, + { offset: 1, color: `rgba(${accentRgb}, 0.02)` }, + ], + }, + }, + itemStyle: { color: accentColor }, + }, + ]; + + const dataMax = data.length > 0 ? safeMax(data) : 0; + const useDecimalAxis = dataMax < 10; + + const yAxis: YAxisDef = { + type: "value", + splitNumber: 4, + axisLabel: { + fontSize: 10, + formatter: useDecimalAxis ? (v: number): string => (v === 0 ? "0" : v.toFixed(1)) : (v: number): string => metric.format(v), + }, + splitLine: { lineStyle: { opacity: 0.15 } }, + }; + + if (hasFixedRange) { + yAxis.min = metric.fixedMin; + yAxis.max = metric.fixedMax; + } else if (dataMax < 1) { + yAxis.min = 0; + yAxis.max = 1; + } + + if (breakerRatingA && metric.entityRole === "current") { + yAxis.min = 0; + yAxis.max = Math.ceil(breakerRatingA * NEC_TRIP_RATING_FACTOR); + + series.push({ + type: "line", + data: [ + [startTime, breakerRatingA * NEC_CONTINUOUS_LOAD_FACTOR], + [now, breakerRatingA * NEC_CONTINUOUS_LOAD_FACTOR], + ], + showSymbol: false, + lineStyle: { width: 1, color: "rgba(255, 200, 40, 0.6)", type: "dashed" }, + itemStyle: { color: "transparent" }, + tooltip: { show: false }, + }); + + series.push({ + type: "line", + data: [ + [startTime, breakerRatingA], + [now, breakerRatingA], + ], + showSymbol: false, + lineStyle: { width: 1.5, color: "rgba(255, 60, 60, 0.7)", type: "solid" }, + itemStyle: { color: "transparent" }, + tooltip: { show: false }, + }); + } + + const options: ChartOptionsDef = { + xAxis: { + type: "time", + min: startTime, + max: now, + axisLabel: { fontSize: 10 }, + splitLine: { show: false }, + }, + yAxis, + grid: { top: 8, right: 4, bottom: 0, left: 0, containLabel: true }, + tooltip: { + trigger: "axis", + axisPointer: { type: "line", lineStyle: { type: "dashed" } }, + formatter: (params: { value: [number, number] }[]): string => { + if (!params || params.length === 0) return ""; + const p = params[0]!; + const date = new Date(p.value[0]); + const timeStr = date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const val = parseFloat(p.value[1].toFixed(2)); + return `
${timeStr}
${metric.format(val)} ${metric.unit(val)}
`; + }, + }, + animation: false, + }; + + return { options, series }; +} diff --git a/src/chart/chart-update.js b/src/chart/chart-update.js deleted file mode 100644 index 7f137cb..0000000 --- a/src/chart/chart-update.js +++ /dev/null @@ -1,17 +0,0 @@ -import { buildChartOptions } from "./chart-options.js"; - -export function updateChart(container, hass, history, durationMs, metric, isProducer, heightPx) { - const { options, series } = buildChartOptions(history, durationMs, metric, isProducer); - let chart = container.querySelector("ha-chart-base"); - if (!chart) { - chart = document.createElement("ha-chart-base"); - chart.style.display = "block"; - chart.style.width = "100%"; - chart.height = (heightPx || 120) + "px"; - container.innerHTML = ""; - container.appendChild(chart); - } - chart.hass = hass; - chart.options = options; - chart.data = series; -} diff --git a/src/chart/chart-update.ts b/src/chart/chart-update.ts new file mode 100644 index 0000000..75fd7c8 --- /dev/null +++ b/src/chart/chart-update.ts @@ -0,0 +1,38 @@ +import { buildChartOptions } from "./chart-options.js"; +import type { HomeAssistant, HistoryPoint, ChartMetricDef } from "../types.js"; + +interface HaChartBaseElement extends HTMLElement { + hass: HomeAssistant; + options: unknown; + data: unknown; + height: string; +} + +export function updateChart( + container: HTMLElement, + hass: HomeAssistant, + history: HistoryPoint[] | undefined, + durationMs: number, + metric: ChartMetricDef | undefined, + isProducer: boolean, + heightPx: number | undefined, + breakerRatingA?: number +): void { + const { options, series } = buildChartOptions(history, durationMs, metric, isProducer, breakerRatingA); + const minH = heightPx ?? 120; + container.style.minHeight = minH + "px"; + + let chart = container.querySelector("ha-chart-base") as HaChartBaseElement | null; + if (!chart) { + chart = document.createElement("ha-chart-base") as HaChartBaseElement; + chart.style.display = "block"; + chart.style.width = "100%"; + container.innerHTML = ""; + container.appendChild(chart); + } + const actualH = container.clientHeight; + chart.height = (actualH > 0 ? actualH : minH) + "px"; + chart.hass = hass; + chart.options = options; + chart.data = series; +} diff --git a/src/constants.js b/src/constants.js deleted file mode 100644 index f4fab2b..0000000 --- a/src/constants.js +++ /dev/null @@ -1,51 +0,0 @@ -export const CARD_VERSION = "0.8.8"; - -// ── Defaults ──────────────────────────────────────────────────────────────── - -export const DEFAULT_HISTORY_DAYS = 0; -export const DEFAULT_HISTORY_HOURS = 0; -export const DEFAULT_HISTORY_MINUTES = 5; -export const DEFAULT_CHART_METRIC = "power"; -export const LIVE_SAMPLE_INTERVAL_MS = 1000; - -// ── Domain / type identifiers ─────────────────────────────────────────────── - -export const INTEGRATION_DOMAIN = "span_panel"; -export const RELAY_STATE_CLOSED = "CLOSED"; -export const DEVICE_TYPE_PV = "pv"; -export const SUB_DEVICE_TYPE_BESS = "bess"; -export const SUB_DEVICE_TYPE_EVSE = "evse"; -export const SUB_DEVICE_KEY_PREFIX = "sub_"; - -// ── Chart metric definitions ──────────────────────────────────────────────── - -export const CHART_METRICS = { - power: { - entityRole: "power", - label: "Power", - unit: v => (Math.abs(v) >= 1000 ? "kW" : "W"), - format: v => (Math.abs(v) >= 1000 ? (Math.abs(v) / 1000).toFixed(1) : String(Math.round(Math.abs(v)))), - }, - current: { - entityRole: "current", - label: "Current", - unit: () => "A", - format: v => Math.abs(v).toFixed(1), - }, -}; - -export const BESS_CHART_METRICS = { - soc: { - label: "State of Charge", - unit: () => "%", - format: v => String(Math.round(v)), - fixedMin: 0, - fixedMax: 100, - }, - soe: { - label: "State of Energy", - unit: () => "kWh", - format: v => v.toFixed(1), - }, - power: CHART_METRICS.power, -}; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..4ebc0e1 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,113 @@ +import { t } from "./i18n.js"; +import type { ChartMetricDef, GraphHorizonPreset, SheddingPriorityDef } from "./types.js"; + +export const CARD_VERSION = "0.9.0"; + +// -- Defaults -- + +export const DEFAULT_HISTORY_DAYS = 0; +export const DEFAULT_HISTORY_HOURS = 0; +export const DEFAULT_HISTORY_MINUTES = 5; +export const DEFAULT_CHART_METRIC = "power"; +export const LIVE_SAMPLE_INTERVAL_MS = 1000; + +// -- Graph time horizon presets -- + +export const DEFAULT_GRAPH_HORIZON = "5m"; + +export const GRAPH_HORIZONS: Record = { + "5m": { ms: 5 * 60 * 1000, refreshMs: 1000, useRealtime: true }, + "1h": { ms: 60 * 60 * 1000, refreshMs: 30000, useRealtime: false }, + "1d": { ms: 24 * 60 * 60 * 1000, refreshMs: 60000, useRealtime: false }, + "1w": { ms: 7 * 24 * 60 * 60 * 1000, refreshMs: 60000, useRealtime: false }, + "1M": { ms: 30 * 24 * 60 * 60 * 1000, refreshMs: 60000, useRealtime: false }, +}; + +// -- Domain / type identifiers -- + +export const INTEGRATION_DOMAIN = "span_panel"; +export const RELAY_STATE_CLOSED = "CLOSED"; +export const DEVICE_TYPE_PV = "pv"; +export const SUB_DEVICE_TYPE_BESS = "bess"; +export const SUB_DEVICE_TYPE_EVSE = "evse"; +export const SUB_DEVICE_KEY_PREFIX = "sub_"; + +// -- Chart layout constants -- + +export const CIRCUIT_CHART_HEIGHT = 100; +export const CIRCUIT_COL_SPAN_CHART_HEIGHT = 200; +export const BESS_CHART_COL_HEIGHT = 120; +export const EVSE_CHART_HEIGHT = 150; + +// -- NEC breaker limits -- + +export const NEC_CONTINUOUS_LOAD_FACTOR = 0.8; +export const NEC_TRIP_RATING_FACTOR = 1.25; + +// -- History thresholds -- + +export const STATISTICS_PERIOD_THRESHOLD_HOURS = 72; +export const MIN_HISTORY_DURATION_MS = 60_000; + +// -- UI debounce / timing -- + +export const INPUT_DEBOUNCE_MS = 500; +export const THRESHOLD_DEBOUNCE_MS = 800; +export const ERROR_DISPLAY_MS = 5_000; + +// -- Chart metric definitions -- + +export const CHART_METRICS: Record = { + power: { + entityRole: "power", + label: () => t("metric.power"), + unit: (v: number) => (Math.abs(v) >= 1000 ? "kW" : "W"), + format: (v: number) => { + const abs = Math.abs(v); + if (abs >= 1000) return (abs / 1000).toFixed(1); + if (abs < 10 && abs > 0) return abs.toFixed(1); + return String(Math.round(abs)); + }, + }, + current: { + entityRole: "current", + label: () => t("metric.current"), + unit: () => "A", + format: (v: number) => Math.abs(v).toFixed(1), + }, +}; + +export const BESS_CHART_METRICS: Record = { + soc: { + entityRole: "soc", + label: () => t("metric.soc"), + unit: () => "%", + format: (v: number) => String(Math.round(v)), + fixedMin: 0, + fixedMax: 100, + }, + soe: { + entityRole: "soe", + label: () => t("metric.soe"), + unit: () => "kWh", + format: (v: number) => v.toFixed(1), + }, + power: CHART_METRICS.power!, +}; + +// -- Shedding priority -- + +export const SHEDDING_PRIORITIES: Record = { + always_on: { icon: "mdi:battery", icon2: "mdi:router-wireless", color: "#4caf50", label: () => t("shedding.always_on") }, + never: { icon: "mdi:battery", color: "#4caf50", label: () => t("shedding.never") }, + soc_threshold: { icon: "mdi:battery-alert-variant-outline", color: "#9c27b0", label: () => t("shedding.soc_threshold"), textLabel: "SoC" }, + off_grid: { icon: "mdi:transmission-tower", color: "#ff9800", label: () => t("shedding.off_grid") }, + unknown: { icon: "mdi:help-circle-outline", color: "#888", label: () => t("shedding.unknown") }, +}; + +export const MONITORING_COLORS: Record = { + normal: "#4caf50", + warning: "#ff9800", + alert: "#f44336", + custom: "#ff9800", +}; diff --git a/src/core/dashboard-controller.ts b/src/core/dashboard-controller.ts new file mode 100644 index 0000000..9e5436d --- /dev/null +++ b/src/core/dashboard-controller.ts @@ -0,0 +1,447 @@ +import { DEFAULT_GRAPH_HORIZON, GRAPH_HORIZONS, 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 { GraphSettingsCache } from "./graph-settings.js"; +import type { HomeAssistant, PanelTopology, CardConfig, HistoryMap, GraphSettings } from "../types.js"; + +const RECORDER_REFRESH_MS = 30_000; +const RESIZE_THRESHOLD_PX = 5; +const RESIZE_DEBOUNCE_MS = 150; +const SLIDE_THRESHOLD = 0.9; + +type DOMRoot = Element | ShadowRoot; + +interface SpanSidePanelElement extends HTMLElement { + hass: HomeAssistant; + open(config: Record): void; +} + +/** + * Shared controller encapsulating dashboard behavior used by both + * the Lovelace card (SpanPanelCard) and the integration panel (DashboardTab). + */ +export class DashboardController { + readonly powerHistory: HistoryMap = new Map(); + readonly horizonMap: Map = new Map(); + readonly subDeviceHorizonMap: Map = new Map(); + readonly monitoringCache = new MonitoringStatusCache(); + readonly graphSettingsCache = new GraphSettingsCache(); + + private _hass: HomeAssistant | null = null; + private _topology: PanelTopology | null = null; + private _config: CardConfig | null = null; + private _configEntryId: string | null = null; + + private _showMonitoring = false; + private _updateInterval: ReturnType | null = null; + private _recorderRefreshInterval: ReturnType | null = null; + private _resizeObserver: ResizeObserver | null = null; + private _lastWidth = 0; + private _resizeDebounce: ReturnType | null = null; + + get hass(): HomeAssistant | null { + return this._hass; + } + + set hass(val: HomeAssistant | null) { + this._hass = val; + } + + get topology(): PanelTopology | null { + return this._topology; + } + + get config(): CardConfig | null { + return this._config; + } + + set showMonitoring(val: boolean) { + this._showMonitoring = val; + } + + init(topology: PanelTopology | null, config: CardConfig, hass: HomeAssistant | null, configEntryId: string | null): void { + this._topology = topology; + this._config = config; + this._hass = hass; + this._configEntryId = configEntryId; + } + + setConfig(config: CardConfig): void { + this._config = config; + } + + buildHorizonMaps(settings: GraphSettings | null): void { + this.horizonMap.clear(); + this.subDeviceHorizonMap.clear(); + if (settings && this._topology?.circuits) { + for (const uuid of Object.keys(this._topology.circuits)) { + this.horizonMap.set(uuid, getEffectiveHorizon(settings, uuid)); + } + } + if (settings && this._topology?.sub_devices) { + for (const devId of Object.keys(this._topology.sub_devices)) { + this.subDeviceHorizonMap.set(devId, getEffectiveSubDeviceHorizon(settings, devId)); + } + } + } + + async fetchAndBuildHorizonMaps(): Promise { + try { + await this.graphSettingsCache.fetch(this._hass!, this._configEntryId); + this.buildHorizonMaps(this.graphSettingsCache.settings); + } catch { + // Graph settings unavailable -- use defaults + } + } + + async loadHistory(): Promise { + await loadHistory(this._hass!, this._topology!, this._config!, this.powerHistory, this.horizonMap, this.subDeviceHorizonMap); + } + + recordSamples(): void { + if (!this._topology || !this._hass || !this._config) return; + const now = Date.now(); + + for (const [uuid, circuit] of Object.entries(this._topology.circuits)) { + const horizon = this.horizonMap.get(uuid) ?? DEFAULT_GRAPH_HORIZON; + if (!GRAPH_HORIZONS[horizon]?.useRealtime) continue; + + const entityId = getCircuitChartEntity(circuit, this._config); + if (!entityId) continue; + const state = this._hass.states[entityId]; + if (!state) continue; + const val = parseFloat(state.state); + if (isNaN(val)) continue; + + const durationMs = getHorizonDurationMs(horizon); + const maxPoints = getMaxHistoryPoints(durationMs); + const minGap = getMinGapMs(durationMs); + const cutoff = now - durationMs; + + const hist = this.powerHistory.get(uuid) ?? []; + if (hist.length > 0 && now - hist[hist.length - 1]!.time < minGap) continue; + + recordSample(this.powerHistory, uuid, val, now, cutoff, maxPoints); + } + + for (const { entityId, key, devId } of collectSubDeviceEntityIds(this._topology)) { + const horizon = this.subDeviceHorizonMap.get(devId) ?? DEFAULT_GRAPH_HORIZON; + if (!GRAPH_HORIZONS[horizon]?.useRealtime) continue; + + const state = this._hass.states[entityId]; + if (!state) continue; + const val = parseFloat(state.state); + if (isNaN(val)) continue; + + const durationMs = getHorizonDurationMs(horizon); + const maxPoints = getMaxHistoryPoints(durationMs); + const minGap = getMinGapMs(durationMs); + const cutoff = now - durationMs; + + const hist = this.powerHistory.get(key) ?? []; + if (hist.length > 0 && now - hist[hist.length - 1]!.time < minGap) continue; + + recordSample(this.powerHistory, key, val, now, cutoff, maxPoints); + } + } + + async refreshRecorderData(root: DOMRoot): Promise { + if (!this._topology || !this._hass || !this._config) return; + + const nonRealtimeMap = new Map(); + for (const [uuid, horizon] of this.horizonMap) { + if (!GRAPH_HORIZONS[horizon]?.useRealtime) { + nonRealtimeMap.set(uuid, horizon); + } + } + + const nonRealtimeSubDeviceMap = new Map(); + for (const [devId, horizon] of this.subDeviceHorizonMap) { + if (!GRAPH_HORIZONS[horizon]?.useRealtime) { + nonRealtimeSubDeviceMap.set(devId, horizon); + } + } + + if (nonRealtimeMap.size === 0 && nonRealtimeSubDeviceMap.size === 0) return; + + const nonRealtimeSubDeviceKeys = new Set(); + if (nonRealtimeSubDeviceMap.size > 0 && this._topology) { + for (const { key, devId } of collectSubDeviceEntityIds(this._topology)) { + if (nonRealtimeSubDeviceMap.has(devId)) { + nonRealtimeSubDeviceKeys.add(key); + } + } + } + + const freshHistory: HistoryMap = new Map(); + try { + await loadHistory(this._hass, this._topology, this._config, freshHistory, nonRealtimeMap, nonRealtimeSubDeviceMap); + for (const uuid of nonRealtimeMap.keys()) { + const data = freshHistory.get(uuid); + if (data) { + this.powerHistory.set(uuid, data); + } else { + this.powerHistory.delete(uuid); + } + } + for (const key of nonRealtimeSubDeviceKeys) { + const data = freshHistory.get(key); + if (data) { + this.powerHistory.set(key, data); + } else { + this.powerHistory.delete(key); + } + } + this.updateDOM(root); + } catch { + // Will refresh on next interval + } + } + + updateDOM(root: DOMRoot): void { + if (!this._hass || !this._topology || !this._config) return; + updateCircuitDOM(root, this._hass, this._topology, this._config, this.powerHistory, this.horizonMap); + updateSubDeviceDOM(root, this._hass, this._topology, this._config, this.powerHistory, this.subDeviceHorizonMap); + } + + 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); + + this.powerHistory.clear(); + try { + await this.loadHistory(); + } catch { + // Will populate on next refresh + } + this.updateDOM(root); + } + + onToggleClick(ev: Event, root: DOMRoot): void { + const target = ev.target as HTMLElement | null; + const pill = target?.closest(".toggle-pill"); + if (!pill) return; + const cb = root.querySelector(".slide-confirm"); + if (!cb || !cb.classList.contains("confirmed")) return; + ev.stopPropagation(); + ev.preventDefault(); + const slot = pill.closest("[data-uuid]") as HTMLElement | null; + if (!slot || !this._topology || !this._hass) return; + const uuid = slot.dataset.uuid; + if (!uuid) return; + const circuit = this._topology.circuits[uuid]; + if (!circuit) return; + const switchEntity = circuit.entities?.switch; + if (!switchEntity) return; + const switchState = this._hass.states[switchEntity]; + if (!switchState) { + console.warn("SPAN Panel: switch entity not found:", switchEntity); + return; + } + 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); + }); + } + + async onGearClick(event: Event, root: DOMRoot): Promise { + const target = event.target as HTMLElement | null; + const gearBtn = target?.closest(".gear-icon") as HTMLElement | null; + if (!gearBtn) return; + + const sidePanel = root.querySelector("span-side-panel") as SpanSidePanelElement | null; + if (!sidePanel || !this._hass) return; + sidePanel.hass = this._hass; + + if (gearBtn.classList.contains("panel-gear")) { + await this.graphSettingsCache.fetch(this._hass, this._configEntryId); + sidePanel.open({ + panelMode: true, + topology: this._topology, + graphSettings: this.graphSettingsCache.settings, + }); + return; + } + + const uuid = gearBtn.dataset.uuid; + if (uuid && this._topology) { + const circuit = this._topology.circuits[uuid]; + if (circuit) { + await this.monitoringCache.fetch(this._hass, this._configEntryId); + const monitoringEntity = circuit.entities?.current ?? circuit.entities?.power; + const monitoringInfo = monitoringEntity ? (this.monitoringCache.status?.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 graphHorizonInfo = circuitOverride ? { ...circuitOverride, globalHorizon } : { horizon: globalHorizon, has_override: false, globalHorizon }; + + sidePanel.open({ + ...circuit, + uuid, + monitoringInfo, + showMonitoring: this._showMonitoring, + graphHorizonInfo, + } as Record); + return; + } + } + + const subDevId = gearBtn.dataset.subdevId; + if (subDevId && this._topology?.sub_devices?.[subDevId]) { + const sub = this._topology.sub_devices[subDevId]!; + + 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 graphHorizonInfo = subOverride ? { ...subOverride, globalHorizon } : { horizon: globalHorizon, has_override: false, globalHorizon }; + + sidePanel.open({ + subDeviceMode: true, + subDeviceId: subDevId, + name: sub.name ?? subDevId, + deviceType: sub.type ?? "", + graphHorizonInfo, + }); + } + } + + bindSlideConfirm(slideEl: Element, parent: Element | null): void { + const knob = slideEl.querySelector(".slide-confirm-knob") as HTMLElement | null; + const textEl = slideEl.querySelector(".slide-confirm-text"); + if (!knob || !textEl) return; + let dragging = false; + let startX = 0; + let maxX = 0; + + const begin = (clientX: number): void => { + if (slideEl.classList.contains("confirmed")) return; + dragging = true; + startX = clientX - knob.offsetLeft; + maxX = (slideEl as HTMLElement).offsetWidth - knob.offsetWidth - 4; + knob.classList.remove("snapping"); + }; + const move = (clientX: number): void => { + if (!dragging) return; + const x = Math.max(2, Math.min(clientX - startX, maxX)); + knob.style.left = x + "px"; + }; + const end = (): void => { + if (!dragging) return; + dragging = false; + const pos = (knob.offsetLeft - 2) / maxX; + if (pos >= SLIDE_THRESHOLD) { + knob.style.left = maxX + "px"; + slideEl.classList.add("confirmed"); + knob.querySelector("ha-icon")?.setAttribute("icon", "mdi:lock-open"); + textEl.textContent = (slideEl as HTMLElement).dataset.textOn ?? ""; + if (parent) parent.classList.remove("switches-disabled"); + } else { + knob.classList.add("snapping"); + knob.style.left = "2px"; + } + }; + + knob.addEventListener("mousedown", (e: MouseEvent) => { + e.preventDefault(); + begin(e.clientX); + }); + slideEl.addEventListener("mousemove", (e: Event) => move((e as MouseEvent).clientX)); + slideEl.addEventListener("mouseup", end); + slideEl.addEventListener("mouseleave", end); + knob.addEventListener( + "touchstart", + (e: TouchEvent) => { + e.preventDefault(); + begin(e.touches[0]!.clientX); + }, + { passive: false } + ); + slideEl.addEventListener("touchmove", (e: Event) => move((e as TouchEvent).touches[0]!.clientX), { passive: true }); + slideEl.addEventListener("touchend", end); + slideEl.addEventListener("touchcancel", end); + + slideEl.addEventListener("click", () => { + if (!slideEl.classList.contains("confirmed")) return; + slideEl.classList.remove("confirmed"); + knob.classList.add("snapping"); + knob.style.left = "2px"; + knob.querySelector("ha-icon")?.setAttribute("icon", "mdi:lock"); + textEl.textContent = (slideEl as HTMLElement).dataset.textOff ?? ""; + if (parent) parent.classList.add("switches-disabled"); + }); + } + + startIntervals(root: DOMRoot, onUpdate?: () => void): void { + this._updateInterval = setInterval(() => { + this.recordSamples(); + this.updateDOM(root); + if (onUpdate) onUpdate(); + }, LIVE_SAMPLE_INTERVAL_MS); + + this._recorderRefreshInterval = setInterval(() => { + this.refreshRecorderData(root); + }, RECORDER_REFRESH_MS); + } + + stopIntervals(): void { + if (this._updateInterval) { + clearInterval(this._updateInterval); + this._updateInterval = null; + } + if (this._recorderRefreshInterval) { + clearInterval(this._recorderRefreshInterval); + this._recorderRefreshInterval = null; + } + this.cleanupResizeObserver(); + } + + setupResizeObserver(root: DOMRoot, element: Element | null): void { + this.cleanupResizeObserver(); + if (!element) return; + this._lastWidth = (element as HTMLElement).clientWidth; + this._resizeObserver = new ResizeObserver(entries => { + const entry = entries[0]; + if (!entry) return; + const newWidth = entry.contentRect.width; + if (Math.abs(newWidth - this._lastWidth) < RESIZE_THRESHOLD_PX) return; + this._lastWidth = newWidth; + if (this._resizeDebounce) clearTimeout(this._resizeDebounce); + this._resizeDebounce = setTimeout(() => { + for (const container of root.querySelectorAll(".chart-container")) { + const chart = container.querySelector("ha-chart-base"); + if (chart) chart.remove(); + } + this.updateDOM(root); + }, RESIZE_DEBOUNCE_MS); + }); + this._resizeObserver.observe(element); + } + + cleanupResizeObserver(): void { + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + this._resizeObserver = null; + } + if (this._resizeDebounce) { + clearTimeout(this._resizeDebounce); + this._resizeDebounce = null; + } + } + + reset(): void { + this.powerHistory.clear(); + this.horizonMap.clear(); + this.subDeviceHorizonMap.clear(); + this.monitoringCache.clear(); + this.graphSettingsCache.clear(); + } +} diff --git a/src/core/dom-updater.ts b/src/core/dom-updater.ts new file mode 100644 index 0000000..a8b518b --- /dev/null +++ b/src/core/dom-updater.ts @@ -0,0 +1,292 @@ +import { + BESS_CHART_METRICS, + DEVICE_TYPE_PV, + RELAY_STATE_CLOSED, + SHEDDING_PRIORITIES, + CIRCUIT_CHART_HEIGHT, + CIRCUIT_COL_SPAN_CHART_HEIGHT, + BESS_CHART_COL_HEIGHT, + EVSE_CHART_HEIGHT, +} from "../constants.js"; +import { formatPowerSigned, formatPowerUnit, formatKw } from "../helpers/format.js"; +import { t } from "../i18n.js"; +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 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 { + 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"); + if (isAmpsMode) { + const siteEid = topology.panel_entities?.site_power; + const siteState = siteEid ? hass.states[siteEid] : null; + const amps = siteState ? parseFloat(siteState.attributes?.amperage as string) : NaN; + if (consumptionEl) consumptionEl.textContent = Number.isFinite(amps) ? Math.abs(amps).toFixed(1) : "--"; + if (consumptionUnitEl) consumptionUnitEl.textContent = "A"; + } else { + const siteEid = topology.panel_entities?.site_power; + if (siteEid) { + const state = hass.states[siteEid]; + if (state) totalConsumption = Math.abs(parseFloat(state.state) || 0); + } + if (consumptionEl) consumptionEl.textContent = formatKw(totalConsumption); + if (consumptionUnitEl) consumptionUnitEl.textContent = "kW"; + } + + // Upstream stat + const upstreamEl = root.querySelector(".stat-upstream .stat-value"); + const upstreamUnitEl = root.querySelector(".stat-upstream .stat-unit"); + if (upstreamEl) { + const upEid = topology.panel_entities?.current_power; + const upState = upEid ? hass.states[upEid] : null; + if (isAmpsMode) { + const amps = upState ? parseFloat(upState.attributes?.amperage as string) : NaN; + upstreamEl.textContent = Number.isFinite(amps) ? Math.abs(amps).toFixed(1) : "--"; + if (upstreamUnitEl) upstreamUnitEl.textContent = "A"; + } else { + const w = upState ? Math.abs(parseFloat(upState.state) || 0) : 0; + upstreamEl.textContent = formatKw(w); + if (upstreamUnitEl) upstreamUnitEl.textContent = "kW"; + } + } + + // Downstream stat + const downstreamEl = root.querySelector(".stat-downstream .stat-value"); + const downstreamUnitEl = root.querySelector(".stat-downstream .stat-unit"); + if (downstreamEl) { + const downEid = topology.panel_entities?.feedthrough_power; + const downState = downEid ? hass.states[downEid] : null; + if (isAmpsMode) { + const amps = downState ? parseFloat(downState.attributes?.amperage as string) : NaN; + downstreamEl.textContent = Number.isFinite(amps) ? Math.abs(amps).toFixed(1) : "--"; + if (downstreamUnitEl) downstreamUnitEl.textContent = "A"; + } else { + const w = downState ? Math.abs(parseFloat(downState.state) || 0) : 0; + downstreamEl.textContent = formatKw(w); + if (downstreamUnitEl) downstreamUnitEl.textContent = "kW"; + } + } + + // 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"); + if (solarEl) { + const solarEid = topology.panel_entities?.pv_power; + const solarState = solarEid ? hass.states[solarEid] : null; + if (isAmpsMode) { + const amps = solarState ? parseFloat(solarState.attributes?.amperage as string) : NaN; + solarEl.textContent = Number.isFinite(amps) ? Math.abs(amps).toFixed(1) : "--"; + if (solarUnitEl) solarUnitEl.textContent = "A"; + } else { + if (solarState) { + const w = Math.abs(parseFloat(solarState.state) || 0); + solarEl.textContent = formatKw(w); + } else { + solarEl.textContent = "--"; + } + if (solarUnitEl) solarUnitEl.textContent = "kW"; + } + } + + // Battery SoC (always %) + const batteryEl = root.querySelector(".stat-battery .stat-value"); + if (batteryEl) { + const battEid = topology.panel_entities?.battery_level; + const battState = battEid ? hass.states[battEid] : null; + if (battState) batteryEl.textContent = `${Math.round(parseFloat(battState.state) || 0)}`; + } + + // Grid / DSM state + const gridStateEl = root.querySelector(".stat-grid-state .stat-value"); + if (gridStateEl) { + const gridEid = topology.panel_entities?.dsm_state; + const gridState = gridEid ? hass.states[gridEid] : null; + gridStateEl.textContent = gridState ? hass.formatEntityState?.(gridState) || gridState.state : "--"; + } +} + +// ── Exported updaters ────────────────────────────────────────────────────── + +export function updateCircuitDOM( + root: Element | ShadowRoot, + hass: HomeAssistant, + topology: PanelTopology, + config: CardConfig, + powerHistory: HistoryMap, + horizonMap: Map | undefined +): void { + if (!root || !topology || !hass) return; + + const defaultDurationMs = getHistoryDurationMs(config); + let totalConsumption = 0; + + for (const [, circuit] of Object.entries(topology.circuits)) { + const entityId = circuit.entities?.power; + if (!entityId) continue; + const state = hass.states[entityId]; + const power = state ? parseFloat(state.state) || 0 : 0; + if (circuit.device_type !== DEVICE_TYPE_PV) { + totalConsumption += Math.abs(power); + } + } + + _updateHeaderStats(root, hass, topology, config, totalConsumption); + + const chartMetric: ChartMetricDef = getChartMetric(config); + const showCurrent = chartMetric.entityRole === "current"; + + for (const [uuid, circuit] of Object.entries(topology.circuits)) { + const slot = root.querySelector(`[data-uuid="${uuid}"]`); + if (!slot) continue; + + const entityId = circuit.entities?.power; + const state = entityId ? hass.states[entityId] : null; + const powerW = state ? parseFloat(state.state) || 0 : 0; + const isProducer = circuit.device_type === DEVICE_TYPE_PV || powerW < 0; + + const switchEntityId = circuit.entities?.switch; + const switchState = switchEntityId ? hass.states[switchEntityId] : null; + const isOn = switchState + ? switchState.state === "on" + : ((state?.attributes?.relay_state as string | undefined) || circuit.relay_state) === RELAY_STATE_CLOSED; + + const powerVal = slot.querySelector(".power-value"); + if (powerVal) { + if (showCurrent) { + const currentEid = circuit.entities?.current; + const currentState = currentEid ? hass.states[currentEid] : null; + const amps = currentState ? parseFloat(currentState.state) || 0 : 0; + powerVal.innerHTML = `${chartMetric.format(amps)}A`; + } else { + powerVal.innerHTML = `${formatPowerSigned(powerW)}${formatPowerUnit(powerW)}`; + } + } + + const toggle = slot.querySelector(".toggle-pill") as HTMLElement | null; + if (toggle) { + toggle.className = `toggle-pill ${isOn ? "toggle-on" : "toggle-off"}`; + const label = toggle.querySelector(".toggle-label"); + if (label) label.textContent = isOn ? t("grid.on") : t("grid.off"); + } + + slot.classList.toggle("circuit-off", !isOn); + slot.classList.toggle("circuit-producer", isProducer); + + // Update shedding priority icon + let priority: string; + if (circuit.always_on) { + priority = "always_on"; + } else { + const selectEid = circuit.entities?.select; + const selectState = selectEid ? hass.states[selectEid] : null; + priority = selectState ? selectState.state : "unknown"; + } + // "unknown" key always exists in SHEDDING_PRIORITIES, so the fallback is guaranteed + const shedInfo = (SHEDDING_PRIORITIES[priority] ?? SHEDDING_PRIORITIES["unknown"])!; + const sheddingIcon = slot.querySelector(".shedding-icon") as HTMLElement | null; + if (sheddingIcon) { + sheddingIcon.setAttribute("icon", shedInfo.icon); + sheddingIcon.style.color = shedInfo.color; + sheddingIcon.title = shedInfo.label(); + } + // Update secondary icon if present + const secondaryIcon = slot.querySelector(".shedding-icon-secondary") as HTMLElement | null; + if (secondaryIcon) { + if (shedInfo.icon2) { + secondaryIcon.setAttribute("icon", shedInfo.icon2); + secondaryIcon.style.color = shedInfo.color; + secondaryIcon.style.display = ""; + } else { + secondaryIcon.style.display = "none"; + } + } + // Update text label if present + const sheddingLabel = slot.querySelector(".shedding-label") as HTMLElement | null; + if (sheddingLabel) { + if (shedInfo.textLabel) { + sheddingLabel.textContent = shedInfo.textLabel; + sheddingLabel.style.color = shedInfo.color; + sheddingLabel.style.display = ""; + } else { + sheddingLabel.style.display = "none"; + } + } + + const chartContainer = slot.querySelector(".chart-container") as HTMLElement | null; + if (chartContainer) { + const history = powerHistory.get(uuid) || []; + const h = slot.classList.contains("circuit-col-span") ? CIRCUIT_COL_SPAN_CHART_HEIGHT : CIRCUIT_CHART_HEIGHT; + const circuitDuration = horizonMap?.has(uuid) ? getHorizonDurationMs(horizonMap.get(uuid)!) : defaultDurationMs; + updateChart(chartContainer, hass, history, circuitDuration, chartMetric, isProducer, h, circuit.breaker_rating_a ?? undefined); + } + } +} + +export function updateSubDeviceDOM( + root: Element | ShadowRoot, + hass: HomeAssistant, + topology: PanelTopology, + config: CardConfig, + powerHistory: HistoryMap, + subDeviceHorizonMap: Map | undefined +): void { + if (!topology.sub_devices) return; + const defaultDurationMs = getHistoryDurationMs(config); + + for (const [devId, sub] of Object.entries(topology.sub_devices)) { + const section = root.querySelector(`[data-subdev="${devId}"]`); + if (!section) continue; + + const powerEid = findSubDevicePowerEntity(sub); + if (powerEid) { + const state = hass.states[powerEid]; + const powerW = state ? parseFloat(state.state) || 0 : 0; + const powerEl = section.querySelector(".sub-power-value"); + if (powerEl) { + powerEl.innerHTML = `${formatPowerSigned(powerW)} ${formatPowerUnit(powerW)}`; + } + } + + const chartContainers = section.querySelectorAll("[data-chart-key]"); + for (const cc of chartContainers) { + const chartKey = (cc as HTMLElement).dataset.chartKey; + if (!chartKey) continue; + const history = powerHistory.get(chartKey) || []; + let metric: ChartMetricDef = BESS_CHART_METRICS["power"]!; + if (chartKey.endsWith("_soc")) metric = BESS_CHART_METRICS["soc"]!; + else if (chartKey.endsWith("_soe")) metric = BESS_CHART_METRICS["soe"]!; + const isBessCol = !!cc.closest(".bess-chart-col"); + const devDuration = subDeviceHorizonMap?.has(devId) ? getHorizonDurationMs(subDeviceHorizonMap.get(devId)!) : defaultDurationMs; + updateChart(cc as HTMLElement, hass, history, devDuration, metric, false, isBessCol ? BESS_CHART_COL_HEIGHT : EVSE_CHART_HEIGHT); + } + + for (const entityId of Object.keys(sub.entities || {})) { + const valEl = section.querySelector(`[data-eid="${entityId}"]`); + if (!valEl) continue; + const state = hass.states[entityId]; + if (state) { + let displayValue: string; + if (hass.formatEntityState) { + displayValue = hass.formatEntityState(state); + } else { + displayValue = state.state; + const unit = (state.attributes.unit_of_measurement as string) || ""; + if (unit) displayValue += " " + unit; + } + const rawUnit = (state.attributes.unit_of_measurement as string) || ""; + if (rawUnit === "Wh") { + const wh = parseFloat(state.state); + if (!isNaN(wh)) displayValue = (wh / 1000).toFixed(1) + " kWh"; + } + valEl.textContent = displayValue; + } + } + } +} diff --git a/src/core/graph-settings.ts b/src/core/graph-settings.ts new file mode 100644 index 0000000..b347158 --- /dev/null +++ b/src/core/graph-settings.ts @@ -0,0 +1,92 @@ +// src/core/graph-settings.ts +import { INTEGRATION_DOMAIN, DEFAULT_GRAPH_HORIZON } from "../constants.js"; +import type { HomeAssistant, GraphSettings } from "../types.js"; + +const GRAPH_SETTINGS_POLL_INTERVAL_MS = 30_000; + +interface GraphSettingsServiceResponse { + response?: GraphSettings; +} + +/** + * Caches graph horizon settings fetched via the get_graph_settings service. + * Re-fetches at most every 30 seconds unless invalidated. + */ +export class GraphSettingsCache { + private _settings: GraphSettings | null; + private _lastFetch: number; + private _fetching: boolean; + + constructor() { + this._settings = null; + this._lastFetch = 0; + this._fetching = false; + } + + /** + * Fetch graph settings, returning cached data if recent. + */ + async fetch(hass: HomeAssistant, configEntryId?: string | null): Promise { + const now = Date.now(); + if (this._fetching) return this._settings; + if (this._settings && now - this._lastFetch < GRAPH_SETTINGS_POLL_INTERVAL_MS) { + return this._settings; + } + + 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_graph_settings", + service_data: serviceData, + return_response: true, + }); + this._settings = resp?.response ?? null; + this._lastFetch = now; + } catch { + this._settings = null; + } finally { + this._fetching = false; + } + return this._settings; + } + + /** Force the next fetch() call to re-query the backend. */ + invalidate(): void { + this._lastFetch = 0; + } + + /** Last fetched settings. */ + get settings(): GraphSettings | null { + return this._settings; + } + + /** Clear cached settings (e.g., on config change). */ + clear(): void { + this._settings = null; + this._lastFetch = 0; + } +} + +/** + * Get the effective horizon for a circuit. + */ +export function getEffectiveHorizon(settings: GraphSettings | null, circuitId: string): string { + if (!settings) return DEFAULT_GRAPH_HORIZON; + const override = settings.circuits?.[circuitId]; + if (override?.has_override) return override.horizon; + return settings.global_horizon ?? DEFAULT_GRAPH_HORIZON; +} + +/** + * Get the effective horizon for a sub-device. + */ +export function getEffectiveSubDeviceHorizon(settings: GraphSettings | null, subDeviceId: string): string { + if (!settings) return DEFAULT_GRAPH_HORIZON; + const override = settings.sub_devices?.[subDeviceId]; + if (override?.has_override) return override.horizon; + return settings.global_horizon ?? DEFAULT_GRAPH_HORIZON; +} diff --git a/src/core/grid-renderer.ts b/src/core/grid-renderer.ts new file mode 100644 index 0000000..86330f2 --- /dev/null +++ b/src/core/grid-renderer.ts @@ -0,0 +1,240 @@ +import { escapeHtml } from "../helpers/sanitize.js"; +import { formatPowerSigned, formatPowerUnit } from "../helpers/format.js"; +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 type { PanelTopology, Circuit, HomeAssistant, CardConfig, MonitoringStatus, MonitoringPointInfo, SheddingPriorityDef } from "../types.js"; + +type SlotLayout = "single" | "row-span" | "col-span"; + +interface TabMapEntry { + uuid: string; + circuit: Circuit; + layout: SlotLayout; +} + +/** + * Build the full grid HTML for the panel breaker grid. + */ +export function buildGridHTML( + topology: PanelTopology, + totalRows: number, + hass: HomeAssistant, + config: CardConfig, + monitoringStatus: MonitoringStatus | null +): string { + const tabMap = new Map(); + const occupiedTabs = new Set(); + + for (const [uuid, circuit] of Object.entries(topology.circuits)) { + const tabs = circuit.tabs; + if (!tabs || tabs.length === 0) continue; + const primaryTab = Math.min(...tabs); + const layout: SlotLayout = tabs.length === 1 ? "single" : (classifyDualTab(tabs) ?? "single"); + tabMap.set(primaryTab, { uuid, circuit, layout }); + for (const tab of tabs) occupiedTabs.add(tab); + } + + const rowsToSkipLeft = new Set(); + const rowsToSkipRight = new Set(); + + for (const [primaryTab, entry] of tabMap) { + if (entry.layout === "col-span") { + const tabs = entry.circuit.tabs; + const secondaryTab = Math.max(...tabs); + const secondaryRow = tabToRow(secondaryTab); + const col = tabToCol(primaryTab); + if (col === 0) rowsToSkipLeft.add(secondaryRow); + else rowsToSkipRight.add(secondaryRow); + } + } + + function lookupMonitoring(entry: TabMapEntry): { + monInfo: MonitoringPointInfo | null; + sheddingPriority: string; + } { + const circuitEntityId = entry.circuit.entities?.current ?? entry.circuit.entities?.power; + const monInfo = monitoringStatus ? getCircuitMonitoringInfo(monitoringStatus, circuitEntityId ?? "") : null; + let sheddingPriority: string; + if (entry.circuit.always_on) { + sheddingPriority = "always_on"; + } else { + const selectEid = entry.circuit.entities?.select; + sheddingPriority = selectEid && hass.states[selectEid] ? hass.states[selectEid].state : "unknown"; + } + return { monInfo, sheddingPriority }; + } + + let gridHTML = ""; + for (let row = 1; row <= totalRows; row++) { + const leftTab = row * 2 - 1; + const rightTab = row * 2; + const leftEntry = tabMap.get(leftTab); + const rightEntry = tabMap.get(rightTab); + + gridHTML += `
${leftTab}
`; + + if (leftEntry && leftEntry.layout === "row-span") { + const { monInfo, sheddingPriority } = lookupMonitoring(leftEntry); + gridHTML += renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, row, "2 / 4", "row-span", hass, config, monInfo, sheddingPriority); + gridHTML += `
${rightTab}
`; + continue; + } + + if (!rowsToSkipLeft.has(row)) { + if (leftEntry && (leftEntry.layout === "col-span" || leftEntry.layout === "single")) { + const { monInfo, sheddingPriority } = lookupMonitoring(leftEntry); + gridHTML += renderCircuitSlot(leftEntry.uuid, leftEntry.circuit, row, "2", leftEntry.layout, hass, config, monInfo, sheddingPriority); + } else if (!occupiedTabs.has(leftTab)) { + gridHTML += renderEmptySlot(row, "2"); + } + } + + if (!rowsToSkipRight.has(row)) { + if (rightEntry && (rightEntry.layout === "col-span" || rightEntry.layout === "single")) { + const { monInfo, sheddingPriority } = lookupMonitoring(rightEntry); + gridHTML += renderCircuitSlot(rightEntry.uuid, rightEntry.circuit, row, "3", rightEntry.layout, hass, config, monInfo, sheddingPriority); + } else if (!occupiedTabs.has(rightTab)) { + gridHTML += renderEmptySlot(row, "3"); + } + } + + gridHTML += `
${rightTab}
`; + } + return gridHTML; +} + +/** + * Render a single circuit breaker slot. + */ +export function renderCircuitSlot( + uuid: string, + circuit: Circuit, + row: number, + col: string, + layout: SlotLayout, + hass: HomeAssistant, + config: CardConfig, + monitoringInfo: MonitoringPointInfo | null, + sheddingPriority: string +): string { + const entityId = circuit.entities?.power; + const state = entityId ? hass.states[entityId] : null; + const powerW = state ? parseFloat(state.state) || 0 : 0; + const isProducer = circuit.device_type === DEVICE_TYPE_PV || powerW < 0; + + const switchEntityId = circuit.entities?.switch; + const switchState = switchEntityId ? hass.states[switchEntityId] : null; + const isOn = switchState + ? switchState.state === "on" + : ((state?.attributes?.relay_state as string | undefined) || circuit.relay_state) === RELAY_STATE_CLOSED; + + const breakerAmps = circuit.breaker_rating_a; + const breakerLabel = breakerAmps ? `${Math.round(breakerAmps)}A` : ""; + const name = escapeHtml(circuit.name || t("grid.unknown")); + + const chartMetric = getChartMetric(config); + const showCurrent = chartMetric.entityRole === "current"; + let valueHTML: string; + if (showCurrent) { + const currentEid = circuit.entities?.current; + const currentState = currentEid ? hass.states[currentEid] : null; + const amps = currentState ? parseFloat(currentState.state) || 0 : 0; + valueHTML = `${chartMetric.format(amps)}A`; + } else { + valueHTML = `${formatPowerSigned(powerW)}${formatPowerUnit(powerW)}`; + } + + // Shedding icon (supports composite: dual-icon or icon+text) + const priority = sheddingPriority || "unknown"; + const shedInfo: SheddingPriorityDef = SHEDDING_PRIORITIES[priority] ?? + SHEDDING_PRIORITIES.unknown ?? { icon: "mdi:help", color: "#999", label: () => "Unknown" }; + let sheddingHTML: string; + if (shedInfo.icon2) { + sheddingHTML = ` + + + `; + } else if (shedInfo.textLabel) { + sheddingHTML = ` + + ${shedInfo.textLabel} + `; + } else { + sheddingHTML = ``; + } + + // Gear icon + const hasOverridesFlag = monitoringInfo && hasCustomOverrides(monitoringInfo); + const gearColor = hasOverridesFlag ? MONITORING_COLORS.custom : "#555"; + const gearHTML = ``; + + // Utilization (shown when monitoring is active) + let utilizationHTML = ""; + if (monitoringInfo?.utilization_pct != null) { + const pct = monitoringInfo.utilization_pct; + const utilClass = getUtilizationClass(monitoringInfo); + utilizationHTML = `${Math.round(pct)}%`; + } + + // Alert and custom monitoring classes + const alertActive = isAlertActive(monitoringInfo); + const alertClass = alertActive ? "circuit-alert" : ""; + const customClass = hasOverridesFlag ? "circuit-custom-monitoring" : ""; + + const rowSpan = layout === "col-span" ? `${row} / span 2` : `${row}`; + const layoutClass = layout === "row-span" ? "circuit-row-span" : layout === "col-span" ? "circuit-col-span" : ""; + + return ` +
+
+
+ ${breakerLabel ? `${breakerLabel}` : ""} + ${name} +
+
+ + ${valueHTML} + + ${ + circuit.is_user_controllable !== false && circuit.entities?.switch + ? ` +
+ ${isOn ? t("grid.on") : t("grid.off")} + +
+ ` + : "" + } +
+
+
+ ${sheddingHTML} + ${utilizationHTML} + ${gearHTML} +
+
+
+ `; +} + +/** + * Render an empty breaker slot. + */ +export function renderEmptySlot(row: number, col: string): string { + return ` +
+ +
+ `; +} diff --git a/src/core/header-renderer.ts b/src/core/header-renderer.ts new file mode 100644 index 0000000..0619e05 --- /dev/null +++ b/src/core/header-renderer.ts @@ -0,0 +1,139 @@ +import { escapeHtml } from "../helpers/sanitize.js"; +import { t } from "../i18n.js"; +import { SHEDDING_PRIORITIES } from "../constants.js"; +import type { PanelTopology, CardConfig, SheddingPriorityDef } from "../types.js"; + +/** + * Build the panel header HTML with stats, gear icon, and A/W toggle. + */ +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"; + + const hasSite: boolean = !!topology.panel_entities?.site_power; + const hasGrid: boolean = !!topology.panel_entities?.dsm_state; + const hasUpstream: boolean = !!topology.panel_entities?.current_power; + const hasDownstream: boolean = !!topology.panel_entities?.feedthrough_power; + const hasSolar: boolean = !!topology.panel_entities?.pv_power; + const hasBattery: boolean = !!topology.panel_entities?.battery_level; + + return ` +
+
+
+

${panelName}

+ ${serial} + +
+ ${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")} +
+ + % +
+
` + : "" + } +
+
+
+
+ ${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("")} +
+
+
+ `; +} diff --git a/src/core/history-loader.ts b/src/core/history-loader.ts new file mode 100644 index 0000000..b0bc8e1 --- /dev/null +++ b/src/core/history-loader.ts @@ -0,0 +1,190 @@ +import { getHistoryDurationMs, getMaxHistoryPoints, getMinGapMs, deduplicateAndTrim, getHorizonDurationMs } from "../helpers/history.js"; +import { getCircuitChartEntity } from "../helpers/chart.js"; +import { findSubDevicePowerEntity, findBatteryLevelEntity, findBatterySoeEntity } from "../helpers/entity-finder.js"; +import { SUB_DEVICE_TYPE_BESS, SUB_DEVICE_KEY_PREFIX, STATISTICS_PERIOD_THRESHOLD_HOURS } from "../constants.js"; +import type { HomeAssistant, PanelTopology, CardConfig, HistoryMap, HistoryPoint, SubDeviceEntityRef } from "../types.js"; + +interface StatisticsEntry { + start: number; + mean: number | null; +} + +interface RawHistoryEntry { + s: string; + lu?: number; + lc?: number; +} + +interface DurationGroup { + entityIds: string[]; + uuidByEntity: Map; +} + +async function loadStatisticsHistory( + hass: HomeAssistant, + entityIds: string[], + uuidByEntity: Map, + durationMs: number, + powerHistory: HistoryMap +): Promise { + const startTime = new Date(Date.now() - durationMs).toISOString(); + const durationHours = durationMs / (60 * 60 * 1000); + const period = durationHours > STATISTICS_PERIOD_THRESHOLD_HOURS ? "hour" : "5minute"; + + const result = await hass.callWS>({ + type: "recorder/statistics_during_period", + start_time: startTime, + statistic_ids: entityIds, + period, + types: ["mean"], + }); + + for (const [entityId, stats] of Object.entries(result)) { + const uuid = uuidByEntity.get(entityId); + if (!uuid || !stats) continue; + + const hist: HistoryPoint[] = []; + for (const entry of stats) { + const val = entry.mean; + if (val == null || !Number.isFinite(val)) continue; + // HA statistics WS API returns start as epoch milliseconds + const time = entry.start; + if (time > 0) hist.push({ time, value: val }); + } + + if (hist.length > 0) { + const existing = powerHistory.get(uuid) || []; + const merged = [...hist, ...existing]; + merged.sort((a: HistoryPoint, b: HistoryPoint) => a.time - b.time); + powerHistory.set(uuid, merged); + } + } +} + +async function loadRawHistory( + hass: HomeAssistant, + entityIds: string[], + uuidByEntity: Map, + durationMs: number, + powerHistory: HistoryMap +): Promise { + const startTime = new Date(Date.now() - durationMs).toISOString(); + const result = await hass.callWS>({ + type: "history/history_during_period", + start_time: startTime, + entity_ids: entityIds, + minimal_response: true, + significant_changes_only: true, + no_attributes: true, + }); + + const maxPoints = getMaxHistoryPoints(durationMs); + const minGapMs = getMinGapMs(durationMs); + for (const [entityId, states] of Object.entries(result)) { + const uuid = uuidByEntity.get(entityId); + if (!uuid || !states) continue; + + const hist: HistoryPoint[] = []; + for (const entry of states) { + const val = parseFloat(entry.s); + if (!Number.isFinite(val)) continue; + const tsSec = entry.lu || entry.lc || 0; + const time = tsSec * 1000; + if (time > 0) hist.push({ time, value: val }); + } + + if (hist.length > 0) { + const existing = powerHistory.get(uuid) || []; + const merged = [...hist, ...existing]; + powerHistory.set(uuid, deduplicateAndTrim(merged, maxPoints, minGapMs)); + } + } +} + +/** + * Build the entity ID list for all sub-devices. + * Returns an array of { entityId, key, devId } so callers can record live samples. + */ +export function collectSubDeviceEntityIds(topology: PanelTopology): SubDeviceEntityRef[] { + if (!topology.sub_devices) return []; + const results: SubDeviceEntityRef[] = []; + for (const [devId, sub] of Object.entries(topology.sub_devices)) { + const eidMap: Record = { power: findSubDevicePowerEntity(sub) }; + if (sub.type === SUB_DEVICE_TYPE_BESS) { + eidMap.soc = findBatteryLevelEntity(sub); + eidMap.soe = findBatterySoeEntity(sub); + } + for (const [role, eid] of Object.entries(eidMap)) { + if (eid) { + results.push({ entityId: eid, key: `${SUB_DEVICE_KEY_PREFIX}${devId}_${role}`, devId }); + } + } + } + return results; +} + +/** + * Load historical power data from HA recorder into the powerHistory Map. + * Supports per-circuit horizons by grouping circuits by their effective duration. + */ +export async function loadHistory( + hass: HomeAssistant, + topology: PanelTopology, + config: CardConfig, + powerHistory: HistoryMap, + horizonMap?: Map, + subDeviceHorizonMap?: Map +): Promise { + if (!topology || !hass) return; + + // Group circuits by effective duration + const groups = new Map(); + + for (const [uuid, circuit] of Object.entries(topology.circuits)) { + const eid = getCircuitChartEntity(circuit, config); + if (!eid) continue; + + let durationMs: number; + if (horizonMap && horizonMap.has(uuid)) { + durationMs = getHorizonDurationMs(horizonMap.get(uuid)!); + } else { + durationMs = getHistoryDurationMs(config); + } + + if (!groups.has(durationMs)) { + groups.set(durationMs, { entityIds: [], uuidByEntity: new Map() }); + } + const group = groups.get(durationMs)!; + group.entityIds.push(eid); + group.uuidByEntity.set(eid, uuid); + } + + // Add sub-device entities grouped by their effective horizon + for (const { entityId, key, devId } of collectSubDeviceEntityIds(topology)) { + let durationMs: number; + if (subDeviceHorizonMap && subDeviceHorizonMap.has(devId)) { + durationMs = getHorizonDurationMs(subDeviceHorizonMap.get(devId)!); + } else { + durationMs = getHistoryDurationMs(config); + } + if (!groups.has(durationMs)) { + groups.set(durationMs, { entityIds: [], uuidByEntity: new Map() }); + } + const group = groups.get(durationMs)!; + group.entityIds.push(entityId); + group.uuidByEntity.set(entityId, key); + } + + // Load each group in parallel + const promises: Promise[] = []; + for (const [durationMs, group] of groups) { + if (group.entityIds.length === 0) continue; + const useStatistics = durationMs > STATISTICS_PERIOD_THRESHOLD_HOURS * 60 * 60 * 1000; + if (useStatistics) { + promises.push(loadStatisticsHistory(hass, group.entityIds, group.uuidByEntity, durationMs, powerHistory)); + } else { + promises.push(loadRawHistory(hass, group.entityIds, group.uuidByEntity, durationMs, powerHistory)); + } + } + await Promise.all(promises); +} diff --git a/src/core/monitoring-status.ts b/src/core/monitoring-status.ts new file mode 100644 index 0000000..d78e99c --- /dev/null +++ b/src/core/monitoring-status.ts @@ -0,0 +1,127 @@ +import { INTEGRATION_DOMAIN } from "../constants.js"; +import { t } from "../i18n.js"; +import type { HomeAssistant, MonitoringPointInfo, MonitoringStatus } from "../types.js"; + +const MONITORING_POLL_INTERVAL_MS = 30_000; + +interface CallServiceResponse { + response?: MonitoringStatus; +} + +/** + * Caches monitoring status fetched via the get_monitoring_status service. + * Re-fetches at most every 30 seconds. + */ +export class MonitoringStatusCache { + private _status: MonitoringStatus | null = null; + private _lastFetch: number = 0; + private _fetching: boolean = false; + + /** + * Fetch monitoring status, returning cached data if recent. + */ + async fetch(hass: HomeAssistant, configEntryId?: string | null): Promise { + const now = Date.now(); + if (this._fetching) return this._status; + 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; + } + + /** Force the next fetch() call to re-query the backend. */ + invalidate(): void { + this._lastFetch = 0; + } + + /** Last fetched status. */ + get status(): MonitoringStatus | null { + return this._status; + } + + /** Clear cached status (e.g., on config change). */ + clear(): void { + this._status = null; + this._lastFetch = 0; + } +} + +/** + * Get monitoring info for a specific circuit entity. + */ +export function getCircuitMonitoringInfo(status: MonitoringStatus | null, entityId: string): MonitoringPointInfo | null { + if (!status?.circuits) return null; + return status.circuits[entityId] ?? null; +} + +/** + * Check if a monitored point has custom (non-global) overrides. + */ +export function hasCustomOverrides(monitoringInfo: MonitoringPointInfo | null): boolean { + if (!monitoringInfo) return false; + return monitoringInfo.continuous_threshold_pct !== undefined; +} + +/** + * Get CSS class for utilization level. + */ +export function getUtilizationClass(monitoringInfo: MonitoringPointInfo | null): string { + if (!monitoringInfo?.utilization_pct) return ""; + const pct = monitoringInfo.utilization_pct; + if (pct >= 100) return "utilization-alert"; + if (pct >= 80) return "utilization-warning"; + return "utilization-normal"; +} + +/** + * Check if a circuit currently has an active alert. + */ +export function isAlertActive(monitoringInfo: MonitoringPointInfo | null): boolean { + if (!monitoringInfo) return false; + return monitoringInfo.over_threshold_since != null; +} + +/** + * Build HTML for the monitoring summary bar. + */ +export function buildMonitoringSummaryHTML(status: MonitoringStatus | null): string { + if (!status) return ""; + + const circuits: MonitoringPointInfo[] = Object.values(status.circuits ?? {}); + const mains: MonitoringPointInfo[] = Object.values(status.mains ?? {}); + const all: MonitoringPointInfo[] = [...circuits, ...mains]; + + const warnings = all.filter(p => p.utilization_pct !== undefined && p.utilization_pct >= 80 && p.utilization_pct < 100).length; + const alerts = all.filter(p => p.utilization_pct !== undefined && p.utilization_pct >= 100).length; + const overrides = all.filter(p => p.has_override).length; + + return ` +
+ ✓ ${t("status.monitoring")} · ${circuits.length} ${t("status.circuits")} · ${mains.length} ${t("status.mains")} + + ${warnings > 0 ? `${warnings} ${warnings > 1 ? t("status.warnings") : t("status.warning")}` : ""} + ${alerts > 0 ? `${alerts} ${alerts > 1 ? t("status.alerts") : t("status.alert")}` : ""} + ${overrides > 0 ? `${overrides} ${overrides > 1 ? t("status.overrides") : t("status.override")}` : ""} + +
+ `; +} diff --git a/src/core/side-panel.ts b/src/core/side-panel.ts new file mode 100644 index 0000000..b90446d --- /dev/null +++ b/src/core/side-panel.ts @@ -0,0 +1,1104 @@ +// 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 { t } from "../i18n.js"; +import type { HomeAssistant, PanelTopology, GraphSettings, CircuitEntities, CircuitGraphOverride, MonitoringPointInfo } from "../types.js"; + +const PRIORITY_OPTIONS: string[] = Object.keys(SHEDDING_PRIORITIES).filter(k => k !== "unknown" && k !== "always_on"); + +// ── Interfaces for config shapes passed to open() ──────────────────────── + +interface GraphHorizonInfo extends CircuitGraphOverride { + globalHorizon: string; +} + +interface PanelModeConfig { + panelMode: true; + subDeviceMode?: undefined; + topology: PanelTopology; + graphSettings: GraphSettings | null; +} + +interface CircuitModeConfig { + panelMode?: undefined; + subDeviceMode?: undefined; + uuid: string; + name: string; + tabs: number[]; + breaker_rating_a?: number; + voltage?: number; + entities: CircuitEntities; + is_user_controllable?: boolean; + always_on?: boolean; + monitoringInfo: MonitoringPointInfo | null; + showMonitoring?: boolean; + graphHorizonInfo: GraphHorizonInfo; +} + +interface SubDeviceModeConfig { + panelMode?: undefined; + subDeviceMode: true; + subDeviceId: string; + name: string; + deviceType: string; + graphHorizonInfo: GraphHorizonInfo; +} + +type SidePanelConfig = PanelModeConfig | CircuitModeConfig | SubDeviceModeConfig; + +// ── Custom element interface for ha-switch ─────────────────────────────── + +interface HaSwitchElement extends HTMLElement { + checked: boolean; +} + +// ── Styles ──────────────────────────────────────────────────────────────── + +const STYLES = ` + :host { + display: block; + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 360px; + max-width: 90vw; + z-index: 1000; + transform: translateX(100%); + transition: transform 0.3s ease; + pointer-events: none; + } + :host([open]) { + transform: translateX(0); + pointer-events: auto; + } + + .backdrop { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.3); + z-index: -1; + } + :host([open]) .backdrop { + display: block; + } + + .panel { + height: 100%; + background: var(--card-background-color, #fff); + border-left: 1px solid var(--divider-color, #e0e0e0); + display: flex; + flex-direction: column; + overflow: hidden; + } + + .panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--divider-color, #e0e0e0); + } + .panel-header .title { + font-size: 18px; + font-weight: 500; + color: var(--primary-text-color, #212121); + margin: 0; + } + .panel-header .subtitle { + font-size: 13px; + color: var(--secondary-text-color, #727272); + margin: 2px 0 0 0; + } + .close-btn { + background: none; + border: none; + cursor: pointer; + color: var(--secondary-text-color, #727272); + padding: 4px; + line-height: 1; + font-size: 20px; + } + + .panel-body { + flex: 1; + overflow-y: auto; + padding: 16px; + } + + .section { + margin-bottom: 20px; + } + .section-label { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + color: var(--secondary-text-color, #727272); + margin: 0 0 8px 0; + letter-spacing: 0.5px; + } + + .field-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + } + .field-label { + font-size: 14px; + color: var(--primary-text-color, #212121); + } + + select { + padding: 6px 8px; + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 4px; + background: var(--card-background-color, #fff); + color: var(--primary-text-color, #212121); + font-size: 14px; + } + + input[type="number"] { + width: 72px; + padding: 6px 8px; + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 4px; + background: var(--card-background-color, #fff); + color: var(--primary-text-color, #212121); + font-size: 14px; + text-align: right; + } + input[type="number"]:disabled { + opacity: 0.5; + } + + .radio-group { + display: flex; + gap: 16px; + padding: 8px 0; + } + .radio-group label { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: var(--primary-text-color, #212121); + cursor: pointer; + } + + .horizon-bar { + display: flex; + border: 1px solid var(--divider-color, #e0e0e0); + border-radius: 6px; + overflow: hidden; + margin-top: 4px; + } + .horizon-segment { + flex: 1; + padding: 6px 0; + text-align: center; + font-size: 13px; + cursor: pointer; + background: var(--card-background-color, #fff); + color: var(--primary-text-color, #212121); + border: none; + border-right: 1px solid var(--divider-color, #e0e0e0); + transition: background 0.15s ease, color 0.15s ease; + user-select: none; + line-height: 1.4; + } + .horizon-segment:last-child { + border-right: none; + } + .horizon-segment:hover:not(.active) { + background: var(--secondary-background-color, #f5f5f5); + } + .horizon-segment.active { + background: var(--primary-color, #03a9f4); + color: #fff; + font-weight: 600; + } + .horizon-segment.referenced { + box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4); + } + + .monitoring-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .panel-mode-info { + font-size: 14px; + color: var(--primary-text-color, #212121); + line-height: 1.6; + } + .panel-mode-info p { + 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 { + private _hass: HomeAssistant | null; + private _config: SidePanelConfig | null; + private _debounceTimers: Record>; + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this._hass = null; + this._config = null; + this._debounceTimers = {}; + } + + set hass(val: HomeAssistant | null) { + this._hass = val; + if (this.hasAttribute("open") && this._config) { + this._updateLiveState(); + } + } + + get hass(): HomeAssistant | null { + return this._hass; + } + + open(config: SidePanelConfig): void { + this._config = config; + this._render(); + // Force reflow before adding attribute so the transition animates + void this.offsetHeight; + this.setAttribute("open", ""); + } + + close(): void { + this.removeAttribute("open"); + this._config = null; + this.dispatchEvent(new CustomEvent("side-panel-closed", { bubbles: true, composed: true })); + } + + // ── Rendering ───────────────────────────────────────────────────────── + + private _render(): void { + const cfg = this._config; + if (!cfg) return; + + const shadow = this.shadowRoot; + if (!shadow) return; + shadow.innerHTML = ""; + + const style = document.createElement("style"); + style.textContent = STYLES; + shadow.appendChild(style); + + const backdrop = document.createElement("div"); + backdrop.className = "backdrop"; + backdrop.addEventListener("click", () => this.close()); + shadow.appendChild(backdrop); + + const panel = document.createElement("div"); + panel.className = "panel"; + shadow.appendChild(panel); + + if (cfg.panelMode) { + this._renderPanelMode(panel); + } else if (cfg.subDeviceMode) { + this._renderSubDeviceMode(panel, cfg); + } else { + this._renderCircuitMode(panel, cfg); + } + } + + private _renderPanelMode(panel: HTMLDivElement): void { + const cfg = this._config as PanelModeConfig; + const header = this._createHeader(t("sidepanel.graph_settings"), t("sidepanel.global_defaults")); + panel.appendChild(header); + + 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 ?? {}; + + // ── Global default horizon ── + const globalSection = document.createElement("div"); + globalSection.className = "section"; + + const globalLabel = document.createElement("div"); + globalLabel.className = "section-label"; + globalLabel.textContent = t("sidepanel.graph_horizon"); + globalSection.appendChild(globalLabel); + + const globalRow = document.createElement("div"); + globalRow.className = "field-row"; + + const globalFieldLabel = document.createElement("span"); + globalFieldLabel.className = "field-label"; + globalFieldLabel.textContent = t("sidepanel.global_default"); + globalRow.appendChild(globalFieldLabel); + + const globalSelect = document.createElement("select"); + 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 === globalHorizon) opt.selected = true; + globalSelect.appendChild(opt); + } + globalSelect.addEventListener("change", () => { + this._callDomainService("set_graph_time_horizon", { horizon: globalSelect.value }) + .then(() => { + this.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); + }) + .catch((err: Error) => this._showError(`${err.message ?? err}`)); + }); + globalRow.appendChild(globalSelect); + globalSection.appendChild(globalRow); + body.appendChild(globalSection); + + // ── Per-circuit horizon scales ── + if (topology?.circuits) { + const circuitSection = document.createElement("div"); + circuitSection.className = "section"; + + const circuitLabel = document.createElement("div"); + circuitLabel.className = "section-label"; + circuitLabel.textContent = t("sidepanel.circuit_scales"); + circuitSection.appendChild(circuitLabel); + + 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); + } + + circuitSection.appendChild(row); + } + + body.appendChild(circuitSection); + } + + // ── Per-sub-device horizon scales ── + const subDeviceSettings = graphSettings?.sub_devices ?? {}; + if (topology?.sub_devices) { + const subDevSection = document.createElement("div"); + subDevSection.className = "section"; + + const subDevLabel = document.createElement("div"); + subDevLabel.className = "section-label"; + subDevLabel.textContent = t("sidepanel.subdevice_scales"); + subDevSection.appendChild(subDevLabel); + + const subDevices = Object.entries(topology.sub_devices).sort(([, a], [, b]) => (a.name || "").localeCompare(b.name || "")); + + for (const [devId, sub] of subDevices) { + const row = document.createElement("div"); + row.className = "field-row"; + + const nameLabel = document.createElement("span"); + nameLabel.className = "field-label"; + nameLabel.textContent = sub.name || devId; + nameLabel.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;"; + row.appendChild(nameLabel); + + const subDevData = subDeviceSettings[devId] || { horizon: globalHorizon, has_override: false }; + const effectiveHorizon = subDevData.has_override ? subDevData.horizon : globalHorizon; + + const select = document.createElement("select"); + select.dataset.subdevId = devId; + 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(`subdev-${devId}`, INPUT_DEBOUNCE_MS, () => { + this._callDomainService("set_subdevice_graph_horizon", { + subdevice_id: devId, + 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 (subDevData.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_subdevice_graph_horizon", { subdevice_id: devId }) + .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); + } + + subDevSection.appendChild(row); + } + + body.appendChild(subDevSection); + } + + panel.appendChild(body); + } + + 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); + panel.appendChild(header); + + const body = document.createElement("div"); + 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); + this._renderSheddingSection(body, cfg); + this._renderGraphHorizonSection(body, cfg); + if (cfg.showMonitoring) { + this._renderMonitoringSection(body, cfg); + } + } + + private _renderSubDeviceMode(panel: HTMLDivElement, cfg: SubDeviceModeConfig): void { + const header = this._createHeader(escapeHtml(cfg.name), escapeHtml(cfg.deviceType)); + panel.appendChild(header); + + const body = document.createElement("div"); + 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._renderSubDeviceHorizonSection(body, cfg); + } + + private _renderSubDeviceHorizonSection(body: HTMLDivElement, cfg: SubDeviceModeConfig): void { + const section = document.createElement("div"); + section.className = "section"; + + const sectionLabel = document.createElement("div"); + sectionLabel.className = "section-label"; + sectionLabel.textContent = t("sidepanel.graph_horizon"); + section.appendChild(sectionLabel); + + const graphInfo = cfg.graphHorizonInfo; + const hasOverride = graphInfo?.has_override === true; + const currentHorizon = graphInfo?.horizon || DEFAULT_GRAPH_HORIZON; + const globalHorizon = graphInfo?.globalHorizon || DEFAULT_GRAPH_HORIZON; + + const bar = document.createElement("div"); + bar.className = "horizon-bar"; + + const segments: { key: string; label: string }[] = [{ key: "global", label: t("sidepanel.global") }]; + for (const key of Object.keys(GRAPH_HORIZONS)) { + segments.push({ key, label: key }); + } + + const activeKey = hasOverride ? currentHorizon : "global"; + + const updateSegmentStates = (newActiveKey: string): void => { + for (const btn of bar.querySelectorAll(".horizon-segment")) { + const key = btn.dataset.horizon; + btn.classList.toggle("active", key === newActiveKey); + btn.classList.toggle("referenced", newActiveKey === "global" && key === globalHorizon); + } + }; + + for (const { key, label } of segments) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "horizon-segment"; + btn.dataset.horizon = key; + btn.textContent = label; + btn.classList.toggle("active", key === activeKey); + btn.classList.toggle("referenced", activeKey === "global" && key === globalHorizon); + + btn.addEventListener("click", () => { + if (btn.classList.contains("active")) return; + + const subDeviceId = cfg.subDeviceId; + if (key === "global") { + updateSegmentStates("global"); + this._callDomainService("clear_subdevice_graph_horizon", { subdevice_id: subDeviceId }) + .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}`)); + } else { + updateSegmentStates(key); + this._callDomainService("set_subdevice_graph_horizon", { + subdevice_id: subDeviceId, + 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}`)); + } + }); + + bar.appendChild(btn); + } + + section.appendChild(bar); + body.appendChild(section); + } + + private _createHeader(title: string, subtitle: string): HTMLDivElement { + const header = document.createElement("div"); + header.className = "panel-header"; + + const titleWrap = document.createElement("div"); + const safeTitle = escapeHtml(title); + const safeSubtitle = escapeHtml(subtitle); + titleWrap.innerHTML = `
${safeTitle}
` + (safeSubtitle ? `
${safeSubtitle}
` : ""); + + const closeBtn = document.createElement("button"); + closeBtn.className = "close-btn"; + closeBtn.innerHTML = "\u2715"; + closeBtn.addEventListener("click", () => this.close()); + + header.appendChild(titleWrap); + header.appendChild(closeBtn); + return header; + } + + // ── Relay section ─────────────────────────────────────────────────── + + private _renderRelaySection(body: HTMLDivElement, cfg: CircuitModeConfig): void { + if (cfg.is_user_controllable === false || !cfg.entities?.switch) return; + + 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.breaker"); + + const toggle = document.createElement("ha-switch") as HaSwitchElement; + toggle.dataset.role = "relay-toggle"; + const entityId = cfg.entities.switch; + const currentState = this._hass?.states?.[entityId]?.state; + if (currentState === "on") { + toggle.setAttribute("checked", ""); + } + + 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}`) + ); + }); + + row.appendChild(label); + row.appendChild(toggle); + section.appendChild(row); + body.appendChild(section); + } + + // ── Shedding section ──────────────────────────────────────────────── + + private _renderSheddingSection(body: HTMLDivElement, cfg: CircuitModeConfig): void { + if (!cfg.entities?.select) return; + + 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.priority_label"); + + const selectEl = document.createElement("select"); + selectEl.dataset.role = "shedding-select"; + const entityId = cfg.entities.select; + const currentPriority = this._hass?.states?.[entityId]?.state || ""; + + for (const key of PRIORITY_OPTIONS) { + const priority = SHEDDING_PRIORITIES[key]; + if (!priority) continue; + const opt = document.createElement("option"); + opt.value = key; + opt.textContent = t(`shedding.select.${key}`) || priority.label(); + if (key === currentPriority) opt.selected = true; + selectEl.appendChild(opt); + } + + 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}`)); + }); + + row.appendChild(label); + row.appendChild(selectEl); + section.appendChild(row); + body.appendChild(section); + } + + // ── Graph horizon section ────────────────────────────────────────── + + private _renderGraphHorizonSection(body: HTMLDivElement, cfg: CircuitModeConfig): void { + const section = document.createElement("div"); + section.className = "section"; + + const sectionLabel = document.createElement("div"); + sectionLabel.className = "section-label"; + sectionLabel.textContent = t("sidepanel.graph_horizon"); + section.appendChild(sectionLabel); + + const graphInfo = cfg.graphHorizonInfo; + const hasOverride = graphInfo?.has_override === true; + const currentHorizon = graphInfo?.horizon || DEFAULT_GRAPH_HORIZON; + const globalHorizon = graphInfo?.globalHorizon || DEFAULT_GRAPH_HORIZON; + + // Segmented button bar: Global | 5m | 1h | 1d | 1w | 1M + const bar = document.createElement("div"); + bar.className = "horizon-bar"; + + const segments: { key: string; label: string }[] = [{ key: "global", label: t("sidepanel.global") }]; + for (const key of Object.keys(GRAPH_HORIZONS)) { + segments.push({ key, label: key }); + } + + const activeKey = hasOverride ? currentHorizon : "global"; + + const updateSegmentStates = (newActiveKey: string): void => { + for (const btn of bar.querySelectorAll(".horizon-segment")) { + const key = btn.dataset.horizon; + btn.classList.toggle("active", key === newActiveKey); + btn.classList.toggle("referenced", newActiveKey === "global" && key === globalHorizon); + } + }; + + for (const { key, label } of segments) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "horizon-segment"; + btn.dataset.horizon = key; + btn.textContent = label; + btn.classList.toggle("active", key === activeKey); + btn.classList.toggle("referenced", activeKey === "global" && key === globalHorizon); + + btn.addEventListener("click", () => { + if (btn.classList.contains("active")) return; + + const circuitId = cfg.uuid; + if (key === "global") { + updateSegmentStates("global"); + this._callDomainService("clear_circuit_graph_horizon", { circuit_id: circuitId }) + .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}`)); + } else { + updateSegmentStates(key); + this._callDomainService("set_circuit_graph_horizon", { + circuit_id: circuitId, + 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}`)); + } + }); + + bar.appendChild(btn); + } + + section.appendChild(bar); + body.appendChild(section); + } + + // ── Monitoring section ────────────────────────────────────────────── + + private _renderMonitoringSection(body: HTMLDivElement, cfg: CircuitModeConfig): void { + const section = document.createElement("div"); + section.className = "section"; + + const headerRow = document.createElement("div"); + headerRow.className = "monitoring-header"; + + const sectionLabel = document.createElement("div"); + sectionLabel.className = "section-label"; + sectionLabel.textContent = t("sidepanel.monitoring"); + sectionLabel.style.margin = "0"; + + const enableToggle = document.createElement("ha-switch") as HaSwitchElement; + enableToggle.dataset.role = "monitoring-toggle"; + + const info = cfg.monitoringInfo; + const isEnabled = info != null && info.monitoring_enabled !== false; + if (isEnabled) { + enableToggle.setAttribute("checked", ""); + } + + headerRow.appendChild(sectionLabel); + headerRow.appendChild(enableToggle); + section.appendChild(headerRow); + + const detailsWrap = document.createElement("div"); + detailsWrap.dataset.role = "monitoring-details"; + detailsWrap.style.display = isEnabled ? "block" : "none"; + section.appendChild(detailsWrap); + + const hasCustom = info?.has_override === true; + + // Global / Custom radio + const radioGroup = document.createElement("div"); + radioGroup.className = "radio-group"; + radioGroup.innerHTML = ` + + + `; + detailsWrap.appendChild(radioGroup); + + // Threshold fields + const thresholdsWrap = document.createElement("div"); + thresholdsWrap.dataset.role = "threshold-fields"; + thresholdsWrap.style.display = hasCustom ? "block" : "none"; + + const continuousVal = info?.continuous_threshold_pct ?? 80; + const spikeVal = info?.spike_threshold_pct ?? 100; + const windowVal = info?.window_duration_m ?? 15; + const cooldownVal = info?.cooldown_duration_m ?? 15; + + thresholdsWrap.appendChild(this._createThresholdRow(t("sidepanel.continuous_pct"), "continuous", continuousVal, cfg)); + thresholdsWrap.appendChild(this._createThresholdRow(t("sidepanel.spike_pct"), "spike", spikeVal, cfg)); + thresholdsWrap.appendChild(this._createDurationRow(t("sidepanel.window_duration"), "window-m", windowVal, 1, 180, "m", cfg)); + thresholdsWrap.appendChild(this._createDurationRow(t("sidepanel.cooldown"), "cooldown-m", cooldownVal, 1, 180, "m", cfg)); + detailsWrap.appendChild(thresholdsWrap); + + // Event: monitoring enable toggle + enableToggle.addEventListener("change", () => { + const checked = enableToggle.checked; + detailsWrap.style.display = checked ? "block" : "none"; + const entityId = cfg.entities?.power || cfg.uuid; + this._callDomainService("set_circuit_threshold", { + circuit_id: entityId, + monitoring_enabled: checked, + }).catch((err: Error) => this._showError(`${t("sidepanel.monitoring_toggle_failed")} ${err.message ?? err}`)); + }); + + // Event: radio change + const radios = radioGroup.querySelectorAll('input[type="radio"]'); + for (const radio of radios) { + radio.addEventListener("change", () => { + const isCustom = radio.value === "custom" && radio.checked; + 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}`) + ); + } + }); + } + + body.appendChild(section); + } + + private _createThresholdRow(label: string, key: string, value: number, cfg: CircuitModeConfig): HTMLDivElement { + const row = document.createElement("div"); + row.className = "field-row"; + + const labelEl = document.createElement("span"); + labelEl.className = "field-label"; + labelEl.textContent = label; + + const input = document.createElement("input"); + input.type = "number"; + input.min = "0"; + input.max = "200"; + input.value = String(value); + input.dataset.role = `threshold-${key}`; + + input.addEventListener("input", () => { + this._debounce(`threshold-${key}`, INPUT_DEBOUNCE_MS, () => { + const shadow = this.shadowRoot; + if (!shadow) return; + 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"]'); + const cooldownM = shadow.querySelector('[data-role="threshold-cooldown-m"]'); + const entityId = cfg.entities?.power || cfg.uuid; + this._callDomainService("set_circuit_threshold", { + 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}`)); + }); + }); + + row.appendChild(labelEl); + row.appendChild(input); + return row; + } + + private _createDurationRow( + label: string, + key: string, + value: number, + min: number, + max: number, + unit: string, + cfg: CircuitModeConfig, + readOnly: boolean = false + ): HTMLDivElement { + const row = document.createElement("div"); + row.className = "field-row"; + + const labelEl = document.createElement("span"); + labelEl.className = "field-label"; + labelEl.textContent = label; + + const inputWrap = document.createElement("div"); + + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); + input.max = String(max); + input.value = String(value); + input.dataset.role = `threshold-${key}`; + if (readOnly) { + input.disabled = true; + } + + const unitSpan = document.createElement("span"); + unitSpan.textContent = unit; + + inputWrap.appendChild(input); + inputWrap.appendChild(unitSpan); + + if (!readOnly) { + input.addEventListener("input", () => { + this._debounce(`threshold-${key}`, INPUT_DEBOUNCE_MS, () => { + const shadow = this.shadowRoot; + if (!shadow) return; + 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", { + 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}`)); + }); + }); + } + + row.appendChild(labelEl); + row.appendChild(inputWrap); + return row; + } + + // ── Live state updates ────────────────────────────────────────────── + + private _updateLiveState(): void { + if (!this._config || this._config.panelMode) return; + const cfg = this._config; + + // Sub-device mode has no live-updating fields + if (cfg.subDeviceMode) return; + + // Update relay toggle + if (cfg.entities?.switch) { + const toggle = this.shadowRoot?.querySelector('[data-role="relay-toggle"]'); + if (toggle) { + const currentState = this._hass?.states?.[cfg.entities.switch]?.state; + if (currentState === "on") { + toggle.setAttribute("checked", ""); + } else { + toggle.removeAttribute("checked"); + } + } + } + + // Update shedding select + if (cfg.entities?.select) { + const selectEl = this.shadowRoot?.querySelector('[data-role="shedding-select"]'); + if (selectEl) { + const currentPriority = this._hass?.states?.[cfg.entities.select]?.state || ""; + selectEl.value = currentPriority; + } + } + } + + // ── Service calls ─────────────────────────────────────────────────── + + private _callService(domain: string, service: string, data: Record): Promise { + if (!this._hass) return Promise.resolve(); + return Promise.resolve(this._hass.callService(domain, service, data)); + } + + private _callDomainService(service: string, data: Record): Promise { + if (!this._hass) return Promise.resolve(); + return this._hass.callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service, + service_data: data, + }); + } + + // ── 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 { + if (this._debounceTimers[key]) { + clearTimeout(this._debounceTimers[key]); + } + this._debounceTimers[key] = setTimeout(() => { + delete this._debounceTimers[key]; + fn(); + }, ms); + } +} + +try { + if (!customElements.get("span-side-panel")) { + customElements.define("span-side-panel", SpanSidePanel); + } +} catch { + // Scoped custom element registry may throw on duplicate registration after upgrade +} diff --git a/src/core/sub-device-renderer.ts b/src/core/sub-device-renderer.ts new file mode 100644 index 0000000..0d7bf0b --- /dev/null +++ b/src/core/sub-device-renderer.ts @@ -0,0 +1,157 @@ +import { escapeHtml } from "../helpers/sanitize.js"; +import { formatPowerSigned, formatPowerUnit } from "../helpers/format.js"; +import { t } from "../i18n.js"; +import { findSubDevicePowerEntity, findBatteryLevelEntity, findBatterySoeEntity, findBatteryCapacityEntity } from "../helpers/entity-finder.js"; +import { SUB_DEVICE_TYPE_BESS, SUB_DEVICE_TYPE_EVSE, SUB_DEVICE_KEY_PREFIX } from "../constants.js"; +import type { PanelTopology, HomeAssistant, CardConfig, SubDevice } from "../types.js"; + +interface BessChartDef { + key: string; + title: string; + available: boolean; +} + +/** + * Build the HTML for all sub-devices (BESS, EVSE, etc.) in the topology. + */ +export function buildSubDevicesHTML(topology: PanelTopology, hass: HomeAssistant, config: CardConfig): string { + const showBattery: boolean = config.show_battery !== false; + const showEvse: boolean = config.show_evse !== false; + + if (!topology.sub_devices) return ""; + + const entries: [string, SubDevice][] = Object.entries(topology.sub_devices).filter(([, sub]) => { + if (sub.type === SUB_DEVICE_TYPE_BESS && !showBattery) return false; + if (sub.type === SUB_DEVICE_TYPE_EVSE && !showEvse) return false; + return true; + }); + + if (entries.length === 0) return ""; + + const evseCount: number = entries.filter(([, sub]) => sub.type === SUB_DEVICE_TYPE_EVSE).length; + let evseIndex = 0; + + let subDevHTML = ""; + for (const [devId, sub] of entries) { + const label: string = + sub.type === SUB_DEVICE_TYPE_EVSE ? t("subdevice.ev_charger") : sub.type === SUB_DEVICE_TYPE_BESS ? t("subdevice.battery") : t("subdevice.fallback"); + const powerEid: string | null = findSubDevicePowerEntity(sub); + const powerState = powerEid ? hass.states[powerEid] : undefined; + const powerW: number = powerState ? parseFloat(powerState.state) || 0 : 0; + + const isBess: boolean = sub.type === SUB_DEVICE_TYPE_BESS; + const isEvse: boolean = sub.type === SUB_DEVICE_TYPE_EVSE; + const battLevelEid: string | null = isBess ? findBatteryLevelEntity(sub) : null; + const battSoeEid: string | null = isBess ? findBatterySoeEntity(sub) : null; + const battCapEid: string | null = isBess ? findBatteryCapacityEntity(sub) : null; + + const hideEids: Set = new Set([powerEid, battLevelEid, battSoeEid, battCapEid].filter((eid): eid is string => eid !== null)); + const entHTML: string = buildSubEntityHTML(sub, hass, config, hideEids); + const chartsHTML: string = buildSubDeviceChartsHTML(devId, sub, isBess, powerEid, battLevelEid, battSoeEid); + + // EVSE: span full row if it's the odd one out (last on its row alone) + let spanClass = ""; + if (isBess) { + spanClass = "sub-device-bess"; + } else if (isEvse) { + evseIndex++; + if (evseIndex === evseCount && evseCount % 2 === 1) { + spanClass = "sub-device-full"; + } + } + + subDevHTML += ` +
+
+ ${escapeHtml(label)} + ${escapeHtml(sub.name || "")} + ${powerEid ? `${formatPowerSigned(powerW)} ${formatPowerUnit(powerW)}` : ""} + +
+ ${chartsHTML} + ${entHTML} +
+ `; + } + return subDevHTML; +} + +/** + * Build the HTML for the visible entities of a single sub-device. + */ +export function buildSubEntityHTML(sub: SubDevice, hass: HomeAssistant, config: CardConfig, hideEids: Set): string { + const visibleEnts: Record = config.visible_sub_entities || {}; + let entHTML = ""; + if (!sub.entities) return entHTML; + + for (const [entityId, info] of Object.entries(sub.entities)) { + if (hideEids.has(entityId)) continue; + if (visibleEnts[entityId] !== true) continue; + const state = hass.states[entityId]; + if (!state) continue; + let name: string = info.original_name || (state.attributes.friendly_name as string) || entityId; + const devName: string = sub.name || ""; + if (name.startsWith(devName + " ")) name = name.slice(devName.length + 1); + let displayValue: string; + if (hass.formatEntityState) { + displayValue = hass.formatEntityState(state); + } else { + displayValue = state.state; + const unit = (state.attributes.unit_of_measurement as string) || ""; + if (unit) displayValue += " " + unit; + } + const rawUnit = (state.attributes.unit_of_measurement as string) || ""; + if (rawUnit === "Wh") { + const wh: number = parseFloat(state.state); + if (!isNaN(wh)) displayValue = (wh / 1000).toFixed(1) + " kWh"; + } + entHTML += ` +
+ ${escapeHtml(name)}: + ${escapeHtml(displayValue)} +
+ `; + } + return entHTML; +} + +/** + * Build the chart container HTML for a sub-device. + */ +export function buildSubDeviceChartsHTML( + devId: string, + _sub: SubDevice, + isBess: boolean, + powerEid: string | null, + battLevelEid: string | null, + battSoeEid: string | null +): string { + if (isBess) { + const bessCharts: BessChartDef[] = [ + { key: `${SUB_DEVICE_KEY_PREFIX}${devId}_soc`, title: t("subdevice.soc"), available: !!battLevelEid }, + { key: `${SUB_DEVICE_KEY_PREFIX}${devId}_soe`, title: t("subdevice.soe"), available: !!battSoeEid }, + { key: `${SUB_DEVICE_KEY_PREFIX}${devId}_power`, title: t("subdevice.power"), available: !!powerEid }, + ].filter((c): c is BessChartDef => c.available); + + return ` +
+ ${bessCharts + .map( + (c: BessChartDef) => ` +
+
${escapeHtml(c.title)}
+
+
+ ` + ) + .join("")} +
+ `; + } + if (powerEid) { + return `
`; + } + return ""; +} diff --git a/src/editor/span-panel-card-editor.js b/src/editor/span-panel-card-editor.ts similarity index 67% rename from src/editor/span-panel-card-editor.js rename to src/editor/span-panel-card-editor.ts index cb76344..95681f6 100644 --- a/src/editor/span-panel-card-editor.js +++ b/src/editor/span-panel-card-editor.ts @@ -1,21 +1,41 @@ import { CHART_METRICS, DEFAULT_CHART_METRIC, INTEGRATION_DOMAIN } from "../constants.js"; +import { t } from "../i18n.js"; +import type { HomeAssistant, CardConfig, PanelTopology, SubDevice, SubDeviceEntityInfo, ChartMetricDef } from "../types.js"; -export class SpanPanelCardEditor extends HTMLElement { - constructor() { - super(); - this._config = {}; - this._hass = null; - this._panels = null; - this._availableRoles = null; - this._built = false; - } +interface PanelOption { + device_id: string; + label: string; +} - setConfig(config) { +interface DeviceRegistryEntry { + id: string; + name?: string; + name_by_user?: string; + identifiers?: [string, string][]; + via_device_id?: string | null; +} + +export class SpanPanelCardEditor extends HTMLElement { + private _config: CardConfig = {}; + private _hass: HomeAssistant | null = null; + private _panels: PanelOption[] | null = null; + private _availableRoles: Set | null = null; + private _built = false; + + private _panelSelect: HTMLSelectElement | null = null; + private _daysInput: HTMLInputElement | null = null; + private _hoursInput: HTMLInputElement | null = null; + private _minsInput: HTMLInputElement | null = null; + private _metricSelect: HTMLSelectElement | null = null; + private _checkboxes: Record = {}; + private _entityContainers: Record = {}; + + setConfig(config: CardConfig): void { this._config = { ...config }; this._updateControls(); } - set hass(hass) { + set hass(hass: HomeAssistant) { this._hass = hass; if (!this._panels) { this._discoverPanels(); @@ -24,20 +44,20 @@ export class SpanPanelCardEditor extends HTMLElement { } } - async _discoverPanels() { + private async _discoverPanels(): Promise { if (!this._hass) return; - const devices = await this._hass.callWS({ type: "config/device_registry/list" }); + const devices = await this._hass.callWS({ type: "config/device_registry/list" }); this._panels = devices - .filter(d => (d.identifiers || []).some(pair => pair[0] === INTEGRATION_DOMAIN) && !d.via_device_id) + .filter(d => (d.identifiers ?? []).some(pair => pair[0] === INTEGRATION_DOMAIN) && !d.via_device_id) .map(d => { - const serial = (d.identifiers || []).find(p => p[0] === INTEGRATION_DOMAIN)?.[1] || ""; - const name = d.name_by_user || d.name || "SPAN Panel"; + const serial = (d.identifiers ?? []).find(p => p[0] === INTEGRATION_DOMAIN)?.[1] ?? ""; + const name = d.name_by_user ?? d.name ?? t("editor.panel_label"); return { device_id: d.id, label: `${name} (${serial})` }; }); this._buildEditor(); } - _buildEditor() { + private _buildEditor(): void { this.innerHTML = ""; this._built = true; @@ -72,18 +92,18 @@ export class SpanPanelCardEditor extends HTMLElement { } } - _buildPanelSelector(wrapper, fieldStyle, labelStyle, groupStyle) { + private _buildPanelSelector(wrapper: HTMLElement, fieldStyle: string, labelStyle: string, groupStyle: string): void { const group = document.createElement("div"); group.style.cssText = groupStyle; const label = document.createElement("label"); - label.textContent = "SPAN Panel"; + label.textContent = t("editor.panel_label"); label.style.cssText = labelStyle; const select = document.createElement("select"); select.style.cssText = fieldStyle; const emptyOpt = document.createElement("option"); emptyOpt.value = ""; - emptyOpt.textContent = "Select a panel..."; + emptyOpt.textContent = t("editor.select_panel"); select.appendChild(emptyOpt); if (this._panels) { @@ -108,11 +128,11 @@ export class SpanPanelCardEditor extends HTMLElement { this._panelSelect = select; } - _buildTimeWindow(wrapper, fieldStyle, labelStyle, groupStyle) { + private _buildTimeWindow(wrapper: HTMLElement, fieldStyle: string, labelStyle: string, groupStyle: string): void { const group = document.createElement("div"); group.style.cssText = groupStyle; const label = document.createElement("label"); - label.textContent = "Chart time window"; + label.textContent = t("editor.chart_window"); label.style.cssText = labelStyle; const row = document.createElement("div"); @@ -121,7 +141,7 @@ export class SpanPanelCardEditor extends HTMLElement { const inputStyle = fieldStyle + "width: 70px; cursor: text;"; const spanStyle = "font-size: 0.9em; color: var(--secondary-text-color);"; - const createTimeField = (value, min, max, unitLabel) => { + const createTimeField = (value: number, min: string, max: string, unitLabel: string): { wrap: HTMLElement; input: HTMLInputElement } => { const wrap = document.createElement("div"); wrap.style.cssText = "display: flex; align-items: center; gap: 6px;"; const input = document.createElement("input"); @@ -138,14 +158,14 @@ export class SpanPanelCardEditor extends HTMLElement { return { wrap, input }; }; - const daysValue = parseInt(this._config.history_days) || 0; - const hoursValue = parseInt(this._config.history_hours) || 0; - const minsValue = parseInt(this._config.history_minutes) || 0; - const days = createTimeField(daysValue, "0", "30", "days"); - const hours = createTimeField(hoursValue, "0", "23", "hours"); - const mins = createTimeField(minsValue, "0", "59", "minutes"); + const daysValue = parseInt(String(this._config.history_days)) || 0; + const hoursValue = parseInt(String(this._config.history_hours)) || 0; + const minsValue = parseInt(String(this._config.history_minutes)) || 0; + const days = createTimeField(daysValue, "0", "30", t("editor.days")); + const hours = createTimeField(hoursValue, "0", "23", t("editor.hours")); + const mins = createTimeField(minsValue, "0", "59", t("editor.minutes")); - const fireTimeChange = () => { + const fireTimeChange = (): void => { this._config = { ...this._config, history_days: parseInt(days.input.value) || 0, @@ -170,11 +190,11 @@ export class SpanPanelCardEditor extends HTMLElement { this._minsInput = mins.input; } - _buildMetricSelector(wrapper, fieldStyle, labelStyle, groupStyle) { + private _buildMetricSelector(wrapper: HTMLElement, fieldStyle: string, labelStyle: string, groupStyle: string): void { const group = document.createElement("div"); group.style.cssText = groupStyle; const label = document.createElement("label"); - label.textContent = "Chart metric"; + label.textContent = t("editor.chart_metric"); label.style.cssText = labelStyle; const select = document.createElement("select"); select.style.cssText = fieldStyle; @@ -190,21 +210,21 @@ export class SpanPanelCardEditor extends HTMLElement { this._metricSelect = select; } - _buildSectionCheckboxes(wrapper, labelStyle, groupStyle) { + private _buildSectionCheckboxes(wrapper: HTMLElement, labelStyle: string, groupStyle: string): void { const group = document.createElement("div"); group.style.cssText = groupStyle; const label = document.createElement("label"); - label.textContent = "Visible sections"; + label.textContent = t("editor.visible_sections"); label.style.cssText = labelStyle; group.appendChild(label); const checkboxStyle = "display: flex; align-items: center; gap: 8px; margin-bottom: 6px; cursor: pointer;"; const cbLabelStyle = "font-size: 0.9em; color: var(--primary-text-color); cursor: pointer;"; - const sections = [ - { key: "show_panel", label: "Panel circuits", subDeviceType: null }, - { key: "show_battery", label: "Battery (BESS)", subDeviceType: "bess" }, - { key: "show_evse", label: "EV Charger (EVSE)", subDeviceType: "evse" }, + const sections: { key: keyof CardConfig; label: string; subDeviceType: string | null }[] = [ + { key: "show_panel", label: t("editor.panel_circuits"), subDeviceType: null }, + { key: "show_battery", label: t("editor.battery_bess"), subDeviceType: "bess" }, + { key: "show_evse", label: t("editor.ev_charger_evse"), subDeviceType: "evse" }, ]; this._checkboxes = {}; @@ -225,7 +245,7 @@ export class SpanPanelCardEditor extends HTMLElement { group.appendChild(row); this._checkboxes[sec.key] = cb; - let entityContainer = null; + let entityContainer: HTMLElement | null = null; if (sec.subDeviceType) { entityContainer = document.createElement("div"); entityContainer.style.cssText = "padding-left: 26px;"; @@ -244,9 +264,9 @@ export class SpanPanelCardEditor extends HTMLElement { wrapper.appendChild(group); } - _isChartEntity(entityId, info, subDeviceType) { - const name = (info.original_name || "").toLowerCase(); - const uid = info.unique_id || ""; + private _isChartEntity(_entityId: string, info: SubDeviceEntityInfo, subDeviceType: string): boolean { + const name = (info.original_name ?? "").toLowerCase(); + const uid = info.unique_id ?? ""; if (name === "power" || name === "battery power" || uid.endsWith("_power")) return true; if (subDeviceType === "bess") { if (name === "battery level" || name === "battery percentage" || uid.endsWith("_battery_level") || uid.endsWith("_battery_percentage")) return true; @@ -256,19 +276,19 @@ export class SpanPanelCardEditor extends HTMLElement { return false; } - _populateEntityCheckboxes(subDevices) { - const visibleEnts = this._config.visible_sub_entities || {}; + private _populateEntityCheckboxes(subDevices: Record): void { + const visibleEnts = this._config.visible_sub_entities ?? {}; const checkboxStyle = "display: flex; align-items: center; gap: 8px; margin-bottom: 5px; cursor: pointer;"; const cbLabelStyle = "font-size: 0.85em; color: var(--primary-text-color); cursor: pointer;"; for (const [, sub] of Object.entries(subDevices)) { - const container = this._entityContainers[sub.type]; + const container = sub.type ? this._entityContainers[sub.type] : undefined; if (!container) continue; container.innerHTML = ""; if (!sub.entities) continue; for (const [entityId, info] of Object.entries(sub.entities)) { - if (info.domain === "sensor" && this._isChartEntity(entityId, info, sub.type)) continue; + if (info.domain === "sensor" && this._isChartEntity(entityId, info, sub.type ?? "")) continue; const row = document.createElement("div"); row.style.cssText = checkboxStyle; const cb = document.createElement("input"); @@ -276,8 +296,8 @@ export class SpanPanelCardEditor extends HTMLElement { cb.checked = visibleEnts[entityId] === true; cb.style.cssText = "width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary-color);"; const lbl = document.createElement("span"); - let name = info.original_name || entityId; - const devName = sub.name || ""; + let name = info.original_name ?? entityId; + const devName = sub.name ?? ""; if (name.startsWith(devName + " ")) name = name.slice(devName.length + 1); lbl.textContent = name; lbl.style.cssText = cbLabelStyle; @@ -286,7 +306,7 @@ export class SpanPanelCardEditor extends HTMLElement { container.appendChild(row); cb.addEventListener("change", () => { - const updated = { ...(this._config.visible_sub_entities || {}) }; + const updated: Record = { ...(this._config.visible_sub_entities ?? {}) }; if (cb.checked) { updated[entityId] = true; } else { @@ -299,16 +319,16 @@ export class SpanPanelCardEditor extends HTMLElement { } } - async _discoverAvailableRoles(deviceId) { + private async _discoverAvailableRoles(deviceId: string): Promise { if (!this._hass || !deviceId) return; try { - const topo = await this._hass.callWS({ + const topo = await this._hass.callWS({ type: `${INTEGRATION_DOMAIN}/panel_topology`, device_id: deviceId, }); - const roles = new Set(); - for (const circuit of Object.values(topo.circuits || {})) { - for (const role of Object.keys(circuit.entities || {})) { + const roles = new Set(); + for (const circuit of Object.values(topo.circuits ?? {})) { + for (const role of Object.keys(circuit.entities ?? {})) { roles.add(role); } } @@ -323,35 +343,35 @@ export class SpanPanelCardEditor extends HTMLElement { } } - _populateMetricSelect() { + private _populateMetricSelect(): void { const select = this._metricSelect; if (!select) return; - const current = this._config.chart_metric || DEFAULT_CHART_METRIC; + const current = this._config.chart_metric ?? DEFAULT_CHART_METRIC; select.innerHTML = ""; - for (const [key, def] of Object.entries(CHART_METRICS)) { + for (const [key, def] of Object.entries(CHART_METRICS) as [string, ChartMetricDef][]) { if (this._availableRoles && !this._availableRoles.has(def.entityRole)) continue; const opt = document.createElement("option"); opt.value = key; - opt.textContent = def.label; + opt.textContent = def.label(); if (key === current) opt.selected = true; select.appendChild(opt); } } - _updateControls() { - if (this._panelSelect) this._panelSelect.value = this._config.device_id || ""; - if (this._daysInput) this._daysInput.value = String(parseInt(this._config.history_days) || 0); - if (this._hoursInput) this._hoursInput.value = String(parseInt(this._config.history_hours) || 0); - if (this._minsInput) this._minsInput.value = String(parseInt(this._config.history_minutes) || 0); - if (this._metricSelect) this._metricSelect.value = this._config.chart_metric || DEFAULT_CHART_METRIC; + private _updateControls(): void { + if (this._panelSelect) this._panelSelect.value = this._config.device_id ?? ""; + if (this._daysInput) this._daysInput.value = String(parseInt(String(this._config.history_days)) || 0); + if (this._hoursInput) this._hoursInput.value = String(parseInt(String(this._config.history_hours)) || 0); + if (this._minsInput) this._minsInput.value = String(parseInt(String(this._config.history_minutes)) || 0); + if (this._metricSelect) this._metricSelect.value = this._config.chart_metric ?? DEFAULT_CHART_METRIC; if (this._checkboxes) { for (const [key, cb] of Object.entries(this._checkboxes)) { - cb.checked = this._config[key] !== false; + cb.checked = (this._config as Record)[key] !== false; } } } - _fireConfigChanged() { + private _fireConfigChanged(): void { this.dispatchEvent(new CustomEvent("config-changed", { detail: { config: this._config } })); } } diff --git a/src/helpers/chart.js b/src/helpers/chart.js deleted file mode 100644 index a27fe05..0000000 --- a/src/helpers/chart.js +++ /dev/null @@ -1,14 +0,0 @@ -import { CHART_METRICS, DEFAULT_CHART_METRIC } from "../constants.js"; - -export function getChartMetric(config) { - return CHART_METRICS[config.chart_metric] || CHART_METRICS[DEFAULT_CHART_METRIC]; -} - -export function getChartEntityRole(config) { - return getChartMetric(config).entityRole; -} - -export function getCircuitChartEntity(circuit, config) { - const role = getChartEntityRole(config); - return circuit.entities?.[role] || circuit.entities?.power || null; -} diff --git a/src/helpers/chart.ts b/src/helpers/chart.ts new file mode 100644 index 0000000..4ce8e42 --- /dev/null +++ b/src/helpers/chart.ts @@ -0,0 +1,16 @@ +import { CHART_METRICS, DEFAULT_CHART_METRIC } from "../constants.js"; +import type { CardConfig, ChartMetricDef, Circuit } from "../types.js"; + +export function getChartMetric(config: CardConfig): ChartMetricDef { + const key = config.chart_metric ?? DEFAULT_CHART_METRIC; + return CHART_METRICS[key] ?? CHART_METRICS[DEFAULT_CHART_METRIC]!; +} + +export function getChartEntityRole(config: CardConfig): string { + return getChartMetric(config).entityRole; +} + +export function getCircuitChartEntity(circuit: Circuit, config: CardConfig): string | null { + const role = getChartEntityRole(config); + return circuit.entities?.[role] ?? circuit.entities?.power ?? null; +} diff --git a/src/helpers/entity-finder.js b/src/helpers/entity-finder.js deleted file mode 100644 index 13e6f57..0000000 --- a/src/helpers/entity-finder.js +++ /dev/null @@ -1,38 +0,0 @@ -// ── Entity descriptor table ────────────────────────────────────────────────── -// -// Each entry describes how to locate a specific sensor entity within a -// sub-device's entity map by matching on the friendly name or unique_id suffix. - -const ENTITY_DESCRIPTORS = { - power: { names: ["power", "battery power"], suffixes: ["_power"] }, - soc: { names: ["battery level", "battery percentage"], suffixes: ["_battery_level", "_battery_percentage"] }, - soe: { names: ["state of energy"], suffixes: ["_soe_kwh"] }, - capacity: { names: ["nameplate capacity"], suffixes: ["_nameplate_capacity"] }, -}; - -function findSubDeviceEntity(subDevice, descriptor) { - if (!subDevice.entities) return null; - for (const [entityId, info] of Object.entries(subDevice.entities)) { - if (info.domain !== "sensor") continue; - const name = (info.original_name || "").toLowerCase(); - if (descriptor.names.some(n => name === n)) return entityId; - if (info.unique_id && descriptor.suffixes.some(s => info.unique_id.endsWith(s))) return entityId; - } - return null; -} - -export function findSubDevicePowerEntity(subDevice) { - return findSubDeviceEntity(subDevice, ENTITY_DESCRIPTORS.power); -} - -export function findBatteryLevelEntity(subDevice) { - return findSubDeviceEntity(subDevice, ENTITY_DESCRIPTORS.soc); -} - -export function findBatterySoeEntity(subDevice) { - return findSubDeviceEntity(subDevice, ENTITY_DESCRIPTORS.soe); -} - -export function findBatteryCapacityEntity(subDevice) { - return findSubDeviceEntity(subDevice, ENTITY_DESCRIPTORS.capacity); -} diff --git a/src/helpers/entity-finder.ts b/src/helpers/entity-finder.ts new file mode 100644 index 0000000..53b82d2 --- /dev/null +++ b/src/helpers/entity-finder.ts @@ -0,0 +1,35 @@ +import type { SubDevice, EntityDescriptor } from "../types.js"; + +const ENTITY_DESCRIPTORS: Record = { + power: { names: ["power", "battery power"], suffixes: ["_power"] }, + soc: { names: ["battery level", "battery percentage"], suffixes: ["_battery_level", "_battery_percentage"] }, + soe: { names: ["state of energy"], suffixes: ["_soe_kwh"] }, + capacity: { names: ["nameplate capacity"], suffixes: ["_nameplate_capacity"] }, +}; + +function findSubDeviceEntity(subDevice: SubDevice, descriptor: EntityDescriptor): string | null { + if (!subDevice.entities) return null; + for (const [entityId, info] of Object.entries(subDevice.entities)) { + if (info.domain !== "sensor") continue; + const name = (info.original_name ?? "").toLowerCase(); + if (descriptor.names.some(n => name === n)) return entityId; + if (info.unique_id && descriptor.suffixes.some(s => info.unique_id!.endsWith(s))) return entityId; + } + return null; +} + +export function findSubDevicePowerEntity(subDevice: SubDevice): string | null { + return findSubDeviceEntity(subDevice, ENTITY_DESCRIPTORS.power!); +} + +export function findBatteryLevelEntity(subDevice: SubDevice): string | null { + return findSubDeviceEntity(subDevice, ENTITY_DESCRIPTORS.soc!); +} + +export function findBatterySoeEntity(subDevice: SubDevice): string | null { + return findSubDeviceEntity(subDevice, ENTITY_DESCRIPTORS.soe!); +} + +export function findBatteryCapacityEntity(subDevice: SubDevice): string | null { + return findSubDeviceEntity(subDevice, ENTITY_DESCRIPTORS.capacity!); +} diff --git a/src/helpers/format.js b/src/helpers/format.js deleted file mode 100644 index 10ebdad..0000000 --- a/src/helpers/format.js +++ /dev/null @@ -1,20 +0,0 @@ -import { CHART_METRICS } from "../constants.js"; - -const powerMetric = CHART_METRICS.power; - -export function formatPower(watts) { - return powerMetric.format(watts); -} - -export function formatPowerUnit(watts) { - return powerMetric.unit(watts); -} - -export function formatPowerSigned(watts) { - const sign = watts < 0 ? "-" : ""; - return sign + powerMetric.format(watts); -} - -export function formatKw(watts) { - return (Math.abs(watts) / 1000).toFixed(1); -} diff --git a/src/helpers/format.ts b/src/helpers/format.ts new file mode 100644 index 0000000..a6b0ab0 --- /dev/null +++ b/src/helpers/format.ts @@ -0,0 +1,16 @@ +import { CHART_METRICS } from "../constants.js"; + +const powerMetric = CHART_METRICS.power!; + +export function formatPowerUnit(watts: number): string { + return powerMetric.unit(watts); +} + +export function formatPowerSigned(watts: number): string { + const sign = watts < 0 ? "-" : ""; + return sign + powerMetric.format(watts); +} + +export function formatKw(watts: number): string { + return (Math.abs(watts) / 1000).toFixed(1); +} diff --git a/src/helpers/history.js b/src/helpers/history.js deleted file mode 100644 index e763995..0000000 --- a/src/helpers/history.js +++ /dev/null @@ -1,43 +0,0 @@ -import { DEFAULT_HISTORY_DAYS, DEFAULT_HISTORY_HOURS, DEFAULT_HISTORY_MINUTES } from "../constants.js"; - -export function getHistoryDurationMs(config) { - const hasAny = config.history_days !== undefined || config.history_hours !== undefined || config.history_minutes !== undefined; - const d = hasAny ? parseInt(config.history_days) || 0 : DEFAULT_HISTORY_DAYS; - const h = hasAny ? parseInt(config.history_hours) || 0 : DEFAULT_HISTORY_HOURS; - const m = hasAny ? parseInt(config.history_minutes) || 0 : DEFAULT_HISTORY_MINUTES; - const total = ((d * 24 + h) * 60 + m) * 60 * 1000; - return Math.max(total, 60000); -} - -export function getMaxHistoryPoints(durationMs) { - const seconds = durationMs / 1000; - if (seconds <= 600) return Math.ceil(seconds); - return Math.min(5000, Math.ceil(seconds / 5)); -} - -export function getMinGapMs(durationMs) { - return Math.max(500, Math.floor(durationMs / 5000)); -} - -// Record a single sample into a history map, pruning old entries. -export function recordSample(historyMap, key, value, now, cutoff, maxPoints) { - if (!historyMap.has(key)) historyMap.set(key, []); - const hist = historyMap.get(key); - hist.push({ time: now, value }); - while (hist.length > 0 && hist[0].time < cutoff) hist.shift(); - if (hist.length > maxPoints) hist.splice(0, hist.length - maxPoints); -} - -// Merge, deduplicate (by minGapMs), and trim a list of history points. -export function deduplicateAndTrim(points, maxPoints, minGapMs = 500) { - if (points.length === 0) return points; - points.sort((a, b) => a.time - b.time); - const deduped = [points[0]]; - for (let i = 1; i < points.length; i++) { - if (points[i].time - deduped[deduped.length - 1].time >= minGapMs) { - deduped.push(points[i]); - } - } - if (deduped.length > maxPoints) deduped.splice(0, deduped.length - maxPoints); - return deduped; -} diff --git a/src/helpers/history.ts b/src/helpers/history.ts new file mode 100644 index 0000000..04a7323 --- /dev/null +++ b/src/helpers/history.ts @@ -0,0 +1,65 @@ +import { + DEFAULT_HISTORY_DAYS, + DEFAULT_HISTORY_HOURS, + DEFAULT_HISTORY_MINUTES, + GRAPH_HORIZONS, + DEFAULT_GRAPH_HORIZON, + MIN_HISTORY_DURATION_MS, +} from "../constants.js"; +import type { CardConfig, HistoryPoint, HistoryMap } from "../types.js"; + +export function getHistoryDurationMs(config: CardConfig): number { + const hasAny = config.history_days !== undefined || config.history_hours !== undefined || config.history_minutes !== undefined; + const d = hasAny ? parseInt(String(config.history_days)) || 0 : DEFAULT_HISTORY_DAYS; + const h = hasAny ? parseInt(String(config.history_hours)) || 0 : DEFAULT_HISTORY_HOURS; + const m = hasAny ? parseInt(String(config.history_minutes)) || 0 : DEFAULT_HISTORY_MINUTES; + const total = ((d * 24 + h) * 60 + m) * 60 * 1000; + return Math.max(total, MIN_HISTORY_DURATION_MS); +} + +export function getHorizonDurationMs(horizonKey: string): number { + const h = GRAPH_HORIZONS[horizonKey]; + return h ? h.ms : GRAPH_HORIZONS[DEFAULT_GRAPH_HORIZON]!.ms; +} + +export function getMaxHistoryPoints(durationMs: number): number { + const seconds = durationMs / 1000; + if (seconds <= 600) return Math.ceil(seconds); + return Math.min(5000, Math.ceil(seconds / 5)); +} + +export function getMinGapMs(durationMs: number): number { + return Math.max(500, Math.floor(durationMs / 5000)); +} + +// Record a single sample into a history map, pruning old entries. +// Uses findIndex + splice instead of a shift() loop to prune in a single pass. +export function recordSample(historyMap: HistoryMap, key: string, value: number, now: number, cutoff: number, maxPoints: number): void { + if (!historyMap.has(key)) historyMap.set(key, []); + const hist = historyMap.get(key)!; + hist.push({ time: now, value }); + + // Prune entries older than cutoff + const firstValid = hist.findIndex(p => p.time >= cutoff); + if (firstValid > 0) { + hist.splice(0, firstValid); + } else if (firstValid === -1) { + hist.length = 0; + } + + if (hist.length > maxPoints) hist.splice(0, hist.length - maxPoints); +} + +// Merge, deduplicate (by minGapMs), and trim a list of history points. +export function deduplicateAndTrim(points: HistoryPoint[], maxPoints: number, minGapMs: number = 500): HistoryPoint[] { + if (points.length === 0) return points; + points.sort((a, b) => a.time - b.time); + const deduped: HistoryPoint[] = [points[0]!]; + for (let i = 1; i < points.length; i++) { + if (points[i]!.time - deduped[deduped.length - 1]!.time >= minGapMs) { + deduped.push(points[i]!); + } + } + if (deduped.length > maxPoints) deduped.splice(0, deduped.length - maxPoints); + return deduped; +} diff --git a/src/helpers/layout.js b/src/helpers/layout.ts similarity index 58% rename from src/helpers/layout.js rename to src/helpers/layout.ts index 31a8843..0004bf8 100644 --- a/src/helpers/layout.js +++ b/src/helpers/layout.ts @@ -1,12 +1,14 @@ -export function tabToRow(tab) { +import type { DualTabLayout } from "../types.js"; + +export function tabToRow(tab: number): number { return Math.ceil(tab / 2); } -export function tabToCol(tab) { +export function tabToCol(tab: number): number { return tab % 2 === 0 ? 1 : 0; } -export function classifyDualTab(tabs) { +export function classifyDualTab(tabs: number[]): DualTabLayout { if (tabs.length !== 2) return null; const [a, b] = [Math.min(...tabs), Math.max(...tabs)]; if (tabToRow(a) === tabToRow(b)) return "row-span"; diff --git a/src/helpers/sanitize.js b/src/helpers/sanitize.js deleted file mode 100644 index 74f6905..0000000 --- a/src/helpers/sanitize.js +++ /dev/null @@ -1,5 +0,0 @@ -const ESC_MAP = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; - -export function escapeHtml(str) { - return String(str).replace(/[&<>"']/g, c => ESC_MAP[c]); -} diff --git a/src/helpers/sanitize.ts b/src/helpers/sanitize.ts new file mode 100644 index 0000000..df96a73 --- /dev/null +++ b/src/helpers/sanitize.ts @@ -0,0 +1,5 @@ +const ESC_MAP: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; + +export function escapeHtml(str: unknown): string { + return String(str).replace(/[&<>"']/g, c => ESC_MAP[c] ?? c); +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..fe656e7 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,811 @@ +/** + * Internationalization module for the SPAN Panel card. + * + * Usage: + * import { setLanguage, t } from "../i18n.js"; + * setLanguage(hass.language); // call once per render cycle + * t("monitoring.heading"); // => "Monitoring" (or translated equivalent) + */ + +let _lang = "en"; + +const translations: Record> = { + en: { + // Tab labels + "tab.panel": "Panel", + "tab.monitoring": "Monitoring", + "tab.settings": "Settings", + + // Monitoring tab + "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 settings + "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 messages + "error.prefix": "Error:", + "error.failed_save": "Failed to save", + "error.failed": "Failed", + + // Settings tab + "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", + + // Graph time horizon + "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 + "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 + "grid.unknown": "Unknown", + "grid.configure": "Configure circuit", + "grid.configure_subdevice": "Configure device", + "grid.on": "On", + "grid.off": "Off", + + // Sub-devices + "subdevice.ev_charger": "EV Charger", + "subdevice.battery": "Battery", + "subdevice.fallback": "Sub-device", + "subdevice.soc": "SoC", + "subdevice.soe": "SoE", + "subdevice.power": "Power", + + // Side panel + "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:", + + // Monitoring status bar + "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 + "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 + "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)", + + // Metrics and shedding (used in constants) + "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.monitoring": "Monitoreo", + "tab.settings": "Configuraci\u00f3n", + "monitoring.heading": "Monitoreo", + "monitoring.global_settings": "Configuraci\u00f3n 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\u00f3n de Notificaciones", + "notification.targets": "Destinos de Notificaci\u00f3n", + "notification.none_selected": "Ninguno seleccionado", + "notification.no_targets": "No se encontraron destinos de notificaci\u00f3n", + "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\u00edtico", + "notification.hint.critical": "Anula silencio/No molestar", + "notification.hint.time_sensitive": "Atraviesa el modo Concentraci\u00f3n", + "notification.hint.passive": "Entrega silenciosa", + "notification.hint.active": "Entrega est\u00e1ndar", + "notification.title_template": "Plantilla de T\u00edtulo", + "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\u00f3n de prueba", + "notification.test_button": "Enviar prueba", + "notification.test_sending": "Enviando...", + "notification.test_sent": "Notificaci\u00f3n de prueba enviada", + "error.prefix": "Error:", + "error.failed_save": "Error al guardar", + "error.failed": "Fall\u00f3", + "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.", + "settings.open_link": "Abrir Configuraci\u00f3n de Integraci\u00f3n 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\u00f3n de monitoreo del panel", + "header.graph_settings": "Configuraci\u00f3n del horizonte temporal del gr\u00e1fico", + "header.site": "Sitio", + "header.grid": "Red", + "header.upstream": "Aguas arriba", + "header.downstream": "Aguas abajo", + "header.solar": "Solar", + "header.battery": "Bater\u00eda", + "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\u00eda", + "subdevice.fallback": "Sub-dispositivo", + "subdevice.soc": "SoC", + "subdevice.soe": "SoE", + "subdevice.power": "Potencia", + "sidepanel.graph_settings": "Configuraci\u00f3n de Gr\u00e1ficos", + "sidepanel.global_defaults": "Valores predeterminados globales para todos los circuitos", + "sidepanel.global_default": "Predeterminado Global", + "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", + "sidepanel.continuous_pct": "Continuo %", + "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:", + "status.monitoring": "Monitoreo", + "status.circuits": "circuitos", + "status.mains": "alimentaci\u00f3n", + "status.warning": "advertencia", + "status.warnings": "advertencias", + "status.alert": "alerta", + "status.alerts": "alertas", + "status.override": "anulaci\u00f3n", + "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", + "editor.select_panel": "Seleccione un panel...", + "editor.chart_window": "Ventana de tiempo del gr\u00e1fico", + "editor.days": "d\u00edas", + "editor.hours": "horas", + "editor.minutes": "minutos", + "editor.chart_metric": "M\u00e9trica del gr\u00e1fico", + "editor.visible_sections": "Secciones visibles", + "editor.panel_circuits": "Circuitos del panel", + "editor.battery_bess": "Bater\u00eda (BESS)", + "editor.ev_charger_evse": "Cargador EV (EVSE)", + "metric.power": "Potencia", + "metric.current": "Corriente", + "metric.soc": "Estado de Carga", + "metric.soe": "Estado de Energ\u00eda", + "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.monitoring": "Surveillance", + "tab.settings": "Param\u00e8tres", + "monitoring.heading": "Surveillance", + "monitoring.global_settings": "Param\u00e8tres Globaux", + "monitoring.enabled": "Activ\u00e9", + "monitoring.continuous": "Continu (%)", + "monitoring.spike": "Pic (%)", + "monitoring.window": "Fen\u00eatre (min)", + "monitoring.cooldown": "Refroidissement (min)", + "monitoring.monitored_points": "Points Surveill\u00e9s", + "monitoring.col.name": "Nom", + "monitoring.col.continuous": "Continu", + "monitoring.col.spike": "Pic", + "monitoring.col.window": "Fen\u00eatre", + "monitoring.col.cooldown": "Refroidissement", + "monitoring.all_none": "Tous / Aucun", + "monitoring.reset": "R\u00e9initialiser", + "notification.heading": "Param\u00e8tres de Notification", + "notification.targets": "Cibles de Notification", + "notification.none_selected": "Aucune s\u00e9lection", + "notification.no_targets": "Aucune cible de notification trouv\u00e9e", + "notification.all_targets": "Tous", + "notification.event_bus_target": "Bus d'\u00e9v\u00e9nements (bus d'\u00e9v\u00e9nements HA)", + "notification.priority": "Priorit\u00e9", + "notification.priority.default": "Par d\u00e9faut", + "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\u00e8le de Titre", + "notification.message_template": "Mod\u00e8le de Message", + "notification.placeholders": "Variables :", + "notification.event_bus_help": "Le Bus d'\u00e9v\u00e9nements d\u00e9clenche le type d'\u00e9v\u00e9nement", + "notification.event_bus_payload": "avec les donn\u00e9es :", + "notification.test_label": "Notification de test", + "notification.test_button": "Envoyer un test", + "notification.test_sending": "Envoi...", + "notification.test_sent": "Notification de test envoy\u00e9e", + "error.prefix": "Erreur :", + "error.failed_save": "\u00c9chec de la sauvegarde", + "error.failed": "\u00c9chou\u00e9", + "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.", + "settings.open_link": "Ouvrir les Param\u00e8tres d'Int\u00e9gration 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\u00e8tres de surveillance du panneau", + "header.graph_settings": "Param\u00e8tres d'horizon temporel du graphique", + "header.site": "Site", + "header.grid": "R\u00e9seau", + "header.upstream": "Amont", + "header.downstream": "Aval", + "header.solar": "Solaire", + "header.battery": "Batterie", + "header.toggle_units": "Basculer Watts / Amp\u00e8res", + "header.enable_switches": "Activer les interrupteurs", + "header.switches_enabled": "Interrupteurs activ\u00e9s", + "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\u00e8tres des Graphiques", + "sidepanel.global_defaults": "Valeurs par d\u00e9faut globales pour tous les circuits", + "sidepanel.global_default": "D\u00e9faut Global", + "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", + "sidepanel.continuous_pct": "Continu %", + "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 :", + "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'\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": + "Impossible de d\u00e9terminer panel_size. Aucun circuit trouv\u00e9 et aucun attribut panel_size. Mettez \u00e0 jour l'int\u00e9gration SPAN Panel.", + "editor.panel_label": "SPAN Panel", + "editor.select_panel": "S\u00e9lectionnez un panneau...", + "editor.chart_window": "Fen\u00eatre de temps du graphique", + "editor.days": "jours", + "editor.hours": "heures", + "editor.minutes": "minutes", + "editor.chart_metric": "M\u00e9trique 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)", + "metric.power": "Puissance", + "metric.current": "Courant", + "metric.soc": "\u00c9tat de Charge", + "metric.soe": "\u00c9tat d'\u00c9nergie", + "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": "\u30d1\u30cd\u30eb", + "tab.monitoring": "\u30e2\u30cb\u30bf\u30ea\u30f3\u30b0", + "tab.settings": "\u8a2d\u5b9a", + "monitoring.heading": "\u30e2\u30cb\u30bf\u30ea\u30f3\u30b0", + "monitoring.global_settings": "\u30b0\u30ed\u30fc\u30d0\u30eb\u8a2d\u5b9a", + "monitoring.enabled": "\u6709\u52b9", + "monitoring.continuous": "\u7d99\u7d9a (%)", + "monitoring.spike": "\u30b9\u30d1\u30a4\u30af (%)", + "monitoring.window": "\u30a6\u30a3\u30f3\u30c9\u30a6 (\u5206)", + "monitoring.cooldown": "\u30af\u30fc\u30eb\u30c0\u30a6\u30f3 (\u5206)", + "monitoring.monitored_points": "\u76e3\u8996\u30dd\u30a4\u30f3\u30c8", + "monitoring.col.name": "\u540d\u524d", + "monitoring.col.continuous": "\u7d99\u7d9a", + "monitoring.col.spike": "\u30b9\u30d1\u30a4\u30af", + "monitoring.col.window": "\u30a6\u30a3\u30f3\u30c9\u30a6", + "monitoring.col.cooldown": "\u30af\u30fc\u30eb\u30c0\u30a6\u30f3", + "monitoring.all_none": "\u5168\u9078\u629e / \u5168\u89e3\u9664", + "monitoring.reset": "\u30ea\u30bb\u30c3\u30c8", + "notification.heading": "\u901a\u77e5\u8a2d\u5b9a", + "notification.targets": "\u901a\u77e5\u5148", + "notification.none_selected": "\u672a\u9078\u629e", + "notification.no_targets": "\u901a\u77e5\u5148\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093", + "notification.all_targets": "\u3059\u3079\u3066", + "notification.event_bus_target": "\u30a4\u30d9\u30f3\u30c8\u30d0\u30b9 (HA\u30a4\u30d9\u30f3\u30c8\u30d0\u30b9)", + "notification.priority": "\u512a\u5148\u5ea6", + "notification.priority.default": "\u30c7\u30d5\u30a9\u30eb\u30c8", + "notification.priority.passive": "\u30d1\u30c3\u30b7\u30d6", + "notification.priority.active": "\u30a2\u30af\u30c6\u30a3\u30d6", + "notification.priority.time_sensitive": "\u7dca\u6025", + "notification.priority.critical": "\u91cd\u5927", + "notification.hint.critical": "\u30b5\u30a4\u30ec\u30f3\u30c8/\u304a\u3084\u3059\u307f\u30e2\u30fc\u30c9\u3092\u7121\u8996", + "notification.hint.time_sensitive": "\u96c6\u4e2d\u30e2\u30fc\u30c9\u3092\u7a81\u7834", + "notification.hint.passive": "\u30b5\u30a4\u30ec\u30f3\u30c8\u914d\u4fe1", + "notification.hint.active": "\u6a19\u6e96\u914d\u4fe1", + "notification.title_template": "\u30bf\u30a4\u30c8\u30eb\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8", + "notification.message_template": "\u30e1\u30c3\u30bb\u30fc\u30b8\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8", + "notification.placeholders": "\u30d7\u30ec\u30fc\u30b9\u30db\u30eb\u30c0\u30fc:", + "notification.event_bus_help": "\u30a4\u30d9\u30f3\u30c8\u30d0\u30b9\u304c\u767a\u884c\u3059\u308b\u30a4\u30d9\u30f3\u30c8\u30bf\u30a4\u30d7", + "notification.event_bus_payload": "\u30da\u30a4\u30ed\u30fc\u30c9:", + "notification.test_label": "\u30c6\u30b9\u30c8\u901a\u77e5", + "notification.test_button": "\u30c6\u30b9\u30c8\u9001\u4fe1", + "notification.test_sending": "\u9001\u4fe1\u4e2d...", + "notification.test_sent": "\u30c6\u30b9\u30c8\u901a\u77e5\u3092\u9001\u4fe1\u3057\u307e\u3057\u305f", + "error.prefix": "\u30a8\u30e9\u30fc:", + "error.failed_save": "\u4fdd\u5b58\u306b\u5931\u6557", + "error.failed": "\u5931\u6557", + "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", + "settings.open_link": "SPAN Panel\u7d71\u5408\u8a2d\u5b9a\u3092\u958b\u304f", + "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": "\u30d1\u30cd\u30eb\u30e2\u30cb\u30bf\u30ea\u30f3\u30b0\u8a2d\u5b9a", + "header.graph_settings": "\u30b0\u30e9\u30d5\u6642\u9593\u7bc4\u56f2\u8a2d\u5b9a", + "header.site": "\u30b5\u30a4\u30c8", + "header.grid": "\u30b0\u30ea\u30c3\u30c9", + "header.upstream": "\u4e0a\u6d41", + "header.downstream": "\u4e0b\u6d41", + "header.solar": "\u30bd\u30fc\u30e9\u30fc", + "header.battery": "\u30d0\u30c3\u30c6\u30ea\u30fc", + "header.toggle_units": "\u30ef\u30c3\u30c8/\u30a2\u30f3\u30da\u30a2\u5207\u308a\u66ff\u3048", + "header.enable_switches": "\u30b9\u30a4\u30c3\u30c1\u3092\u6709\u52b9\u5316", + "header.switches_enabled": "\u30b9\u30a4\u30c3\u30c1\u6709\u52b9", + "grid.unknown": "\u4e0d\u660e", + "grid.configure": "\u56de\u8def\u3092\u8a2d\u5b9a", + "grid.configure_subdevice": "\u30c7\u30d0\u30a4\u30b9\u3092\u8a2d\u5b9a", + "grid.on": "\u30aa\u30f3", + "grid.off": "\u30aa\u30d5", + "subdevice.ev_charger": "EV\u5145\u96fb\u5668", + "subdevice.battery": "\u30d0\u30c3\u30c6\u30ea\u30fc", + "subdevice.fallback": "\u30b5\u30d6\u30c7\u30d0\u30a4\u30b9", + "subdevice.soc": "SoC", + "subdevice.soe": "SoE", + "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.global_default": "\u30b0\u30ed\u30fc\u30d0\u30eb\u30c7\u30d5\u30a9\u30eb\u30c8", + "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", + "sidepanel.continuous_pct": "\u7d99\u7d9a %", + "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:", + "status.monitoring": "\u30e2\u30cb\u30bf\u30ea\u30f3\u30b0", + "status.circuits": "\u56de\u8def", + "status.mains": "\u4e3b\u96fb\u6e90", + "status.warning": "\u8b66\u544a", + "status.warnings": "\u8b66\u544a", + "status.alert": "\u30a2\u30e9\u30fc\u30c8", + "status.alerts": "\u30a2\u30e9\u30fc\u30c8", + "status.override": "\u4e0a\u66f8\u304d", + "status.overrides": "\u4e0a\u66f8\u304d", + "card.no_device": + "\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": + "panel_size\u3092\u5224\u5b9a\u3067\u304d\u307e\u305b\u3093\u3002\u56de\u8def\u304cpanel_size\u5c5e\u6027\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3002SPAN Panel\u7d71\u5408\u3092\u66f4\u65b0\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "editor.panel_label": "SPAN Panel", + "editor.select_panel": "\u30d1\u30cd\u30eb\u3092\u9078\u629e...", + "editor.chart_window": "\u30b0\u30e9\u30d5\u6642\u9593\u30a6\u30a3\u30f3\u30c9\u30a6", + "editor.days": "\u65e5", + "editor.hours": "\u6642\u9593", + "editor.minutes": "\u5206", + "editor.chart_metric": "\u30b0\u30e9\u30d5\u6307\u6a19", + "editor.visible_sections": "\u8868\u793a\u30bb\u30af\u30b7\u30e7\u30f3", + "editor.panel_circuits": "\u30d1\u30cd\u30eb\u56de\u8def", + "editor.battery_bess": "\u30d0\u30c3\u30c6\u30ea\u30fc (BESS)", + "editor.ev_charger_evse": "EV\u5145\u96fb\u5668 (EVSE)", + "metric.power": "\u96fb\u529b", + "metric.current": "\u96fb\u6d41", + "metric.soc": "\u5145\u96fb\u72b6\u614b", + "metric.soe": "\u30a8\u30cd\u30eb\u30ae\u30fc\u72b6\u614b", + "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.monitoring": "Monitoramento", + "tab.settings": "Configura\u00e7\u00f5es", + "monitoring.heading": "Monitoramento", + "monitoring.global_settings": "Configura\u00e7\u00f5es Globais", + "monitoring.enabled": "Ativado", + "monitoring.continuous": "Cont\u00ednuo (%)", + "monitoring.spike": "Pico (%)", + "monitoring.window": "Janela (min)", + "monitoring.cooldown": "Resfriamento (min)", + "monitoring.monitored_points": "Pontos Monitorados", + "monitoring.col.name": "Nome", + "monitoring.col.continuous": "Cont\u00ednuo", + "monitoring.col.spike": "Pico", + "monitoring.col.window": "Janela", + "monitoring.col.cooldown": "Resfriamento", + "monitoring.all_none": "Todos / Nenhum", + "monitoring.reset": "Redefinir", + "notification.heading": "Configura\u00e7\u00f5es de Notifica\u00e7\u00e3o", + "notification.targets": "Destinos de Notifica\u00e7\u00e3o", + "notification.none_selected": "Nenhum selecionado", + "notification.no_targets": "Nenhum destino de notifica\u00e7\u00e3o encontrado", + "notification.all_targets": "Todos", + "notification.event_bus_target": "Barramento de Eventos (barramento de eventos do HA)", + "notification.priority": "Prioridade", + "notification.priority.default": "Padr\u00e3o", + "notification.priority.passive": "Passivo", + "notification.priority.active": "Ativo", + "notification.priority.time_sensitive": "Urgente", + "notification.priority.critical": "Cr\u00edtico", + "notification.hint.critical": "Substitui silencioso/N\u00e3o perturbar", + "notification.hint.time_sensitive": "Atravessa o modo Foco", + "notification.hint.passive": "Entrega silenciosa", + "notification.hint.active": "Entrega padr\u00e3o", + "notification.title_template": "Modelo de T\u00edtulo", + "notification.message_template": "Modelo de Mensagem", + "notification.placeholders": "Vari\u00e1veis:", + "notification.event_bus_help": "O Barramento de Eventos dispara o tipo de evento", + "notification.event_bus_payload": "com dados:", + "notification.test_label": "Notifica\u00e7\u00e3o de teste", + "notification.test_button": "Enviar teste", + "notification.test_sending": "Enviando...", + "notification.test_sent": "Notifica\u00e7\u00e3o de teste enviada", + "error.prefix": "Erro:", + "error.failed_save": "Falha ao salvar", + "error.failed": "Falhou", + "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.", + "settings.open_link": "Abrir Configura\u00e7\u00f5es de Integra\u00e7\u00e3o 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\u00e7\u00f5es de monitoramento do painel", + "header.graph_settings": "Configura\u00e7\u00f5es do horizonte temporal do gr\u00e1fico", + "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\u00eancia", + "sidepanel.graph_settings": "Configura\u00e7\u00f5es de Gr\u00e1ficos", + "sidepanel.global_defaults": "Padr\u00f5es globais para todos os circuitos", + "sidepanel.global_default": "Padr\u00e3o Global", + "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", + "sidepanel.continuous_pct": "Cont\u00ednuo %", + "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:", + "status.monitoring": "Monitoramento", + "status.circuits": "circuitos", + "status.mains": "alimenta\u00e7\u00e3o", + "status.warning": "aviso", + "status.warnings": "avisos", + "status.alert": "alerta", + "status.alerts": "alertas", + "status.override": "substitui\u00e7\u00e3o", + "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.", + "editor.panel_label": "SPAN Panel", + "editor.select_panel": "Selecione um painel...", + "editor.chart_window": "Janela de tempo do gr\u00e1fico", + "editor.days": "dias", + "editor.hours": "horas", + "editor.minutes": "minutos", + "editor.chart_metric": "M\u00e9trica do gr\u00e1fico", + "editor.visible_sections": "Se\u00e7\u00f5es vis\u00edveis", + "editor.panel_circuits": "Circuitos do painel", + "editor.battery_bess": "Bateria (BESS)", + "editor.ev_charger_evse": "Carregador VE (EVSE)", + "metric.power": "Pot\u00eancia", + "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", + }, +}; + +/** + * Set the active language. Call once per render cycle with hass.language. + * Falls back to "en" for unsupported languages. + */ +export function setLanguage(lang: string | undefined): void { + _lang = lang && translations[lang] ? lang : "en"; +} + +/** + * Look up a translation key. Returns the English fallback when the key + * is missing in the active language. + */ +export function t(key: string): string { + return translations[_lang]?.[key] ?? translations.en?.[key] ?? key; +} diff --git a/src/index.js b/src/index.ts similarity index 55% rename from src/index.js rename to src/index.ts index 2ed471d..64f1613 100644 --- a/src/index.js +++ b/src/index.ts @@ -2,10 +2,31 @@ import { CARD_VERSION } from "./constants.js"; import { SpanPanelCard } from "./card/span-panel-card.js"; import { SpanPanelCardEditor } from "./editor/span-panel-card-editor.js"; -customElements.define("span-panel-card", SpanPanelCard); -customElements.define("span-panel-card-editor", SpanPanelCardEditor); +try { + if (!customElements.get("span-panel-card")) { + customElements.define("span-panel-card", SpanPanelCard); + } + if (!customElements.get("span-panel-card-editor")) { + customElements.define("span-panel-card-editor", SpanPanelCardEditor); + } +} catch { + // Scoped custom element registry may throw on duplicate registration after upgrade +} -window.customCards = window.customCards || []; +interface CustomCardDef { + type: string; + name: string; + description: string; + preview: boolean; +} + +declare global { + interface Window { + customCards?: CustomCardDef[]; + } +} + +window.customCards = window.customCards ?? []; window.customCards.push({ type: "span-panel-card", name: "SPAN Panel", diff --git a/src/panel/index.ts b/src/panel/index.ts new file mode 100644 index 0000000..39129df --- /dev/null +++ b/src/panel/index.ts @@ -0,0 +1,16 @@ +import { CARD_VERSION } from "../constants.js"; +import { SpanPanelElement } from "./span-panel.js"; + +try { + if (!customElements.get("span-panel")) { + customElements.define("span-panel", SpanPanelElement); + } +} catch { + // Scoped custom element registry may throw on duplicate registration after upgrade +} + +console.warn( + `%c SPAN-PANEL %c v${CARD_VERSION} `, + "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;" +); diff --git a/src/panel/span-panel.ts b/src/panel/span-panel.ts new file mode 100644 index 0000000..31db6b5 --- /dev/null +++ b/src/panel/span-panel.ts @@ -0,0 +1,399 @@ +import { INTEGRATION_DOMAIN } from "../constants.js"; +import { escapeHtml } from "../helpers/sanitize.js"; +import { setLanguage, t } from "../i18n.js"; +import "../core/side-panel.js"; +import { DashboardTab } from "./tab-dashboard.js"; +import { MonitoringTab } from "./tab-monitoring.js"; +import { SettingsTab } from "./tab-settings.js"; +import type { HomeAssistant, PanelDevice, CardConfig } from "../types.js"; + +interface HaMenuButton extends HTMLElement { + hass: HomeAssistant; + narrow: boolean; +} + +const PANEL_STYLES = ` + :host { + color: var(--primary-text-color); + } + .header { + background-color: var(--app-header-background-color); + color: var(--app-header-text-color, white); + border-bottom: var(--app-header-border-bottom, none); + } + .toolbar { + height: var(--header-height); + display: flex; + align-items: center; + font-size: 20px; + padding: 0 16px; + font-weight: 400; + box-sizing: border-box; + } + .main-title { + margin: 0 0 0 24px; + line-height: 20px; + flex-grow: 1; + } + .panel-selector select { + color: inherit; + font-size: inherit; + font-weight: inherit; + cursor: pointer; + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 6px; + background-color: rgba(255, 255, 255, 0.1); + } + .panel-selector select option { + background: var(--card-background-color, #333); + 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; + } + .panel-tab { + padding: 8px 20px; + cursor: pointer; + font-size: 0.9em; + font-weight: 500; + color: var(--app-header-text-color, white); + opacity: 0.7; + border-bottom: 2px solid transparent; + background: none; + border-top: none; + border-left: none; + border-right: none; + } + .panel-tab.active { + opacity: 1; + border-bottom-color: var(--app-header-text-color, white); + } + .view { + padding: 16px; + } + .view-content { + width: 100%; + } + .tab-content { + min-height: 400px; + } +`; + +type TabName = "dashboard" | "monitoring" | "settings"; + +export class SpanPanelElement extends HTMLElement { + private _hass: HomeAssistant | null; + // _config is set by HA but dashboard builds its own config + private _panels: PanelDevice[]; + private _selectedPanelId: string | null; + private _activeTab: TabName; + private _discovered: boolean; + private _narrow: boolean; + private _dashboardTab: DashboardTab; + private _monitoringTab: MonitoringTab; + private _settingsTab: SettingsTab; + private _chartMetric: string | undefined; + private _onVisibilityChange: (() => void) | null; + private _deviceRegistryUnsub: Promise<() => void> | null; + private _shadowListenersBound: boolean; + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this._hass = null; + this._panels = []; + this._selectedPanelId = null; + this._activeTab = "dashboard"; + this._discovered = false; + this._narrow = false; + this._dashboardTab = new DashboardTab(); + this._monitoringTab = new MonitoringTab(); + this._settingsTab = new SettingsTab(); + this._onVisibilityChange = null; + this._deviceRegistryUnsub = null; + this._shadowListenersBound = false; + } + + connectedCallback(): void { + // When HA navigates back to this panel, re-render if we already have data + if (this._discovered && this._hass) { + this._render(); + } + + this._onVisibilityChange = (): void => { + if (document.visibilityState !== "visible" || !this._discovered || !this._hass) return; + this._recoverIfNeeded(); + }; + document.addEventListener("visibilitychange", this._onVisibilityChange); + + this._subscribeDeviceRegistry(); + } + + disconnectedCallback(): void { + this._dashboardTab.stop(); + this._monitoringTab.stop(); + this._settingsTab.stop(); + if (this._onVisibilityChange) { + document.removeEventListener("visibilitychange", this._onVisibilityChange); + this._onVisibilityChange = null; + } + this._unsubscribeDeviceRegistry(); + } + + private _subscribeDeviceRegistry(): void { + if (this._deviceRegistryUnsub || !this._hass?.connection) return; + this._deviceRegistryUnsub = this._hass.connection.subscribeEvents(() => this._refreshPanels(), "device_registry_updated"); + } + + private _unsubscribeDeviceRegistry(): void { + if (this._deviceRegistryUnsub) { + this._deviceRegistryUnsub.then(unsub => unsub()); + this._deviceRegistryUnsub = null; + } + } + + private async _refreshPanels(): Promise { + if (!this._hass || !this._discovered) return; + + 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 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; + + this._panels = panels; + 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); + } + this._render(); + } + + set hass(val: HomeAssistant) { + const firstHass = !this._hass && val; + this._hass = val; + this._dashboardTab.hass = val; + // Update ha-menu-button if already rendered + const menuBtn = this.shadowRoot!.querySelector("ha-menu-button"); + if (menuBtn) menuBtn.hass = val; + if (!this._discovered) { + this._discoverPanels(); + } else if (!this.shadowRoot!.getElementById("tab-content")) { + // Shell DOM was lost (e.g. after prolonged background) — rebuild + this._render(); + } + if (firstHass) { + this._subscribeDeviceRegistry(); + } + } + + set narrow(val: boolean) { + this._narrow = val; + const menuBtn = this.shadowRoot!.querySelector("ha-menu-button"); + if (menuBtn) menuBtn.narrow = val; + } + + setConfig(_config: CardConfig): void { + // Config is set by HA but the dashboard tab builds its own config + } + + /** + * Rebuild the panel if the shell DOM was lost (e.g. after a WS reconnect + * or browser memory reclamation while the tab was hidden). + * If the render fails (WS still reconnecting), retry up to 3 times. + */ + private async _recoverIfNeeded(attempt = 0): Promise { + try { + if (!this.shadowRoot!.getElementById("tab-content")) { + this._render(); + } else { + await this._renderTab(); + } + } catch { + if (attempt < 3) { + setTimeout(() => this._recoverIfNeeded(attempt + 1), 2000 * (attempt + 1)); + } + } + } + + private async _discoverPanels(): Promise { + if (!this._hass) return; + this._discovered = true; + + 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 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; + } + + this._chartMetric = localStorage.getItem("span_panel_metric") || "power"; + + this._render(); + } + + private _render(): void { + setLanguage(this._hass?.language); + this.shadowRoot!.innerHTML = ` + + +
+
+ +
+ + + +
+
+ +
+ + + +
+
+ +
+
+
+
+
+ `; + + // Wire up ha-menu-button + const menuBtn = this.shadowRoot!.querySelector("ha-menu-button"); + if (menuBtn) { + menuBtn.hass = this._hass!; + menuBtn.narrow = this._narrow; + } + + const select = this.shadowRoot!.getElementById("panel-select") as HTMLSelectElement | null; + if (select) { + select.addEventListener("change", () => { + this._selectedPanelId = select.value; + localStorage.setItem("span_panel_selected", select.value); + this._renderTab(); + }); + } + + for (const tab of this.shadowRoot!.querySelectorAll(".panel-tab")) { + tab.addEventListener("click", () => { + this._activeTab = tab.dataset.tab as TabName; + for (const tabEl of this.shadowRoot!.querySelectorAll(".panel-tab")) { + tabEl.classList.toggle("active", tabEl.dataset.tab === this._activeTab); + } + this._renderTab(); + }); + } + + if (!this._shadowListenersBound) { + this._bindUnitToggle(); + this._bindTabNavigation(); + + // Sync: if graph settings change (from side panel or settings tab), + // re-render settings tab if it's visible + this.shadowRoot!.addEventListener("graph-settings-changed", () => { + if (this._activeTab === "settings") { + this._renderTab(); + } + }); + this._shadowListenersBound = true; + } + + this._renderTab(); + } + + private _bindUnitToggle(): void { + this.shadowRoot!.addEventListener("click", (e: Event) => { + const target = e.target as HTMLElement; + const btn = target.closest(".unit-btn"); + if (!btn) return; + const metric = btn.dataset.unit; + if (!metric || metric === this._chartMetric) return; + this._chartMetric = metric; + localStorage.setItem("span_panel_metric", metric); + if (this._activeTab === "dashboard") { + this._renderTab(); + } + }); + } + + private _bindTabNavigation(): void { + this.shadowRoot!.addEventListener("navigate-tab", (e: Event) => { + const tab = (e as CustomEvent).detail; + if (!tab) return; + this._activeTab = tab as TabName; + for (const tabEl of this.shadowRoot!.querySelectorAll(".panel-tab")) { + tabEl.classList.toggle("active", tabEl.dataset.tab === tab); + } + this._renderTab(); + }); + } + + private _buildDashboardConfig(): CardConfig { + return { + chart_metric: this._chartMetric, + history_minutes: 5, + show_panel: true, + show_battery: true, + show_evse: true, + }; + } + + private async _renderTab(): Promise { + this._dashboardTab.stop(); + this._monitoringTab.stop(); + this._settingsTab.stop(); + + const container = this.shadowRoot!.getElementById("tab-content"); + if (!container) return; + + switch (this._activeTab) { + case "dashboard": { + container.innerHTML = ""; + const config = this._buildDashboardConfig(); + const dashDevice = this._panels.find(p => p.id === this._selectedPanelId); + const dashEntryId = dashDevice?.config_entries?.[0] ?? null; + await this._dashboardTab.render(container, this._hass!, this._selectedPanelId ?? "", config, dashEntryId); + break; + } + case "monitoring": { + container.innerHTML = ""; + const monDevice = this._panels.find(p => p.id === this._selectedPanelId); + const monEntryId = monDevice?.config_entries?.[0] ?? null; + await this._monitoringTab.render(container, this._hass!, monEntryId ?? undefined); + break; + } + case "settings": { + container.innerHTML = ""; + const selectedDevice = this._panels.find(p => p.id === this._selectedPanelId); + const configEntryId = selectedDevice?.config_entries?.[0] ?? null; + await this._settingsTab.render(container, this._hass!, configEntryId ?? undefined, this._selectedPanelId ?? undefined); + break; + } + } + } +} diff --git a/src/panel/tab-dashboard.ts b/src/panel/tab-dashboard.ts new file mode 100644 index 0000000..5da50f2 --- /dev/null +++ b/src/panel/tab-dashboard.ts @@ -0,0 +1,131 @@ +import { discoverTopology } from "../card/card-discovery.js"; +import { escapeHtml } from "../helpers/sanitize.js"; +import { buildHeaderHTML } from "../core/header-renderer.js"; +import { buildGridHTML } from "../core/grid-renderer.js"; +import { buildSubDevicesHTML } from "../core/sub-device-renderer.js"; +import { buildMonitoringSummaryHTML } from "../core/monitoring-status.js"; +import { DashboardController } from "../core/dashboard-controller.js"; +import { CARD_STYLES } from "../card/card-styles.js"; +import "../core/side-panel.js"; +import type { HomeAssistant, CardConfig } from "../types.js"; + +export class DashboardTab { + private readonly _ctrl = new DashboardController(); + private _container: HTMLElement | null = null; + private _onGearClick: ((ev: Event) => void) | null = null; + private _onToggleClick: ((ev: Event) => void) | null = null; + private _onSidePanelClosed: (() => void) | null = null; + private _onGraphSettingsChanged: (() => void) | null = null; + + get hass(): HomeAssistant | null { + return this._ctrl.hass; + } + + set hass(val: HomeAssistant | null) { + this._ctrl.hass = val; + } + + async render(container: HTMLElement, hass: HomeAssistant, deviceId: string, config: CardConfig, configEntryId?: string | null): Promise { + this.stop(); + this._ctrl.reset(); + this._ctrl.showMonitoring = true; + this._container = container; + this._ctrl.hass = hass; + + let topology, panelSize; + try { + const result = await discoverTopology(hass, deviceId); + topology = result.topology; + panelSize = result.panelSize; + } catch (err) { + container.innerHTML = `

${escapeHtml((err as Error).message)}

`; + return; + } + + this._ctrl.init(topology, config, hass, configEntryId ?? null); + await this._ctrl.monitoringCache.fetch(hass, configEntryId ?? null); + await this._ctrl.fetchAndBuildHorizonMaps(); + + const totalRows = Math.ceil(panelSize / 2); + const monitoringStatus = this._ctrl.monitoringCache.status; + + const headerHTML = buildHeaderHTML(topology!, config); + const monitoringSummaryHTML = buildMonitoringSummaryHTML(monitoringStatus); + const gridHTML = buildGridHTML(topology!, totalRows, hass, config, monitoringStatus); + const subDevHTML = buildSubDevicesHTML(topology!, hass, config); + + container.innerHTML = ` + + ${headerHTML} + ${monitoringSummaryHTML} + ${subDevHTML ? `
${subDevHTML}
` : ""} + ${ + config.show_panel !== false + ? ` +
+ ${gridHTML} +
+ ` + : "" + } + + `; + + this._onGearClick = (ev: Event) => { + this._ctrl.onGearClick(ev, container); + }; + this._onToggleClick = (ev: Event) => { + this._ctrl.onToggleClick(ev, container); + }; + container.addEventListener("click", this._onGearClick); + container.addEventListener("click", this._onToggleClick); + + this._onSidePanelClosed = () => { + this._ctrl.monitoringCache.invalidate(); + this._ctrl.graphSettingsCache.invalidate(); + }; + container.addEventListener("side-panel-closed", this._onSidePanelClosed); + + this._onGraphSettingsChanged = () => this._ctrl.onGraphSettingsChanged(container); + container.addEventListener("graph-settings-changed", this._onGraphSettingsChanged); + + try { + await this._ctrl.loadHistory(); + } catch { + // Charts will populate live + } + + this._ctrl.updateDOM(container); + + const slideEl = container.querySelector(".slide-confirm"); + if (slideEl) { + this._ctrl.bindSlideConfirm(slideEl, container); + container.classList.add("switches-disabled"); + } + + this._ctrl.setupResizeObserver(container, container); + this._ctrl.startIntervals(container); + } + + stop(): void { + this._ctrl.stopIntervals(); + if (this._container) { + if (this._onGearClick) { + this._container.removeEventListener("click", this._onGearClick); + this._onGearClick = null; + } + if (this._onToggleClick) { + this._container.removeEventListener("click", this._onToggleClick); + this._onToggleClick = null; + } + if (this._onSidePanelClosed) { + this._container.removeEventListener("side-panel-closed", this._onSidePanelClosed); + this._onSidePanelClosed = null; + } + if (this._onGraphSettingsChanged) { + this._container.removeEventListener("graph-settings-changed", this._onGraphSettingsChanged); + this._onGraphSettingsChanged = null; + } + } + } +} diff --git a/src/panel/tab-monitoring.ts b/src/panel/tab-monitoring.ts new file mode 100644 index 0000000..831fe29 --- /dev/null +++ b/src/panel/tab-monitoring.ts @@ -0,0 +1,773 @@ +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 { HomeAssistant, MonitoringPointInfo, MonitoringStatusResponse, CallServiceResponse } from "../types.js"; + +const FIELD_STYLE = ` + display:flex;align-items:center;gap:8px;margin-bottom:8px; +`; +const INPUT_STYLE = ` + background:var(--secondary-background-color,#333); + border:1px solid var(--divider-color); + color:var(--primary-text-color); + border-radius:4px;padding:6px 10px;width:80px;font-size:0.85em; +`; +const LABEL_STYLE = ` + min-width:130px;font-size:0.85em;color:var(--secondary-text-color); +`; +const WIDE_LABEL_STYLE = ` + min-width:160px;font-size:0.85em;color:var(--secondary-text-color); +`; +const TEXT_INPUT_STYLE = ` + background:var(--secondary-background-color,#333); + border:1px solid var(--divider-color); + color:var(--primary-text-color); + border-radius:4px;padding:6px 10px;flex:1;font-size:0.85em; + font-family:monospace; +`; +const CELL_INPUT_STYLE = ` + background:var(--secondary-background-color,#333); + border:1px solid var(--divider-color); + color:var(--primary-text-color); + border-radius:3px;padding:3px 6px;width:50px;font-size:0.8em; + text-align:center; +`; + +function thresholdCell(entityId: string, field: string, value: number | undefined, unit: string, type: string): string { + return ` + ${unit} + `; +} + +export class MonitoringTab { + private _debounceTimer: ReturnType | null; + private _configEntryId: string | null; + private _notifyCloseHandler: ((e: MouseEvent) => void) | null; + + constructor() { + this._debounceTimer = null; + this._configEntryId = null; + this._notifyCloseHandler = null; + } + + stop(): void { + if (this._notifyCloseHandler) { + document.removeEventListener("click", this._notifyCloseHandler as EventListener); + this._notifyCloseHandler = null; + } + if (this._debounceTimer) { + clearTimeout(this._debounceTimer); + this._debounceTimer = null; + } + } + + async render(container: HTMLElement, hass: HomeAssistant, configEntryId?: string): Promise { + if (configEntryId !== undefined) this._configEntryId = configEntryId; + if (this._notifyCloseHandler) { + document.removeEventListener("click", this._notifyCloseHandler as EventListener); + this._notifyCloseHandler = null; + } + let status: MonitoringStatusResponse | null; + try { + const serviceData: Record = {}; + if (this._configEntryId) serviceData.config_entry_id = this._configEntryId; + const resp = await hass.callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "get_monitoring_status", + service_data: serviceData, + return_response: true, + }); + status = (resp?.response as MonitoringStatusResponse) || null; + } catch { + status = null; + } + + const globalSettings = status?.global_settings ?? {}; + const isEnabled = status?.enabled === true; + const circuits = status?.circuits ?? {}; + const mains = status?.mains ?? {}; + + // Discover notify targets from entity state and service registry + const targetSet = new Set(); + for (const eid of Object.keys(hass.states || {})) { + if (eid.startsWith("notify.")) targetSet.add(eid); + } + // Include service-based targets not yet migrated to entities, + // excluding the broadcast "notify" and the "send_message" action + const excludedServices = new Set(["notify", "send_message"]); + for (const svc of Object.keys(hass.services?.notify || {})) { + if (!excludedServices.has(svc)) targetSet.add(`notify.${svc}`); + } + // Event bus is a virtual target — always available + targetSet.add("event_bus"); + const allNotifyTargets = [...targetSet].sort(); + + const rawTargets = globalSettings.notify_targets ?? ""; + const selectedTargets = (typeof rawTargets === "string" ? rawTargets.split(",") : rawTargets).map((s: string) => s.trim()).filter(Boolean); + const allTargetsSelected = allNotifyTargets.length > 0 && allNotifyTargets.every(tgt => selectedTargets.includes(tgt)); + const titleTemplate = globalSettings.notification_title_template ?? "SPAN: {name} {alert_type}"; + const messageTemplate = globalSettings.notification_message_template ?? "{name} at {current_a}A ({utilization_pct}% of {breaker_rating_a}A rating)"; + const currentPriority = globalSettings.notification_priority ?? "default"; + + const circuitEntries = Object.entries(circuits).sort(([, a], [, b]) => (a.name ?? "").localeCompare(b.name ?? "")); + const mainsEntries = Object.entries(mains); + const allPoints: [string, MonitoringPointInfo][] = [...circuitEntries, ...mainsEntries]; + const allEnabled = allPoints.length > 0 && allPoints.every(([, c]) => c.monitoring_enabled !== false); + const someEnabled = allPoints.some(([, c]) => c.monitoring_enabled !== false); + + const circuitRows = circuitEntries + .map(([entityId, info]) => { + const name = escapeHtml(info.name ?? entityId); + const enabled = info.monitoring_enabled !== false; + const hasOverride = info.has_override === true; + const dimStyle = enabled ? "" : "opacity:0.4;"; + const eid = escapeHtml(entityId); + return ` + + + + + ${thresholdCell(eid, "continuous_threshold_pct", info.continuous_threshold_pct, "%", "circuit")} + ${thresholdCell(eid, "spike_threshold_pct", info.spike_threshold_pct, "%", "circuit")} + ${thresholdCell(eid, "window_duration_m", info.window_duration_m, "m", "circuit")} + ${thresholdCell(eid, "cooldown_duration_m", info.cooldown_duration_m, "m", "circuit")} + + ${ + hasOverride + ? `` + : "" + } + + + `; + }) + .join(""); + + const mainsRows = Object.entries(mains) + .map(([entityId, info]) => { + const name = escapeHtml(info.name ?? entityId); + const enabled = info.monitoring_enabled !== false; + const hasOverride = info.has_override === true; + const dimStyle = enabled ? "" : "opacity:0.4;"; + const eid = escapeHtml(entityId); + return ` + + + + + ${thresholdCell(eid, "continuous_threshold_pct", info.continuous_threshold_pct, "%", "mains")} + ${thresholdCell(eid, "spike_threshold_pct", info.spike_threshold_pct, "%", "mains")} + ${thresholdCell(eid, "window_duration_m", info.window_duration_m, "m", "mains")} + ${thresholdCell(eid, "cooldown_duration_m", info.cooldown_duration_m, "m", "mains")} + + ${ + hasOverride + ? `` + : "" + } + + + `; + }) + .join(""); + + container.innerHTML = ` +
+

${t("monitoring.heading")}

+ +
+
+

${t("monitoring.global_settings")}

+ +
+ +
+
+ ${t("monitoring.continuous")} + +
+
+ ${t("monitoring.spike")} + +
+
+ ${t("monitoring.window")} + +
+
+ ${t("monitoring.cooldown")} + +
+ +
+

${t("notification.heading")}

+ +
+ ${t("notification.targets")} + +
+ +
+ ${ + allNotifyTargets.length === 0 + ? `
${t("notification.no_targets")}
` + : allNotifyTargets + .map(target => { + const checked = selectedTargets.includes(target); + const isEventBus = target === "event_bus"; + const stateObj = isEventBus ? null : hass.states[target]; + const friendly = stateObj?.attributes?.friendly_name as string | undefined; + const displayLabel = isEventBus + ? t("notification.event_bus_target") + : friendly + ? `${escapeHtml(friendly)} (${escapeHtml(target)})` + : escapeHtml(target); + return ``; + }) + .join("") + } +
+
+
+ +
+ ${t("notification.priority")} + + + ${ + currentPriority === "critical" + ? t("notification.hint.critical") + : currentPriority === "time-sensitive" + ? t("notification.hint.time_sensitive") + : currentPriority === "passive" + ? t("notification.hint.passive") + : currentPriority === "active" + ? t("notification.hint.active") + : "" + } + +
+ +
+ ${t("notification.title_template")} + +
+ +
+ ${t("notification.message_template")} + +
+ +
+ ${t("notification.placeholders")} {name} {entity_id} {alert_type} + {current_a} {breaker_rating_a} {threshold_pct} + {utilization_pct} {window_m} {local_time} +
+
+ ${t("notification.event_bus_help")} span_panel_current_alert + ${t("notification.event_bus_payload")} alert_source alert_id + alert_name alert_type current_a + breaker_rating_a threshold_pct utilization_pct + panel_serial window_duration_s local_time +
+ +
+ ${t("notification.test_label")} + + +
+
+ +
+
+ +

${t("monitoring.monitored_points")}

+ + + + + + + + + + + + + + + + ${mainsRows} + ${circuitRows} + +
${t("monitoring.col.name")}${t("monitoring.col.continuous")}${t("monitoring.col.spike")}${t("monitoring.col.window")}${t("monitoring.col.cooldown")}
+ +
+
+ `; + + // Set indeterminate state on toggle-all checkbox + const toggleAllCb = container.querySelector("#toggle-all-circuits"); + if (toggleAllCb && !allEnabled && someEnabled) { + toggleAllCb.indeterminate = true; + } + + // Set indeterminate state on all-targets toggle + const allTargetsInit = container.querySelector("#notify-all-targets"); + if (allTargetsInit && allNotifyTargets.length > 0) { + const someSelected = selectedTargets.length > 0; + if (!allTargetsSelected && someSelected) { + allTargetsInit.indeterminate = true; + } + } + + this._bindGlobalControls(container, hass); + this._bindNotifyTargetSelect(container, hass); + this._bindNotificationSettings(container, hass); + this._bindToggleAll(container, hass, circuits, mains); + this._bindCircuitToggles(container, hass); + this._bindMainsToggles(container, hass); + this._bindThresholdInputs(container, hass); + this._bindResetButtons(container, hass); + } + + private _serviceData(data: Record): Record { + if (this._configEntryId) data.config_entry_id = this._configEntryId; + return data; + } + + private _callSetGlobal(hass: HomeAssistant, data: Record): Promise { + return hass.callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "set_global_monitoring", + service_data: this._serviceData({ ...data }), + }); + } + + private _bindGlobalControls(container: HTMLElement, hass: HomeAssistant): void { + const enabledCheckbox = container.querySelector("#monitoring-enabled"); + const fieldsDiv = container.querySelector("#global-fields"); + const statusEl = container.querySelector("#global-status"); + + 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), + }; + 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)"; + } + } + }, INPUT_DEBOUNCE_MS); + }; + + if (enabledCheckbox) { + enabledCheckbox.addEventListener("change", async () => { + const enabled = enabledCheckbox.checked; + if (fieldsDiv) { + fieldsDiv.style.opacity = enabled ? "" : "0.4"; + fieldsDiv.style.pointerEvents = enabled ? "" : "none"; + } + 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), + }; + 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)"; + } + return; + } + await this.render(container, hass); + }); + } + + for (const input of container.querySelectorAll("#global-fields input[type=number]")) { + input.addEventListener("input", saveGlobal); + } + } + + private _bindNotifyTargetSelect(container: HTMLElement, hass: HomeAssistant): void { + const btn = container.querySelector("#notify-target-btn"); + const dropdown = container.querySelector("#notify-target-dropdown"); + const label = container.querySelector("#notify-target-label"); + if (!btn || !dropdown) return; + + btn.addEventListener("click", (e: MouseEvent) => { + e.stopPropagation(); + const isOpen = dropdown.style.display !== "none"; + dropdown.style.display = isOpen ? "none" : "block"; + }); + + // Close dropdown when clicking outside + const closeHandler = (e: MouseEvent): void => { + const selectEl = container.querySelector("#notify-target-select"); + if (selectEl && !selectEl.contains(e.target as Node)) { + dropdown.style.display = "none"; + } + }; + document.addEventListener("click", closeHandler as EventListener); + // Store ref for cleanup on next render (dropdown rebuilt each render) + this._notifyCloseHandler = closeHandler; + + const saveTargets = (): void => { + const checked = [...container.querySelectorAll(".notify-target-cb:checked")]; + const targets = checked.map(c => c.value); + if (label) { + const displayTargets = targets.map(v => (v === "event_bus" ? t("notification.event_bus_target") : v)); + label.textContent = displayTargets.length ? displayTargets.join(", ") : t("notification.none_selected"); + } + // Sync the "All Targets" checkbox state + const allCb = container.querySelector("#notify-all-targets"); + if (allCb) { + const allCbs = [...container.querySelectorAll(".notify-target-cb")]; + allCb.checked = allCbs.length > 0 && allCbs.every(c => c.checked); + allCb.indeterminate = !allCb.checked && allCbs.some(c => c.checked); + } + if (this._debounceTimer) clearTimeout(this._debounceTimer); + this._debounceTimer = setTimeout(async () => { + try { + await this._callSetGlobal(hass, { notify_targets: targets.join(", ") }); + } catch { + // will show on next render + } + }, INPUT_DEBOUNCE_MS); + }; + + // "All Targets" toggle + const allTargetsCb = container.querySelector("#notify-all-targets"); + if (allTargetsCb) { + allTargetsCb.addEventListener("change", () => { + for (const cb of container.querySelectorAll(".notify-target-cb")) { + cb.checked = allTargetsCb.checked; + } + // Enable/disable dropdown + const btnEl = container.querySelector("#notify-target-btn"); + if (btnEl) { + btnEl.style.opacity = allTargetsCb.checked ? "0.4" : ""; + btnEl.style.pointerEvents = allTargetsCb.checked ? "none" : ""; + } + if (allTargetsCb.checked) dropdown.style.display = "none"; + saveTargets(); + }); + } + + // Individual target checkboxes + for (const cb of container.querySelectorAll(".notify-target-cb")) { + cb.addEventListener("change", () => { + saveTargets(); + }); + } + } + + private _bindNotificationSettings(container: HTMLElement, hass: HomeAssistant): void { + const prioritySelect = container.querySelector("#g-priority"); + const titleInput = container.querySelector("#g-title-template"); + const messageInput = container.querySelector("#g-message-template"); + + const saveField = (field: string, value: string | boolean): void => { + if (this._debounceTimer) clearTimeout(this._debounceTimer); + this._debounceTimer = setTimeout(async () => { + try { + await this._callSetGlobal(hass, { [field]: value }); + } catch { + // will show on next render + } + }, INPUT_DEBOUNCE_MS); + }; + + if (prioritySelect) { + prioritySelect.addEventListener("change", async () => { + try { + await this._callSetGlobal(hass, { notification_priority: prioritySelect.value }); + await this.render(container, hass); + } catch { + // will show on next render + } + }); + } + if (titleInput) { + titleInput.addEventListener("input", () => { + saveField("notification_title_template", titleInput.value); + }); + } + if (messageInput) { + messageInput.addEventListener("input", () => { + saveField("notification_message_template", messageInput.value); + }); + } + + const testBtn = container.querySelector("#test-notification-btn"); + const testStatus = container.querySelector("#test-notification-status"); + if (testBtn) { + testBtn.addEventListener("click", async () => { + testBtn.disabled = true; + if (testStatus) { + testStatus.textContent = t("notification.test_sending"); + testStatus.style.color = "var(--secondary-text-color)"; + } + try { + // Flush any pending settings save before testing + if (this._debounceTimer) { + clearTimeout(this._debounceTimer); + this._debounceTimer = null; + } + // Save current notify_targets so the test uses the latest selections + const checked = [...container.querySelectorAll(".notify-target-cb:checked")]; + const currentTargets = checked.map(c => c.value).join(", "); + await this._callSetGlobal(hass, { notify_targets: currentTargets }); + + const serviceData: Record = {}; + if (this._configEntryId) serviceData.config_entry_id = this._configEntryId; + await hass.callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "test_notification", + service_data: serviceData, + }); + if (testStatus) { + testStatus.textContent = t("notification.test_sent"); + testStatus.style.color = "var(--success-color, #4caf50)"; + } + } catch (err: unknown) { + if (testStatus) { + const message = err instanceof Error ? err.message : t("error.failed"); + testStatus.textContent = `${t("error.prefix")} ${message}`; + testStatus.style.color = "var(--error-color, #f44336)"; + } + } finally { + testBtn.disabled = false; + } + }); + } + } + + private _bindToggleAll( + container: HTMLElement, + hass: HomeAssistant, + circuits: Record, + mains: Record + ): void { + const toggleAll = container.querySelector("#toggle-all-circuits"); + if (!toggleAll) return; + toggleAll.addEventListener("change", async () => { + const enabled = toggleAll.checked; + const calls: Promise[] = [ + ...Object.keys(circuits).map(entityId => + hass + .callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "set_circuit_threshold", + service_data: this._serviceData({ circuit_id: entityId, monitoring_enabled: enabled }), + }) + .catch(() => {}) + ), + ...Object.keys(mains).map(entityId => + hass + .callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "set_mains_threshold", + service_data: this._serviceData({ leg: entityId, monitoring_enabled: enabled }), + }) + .catch(() => {}) + ), + ]; + await Promise.all(calls); + await this.render(container, hass); + }); + } + + private _bindMainsToggles(container: HTMLElement, hass: HomeAssistant): void { + for (const cb of container.querySelectorAll(".mains-toggle")) { + cb.addEventListener("change", async () => { + const entityId = cb.dataset.entity; + const enabled = cb.checked; + try { + await hass.callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "set_mains_threshold", + service_data: this._serviceData({ leg: entityId, monitoring_enabled: enabled }), + }); + } catch { + cb.checked = !enabled; + return; + } + await this.render(container, hass); + }); + } + } + + private _bindCircuitToggles(container: HTMLElement, hass: HomeAssistant): void { + for (const cb of container.querySelectorAll(".circuit-toggle")) { + cb.addEventListener("change", async () => { + const entityId = cb.dataset.entity; + const enabled = cb.checked; + try { + await hass.callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "set_circuit_threshold", + service_data: this._serviceData({ circuit_id: entityId, monitoring_enabled: enabled }), + }); + } catch { + cb.checked = !enabled; + return; + } + await this.render(container, hass); + }); + } + } + + private _bindThresholdInputs(container: HTMLElement, hass: HomeAssistant): void { + const timers = new Map>(); + for (const input of container.querySelectorAll(".threshold-input")) { + input.addEventListener("input", () => { + const key = `${input.dataset.entity}-${input.dataset.field}`; + const existing = timers.get(key); + if (existing) clearTimeout(existing); + timers.set( + key, + setTimeout(async () => { + const val = parseInt(input.value, 10); + if (!val || val < 1) return; + const entityId = input.dataset.entity; + const field = input.dataset.field; + const type = input.dataset.type; + const service = type === "mains" ? "set_mains_threshold" : "set_circuit_threshold"; + const idField = type === "mains" ? "leg" : "circuit_id"; + try { + await hass.callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service, + service_data: this._serviceData({ [idField]: entityId, [field!]: val }), + }); + // Re-render to update Custom badge and Reset button + await this.render(container, hass); + } catch { + input.style.borderColor = "var(--error-color, #f44336)"; + } + }, THRESHOLD_DEBOUNCE_MS) + ); + }); + } + } + + private _bindResetButtons(container: HTMLElement, hass: HomeAssistant): void { + for (const btn of container.querySelectorAll(".reset-btn")) { + btn.addEventListener("click", async () => { + const entityId = btn.dataset.entity; + if (!entityId) return; + const type = btn.dataset.type; + const service = type === "mains" ? "clear_mains_threshold" : "clear_circuit_threshold"; + const param = this._serviceData(type === "mains" ? { leg: entityId } : { circuit_id: entityId }); + await hass.callService(INTEGRATION_DOMAIN, service, param); + await this.render(container, hass); + }); + } + } +} diff --git a/src/panel/tab-settings.ts b/src/panel/tab-settings.ts new file mode 100644 index 0000000..766d5b9 --- /dev/null +++ b/src/panel/tab-settings.ts @@ -0,0 +1,223 @@ +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"; + +function horizonOptions(selectedKey: string): string { + return Object.keys(GRAPH_HORIZONS) + .map(key => { + const labelKey = `horizon.${key}`; + const translated = t(labelKey); + return ``; + }) + .join(""); +} + +const SELECT_STYLE = ` + background:var(--secondary-background-color,#333); + border:1px solid var(--divider-color); + color:var(--primary-text-color); + border-radius:4px;padding:4px 8px;font-size:0.85em; +`; + +export class SettingsTab { + private _debounceTimers: Map>; + private _configEntryId: string | null; + private _deviceId: string | null; + + constructor() { + this._debounceTimers = new Map(); + this._configEntryId = null; + this._deviceId = null; + } + + stop(): void { + for (const timer of this._debounceTimers.values()) { + clearTimeout(timer); + } + this._debounceTimers.clear(); + } + + async render(container: HTMLElement, hass: HomeAssistant, configEntryId?: string, deviceId?: string): Promise { + if (configEntryId !== undefined) this._configEntryId = configEntryId; + if (deviceId !== undefined) this._deviceId = deviceId; + + let graphSettings: GraphSettings | null; + try { + const serviceData: Record = {}; + if (this._configEntryId) serviceData.config_entry_id = this._configEntryId; + const resp = await hass.callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "get_graph_settings", + service_data: serviceData, + return_response: true, + }); + graphSettings = (resp?.response as GraphSettings) || null; + } catch { + graphSettings = null; + } + + let topology: PanelTopology | null = null; + try { + if (this._deviceId) { + topology = await hass.callWS({ + type: `${INTEGRATION_DOMAIN}/panel_topology`, + device_id: this._deviceId, + }); + } + } catch { + topology = null; + } + + const globalHorizon = graphSettings?.global_horizon ?? DEFAULT_GRAPH_HORIZON; + const circuitSettings = graphSettings?.circuits ?? {}; + + const circuitEntries = topology ? Object.entries(topology.circuits || {}).sort(([, a], [, b]) => (a.name || "").localeCompare(b.name || "")) : []; + + const circuitRows = circuitEntries + .map(([uuid, circuit]) => { + const name = escapeHtml(circuit.name || uuid); + const circuitData = circuitSettings[uuid]; + const effectiveHorizon = circuitData?.horizon ?? globalHorizon; + const hasOverride = circuitData?.has_override === true; + const safeUuid = escapeHtml(uuid); + return ` + + ${name} + + + + + ${ + hasOverride + ? `` + : "" + } + + + `; + }) + .join(""); + + const href = this._configEntryId + ? `/config/integrations/integration/${INTEGRATION_DOMAIN}#config_entry=${this._configEntryId}` + : `/config/integrations/integration/${INTEGRATION_DOMAIN}`; + + container.innerHTML = ` +
+

${t("settings.heading")}

+

+ ${t("settings.description")} +

+ + ${t("settings.open_link")} → + + +
+ +

${t("settings.graph_horizon_heading")}

+ +
+
+ ${t("settings.global_default")} + +
+
+ + ${ + circuitEntries.length > 0 + ? ` +

${t("settings.circuit_graph_scales")}

+ + + + + + + + + + ${circuitRows} + +
${t("settings.col.circuit")}${t("settings.col.scale")}
+ ` + : "" + } +
+ `; + + this._bindGlobalHorizon(container, hass); + this._bindCircuitHorizons(container, hass); + this._bindResetButtons(container, hass); + } + + private _serviceData(data: Record): Record { + if (this._configEntryId) data.config_entry_id = this._configEntryId; + return data; + } + + private _bindGlobalHorizon(container: HTMLElement, hass: HomeAssistant): void { + const select = container.querySelector("#global-horizon"); + if (!select) return; + select.addEventListener("change", async () => { + await hass.callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "set_graph_time_horizon", + service_data: this._serviceData({ horizon: select.value }), + }); + container.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); + await this.render(container, hass); + }); + } + + private _bindCircuitHorizons(container: HTMLElement, hass: HomeAssistant): void { + for (const select of container.querySelectorAll(".horizon-select")) { + select.addEventListener("change", () => { + const uuid = select.dataset.circuit; + if (!uuid) return; + const key = `circuit-${uuid}`; + const existing = this._debounceTimers.get(key); + if (existing) clearTimeout(existing); + this._debounceTimers.set( + key, + setTimeout(async () => { + await hass.callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "set_circuit_graph_horizon", + service_data: this._serviceData({ circuit_id: uuid, horizon: select.value }), + }); + container.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); + await this.render(container, hass); + }, INPUT_DEBOUNCE_MS) + ); + }); + } + } + + private _bindResetButtons(container: HTMLElement, hass: HomeAssistant): void { + for (const btn of container.querySelectorAll(".reset-btn")) { + btn.addEventListener("click", async () => { + const uuid = btn.dataset.circuit; + if (!uuid) return; + await hass.callWS({ + type: "call_service", + domain: INTEGRATION_DOMAIN, + service: "clear_circuit_graph_horizon", + service_data: this._serviceData({ circuit_id: uuid }), + }); + container.dispatchEvent(new CustomEvent("graph-settings-changed", { bubbles: true, composed: true })); + await this.render(container, hass); + }); + } + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..862fa9f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,217 @@ +// -- Home Assistant types (subset used by this project) -- + +export interface HassEntity { + entity_id: string; + state: string; + attributes: Record; + last_changed: string; + last_updated: string; +} + +export interface HomeAssistant { + states: Record; + services: Record>; + language: string; + callService: (domain: string, service: string, data?: Record, target?: Record) => Promise; + callWS: (msg: Record) => Promise; + formatEntityState?: (entity: HassEntity) => string; + connection?: { + subscribeEvents: (callback: () => void, event: string) => Promise<() => void>; + }; +} + +// -- SPAN topology types -- + +export interface CircuitEntities { + power?: string; + current?: string; + switch?: string; + select?: string; + [key: string]: string | undefined; +} + +export interface Circuit { + name: string; + tabs: number[]; + entities: CircuitEntities; + breaker_rating_a?: number | null; + device_type?: string; + relay_state?: string; + is_user_controllable?: boolean; + always_on?: boolean; + voltage?: number; +} + +export interface SubDeviceEntityInfo { + domain: string; + original_name?: string; + unique_id?: string; +} + +export interface SubDevice { + name?: string; + type?: string; + entities?: Record; +} + +export interface PanelEntities { + site_power?: string; + current_power?: string; + feedthrough_power?: string; + pv_power?: string; + battery_level?: string; + dsm_state?: string; +} + +export interface PanelTopology { + circuits: Record; + sub_devices?: Record; + panel_entities?: PanelEntities; + device_name?: string; + serial?: string; + firmware?: string; + panel_size?: number; + device_id?: string; +} + +export interface PanelDevice { + id: string; + name?: string; + name_by_user?: string; + config_entries?: string[]; + identifiers?: [string, string][]; + via_device_id?: string | null; + sw_version?: string; + model?: string; +} + +export interface DiscoveryResult { + topology: PanelTopology | null; + panelDevice: PanelDevice | null; + panelSize: number; +} + +// -- Card configuration -- + +export interface CardConfig { + device_id?: string; + history_days?: number; + history_hours?: number; + history_minutes?: number; + chart_metric?: string; + show_panel?: boolean; + show_battery?: boolean; + show_evse?: boolean; + visible_sub_entities?: Record; +} + +// -- Chart & history types -- + +export interface HistoryPoint { + time: number; + value: number; +} + +export type HistoryMap = Map; + +export interface ChartMetricDef { + entityRole: string; + label: () => string; + unit: (v: number) => string; + format: (v: number) => string; + fixedMin?: number; + fixedMax?: number; +} + +// -- Graph settings -- + +export interface CircuitGraphOverride { + horizon: string; + has_override: boolean; +} + +export interface GraphSettings { + global_horizon?: string; + circuits?: Record; + sub_devices?: Record; +} + +// -- Monitoring -- + +export interface MonitoringPointInfo { + name?: string; + monitoring_enabled?: boolean; + utilization_pct?: number; + over_threshold_since?: string | null; + has_override?: boolean; + continuous_threshold_pct?: number; + spike_threshold_pct?: number; + window_duration_m?: number; + cooldown_duration_m?: number; +} + +export interface MonitoringGlobalSettings { + continuous_threshold_pct?: number; + spike_threshold_pct?: number; + window_duration_m?: number; + cooldown_duration_m?: number; + notify_targets?: string | string[]; + notification_title_template?: string; + notification_message_template?: string; + enable_persistent_notifications?: boolean; + enable_event_bus?: boolean; + notification_priority?: string; +} + +export interface MonitoringStatusResponse { + enabled?: boolean; + global_settings?: MonitoringGlobalSettings; + circuits?: Record; + mains?: Record; +} + +export interface CallServiceResponse { + response?: unknown; +} + +export interface MonitoringStatus { + circuits?: Record; + mains?: Record; +} + +// -- Graph horizon preset -- + +export interface GraphHorizonPreset { + ms: number; + refreshMs: number; + useRealtime: boolean; +} + +// -- Shedding priority -- + +export interface SheddingPriorityDef { + icon: string; + icon2?: string; + color: string; + label: () => string; + textLabel?: string; +} + +// -- Sub-device entity collection -- + +export interface SubDeviceEntityRef { + entityId: string; + key: string; + devId: string; +} + +// -- Entity descriptor for sub-device entity finder -- + +export interface EntityDescriptor { + names: string[]; + suffixes: string[]; +} + +// -- Dual-tab layout classification -- + +export type DualTabLayout = "row-span" | "col-span" | null; diff --git a/tests/chart-options.test.ts b/tests/chart-options.test.ts new file mode 100644 index 0000000..1951cd0 --- /dev/null +++ b/tests/chart-options.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { buildChartOptions } from "../src/chart/chart-options.js"; +import { CHART_METRICS, BESS_CHART_METRICS } from "../src/constants.js"; +import type { HistoryPoint, ChartMetricDef } from "../src/types.js"; + +const powerMetric = CHART_METRICS["power"]!; +const currentMetric = CHART_METRICS["current"]!; +const socMetric = BESS_CHART_METRICS["soc"]!; + +describe("buildChartOptions", () => { + it("returns valid options with empty history", () => { + const result = buildChartOptions([], 300_000, powerMetric, false, undefined); + expect(result.options).toBeDefined(); + expect(result.series).toHaveLength(1); + expect(result.series[0]!.data).toEqual([]); + }); + + it("returns valid options when history is undefined", () => { + const result = buildChartOptions(undefined, 300_000, powerMetric, false, undefined); + expect(result.series[0]!.data).toEqual([]); + }); + + it("returns correct time axis range", () => { + const durationMs = 300_000; + const before = Date.now(); + const result = buildChartOptions([], durationMs, powerMetric, false, undefined); + const after = Date.now(); + + expect(result.options.xAxis.type).toBe("time"); + expect(result.options.xAxis.max).toBeGreaterThanOrEqual(before); + expect(result.options.xAxis.max).toBeLessThanOrEqual(after); + expect(result.options.xAxis.min).toBeGreaterThanOrEqual(before - durationMs); + expect(result.options.xAxis.min).toBeLessThanOrEqual(after - durationMs); + }); + + it("adds NEC limit lines when breakerRatingA is set and metric is current", () => { + const result = buildChartOptions([], 300_000, currentMetric, false, 20); + // Main data series + continuous load line + trip rating line + expect(result.series).toHaveLength(3); + // Continuous load line (80% of 20A = 16A) + expect(result.series[1]!.data[0]![1]).toBe(16); + // Trip rating line (breaker rating = 20A) + expect(result.series[2]!.data[0]![1]).toBe(20); + // Y-axis max should be ceil(20 * 1.25) = 25 + expect(result.options.yAxis.max).toBe(25); + expect(result.options.yAxis.min).toBe(0); + }); + + it("does not add NEC lines for power metric", () => { + const result = buildChartOptions([], 300_000, powerMetric, false, 20); + expect(result.series).toHaveLength(1); + }); + + it("uses producer accent color when isProducer is true", () => { + const producerResult = buildChartOptions([], 300_000, powerMetric, true, undefined); + const consumerResult = buildChartOptions([], 300_000, powerMetric, false, undefined); + + const producerColor = producerResult.series[0]!.lineStyle.color; + const consumerColor = consumerResult.series[0]!.lineStyle.color; + + expect(producerColor).toBe("rgb(140, 160, 220)"); + expect(consumerColor).toBe("rgb(77, 217, 175)"); + expect(producerColor).not.toBe(consumerColor); + }); + + it("uses fixedMin/fixedMax when metric has them (SoC 0-100)", () => { + const result = buildChartOptions([], 300_000, socMetric, false, undefined); + expect(result.options.yAxis.min).toBe(0); + expect(result.options.yAxis.max).toBe(100); + }); + + it("defaults to power metric when metric is undefined", () => { + const result = buildChartOptions([], 300_000, undefined, false, undefined); + expect(result.options).toBeDefined(); + expect(result.series).toHaveLength(1); + }); + + it("filters history points outside the duration window", () => { + const now = Date.now(); + const history: HistoryPoint[] = [ + { time: now - 600_000, value: 100 }, // outside 5m window + { time: now - 60_000, value: 200 }, // inside window + ]; + const result = buildChartOptions(history, 300_000, powerMetric, false, undefined); + expect(result.series[0]!.data).toHaveLength(1); + expect(result.series[0]!.data[0]![1]).toBe(200); + }); + + it("uses absolute values for data points", () => { + const now = Date.now(); + const history: HistoryPoint[] = [{ time: now - 1000, value: -500 }]; + const result = buildChartOptions(history, 300_000, powerMetric, false, undefined); + expect(result.series[0]!.data[0]![1]).toBe(500); + }); +}); diff --git a/tests/entity-finder.test.ts b/tests/entity-finder.test.ts new file mode 100644 index 0000000..326d3e6 --- /dev/null +++ b/tests/entity-finder.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { findSubDevicePowerEntity, findBatteryLevelEntity, findBatterySoeEntity, findBatteryCapacityEntity } from "../src/helpers/entity-finder.js"; +import type { SubDevice } from "../src/types.js"; + +function makeSubDevice(entities: Record): SubDevice { + return { entities } as SubDevice; +} + +describe("findSubDevicePowerEntity", () => { + it("finds power entity by name", () => { + const sub = makeSubDevice({ + "sensor.bess_power": { domain: "sensor", original_name: "Power" }, + }); + expect(findSubDevicePowerEntity(sub)).toBe("sensor.bess_power"); + }); + + it("finds power entity by unique_id suffix", () => { + const sub = makeSubDevice({ + "sensor.bess_1": { domain: "sensor", original_name: "Something", unique_id: "span_bess_power" }, + }); + expect(findSubDevicePowerEntity(sub)).toBe("sensor.bess_1"); + }); + + it("returns null when no power entity exists", () => { + const sub = makeSubDevice({ + "sensor.bess_temp": { domain: "sensor", original_name: "Temperature" }, + }); + expect(findSubDevicePowerEntity(sub)).toBeNull(); + }); + + it("skips non-sensor entities", () => { + const sub = makeSubDevice({ + "switch.bess_power": { domain: "switch", original_name: "Power" }, + }); + expect(findSubDevicePowerEntity(sub)).toBeNull(); + }); +}); + +describe("findBatteryLevelEntity", () => { + it("finds by name", () => { + const sub = makeSubDevice({ + "sensor.batt": { domain: "sensor", original_name: "Battery Level" }, + }); + expect(findBatteryLevelEntity(sub)).toBe("sensor.batt"); + }); + + it("finds by unique_id suffix", () => { + const sub = makeSubDevice({ + "sensor.batt": { domain: "sensor", original_name: "Other", unique_id: "span_battery_level" }, + }); + expect(findBatteryLevelEntity(sub)).toBe("sensor.batt"); + }); +}); + +describe("findBatterySoeEntity", () => { + it("finds state of energy entity", () => { + const sub = makeSubDevice({ + "sensor.soe": { domain: "sensor", original_name: "State of Energy" }, + }); + expect(findBatterySoeEntity(sub)).toBe("sensor.soe"); + }); +}); + +describe("findBatteryCapacityEntity", () => { + it("finds nameplate capacity entity", () => { + const sub = makeSubDevice({ + "sensor.cap": { domain: "sensor", original_name: "Nameplate Capacity" }, + }); + expect(findBatteryCapacityEntity(sub)).toBe("sensor.cap"); + }); +}); diff --git a/tests/format.test.ts b/tests/format.test.ts new file mode 100644 index 0000000..785d31e --- /dev/null +++ b/tests/format.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { formatPowerUnit, formatPowerSigned, formatKw } from "../src/helpers/format.js"; + +describe("formatPowerUnit", () => { + it("returns W for values under 1000", () => { + expect(formatPowerUnit(500)).toBe("W"); + expect(formatPowerUnit(0)).toBe("W"); + expect(formatPowerUnit(999)).toBe("W"); + }); + + it("returns kW for values >= 1000", () => { + expect(formatPowerUnit(1000)).toBe("kW"); + expect(formatPowerUnit(5000)).toBe("kW"); + }); + + it("handles negative values", () => { + expect(formatPowerUnit(-500)).toBe("W"); + expect(formatPowerUnit(-1000)).toBe("kW"); + }); +}); + +describe("formatPowerSigned", () => { + it("formats positive values without sign", () => { + expect(formatPowerSigned(500)).toBe("500"); + expect(formatPowerSigned(1500)).toBe("1.5"); + }); + + it("formats negative values with minus sign", () => { + expect(formatPowerSigned(-500)).toBe("-500"); + expect(formatPowerSigned(-1500)).toBe("-1.5"); + }); + + it("formats zero", () => { + expect(formatPowerSigned(0)).toBe("0"); + }); + + it("formats small values with one decimal", () => { + expect(formatPowerSigned(5)).toBe("5.0"); + }); +}); + +describe("formatKw", () => { + it("converts watts to kW with one decimal", () => { + expect(formatKw(1000)).toBe("1.0"); + expect(formatKw(1500)).toBe("1.5"); + expect(formatKw(2345)).toBe("2.3"); + }); + + it("handles negative values using absolute value", () => { + expect(formatKw(-1500)).toBe("1.5"); + }); + + it("handles zero", () => { + expect(formatKw(0)).toBe("0.0"); + }); +}); diff --git a/tests/graph-settings.test.ts b/tests/graph-settings.test.ts new file mode 100644 index 0000000..4ef4bed --- /dev/null +++ b/tests/graph-settings.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { getEffectiveHorizon, getEffectiveSubDeviceHorizon } from "../src/core/graph-settings.js"; +import type { GraphSettings } from "../src/types.js"; + +describe("getEffectiveHorizon", () => { + it("returns DEFAULT_GRAPH_HORIZON when settings is null", () => { + expect(getEffectiveHorizon(null, "circuit_1")).toBe("5m"); + }); + + it("returns global_horizon when no circuit override exists", () => { + const settings: GraphSettings = { global_horizon: "1h" }; + expect(getEffectiveHorizon(settings, "circuit_1")).toBe("1h"); + }); + + it("returns global_horizon when circuit exists but has_override is false", () => { + const settings: GraphSettings = { + global_horizon: "1h", + circuits: { circuit_1: { horizon: "1d", has_override: false } }, + }; + expect(getEffectiveHorizon(settings, "circuit_1")).toBe("1h"); + }); + + it("returns override horizon when has_override is true", () => { + const settings: GraphSettings = { + global_horizon: "1h", + circuits: { circuit_1: { horizon: "1d", has_override: true } }, + }; + expect(getEffectiveHorizon(settings, "circuit_1")).toBe("1d"); + }); + + it("falls back to DEFAULT_GRAPH_HORIZON when global_horizon is undefined", () => { + const settings: GraphSettings = {}; + expect(getEffectiveHorizon(settings, "circuit_1")).toBe("5m"); + }); +}); + +describe("getEffectiveSubDeviceHorizon", () => { + it("returns DEFAULT_GRAPH_HORIZON when settings is null", () => { + expect(getEffectiveSubDeviceHorizon(null, "sub_1")).toBe("5m"); + }); + + it("returns global_horizon when no sub-device override exists", () => { + const settings: GraphSettings = { global_horizon: "1h" }; + expect(getEffectiveSubDeviceHorizon(settings, "sub_1")).toBe("1h"); + }); + + it("returns global_horizon when sub-device exists but has_override is false", () => { + const settings: GraphSettings = { + global_horizon: "1h", + sub_devices: { sub_1: { horizon: "1w", has_override: false } }, + }; + expect(getEffectiveSubDeviceHorizon(settings, "sub_1")).toBe("1h"); + }); + + it("returns override horizon when has_override is true", () => { + const settings: GraphSettings = { + global_horizon: "1h", + sub_devices: { sub_1: { horizon: "1w", has_override: true } }, + }; + expect(getEffectiveSubDeviceHorizon(settings, "sub_1")).toBe("1w"); + }); + + it("falls back to DEFAULT_GRAPH_HORIZON when global_horizon is undefined", () => { + const settings: GraphSettings = {}; + expect(getEffectiveSubDeviceHorizon(settings, "sub_1")).toBe("5m"); + }); +}); diff --git a/tests/history.test.ts b/tests/history.test.ts new file mode 100644 index 0000000..be3accf --- /dev/null +++ b/tests/history.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { getHistoryDurationMs, getHorizonDurationMs, getMaxHistoryPoints, getMinGapMs, recordSample, deduplicateAndTrim } from "../src/helpers/history.js"; +import type { HistoryMap, HistoryPoint } from "../src/types.js"; + +describe("getHistoryDurationMs", () => { + it("returns default 5 minutes when no config specified", () => { + expect(getHistoryDurationMs({})).toBe(5 * 60 * 1000); + }); + + it("computes from days/hours/minutes", () => { + expect(getHistoryDurationMs({ history_days: 1 })).toBe(24 * 60 * 60 * 1000); + expect(getHistoryDurationMs({ history_hours: 2 })).toBe(2 * 60 * 60 * 1000); + expect(getHistoryDurationMs({ history_minutes: 30 })).toBe(30 * 60 * 1000); + }); + + it("enforces minimum of 60 seconds", () => { + expect(getHistoryDurationMs({ history_minutes: 0 })).toBe(60000); + }); + + it("combines days, hours, and minutes", () => { + const expected = ((1 * 24 + 2) * 60 + 30) * 60 * 1000; + expect(getHistoryDurationMs({ history_days: 1, history_hours: 2, history_minutes: 30 })).toBe(expected); + }); +}); + +describe("getHorizonDurationMs", () => { + it("returns known horizon durations", () => { + expect(getHorizonDurationMs("5m")).toBe(5 * 60 * 1000); + expect(getHorizonDurationMs("1h")).toBe(60 * 60 * 1000); + expect(getHorizonDurationMs("1d")).toBe(24 * 60 * 60 * 1000); + }); + + it("falls back to default for unknown horizons", () => { + expect(getHorizonDurationMs("unknown")).toBe(5 * 60 * 1000); + }); +}); + +describe("getMaxHistoryPoints", () => { + it("returns seconds for durations <= 10 minutes", () => { + expect(getMaxHistoryPoints(5 * 60 * 1000)).toBe(300); + }); + + it("caps at 5000 for large durations", () => { + expect(getMaxHistoryPoints(30 * 24 * 60 * 60 * 1000)).toBe(5000); + }); +}); + +describe("getMinGapMs", () => { + it("returns minimum 500ms", () => { + expect(getMinGapMs(1000)).toBe(500); + }); + + it("scales with duration", () => { + expect(getMinGapMs(60 * 60 * 1000)).toBe(Math.floor(3600000 / 5000)); + }); +}); + +describe("recordSample", () => { + it("creates new entry if key does not exist", () => { + const map: HistoryMap = new Map(); + recordSample(map, "test", 100, 1000, 0, 100); + expect(map.get("test")).toHaveLength(1); + expect(map.get("test")![0]).toEqual({ time: 1000, value: 100 }); + }); + + it("prunes entries older than cutoff", () => { + const map: HistoryMap = new Map(); + map.set("test", [ + { time: 100, value: 1 }, + { time: 200, value: 2 }, + { time: 300, value: 3 }, + ]); + recordSample(map, "test", 4, 400, 250, 100); + const result = map.get("test")!; + expect(result.every(p => p.time >= 250)).toBe(true); + expect(result[result.length - 1]!.value).toBe(4); + }); + + it("enforces maxPoints limit", () => { + const map: HistoryMap = new Map(); + for (let i = 0; i < 10; i++) { + recordSample(map, "test", i, i * 100, 0, 5); + } + expect(map.get("test")!.length).toBeLessThanOrEqual(5); + }); +}); + +describe("deduplicateAndTrim", () => { + it("returns empty array for empty input", () => { + expect(deduplicateAndTrim([], 100)).toEqual([]); + }); + + it("removes points closer than minGapMs", () => { + const points: HistoryPoint[] = [ + { time: 100, value: 1 }, + { time: 200, value: 2 }, + { time: 300, value: 3 }, + { time: 1100, value: 4 }, + ]; + const result = deduplicateAndTrim(points, 100, 500); + expect(result).toHaveLength(2); + expect(result[0]!.time).toBe(100); + expect(result[1]!.time).toBe(1100); + }); + + it("trims to maxPoints", () => { + const points: HistoryPoint[] = Array.from({ length: 20 }, (_, i) => ({ + time: i * 1000, + value: i, + })); + const result = deduplicateAndTrim(points, 5, 0); + expect(result).toHaveLength(5); + }); + + it("sorts unsorted input", () => { + const points: HistoryPoint[] = [ + { time: 300, value: 3 }, + { time: 100, value: 1 }, + { time: 200, value: 2 }, + ]; + const result = deduplicateAndTrim(points, 100, 0); + expect(result[0]!.time).toBe(100); + expect(result[2]!.time).toBe(300); + }); +}); diff --git a/tests/layout.test.ts b/tests/layout.test.ts new file mode 100644 index 0000000..dcf5ce9 --- /dev/null +++ b/tests/layout.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { tabToRow, tabToCol, classifyDualTab } from "../src/helpers/layout.js"; + +describe("tabToRow", () => { + it("maps odd tabs to correct rows", () => { + expect(tabToRow(1)).toBe(1); + expect(tabToRow(3)).toBe(2); + expect(tabToRow(5)).toBe(3); + }); + + it("maps even tabs to correct rows", () => { + expect(tabToRow(2)).toBe(1); + expect(tabToRow(4)).toBe(2); + expect(tabToRow(6)).toBe(3); + }); +}); + +describe("tabToCol", () => { + it("maps odd tabs to left column (0)", () => { + expect(tabToCol(1)).toBe(0); + expect(tabToCol(3)).toBe(0); + expect(tabToCol(5)).toBe(0); + }); + + it("maps even tabs to right column (1)", () => { + expect(tabToCol(2)).toBe(1); + expect(tabToCol(4)).toBe(1); + expect(tabToCol(6)).toBe(1); + }); +}); + +describe("classifyDualTab", () => { + it("returns null for non-dual tabs", () => { + expect(classifyDualTab([1])).toBeNull(); + expect(classifyDualTab([1, 2, 3])).toBeNull(); + }); + + it("returns row-span for adjacent tabs in same row", () => { + expect(classifyDualTab([1, 2])).toBe("row-span"); + expect(classifyDualTab([3, 4])).toBe("row-span"); + }); + + it("returns col-span for tabs in same column", () => { + expect(classifyDualTab([1, 3])).toBe("col-span"); + expect(classifyDualTab([2, 4])).toBe("col-span"); + }); + + it("handles unordered tab arrays", () => { + expect(classifyDualTab([2, 1])).toBe("row-span"); + expect(classifyDualTab([3, 1])).toBe("col-span"); + }); + + it("defaults to row-span for diagonal tabs", () => { + expect(classifyDualTab([1, 4])).toBe("row-span"); + }); +}); diff --git a/tests/monitoring-status.test.ts b/tests/monitoring-status.test.ts new file mode 100644 index 0000000..eb818c0 --- /dev/null +++ b/tests/monitoring-status.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { getCircuitMonitoringInfo, hasCustomOverrides, getUtilizationClass, isAlertActive } from "../src/core/monitoring-status.js"; +import type { MonitoringPointInfo, MonitoringStatus } from "../src/types.js"; + +describe("getCircuitMonitoringInfo", () => { + it("returns null when status is null", () => { + expect(getCircuitMonitoringInfo(null, "sensor.circuit_1_power")).toBeNull(); + }); + + it("returns null when circuits map is missing", () => { + const status: MonitoringStatus = {}; + expect(getCircuitMonitoringInfo(status, "sensor.circuit_1_power")).toBeNull(); + }); + + it("returns null when entity is not in circuits map", () => { + const status: MonitoringStatus = { circuits: {} }; + expect(getCircuitMonitoringInfo(status, "sensor.circuit_1_power")).toBeNull(); + }); + + it("returns monitoring info when entity exists", () => { + const info: MonitoringPointInfo = { utilization_pct: 55, monitoring_enabled: true }; + const status: MonitoringStatus = { + circuits: { "sensor.circuit_1_power": info }, + }; + expect(getCircuitMonitoringInfo(status, "sensor.circuit_1_power")).toBe(info); + }); +}); + +describe("hasCustomOverrides", () => { + it("returns false when info is null", () => { + expect(hasCustomOverrides(null)).toBe(false); + }); + + it("returns false when continuous_threshold_pct is undefined", () => { + const info: MonitoringPointInfo = { utilization_pct: 50 }; + expect(hasCustomOverrides(info)).toBe(false); + }); + + it("returns true when continuous_threshold_pct is defined", () => { + const info: MonitoringPointInfo = { continuous_threshold_pct: 80 }; + expect(hasCustomOverrides(info)).toBe(true); + }); +}); + +describe("getUtilizationClass", () => { + it("returns empty string when info is null", () => { + expect(getUtilizationClass(null)).toBe(""); + }); + + it("returns empty string when utilization_pct is undefined", () => { + const info: MonitoringPointInfo = {}; + expect(getUtilizationClass(info)).toBe(""); + }); + + it("returns empty string when utilization_pct is 0", () => { + const info: MonitoringPointInfo = { utilization_pct: 0 }; + expect(getUtilizationClass(info)).toBe(""); + }); + + it("returns utilization-normal for pct below 80", () => { + const info: MonitoringPointInfo = { utilization_pct: 50 }; + expect(getUtilizationClass(info)).toBe("utilization-normal"); + }); + + it("returns utilization-warning for pct at 80", () => { + const info: MonitoringPointInfo = { utilization_pct: 80 }; + expect(getUtilizationClass(info)).toBe("utilization-warning"); + }); + + it("returns utilization-warning for pct between 80 and 99", () => { + const info: MonitoringPointInfo = { utilization_pct: 95 }; + expect(getUtilizationClass(info)).toBe("utilization-warning"); + }); + + it("returns utilization-alert for pct at 100", () => { + const info: MonitoringPointInfo = { utilization_pct: 100 }; + expect(getUtilizationClass(info)).toBe("utilization-alert"); + }); + + it("returns utilization-alert for pct above 100", () => { + const info: MonitoringPointInfo = { utilization_pct: 120 }; + expect(getUtilizationClass(info)).toBe("utilization-alert"); + }); +}); + +describe("isAlertActive", () => { + it("returns false when info is null", () => { + expect(isAlertActive(null)).toBe(false); + }); + + it("returns false when over_threshold_since is null", () => { + const info: MonitoringPointInfo = { over_threshold_since: null }; + expect(isAlertActive(info)).toBe(false); + }); + + it("returns false when over_threshold_since is undefined", () => { + const info: MonitoringPointInfo = {}; + expect(isAlertActive(info)).toBe(false); + }); + + it("returns true when over_threshold_since is a timestamp string", () => { + const info: MonitoringPointInfo = { over_threshold_since: "2026-04-01T12:00:00Z" }; + expect(isAlertActive(info)).toBe(true); + }); +}); diff --git a/tests/sanitize.test.ts b/tests/sanitize.test.ts new file mode 100644 index 0000000..b9d07ce --- /dev/null +++ b/tests/sanitize.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { escapeHtml } from "../src/helpers/sanitize.js"; + +describe("escapeHtml", () => { + it("escapes ampersands", () => { + expect(escapeHtml("a&b")).toBe("a&b"); + }); + + it("escapes angle brackets", () => { + expect(escapeHtml("