From 7bd3f37fb99bbb50c831ae6c4396da6304f1a29e Mon Sep 17 00:00:00 2001
From: NoUsername10 <71009757+NoUsername10@users.noreply.github.com>
Date: Thu, 5 Nov 2026 21:38:04 +0100
Subject: [PATCH] Fix HA 2026.11 labels and add draft release workflow
Move the complete card project into the GitHub checkout. Replace removed ha-formfield wrappers, repair TypeScript errors, and add release checks and a verified 0.2.5 install ZIP workflow.
---
.github/workflows/release-checks.yml | 25 +
.github/workflows/release.yml | 128 +
.gitignore | 5 +
CHANGELOG.md | 8 +
RELEASE_NOTES.md | 16 +
dist/solar-panel-visualizer.js | 195 +-
package-lock.json | 1677 +++++
package.json | 31 +
scripts/package_release.py | 62 +
solar-panel-visualizer.js | 8583 ++++++++++++++++++++++++++
src/compare-graph.ts | 145 +
src/config.ts | 733 +++
src/custom-kpi.ts | 26 +
src/editor-autofill.ts | 1316 ++++
src/forecast.ts | 26 +
src/i18n.ts | 141 +
src/index.ts | 16 +
src/motion-engine.ts | 395 ++
src/motion-overlay.ts | 732 +++
src/motion-styles.ts | 305 +
src/natural-sort.ts | 17 +
src/panel-performance-label.ts | 52 +
src/panel-state.ts | 940 +++
src/popup-graph.ts | 268 +
src/solar-panel-visualizer-card.ts | 6335 +++++++++++++++++++
src/solar-panel-visualizer-editor.ts | 2127 +++++++
src/translations/en.json | 380 ++
src/types.ts | 187 +
test/card-metadata.test.ts | 17 +
test/compare-graph.test.ts | 130 +
test/config.test.ts | 582 ++
test/custom-kpi.test.ts | 22 +
test/editor-autofill.test.ts | 2128 +++++++
test/editor-markup.test.ts | 67 +
test/forecast.test.ts | 64 +
test/i18n.test.ts | 122 +
test/kpi-detail-layout.test.ts | 43 +
test/motion-overlay.test.ts | 250 +
test/natural-sort.test.ts | 36 +
test/panel-performance-label.test.ts | 65 +
test/panel-state.test.ts | 797 +++
test/popup-graph.test.ts | 192 +
test/popup-interaction.test.ts | 31 +
test/string-sharing-static.test.ts | 32 +
test/telemetry-graph.test.ts | 28 +
test/virtual-total-kpi.test.ts | 24 +
tsconfig.json | 17 +
vite.config.ts | 24 +
48 files changed, 29428 insertions(+), 114 deletions(-)
create mode 100644 .github/workflows/release-checks.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 .gitignore
create mode 100644 RELEASE_NOTES.md
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 scripts/package_release.py
create mode 100644 solar-panel-visualizer.js
create mode 100644 src/compare-graph.ts
create mode 100644 src/config.ts
create mode 100644 src/custom-kpi.ts
create mode 100644 src/editor-autofill.ts
create mode 100644 src/forecast.ts
create mode 100644 src/i18n.ts
create mode 100644 src/index.ts
create mode 100644 src/motion-engine.ts
create mode 100644 src/motion-overlay.ts
create mode 100644 src/motion-styles.ts
create mode 100644 src/natural-sort.ts
create mode 100644 src/panel-performance-label.ts
create mode 100644 src/panel-state.ts
create mode 100644 src/popup-graph.ts
create mode 100644 src/solar-panel-visualizer-card.ts
create mode 100644 src/solar-panel-visualizer-editor.ts
create mode 100644 src/translations/en.json
create mode 100644 src/types.ts
create mode 100644 test/card-metadata.test.ts
create mode 100644 test/compare-graph.test.ts
create mode 100644 test/config.test.ts
create mode 100644 test/custom-kpi.test.ts
create mode 100644 test/editor-autofill.test.ts
create mode 100644 test/editor-markup.test.ts
create mode 100644 test/forecast.test.ts
create mode 100644 test/i18n.test.ts
create mode 100644 test/kpi-detail-layout.test.ts
create mode 100644 test/motion-overlay.test.ts
create mode 100644 test/natural-sort.test.ts
create mode 100644 test/panel-performance-label.test.ts
create mode 100644 test/panel-state.test.ts
create mode 100644 test/popup-graph.test.ts
create mode 100644 test/popup-interaction.test.ts
create mode 100644 test/string-sharing-static.test.ts
create mode 100644 test/telemetry-graph.test.ts
create mode 100644 test/virtual-total-kpi.test.ts
create mode 100644 tsconfig.json
create mode 100644 vite.config.ts
diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml
new file mode 100644
index 0000000..89c2505
--- /dev/null
+++ b/.github/workflows/release-checks.yml
@@ -0,0 +1,25 @@
+name: Release checks
+
+on:
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ card:
+ name: Card, tests, and TypeScript
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: "22"
+ cache: npm
+ - run: npm ci
+ - run: npm test
+ - run: npm run typecheck
+ - run: npm run build:release
+ - run: node --check solar-panel-visualizer.js
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..1230869
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,128 @@
+name: Create Draft Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Existing version tag on main, for example 0.2.5"
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+concurrency:
+ group: draft-release-${{ inputs.tag }}
+ cancel-in-progress: false
+
+jobs:
+ preflight:
+ runs-on: ubuntu-24.04
+ outputs:
+ release_sha: ${{ steps.verify.outputs.release_sha }}
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ - name: Verify main, tag, version, and release notes
+ id: verify
+ env:
+ RELEASE_TAG: ${{ inputs.tag }}
+ run: |
+ if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
+ echo "Run Create Draft Release from the main branch." >&2
+ exit 1
+ fi
+ if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Enter a version such as 0.2.5, without a v prefix." >&2
+ exit 1
+ fi
+ git fetch origin main:refs/remotes/origin/main
+ git fetch origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
+ release_sha=$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")
+ if ! git merge-base --is-ancestor "$release_sha" origin/main; then
+ echo "Tag $RELEASE_TAG must point to a commit on main." >&2
+ exit 1
+ fi
+ tag_version=$(git show "$release_sha:package.json" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).version))')
+ if [[ "$tag_version" != "$RELEASE_TAG" ]]; then
+ echo "Tag $RELEASE_TAG does not match package.json version $tag_version." >&2
+ exit 1
+ fi
+ git show "$release_sha:CHANGELOG.md" | grep -Fq "## [$RELEASE_TAG] -"
+ git show "$release_sha:RELEASE_NOTES.md" | grep -Fq "# Solar Panel Visualizer $RELEASE_TAG"
+ echo "release_sha=$release_sha" >> "$GITHUB_OUTPUT"
+
+ checks:
+ needs: preflight
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ ref: ${{ needs.preflight.outputs.release_sha }}
+ - uses: actions/setup-node@v7
+ with:
+ node-version: "22"
+ cache: npm
+ - run: npm ci
+ - run: npm test
+ - run: npm run typecheck
+ - run: npm run build:release
+ - run: node --check solar-panel-visualizer.js
+ - name: Build and verify install package
+ env:
+ RELEASE_TAG: ${{ inputs.tag }}
+ run: python3 scripts/package_release.py --version "$RELEASE_TAG" --output-dir dist
+ - uses: actions/upload-artifact@v8
+ with:
+ name: solar-panel-visualizer-release
+ path: dist/solar-panel-visualizer-${{ inputs.tag }}.zip
+ if-no-files-found: error
+
+ validate-hacs:
+ needs: preflight
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ ref: ${{ needs.preflight.outputs.release_sha }}
+ - name: HACS plugin validation
+ uses: hacs/action@main
+ with:
+ category: plugin
+
+ draft-release:
+ needs: [preflight, checks, validate-hacs]
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ ref: ${{ needs.preflight.outputs.release_sha }}
+ - uses: actions/download-artifact@v8
+ with:
+ name: solar-panel-visualizer-release
+ path: dist
+ - name: Recheck tag before creating draft
+ env:
+ RELEASE_TAG: ${{ inputs.tag }}
+ RELEASE_SHA: ${{ needs.preflight.outputs.release_sha }}
+ run: |
+ git fetch origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
+ test "$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")" = "$RELEASE_SHA"
+ - name: Create unpublished draft
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_PROMPT_DISABLED: "1"
+ RELEASE_TAG: ${{ inputs.tag }}
+ run: |
+ if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
+ echo "A GitHub release already exists for $RELEASE_TAG." >&2
+ exit 1
+ fi
+ gh release create "$RELEASE_TAG" \
+ "dist/solar-panel-visualizer-$RELEASE_TAG.zip" \
+ --draft --verify-tag \
+ --title "Solar Panel Visualizer $RELEASE_TAG" \
+ --notes-file RELEASE_NOTES.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1159202
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+dist
+node_modules
+coverage
+.DS_Store
+*.tsbuildinfo
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d427aaf..fbe2a65 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [0.2.5] - 2026-11-05
+
+### Fixed
+- Restored GUI switch labels for Home Assistant 2026.11 and later by using the supported `ha-switch` label slot.
+- Added a tested GitHub Actions draft-release flow for the packaged HACS card.
+- Fixed strict TypeScript errors so type checking can run as a release gate.
+
## [0.2.4] - 2026-06-21
### Added
@@ -23,6 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- README setup documentation now explains the safer auto-fill behavior for power, energy, inverter/panel status, and advanced telemetry.
### Fixed
+- Added `waiting` to the default inverter/panel working status terms, so a plain `Waiting` state is treated as normal.
- Energy auto-fill and energy selectors now accept Home Assistant `device_class: energy`, so friendly-name searches such as `Yield Today` can find inverter daily yield sensors even when the unit is missing or unusual.
- Status auto-fill now tries clear one-to-one panel status matches before falling back to shared inverter/string status, fixing panels named like `sensor.power_1_0_#` with matching `sensor.status_1_0_#`.
- String setup now regenerates old automatic names such as `String 2 Panel 3` after layout changes, preventing duplicate stale panel names while preserving custom names.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
new file mode 100644
index 0000000..b62fe4f
--- /dev/null
+++ b/RELEASE_NOTES.md
@@ -0,0 +1,16 @@
+# Solar Panel Visualizer 0.2.5
+
+Release date: 2026-11-05
+
+This release restores the GUI setting labels in Home Assistant 2026.11 and adds a tested, manually reviewed draft-release workflow.
+
+## Fixed
+
+- Replaced removed `ha-formfield` wrappers with labels rendered by `ha-switch` across the editor and panel quick setup.
+- Corrected strict TypeScript errors and added type checking to the release checks.
+
+## Install
+
+Update Solar Panel Visualizer from HACS, then reload the Home Assistant frontend if HACS asks you to.
+
+No manual YAML changes are required. Existing card configurations continue to work.
diff --git a/dist/solar-panel-visualizer.js b/dist/solar-panel-visualizer.js
index dda3102..7418d39 100644
--- a/dist/solar-panel-visualizer.js
+++ b/dist/solar-panel-visualizer.js
@@ -9,6 +9,7 @@ const kr = "solar-panel-visualizer", Rt = `custom:${kr}`, Nt = {
"normal",
"ok",
"running",
+ "waiting",
"waiting for operation",
"producing"
], V = (i, e, t = 1, r = Y) => {
@@ -347,7 +348,7 @@ const kr = "solar-panel-visualizer", Rt = `custom:${kr}`, Nt = {
`\`panels[${r}].deviation_derate_percent\` must be between 1 and 100.`
);
}), e);
-}, Hs = { card_name: "Solar Panel Visualizer", card_description: "GUI-first solar array card with panel health, animated power rails, forecasts, history graphs, and light/dark themes." }, zs = { unavailable: "Unavailable", not_configured: "Not configured", disabled: "Disabled", unknown_recorder_error: "Unknown recorder error" }, Bs = { default_title: "Solar Array", eyebrow: "Solar Panel Visualizer", subtitle: { loading_history: "Loading shared {hours}h solar panel history...", warmup: "Deviation checks are warming up.", deviation_detected: "{count} panel{suffix} below expected output", tap_diagnostics: "Tap a panel for detailed diagnostics", drag_hint: "Drag and drop panel tiles to swap positions." }, summary: { power: "Power", energy: "Energy", alerts: "Alerts", system_sensor: "System sensor", sum_panel_sensors: "Sum of panel sensors", custom_sensor: "Custom KPI sensor", custom_default_title: "Custom KPI" }, panel: { hidden: "hidden", hidden_name: "Hidden Panel", hidden_performance: "Hidden", info_label: "Info", info_title: "Open panel details", inverter_prefix: "Status: {status}", inverter_short: { ok: "Status: OK", deviation: "Status: Deviation", error: "Status: Error" }, performance_compact: "{percent}%", performance_medium: "{percent}% of {rated}W", performance_full: "{percent}% of {rated}W Panel", string_label: "String {index}", slot_label: "R{row}C{column}", status: { normal: "normal", deviation: "deviation", inverter: "inverter", error: "error", offline: "offline", unconfigured: "unconfigured", disabled: "hidden" } }, popup: { close_detail: "Close detail", close_live_power: "Close live power detail", close_energy: "Close energy detail", close_custom_kpi: "Close custom KPI detail", close_system_health: "Close system health", panel_eyebrow: "Panel Detail {slot}", power_eyebrow: "Power Detail", power_title: "Live Power", energy_eyebrow: "Energy Detail", energy_title: "Energy", custom_eyebrow: "Custom KPI Detail", system_health_eyebrow: "System Health", system_health_title: "Overview", detail: { status: "Status", power: "Power", energy: "Energy", estimated_power_now: "Forecast production", estimated_energy_now: "Forecast production", deviation: "Deviation", rated_performance: "Rated / Performance", information: "Information", current: "Current", source: "Source" }, deviation: { inverter_mismatch: "Status mismatch", below_peers: "{percent}% below peers", within_range: "Within range" }, rated_performance: { format: "{rated} W / {percent}", na: "n/a" }, info: { power_source: "Power source: {value}", string_share: "{label}: {percent}% of {source}", current_inverter_status: 'Current status: "{value}"', inverter_evaluation: "Status evaluation: {value}", inverter_source: "Status source: {value}" }, inverter_eval: { no_status: "No status available", fault_match: "Fault term matched", working_match: "Working term matched", no_match: "No configured term matched" }, telemetry: { title: "Panel / Inverter Info", configured_title: "Configured", unconfigured_title: "Unconfigured", none_configured: "No advanced telemetry configured for this panel.", setup_hint: "Configure in Panels > Advanced telemetry.", label: { inverter_status: "Inverter / panel status", inverter_ac_power: "Inverter AC power", inverter_ac_voltage: "Inverter AC voltage", inverter_ac_current: "Inverter AC current", inverter_temp: "Inverter temperature", panel_current: "Panel current", panel_voltage: "Panel voltage", panel_power: "Panel power" } }, history: { power: "Power History", system_power: "System Power History", total_panel_power: "Total Panel Power History", system_energy: "System Energy History", total_panel_energy: "Total Panel Energy History", panel_power_values: "Panel Power Values", panel_energy_values: "Panel Energy Values", panel_compare: "Panel Performance Comparison", panel_compare_power: "Panel Power Comparison", panel_compare_energy: "Panel Energy Comparison", overlay_forecast: "Forecast", graph_not_configured: "No sensor configured for graph.", custom_not_configured: "No sensor configured for Custom KPI.", loading: "Loading sensor history...", no_data: "No history data for selected range.", unable_load: "Unable to load panel history ({error})", unable_load_plain: "Unable to load panel history", max: "Max {value}", median: "Median {value}", min: "Min {value}", time_range: "{start} - {end}" }, forecast: { enable_button: "Enable forecasts", disabled_hint: "Forecast overlay is disabled.", not_configured: "Forecast.Solar not configured.", default_sensor_not_found: "Default forecast sensor not found ({entity}).", power_compare_requires_system: "System power sensor is required for power forecast comparison.", energy_compare_requires_system: "System energy sensor is required for energy forecast comparison." }, panel_compare: { toggle: "Compare Panel Performance", toggle_power: "Compare Panel Power", toggle_energy: "Compare Panel Energy", loading: "Loading panel comparison history...", no_panels: "No configured panel sensors available for comparison.", no_panels_power: "No configured panel power sensors available for comparison.", no_panels_energy: "No configured panel energy sensors available for comparison.", no_data: "No comparison data for selected range.", unable_load: "Unable to load panel comparison history ({error}).", render_failure: "Comparison data loaded, but traces could not be drawn.", diagnostics_title: "Compare graph diagnostics (temporary)", diagnostics_summary: "model hasData={hasData}, drawable={drawable}, series={series}, range={range}h", diagnostics_reason_render_failure: "Series exist but no drawable traces were produced.", diagnostics_reason_suspect: "One or more series produced an invalid drawable shape.", diagnostics_row: "{label}: samples={samples}, points={points}, first={first}, last={last}, min={min}, max={max}" }, system_health: { everything_ok: "Everything is working well.", section: { inverter: "Inverter", error: "Error", deviation: "Deviation", offline: "Unavailable", setup: "Needs setup" }, item: "Panel on {slot} ({label}): {reason}" } }, quick_setup: { title: "Quick Setup:", select_power_sensor: "Select panel power sensor:", selector_label: "Select panel power sensor", no_sensors: "No available W sensors found.", disable_panel: "Disable Panel (hide but keep slot when off)" }, system_health_chip: { faults: "{count} Fault{suffix}", unavailable: "{count} Unavailable", deviation: "{count} Deviation", needs_setup: "{count} Needs Setup", ok: "System OK" } }, Us = { reason: { slot_hidden: "Panel slot is hidden in the card configuration.", select_power_sensor: "Select a power sensor to activate this panel slot.", power_entity_missing: "Power entity {entity} was not found.", power_entity_unavailable: "{entity} is unavailable.", inverter_fault_match: 'Current status: "{status}" matches configured fault terms.', inverter_working_mismatch: 'Current status: "{status}" does not match configured working terms.', producing_expected: "Producing within the expected array range.", producing_adjusted: "Producing within array-adjusted target range.", rated_not_configured: "Rated power not configured; excluded from deviation checks.", output_below_target: "Output is {percent}% and {shortfall} W below array target.", array_check_disabled: "Array Health Check is disabled.", need_non_derated_panels: "Need at least {count} non-derated active rated panels for deviation checks.", collecting_samples: "Collecting samples ({current}/{required}).", warmup_progress: "Warm-up in progress ({current}/{required} min).", low_light_pause: "Low-light pause: waiting above {floor} W target floor." }, status_display: { disabled: "Disabled", not_configured: "Not configured" }, energy: { default_unit: "kWh" }, power: { default_unit: "W" } }, Ws = { section: { layout_title: "Layout", layout_copy: "Set the array size first. Panel slots expand automatically from the row and column values.", display_title: "Display", display_copy: "Tune precision and panel detail behavior.", appearance_title: "Appearance", appearance_copy: "Auto follows the active Home Assistant theme. Force Light or Dark if a dashboard theme needs a specific card style.", forecast_title: "Forecast.Solar", forecast_copy: "Auto-detects Home Assistant default forecast sensors and overlays estimated production in Power/Energy KPI popups.", array_health_title: "Array Health Check", array_health_copy: "Automatically checks panel health by comparing active panels against each other using rated power, shared solar panel history, and configurable guardrails.", array_health_smoothing_help: "Smoothing window averages recent samples before checks; 0 means no smoothing.", inverter_title: "Status Checks", inverter_copy: "Track textual status from each panel’s inverter or panel status sensor. A panel turns red only when status text contains one of the configured fault terms. The current status is shown in the panel popup.", status_colors_title: "Status Colors", status_colors_copy: "Production colors blend based on panel output. Alert colors override the production scale.", motion_title: "Motion", motion_copy: "Animate live production with left-collector power rails, Power/Energy KPI impact effects, and repeated alert ripples. Motion automatically respects reduced-motion preferences.", panels_title: "Panels", panels_copy: "Each generated slot can be configured with its own power, energy, and optional inverter/panel status sensor. Disable a slot to hide that panel while keeping grid spacing.", panels_drag_hint: "In the card view, drag and drop panel tiles to swap their positions.", string_setup_title: "String of panels", string_setup_copy: "Use this when one power or energy sensor represents a full string of physical panels. The tree creates one visual row per string.", panel_defaults_title: "Panel default rated power", panel_defaults_copy: "Set a common default panel power and apply it to all panel slots.", autofill_title: "Auto-populate sensors", autofill_copy: "Fill panel sensors in slot order. Use sensor. for exact entity ID prefixes, or type friendly-name text to search sensor names.", advanced_title: "Advanced telemetry", advanced_copy: "Optional manual telemetry mappings shown in the panel popup when pressing INFO." }, field: { title: "Title", rows: "Rows", columns: "Columns", max_card_width: "Max card width (px)", max_card_height: "Max card height (px)", theme_mode: "Theme mode", power_decimals: "Power decimals", energy_decimals: "Energy decimals", custom_kpi_decimals: "Custom KPI decimals", panel_tap_action: "Panel tap action", system_power_sensor: "System power sensor (W)", system_energy_sensor: "System daily energy sensor", custom_kpi_sensor: "Custom KPI sensor", custom_kpi_heading: "Custom KPI heading", max_panel_tile_width: "Max panel tile width (px)", deviation_threshold: "Deviation threshold (%)", deviation_absolute_shortfall: "Absolute shortfall threshold (W)", deviation_check_time: "Deviation check time (minutes)", deviation_min_active_panels: "Minimum active panels", deviation_min_samples: "Minimum samples per panel", deviation_smoothing: "Smoothing window (minutes)", deviation_dynamic_floor: "Dynamic floor start (W)", deviation_history_window: "Shared history window (hours)", fault_terms: "Fault terms (comma-separated)", working_terms: "Working terms (comma-separated)", production_base: "Production base", production_mid: "Production mid", production_peak: "Production peak", deviation_color: "Deviation", error_color: "Error", unavailable_color: "Unavailable", production_intensity: "Production color intensity ({value})", default_panel_rated_power: "Default panel rated power (W)", power_prefix: "Power search", energy_prefix: "Energy search (optional)", status_prefix: "Status search (optional)", autofill_sort_mode: "Auto-fill sort order", string_group_count: "Number of strings", string_group_name: "String name", string_group_panel_count: "Number of panels", string_group_power_sensor: "String power sensor", string_group_energy_sensor: "String energy sensor (optional)", display_name: "Display name", power_sensor: "Power sensor P(W)", energy_sensor: "Energy sensor (kWh/Wh)", panel_rated_power: "Panel rated power (W)", deviation_derate: "Deviation derate (%)", inverter_status_sensor: "Inverter / panel status sensor (optional)", advanced_inverter_ac_power: "Inverter AC power (W)", advanced_inverter_ac_voltage: "Inverter AC voltage (V)", advanced_inverter_ac_current: "Inverter AC current (A)", advanced_inverter_temp: "Inverter temperature (°C/°F)", advanced_panel_current: "Panel current (A)", advanced_panel_voltage: "Panel voltage (V)", advanced_panel_power: "Panel power (W)", panel_energy_toggle: "Show panel energy", panel_enabled_toggle: "Show panel tile (hide but keep slot when off)" }, toggle: { use_system_power: "Use one system power sensor for top KPI", invert_system_power: "Invert system power value", use_system_energy: "Use one system daily energy sensor for top KPI", invert_custom_kpi: "Invert Custom KPI value", enable_forecast_overlay: "Enable forecast overlays in popups", motion_enabled: "Enable motion", motion_power_flow: "Power-rail flow to Power KPI", motion_update_shimmer: "Power/Energy KPI update effect", motion_alert_ripple: "Alert ripple for deviation/inverter/error", show_custom_kpi: "Show Custom KPI box", limit_panel_width: "Limit panel tile max width", enable_array_health: "Enable Array Health Check", enable_inverter_status: "Enable status checks", show_inverter_status_tiles: "Show status on panel tiles", enable_string_sensor_sharing: "String of panels / share duplicate sensors", autofill_inverter_status: "Fill inverter or panel status", autofill_advanced_telemetry: "Fill advanced telemetry" }, select: { theme_auto: "Auto", theme_dark: "Dark", theme_light: "Light", panel_tap_details: "Open detail popover", panel_tap_none: "No action", autofill_sort_auto: "Auto detect (recommended)", autofill_sort_entity: "Literal entity ID order", autofill_sort_friendly: "Literal friendly name order", autofill_sort_grouped: "Group by repeated suffix" }, button: { apply_default_rated_power: "Apply default rated W to all panels", autofill_sensors: "Auto-fill panel sensors", remove_all_sensors: "Remove all sensors" }, helper: { fault_example: "Example: fault, alarm, error, failed, failure, trip", working_example: "Working example: normal, ok, running, waiting for operation, producing", derate_help: "Used only by Array Health Check for naturally shaded panels.", autofill_search_help: "Search with sensor.panel_ to match entity IDs by prefix, or type words like Roof Panel or Daily Energy to search friendly names. Auto detect is recommended for most setups: it uses natural sorting and can group matching sensors by repeated serial/device parts or serial-like suffixes when that better matches the physical layout. Use the other sort modes only when Auto does not match your array.", string_sensor_sharing_help: "Turn this on to allow duplicate string sensors. Shared power and energy values are split across the generated panels by rated W, with equal split if rated W is missing.", autofill_inverter_status_help: "When enabled, status is filled from configured panel power sensors. The card first tries one-to-one panel status matches, then falls back to a shared inverter/string status sensor. Use Status search only when you need to narrow the candidates.", autofill_advanced_help: "When enabled, advanced telemetry is filled from configured panel power sensors by matching the same repeated serial/device/group. Existing advanced telemetry choices are preserved." }, forecast: { default_sensors: "Uses: sensor.power_production_now and sensor.energy_production_today.", line_help: "The forecast reference is a thin dashed line shown only for the selected history range up to the current time, with no future projection.", detected: "Forecast.Solar default sensors detected.", missing: "Forecast.Solar default sensors not fully detected. Expected: sensor.power_production_now and sensor.energy_production_today." }, panel_slot_title: "Row {row}, Column {column}", autofill: { enter_prefix: "Enter at least one prefix to run auto-fill.", power_summary: "Power matched {matched}, filled {filled}, skipped {skipped}.", energy_summary: "Energy matched {matched}, filled {filled}, skipped {skipped}.", inverter_status_summary: "Status filled {filled} panels, skipped {skipped} panels.", advanced_summary: "Advanced telemetry filled {filled} fields, skipped {skipped} panels.", cleared: "Cleared power, energy, inverter, and advanced telemetry sensors on all panels." }, string_group: { panel_count_chip: "{count} panels" } }, ts = {
+}, Hs = { card_name: "Solar Panel Visualizer", card_description: "GUI-first solar array card with panel health, animated power rails, forecasts, history graphs, and light/dark themes." }, zs = { unavailable: "Unavailable", not_configured: "Not configured", disabled: "Disabled", unknown_recorder_error: "Unknown recorder error" }, Bs = { default_title: "Solar Array", eyebrow: "Solar Panel Visualizer", subtitle: { loading_history: "Loading shared {hours}h solar panel history...", warmup: "Deviation checks are warming up.", deviation_detected: "{count} panel{suffix} below expected output", tap_diagnostics: "Tap a panel for detailed diagnostics", drag_hint: "Drag and drop panel tiles to swap positions." }, summary: { power: "Power", energy: "Energy", alerts: "Alerts", system_sensor: "System sensor", sum_panel_sensors: "Sum of panel sensors", custom_sensor: "Custom KPI sensor", custom_default_title: "Custom KPI" }, panel: { hidden: "hidden", hidden_name: "Hidden Panel", hidden_performance: "Hidden", info_label: "Info", info_title: "Open panel details", inverter_prefix: "Status: {status}", inverter_short: { ok: "Status: OK", deviation: "Status: Deviation", error: "Status: Error" }, performance_compact: "{percent}%", performance_medium: "{percent}% of {rated}W", performance_full: "{percent}% of {rated}W Panel", string_label: "String {index}", slot_label: "R{row}C{column}", status: { normal: "normal", deviation: "deviation", inverter: "inverter", error: "error", offline: "offline", unconfigured: "unconfigured", disabled: "hidden" } }, popup: { close_detail: "Close detail", close_live_power: "Close live power detail", close_energy: "Close energy detail", close_custom_kpi: "Close custom KPI detail", close_system_health: "Close system health", panel_eyebrow: "Panel Detail {slot}", power_eyebrow: "Power Detail", power_title: "Live Power", energy_eyebrow: "Energy Detail", energy_title: "Energy", custom_eyebrow: "Custom KPI Detail", system_health_eyebrow: "System Health", system_health_title: "Overview", detail: { status: "Status", power: "Power", energy: "Energy", estimated_power_now: "Forecast production", estimated_energy_now: "Forecast production", deviation: "Deviation", rated_performance: "Rated / Performance", information: "Information", current: "Current", source: "Source" }, deviation: { inverter_mismatch: "Status mismatch", below_peers: "{percent}% below peers", within_range: "Within range" }, rated_performance: { format: "{rated} W / {percent}", na: "n/a" }, info: { power_source: "Power source: {value}", string_share: "{label}: {percent}% of {source}", current_inverter_status: 'Current status: "{value}"', inverter_evaluation: "Status evaluation: {value}", inverter_source: "Status source: {value}" }, inverter_eval: { no_status: "No status available", fault_match: "Fault term matched", working_match: "Working term matched", no_match: "No configured term matched" }, telemetry: { title: "Panel / Inverter Info", configured_title: "Configured", unconfigured_title: "Unconfigured", none_configured: "No advanced telemetry configured for this panel.", setup_hint: "Configure in Panels > Advanced telemetry.", label: { inverter_status: "Inverter / panel status", inverter_ac_power: "Inverter AC power", inverter_ac_voltage: "Inverter AC voltage", inverter_ac_current: "Inverter AC current", inverter_temp: "Inverter temperature", panel_current: "Panel current", panel_voltage: "Panel voltage", panel_power: "Panel power" } }, history: { power: "Power History", system_power: "System Power History", total_panel_power: "Total Panel Power History", system_energy: "System Energy History", total_panel_energy: "Total Panel Energy History", panel_power_values: "Panel Power Values", panel_energy_values: "Panel Energy Values", panel_compare: "Panel Performance Comparison", panel_compare_power: "Panel Power Comparison", panel_compare_energy: "Panel Energy Comparison", overlay_forecast: "Forecast", graph_not_configured: "No sensor configured for graph.", custom_not_configured: "No sensor configured for Custom KPI.", loading: "Loading sensor history...", no_data: "No history data for selected range.", unable_load: "Unable to load panel history ({error})", unable_load_plain: "Unable to load panel history", max: "Max {value}", median: "Median {value}", min: "Min {value}", time_range: "{start} - {end}" }, forecast: { enable_button: "Enable forecasts", disabled_hint: "Forecast overlay is disabled.", not_configured: "Forecast.Solar not configured.", default_sensor_not_found: "Default forecast sensor not found ({entity}).", power_compare_requires_system: "System power sensor is required for power forecast comparison.", energy_compare_requires_system: "System energy sensor is required for energy forecast comparison." }, panel_compare: { toggle: "Compare Panel Performance", toggle_power: "Compare Panel Power", toggle_energy: "Compare Panel Energy", loading: "Loading panel comparison history...", no_panels: "No configured panel sensors available for comparison.", no_panels_power: "No configured panel power sensors available for comparison.", no_panels_energy: "No configured panel energy sensors available for comparison.", no_data: "No comparison data for selected range.", unable_load: "Unable to load panel comparison history ({error}).", render_failure: "Comparison data loaded, but traces could not be drawn.", diagnostics_title: "Compare graph diagnostics (temporary)", diagnostics_summary: "model hasData={hasData}, drawable={drawable}, series={series}, range={range}h", diagnostics_reason_render_failure: "Series exist but no drawable traces were produced.", diagnostics_reason_suspect: "One or more series produced an invalid drawable shape.", diagnostics_row: "{label}: samples={samples}, points={points}, first={first}, last={last}, min={min}, max={max}" }, system_health: { everything_ok: "Everything is working well.", section: { inverter: "Inverter", error: "Error", deviation: "Deviation", offline: "Unavailable", setup: "Needs setup" }, item: "Panel on {slot} ({label}): {reason}" } }, quick_setup: { title: "Quick Setup:", select_power_sensor: "Select panel power sensor:", selector_label: "Select panel power sensor", no_sensors: "No available W sensors found.", disable_panel: "Disable Panel (hide but keep slot when off)" }, system_health_chip: { faults: "{count} Fault{suffix}", unavailable: "{count} Unavailable", deviation: "{count} Deviation", needs_setup: "{count} Needs Setup", ok: "System OK" } }, Us = { reason: { slot_hidden: "Panel slot is hidden in the card configuration.", select_power_sensor: "Select a power sensor to activate this panel slot.", power_entity_missing: "Power entity {entity} was not found.", power_entity_unavailable: "{entity} is unavailable.", inverter_fault_match: 'Current status: "{status}" matches configured fault terms.', inverter_working_mismatch: 'Current status: "{status}" does not match configured working terms.', producing_expected: "Producing within the expected array range.", producing_adjusted: "Producing within array-adjusted target range.", rated_not_configured: "Rated power not configured; excluded from deviation checks.", output_below_target: "Output is {percent}% and {shortfall} W below array target.", array_check_disabled: "Array Health Check is disabled.", need_non_derated_panels: "Need at least {count} non-derated active rated panels for deviation checks.", collecting_samples: "Collecting samples ({current}/{required}).", warmup_progress: "Warm-up in progress ({current}/{required} min).", low_light_pause: "Low-light pause: waiting above {floor} W target floor." }, status_display: { disabled: "Disabled", not_configured: "Not configured" }, energy: { default_unit: "kWh" }, power: { default_unit: "W" } }, Ws = { section: { layout_title: "Layout", layout_copy: "Set the array size first. Panel slots expand automatically from the row and column values.", display_title: "Display", display_copy: "Tune precision and panel detail behavior.", appearance_title: "Appearance", appearance_copy: "Auto follows the active Home Assistant theme. Force Light or Dark if a dashboard theme needs a specific card style.", forecast_title: "Forecast.Solar", forecast_copy: "Auto-detects Home Assistant default forecast sensors and overlays estimated production in Power/Energy KPI popups.", array_health_title: "Array Health Check", array_health_copy: "Automatically checks panel health by comparing active panels against each other using rated power, shared solar panel history, and configurable guardrails.", array_health_smoothing_help: "Smoothing window averages recent samples before checks; 0 means no smoothing.", inverter_title: "Status Checks", inverter_copy: "Track textual status from each panel’s inverter or panel status sensor. A panel turns red only when status text contains one of the configured fault terms. The current status is shown in the panel popup.", status_colors_title: "Status Colors", status_colors_copy: "Production colors blend based on panel output. Alert colors override the production scale.", motion_title: "Motion", motion_copy: "Animate live production with left-collector power rails, Power/Energy KPI impact effects, and repeated alert ripples. Motion automatically respects reduced-motion preferences.", panels_title: "Panels", panels_copy: "Each generated slot can be configured with its own power, energy, and optional inverter/panel status sensor. Disable a slot to hide that panel while keeping grid spacing.", panels_drag_hint: "In the card view, drag and drop panel tiles to swap their positions.", string_setup_title: "String of panels", string_setup_copy: "Use this when one power or energy sensor represents a full string of physical panels. The tree creates one visual row per string.", panel_defaults_title: "Panel default rated power", panel_defaults_copy: "Set a common default panel power and apply it to all panel slots.", autofill_title: "Auto-populate sensors", autofill_copy: "Fill panel sensors in slot order. Use sensor. for exact entity ID prefixes, or type friendly-name text to search sensor names.", advanced_title: "Advanced telemetry", advanced_copy: "Optional manual telemetry mappings shown in the panel popup when pressing INFO." }, field: { title: "Title", rows: "Rows", columns: "Columns", max_card_width: "Max card width (px)", max_card_height: "Max card height (px)", theme_mode: "Theme mode", power_decimals: "Power decimals", energy_decimals: "Energy decimals", custom_kpi_decimals: "Custom KPI decimals", panel_tap_action: "Panel tap action", system_power_sensor: "System power sensor (W)", system_energy_sensor: "System daily energy sensor", custom_kpi_sensor: "Custom KPI sensor", custom_kpi_heading: "Custom KPI heading", max_panel_tile_width: "Max panel tile width (px)", deviation_threshold: "Deviation threshold (%)", deviation_absolute_shortfall: "Absolute shortfall threshold (W)", deviation_check_time: "Deviation check time (minutes)", deviation_min_active_panels: "Minimum active panels", deviation_min_samples: "Minimum samples per panel", deviation_smoothing: "Smoothing window (minutes)", deviation_dynamic_floor: "Dynamic floor start (W)", deviation_history_window: "Shared history window (hours)", fault_terms: "Fault terms (comma-separated)", working_terms: "Working terms (comma-separated)", production_base: "Production base", production_mid: "Production mid", production_peak: "Production peak", deviation_color: "Deviation", error_color: "Error", unavailable_color: "Unavailable", production_intensity: "Production color intensity ({value})", default_panel_rated_power: "Default panel rated power (W)", power_prefix: "Power search", energy_prefix: "Energy search (optional)", status_prefix: "Status search (optional)", autofill_sort_mode: "Auto-fill sort order", string_group_count: "Number of strings", string_group_name: "String name", string_group_panel_count: "Number of panels", string_group_power_sensor: "String power sensor", string_group_energy_sensor: "String energy sensor (optional)", display_name: "Display name", power_sensor: "Power sensor P(W)", energy_sensor: "Energy sensor (kWh/Wh)", panel_rated_power: "Panel rated power (W)", deviation_derate: "Deviation derate (%)", inverter_status_sensor: "Inverter / panel status sensor (optional)", advanced_inverter_ac_power: "Inverter AC power (W)", advanced_inverter_ac_voltage: "Inverter AC voltage (V)", advanced_inverter_ac_current: "Inverter AC current (A)", advanced_inverter_temp: "Inverter temperature (°C/°F)", advanced_panel_current: "Panel current (A)", advanced_panel_voltage: "Panel voltage (V)", advanced_panel_power: "Panel power (W)", panel_energy_toggle: "Show panel energy", panel_enabled_toggle: "Show panel tile (hide but keep slot when off)" }, toggle: { use_system_power: "Use one system power sensor for top KPI", invert_system_power: "Invert system power value", use_system_energy: "Use one system daily energy sensor for top KPI", invert_custom_kpi: "Invert Custom KPI value", enable_forecast_overlay: "Enable forecast overlays in popups", motion_enabled: "Enable motion", motion_power_flow: "Power-rail flow to Power KPI", motion_update_shimmer: "Power/Energy KPI update effect", motion_alert_ripple: "Alert ripple for deviation/inverter/error", show_custom_kpi: "Show Custom KPI box", limit_panel_width: "Limit panel tile max width", enable_array_health: "Enable Array Health Check", enable_inverter_status: "Enable status checks", show_inverter_status_tiles: "Show status on panel tiles", enable_string_sensor_sharing: "String of panels / share duplicate sensors", autofill_inverter_status: "Fill inverter or panel status", autofill_advanced_telemetry: "Fill advanced telemetry" }, select: { theme_auto: "Auto", theme_dark: "Dark", theme_light: "Light", panel_tap_details: "Open detail popover", panel_tap_none: "No action", autofill_sort_auto: "Auto detect (recommended)", autofill_sort_entity: "Literal entity ID order", autofill_sort_friendly: "Literal friendly name order", autofill_sort_grouped: "Group by repeated suffix" }, button: { apply_default_rated_power: "Apply default rated W to all panels", autofill_sensors: "Auto-fill panel sensors", remove_all_sensors: "Remove all sensors" }, helper: { fault_example: "Example: fault, alarm, error, failed, failure, trip", working_example: "Working example: normal, ok, running, waiting, waiting for operation, producing", derate_help: "Used only by Array Health Check for naturally shaded panels.", autofill_search_help: "Search with sensor.panel_ to match entity IDs by prefix, or type words like Roof Panel or Daily Energy to search friendly names. Auto detect is recommended for most setups: it uses natural sorting and can group matching sensors by repeated serial/device parts or serial-like suffixes when that better matches the physical layout. Use the other sort modes only when Auto does not match your array.", string_sensor_sharing_help: "Turn this on to allow duplicate string sensors. Shared power and energy values are split across the generated panels by rated W, with equal split if rated W is missing.", autofill_inverter_status_help: "When enabled, status is filled from configured panel power sensors. The card first tries one-to-one panel status matches, then falls back to a shared inverter/string status sensor. Use Status search only when you need to narrow the candidates.", autofill_advanced_help: "When enabled, advanced telemetry is filled from configured panel power sensors by matching the same repeated serial/device/group. Existing advanced telemetry choices are preserved." }, forecast: { default_sensors: "Uses: sensor.power_production_now and sensor.energy_production_today.", line_help: "The forecast reference is a thin dashed line shown only for the selected history range up to the current time, with no future projection.", detected: "Forecast.Solar default sensors detected.", missing: "Forecast.Solar default sensors not fully detected. Expected: sensor.power_production_now and sensor.energy_production_today." }, panel_slot_title: "Row {row}, Column {column}", autofill: { enter_prefix: "Enter at least one prefix to run auto-fill.", power_summary: "Power matched {matched}, filled {filled}, skipped {skipped}.", energy_summary: "Energy matched {matched}, filled {filled}, skipped {skipped}.", inverter_status_summary: "Status filled {filled} panels, skipped {skipped} panels.", advanced_summary: "Advanced telemetry filled {filled} fields, skipped {skipped} panels.", cleared: "Cleared power, energy, inverter, and advanced telemetry sensors on all panels." }, string_group: { panel_count_chip: "{count} panels" } }, ts = {
meta: Hs,
common: zs,
card: Bs,
@@ -2026,7 +2027,9 @@ const Q = (i, e, t) => Math.max(e, Math.min(t, i)), lr = (i, e) => Math.abs(i -
(_) => _.status === "normal" && _.power !== null
).map((_) => _.power ?? 0), m = R.length > 0 ? Math.max(...R) : 0;
let P = 0, k = 0, F = 0, A = 0, O = 0, se = 0, b = 0, C, W = !1;
- const pe = E.filter((_) => _.status === "normal" && _.power !== null).flatMap((_) => {
+ const pe = E.flatMap((_) => {
+ if (_.status !== "normal" || _.power === null)
+ return [];
const N = _.config.rated_power_w ?? i.default_panel_rated_power_w ?? null;
if (N === null || N <= 0 || !_.config.power_entity)
return [];
@@ -2526,8 +2529,8 @@ const Q = (i, e, t) => Math.max(e, Math.min(t, i)), lr = (i, e) => Math.abs(i -
return {
totalPower: e.totalPower,
popupOpen: this._hasPopupOpen(),
- powerKpiValue: this._resolveSummaryPower(e).value?.toString() ?? this._t("state.unavailable"),
- energyKpiValue: this._resolveSummaryEnergy(e).value?.toString() ?? this._t("state.unavailable"),
+ powerKpiValue: this._resolveSummaryPower(e).value?.toString() ?? this._t("common.unavailable"),
+ energyKpiValue: this._resolveSummaryEnergy(e).value?.toString() ?? this._t("common.unavailable"),
panelPowers: t,
panelStatuses: r
};
@@ -4198,7 +4201,7 @@ const Q = (i, e, t) => Math.max(e, Math.min(t, i)), lr = (i, e) => Math.abs(i -
return !r || r.length === 0 ? null : r;
}
_stabilizePopupRangeSamples(e, t, r, s, n) {
- const o = s - n * 60 * 60 * 1e3, l = [...t].sort((u, g) => u.ts - g.ts), a = [...r].sort((u, g) => u.ts - g.ts), c = l.filter((u) => u.ts < o).at(-1) ?? null, p = this._isForecastDefaultEntity(e), d = Number(this.hass?.states?.[e]?.state);
+ const o = s - n * 60 * 60 * 1e3, l = [...t].sort((u, g) => u.ts - g.ts), a = [...r].sort((u, g) => u.ts - g.ts), c = l.filter((u) => u.ts < o).pop() ?? null, p = this._isForecastDefaultEntity(e), d = Number(this.hass?.states?.[e]?.state);
if (a.length === 0)
p && Number.isFinite(d) ? a.push({ ts: o, value: d }) : c ? a.push({ ts: o, value: c.value }) : Number.isFinite(d) && a.push({ ts: o, value: d });
else if (a[0].ts > o && (c || p)) {
@@ -4611,7 +4614,7 @@ const Q = (i, e, t) => Math.max(e, Math.min(t, i)), lr = (i, e) => Math.abs(i -
}
_clearPopupScrollRestore() {
const e = this._popupScrollRestore;
- e?.frameId !== null && typeof window < "u" && window.cancelAnimationFrame(e.frameId), this._popupScrollRestore = null, this._popupScrollRestoreToken++;
+ e && e.frameId !== null && typeof window < "u" && window.cancelAnimationFrame(e.frameId), this._popupScrollRestore = null, this._popupScrollRestoreToken++;
}
_captureScrollPositionsForPopupGraph(e) {
const s = (e?.currentTarget instanceof HTMLElement ? e.currentTarget : e?.target instanceof HTMLElement ? e.target : null)?.closest(".spv-popup") ?? null ?? this.renderRoot.querySelector(".spv-popup");
@@ -4684,16 +4687,14 @@ const Q = (i, e, t) => Math.max(e, Math.min(t, i)), lr = (i, e) => Math.abs(i -
>
${r.length === 0 ? y`
No available W sensors found.
` : w}
-
- this._updatePanelConfigFromCard(
+ this._updatePanelConfigFromCard(
e.id,
"enabled",
!n.currentTarget.checked
)}
- >
-
+ >Disable Panel (hide but keep slot when off)
`;
@@ -7232,15 +7233,13 @@ const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i,
)}
-
- this._updateRootValue(
+ this._updateRootValue(
"use_system_power_entity",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.use_system_power")}
${this._renderSelectorWithClear(
this._t("editor.field.system_power_sensor"),
@@ -7253,26 +7252,22 @@ const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i,
(s) => this._updateRootValue("system_power_entity", s)
)}
-
- this._updateRootValue(
+ this._updateRootValue(
"invert_system_power",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.invert_system_power")}
-
- this._updateRootValue(
+ this._updateRootValue(
"use_system_energy_entity",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.use_system_energy")}
${this._renderSelectorWithClear(
this._t("editor.field.system_energy_sensor"),
@@ -7295,15 +7290,13 @@ const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i,
(s) => this._updateRootValue("custom_kpi_entity", s)
)}
-
- this._updateRootValue(
+ this._updateRootValue(
"invert_custom_kpi",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.invert_custom_kpi")}
${this._renderTextField(
"custom_kpi_title",
@@ -7311,26 +7304,22 @@ const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i,
this._config.custom_kpi_title ?? ""
)}
-
- this._updateRootValue(
+ this._updateRootValue(
"show_custom_kpi",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.show_custom_kpi")}
-
- this._updateRootValue(
+ this._updateRootValue(
"limit_panel_width",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.limit_panel_width")}
${this._renderEditorInput(
this._t("editor.field.max_panel_tile_width"),
@@ -7368,15 +7357,13 @@ const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i,
-
- this._updateRootValue(
+ this._updateRootValue(
"enable_forecast_overlay",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.enable_forecast_overlay")}
${r}
@@ -7389,15 +7376,13 @@ const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i,
-
- this._updateRootValue(
+ this._updateRootValue(
"enable_array_checks",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.enable_array_health")}
${this._t("editor.section.array_health_smoothing_help")}
@@ -7470,24 +7455,20 @@ const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i,
-
- this._updateRootValue(
+ this._updateRootValue(
"enable_inverter_status",
s.currentTarget.checked
)}
- >
-
-
- this._updateRootValue(
+ >${this._t("editor.toggle.enable_inverter_status")}
+ this._updateRootValue(
"show_inverter_status_on_tiles",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.show_inverter_status_tiles")}
${this._renderEditorInput(
this._t("editor.field.fault_terms"),
@@ -7556,48 +7537,40 @@ const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i,
-
- this._updateRootValue(
+ this._updateRootValue(
"motion_enabled",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.motion_enabled")}
-
- this._updateRootValue(
+ this._updateRootValue(
"motion_power_flow",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.motion_power_flow")}
-
- this._updateRootValue(
+ this._updateRootValue(
"motion_update_shimmer",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.motion_update_shimmer")}
-
- this._updateRootValue(
+ this._updateRootValue(
"motion_alert_ripple",
s.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.motion_alert_ripple")}
@@ -7730,14 +7703,12 @@ const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i,
-
- this._handleStringSensorSharingToggle(
+ this._handleStringSensorSharingToggle(
r.currentTarget.checked
)}
- >
-
+ >${this._t("editor.toggle.enable_string_sensor_sharing")}
${this._t("editor.helper.string_sensor_sharing_help")}
@@ -7871,16 +7842,14 @@ const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i,
s
)}
-
- this._updatePanelValue(
+ this._updatePanelValue(
t,
"show_energy",
n.currentTarget.checked
)}
- >
-
+ >${this._t("editor.field.panel_energy_toggle")}
${this._renderPanelOptionalNumberField(
t,
@@ -7910,16 +7879,14 @@ const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i,
)}
${this._renderPanelAdvancedSection(t, e)}
-
- this._updatePanelValue(
+ this._updatePanelValue(
t,
"enabled",
n.currentTarget.checked
)}
- >
-
+ >${this._t("editor.field.panel_enabled_toggle")}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..48b8996
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1677 @@
+{
+ "name": "solar-panel-visualizer",
+ "version": "0.2.5",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "solar-panel-visualizer",
+ "version": "0.2.5",
+ "license": "MIT",
+ "dependencies": {
+ "lit": "^3.3.0"
+ },
+ "devDependencies": {
+ "@types/node": "^24.0.0",
+ "typescript": "^5.9.2",
+ "vite": "^7.1.3",
+ "vitest": "^3.2.4"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@lit-labs/ssr-dom-shim": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz",
+ "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@lit/reactive-element": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz",
+ "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit-labs/ssr-dom-shim": "^1.5.0"
+ }
+ },
+ "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",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "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/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/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.12.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
+ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "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/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.3",
+ "@esbuild/android-arm": "0.27.3",
+ "@esbuild/android-arm64": "0.27.3",
+ "@esbuild/android-x64": "0.27.3",
+ "@esbuild/darwin-arm64": "0.27.3",
+ "@esbuild/darwin-x64": "0.27.3",
+ "@esbuild/freebsd-arm64": "0.27.3",
+ "@esbuild/freebsd-x64": "0.27.3",
+ "@esbuild/linux-arm": "0.27.3",
+ "@esbuild/linux-arm64": "0.27.3",
+ "@esbuild/linux-ia32": "0.27.3",
+ "@esbuild/linux-loong64": "0.27.3",
+ "@esbuild/linux-mips64el": "0.27.3",
+ "@esbuild/linux-ppc64": "0.27.3",
+ "@esbuild/linux-riscv64": "0.27.3",
+ "@esbuild/linux-s390x": "0.27.3",
+ "@esbuild/linux-x64": "0.27.3",
+ "@esbuild/netbsd-arm64": "0.27.3",
+ "@esbuild/netbsd-x64": "0.27.3",
+ "@esbuild/openbsd-arm64": "0.27.3",
+ "@esbuild/openbsd-x64": "0.27.3",
+ "@esbuild/openharmony-arm64": "0.27.3",
+ "@esbuild/sunos-x64": "0.27.3",
+ "@esbuild/win32-arm64": "0.27.3",
+ "@esbuild/win32-ia32": "0.27.3",
+ "@esbuild/win32-x64": "0.27.3"
+ }
+ },
+ "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/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/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/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lit": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz",
+ "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit/reactive-element": "^2.1.0",
+ "lit-element": "^4.2.0",
+ "lit-html": "^3.3.0"
+ }
+ },
+ "node_modules/lit-element": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz",
+ "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit-labs/ssr-dom-shim": "^1.5.0",
+ "@lit/reactive-element": "^2.1.0",
+ "lit-html": "^3.3.0"
+ }
+ },
+ "node_modules/lit-html": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz",
+ "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@types/trusted-types": "^2.0.2"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "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/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/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "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": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "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/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "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/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/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": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "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": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "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",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.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
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "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": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "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"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0058589
--- /dev/null
+++ b/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "solar-panel-visualizer",
+ "version": "0.2.5",
+ "private": true,
+ "type": "module",
+ "description": "Home Assistant Lovelace custom card for visualizing solar panel arrays.",
+ "scripts": {
+ "build": "vite build",
+ "build:release": "npm run build && cp dist/solar-panel-visualizer.js solar-panel-visualizer.js",
+ "typecheck": "tsc --noEmit",
+ "dev": "vite build --watch",
+ "test": "vitest run"
+ },
+ "keywords": [
+ "home-assistant",
+ "lovelace",
+ "custom-card",
+ "hacs",
+ "solar"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lit": "^3.3.0"
+ },
+ "devDependencies": {
+ "@types/node": "^24.0.0",
+ "typescript": "^5.9.2",
+ "vite": "^7.1.3",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/scripts/package_release.py b/scripts/package_release.py
new file mode 100644
index 0000000..ec9001b
--- /dev/null
+++ b/scripts/package_release.py
@@ -0,0 +1,62 @@
+#!/usr/bin/env python3
+"""Build and validate the installable Solar Panel Visualizer release ZIP."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+from pathlib import Path
+from zipfile import ZIP_DEFLATED, ZipFile
+
+
+RELEASE_FILES = (
+ "solar-panel-visualizer.js",
+ "hacs.json",
+ "README.md",
+ "LICENSE",
+)
+VERSION_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--version", required=True)
+ parser.add_argument("--output-dir", type=Path, default=Path("dist"))
+ args = parser.parse_args()
+
+ if not VERSION_PATTERN.fullmatch(args.version):
+ raise SystemExit("Version must use plain MAJOR.MINOR.PATCH format, such as 0.2.5.")
+
+ root = Path.cwd()
+ package = json.loads((root / "package.json").read_text())
+ hacs = json.loads((root / "hacs.json").read_text())
+ if package.get("version") != args.version:
+ raise SystemExit(
+ f"package.json version {package.get('version')} does not match {args.version}."
+ )
+ if hacs.get("filename") != RELEASE_FILES[0]:
+ raise SystemExit("hacs.json filename does not match the packaged card bundle.")
+
+ missing = [name for name in RELEASE_FILES if not (root / name).is_file()]
+ if missing:
+ raise SystemExit(f"Missing release files: {', '.join(missing)}")
+
+ args.output_dir.mkdir(parents=True, exist_ok=True)
+ archive_path = args.output_dir / f"solar-panel-visualizer-{args.version}.zip"
+ with ZipFile(archive_path, "w", compression=ZIP_DEFLATED) as archive:
+ for name in RELEASE_FILES:
+ archive.write(root / name, arcname=name)
+
+ with ZipFile(archive_path) as archive:
+ if archive.namelist() != list(RELEASE_FILES):
+ raise SystemExit("Release ZIP contents do not match the install package manifest.")
+ corrupt_file = archive.testzip()
+ if corrupt_file is not None:
+ raise SystemExit(f"Release ZIP failed CRC validation at {corrupt_file}.")
+
+ print(f"Created and verified {archive_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/solar-panel-visualizer.js b/solar-panel-visualizer.js
new file mode 100644
index 0000000..7418d39
--- /dev/null
+++ b/solar-panel-visualizer.js
@@ -0,0 +1,8583 @@
+const kr = "solar-panel-visualizer", Rt = `custom:${kr}`, Nt = {
+ production_start: "#8dcf72",
+ production_mid: "#bfe36a",
+ production_end: "#ffd35a",
+ deviation: "#ff9667",
+ error: "#ff627f",
+ unavailable: "#586779"
+}, Ir = 2, pt = 3, Fr = 15, Tr = 50, Mr = 3, Ar = 3, Rr = 20, Nr = 0, Lr = 30, Dr = 12, Or = 0, Gr = 2, Hr = 0, zr = !1, Br = 220, Ur = 980, Wr = 1, Kr = !1, Vr = !0, jr = !0, Yr = !0, qr = !0, Xr = "auto", dt = 1e4, Y = 30, Qr = ["fault", "alarm", "error", "failed", "failure", "trip"], Zr = [
+ "normal",
+ "ok",
+ "running",
+ "waiting",
+ "waiting for operation",
+ "producing"
+], V = (i, e, t = 1, r = Y) => {
+ const s = Number(i);
+ return Number.isFinite(s) ? Math.min(Math.max(Math.round(s), t), r) : e;
+}, ze = (i, e, t = 0, r = 100) => {
+ const s = Number(i);
+ return Number.isFinite(s) ? Math.min(Math.max(s, t), r) : e;
+}, G = (i) => {
+ if (typeof i != "string")
+ return;
+ const e = i.trim();
+ return e.length > 0 ? e : void 0;
+}, $s = (i) => {
+ if (typeof i == "string")
+ return i.trim();
+}, H = (i, e) => typeof i == "boolean" ? i : e, Wt = (i, e) => {
+ const r = (Array.isArray(i) ? i : typeof i == "string" ? i.split(",") : []).map((s) => typeof s == "string" ? s.trim().toLowerCase() : "").filter((s) => s.length > 0);
+ return r.length === 0 ? [...e] : [...new Set(r)];
+}, Kt = (i, e, t) => {
+ if (i == null || i === "")
+ return;
+ const r = Number(i);
+ if (Number.isFinite(r))
+ return Math.min(Math.max(r, e), t);
+}, Jr = (i, e, t) => {
+ if (i == null || i === "")
+ return;
+ const r = Number(i);
+ if (!(!Number.isFinite(r) || r < e))
+ return Math.min(r, t);
+}, Cs = (i) => `panel-${i + 1}`, Es = (i) => i === "none" ? "none" : "details", ks = (i) => i === "dark" || i === "light" ? i : Xr, Is = (i) => {
+ if (typeof i != "object" || i === null)
+ return;
+ const e = i, t = e.columns === "full" || typeof e.columns == "number" ? e.columns : void 0, r = e.rows === "auto" || typeof e.rows == "number" ? e.rows : void 0;
+ if (!(t === void 0 && r === void 0))
+ return {
+ columns: t,
+ rows: r
+ };
+}, Fs = [
+ "inverter_ac_power_entity",
+ "inverter_ac_voltage_entity",
+ "inverter_ac_current_entity",
+ "inverter_temp_entity",
+ "panel_current_entity",
+ "panel_voltage_entity",
+ "panel_power_entity"
+], Ts = (i, e) => {
+ if (!e || typeof i != "object" || i === null)
+ return;
+ const t = i, r = {};
+ let s = !1;
+ for (const n of Fs) {
+ const o = G(t[n]);
+ r[n] = o, o && (s = !0);
+ }
+ if (s)
+ return r;
+}, Ft = (i, e) => {
+ const t = H(i?.enabled, !0);
+ return {
+ id: G(i?.id) ?? Cs(e),
+ name: G(i?.name),
+ power_entity: t ? G(i?.power_entity) : void 0,
+ energy_entity: t ? G(i?.energy_entity) : void 0,
+ show_energy: H(i?.show_energy, !1),
+ inverter_status_entity: t ? G(i?.inverter_status_entity) ?? G(i?.error_entity) : void 0,
+ error_entity: t ? G(i?.error_entity) : void 0,
+ advanced_metrics: Ts(i?.advanced_metrics, t),
+ enabled: t,
+ rated_power_w: Jr(i?.rated_power_w, 1, dt),
+ deviation_derate_percent: ze(i?.deviation_derate_percent, 100, 1, 100)
+ };
+}, Ms = (i) => `string-${i + 1}`, As = (i, e) => ({
+ id: G(i?.id) ?? Ms(e),
+ name: G(i?.name),
+ panel_count: V(
+ i?.panel_count,
+ pt,
+ 1,
+ Y
+ ),
+ power_entity: G(i?.power_entity),
+ energy_entity: G(i?.energy_entity)
+}), Rs = (i) => Array.isArray(i) ? i.slice(0, Y).map(
+ (e, t) => As(
+ typeof e == "object" && e !== null ? e : void 0,
+ t
+ )
+) : [], Ns = /^string\s+\d+\s+panel\s+\d+$/i, Ls = /^panel\s+\d+$/i, Vt = (i) => typeof i == "string" && (Ns.test(i.trim()) || Ls.test(i.trim())), es = (i, e, t = []) => {
+ const r = i * e;
+ return Array.from(
+ { length: r },
+ (s, n) => Ft(t[n], n)
+ );
+}, Ds = (i, e, t = [], r = e) => {
+ const n = i.length * e, o = V(
+ r,
+ e,
+ 1,
+ Y
+ );
+ return Array.from({ length: n }, (l, a) => {
+ const c = Math.floor(a / e), p = a % e, d = c * o + p, h = i[c], u = p < o ? t[d] ?? {} : {}, g = `${h?.name ?? `String ${c + 1}`} Panel ${p + 1}`, f = G(u.name);
+ return !h || p >= h.panel_count ? Ft(
+ {
+ ...u,
+ name: f && !Vt(f) ? f : void 0,
+ enabled: !1,
+ power_entity: void 0,
+ energy_entity: void 0,
+ show_energy: !1
+ },
+ a
+ ) : Ft(
+ {
+ ...u,
+ name: f && !Vt(f) ? f : g,
+ enabled: !0,
+ power_entity: h.power_entity,
+ energy_entity: h.energy_entity,
+ show_energy: h.energy_entity ? !0 : H(u.show_energy, !1)
+ },
+ a
+ );
+ });
+}, Os = (i = Ir, e = pt) => ({
+ type: Rt,
+ title: "Solar Array",
+ theme_mode: Xr,
+ rows: i,
+ columns: e,
+ panels: es(i, e),
+ enable_inverter_status: !1,
+ enable_string_sensor_sharing: !1,
+ string_groups: [],
+ inverter_fault_terms: [...Qr],
+ inverter_working_terms: [...Zr],
+ show_inverter_status_on_tiles: !1,
+ enable_array_checks: !1,
+ deviation_threshold_percent: Fr,
+ deviation_absolute_w_threshold: Tr,
+ deviation_min_active_panels: Mr,
+ deviation_min_samples: Ar,
+ deviation_min_runtime_minutes: Rr,
+ deviation_smoothing_minutes: Nr,
+ deviation_dynamic_floor_w: Lr,
+ deviation_history_hours: Dr,
+ colors: Nt,
+ production_color_intensity: Wr,
+ show_energy: !0,
+ use_system_power_entity: !1,
+ system_power_entity: void 0,
+ invert_system_power: !1,
+ use_system_energy_entity: !1,
+ system_energy_entity: void 0,
+ enable_forecast_overlay: Kr,
+ motion_enabled: Vr,
+ motion_power_flow: jr,
+ motion_update_shimmer: Yr,
+ motion_alert_ripple: qr,
+ show_custom_kpi: !0,
+ custom_kpi_title: "Custom KPI",
+ custom_kpi_entity: void 0,
+ custom_kpi_decimals: Hr,
+ invert_custom_kpi: !1,
+ panel_tap_action: "details",
+ power_decimals: Or,
+ energy_decimals: Gr,
+ limit_panel_width: zr,
+ panel_max_width_px: Br,
+ max_card_width_px: Ur
+}), Be = (i = {}) => {
+ const e = H(
+ i.enable_string_sensor_sharing,
+ !1
+ ), t = Rs(i.string_groups), r = e && t.length > 0, s = r ? t.length : V(i.rows, Ir), n = r ? Math.max(...t.map((l) => l.panel_count), 1) : V(i.columns, pt), o = {
+ ...Nt,
+ ...i.colors ?? {}
+ };
+ return {
+ type: G(i.type) ?? Rt,
+ title: G(i.title),
+ theme_mode: ks(i.theme_mode),
+ rows: s,
+ columns: n,
+ panels: r ? Ds(
+ t,
+ n,
+ i.panels ?? [],
+ V(i.columns, pt)
+ ) : es(s, n, i.panels ?? []),
+ enable_inverter_status: H(i.enable_inverter_status, !1),
+ enable_string_sensor_sharing: e,
+ string_groups: t,
+ inverter_fault_terms: Wt(
+ i.inverter_fault_terms,
+ Qr
+ ),
+ inverter_working_terms: Wt(
+ i.inverter_working_terms,
+ Zr
+ ),
+ show_inverter_status_on_tiles: H(
+ i.show_inverter_status_on_tiles,
+ !1
+ ),
+ enable_array_checks: H(i.enable_array_checks, !1),
+ deviation_threshold_percent: ze(
+ i.deviation_threshold_percent,
+ Fr,
+ 1,
+ 100
+ ),
+ deviation_absolute_w_threshold: ze(
+ i.deviation_absolute_w_threshold,
+ Tr,
+ 0,
+ 5e3
+ ),
+ deviation_min_active_panels: V(
+ i.deviation_min_active_panels,
+ Mr,
+ 2,
+ 30
+ ),
+ deviation_min_samples: V(
+ i.deviation_min_samples,
+ Ar,
+ 1,
+ 120
+ ),
+ deviation_min_runtime_minutes: V(
+ i.deviation_min_runtime_minutes,
+ Rr,
+ 0,
+ 1440
+ ),
+ deviation_smoothing_minutes: V(
+ i.deviation_smoothing_minutes,
+ Nr,
+ 0,
+ 1440
+ ),
+ deviation_dynamic_floor_w: ze(
+ i.deviation_dynamic_floor_w,
+ Lr,
+ 0,
+ 5e3
+ ),
+ deviation_restart_entity: G(i.deviation_restart_entity),
+ deviation_history_hours: V(
+ i.deviation_history_hours,
+ Dr,
+ 1,
+ 168
+ ),
+ colors: o,
+ production_color_intensity: ze(
+ i.production_color_intensity,
+ Wr,
+ 0.2,
+ 1.6
+ ),
+ show_energy: !0,
+ use_system_power_entity: H(i.use_system_power_entity, !1),
+ system_power_entity: G(i.system_power_entity),
+ invert_system_power: H(i.invert_system_power, !1),
+ use_system_energy_entity: H(i.use_system_energy_entity, !1),
+ system_energy_entity: G(i.system_energy_entity),
+ enable_forecast_overlay: H(
+ i.enable_forecast_overlay,
+ Kr
+ ),
+ motion_enabled: H(i.motion_enabled, Vr),
+ motion_power_flow: H(
+ i.motion_power_flow,
+ jr
+ ),
+ motion_update_shimmer: H(
+ i.motion_update_shimmer,
+ Yr
+ ),
+ motion_alert_ripple: H(
+ i.motion_alert_ripple,
+ qr
+ ),
+ show_custom_kpi: H(i.show_custom_kpi, !0),
+ custom_kpi_title: $s(i.custom_kpi_title) ?? "Custom KPI",
+ custom_kpi_entity: G(i.custom_kpi_entity),
+ custom_kpi_decimals: V(
+ i.custom_kpi_decimals,
+ Hr,
+ 0,
+ 4
+ ),
+ invert_custom_kpi: H(i.invert_custom_kpi, !1),
+ panel_tap_action: Es(i.panel_tap_action),
+ power_decimals: V(
+ i.power_decimals,
+ Or,
+ 0,
+ 4
+ ),
+ energy_decimals: V(
+ i.energy_decimals,
+ Gr,
+ 0,
+ 4
+ ),
+ limit_panel_width: H(
+ i.limit_panel_width,
+ zr
+ ),
+ panel_max_width_px: V(
+ i.panel_max_width_px,
+ Br,
+ 120,
+ 320
+ ),
+ default_panel_rated_power_w: Jr(
+ i.default_panel_rated_power_w,
+ 1,
+ dt
+ ),
+ max_card_width_px: Kt(i.max_card_width_px, 300, 2400) ?? Ur,
+ max_card_height_px: Kt(i.max_card_height_px, 300, 2600),
+ grid_options: Is(i.grid_options)
+ };
+}, Gs = (i) => {
+ const e = [];
+ return typeof i != "object" || i === null ? ["Configuration must be an object."] : (i.rows !== void 0 && (!Number.isFinite(Number(i.rows)) || Number(i.rows) < 1) && e.push("`rows` must be a positive number."), i.columns !== void 0 && (!Number.isFinite(Number(i.columns)) || Number(i.columns) < 1) && e.push("`columns` must be a positive number."), i.max_card_width_px !== void 0 && !Number.isFinite(Number(i.max_card_width_px)) && e.push("`max_card_width_px` must be a number if set."), i.panel_max_width_px !== void 0 && (!Number.isFinite(Number(i.panel_max_width_px)) || Number(i.panel_max_width_px) < 120 || Number(i.panel_max_width_px) > 320) && e.push("`panel_max_width_px` must be between 120 and 320."), i.max_card_height_px !== void 0 && !Number.isFinite(Number(i.max_card_height_px)) && e.push("`max_card_height_px` must be a number if set."), i.custom_kpi_decimals !== void 0 && (!Number.isFinite(Number(i.custom_kpi_decimals)) || Number(i.custom_kpi_decimals) < 0 || Number(i.custom_kpi_decimals) > 4) && e.push("`custom_kpi_decimals` must be between 0 and 4."), i.production_color_intensity !== void 0 && (!Number.isFinite(Number(i.production_color_intensity)) || Number(i.production_color_intensity) < 0.2 || Number(i.production_color_intensity) > 1.6) && e.push("`production_color_intensity` must be between 0.2 and 1.6."), i.deviation_absolute_w_threshold !== void 0 && (!Number.isFinite(Number(i.deviation_absolute_w_threshold)) || Number(i.deviation_absolute_w_threshold) < 0) && e.push("`deviation_absolute_w_threshold` must be 0 or higher."), i.deviation_min_active_panels !== void 0 && (!Number.isFinite(Number(i.deviation_min_active_panels)) || Number(i.deviation_min_active_panels) < 2) && e.push("`deviation_min_active_panels` must be 2 or higher."), i.deviation_min_samples !== void 0 && (!Number.isFinite(Number(i.deviation_min_samples)) || Number(i.deviation_min_samples) < 1) && e.push("`deviation_min_samples` must be 1 or higher."), i.deviation_min_runtime_minutes !== void 0 && (!Number.isFinite(Number(i.deviation_min_runtime_minutes)) || Number(i.deviation_min_runtime_minutes) < 0) && e.push("`deviation_min_runtime_minutes` must be 0 or higher."), i.deviation_smoothing_minutes !== void 0 && (!Number.isFinite(Number(i.deviation_smoothing_minutes)) || Number(i.deviation_smoothing_minutes) < 0) && e.push("`deviation_smoothing_minutes` must be 0 or higher."), i.deviation_dynamic_floor_w !== void 0 && (!Number.isFinite(Number(i.deviation_dynamic_floor_w)) || Number(i.deviation_dynamic_floor_w) < 0) && e.push("`deviation_dynamic_floor_w` must be 0 or higher."), i.deviation_history_hours !== void 0 && (!Number.isFinite(Number(i.deviation_history_hours)) || Number(i.deviation_history_hours) < 1) && e.push("`deviation_history_hours` must be 1 or higher."), Array.isArray(i.panels) && i.panels.forEach((t, r) => {
+ const s = t?.deviation_derate_percent;
+ s !== void 0 && (!Number.isFinite(Number(s)) || Number(s) < 1 || Number(s) > 100) && e.push(
+ `\`panels[${r}].deviation_derate_percent\` must be between 1 and 100.`
+ );
+ }), e);
+}, Hs = { card_name: "Solar Panel Visualizer", card_description: "GUI-first solar array card with panel health, animated power rails, forecasts, history graphs, and light/dark themes." }, zs = { unavailable: "Unavailable", not_configured: "Not configured", disabled: "Disabled", unknown_recorder_error: "Unknown recorder error" }, Bs = { default_title: "Solar Array", eyebrow: "Solar Panel Visualizer", subtitle: { loading_history: "Loading shared {hours}h solar panel history...", warmup: "Deviation checks are warming up.", deviation_detected: "{count} panel{suffix} below expected output", tap_diagnostics: "Tap a panel for detailed diagnostics", drag_hint: "Drag and drop panel tiles to swap positions." }, summary: { power: "Power", energy: "Energy", alerts: "Alerts", system_sensor: "System sensor", sum_panel_sensors: "Sum of panel sensors", custom_sensor: "Custom KPI sensor", custom_default_title: "Custom KPI" }, panel: { hidden: "hidden", hidden_name: "Hidden Panel", hidden_performance: "Hidden", info_label: "Info", info_title: "Open panel details", inverter_prefix: "Status: {status}", inverter_short: { ok: "Status: OK", deviation: "Status: Deviation", error: "Status: Error" }, performance_compact: "{percent}%", performance_medium: "{percent}% of {rated}W", performance_full: "{percent}% of {rated}W Panel", string_label: "String {index}", slot_label: "R{row}C{column}", status: { normal: "normal", deviation: "deviation", inverter: "inverter", error: "error", offline: "offline", unconfigured: "unconfigured", disabled: "hidden" } }, popup: { close_detail: "Close detail", close_live_power: "Close live power detail", close_energy: "Close energy detail", close_custom_kpi: "Close custom KPI detail", close_system_health: "Close system health", panel_eyebrow: "Panel Detail {slot}", power_eyebrow: "Power Detail", power_title: "Live Power", energy_eyebrow: "Energy Detail", energy_title: "Energy", custom_eyebrow: "Custom KPI Detail", system_health_eyebrow: "System Health", system_health_title: "Overview", detail: { status: "Status", power: "Power", energy: "Energy", estimated_power_now: "Forecast production", estimated_energy_now: "Forecast production", deviation: "Deviation", rated_performance: "Rated / Performance", information: "Information", current: "Current", source: "Source" }, deviation: { inverter_mismatch: "Status mismatch", below_peers: "{percent}% below peers", within_range: "Within range" }, rated_performance: { format: "{rated} W / {percent}", na: "n/a" }, info: { power_source: "Power source: {value}", string_share: "{label}: {percent}% of {source}", current_inverter_status: 'Current status: "{value}"', inverter_evaluation: "Status evaluation: {value}", inverter_source: "Status source: {value}" }, inverter_eval: { no_status: "No status available", fault_match: "Fault term matched", working_match: "Working term matched", no_match: "No configured term matched" }, telemetry: { title: "Panel / Inverter Info", configured_title: "Configured", unconfigured_title: "Unconfigured", none_configured: "No advanced telemetry configured for this panel.", setup_hint: "Configure in Panels > Advanced telemetry.", label: { inverter_status: "Inverter / panel status", inverter_ac_power: "Inverter AC power", inverter_ac_voltage: "Inverter AC voltage", inverter_ac_current: "Inverter AC current", inverter_temp: "Inverter temperature", panel_current: "Panel current", panel_voltage: "Panel voltage", panel_power: "Panel power" } }, history: { power: "Power History", system_power: "System Power History", total_panel_power: "Total Panel Power History", system_energy: "System Energy History", total_panel_energy: "Total Panel Energy History", panel_power_values: "Panel Power Values", panel_energy_values: "Panel Energy Values", panel_compare: "Panel Performance Comparison", panel_compare_power: "Panel Power Comparison", panel_compare_energy: "Panel Energy Comparison", overlay_forecast: "Forecast", graph_not_configured: "No sensor configured for graph.", custom_not_configured: "No sensor configured for Custom KPI.", loading: "Loading sensor history...", no_data: "No history data for selected range.", unable_load: "Unable to load panel history ({error})", unable_load_plain: "Unable to load panel history", max: "Max {value}", median: "Median {value}", min: "Min {value}", time_range: "{start} - {end}" }, forecast: { enable_button: "Enable forecasts", disabled_hint: "Forecast overlay is disabled.", not_configured: "Forecast.Solar not configured.", default_sensor_not_found: "Default forecast sensor not found ({entity}).", power_compare_requires_system: "System power sensor is required for power forecast comparison.", energy_compare_requires_system: "System energy sensor is required for energy forecast comparison." }, panel_compare: { toggle: "Compare Panel Performance", toggle_power: "Compare Panel Power", toggle_energy: "Compare Panel Energy", loading: "Loading panel comparison history...", no_panels: "No configured panel sensors available for comparison.", no_panels_power: "No configured panel power sensors available for comparison.", no_panels_energy: "No configured panel energy sensors available for comparison.", no_data: "No comparison data for selected range.", unable_load: "Unable to load panel comparison history ({error}).", render_failure: "Comparison data loaded, but traces could not be drawn.", diagnostics_title: "Compare graph diagnostics (temporary)", diagnostics_summary: "model hasData={hasData}, drawable={drawable}, series={series}, range={range}h", diagnostics_reason_render_failure: "Series exist but no drawable traces were produced.", diagnostics_reason_suspect: "One or more series produced an invalid drawable shape.", diagnostics_row: "{label}: samples={samples}, points={points}, first={first}, last={last}, min={min}, max={max}" }, system_health: { everything_ok: "Everything is working well.", section: { inverter: "Inverter", error: "Error", deviation: "Deviation", offline: "Unavailable", setup: "Needs setup" }, item: "Panel on {slot} ({label}): {reason}" } }, quick_setup: { title: "Quick Setup:", select_power_sensor: "Select panel power sensor:", selector_label: "Select panel power sensor", no_sensors: "No available W sensors found.", disable_panel: "Disable Panel (hide but keep slot when off)" }, system_health_chip: { faults: "{count} Fault{suffix}", unavailable: "{count} Unavailable", deviation: "{count} Deviation", needs_setup: "{count} Needs Setup", ok: "System OK" } }, Us = { reason: { slot_hidden: "Panel slot is hidden in the card configuration.", select_power_sensor: "Select a power sensor to activate this panel slot.", power_entity_missing: "Power entity {entity} was not found.", power_entity_unavailable: "{entity} is unavailable.", inverter_fault_match: 'Current status: "{status}" matches configured fault terms.', inverter_working_mismatch: 'Current status: "{status}" does not match configured working terms.', producing_expected: "Producing within the expected array range.", producing_adjusted: "Producing within array-adjusted target range.", rated_not_configured: "Rated power not configured; excluded from deviation checks.", output_below_target: "Output is {percent}% and {shortfall} W below array target.", array_check_disabled: "Array Health Check is disabled.", need_non_derated_panels: "Need at least {count} non-derated active rated panels for deviation checks.", collecting_samples: "Collecting samples ({current}/{required}).", warmup_progress: "Warm-up in progress ({current}/{required} min).", low_light_pause: "Low-light pause: waiting above {floor} W target floor." }, status_display: { disabled: "Disabled", not_configured: "Not configured" }, energy: { default_unit: "kWh" }, power: { default_unit: "W" } }, Ws = { section: { layout_title: "Layout", layout_copy: "Set the array size first. Panel slots expand automatically from the row and column values.", display_title: "Display", display_copy: "Tune precision and panel detail behavior.", appearance_title: "Appearance", appearance_copy: "Auto follows the active Home Assistant theme. Force Light or Dark if a dashboard theme needs a specific card style.", forecast_title: "Forecast.Solar", forecast_copy: "Auto-detects Home Assistant default forecast sensors and overlays estimated production in Power/Energy KPI popups.", array_health_title: "Array Health Check", array_health_copy: "Automatically checks panel health by comparing active panels against each other using rated power, shared solar panel history, and configurable guardrails.", array_health_smoothing_help: "Smoothing window averages recent samples before checks; 0 means no smoothing.", inverter_title: "Status Checks", inverter_copy: "Track textual status from each panel’s inverter or panel status sensor. A panel turns red only when status text contains one of the configured fault terms. The current status is shown in the panel popup.", status_colors_title: "Status Colors", status_colors_copy: "Production colors blend based on panel output. Alert colors override the production scale.", motion_title: "Motion", motion_copy: "Animate live production with left-collector power rails, Power/Energy KPI impact effects, and repeated alert ripples. Motion automatically respects reduced-motion preferences.", panels_title: "Panels", panels_copy: "Each generated slot can be configured with its own power, energy, and optional inverter/panel status sensor. Disable a slot to hide that panel while keeping grid spacing.", panels_drag_hint: "In the card view, drag and drop panel tiles to swap their positions.", string_setup_title: "String of panels", string_setup_copy: "Use this when one power or energy sensor represents a full string of physical panels. The tree creates one visual row per string.", panel_defaults_title: "Panel default rated power", panel_defaults_copy: "Set a common default panel power and apply it to all panel slots.", autofill_title: "Auto-populate sensors", autofill_copy: "Fill panel sensors in slot order. Use sensor. for exact entity ID prefixes, or type friendly-name text to search sensor names.", advanced_title: "Advanced telemetry", advanced_copy: "Optional manual telemetry mappings shown in the panel popup when pressing INFO." }, field: { title: "Title", rows: "Rows", columns: "Columns", max_card_width: "Max card width (px)", max_card_height: "Max card height (px)", theme_mode: "Theme mode", power_decimals: "Power decimals", energy_decimals: "Energy decimals", custom_kpi_decimals: "Custom KPI decimals", panel_tap_action: "Panel tap action", system_power_sensor: "System power sensor (W)", system_energy_sensor: "System daily energy sensor", custom_kpi_sensor: "Custom KPI sensor", custom_kpi_heading: "Custom KPI heading", max_panel_tile_width: "Max panel tile width (px)", deviation_threshold: "Deviation threshold (%)", deviation_absolute_shortfall: "Absolute shortfall threshold (W)", deviation_check_time: "Deviation check time (minutes)", deviation_min_active_panels: "Minimum active panels", deviation_min_samples: "Minimum samples per panel", deviation_smoothing: "Smoothing window (minutes)", deviation_dynamic_floor: "Dynamic floor start (W)", deviation_history_window: "Shared history window (hours)", fault_terms: "Fault terms (comma-separated)", working_terms: "Working terms (comma-separated)", production_base: "Production base", production_mid: "Production mid", production_peak: "Production peak", deviation_color: "Deviation", error_color: "Error", unavailable_color: "Unavailable", production_intensity: "Production color intensity ({value})", default_panel_rated_power: "Default panel rated power (W)", power_prefix: "Power search", energy_prefix: "Energy search (optional)", status_prefix: "Status search (optional)", autofill_sort_mode: "Auto-fill sort order", string_group_count: "Number of strings", string_group_name: "String name", string_group_panel_count: "Number of panels", string_group_power_sensor: "String power sensor", string_group_energy_sensor: "String energy sensor (optional)", display_name: "Display name", power_sensor: "Power sensor P(W)", energy_sensor: "Energy sensor (kWh/Wh)", panel_rated_power: "Panel rated power (W)", deviation_derate: "Deviation derate (%)", inverter_status_sensor: "Inverter / panel status sensor (optional)", advanced_inverter_ac_power: "Inverter AC power (W)", advanced_inverter_ac_voltage: "Inverter AC voltage (V)", advanced_inverter_ac_current: "Inverter AC current (A)", advanced_inverter_temp: "Inverter temperature (°C/°F)", advanced_panel_current: "Panel current (A)", advanced_panel_voltage: "Panel voltage (V)", advanced_panel_power: "Panel power (W)", panel_energy_toggle: "Show panel energy", panel_enabled_toggle: "Show panel tile (hide but keep slot when off)" }, toggle: { use_system_power: "Use one system power sensor for top KPI", invert_system_power: "Invert system power value", use_system_energy: "Use one system daily energy sensor for top KPI", invert_custom_kpi: "Invert Custom KPI value", enable_forecast_overlay: "Enable forecast overlays in popups", motion_enabled: "Enable motion", motion_power_flow: "Power-rail flow to Power KPI", motion_update_shimmer: "Power/Energy KPI update effect", motion_alert_ripple: "Alert ripple for deviation/inverter/error", show_custom_kpi: "Show Custom KPI box", limit_panel_width: "Limit panel tile max width", enable_array_health: "Enable Array Health Check", enable_inverter_status: "Enable status checks", show_inverter_status_tiles: "Show status on panel tiles", enable_string_sensor_sharing: "String of panels / share duplicate sensors", autofill_inverter_status: "Fill inverter or panel status", autofill_advanced_telemetry: "Fill advanced telemetry" }, select: { theme_auto: "Auto", theme_dark: "Dark", theme_light: "Light", panel_tap_details: "Open detail popover", panel_tap_none: "No action", autofill_sort_auto: "Auto detect (recommended)", autofill_sort_entity: "Literal entity ID order", autofill_sort_friendly: "Literal friendly name order", autofill_sort_grouped: "Group by repeated suffix" }, button: { apply_default_rated_power: "Apply default rated W to all panels", autofill_sensors: "Auto-fill panel sensors", remove_all_sensors: "Remove all sensors" }, helper: { fault_example: "Example: fault, alarm, error, failed, failure, trip", working_example: "Working example: normal, ok, running, waiting, waiting for operation, producing", derate_help: "Used only by Array Health Check for naturally shaded panels.", autofill_search_help: "Search with sensor.panel_ to match entity IDs by prefix, or type words like Roof Panel or Daily Energy to search friendly names. Auto detect is recommended for most setups: it uses natural sorting and can group matching sensors by repeated serial/device parts or serial-like suffixes when that better matches the physical layout. Use the other sort modes only when Auto does not match your array.", string_sensor_sharing_help: "Turn this on to allow duplicate string sensors. Shared power and energy values are split across the generated panels by rated W, with equal split if rated W is missing.", autofill_inverter_status_help: "When enabled, status is filled from configured panel power sensors. The card first tries one-to-one panel status matches, then falls back to a shared inverter/string status sensor. Use Status search only when you need to narrow the candidates.", autofill_advanced_help: "When enabled, advanced telemetry is filled from configured panel power sensors by matching the same repeated serial/device/group. Existing advanced telemetry choices are preserved." }, forecast: { default_sensors: "Uses: sensor.power_production_now and sensor.energy_production_today.", line_help: "The forecast reference is a thin dashed line shown only for the selected history range up to the current time, with no future projection.", detected: "Forecast.Solar default sensors detected.", missing: "Forecast.Solar default sensors not fully detected. Expected: sensor.power_production_now and sensor.energy_production_today." }, panel_slot_title: "Row {row}, Column {column}", autofill: { enter_prefix: "Enter at least one prefix to run auto-fill.", power_summary: "Power matched {matched}, filled {filled}, skipped {skipped}.", energy_summary: "Energy matched {matched}, filled {filled}, skipped {skipped}.", inverter_status_summary: "Status filled {filled} panels, skipped {skipped} panels.", advanced_summary: "Advanced telemetry filled {filled} fields, skipped {skipped} panels.", cleared: "Cleared power, energy, inverter, and advanced telemetry sensors on all panels." }, string_group: { panel_count_chip: "{count} panels" } }, ts = {
+ meta: Hs,
+ common: zs,
+ card: Bs,
+ state: Us,
+ editor: Ws
+}, Ks = { DEV: !1 }, Vs = {
+ en: ts
+}, jt = /* @__PURE__ */ new Set(), js = () => typeof import.meta < "u" ? Ks.DEV : typeof process < "u" ? process.env.NODE_ENV !== "production" : !1, Ys = (i, e) => {
+ if (!js())
+ return;
+ const t = `${e ?? "unknown"}:${i}`;
+ jt.has(t) || (jt.add(t), console.warn(
+ `[Solar Panel Visualizer i18n] Missing translation key "${i}" for locale "${e ?? "unknown"}".`
+ ));
+}, Yt = (i, e) => {
+ if (i)
+ return e.split(".").reduce(
+ (t, r) => typeof t == "object" && t !== null ? t[r] : void 0,
+ i
+ );
+}, qt = (i, e) => e ? i.replace(/\{([a-zA-Z0-9_]+)\}/g, (t, r) => {
+ const s = e[r];
+ return s === void 0 ? `{${r}}` : String(s);
+}) : i, qs = (i) => {
+ if (!i)
+ return ["en"];
+ const e = i.trim().toLowerCase();
+ if (e.length === 0)
+ return ["en"];
+ const t = e.split("-")[0], r = [e, t, "en"];
+ return [...new Set(r)];
+}, Xs = (i, e, t, r) => {
+ for (const n of qs(e)) {
+ const o = Yt(i[n], t);
+ if (typeof o == "string")
+ return qt(o, r);
+ }
+ const s = Yt(i.en ?? ts, t);
+ return typeof s == "string" ? qt(s, r) : (Ys(t, e), "");
+}, ht = (i, e, t) => Xs(
+ Vs,
+ i,
+ e,
+ t
+), rs = (i, e, t) => ht(i?.locale?.language, e, t);
+const ot = globalThis, Lt = ot.ShadowRoot && (ot.ShadyCSS === void 0 || ot.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, Dt = /* @__PURE__ */ Symbol(), Xt = /* @__PURE__ */ new WeakMap();
+let ss = class {
+ constructor(e, t, r) {
+ if (this._$cssResult$ = !0, r !== Dt) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");
+ this.cssText = e, this.t = t;
+ }
+ get styleSheet() {
+ let e = this.o;
+ const t = this.t;
+ if (Lt && e === void 0) {
+ const r = t !== void 0 && t.length === 1;
+ r && (e = Xt.get(t)), e === void 0 && ((this.o = e = new CSSStyleSheet()).replaceSync(this.cssText), r && Xt.set(t, e));
+ }
+ return e;
+ }
+ toString() {
+ return this.cssText;
+ }
+};
+const Qs = (i) => new ss(typeof i == "string" ? i : i + "", void 0, Dt), Ot = (i, ...e) => {
+ const t = i.length === 1 ? i[0] : e.reduce((r, s, n) => r + ((o) => {
+ if (o._$cssResult$ === !0) return o.cssText;
+ if (typeof o == "number") return o;
+ throw Error("Value passed to 'css' function must be a 'css' function result: " + o + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.");
+ })(s) + i[n + 1], i[0]);
+ return new ss(t, i, Dt);
+}, Zs = (i, e) => {
+ if (Lt) i.adoptedStyleSheets = e.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet);
+ else for (const t of e) {
+ const r = document.createElement("style"), s = ot.litNonce;
+ s !== void 0 && r.setAttribute("nonce", s), r.textContent = t.cssText, i.appendChild(r);
+ }
+}, Qt = Lt ? (i) => i : (i) => i instanceof CSSStyleSheet ? ((e) => {
+ let t = "";
+ for (const r of e.cssRules) t += r.cssText;
+ return Qs(t);
+})(i) : i;
+const { is: Js, defineProperty: ei, getOwnPropertyDescriptor: ti, getOwnPropertyNames: ri, getOwnPropertySymbols: si, getPrototypeOf: ii } = Object, le = globalThis, Zt = le.trustedTypes, ni = Zt ? Zt.emptyScript : "", oi = le.reactiveElementPolyfillSupport, Ue = (i, e) => i, Tt = { toAttribute(i, e) {
+ switch (e) {
+ case Boolean:
+ i = i ? ni : null;
+ break;
+ case Object:
+ case Array:
+ i = i == null ? i : JSON.stringify(i);
+ }
+ return i;
+}, fromAttribute(i, e) {
+ let t = i;
+ switch (e) {
+ case Boolean:
+ t = i !== null;
+ break;
+ case Number:
+ t = i === null ? null : Number(i);
+ break;
+ case Object:
+ case Array:
+ try {
+ t = JSON.parse(i);
+ } catch {
+ t = null;
+ }
+ }
+ return t;
+} }, is = (i, e) => !Js(i, e), Jt = { attribute: !0, type: String, converter: Tt, reflect: !1, useDefault: !1, hasChanged: is };
+Symbol.metadata ?? (Symbol.metadata = /* @__PURE__ */ Symbol("metadata")), le.litPropertyMetadata ?? (le.litPropertyMetadata = /* @__PURE__ */ new WeakMap());
+let ke = class extends HTMLElement {
+ static addInitializer(e) {
+ this._$Ei(), (this.l ?? (this.l = [])).push(e);
+ }
+ static get observedAttributes() {
+ return this.finalize(), this._$Eh && [...this._$Eh.keys()];
+ }
+ static createProperty(e, t = Jt) {
+ if (t.state && (t.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(e) && ((t = Object.create(t)).wrapped = !0), this.elementProperties.set(e, t), !t.noAccessor) {
+ const r = /* @__PURE__ */ Symbol(), s = this.getPropertyDescriptor(e, r, t);
+ s !== void 0 && ei(this.prototype, e, s);
+ }
+ }
+ static getPropertyDescriptor(e, t, r) {
+ const { get: s, set: n } = ti(this.prototype, e) ?? { get() {
+ return this[t];
+ }, set(o) {
+ this[t] = o;
+ } };
+ return { get: s, set(o) {
+ const l = s?.call(this);
+ n?.call(this, o), this.requestUpdate(e, l, r);
+ }, configurable: !0, enumerable: !0 };
+ }
+ static getPropertyOptions(e) {
+ return this.elementProperties.get(e) ?? Jt;
+ }
+ static _$Ei() {
+ if (this.hasOwnProperty(Ue("elementProperties"))) return;
+ const e = ii(this);
+ e.finalize(), e.l !== void 0 && (this.l = [...e.l]), this.elementProperties = new Map(e.elementProperties);
+ }
+ static finalize() {
+ if (this.hasOwnProperty(Ue("finalized"))) return;
+ if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(Ue("properties"))) {
+ const t = this.properties, r = [...ri(t), ...si(t)];
+ for (const s of r) this.createProperty(s, t[s]);
+ }
+ const e = this[Symbol.metadata];
+ if (e !== null) {
+ const t = litPropertyMetadata.get(e);
+ if (t !== void 0) for (const [r, s] of t) this.elementProperties.set(r, s);
+ }
+ this._$Eh = /* @__PURE__ */ new Map();
+ for (const [t, r] of this.elementProperties) {
+ const s = this._$Eu(t, r);
+ s !== void 0 && this._$Eh.set(s, t);
+ }
+ this.elementStyles = this.finalizeStyles(this.styles);
+ }
+ static finalizeStyles(e) {
+ const t = [];
+ if (Array.isArray(e)) {
+ const r = new Set(e.flat(1 / 0).reverse());
+ for (const s of r) t.unshift(Qt(s));
+ } else e !== void 0 && t.push(Qt(e));
+ return t;
+ }
+ static _$Eu(e, t) {
+ const r = t.attribute;
+ return r === !1 ? void 0 : typeof r == "string" ? r : typeof e == "string" ? e.toLowerCase() : void 0;
+ }
+ constructor() {
+ super(), this._$Ep = void 0, this.isUpdatePending = !1, this.hasUpdated = !1, this._$Em = null, this._$Ev();
+ }
+ _$Ev() {
+ this._$ES = new Promise((e) => this.enableUpdating = e), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((e) => e(this));
+ }
+ addController(e) {
+ (this._$EO ?? (this._$EO = /* @__PURE__ */ new Set())).add(e), this.renderRoot !== void 0 && this.isConnected && e.hostConnected?.();
+ }
+ removeController(e) {
+ this._$EO?.delete(e);
+ }
+ _$E_() {
+ const e = /* @__PURE__ */ new Map(), t = this.constructor.elementProperties;
+ for (const r of t.keys()) this.hasOwnProperty(r) && (e.set(r, this[r]), delete this[r]);
+ e.size > 0 && (this._$Ep = e);
+ }
+ createRenderRoot() {
+ const e = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);
+ return Zs(e, this.constructor.elementStyles), e;
+ }
+ connectedCallback() {
+ this.renderRoot ?? (this.renderRoot = this.createRenderRoot()), this.enableUpdating(!0), this._$EO?.forEach((e) => e.hostConnected?.());
+ }
+ enableUpdating(e) {
+ }
+ disconnectedCallback() {
+ this._$EO?.forEach((e) => e.hostDisconnected?.());
+ }
+ attributeChangedCallback(e, t, r) {
+ this._$AK(e, r);
+ }
+ _$ET(e, t) {
+ const r = this.constructor.elementProperties.get(e), s = this.constructor._$Eu(e, r);
+ if (s !== void 0 && r.reflect === !0) {
+ const n = (r.converter?.toAttribute !== void 0 ? r.converter : Tt).toAttribute(t, r.type);
+ this._$Em = e, n == null ? this.removeAttribute(s) : this.setAttribute(s, n), this._$Em = null;
+ }
+ }
+ _$AK(e, t) {
+ const r = this.constructor, s = r._$Eh.get(e);
+ if (s !== void 0 && this._$Em !== s) {
+ const n = r.getPropertyOptions(s), o = typeof n.converter == "function" ? { fromAttribute: n.converter } : n.converter?.fromAttribute !== void 0 ? n.converter : Tt;
+ this._$Em = s;
+ const l = o.fromAttribute(t, n.type);
+ this[s] = l ?? this._$Ej?.get(s) ?? l, this._$Em = null;
+ }
+ }
+ requestUpdate(e, t, r, s = !1, n) {
+ if (e !== void 0) {
+ const o = this.constructor;
+ if (s === !1 && (n = this[e]), r ?? (r = o.getPropertyOptions(e)), !((r.hasChanged ?? is)(n, t) || r.useDefault && r.reflect && n === this._$Ej?.get(e) && !this.hasAttribute(o._$Eu(e, r)))) return;
+ this.C(e, t, r);
+ }
+ this.isUpdatePending === !1 && (this._$ES = this._$EP());
+ }
+ C(e, t, { useDefault: r, reflect: s, wrapped: n }, o) {
+ r && !(this._$Ej ?? (this._$Ej = /* @__PURE__ */ new Map())).has(e) && (this._$Ej.set(e, o ?? t ?? this[e]), n !== !0 || o !== void 0) || (this._$AL.has(e) || (this.hasUpdated || r || (t = void 0), this._$AL.set(e, t)), s === !0 && this._$Em !== e && (this._$Eq ?? (this._$Eq = /* @__PURE__ */ new Set())).add(e));
+ }
+ async _$EP() {
+ this.isUpdatePending = !0;
+ try {
+ await this._$ES;
+ } catch (t) {
+ Promise.reject(t);
+ }
+ const e = this.scheduleUpdate();
+ return e != null && await e, !this.isUpdatePending;
+ }
+ scheduleUpdate() {
+ return this.performUpdate();
+ }
+ performUpdate() {
+ if (!this.isUpdatePending) return;
+ if (!this.hasUpdated) {
+ if (this.renderRoot ?? (this.renderRoot = this.createRenderRoot()), this._$Ep) {
+ for (const [s, n] of this._$Ep) this[s] = n;
+ this._$Ep = void 0;
+ }
+ const r = this.constructor.elementProperties;
+ if (r.size > 0) for (const [s, n] of r) {
+ const { wrapped: o } = n, l = this[s];
+ o !== !0 || this._$AL.has(s) || l === void 0 || this.C(s, void 0, n, l);
+ }
+ }
+ let e = !1;
+ const t = this._$AL;
+ try {
+ e = this.shouldUpdate(t), e ? (this.willUpdate(t), this._$EO?.forEach((r) => r.hostUpdate?.()), this.update(t)) : this._$EM();
+ } catch (r) {
+ throw e = !1, this._$EM(), r;
+ }
+ e && this._$AE(t);
+ }
+ willUpdate(e) {
+ }
+ _$AE(e) {
+ this._$EO?.forEach((t) => t.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = !0, this.firstUpdated(e)), this.updated(e);
+ }
+ _$EM() {
+ this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = !1;
+ }
+ get updateComplete() {
+ return this.getUpdateComplete();
+ }
+ getUpdateComplete() {
+ return this._$ES;
+ }
+ shouldUpdate(e) {
+ return !0;
+ }
+ update(e) {
+ this._$Eq && (this._$Eq = this._$Eq.forEach((t) => this._$ET(t, this[t]))), this._$EM();
+ }
+ updated(e) {
+ }
+ firstUpdated(e) {
+ }
+};
+ke.elementStyles = [], ke.shadowRootOptions = { mode: "open" }, ke[Ue("elementProperties")] = /* @__PURE__ */ new Map(), ke[Ue("finalized")] = /* @__PURE__ */ new Map(), oi?.({ ReactiveElement: ke }), (le.reactiveElementVersions ?? (le.reactiveElementVersions = [])).push("2.1.2");
+const We = globalThis, er = (i) => i, ut = We.trustedTypes, tr = ut ? ut.createPolicy("lit-html", { createHTML: (i) => i }) : void 0, ns = "$lit$", ae = `lit$${Math.random().toFixed(9).slice(2)}$`, os = "?" + ae, ai = `<${os}>`, ge = document, Ve = () => ge.createComment(""), je = (i) => i === null || typeof i != "object" && typeof i != "function", Gt = Array.isArray, li = (i) => Gt(i) || typeof i?.[Symbol.iterator] == "function", xt = `[
+\f\r]`, Oe = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, rr = /-->/g, sr = />/g, de = RegExp(`>|${xt}(?:([^\\s"'>=/]+)(${xt}*=${xt}*(?:[^
+\f\r"'\`<>=]|("|')|))|$)`, "g"), ir = /'/g, nr = /"/g, as = /^(?:script|style|textarea|title)$/i, ls = (i) => (e, ...t) => ({ _$litType$: i, strings: e, values: t }), y = ls(1), U = ls(2), Fe = /* @__PURE__ */ Symbol.for("lit-noChange"), w = /* @__PURE__ */ Symbol.for("lit-nothing"), or = /* @__PURE__ */ new WeakMap(), me = ge.createTreeWalker(ge, 129);
+function cs(i, e) {
+ if (!Gt(i) || !i.hasOwnProperty("raw")) throw Error("invalid template strings array");
+ return tr !== void 0 ? tr.createHTML(e) : e;
+}
+const ci = (i, e) => {
+ const t = i.length - 1, r = [];
+ let s, n = e === 2 ? "" : e === 3 ? "" : "")), r];
+};
+class Ye {
+ constructor({ strings: e, _$litType$: t }, r) {
+ let s;
+ this.parts = [];
+ let n = 0, o = 0;
+ const l = e.length - 1, a = this.parts, [c, p] = ci(e, t);
+ if (this.el = Ye.createElement(c, r), me.currentNode = this.el.content, t === 2 || t === 3) {
+ const d = this.el.content.firstChild;
+ d.replaceWith(...d.childNodes);
+ }
+ for (; (s = me.nextNode()) !== null && a.length < l; ) {
+ if (s.nodeType === 1) {
+ if (s.hasAttributes()) for (const d of s.getAttributeNames()) if (d.endsWith(ns)) {
+ const h = p[o++], u = s.getAttribute(d).split(ae), g = /([.?@])?(.*)/.exec(h);
+ a.push({ type: 1, index: n, name: g[2], strings: u, ctor: g[1] === "." ? di : g[1] === "?" ? hi : g[1] === "@" ? ui : ft }), s.removeAttribute(d);
+ } else d.startsWith(ae) && (a.push({ type: 6, index: n }), s.removeAttribute(d));
+ if (as.test(s.tagName)) {
+ const d = s.textContent.split(ae), h = d.length - 1;
+ if (h > 0) {
+ s.textContent = ut ? ut.emptyScript : "";
+ for (let u = 0; u < h; u++) s.append(d[u], Ve()), me.nextNode(), a.push({ type: 2, index: ++n });
+ s.append(d[h], Ve());
+ }
+ }
+ } else if (s.nodeType === 8) if (s.data === os) a.push({ type: 2, index: n });
+ else {
+ let d = -1;
+ for (; (d = s.data.indexOf(ae, d + 1)) !== -1; ) a.push({ type: 7, index: n }), d += ae.length - 1;
+ }
+ n++;
+ }
+ }
+ static createElement(e, t) {
+ const r = ge.createElement("template");
+ return r.innerHTML = e, r;
+ }
+}
+function Te(i, e, t = i, r) {
+ if (e === Fe) return e;
+ let s = r !== void 0 ? t._$Co?.[r] : t._$Cl;
+ const n = je(e) ? void 0 : e._$litDirective$;
+ return s?.constructor !== n && (s?._$AO?.(!1), n === void 0 ? s = void 0 : (s = new n(i), s._$AT(i, t, r)), r !== void 0 ? (t._$Co ?? (t._$Co = []))[r] = s : t._$Cl = s), s !== void 0 && (e = Te(i, s._$AS(i, e.values), s, r)), e;
+}
+class pi {
+ constructor(e, t) {
+ this._$AV = [], this._$AN = void 0, this._$AD = e, this._$AM = t;
+ }
+ get parentNode() {
+ return this._$AM.parentNode;
+ }
+ get _$AU() {
+ return this._$AM._$AU;
+ }
+ u(e) {
+ const { el: { content: t }, parts: r } = this._$AD, s = (e?.creationScope ?? ge).importNode(t, !0);
+ me.currentNode = s;
+ let n = me.nextNode(), o = 0, l = 0, a = r[0];
+ for (; a !== void 0; ) {
+ if (o === a.index) {
+ let c;
+ a.type === 2 ? c = new Xe(n, n.nextSibling, this, e) : a.type === 1 ? c = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (c = new _i(n, this, e)), this._$AV.push(c), a = r[++l];
+ }
+ o !== a?.index && (n = me.nextNode(), o++);
+ }
+ return me.currentNode = ge, s;
+ }
+ p(e) {
+ let t = 0;
+ for (const r of this._$AV) r !== void 0 && (r.strings !== void 0 ? (r._$AI(e, r, t), t += r.strings.length - 2) : r._$AI(e[t])), t++;
+ }
+}
+class Xe {
+ get _$AU() {
+ return this._$AM?._$AU ?? this._$Cv;
+ }
+ constructor(e, t, r, s) {
+ this.type = 2, this._$AH = w, this._$AN = void 0, this._$AA = e, this._$AB = t, this._$AM = r, this.options = s, this._$Cv = s?.isConnected ?? !0;
+ }
+ get parentNode() {
+ let e = this._$AA.parentNode;
+ const t = this._$AM;
+ return t !== void 0 && e?.nodeType === 11 && (e = t.parentNode), e;
+ }
+ get startNode() {
+ return this._$AA;
+ }
+ get endNode() {
+ return this._$AB;
+ }
+ _$AI(e, t = this) {
+ e = Te(this, e, t), je(e) ? e === w || e == null || e === "" ? (this._$AH !== w && this._$AR(), this._$AH = w) : e !== this._$AH && e !== Fe && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : li(e) ? this.k(e) : this._(e);
+ }
+ O(e) {
+ return this._$AA.parentNode.insertBefore(e, this._$AB);
+ }
+ T(e) {
+ this._$AH !== e && (this._$AR(), this._$AH = this.O(e));
+ }
+ _(e) {
+ this._$AH !== w && je(this._$AH) ? this._$AA.nextSibling.data = e : this.T(ge.createTextNode(e)), this._$AH = e;
+ }
+ $(e) {
+ const { values: t, _$litType$: r } = e, s = typeof r == "number" ? this._$AC(e) : (r.el === void 0 && (r.el = Ye.createElement(cs(r.h, r.h[0]), this.options)), r);
+ if (this._$AH?._$AD === s) this._$AH.p(t);
+ else {
+ const n = new pi(s, this), o = n.u(this.options);
+ n.p(t), this.T(o), this._$AH = n;
+ }
+ }
+ _$AC(e) {
+ let t = or.get(e.strings);
+ return t === void 0 && or.set(e.strings, t = new Ye(e)), t;
+ }
+ k(e) {
+ Gt(this._$AH) || (this._$AH = [], this._$AR());
+ const t = this._$AH;
+ let r, s = 0;
+ for (const n of e) s === t.length ? t.push(r = new Xe(this.O(Ve()), this.O(Ve()), this, this.options)) : r = t[s], r._$AI(n), s++;
+ s < t.length && (this._$AR(r && r._$AB.nextSibling, s), t.length = s);
+ }
+ _$AR(e = this._$AA.nextSibling, t) {
+ for (this._$AP?.(!1, !0, t); e !== this._$AB; ) {
+ const r = er(e).nextSibling;
+ er(e).remove(), e = r;
+ }
+ }
+ setConnected(e) {
+ this._$AM === void 0 && (this._$Cv = e, this._$AP?.(e));
+ }
+}
+class ft {
+ get tagName() {
+ return this.element.tagName;
+ }
+ get _$AU() {
+ return this._$AM._$AU;
+ }
+ constructor(e, t, r, s, n) {
+ this.type = 1, this._$AH = w, this._$AN = void 0, this.element = e, this.name = t, this._$AM = s, this.options = n, r.length > 2 || r[0] !== "" || r[1] !== "" ? (this._$AH = Array(r.length - 1).fill(new String()), this.strings = r) : this._$AH = w;
+ }
+ _$AI(e, t = this, r, s) {
+ const n = this.strings;
+ let o = !1;
+ if (n === void 0) e = Te(this, e, t, 0), o = !je(e) || e !== this._$AH && e !== Fe, o && (this._$AH = e);
+ else {
+ const l = e;
+ let a, c;
+ for (e = n[0], a = 0; a < n.length - 1; a++) c = Te(this, l[r + a], t, a), c === Fe && (c = this._$AH[a]), o || (o = !je(c) || c !== this._$AH[a]), c === w ? e = w : e !== w && (e += (c ?? "") + n[a + 1]), this._$AH[a] = c;
+ }
+ o && !s && this.j(e);
+ }
+ j(e) {
+ e === w ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, e ?? "");
+ }
+}
+class di extends ft {
+ constructor() {
+ super(...arguments), this.type = 3;
+ }
+ j(e) {
+ this.element[this.name] = e === w ? void 0 : e;
+ }
+}
+class hi extends ft {
+ constructor() {
+ super(...arguments), this.type = 4;
+ }
+ j(e) {
+ this.element.toggleAttribute(this.name, !!e && e !== w);
+ }
+}
+class ui extends ft {
+ constructor(e, t, r, s, n) {
+ super(e, t, r, s, n), this.type = 5;
+ }
+ _$AI(e, t = this) {
+ if ((e = Te(this, e, t, 0) ?? w) === Fe) return;
+ const r = this._$AH, s = e === w && r !== w || e.capture !== r.capture || e.once !== r.once || e.passive !== r.passive, n = e !== w && (r === w || s);
+ s && this.element.removeEventListener(this.name, this, r), n && this.element.addEventListener(this.name, this, e), this._$AH = e;
+ }
+ handleEvent(e) {
+ typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, e) : this._$AH.handleEvent(e);
+ }
+}
+class _i {
+ constructor(e, t, r) {
+ this.element = e, this.type = 6, this._$AN = void 0, this._$AM = t, this.options = r;
+ }
+ get _$AU() {
+ return this._$AM._$AU;
+ }
+ _$AI(e) {
+ Te(this, e);
+ }
+}
+const mi = We.litHtmlPolyfillSupport;
+mi?.(Ye, Xe), (We.litHtmlVersions ?? (We.litHtmlVersions = [])).push("3.3.2");
+const gi = (i, e, t) => {
+ const r = t?.renderBefore ?? e;
+ let s = r._$litPart$;
+ if (s === void 0) {
+ const n = t?.renderBefore ?? null;
+ r._$litPart$ = s = new Xe(e.insertBefore(Ve(), n), n, void 0, t ?? {});
+ }
+ return s._$AI(i), s;
+};
+const Ke = globalThis;
+class Ie extends ke {
+ constructor() {
+ super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0;
+ }
+ createRenderRoot() {
+ var t;
+ const e = super.createRenderRoot();
+ return (t = this.renderOptions).renderBefore ?? (t.renderBefore = e.firstChild), e;
+ }
+ update(e) {
+ const t = this.render();
+ this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = gi(t, this.renderRoot, this.renderOptions);
+ }
+ connectedCallback() {
+ super.connectedCallback(), this._$Do?.setConnected(!0);
+ }
+ disconnectedCallback() {
+ super.disconnectedCallback(), this._$Do?.setConnected(!1);
+ }
+ render() {
+ return Fe;
+ }
+}
+Ie._$litElement$ = !0, Ie.finalized = !0, Ke.litElementHydrateSupport?.({ LitElement: Ie });
+const fi = Ke.litElementPolyfillSupport;
+fi?.({ LitElement: Ie });
+(Ke.litElementVersions ?? (Ke.litElementVersions = [])).push("4.2.2");
+const yi = (i) => Object.is(i, -0) ? 0 : i, vi = (i) => Math.min(Math.max(Math.round(i), 0), 4), wi = (i, e, t, r) => {
+ const s = Number(i);
+ if (!Number.isFinite(s))
+ return null;
+ const n = vi(t), o = new Intl.NumberFormat(e, {
+ minimumFractionDigits: n,
+ maximumFractionDigits: n
+ }).format(yi(s)), l = r?.trim();
+ return l ? `${o} ${l}` : o;
+}, bi = "sensor.power_production_now", xi = "sensor.energy_production_today", Pi = {
+ power: bi,
+ energy: xi
+}, at = (i) => Pi[i], Si = (i, e) => {
+ const t = at(e);
+ return i?.states?.[t] ? t : null;
+}, $i = 1100, Ci = 900, ar = 900, Ei = 6e3, ki = 5e3, Ii = 16e3, Pt = /* @__PURE__ */ new Set(["deviation", "inverter", "error"]);
+class Fi {
+ constructor(e) {
+ this._flags = {
+ enabled: !1,
+ powerFlow: !1,
+ updateShimmer: !1,
+ alertRipple: !1
+ }, this._panelShimmers = /* @__PURE__ */ new Set(), this._kpiShimmers = /* @__PURE__ */ new Set(), this._alertRipples = /* @__PURE__ */ new Set(), this._timers = /* @__PURE__ */ new Map(), this._flowPulsePanelId = null, this._flowPulseIndex = 0, this._flowPulsePanelOrder = [], this._reducedMotion = !1, this._visibilityHandler = () => {
+ this._snapshot && this._refreshFlowPulseLoop(this._snapshot);
+ }, this._mediaQueryHandler = (t) => {
+ this._reducedMotion = t.matches, this._clearAllMotion(), this._notify();
+ }, this._notify = e, typeof window < "u" && (window.document.addEventListener("visibilitychange", this._visibilityHandler), typeof window.matchMedia == "function" && (this._mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"), this._reducedMotion = this._mediaQuery.matches, this._mediaQuery.addEventListener("change", this._mediaQueryHandler)));
+ }
+ destroy() {
+ typeof window < "u" && window.document.removeEventListener("visibilitychange", this._visibilityHandler), this._mediaQuery?.removeEventListener("change", this._mediaQueryHandler), this._clearAllMotion(), this._clearTimeoutByKey("flow-pulse-step"), this._clearTimeoutByKey("flow-pulse-timeout");
+ }
+ update(e, t) {
+ const r = this._snapshot;
+ if (this._flags = e, this._snapshot = t, !e.enabled || this._reducedMotion) {
+ this._clearAllMotion(), this._refreshFlowPulseLoop(t);
+ return;
+ }
+ e.updateShimmer && r && this._triggerKpiShimmers(r, t), e.alertRipple && r && this._triggerAlertRipples(r, t), this._syncAlertRippleLoops(t), this._refreshFlowPulseLoop(t);
+ }
+ getState() {
+ const e = this._snapshot;
+ return {
+ panelShimmers: new Set(this._panelShimmers),
+ kpiShimmers: new Set(this._kpiShimmers),
+ alertRipples: new Set(this._alertRipples),
+ powerFlowActive: !!(this._flags.enabled && this._flags.powerFlow && !this._reducedMotion && e && e.totalPower > 0 && !e.popupOpen),
+ flowPulsePanelId: this._flowPulsePanelId,
+ reducedMotion: this._reducedMotion
+ };
+ }
+ triggerPowerImpactFromPulse() {
+ const e = this._snapshot;
+ e && (!this._flags.enabled || !this._flags.powerFlow || this._reducedMotion || e.popupOpen || e.totalPower <= 0 || this._armTransient("power", this._kpiShimmers, Ci, "kpi-shimmer"));
+ }
+ completeFlowPulse() {
+ const e = this._flowPulsePanelId;
+ e && this._finalizeActiveFlowPulse(e);
+ }
+ _refreshFlowPulseLoop(e) {
+ const t = Object.entries(e.panelPowers).filter(([, o]) => typeof o == "number" && o > 0).map(([o]) => o);
+ if (!this._canRunFlowPulse(e, t.length)) {
+ this._clearFlowPulseLoop();
+ return;
+ }
+ const s = t.join("|"), n = this._flowPulsePanelOrder.join("|");
+ s !== n && (this._flowPulsePanelOrder = t, this._flowPulseIndex = 0, this._clearTimeoutByKey("flow-pulse-step"), this._clearTimeoutByKey("flow-pulse-timeout"), this._flowPulsePanelId !== null && (this._flowPulsePanelId = null, this._notify())), !(this._timers.has("flow-pulse-step") || this._timers.has("flow-pulse-timeout") || this._flowPulsePanelId !== null) && this._triggerNextFlowPulse();
+ }
+ _canRunFlowPulse(e, t) {
+ const r = typeof document < "u" ? document.hidden : !1;
+ return !!(this._flags.enabled && this._flags.powerFlow && !this._reducedMotion && !r && e && e.totalPower > 0 && !e.popupOpen && t > 0);
+ }
+ _triggerNextFlowPulse() {
+ if (!this._canRunFlowPulse(this._snapshot, this._flowPulsePanelOrder.length)) {
+ this._clearFlowPulseLoop();
+ return;
+ }
+ const e = this._flowPulsePanelOrder[this._flowPulseIndex % this._flowPulsePanelOrder.length];
+ this._flowPulseIndex = (this._flowPulseIndex + 1) % Math.max(this._flowPulsePanelOrder.length, 1), this._flowPulsePanelId = e, this._notify(), this._clearTimeoutByKey("flow-pulse-timeout");
+ const t = window.setTimeout(() => {
+ this._timers.delete("flow-pulse-timeout"), this._finalizeActiveFlowPulse(e);
+ }, Ii);
+ this._timers.set("flow-pulse-timeout", t);
+ }
+ _scheduleNextFlowPulse() {
+ this._clearTimeoutByKey("flow-pulse-step");
+ const e = window.setTimeout(() => {
+ this._timers.delete("flow-pulse-step"), this._triggerNextFlowPulse();
+ }, ki);
+ this._timers.set("flow-pulse-step", e);
+ }
+ _finalizeActiveFlowPulse(e) {
+ this._flowPulsePanelId === e && (this._clearTimeoutByKey("flow-pulse-timeout"), this._flowPulsePanelId = null, this._notify(), this.triggerPowerImpactFromPulse(), this._scheduleNextFlowPulse());
+ }
+ _clearFlowPulseLoop() {
+ this._clearTimeoutByKey("flow-pulse-step"), this._clearTimeoutByKey("flow-pulse-timeout"), this._flowPulsePanelOrder = [], this._flowPulseIndex = 0, this._flowPulsePanelId !== null && (this._flowPulsePanelId = null, this._notify());
+ }
+ _triggerKpiShimmers(e, t) {
+ e.energyKpiValue !== t.energyKpiValue && this._armTransient("energy", this._kpiShimmers, $i, "kpi-shimmer");
+ }
+ _triggerAlertRipples(e, t) {
+ for (const [r, s] of Object.entries(t.panelStatuses)) {
+ const n = e.panelStatuses[r];
+ s !== n && Pt.has(s) && this._armTransient(r, this._alertRipples, ar, "alert-ripple");
+ }
+ }
+ _syncAlertRippleLoops(e) {
+ if (!this._flags.alertRipple) {
+ for (const r of [...this._timers.keys()])
+ r.startsWith("alert-ripple-loop:") && this._clearTimeoutByKey(r);
+ return;
+ }
+ const t = new Set(
+ Object.entries(e.panelStatuses).filter(([, r]) => Pt.has(r)).map(([r]) => r)
+ );
+ for (const r of t) {
+ const s = this._alertRippleLoopKey(r);
+ this._timers.has(s) || this._armAlertRippleLoop(r);
+ }
+ for (const r of [...this._timers.keys()]) {
+ if (!r.startsWith("alert-ripple-loop:"))
+ continue;
+ const s = r.slice(18);
+ t.has(s) || this._clearTimeoutByKey(r);
+ }
+ }
+ _armAlertRippleLoop(e) {
+ const t = this._alertRippleLoopKey(e);
+ this._clearTimeoutByKey(t);
+ const r = window.setTimeout(() => {
+ if (!this._canRepeatAlertRipple(e)) {
+ this._clearTimeoutByKey(t);
+ return;
+ }
+ this._armTransient(e, this._alertRipples, ar, "alert-ripple"), this._armAlertRippleLoop(e);
+ }, Ei);
+ this._timers.set(t, r);
+ }
+ _canRepeatAlertRipple(e) {
+ const t = this._snapshot;
+ if (!t || !this._flags.enabled || !this._flags.alertRipple || this._reducedMotion)
+ return !1;
+ const r = t.panelStatuses[e];
+ return Pt.has(r);
+ }
+ _alertRippleLoopKey(e) {
+ return `alert-ripple-loop:${e}`;
+ }
+ _armTransient(e, t, r, s) {
+ t.add(e), this._notify();
+ const n = `${s}:${e}`;
+ this._clearTimeoutByKey(n);
+ const o = window.setTimeout(() => {
+ t.delete(e), this._timers.delete(n), this._notify();
+ }, r);
+ this._timers.set(n, o);
+ }
+ _clearAllMotion() {
+ this._panelShimmers.clear(), this._kpiShimmers.clear(), this._alertRipples.clear(), this._flowPulsePanelOrder = [], this._flowPulseIndex = 0, this._flowPulsePanelId = null;
+ for (const e of [...this._timers.keys()])
+ (e.startsWith("panel-shimmer:") || e.startsWith("kpi-shimmer:") || e.startsWith("alert-ripple:") || e.startsWith("alert-ripple-loop:") || e.startsWith("flow-pulse-")) && this._clearTimeoutByKey(e);
+ }
+ _clearTimeoutByKey(e) {
+ const t = this._timers.get(e);
+ t !== void 0 && (window.clearTimeout(t), this._timers.delete(e));
+ }
+}
+const Q = (i, e, t) => Math.max(e, Math.min(t, i)), lr = (i, e) => Math.abs(i - e) < 0.01, Ti = (i, e) => Math.hypot(e.x - i.x, e.y - i.y), Mi = (i, e) => ({
+ x: i.left - e.left + i.width / 2,
+ y: i.top - e.top + i.height / 2
+}), Ge = "rgba(224, 232, 242, 1)", Ai = 8, cr = 28, it = 14, Ri = (i) => {
+ const e = [];
+ for (const t of i) {
+ const r = e[e.length - 1];
+ r && lr(r.x, t.x) && lr(r.y, t.y) || e.push(t);
+ }
+ return e;
+}, pr = (i) => {
+ const e = Ri(i);
+ if (e.length < 2)
+ return { d: "", length: 0 };
+ let t = `M ${e[0].x.toFixed(2)} ${e[0].y.toFixed(2)}`, r = 0;
+ for (let s = 1; s < e.length; s += 1) {
+ const n = e[s - 1], o = e[s];
+ t += ` L ${o.x.toFixed(2)} ${o.y.toFixed(2)}`, r += Ti(n, o);
+ }
+ return { d: t, length: r };
+}, Ni = (i, e, t, r) => {
+ const s = t - e;
+ if (!Number.isFinite(i) || !Number.isFinite(e) || !Number.isFinite(t) || !Number.isFinite(r) || i <= 0 || s <= 0)
+ return !1;
+ const n = e + s * 0.28, o = i * 0.36;
+ return r >= Math.max(n, o) && r <= t;
+}, Li = (i, e, t = !1, r = { x: 0, y: 0 }) => {
+ const s = Mi(e, i), n = e.left - i.left, o = e.bottom - i.top;
+ return {
+ entry: {
+ x: (t ? s.x : n - 0.5) + r.x,
+ y: (t ? o + 0.5 : s.y) + r.y
+ }
+ };
+}, Di = (i) => {
+ const e = i.left + (i.right - i.left) * 0.22;
+ return Q(i.statusCenterX || e, i.left + 10, i.right - 10);
+}, Oi = (i) => {
+ if (i.length === 0)
+ return [];
+ const e = i.reduce((l, a) => l + Math.max(1, a.bottom - a.top), 0) / i.length, t = Q(e * 0.32, 6, 28), r = [], s = [], n = new Array(i.length).fill(0), o = i.map((l, a) => ({ item: l, index: a })).sort((l, a) => l.item.top - a.item.top || l.item.left - a.item.left);
+ for (const { item: l, index: a } of o) {
+ const c = l.top + (l.bottom - l.top) / 2;
+ let p = r.findIndex((d) => Math.abs(d - c) <= t);
+ p < 0 ? (p = r.length, r.push(c), s.push(1)) : (s[p] += 1, r[p] = r[p] + (c - r[p]) / s[p]), n[a] = p;
+ }
+ return n;
+}, Gi = (i, e, t, r, s = Math.min(...i.map((o) => o.left)), n = "left-collector") => {
+ const o = [], l = [], a = [], c = [], p = (m, P, k) => {
+ const F = pr(P);
+ F.d && l.push({
+ id: m,
+ d: F.d,
+ color: Ge,
+ opacity: k,
+ delayMs: 0,
+ durationMs: 1200,
+ travelPx: Math.max(24, F.length)
+ });
+ }, d = (m) => {
+ a.some((k) => {
+ const F = k.cx - m.cx, A = k.cy - m.cy;
+ return Math.hypot(F, A) < Math.max(k.r, m.r) * 0.9;
+ }) || a.push(m);
+ }, h = /* @__PURE__ */ new Map();
+ for (const m of i) {
+ const P = h.get(m.row) ?? [];
+ P.push(m), h.set(m.row, P);
+ }
+ const u = [...h.entries()].sort((m, P) => m[0] - P[0]).map(([m, P]) => ({
+ row: m,
+ busY: 0,
+ minTop: Math.min(...P.map((k) => k.top)),
+ maxBottom: Math.max(...P.map((k) => k.bottom))
+ }));
+ if (u.length === 0)
+ return { flowPaths: o, topologyPaths: l, topologyNodes: a, collectorX: it, rowBuses: c };
+ const g = Math.max(0, Math.min(s, ...i.map((m) => m.left))), f = Math.max(6, it * 0.5), v = Math.max(f, g - 8), x = Q(
+ g / 2,
+ f,
+ Math.min(v, Math.max(it, r - it))
+ ), S = e.y + 8;
+ for (let m = 0; m < u.length; m += 1) {
+ const P = u[m], k = u[m - 1], F = P.minTop - 12, A = m === 0 ? S : Math.max(k.maxBottom + 8, k.busY + 8), O = P.minTop - 6;
+ P.busY = O > A ? Q(F, A, O) : Math.max(S, P.minTop - 6);
+ }
+ const I = Q(0.24 / Math.sqrt(Math.max(u.length, 1)), 0.15, 0.22), M = Q(0.24 / Math.sqrt(Math.max(i.length, 1)), 0.09, 0.16);
+ d({
+ id: "power-socket-node",
+ cx: e.x,
+ cy: e.y,
+ r: Q(t * 0.18, 2.8, 4.8),
+ color: Ge,
+ opacity: 0.36
+ });
+ const $ = u[0], E = Math.max(...u.map((m) => m.busY)), L = n === "direct-first-row", R = u.length > 1;
+ L ? (p(
+ "power-drop-rail",
+ [
+ e,
+ { x: e.x, y: $.busY }
+ ],
+ I
+ ), d({
+ id: "power-drop-node",
+ cx: e.x,
+ cy: $.busY,
+ r: Q(t * 0.18, 2.8, 4.8),
+ color: Ge,
+ opacity: 0.36
+ }), R && p(
+ "collector-rail",
+ [
+ { x, y: $.busY },
+ { x, y: E }
+ ],
+ I
+ )) : (p(
+ "collector-rail",
+ [
+ { x, y: e.y },
+ { x, y: E }
+ ],
+ I
+ ), p(
+ "power-entry-rail",
+ [
+ { x, y: e.y },
+ e
+ ],
+ I
+ ));
+ for (const m of u) {
+ const P = h.get(m.row) ?? [];
+ if (P.length === 0)
+ continue;
+ const k = P.map((b) => b.anchorX), F = m.row === $.row, A = L && F ? [e.x, ...k, ...R ? [x] : []] : [x, ...k], O = Math.min(...A), se = Math.max(...A);
+ c.push({
+ row: m.row,
+ y: m.busY,
+ startX: O,
+ endX: se
+ }), p(
+ `row-rail-${m.row}`,
+ [
+ { x: O, y: m.busY },
+ { x: se, y: m.busY }
+ ],
+ I
+ ), (!L || !F || R) && d({
+ id: `row-node-${m.row}`,
+ cx: x,
+ cy: m.busY,
+ r: Q(t * 0.2, 2.8, 4.8),
+ color: Ge,
+ opacity: 0.32
+ });
+ for (const b of P) {
+ const C = L && F ? [
+ { x: b.anchorX, y: b.anchorY },
+ { x: b.anchorX, y: m.busY },
+ { x: e.x, y: m.busY },
+ e
+ ] : L ? [
+ { x: b.anchorX, y: b.anchorY },
+ { x: b.anchorX, y: m.busY },
+ { x, y: m.busY },
+ { x, y: $.busY },
+ { x: e.x, y: $.busY },
+ e
+ ] : [
+ { x: b.anchorX, y: b.anchorY },
+ { x: b.anchorX, y: m.busY },
+ { x, y: m.busY },
+ { x, y: e.y },
+ e
+ ], W = pr(
+ C
+ );
+ if (p(
+ `tap-rail-${b.id}`,
+ [
+ { x: b.anchorX, y: b.anchorY },
+ { x: b.anchorX, y: m.busY }
+ ],
+ M
+ ), d({
+ id: `tap-node-${b.id}`,
+ cx: b.anchorX,
+ cy: m.busY,
+ r: Q(t * 0.18, 2.6, 4.4),
+ color: Ge,
+ opacity: 0.3
+ }), !b.producing || !W.d)
+ continue;
+ const pe = Math.max(cr, W.length), K = pe + cr;
+ o.push({
+ id: `flow-${b.id}`,
+ panelId: b.id,
+ d: W.d,
+ color: b.accentColor,
+ opacity: 0.62 + Math.min(b.intensity, 1) * 0.2,
+ delayMs: 0,
+ durationMs: Math.round(K * Ai),
+ travelPx: pe,
+ offsetPx: -K
+ });
+ }
+ }
+ return { flowPaths: o, topologyPaths: l, topologyNodes: a, collectorX: x, rowBuses: c };
+}, Hi = (i, e, t, r, s, n, o) => {
+ const l = i.map((c) => ({
+ id: c.panel.id,
+ row: c.row,
+ anchorX: Di(c),
+ anchorY: c.top + 1.5,
+ left: c.left,
+ right: c.right,
+ top: c.top,
+ bottom: c.bottom,
+ accentColor: c.panel.accentColor,
+ intensity: c.panel.intensity,
+ producing: e.has(c.panel.id)
+ })), a = Gi(
+ l,
+ t.entry,
+ r,
+ s,
+ n,
+ o
+ );
+ return {
+ flowPaths: a.flowPaths,
+ topologyPaths: a.topologyPaths,
+ topologyNodes: a.topologyNodes
+ };
+}, zi = (i, e, t, r) => {
+ const s = e.querySelector("ha-card");
+ if (!s)
+ return null;
+ const n = s.getBoundingClientRect();
+ if (n.width <= 0 || n.height <= 0)
+ return null;
+ const o = {
+ x: s.scrollLeft,
+ y: s.scrollTop
+ }, l = e.querySelector('[data-kpi="power"]');
+ if (!l)
+ return null;
+ const a = r.panels.map((m) => e.querySelector(`[data-panel-id="${m.id}"]`)).filter((m) => !!m).map((m) => {
+ const P = m.getBoundingClientRect();
+ return {
+ left: P.left - n.left + o.x,
+ right: P.right - n.left + o.x,
+ top: P.top - n.top + o.y,
+ bottom: P.bottom - n.top + o.y
+ };
+ }).filter((m) => Number.isFinite(m.left) && Number.isFinite(m.right)), c = a.length > 0 ? Math.min(...a.map((m) => m.left)) : 0, p = a.length > 0 ? Math.max(...a.map((m) => m.right)) : n.width, d = r.panels.filter(
+ (m) => !m.hiddenSlot && m.enabled && !!m.powerEntityName
+ );
+ if (d.length === 0)
+ return null;
+ const h = new Set(
+ d.filter((m) => typeof m.power == "number" && m.power > 0).map((m) => m.id)
+ ), u = d.map((m) => ({
+ panel: m,
+ element: e.querySelector(`[data-panel-id="${m.id}"]`)
+ })).filter(
+ (m) => !!m.element
+ ).map((m) => {
+ const P = m.element.getBoundingClientRect(), k = {
+ x: P.left - n.left + o.x + P.width / 2,
+ y: P.top - n.top + o.y + P.height / 2
+ }, A = m.element.querySelector(".status")?.getBoundingClientRect(), O = A && A.width > 0 ? A.left - n.left + o.x + A.width * 0.33 : k.x;
+ return {
+ panel: m.panel,
+ row: 0,
+ center: k,
+ statusCenterX: O,
+ left: P.left - n.left + o.x,
+ right: P.right - n.left + o.x,
+ top: P.top - n.top + o.y,
+ bottom: P.bottom - n.top + o.y
+ };
+ }), g = Oi(u), f = u.map((m, P) => ({
+ ...m,
+ row: g[P] ?? 0
+ }));
+ if (f.length === 0)
+ return null;
+ const v = f.reduce((m, P) => {
+ const k = P.right - P.left, F = P.bottom - P.top;
+ return m + Math.min(k, F);
+ }, 0) / f.length, x = Q(Math.round(v * 0.2), 18, 34), S = l.getBoundingClientRect(), I = S.left - n.left + o.x + S.width / 2, M = Ni(
+ n.width,
+ c,
+ p,
+ I
+ ), $ = Li(
+ n,
+ S,
+ M,
+ o
+ ), { flowPaths: E, topologyPaths: L, topologyNodes: R } = Hi(
+ f,
+ h,
+ $,
+ x,
+ n.width,
+ c,
+ M ? "direct-first-row" : "left-collector"
+ );
+ return {
+ width: Math.max(
+ s.clientWidth,
+ n.width,
+ p,
+ $.entry.x
+ ),
+ height: Math.max(
+ s.clientHeight,
+ ...f.map((m) => m.bottom),
+ $.entry.y
+ ),
+ flowPaths: E,
+ topologyPaths: L,
+ topologyNodes: R
+ };
+}, Bi = (i, e) => {
+ if (!i || i.flowPaths.length === 0 && i.topologyPaths.length === 0)
+ return w;
+ const t = i.flowPaths.filter(
+ (r) => e.showFlow && e.activeFlowPanelId !== null && r.panelId === e.activeFlowPanelId
+ );
+ return y`
+
+ `;
+}, Ui = Ot`
+ .chrome {
+ display: none;
+ }
+
+ .panel {
+ background: linear-gradient(180deg, rgba(8, 17, 32, 0.94), rgba(8, 17, 32, 0.98));
+ border-color: var(--panel-border-accent, rgba(255, 255, 255, 0.2));
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.07),
+ 0 0 calc(1px + var(--panel-intensity, 0.4) * 2.5px) var(--panel-glow-accent, transparent);
+ transition: border-color 180ms ease, box-shadow 180ms ease;
+ }
+
+ .panel::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ pointer-events: none;
+ background:
+ radial-gradient(circle at 70% 78%, var(--panel-fill-accent, transparent) 0%, transparent 62%),
+ radial-gradient(
+ circle at 36% 62%,
+ color-mix(in srgb, var(--panel-fill-accent, transparent) 65%, transparent) 0%,
+ transparent 58%
+ );
+ opacity: 1;
+ filter: blur(1.5px);
+ }
+
+ .panel::before {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 36%);
+ opacity: 0.42;
+ }
+
+ .panel:hover {
+ transform: none;
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.09),
+ 0 0 calc(2px + var(--panel-intensity, 0.4) * 3.5px) var(--panel-glow-accent, transparent);
+ }
+
+ .panel.hidden-slot {
+ background: linear-gradient(180deg, rgba(10, 17, 29, 0.78), rgba(7, 12, 21, 0.82));
+ border-color: rgba(255, 255, 255, 0.18) !important;
+ box-shadow: none;
+ }
+
+ ha-card[data-spv-theme="light"] .panel {
+ background: linear-gradient(180deg, rgba(251, 254, 255, 0.96), rgba(230, 240, 249, 0.98));
+ border-color: var(--panel-border-accent, rgba(45, 67, 90, 0.24));
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.9),
+ 0 0 calc(1px + var(--panel-intensity, 0.4) * 2px) var(--panel-glow-accent, transparent);
+ }
+
+ ha-card[data-spv-theme="light"] .panel::before {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.42), transparent 38%);
+ opacity: 0.36;
+ }
+
+ ha-card[data-spv-theme="light"] .panel::after {
+ background:
+ radial-gradient(circle at 70% 78%, var(--panel-fill-accent, transparent) 0%, transparent 64%),
+ radial-gradient(
+ circle at 36% 62%,
+ color-mix(in srgb, var(--panel-fill-accent, transparent) 48%, transparent) 0%,
+ transparent 58%
+ );
+ filter: blur(1px);
+ opacity: 0.72;
+ }
+
+ ha-card[data-spv-theme="light"] .panel:hover {
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.94),
+ 0 0 calc(2px + var(--panel-intensity, 0.4) * 3px) var(--panel-glow-accent, transparent);
+ }
+
+ ha-card[data-spv-theme="light"] .panel.hidden-slot {
+ background: linear-gradient(180deg, rgba(238, 246, 252, 0.72), rgba(222, 234, 244, 0.82));
+ border-color: rgba(58, 80, 104, 0.32) !important;
+ }
+
+ ha-card[data-spv-theme="light"] .spv-motion-backbone {
+ stroke: rgba(74, 171, 204, 0.78);
+ stroke-width: 3.1;
+ opacity: calc(var(--spv-motion-opacity, 0.18) * 2.25 + 0.34);
+ mix-blend-mode: normal;
+ filter:
+ drop-shadow(0 1px 0 rgba(255, 255, 255, 0.72))
+ drop-shadow(0 0 2px rgba(74, 171, 204, 0.24));
+ }
+
+ ha-card[data-spv-theme="light"] .spv-motion-node {
+ fill: rgba(74, 171, 204, 0.78);
+ opacity: 0.92;
+ mix-blend-mode: normal;
+ filter:
+ drop-shadow(0 1px 0 rgba(255, 255, 255, 0.75))
+ drop-shadow(0 0 2px rgba(74, 171, 204, 0.24));
+ }
+
+ :host([data-spv-busbars]) .grid {
+ column-gap: 16px;
+ row-gap: 22px;
+ margin-top: 20px;
+ }
+
+ .spv-motion-overlay-svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+ overflow: visible;
+ }
+
+ .spv-motion-flow,
+ .spv-motion-backbone {
+ fill: none;
+ stroke-linejoin: miter;
+ pointer-events: none;
+ }
+
+ .spv-motion-backbone {
+ stroke: color-mix(in srgb, var(--secondary-text-color, #cdd7e4) 82%, white 18%);
+ stroke-linecap: butt;
+ stroke-width: 2.8;
+ opacity: var(--spv-motion-opacity, 0.22);
+ mix-blend-mode: screen;
+ filter: drop-shadow(
+ 0 0 2px color-mix(in srgb, var(--secondary-text-color, #cdd7e4) 24%, transparent)
+ );
+ }
+
+ .spv-motion-node {
+ fill: color-mix(in srgb, var(--secondary-text-color, #d7e0eb) 88%, white 12%);
+ opacity: var(--spv-motion-opacity, 0.75);
+ filter: drop-shadow(
+ 0 0 2px color-mix(in srgb, var(--secondary-text-color, #d7e0eb) 32%, transparent)
+ );
+ }
+
+ .spv-motion-node.is-socket-impact {
+ animation: spv-socket-impact 900ms ease-out;
+ }
+
+ .spv-motion-flow {
+ stroke-linecap: round;
+ stroke: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--spv-motion-color, rgba(111, 201, 255, 0.9)) 62%, #ff9d35) 82%,
+ #ffd08a
+ );
+ stroke-width: 8.4;
+ stroke-dasharray: 28 2500;
+ opacity: var(--spv-motion-opacity, 0.68);
+ mix-blend-mode: screen;
+ filter:
+ drop-shadow(0 0 10px color-mix(in srgb, var(--spv-motion-color) 48%, #ff9d35 52%))
+ drop-shadow(0 0 20px color-mix(in srgb, var(--spv-motion-color) 36%, #ffb04e 64%));
+ animation: spv-flow-pulse var(--spv-flow-duration, 1800ms) linear;
+ animation-delay: var(--spv-flow-delay, 0ms);
+ animation-fill-mode: both;
+ }
+
+ ha-card[data-spv-theme="light"] .spv-motion-flow {
+ stroke: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--spv-motion-color, #11a36a) 66%, #f59f22) 82%,
+ #4f3406
+ );
+ stroke-width: 9.4;
+ opacity: 0.96;
+ mix-blend-mode: normal;
+ filter:
+ drop-shadow(0 0 4px color-mix(in srgb, var(--spv-motion-color, #11a36a) 64%, #f59f22 36%))
+ drop-shadow(0 0 10px rgba(138, 91, 12, 0.38))
+ drop-shadow(0 1px 0 rgba(255, 255, 255, 0.52));
+ }
+
+ .summary-chip.spv-motion-shimmer {
+ background-size: 220% 100%, auto, auto;
+ animation: spv-shimmer 1100ms cubic-bezier(0.2, 0.85, 0.2, 1);
+ background-image:
+ linear-gradient(
+ 112deg,
+ transparent 18%,
+ rgba(255, 255, 255, 0.14) 46%,
+ rgba(255, 255, 255, 0.03) 58%,
+ transparent 76%
+ ),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.05));
+ }
+
+ .summary-chip.spv-motion-power-impact {
+ --spv-impact-color: color-mix(
+ in srgb,
+ var(--spv-shimmer-color, rgba(111, 201, 255, 0.6)) 70%,
+ transparent
+ );
+ animation: spv-alert-ripple 900ms ease-out;
+ }
+
+ .summary-chip.spv-motion-neutral-impact {
+ --spv-impact-color: rgba(214, 223, 235, 0.36);
+ animation: spv-alert-ripple 900ms ease-out;
+ }
+
+ .panel.spv-motion-alert-ripple {
+ --spv-impact-color: rgba(255, 98, 127, 0.42);
+ animation: spv-alert-ripple 900ms ease-out;
+ }
+
+ .panel.spv-motion-alert-ripple .status {
+ animation: spv-alert-badge 900ms ease-out;
+ }
+
+ .panel .inverter-status {
+ position: relative;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--spv-text-soft);
+ }
+
+ @keyframes spv-flow-pulse {
+ 0% {
+ opacity: var(--spv-motion-opacity, 0.82);
+ stroke-dashoffset: 0;
+ }
+ 100% {
+ opacity: var(--spv-motion-opacity, 0.82);
+ stroke-dashoffset: var(--spv-flow-offset, calc(-1 * var(--spv-flow-travel, 220px)));
+ }
+ }
+
+ @keyframes spv-shimmer {
+ 0% {
+ background-position: 140% 0, 0 0, 0 0;
+ }
+ 100% {
+ background-position: -120% 0, 0 0, 0 0;
+ }
+ }
+
+ @keyframes spv-socket-impact {
+ 0% {
+ opacity: 0.95;
+ transform: scale(1);
+ transform-origin: center;
+ filter: drop-shadow(0 0 4px color-mix(in srgb, var(--spv-motion-color) 48%, transparent));
+ }
+ 60% {
+ opacity: 0.25;
+ transform: scale(1.85);
+ transform-origin: center;
+ filter: drop-shadow(0 0 14px color-mix(in srgb, var(--spv-motion-color) 62%, transparent));
+ }
+ 100% {
+ opacity: 0.95;
+ transform: scale(1);
+ transform-origin: center;
+ filter: drop-shadow(0 0 4px color-mix(in srgb, var(--spv-motion-color) 48%, transparent));
+ }
+ }
+
+ @keyframes spv-alert-ripple {
+ 0% {
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.08),
+ 0 0 0 0 var(--spv-impact-color, rgba(255, 98, 127, 0.42)),
+ 0 10px 24px rgba(0, 0, 0, 0.18);
+ }
+ 55% {
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.08),
+ 0 0 0 7px color-mix(in srgb, var(--spv-impact-color, rgba(255, 98, 127, 0.42)) 0%, transparent),
+ 0 10px 24px rgba(0, 0, 0, 0.18);
+ }
+ 100% {
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.08),
+ 0 10px 24px rgba(0, 0, 0, 0.18);
+ }
+ }
+
+ @keyframes spv-alert-badge {
+ 0% {
+ transform: scale(1);
+ box-shadow: 0 0 0 0 rgba(255, 98, 127, 0.32);
+ }
+ 50% {
+ transform: scale(1.045);
+ box-shadow: 0 0 0 5px rgba(255, 98, 127, 0);
+ }
+ 100% {
+ transform: scale(1);
+ box-shadow: 0 0 0 0 rgba(255, 98, 127, 0);
+ }
+ }
+`, Wi = new Intl.Collator("en", {
+ numeric: !0,
+ sensitivity: "base"
+}), q = (i, e) => {
+ const t = Wi.compare(i, e);
+ return t !== 0 ? t : i === e ? 0 : i < e ? -1 : 1;
+}, ps = 1.2, Ki = /* @__PURE__ */ new Set(["unknown", "unavailable", "none", "null", ""]), ne = (i, e = 0, t = 1) => Math.min(Math.max(i, e), t), Se = (i) => {
+ if (!i)
+ return null;
+ const e = i.state?.toString().trim().toLowerCase();
+ if (Ki.has(e))
+ return null;
+ const t = Number(i.state);
+ return Number.isFinite(t) ? t : null;
+}, Vi = (i) => {
+ if (i == null)
+ return;
+ const e = i.toString().replace(/[\u200B-\u200D\uFEFF]/g, "").replace(/\s+/g, " ").trim();
+ return e.length > 0 ? e : void 0;
+}, dr = (i) => i.toLowerCase().match(/[a-z0-9]+/g) ?? [], hr = (i, e) => {
+ const t = dr(i);
+ return t.length === 0 ? !1 : e.some((r) => {
+ const s = dr(r);
+ if (s.length === 0 || s.length > t.length)
+ return !1;
+ for (let n = 0; n <= t.length - s.length; n += 1) {
+ let o = !0;
+ for (let l = 0; l < s.length; l += 1)
+ if (t[n + l] !== s[l]) {
+ o = !1;
+ break;
+ }
+ if (o)
+ return !0;
+ }
+ return !1;
+ });
+}, $e = (i, e) => {
+ const t = i?.attributes?.friendly_name;
+ return typeof t == "string" && t.trim().length > 0 ? t : e;
+}, ur = (i, e, t, r, s = "en") => {
+ if (i === null)
+ return r;
+ const n = Object.is(i, -0) ? 0 : i, o = new Intl.NumberFormat(s, {
+ minimumFractionDigits: t,
+ maximumFractionDigits: t
+ }).format(n);
+ return e ? `${o} ${e}` : o;
+}, _r = (i) => {
+ const e = i.replace("#", ""), t = e.length === 3 ? e.split("").map((r) => `${r}${r}`).join("") : e;
+ return [
+ parseInt(t.slice(0, 2), 16),
+ parseInt(t.slice(2, 4), 16),
+ parseInt(t.slice(4, 6), 16)
+ ];
+}, mr = (i, e, t) => {
+ const r = ne(t), [s, n, o] = _r(i), [l, a, c] = _r(e), p = (d, h) => Math.round(d + (h - d) * r);
+ return `rgb(${p(s, l)}, ${p(n, a)}, ${p(o, c)})`;
+}, ji = (i, e, t, r) => {
+ const s = ne(r);
+ return s <= 0.5 ? mr(i, e, s * 2) : mr(e, t, (s - 0.5) * 2);
+}, Yi = (i) => {
+ if (i.length === 0)
+ return 0;
+ const e = [...i].sort((n, o) => n - o), t = Math.floor(e.length * 0.4), r = e.slice(t), s = r.reduce((n, o) => n + o, 0) / Math.max(r.length, 1);
+ return ne(s, 0, ps);
+}, Ce = (i, e) => i === null ? null : i * e, qi = (i, e) => !i || e === 1 ? i : i.map((t) => ({
+ ts: t.ts,
+ value: t.value * e
+})), Xi = (i, e) => {
+ const t = i.rated_power_w ?? e.default_panel_rated_power_w ?? null;
+ return t !== null && Number.isFinite(t) && t > 0 ? t : null;
+}, gr = (i, e, t) => {
+ if (!(i.enable_string_sensor_sharing ?? !1))
+ return /* @__PURE__ */ new Map();
+ const r = /* @__PURE__ */ new Map();
+ i.panels.forEach((o, l) => {
+ if (o.enabled === !1)
+ return;
+ const a = o[e]?.trim();
+ if (!a)
+ return;
+ const c = r.get(a) ?? [];
+ c.push({
+ panel: o,
+ slotIndex: l,
+ ratedPower: Xi(o, i)
+ }), r.set(a, c);
+ });
+ const s = [...r.entries()].filter(([, o]) => o.length > 1).sort(([, o], [, l]) => o[0].slotIndex - l[0].slotIndex), n = /* @__PURE__ */ new Map();
+ return s.forEach(([o, l], a) => {
+ const c = l.some(
+ (u) => u.ratedPower === null || u.ratedPower <= 0
+ ), p = l.reduce(
+ (u, g) => u + (g.ratedPower ?? 0),
+ 0
+ ), d = t ? `String ${a + 1}` : void 0, h = 1 / l.length;
+ l.forEach((u) => {
+ const g = c || p <= 0 ? h : (u.ratedPower ?? 0) / p;
+ n.set(u.panel.id, {
+ entityId: o,
+ factor: g,
+ groupLabel: d
+ });
+ });
+ }), n;
+}, Qi = (i) => ({
+ powerByPanelId: gr(i, "power_entity", !0),
+ energyByPanelId: gr(i, "energy_entity", !1)
+}), Zi = (i, e, t) => {
+ if (t <= 0)
+ return i;
+ const r = e - t * 60 * 1e3;
+ return i.filter((s) => s.ts >= r);
+}, Ji = (i) => {
+ if (i.length < 2)
+ return 1;
+ const e = [];
+ for (let r = 1; r < i.length; r += 1) {
+ const s = (i[r].ts - i[r - 1].ts) / 6e4;
+ Number.isFinite(s) && s > 0 && e.push(s);
+ }
+ if (e.length === 0)
+ return 1;
+ e.sort((r, s) => r - s);
+ const t = Math.floor(e.length / 2);
+ return e.length % 2 === 0 ? (e[t - 1] + e[t]) / 2 : e[t];
+}, en = (i, e, t, r) => {
+ if (!e || e.length === 0)
+ return {
+ value: i,
+ sampleCount: 1,
+ firstSampleTs: t
+ };
+ const s = e.filter((p) => Number.isFinite(p.value)).sort((p, d) => p.ts - d.ts);
+ if (s.length === 0)
+ return {
+ value: i,
+ sampleCount: 1,
+ firstSampleTs: t
+ };
+ const n = Zi(s, t, r), o = Ji(s), l = r > 0 ? Math.max(2, Math.ceil(r / Math.max(o, 0.1)) + 1) : 1;
+ let a = n.length > 0 ? n : s;
+ return r > 0 && a.length < l && s.length > a.length && (a = s.slice(-l)), {
+ value: a.reduce((p, d) => p + d.value, 0) / Math.max(a.length, 1),
+ sampleCount: a.length,
+ firstSampleTs: s[0].ts ?? null
+ };
+}, Ee = (i, e, t) => {
+ const r = e?.locale?.language ?? "en", s = (_, N) => ht(r, _, N), n = s("common.unavailable"), o = t?.nowMs ?? Date.now(), l = { ...Nt, ...i.colors ?? {} }, a = i.enable_inverter_status ?? !1, c = (i.inverter_fault_terms ?? []).map((_) => _.trim().toLowerCase()).filter((_) => _.length > 0), p = (i.inverter_working_terms ?? []).map((_) => _.trim().toLowerCase()).filter((_) => _.length > 0), d = i.production_color_intensity ?? 1, h = t?.deviationEnabled ?? !0, u = i.deviation_min_active_panels ?? 3, g = i.deviation_min_samples ?? 3, f = i.deviation_min_runtime_minutes ?? 15, v = i.deviation_smoothing_minutes ?? 0, x = i.deviation_dynamic_floor_w ?? 20, S = i.deviation_threshold_percent ?? 15, I = i.deviation_absolute_w_threshold ?? 50, M = t?.historyByEntityId ?? {}, $ = Qi(i), E = i.panels.map((_, N) => {
+ const be = _.enabled ?? !0, j = _.name ?? `Panel ${N + 1}`, X = $.powerByPanelId.get(_.id), wt = $.energyByPanelId.get(_.id), T = X?.factor ?? 1, oe = wt?.factor ?? 1, z = {
+ powerShareFactor: T,
+ energyShareFactor: oe,
+ stringGroupLabel: X?.groupLabel,
+ stringPowerSourceEntityId: X?.entityId,
+ stringPowerSharePercent: X ? X.factor * 100 : void 0
+ }, xe = _.inverter_status_entity ?? _.error_entity, rt = xe ? e?.states?.[xe] : void 0, D = xe ? Vi(rt?.state) ?? n : void 0, Z = $e(
+ rt,
+ xe
+ ), J = D !== void 0 && D !== n && hr(D, c), ee = D !== void 0 && D !== n && hr(D, p);
+ if (!be)
+ return {
+ config: _,
+ slotIndex: N,
+ label: j,
+ power: null,
+ energy: null,
+ ...z,
+ inverterStatusEntityName: Z,
+ inverterStatusDisplay: D,
+ inverterFaultMatched: J,
+ inverterWorkingMatched: ee,
+ reason: s("state.reason.slot_hidden"),
+ status: "disabled",
+ enabled: !1,
+ hiddenSlot: !0
+ };
+ if (!_.power_entity)
+ return {
+ config: _,
+ slotIndex: N,
+ label: j,
+ power: null,
+ energy: null,
+ ...z,
+ inverterStatusEntityName: Z,
+ inverterStatusDisplay: D,
+ inverterFaultMatched: J,
+ inverterWorkingMatched: ee,
+ reason: s("state.reason.select_power_sensor"),
+ status: "unconfigured",
+ enabled: !0,
+ hiddenSlot: !1
+ };
+ const te = e?.states?.[_.power_entity], Le = _.energy_entity ? e?.states?.[_.energy_entity] : void 0;
+ if (!te)
+ return {
+ config: _,
+ slotIndex: N,
+ label: j,
+ power: null,
+ energy: null,
+ ...z,
+ inverterStatusEntityName: Z,
+ inverterStatusDisplay: D,
+ inverterFaultMatched: J,
+ inverterWorkingMatched: ee,
+ reason: s("state.reason.power_entity_missing", {
+ entity: _.power_entity
+ }),
+ status: "offline",
+ enabled: !0,
+ hiddenSlot: !1
+ };
+ if (a && D && D !== n && J)
+ return {
+ config: _,
+ slotIndex: N,
+ label: j,
+ power: Ce(Se(te), T),
+ energy: Ce(Se(Le), oe),
+ ...z,
+ powerEntityName: $e(te, _.power_entity),
+ inverterStatusEntityName: Z,
+ inverterStatusDisplay: D,
+ inverterFaultMatched: J,
+ inverterWorkingMatched: ee,
+ reason: s("state.reason.inverter_fault_match", {
+ status: D
+ }),
+ status: "error",
+ enabled: !0,
+ hiddenSlot: !1
+ };
+ if (a && D && D !== n && p.length > 0 && !ee)
+ return {
+ config: _,
+ slotIndex: N,
+ label: j,
+ power: Ce(Se(te), T),
+ energy: Ce(Se(Le), oe),
+ ...z,
+ powerEntityName: $e(te, _.power_entity),
+ inverterStatusEntityName: Z,
+ inverterStatusDisplay: D,
+ inverterFaultMatched: J,
+ inverterWorkingMatched: ee,
+ reason: s("state.reason.inverter_working_mismatch", {
+ status: D
+ }),
+ status: "inverter",
+ enabled: !0,
+ hiddenSlot: !1
+ };
+ const st = Ce(Se(te), T), Pe = Ce(Se(Le), oe);
+ return st === null ? {
+ config: _,
+ slotIndex: N,
+ label: j,
+ power: null,
+ energy: Pe,
+ ...z,
+ powerEntityName: $e(te, _.power_entity),
+ inverterStatusEntityName: Z,
+ inverterStatusDisplay: D,
+ inverterFaultMatched: J,
+ inverterWorkingMatched: ee,
+ reason: s("state.reason.power_entity_unavailable", {
+ entity: _.power_entity
+ }),
+ status: "offline",
+ enabled: !0,
+ hiddenSlot: !1
+ } : {
+ config: _,
+ slotIndex: N,
+ label: j,
+ power: st,
+ energy: Pe,
+ ...z,
+ powerEntityName: $e(te, _.power_entity),
+ inverterStatusEntityName: Z,
+ inverterStatusDisplay: D,
+ inverterFaultMatched: J,
+ inverterWorkingMatched: ee,
+ reason: s("state.reason.producing_expected"),
+ status: "normal",
+ enabled: !0,
+ hiddenSlot: !1
+ };
+ }), R = E.filter(
+ (_) => _.status === "normal" && _.power !== null
+ ).map((_) => _.power ?? 0), m = R.length > 0 ? Math.max(...R) : 0;
+ let P = 0, k = 0, F = 0, A = 0, O = 0, se = 0, b = 0, C, W = !1;
+ const pe = E.flatMap((_) => {
+ if (_.status !== "normal" || _.power === null)
+ return [];
+ const N = _.config.rated_power_w ?? i.default_panel_rated_power_w ?? null;
+ if (N === null || N <= 0 || !_.config.power_entity)
+ return [];
+ const be = Math.min(
+ Math.max(_.config.deviation_derate_percent ?? 100, 1),
+ 100
+ ), j = N * (be / 100);
+ if (j <= 0)
+ return [];
+ const X = en(
+ _.power,
+ qi(
+ M[_.config.power_entity],
+ _.powerShareFactor
+ ),
+ o,
+ v
+ );
+ return [
+ {
+ id: _.config.id,
+ effectivePower: X.value,
+ livePower: _.power,
+ ratedPowerW: N,
+ ratedForDeviationW: j,
+ isDerated: be < 100,
+ sampleCount: X.sampleCount,
+ firstSampleTs: X.firstSampleTs
+ }
+ ];
+ }), K = pe.filter((_) => !_.isDerated), ye = K.length > 0 ? Math.min(...K.map((_) => _.sampleCount)) : 0, ve = K.length > 0 ? Math.min(
+ ...K.map(
+ (_) => _.firstSampleTs === null ? 0 : (o - _.firstSampleTs) / 6e4
+ )
+ ) : 0;
+ h ? K.length < u ? C = s("state.reason.need_non_derated_panels", {
+ count: u
+ }) : ye < g ? C = s("state.reason.collecting_samples", {
+ current: ye,
+ required: g
+ }) : ve < f ? C = s("state.reason.warmup_progress", {
+ current: Math.floor(ve),
+ required: f
+ }) : W = !0 : C = s("state.reason.array_check_disabled");
+ const et = K.map(
+ (_) => ne(_.effectivePower / _.ratedForDeviationW, 0, ps)
+ ), Ne = Yi(et);
+ W && Math.max(
+ ...K.map(
+ (N) => N.ratedForDeviationW * Ne
+ ),
+ 0
+ ) < x && (W = !1, C = s("state.reason.low_light_pause", {
+ floor: x.toFixed(0)
+ }));
+ const we = E.map((_) => {
+ const N = (_.config.power_entity ? e?.states?.[_.config.power_entity]?.attributes?.unit_of_measurement : void 0) ?? s("state.power.default_unit"), be = _.config.energy_entity ? e?.states?.[_.config.energy_entity]?.attributes?.unit_of_measurement : void 0, j = !!_.config.energy_entity, X = _.config.show_energy ?? !1, wt = _.stringPowerSourceEntityId ? $e(
+ e?.states?.[_.stringPowerSourceEntityId],
+ _.stringPowerSourceEntityId
+ ) : void 0;
+ let T = _.status, oe = null;
+ const z = _.config.rated_power_w ?? i.default_panel_rated_power_w ?? null, xe = z !== null && _.power !== null && z > 0 ? ne(_.power / z * 100, 0, 999) : null;
+ if (T === "normal" && z === null && (_.reason = s("state.reason.rated_not_configured")), W && T === "normal" && z !== null && _.power !== null) {
+ const Pe = pe.find(
+ (De) => De.id === _.config.id
+ );
+ if (Pe) {
+ const De = Pe.ratedForDeviationW * Ne, bt = Math.max(De - Pe.effectivePower, 0), Ut = De > 0 ? bt / De * 100 : 0;
+ Ut >= S && bt >= I ? (T = "deviation", oe = ne(Ut, 0, 100), _.reason = s("state.reason.output_below_target", {
+ percent: oe.toFixed(0),
+ shortfall: bt.toFixed(0)
+ })) : _.reason = s("state.reason.producing_adjusted");
+ }
+ } else T === "normal" && C && (_.reason = C);
+ T === "error" && (F += 1), T === "inverter" && (k += 1), T === "deviation" && (P += 1), T === "offline" && (A += 1), T === "normal" && (O += 1), T === "unconfigured" && (se += 1), T === "disabled" && (b += 1);
+ const rt = z !== null && z > 0 && _.power !== null ? _.power / z : null, D = _.power !== null && m > 0 ? _.power / m : 0, Z = ne(rt ?? D, 0, 1), J = ne((d - 0.2) / 1.4, 0, 1), ee = ne(
+ (0.1 + J * 0.9) * (0.15 + Z * 0.85),
+ 0.06,
+ 1
+ ), te = T === "deviation" || T === "error" || T === "inverter" ? 1 : ee, Le = T === "normal" && _.power !== null && _.power <= 0, st = T === "error" || T === "inverter" ? l.error : Le || T === "offline" || T === "unconfigured" || T === "disabled" ? l.unavailable : T === "deviation" ? l.deviation : ji(
+ l.production_start,
+ l.production_mid,
+ l.production_end,
+ Z
+ );
+ return {
+ id: _.config.id,
+ slotIndex: _.slotIndex,
+ label: _.label,
+ status: T,
+ power: _.power,
+ powerDisplay: T === "disabled" ? s("state.status_display.disabled") : T === "unconfigured" ? s("state.status_display.not_configured") : ur(
+ _.power,
+ N,
+ i.power_decimals ?? 0,
+ n,
+ r
+ ),
+ energy: _.energy,
+ energyDisplay: _.hiddenSlot || !j || !X ? void 0 : _.energy !== null ? ur(
+ _.energy,
+ be ?? s("state.energy.default_unit"),
+ i.energy_decimals ?? 2,
+ n,
+ r
+ ) : n,
+ powerEntityName: _.powerEntityName,
+ inverterStatusEntityName: _.inverterStatusEntityName,
+ inverterStatusDisplay: _.inverterStatusDisplay,
+ inverterFaultMatched: _.inverterFaultMatched,
+ inverterWorkingMatched: _.inverterWorkingMatched,
+ deviationPercent: oe,
+ reason: _.reason,
+ accentColor: st,
+ intensity: te,
+ enabled: _.enabled,
+ hiddenSlot: _.hiddenSlot,
+ ratedPowerW: z,
+ performancePercent: xe,
+ powerShareFactor: _.powerShareFactor,
+ energyShareFactor: _.energyShareFactor,
+ stringGroupLabel: _.stringGroupLabel,
+ stringPowerSourceName: wt,
+ stringPowerSharePercent: _.stringPowerSharePercent
+ };
+ }), tt = we.reduce((_, N) => _ + (N.power ?? 0), 0), B = we.map((_) => _.energy).filter((_) => _ !== null), Ss = B.length > 0 ? B.reduce((_, N) => _ + N, 0) : null;
+ return {
+ panels: we,
+ totalPower: tt,
+ totalEnergy: Ss,
+ maxPower: m,
+ deviationCount: P,
+ inverterCount: k,
+ errorCount: F,
+ offlineCount: A,
+ normalCount: O,
+ unconfiguredCount: se,
+ disabledCount: b,
+ deviationReady: W,
+ deviationSuppressedReason: C
+ };
+}, fr = (i, e = 0) => Math.max(36, i - 36 - Math.max(0, e)), tn = ({
+ candidates: i,
+ panelWidthPx: e,
+ panelHeightPx: t,
+ fontPx: r,
+ reservedRightPx: s = 0,
+ measureTextWidthPx: n
+}) => {
+ const o = i.find((d) => d.variant === "compact");
+ if (!o)
+ throw new Error("Panel performance label requires a compact candidate.");
+ if (t < 96 || e < 112)
+ return o;
+ const l = i.find((d) => d.variant === "full"), a = fr(e, s);
+ if (l && n(l.text, r) <= a)
+ return l;
+ const c = fr(e), p = i.find((d) => d.variant === "medium");
+ return p && n(p.text, r) <= c ? p : o;
+}, Ht = (i) => Number.isFinite(i.ts) && Number.isFinite(i.value), rn = 1e-4, sn = (i) => i === 1 ? 6e4 : i === 6 ? 3 * 6e4 : i === 24 ? 10 * 6e4 : 5 * 6e4, lt = (i, e, t) => {
+ const r = e - t * 60 * 60 * 1e3;
+ return i.filter(Ht).filter((s) => s.ts >= r && s.ts <= e).sort((s, n) => s.ts - n.ts);
+}, ds = (i, e) => {
+ if (i.length < 2)
+ return [...i].sort((p, d) => p.ts - d.ts);
+ const t = e?.zeroTolerance ?? rn, r = [...i].filter(Ht).sort((p, d) => p.ts - d.ts);
+ if (r.length < 2)
+ return r;
+ const s = r.slice(1).map((p, d) => p.ts - r[d].ts).filter((p) => Number.isFinite(p) && p > 0).sort((p, d) => p - d), n = s.length > 0 ? s[Math.floor(s.length / 2)] : Number.NaN, o = sn(e?.rangeHours), l = Math.min(
+ Math.max(
+ Number.isFinite(e?.stepMs) ? e?.stepMs : Number.isFinite(n) ? n : o,
+ 3e4
+ ),
+ o * 2
+ ), a = l * 2, c = [r[0]];
+ for (let p = 1; p < r.length; p += 1) {
+ const d = r[p - 1], h = r[p], u = h.ts - d.ts, g = Math.abs(d.value) <= t, f = h.value > t;
+ if (g && f && u >= a) {
+ const v = Math.max(d.ts + 1, h.ts - l);
+ v > d.ts && v < h.ts && c.push({ ts: v, value: 0 });
+ }
+ c.push(h);
+ }
+ return c.filter(
+ (p, d, h) => d === 0 ? !0 : p.ts !== h[d - 1].ts || p.value !== h[d - 1].value
+ );
+}, Mt = (i, e) => {
+ if (i.length <= e || e <= 2)
+ return [...i];
+ const t = i[0], r = i[i.length - 1], s = i.slice(1, i.length - 1), n = e - 2, o = s.length / n, l = [t];
+ let a = -1;
+ for (let c = 0; c < n; c += 1) {
+ const p = Math.min(
+ s.length - 1,
+ Math.floor(c * o)
+ );
+ p !== a && (a = p, l.push(s[p]));
+ }
+ return l.push(r), l.sort((c, p) => c.ts - p.ts).filter(
+ (c, p, d) => p === 0 ? !0 : c.ts !== d[p - 1].ts || c.value !== d[p - 1].value
+ ).slice(0, e);
+}, nn = (i) => {
+ const e = i.map(
+ (o) => [...o].filter(Ht).sort((l, a) => l.ts - a.ts).filter(
+ (l, a, c) => a === c.length - 1 ? !0 : l.ts !== c[a + 1].ts
+ )
+ ).filter((o) => o.length > 0);
+ if (e.length === 0)
+ return [];
+ const t = [
+ ...new Set(e.flatMap((o) => o.map((l) => l.ts)))
+ ].sort((o, l) => o - l), r = e.map(() => 0), s = e.map(() => null), n = [];
+ for (const o of t) {
+ let l = 0;
+ for (let c = 0; c < e.length; c += 1) {
+ const p = e[c];
+ for (; r[c] < p.length && p[r[c]].ts <= o; )
+ s[c] = p[r[c]].value, r[c] += 1;
+ s[c] !== null && (l += 1);
+ }
+ if (l === 0)
+ continue;
+ const a = s.reduce(
+ (c, p) => c + (p ?? 0),
+ 0
+ );
+ n.push({
+ ts: o,
+ value: Object.is(a, -0) ? 0 : a
+ });
+ }
+ return n;
+}, yr = (i) => Number.isFinite(i.ts) && Number.isFinite(i.value), on = (i) => i.map(
+ (e, t) => `${t === 0 ? "M" : "L"}${e.x.toFixed(2)},${e.y.toFixed(2)}`
+).join(" "), an = (i, e, t, r, s = 320, n = 132, o = 10) => {
+ const l = t * 60 * 60 * 1e3, a = e - l, c = e, p = Math.max(c - a, 1), d = Math.max(s - o * 2, 1), h = Math.max(n - o * 2, 1), u = i.map(($) => {
+ const E = lt($.samples, e, t).filter(yr), L = E.length === 1 ? [
+ { ts: a, value: E[0].value },
+ { ts: c, value: E[0].value }
+ ] : E, R = ds(L, { rangeHours: t }), m = Mt(R, r).filter(yr), P = m.map((k) => k.value);
+ return {
+ id: $.id,
+ samples: m,
+ sampleCount: m.length,
+ firstTs: m.length > 0 ? m[0].ts : null,
+ lastTs: m.length > 0 ? m[m.length - 1].ts : null,
+ minValue: P.length > 0 ? Math.min(...P) : null,
+ maxValue: P.length > 0 ? Math.max(...P) : null
+ };
+ }), g = u.flatMap(($) => $.samples.map((E) => E.value)), f = g.length > 0;
+ let v = 0, x = 1;
+ f && (v = Math.min(...g), x = Math.max(...g), v === x && (v -= 1, x += 1));
+ const S = Math.max(x - v, 1), I = u.map(($) => {
+ const E = $.samples.map((R) => {
+ const m = (R.ts - a) / p, P = o + Math.min(Math.max(m, 0), 1) * d, k = n - o - (R.value - v) / S * h;
+ return !Number.isFinite(P) || !Number.isFinite(k) ? null : { x: P, y: k };
+ }).filter((R) => R !== null), L = E.length >= 2 ? on(E) : "";
+ return {
+ id: $.id,
+ samples: $.samples,
+ points: E,
+ linePath: L,
+ firstPoint: E.length > 0 ? E[0] : null,
+ lastPoint: E.length > 0 ? E[E.length - 1] : null,
+ sampleCount: $.sampleCount,
+ pointsCount: E.length,
+ firstTs: $.firstTs,
+ lastTs: $.lastTs,
+ minValue: $.minValue,
+ maxValue: $.maxValue
+ };
+ }), M = I.filter(($) => $.pointsCount > 0).length;
+ return {
+ startTs: a,
+ endTs: c,
+ hasData: f,
+ drawableCount: M,
+ series: I
+ };
+}, He = (i, e, t, r, s = "en") => {
+ if (i === null)
+ return r;
+ const n = Object.is(i, -0) ? 0 : i;
+ return `${new Intl.NumberFormat(s, {
+ minimumFractionDigits: e,
+ maximumFractionDigits: e
+ }).format(n)} ${t}`;
+}, re = (i) => Object.is(i, -0) ? 0 : i, ln = (i) => {
+ if (!i)
+ return;
+ const e = i.replace(/[\u200B-\u200D\uFEFF]/g, "").replace(/\s+/g, " ").trim();
+ return e.length > 0 ? e : void 0;
+}, vr = 1, cn = "spv:history:", pn = "spv-card-config-updated", dn = 6e4, nt = [1, 6, 24], St = 150, hn = 45e3, un = 45e3, _n = 64, mn = /* @__PURE__ */ new Set(["unknown", "unavailable", "none", "null", ""]), wr = [
+ "#8ed072",
+ "#6fc9ff",
+ "#ffbf66",
+ "#b391ff",
+ "#4de3c1",
+ "#ff8fb3",
+ "#ffd56a",
+ "#84a8ff",
+ "#7be56e",
+ "#f58cff"
+], $t = (i, e = "Unknown recorder error") => {
+ if (i instanceof Error && i.message.trim().length > 0)
+ return i.message.trim();
+ if (typeof i == "string" && i.trim().length > 0)
+ return i.trim();
+ if (typeof i == "object" && i !== null) {
+ const t = i;
+ if (typeof t.message == "string" && t.message.trim().length > 0)
+ return t.message.trim();
+ if (typeof t.error == "string" && t.error.trim().length > 0)
+ return t.error.trim();
+ if (typeof t.body?.message == "string" && t.body.message.trim().length > 0)
+ return t.body.message.trim();
+ }
+ return e;
+}, Ct = (i, e = 120) => i.length <= e ? i : `${i.slice(0, e - 1)}…`, _e = class _e extends Ie {
+ constructor() {
+ super(...arguments), this._cardWidth = 0, this._selectedPanelId = null, this._showLivePowerPopup = !1, this._showEnergyPopup = !1, this._showCustomKpiPopup = !1, this._showSystemHealthPopup = !1, this._dragSourceSlotIndex = null, this._historyByEntityId = {}, this._historyState = "idle", this._historySignature = "", this._historyQuerySignature = "", this._historyLastLoadMs = 0, this._historyLoadToken = 0, this._popupGraphRangeHours = 6, this._popupGraphCache = {}, this._popupGraphRequestToken = 0, this._popupGraphLatestTokenByKey = {}, this._telemetryGraphPanelId = null, this._telemetryGraphEntityId = null, this._telemetryGraphRangeHours = 6, this._kpiCompareExpanded = {
+ power: !1,
+ energy: !1
+ }, this._kpiCompareRangeHours = {
+ power: 6,
+ energy: 6
+ }, this._kpiCompareCache = {}, this._kpiCompareRequestToken = 0, this._kpiCompareLatestTokenByKey = {}, this._persistConfigToken = 0, this._popupScrollRestoreToken = 0, this._popupScrollRestore = null, this._motionEngine = new Fi(() => this.requestUpdate()), this._motionOverlayModel = null, this._motionOverlaySignature = "", this._overlayMeasureFrame = null, this._panelDetailOverflowFrame = null, this._activePulseColor = "rgba(111, 201, 255, 0.72)", this._onMotionAnimationEnd = (e) => {
+ const t = e;
+ if (t.animationName !== "spv-flow-pulse")
+ return;
+ const r = t.target;
+ r instanceof SVGPathElement && r.classList.contains("spv-motion-flow") && (this._hasPopupOpen() || this._motionEngine.completeFlowPulse());
+ }, this._handlePanelDetailScroll = (e) => {
+ const t = e.currentTarget;
+ if (!t)
+ return;
+ const r = this._getPanelDetailMaxScrollTop(t);
+ t.scrollTop > r && (t.scrollTop = r);
+ }, this._enableForecastOverlayFromPopup = () => {
+ if (!this._config || this._isForecastOverlayEnabled())
+ return;
+ const e = this._getForecastEntityId("power"), t = this._getForecastEntityId("energy");
+ if (this._commitConfigFromCard(
+ Be({
+ ...this._config,
+ enable_forecast_overlay: !0
+ })
+ ), this._showLivePowerPopup && e) {
+ const r = this._getSystemPowerEntityId(), s = r ? this._popupGraphRangeHours : this._kpiCompareRangeHours.power;
+ if (r)
+ this._ensurePopupGraphLoadedByEntity(r, s);
+ else {
+ const n = this._derived ?? (this._config && this.hass ? Ee(this._config, this.hass) : null);
+ n && this._ensurePanelCompareLoaded(n, "power", s);
+ }
+ this._ensurePopupGraphLoadedByEntity(e, s);
+ }
+ if (this._showEnergyPopup && t) {
+ const r = this._getSystemEnergyEntityId(), s = r ? this._popupGraphRangeHours : this._kpiCompareRangeHours.energy;
+ if (r)
+ this._ensurePopupGraphLoadedByEntity(r, s);
+ else {
+ const n = this._derived ?? (this._config && this.hass ? Ee(this._config, this.hass) : null);
+ n && this._ensurePanelCompareLoaded(n, "energy", s);
+ }
+ this._ensurePopupGraphLoadedByEntity(t, s);
+ }
+ }, this._handlePopupUserInteraction = (e) => {
+ e.stopPropagation(), this._clearPopupScrollRestore();
+ }, this._stopPopupEventPropagation = (e) => {
+ e.stopPropagation();
+ }, this._closeDialog = () => {
+ this._closeAllPopups(), this._kpiCompareExpanded = { power: !1, energy: !1 }, this._kpiCompareRangeHours = { power: 6, energy: 6 };
+ }, this._openLivePowerPopup = () => {
+ this._selectedPanelId = null, this._clearTelemetryGraph();
+ const e = this._getSystemPowerEntityId();
+ if (this._kpiCompareExpanded = { power: !1, energy: !1 }, this._kpiCompareRangeHours = { power: 6, energy: 6 }, this._showLivePowerPopup = !0, this._showEnergyPopup = !1, this._showCustomKpiPopup = !1, this._showSystemHealthPopup = !1, this._popupGraphRangeHours = 6, e) {
+ if (this._ensurePopupGraphLoadedByEntity(e, 6), this._isForecastOverlayEnabled()) {
+ const t = this._getForecastEntityId("power");
+ t && this._ensurePopupGraphLoadedByEntity(t, 6);
+ }
+ } else {
+ const t = this._derived ?? (this._config && this.hass ? Ee(this._config, this.hass) : null);
+ if (t && (this._kpiCompareExpanded = {
+ ...this._kpiCompareExpanded,
+ power: this._buildPanelCompareTargets(t, "power").length > 0
+ }, this._ensurePanelCompareLoaded(t, "power", this._kpiCompareRangeHours.power)), this._isForecastOverlayEnabled()) {
+ const r = this._getForecastEntityId("power");
+ r && this._ensurePopupGraphLoadedByEntity(
+ r,
+ this._kpiCompareRangeHours.power
+ );
+ }
+ }
+ }, this._closeLivePowerPopup = () => {
+ this._closeAllPopups();
+ }, this._openEnergyPopup = () => {
+ this._selectedPanelId = null, this._clearTelemetryGraph();
+ const e = this._getSystemEnergyEntityId();
+ if (this._kpiCompareExpanded = { power: !1, energy: !1 }, this._kpiCompareRangeHours = { power: 6, energy: 6 }, this._showEnergyPopup = !0, this._showLivePowerPopup = !1, this._showCustomKpiPopup = !1, this._showSystemHealthPopup = !1, this._popupGraphRangeHours = 6, e) {
+ if (this._ensurePopupGraphLoadedByEntity(e, 6), this._isForecastOverlayEnabled()) {
+ const t = this._getForecastEntityId("energy");
+ t && this._ensurePopupGraphLoadedByEntity(t, 6);
+ }
+ } else {
+ const t = this._derived ?? (this._config && this.hass ? Ee(this._config, this.hass) : null);
+ if (t && (this._kpiCompareExpanded = {
+ ...this._kpiCompareExpanded,
+ energy: this._buildPanelCompareTargets(t, "energy").length > 0
+ }, this._ensurePanelCompareLoaded(t, "energy", this._kpiCompareRangeHours.energy)), this._isForecastOverlayEnabled()) {
+ const r = this._getForecastEntityId("energy");
+ r && this._ensurePopupGraphLoadedByEntity(
+ r,
+ this._kpiCompareRangeHours.energy
+ );
+ }
+ }
+ }, this._closeEnergyPopup = () => {
+ this._closeAllPopups();
+ }, this._openCustomKpiPopup = () => {
+ this._selectedPanelId = null, this._clearTelemetryGraph(), this._kpiCompareExpanded = { power: !1, energy: !1 }, this._kpiCompareRangeHours = { power: 6, energy: 6 }, this._showCustomKpiPopup = !0, this._showLivePowerPopup = !1, this._showEnergyPopup = !1, this._showSystemHealthPopup = !1, this._popupGraphRangeHours = 6;
+ const e = this._getCustomKpiEntityId();
+ e && this._ensurePopupGraphLoadedByEntity(e, 6);
+ }, this._closeCustomKpiPopup = () => {
+ this._closeAllPopups();
+ }, this._openSystemHealthPopup = () => {
+ this._showSystemHealthPopup = !0, this._showLivePowerPopup = !1, this._showEnergyPopup = !1, this._showCustomKpiPopup = !1, this._selectedPanelId = null, this._clearTelemetryGraph(), this._kpiCompareExpanded = { power: !1, energy: !1 }, this._kpiCompareRangeHours = { power: 6, energy: 6 };
+ }, this._closeSystemHealthPopup = () => {
+ this._closeAllPopups();
+ }, this._preventRangeChipFocusScroll = (e) => {
+ e.preventDefault();
+ }, this._handleDragOver = (e) => {
+ e.preventDefault(), e.dataTransfer && (e.dataTransfer.dropEffect = "move");
+ }, this._handleDragEnd = () => {
+ this._dragSourceSlotIndex = null;
+ };
+ }
+ static async getConfigElement() {
+ return await Promise.resolve().then(() => Un), document.createElement("solar-panel-visualizer-card-editor");
+ }
+ static getStubConfig() {
+ return Os();
+ }
+ _normalizeCardConfig(e) {
+ return Be(e);
+ }
+ _renderCardOverlay(e) {
+ const t = this._getCurrentConfig(), r = this._motionEngine.getState();
+ return Bi(this._motionOverlayModel, {
+ showTopology: !!(t?.motion_enabled ?? !0) && !!(t?.motion_power_flow ?? !0),
+ showFlow: r.powerFlowActive,
+ activeFlowPanelId: r.flowPulsePanelId,
+ powerImpactActive: r.kpiShimmers.has("power")
+ });
+ }
+ _afterBaseUpdated(e) {
+ const t = this._getCurrentConfig(), r = this._getCurrentDerived();
+ if (!t || !r)
+ return;
+ const s = (t.motion_enabled ?? !0) && (t.motion_power_flow ?? !0);
+ this.toggleAttribute("data-spv-busbars", s), this._motionEngine.update(this._buildMotionFlags(t), this._buildMotionSnapshot(r)), this._syncActivePulseColor(r), this._scheduleOverlayMeasure();
+ }
+ _getPanelExtraClass(e) {
+ const t = [];
+ return this._motionEngine.getState().alertRipples.has(e.id) && t.push("spv-motion-alert-ripple"), t.join(" ");
+ }
+ _getSummaryExtraClass(e) {
+ return e === "alerts" || e === "custom" || !this._motionEngine.getState().kpiShimmers.has(e) ? "" : e === "power" ? "spv-motion-power-impact" : "spv-motion-neutral-impact";
+ }
+ _getSummaryExtraStyle(e) {
+ return e !== "power" ? "" : `--spv-shimmer-color:${this._activePulseColor};`;
+ }
+ _getCurrentConfig() {
+ return this._config;
+ }
+ _getCurrentDerived() {
+ return this._derived;
+ }
+ _hasPopupOpen() {
+ return !!(this._selectedPanelId || this._showLivePowerPopup || this._showEnergyPopup || this._showCustomKpiPopup || this._showSystemHealthPopup);
+ }
+ _t(e, t) {
+ return rs(this.hass, e, t);
+ }
+ _buildMotionFlags(e) {
+ return {
+ enabled: e.motion_enabled ?? !0,
+ powerFlow: e.motion_power_flow ?? !0,
+ updateShimmer: e.motion_update_shimmer ?? !0,
+ alertRipple: e.motion_alert_ripple ?? !0
+ };
+ }
+ _buildMotionSnapshot(e) {
+ const t = {}, r = {};
+ for (const s of e.panels)
+ t[s.id] = s.power, r[s.id] = s.status;
+ return {
+ totalPower: e.totalPower,
+ popupOpen: this._hasPopupOpen(),
+ powerKpiValue: this._resolveSummaryPower(e).value?.toString() ?? this._t("common.unavailable"),
+ energyKpiValue: this._resolveSummaryEnergy(e).value?.toString() ?? this._t("common.unavailable"),
+ panelPowers: t,
+ panelStatuses: r
+ };
+ }
+ _scheduleOverlayMeasure() {
+ this._overlayMeasureFrame !== null || typeof window > "u" || (this._overlayMeasureFrame = window.requestAnimationFrame(() => {
+ this._overlayMeasureFrame = null, this._measureOverlay();
+ }));
+ }
+ _syncActivePulseColor(e) {
+ const t = this._motionEngine.getState().flowPulsePanelId, r = e.panels.find((s) => s.id === t);
+ r?.accentColor && (this._activePulseColor = r.accentColor);
+ }
+ _measureOverlay() {
+ const e = this._getCurrentConfig(), t = this._getCurrentDerived(), r = this.shadowRoot;
+ if (!e || !t || !r)
+ return;
+ const s = zi(this, r, e, t), n = JSON.stringify(s);
+ n !== this._motionOverlaySignature && (this._motionOverlayModel = s, this._motionOverlaySignature = n, this.requestUpdate());
+ }
+ _startOverlayResizeObserver() {
+ typeof ResizeObserver > "u" || (this._overlayResizeObserver?.disconnect(), this._overlayResizeObserver = new ResizeObserver(() => this._scheduleOverlayMeasure()), this._overlayResizeObserver.observe(this));
+ }
+ _resolveDefaultTitle(e) {
+ return !e || e.trim().length === 0 ? this._t("card.default_title") : e === "Solar Array" ? this._t("card.default_title") : e;
+ }
+ _resolveCustomKpiTitle(e) {
+ return e === "Custom KPI" ? this._t("card.summary.custom_default_title") : e;
+ }
+ _panelStatusLabel(e) {
+ return this._t(`card.panel.status.${e}`);
+ }
+ connectedCallback() {
+ super.connectedCallback(), this._startResizeObserver(), this._startOverlayResizeObserver();
+ }
+ disconnectedCallback() {
+ this._overlayMeasureFrame !== null && typeof window < "u" && (window.cancelAnimationFrame(this._overlayMeasureFrame), this._overlayMeasureFrame = null), this._panelDetailOverflowFrame !== null && typeof window < "u" && (window.cancelAnimationFrame(this._panelDetailOverflowFrame), this._panelDetailOverflowFrame = null), this._overlayResizeObserver?.disconnect(), this._overlayResizeObserver = void 0, this.renderRoot.removeEventListener("animationend", this._onMotionAnimationEnd, !0), this._clearPopupScrollRestore(), this._motionEngine.destroy(), this._resizeObserver?.disconnect(), this._resizeObserver = void 0, super.disconnectedCallback();
+ }
+ firstUpdated(e) {
+ super.firstUpdated(e), this.renderRoot.addEventListener("animationend", this._onMotionAnimationEnd, !0);
+ }
+ setConfig(e) {
+ const t = Gs(e);
+ if (t.length > 0)
+ throw new Error(t.join(" "));
+ const r = this._normalizeCardConfig(e), s = this._config !== void 0 && this._configsEqual(this._config, r);
+ this._sourceConfigRef = e, this._config = r, s || (this._historySignature = "", this._historyQuerySignature = "", this._historyLastLoadMs = 0, this._historyLoadToken = 0, this._popupGraphRangeHours = 6, this._popupGraphCache = {}, this._popupGraphLatestTokenByKey = {}, this._kpiCompareExpanded = { power: !1, energy: !1 }, this._kpiCompareRangeHours = { power: 6, energy: 6 }, this._kpiCompareCache = {}, this._kpiCompareLatestTokenByKey = {}, this._showLivePowerPopup = !1, this._showEnergyPopup = !1, this._showCustomKpiPopup = !1, this._showSystemHealthPopup = !1, this._selectedPanelId = null), this._refreshDerived(), this._ensureHistoryLoaded();
+ }
+ _configsEqual(e, t) {
+ return JSON.stringify(e) === JSON.stringify(t);
+ }
+ getCardSize() {
+ const e = this._config?.panels.length ?? 6, t = this._config?.columns ?? 3, r = this._computeRenderedColumns(
+ t,
+ this._config?.max_card_width_px
+ ), s = this._computeRenderedRows(e, r), n = this._computePanelHeightPx(
+ s,
+ this._config?.max_card_height_px,
+ r,
+ this._config?.max_card_width_px
+ ), o = 220 + s * n + Math.max(0, s - 1) * 10, l = this._config?.max_card_height_px ? Math.min(o, this._config.max_card_height_px) : o;
+ return Math.max(5, Math.ceil(l / 50));
+ }
+ updated(e) {
+ e.has("hass") && (this._syncLiveSamplesFromHass(), this._ensureHistoryLoaded(), this._refreshDerived()), this._derived && (this._showLivePowerPopup && this._kpiCompareExpanded.power && this._ensurePanelCompareLoaded(
+ this._derived,
+ "power",
+ this._kpiCompareRangeHours.power
+ ), this._showEnergyPopup && this._kpiCompareExpanded.energy && this._ensurePanelCompareLoaded(
+ this._derived,
+ "energy",
+ this._kpiCompareRangeHours.energy
+ )), this._afterBaseUpdated(e), this._schedulePanelDetailOverflowSync();
+ }
+ _schedulePanelDetailOverflowSync() {
+ this._panelDetailOverflowFrame !== null || typeof window > "u" || (this._panelDetailOverflowFrame = window.requestAnimationFrame(() => {
+ this._panelDetailOverflowFrame = null, this._syncPanelDetailOverflow();
+ }));
+ }
+ _syncPanelDetailOverflow() {
+ this.renderRoot.querySelectorAll(".panel-detail-scroll").forEach((t) => {
+ const r = t.closest(".panel");
+ if (!r)
+ return;
+ if (r.classList.contains("hidden-slot") || t.childElementCount === 0) {
+ r.classList.remove("has-detail-overflow"), t.scrollTop = 0;
+ return;
+ }
+ const s = t.scrollTop;
+ r.classList.remove("has-detail-overflow"), this._syncPerformanceLabelFit(t);
+ const n = t.scrollHeight > t.clientHeight + 1;
+ if (r.classList.toggle("has-detail-overflow", n), n) {
+ this._syncPerformanceLabelFit(t);
+ const o = this._getPanelDetailMaxScrollTop(t);
+ t.scrollTop = Math.min(s, o);
+ } else
+ t.scrollTop = 0;
+ });
+ }
+ _getPanelDetailMaxScrollTop(e) {
+ const t = Math.max(0, e.scrollHeight - e.clientHeight), r = Array.from(e.children).filter(
+ (n) => n instanceof HTMLElement && n.offsetParent !== null && n.offsetHeight > 0
+ ), s = r[r.length - 1];
+ return s ? Math.min(t, Math.max(0, s.offsetTop)) : t;
+ }
+ _syncPerformanceLabelFit(e) {
+ const t = e.querySelector(".performance");
+ if (!t)
+ return;
+ const r = t.dataset.performanceFull, s = t.dataset.performanceMedium, n = t.dataset.performanceCompact, o = [r, s, n].filter(
+ (c) => !!c
+ );
+ if (o.length === 0)
+ return;
+ const l = t.textContent?.trim(), a = Math.max(
+ 0,
+ o.findIndex((c) => c === l)
+ );
+ for (const c of o.slice(a))
+ if (t.textContent = c, t.scrollWidth <= t.clientWidth + 1)
+ return;
+ t.textContent = o[o.length - 1];
+ }
+ render() {
+ if (!this._config)
+ return w;
+ const e = this._derived ?? Ee(this._config, this.hass), t = this.hass?.locale?.language ?? "en", r = this._t("common.unavailable"), s = this._t("common.not_configured"), n = this._resolveDefaultTitle(this._config.title), o = this._resolveThemeMode(), l = this._resolveSummaryPower(e), a = this._resolveSummaryEnergy(e), c = this._resolveCustomKpi(), p = this._isSummaryEnergyConfigured(), d = this._config.show_custom_kpi ?? !0, h = this._computeRenderedColumns(
+ this._config.columns,
+ this._config.max_card_width_px
+ ), u = this._computeRenderedRows(
+ this._config.panels.length,
+ h
+ ), g = this._computePanelHeightPx(
+ u,
+ this._config.max_card_height_px,
+ h,
+ this._config.max_card_width_px
+ ), f = this._computePanelWidthPx(
+ h,
+ this._config.max_card_width_px
+ ), v = this._computePanelScale(g), x = this._getPanelWidthCapPx(), S = x !== null ? `grid-template-columns: repeat(${h}, minmax(0, ${x}px)); justify-content: center;` : `grid-template-columns: repeat(${h}, minmax(0, 1fr));`, I = this._config.deviation_history_hours ?? 12, M = `--spv-max-width:${this._config.max_card_width_px ?? 980}px; ${this._config.max_card_height_px ? `--spv-max-height:${this._config.max_card_height_px}px;` : "--spv-max-height:none;"} --spv-panel-height:${g}px; --spv-panel-scale:${v}; --spv-panel-max-width:${x ? `${x}px` : "100%"};`, $ = this._buildSystemHealthState(e), E = e.inverterCount + e.errorCount + e.offlineCount + e.deviationCount, L = this._motionOverlayModel ? `width:${this._motionOverlayModel.width}px; height:${this._motionOverlayModel.height}px;` : "", R = (this._config.enable_array_checks ?? !1) && this._historyState === "loading" ? this._t("card.subtitle.loading_history", { hours: I }) : e.deviationReady ? e.deviationCount > 0 ? this._t("card.subtitle.deviation_detected", {
+ count: e.deviationCount,
+ suffix: e.deviationCount === 1 ? "" : "s"
+ }) : this._t("card.subtitle.tap_diagnostics") : this._historyStateReason ?? e.deviationSuppressedReason ?? this._t("card.subtitle.warmup"), m = e.panels.find(
+ (P) => P.id === this._selectedPanelId
+ );
+ return y`
+
+
+
+ ${this._renderCardOverlay(e)}
+
+
+
+
+ ${e.panels.map((P) => this._renderPanel(P, g, f))}
+
+
+ ${this._showLivePowerPopup ? this._renderLivePowerDialog(e) : w}
+ ${this._showEnergyPopup ? this._renderEnergyDialog(e) : w}
+ ${this._showCustomKpiPopup ? this._renderCustomKpiDialog() : w}
+ ${this._showSystemHealthPopup ? this._renderSystemHealthDialog(e) : w}
+ ${m ? this._renderPanelDialog(m) : w}
+
+ `;
+ }
+ _refreshDerived() {
+ if (!this._config) {
+ this._derived = void 0;
+ return;
+ }
+ this._derived = Ee(this._config, this.hass, {
+ deviationEnabled: this._config.enable_array_checks ?? !1,
+ historyByEntityId: this._historyByEntityId,
+ nowMs: Date.now()
+ });
+ }
+ _formatSlotLabel(e) {
+ const t = this._config?.columns ?? 1, r = Math.floor(e / t) + 1, s = e % t + 1;
+ return this._t("card.panel.slot_label", { row: r, column: s });
+ }
+ _formatPanelSlotLabel(e) {
+ const t = this._formatSlotLabel(e.slotIndex);
+ return e.stringGroupLabel ? `${e.stringGroupLabel} · ${t}` : t;
+ }
+ _renderPanel(e, t, r) {
+ const s = this._shouldShowPerformance(e, t, r), n = this._getPanelExtraClass(e), o = ln(e.inverterStatusDisplay), l = this._formatInverterTileSummary(e, o), a = Math.max(0, Math.min(e.intensity, 1)), c = Math.round(24 + a * 62), p = Math.round(6 + a * 24), d = Math.round(4 + a * 18), h = [
+ `--panel-accent:${e.accentColor}`,
+ `--panel-intensity:${a.toFixed(3)}`,
+ `--panel-border-accent:color-mix(in srgb, ${e.accentColor} ${c}%, var(--spv-panel-accent-mix-base))`,
+ `--panel-fill-accent:color-mix(in srgb, ${e.accentColor} ${p}%, transparent)`,
+ `--panel-glow-accent:color-mix(in srgb, ${e.accentColor} ${d}%, transparent)`
+ ].join("; "), u = s ? this._buildPanelPerformanceLabelCandidates(e) : null;
+ return e.hiddenSlot ? y`
+
+ ` : y`
+
+ `;
+ }
+ _renderPopupCloseButton(e, t) {
+ return y`
+
+ `;
+ }
+ _formatInverterTileSummary(e, t) {
+ if (t)
+ return e.status === "deviation" ? this._t("card.panel.inverter_short.deviation") : e.status === "error" || e.status === "inverter" || e.status === "offline" ? this._t("card.panel.inverter_short.error") : e.inverterWorkingMatched ? this._t("card.panel.inverter_short.ok") : this._t("card.panel.inverter_prefix", {
+ status: t
+ });
+ }
+ _renderPanelDialog(e) {
+ const r = this._getPanelConfig(e.id)?.energy_entity ? e.energyDisplay ?? this._t("common.unavailable") : this._t("common.not_configured");
+ return e.status === "unconfigured" ? y`
+
+ ` : y`
+
+ `;
+ }
+ _renderPanelTelemetrySection(e) {
+ const t = this._getPanelConfig(e.id);
+ if (!t)
+ return w;
+ const r = this._getPanelTelemetryMetrics(t), s = r.filter((a) => !!a.entityId), n = r.filter((a) => !a.entityId), o = s.length > 0, l = this._telemetryGraphPanelId === e.id ? this._telemetryGraphEntityId : null;
+ return y`
+
+ ${this._t("card.popup.telemetry.title")}
+
+ ${o ? y`
+
+
${this._t("card.popup.telemetry.configured_title")}
+
+ ${s.map(
+ (a) => this._renderTelemetryMetricCard(
+ e.id,
+ a,
+ a.entityId === l
+ )
+ )}
+
+
+ ` : w}
+
+ ${n.length > 0 ? y`
+
+
${this._t("card.popup.telemetry.unconfigured_title")}
+
${this._t("card.popup.telemetry.setup_hint")}
+
+ ${n.map(
+ (a) => y`
+
+ ${a.label}
+ ${this._t("common.not_configured")}
+
+ `
+ )}
+
+
+ ` : o ? w : y`${this._t("card.popup.telemetry.setup_hint")}
`}
+
+ `;
+ }
+ _renderTelemetryMetricCard(e, t, r) {
+ const s = t.entityId;
+ return s ? y`
+
+
+ ${r ? this._renderTelemetryMetricGraph(t) : w}
+
+ ` : w;
+ }
+ _renderTelemetryMetricGraph(e) {
+ const t = e.entityId;
+ if (!t)
+ return w;
+ const r = this._telemetryGraphRangeHours, s = this._getPopupGraphCacheKey(t, r), n = this._popupGraphCache[s], o = n?.samples ?? [], l = this._adjustGraphSamplesForEntity(t, o), a = this._getUnitForEntity(t, "");
+ return y`
+
+
+ ${this._renderPopupGraphBodyFromSamples(
+ s,
+ a,
+ l,
+ r,
+ n?.loading ?? !1,
+ n?.error,
+ { fillStartWithFirstSample: !0 }
+ )}
+
+ `;
+ }
+ _getPanelTelemetryMetrics(e) {
+ const t = e.advanced_metrics ?? {};
+ return [
+ {
+ label: this._t("card.popup.telemetry.label.inverter_status"),
+ entityId: e.inverter_status_entity ?? e.error_entity
+ },
+ {
+ label: this._t("card.popup.telemetry.label.inverter_ac_power"),
+ entityId: t.inverter_ac_power_entity
+ },
+ {
+ label: this._t("card.popup.telemetry.label.inverter_ac_voltage"),
+ entityId: t.inverter_ac_voltage_entity
+ },
+ {
+ label: this._t("card.popup.telemetry.label.inverter_ac_current"),
+ entityId: t.inverter_ac_current_entity
+ },
+ {
+ label: this._t("card.popup.telemetry.label.inverter_temp"),
+ entityId: t.inverter_temp_entity
+ },
+ {
+ label: this._t("card.popup.telemetry.label.panel_current"),
+ entityId: t.panel_current_entity
+ },
+ {
+ label: this._t("card.popup.telemetry.label.panel_voltage"),
+ entityId: t.panel_voltage_entity
+ },
+ {
+ label: this._t("card.popup.telemetry.label.panel_power"),
+ entityId: t.panel_power_entity
+ }
+ ];
+ }
+ _formatTelemetryMetricValue(e) {
+ const t = this.hass?.states?.[e];
+ if (!t)
+ return this._t("common.unavailable");
+ const r = t.state?.toString().trim() ?? "", s = r.toLowerCase();
+ if (s.length === 0 || mn.has(s))
+ return this._t("common.unavailable");
+ const n = Number(t.state), o = typeof t.attributes?.unit_of_measurement == "string" ? t.attributes.unit_of_measurement.trim() : "";
+ if (Number.isFinite(n)) {
+ const l = this.hass?.locale?.language ?? "en", a = Object.is(n, -0) ? 0 : n, c = Number.isInteger(a) ? 0 : 2, p = new Intl.NumberFormat(l, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: c
+ }).format(a);
+ return o ? `${p} ${o}` : p;
+ }
+ return o ? `${r} ${o}` : r;
+ }
+ _renderKpiCompareControls(e, t) {
+ const r = this._kpiCompareExpanded[t], s = t === "power" ? this._t("card.popup.panel_compare.toggle_power") : this._t("card.popup.panel_compare.toggle_energy");
+ return y`
+
+
+
+ ${r ? this._renderPanelCompareGraph(e, t) : w}
+ `;
+ }
+ _togglePanelCompareGraph(e, t, r) {
+ const s = this._captureScrollPositionsForPopupGraph(e), n = !this._kpiCompareExpanded[t];
+ this._kpiCompareExpanded = {
+ ...this._kpiCompareExpanded,
+ [t]: n
+ }, this._scheduleCapturedScrollRestore(s), n && this._ensurePanelCompareLoaded(
+ r,
+ t,
+ this._kpiCompareRangeHours[t],
+ s
+ );
+ }
+ _renderPanelCompareGraph(e, t) {
+ const r = this._buildPanelCompareTargets(e, t), s = this._kpiCompareRangeHours[t];
+ if (r.length === 0)
+ return y`
+
+ ${t === "energy" ? this._t("card.popup.panel_compare.no_panels_energy") : this._t("card.popup.panel_compare.no_panels_power")}
+
+ `;
+ const n = this._getPanelCompareSignature(t, r), o = this._getPanelCompareCacheKey(t, n, s), l = this._kpiCompareCache[o], a = l?.loading ?? !1, c = l?.error, p = r.map((b, C) => ({
+ ...b,
+ color: wr[C % wr.length],
+ samples: this._scaleGraphSamples(
+ this._adjustGraphSamplesForEntity(
+ b.entityId,
+ l?.samplesByPanelId[b.panelId] ?? []
+ ),
+ b.scale
+ )
+ })), d = an(
+ p.map((b) => ({
+ id: b.panelId,
+ samples: b.samples
+ })),
+ Date.now(),
+ s,
+ St,
+ 320,
+ 132,
+ 10
+ ), h = new Map(d.series.map((b) => [b.id, b])), u = d.series.some(
+ (b) => b.sampleCount > 0 && (b.pointsCount === 0 || b.sampleCount > 1 && b.pointsCount < 2)
+ ), g = d.hasData && d.drawableCount === 0, f = g || u, v = t === "energy" ? "kWh" : "W", x = d.series.flatMap(
+ (b) => b.samples.map((C) => C.value).filter((C) => Number.isFinite(C))
+ ), S = x.length > 0 ? this._computeGraphStats(
+ x.map((b, C) => ({
+ ts: C,
+ value: b
+ }))
+ ) : { min: null, max: null, median: null }, I = x.length > 0 ? Math.min(...x) : null, M = x.length > 0 ? Math.max(...x) : null, $ = I === null || M === null ? null : I === M ? I - 1 : I, E = I === null || M === null ? null : I === M ? M + 1 : M, L = S.max !== null && $ !== null && E !== null ? this._toGraphY(S.max, $, E, 132, 10) : null, R = S.median !== null && $ !== null && E !== null ? this._toGraphY(S.median, $, E, 132, 10) : null, m = S.min !== null && $ !== null && E !== null ? this._toGraphY(S.min, $, E, 132, 10) : null, P = S.max !== null ? this._t("card.popup.history.max", {
+ value: this._formatGraphPower(S.max, v)
+ }) : null, k = S.median !== null ? this._t("card.popup.history.median", {
+ value: this._formatGraphPower(S.median, v)
+ }) : null, F = S.min !== null ? this._t("card.popup.history.min", {
+ value: this._formatGraphPower(S.min, v)
+ }) : null, A = this._buildGraphHourTicks(
+ {
+ startTs: d.startTs,
+ endTs: d.endTs
+ },
+ s
+ ), O = this._buildGraphAxisTicks(A), se = t === "energy" ? this._t("card.popup.history.panel_compare_energy") : this._t("card.popup.history.panel_compare_power");
+ return y`
+
+
+
+ ${a ? y`
${this._t("card.popup.panel_compare.loading")}
` : c ? y`
${c}
` : d.hasData ? g ? y`
${this._t("card.popup.panel_compare.render_failure")}
` : y`
+
+ ${P ? y`
${P}` : w}
+ ${k ? y`
+
+ ${k}
+
+ ` : w}
+ ${F ? y`
${F}` : w}
+
+
+
+ ${O.map(
+ (b) => y`
+
+ ${b.label}
+
+ `
+ )}
+
+
+
+ ${this._t("card.popup.history.time_range", {
+ start: this._formatGraphTime(d.startTs),
+ end: this._formatGraphTime(d.endTs)
+ })}
+
+
+ ` : y`
${this._t("card.popup.panel_compare.no_data")}
`}
+
+ ${f ? y`
+
+
+ ${this._t("card.popup.panel_compare.diagnostics_title")}
+
+
+ ${this._t("card.popup.panel_compare.diagnostics_summary", {
+ hasData: d.hasData ? "true" : "false",
+ drawable: d.drawableCount,
+ series: d.series.length,
+ range: s
+ })}
+
+
+ ${g ? this._t("card.popup.panel_compare.diagnostics_reason_render_failure") : this._t("card.popup.panel_compare.diagnostics_reason_suspect")}
+
+ ${p.map((b) => {
+ const C = h.get(b.panelId), W = this._getUnitForEntity(b.entityId, v);
+ return y`
+
+ ${this._t("card.popup.panel_compare.diagnostics_row", {
+ label: b.label,
+ samples: C?.sampleCount ?? 0,
+ points: C?.pointsCount ?? 0,
+ pathLen: C?.linePath.length ?? 0,
+ first: this._formatGraphDiagnosticTime(C?.firstTs ?? null),
+ last: this._formatGraphDiagnosticTime(C?.lastTs ?? null),
+ min: C?.minValue === null || C?.minValue === void 0 ? "--" : this._formatGraphPower(C.minValue, W),
+ max: C?.maxValue === null || C?.maxValue === void 0 ? "--" : this._formatGraphPower(C.maxValue, W)
+ })}
+
+ `;
+ })}
+
+ ` : w}
+
+
+ ${p.map(
+ (b) => y`
+
+
+ ${b.label}
+
+ `
+ )}
+
+
+ `;
+ }
+ _renderAggregatedPanelGraph(e, t, r = {}) {
+ const s = this._buildPanelCompareTargets(e, t), n = this._kpiCompareRangeHours[t];
+ if (s.length === 0)
+ return y`
+
+ ${t === "energy" ? this._t("card.popup.panel_compare.no_panels_energy") : this._t("card.popup.panel_compare.no_panels_power")}
+
+ `;
+ const o = this._getPanelCompareSignature(t, s), l = this._getPanelCompareCacheKey(t, o, n), a = this._kpiCompareCache[l], c = a?.loading ?? !1, p = a?.error, d = nn(
+ s.map(
+ (g) => this._scaleGraphSamples(
+ this._adjustGraphSamplesForEntity(
+ g.entityId,
+ a?.samplesByPanelId[g.panelId] ?? []
+ ),
+ g.scale
+ )
+ )
+ ), h = t === "energy" ? this._resolveUnit("energy_entity", "kWh") : this._resolveUnit("power_entity", "W"), u = t === "energy" ? this._t("card.popup.history.total_panel_energy") : this._t("card.popup.history.total_panel_power");
+ return y`
+
+
+ ${this._renderPopupGraphBodyFromSamples(
+ `panel-total-${t}|${o}|${n}`,
+ h,
+ d,
+ n,
+ c,
+ p,
+ r
+ )}
+ ${r.overlayStateMessage ? y`
${r.overlayStateMessage}
` : w}
+ ${r.showEnableForecastButton ? y`
+
+
+
+ ` : w}
+
+ `;
+ }
+ _handleAggregatedPanelRangeChange(e, t, r, s, n) {
+ e.preventDefault(), e.stopPropagation();
+ const o = this._captureScrollPositionsForPopupGraph(e);
+ this._kpiCompareRangeHours = {
+ ...this._kpiCompareRangeHours,
+ [r]: s
+ }, this._scheduleCapturedScrollRestore(o), this._ensurePanelCompareLoaded(t, r, s, o), n && this._ensurePopupGraphLoadedByEntity(n, s, o);
+ }
+ _handlePanelCompareRangeChange(e, t, r, s) {
+ e.preventDefault(), e.stopPropagation();
+ const n = this._captureScrollPositionsForPopupGraph(e);
+ if (this._kpiCompareRangeHours = {
+ ...this._kpiCompareRangeHours,
+ [r]: s
+ }, this._scheduleCapturedScrollRestore(n), this._kpiCompareExpanded[r] && this._ensurePanelCompareLoaded(t, r, s, n), !this._getSystemEntityIdForKind(r) && this._isForecastOverlayEnabled()) {
+ const o = this._getForecastEntityId(r);
+ o && this._ensurePopupGraphLoadedByEntity(o, s, n);
+ }
+ }
+ _buildPanelCompareTargets(e, t) {
+ const r = new Map(
+ (this._config?.panels ?? []).map((s) => [s.id, s])
+ );
+ return e.panels.filter((s) => !s.hiddenSlot && s.enabled).map((s) => {
+ const n = r.get(s.id), o = t === "energy" ? n?.energy_entity?.trim() : n?.power_entity?.trim();
+ return o ? {
+ panelId: s.id,
+ label: s.label,
+ entityId: o,
+ scale: t === "energy" ? s.energyShareFactor ?? 1 : s.powerShareFactor ?? 1
+ } : null;
+ }).filter((s) => !!s);
+ }
+ _getPanelCompareSignature(e, t) {
+ return `${e}|${t.map((r) => `${r.panelId}:${r.entityId}:${r.scale}`).join("|")}`;
+ }
+ _getPanelCompareCacheKey(e, t, r) {
+ return `${e}|${t}|${r}`;
+ }
+ async _ensurePanelCompareLoaded(e, t, r, s) {
+ if (!this._config || !this.hass)
+ return;
+ const n = this._buildPanelCompareTargets(e, t);
+ if (n.length === 0)
+ return;
+ const o = this._getPanelCompareSignature(t, n), l = this._getPanelCompareCacheKey(t, o, r), a = this._kpiCompareCache[l], c = Date.now();
+ if (a?.loading || a && !a.error && a.loadedAt && c - a.loadedAt < un)
+ return;
+ const p = ++this._kpiCompareRequestToken;
+ if (this._kpiCompareLatestTokenByKey[l] = p, this._kpiCompareCache = {
+ ...this._kpiCompareCache,
+ [l]: {
+ loading: !0,
+ samplesByPanelId: a?.samplesByPanelId ?? {},
+ loadedAt: a?.loadedAt
+ }
+ }, this._scheduleCapturedScrollRestore(s), !this.hass.callApi && !this.hass.callWS) {
+ if (this._kpiCompareLatestTokenByKey[l] !== p)
+ return;
+ this._kpiCompareCache = {
+ ...this._kpiCompareCache,
+ [l]: {
+ loading: !1,
+ samplesByPanelId: {},
+ error: this._t("card.popup.panel_compare.unable_load", {
+ error: this._t("common.unknown_recorder_error")
+ }),
+ loadedAt: Date.now()
+ }
+ }, this._scheduleCapturedScrollRestore(s);
+ return;
+ }
+ try {
+ const d = [...new Set(n.map((S) => S.entityId))], h = {};
+ let u = null;
+ const g = await Promise.allSettled(
+ d.map(async (S) => ({
+ entityId: S,
+ samples: await this._loadPopupGraphRecorderSamples(S, r)
+ }))
+ );
+ for (const S of g) {
+ if (S.status === "fulfilled") {
+ h[S.value.entityId] = S.value.samples;
+ continue;
+ }
+ u || (u = Ct(
+ $t(S.reason, this._t("common.unknown_recorder_error"))
+ ));
+ }
+ if (this._kpiCompareLatestTokenByKey[l] !== p)
+ return;
+ const f = Object.values(h).some(
+ (S) => S.length > 0
+ );
+ if (Object.keys(h).length === 0 && u) {
+ this._kpiCompareCache = {
+ ...this._kpiCompareCache,
+ [l]: {
+ loading: !1,
+ samplesByPanelId: {},
+ error: this._t("card.popup.panel_compare.unable_load", {
+ error: u
+ }),
+ loadedAt: Date.now()
+ }
+ }, this._scheduleCapturedScrollRestore(s);
+ return;
+ }
+ const x = {};
+ for (const S of n)
+ x[S.panelId] = h[S.entityId] ?? [];
+ this._kpiCompareCache = {
+ ...this._kpiCompareCache,
+ [l]: {
+ loading: !1,
+ samplesByPanelId: x,
+ error: !f && u ? this._t("card.popup.panel_compare.unable_load", {
+ error: u
+ }) : void 0,
+ loadedAt: Date.now()
+ }
+ }, this._scheduleCapturedScrollRestore(s);
+ } catch (d) {
+ if (this._kpiCompareLatestTokenByKey[l] !== p)
+ return;
+ this._kpiCompareCache = {
+ ...this._kpiCompareCache,
+ [l]: {
+ loading: !1,
+ samplesByPanelId: {},
+ error: this._t("card.popup.panel_compare.unable_load", {
+ error: Ct($t(d, this._t("common.unknown_recorder_error")))
+ }),
+ loadedAt: Date.now()
+ }
+ }, this._scheduleCapturedScrollRestore(s);
+ }
+ }
+ _renderInverterEvaluation(e) {
+ return !e.inverterStatusDisplay || e.inverterStatusDisplay === this._t("common.unavailable") ? this._t("card.popup.inverter_eval.no_status") : e.inverterFaultMatched ? this._t("card.popup.inverter_eval.fault_match") : e.inverterWorkingMatched ? this._t("card.popup.inverter_eval.working_match") : this._t("card.popup.inverter_eval.no_match");
+ }
+ _renderLivePowerDialog(e) {
+ const t = this._getSystemPowerEntityId(), r = this._resolveSummaryPower(e), s = this._resolveForecastMetricDisplay(
+ "power",
+ this._config?.power_decimals ?? 0,
+ "W"
+ ), n = this._isForecastOverlayEnabled() && !!s.entityId, o = s.entityId ? this._isForecastOverlayEnabled() ? null : this._t("card.popup.forecast.disabled_hint") : this._t("card.popup.forecast.default_sensor_not_found", {
+ entity: s.expectedEntityId
+ }), l = !this._isForecastOverlayEnabled() && !!s.entityId, a = this.hass?.locale?.language ?? "en";
+ return y`
+
+ `;
+ }
+ _renderEnergyDialog(e) {
+ const t = this._getSystemEnergyEntityId(), r = this._resolveSummaryEnergy(e), s = this._resolveForecastMetricDisplay(
+ "energy",
+ this._config?.energy_decimals ?? 2,
+ "kWh"
+ ), n = this._isForecastOverlayEnabled() && !!s.entityId, o = s.entityId ? this._isForecastOverlayEnabled() ? null : this._t("card.popup.forecast.disabled_hint") : this._t("card.popup.forecast.default_sensor_not_found", {
+ entity: s.expectedEntityId
+ }), l = !this._isForecastOverlayEnabled() && !!s.entityId, a = this.hass?.locale?.language ?? "en";
+ return y`
+
+ `;
+ }
+ _renderCustomKpiDialog() {
+ const e = this._resolveCustomKpi(), t = e.title.length > 0 ? this._resolveCustomKpiTitle(e.title) : this._t("card.summary.custom_default_title"), r = this._getCustomKpiEntityId();
+ return y`
+
+ `;
+ }
+ _renderSystemHealthDialog(e) {
+ const t = this._buildSystemHealthSections(e), r = t.length > 0;
+ return y`
+
+ `;
+ }
+ _renderMetricList(e, t) {
+ return y`
+
+
+ ${t.map(
+ (r) => y`
+
+ ${r.label}: ${r.value}
+
+ `
+ )}
+
+ `;
+ }
+ _renderPopupGraph(e) {
+ const t = this._getPanelConfig(e.id);
+ return this._renderPopupGraphForEntity(
+ t?.power_entity,
+ this._t("card.popup.history.power"),
+ {
+ primaryValueScale: e.powerShareFactor ?? 1
+ }
+ );
+ }
+ _renderPopupGraphForEntity(e, t, r = {}) {
+ return y`
+
+
+ ${e ? this._renderPopupGraphBody(e, r) : y`
${this._t("card.popup.history.graph_not_configured")}
`}
+ ${r.overlayStateMessage ? y`
${r.overlayStateMessage}
` : w}
+ ${r.showEnableForecastButton ? y`
+
+
+
+ ` : w}
+
+ `;
+ }
+ _renderPopupGraphBody(e, t = {}) {
+ const r = this._getPopupGraphCacheKey(e, this._popupGraphRangeHours), s = this._popupGraphCache[r], n = s?.loading ?? !1, o = s?.error, l = s?.samples ?? [], a = this._adjustGraphSamplesForEntity(e, l), c = t.invertPrimaryValues ? a.map((h) => ({
+ ts: h.ts,
+ value: re(h.value * -1)
+ })) : a, p = this._scaleGraphSamples(
+ c,
+ t.primaryValueScale ?? 1
+ ), d = this._getUnitForEntity(e, "W");
+ return this._renderPopupGraphBodyFromSamples(
+ r,
+ d,
+ p,
+ this._popupGraphRangeHours,
+ n,
+ o,
+ t
+ );
+ }
+ _renderPopupGraphBodyFromSamples(e, t, r, s, n, o, l = {}) {
+ const a = this._fillRangeStartWithFirstSample(
+ r,
+ s,
+ l.fillStartWithFirstSample ?? !1
+ ), c = ds(a, {
+ rangeHours: s
+ }), p = this._expandSinglePointRangeSamples(
+ c,
+ s
+ ), d = l.overlayEntityId ?? null, h = d ? this._popupGraphCache[this._getPopupGraphCacheKey(d, s)] : void 0, u = h?.samples ?? [], g = d ? this._expandSinglePointRangeSamples(
+ this._adjustGraphSamplesForEntity(d, u),
+ s
+ ) : [], f = d ? h?.loading ?? !1 : !1, v = d ? h?.error : void 0;
+ if (n)
+ return y`${this._t("card.popup.history.loading")}
`;
+ if (o)
+ return y`${o}
`;
+ if (p.length === 0)
+ return y`${this._t("card.popup.history.no_data")}
`;
+ const x = Mt(p, St), S = this._computeGraphStats(p), I = Date.now(), M = I - s * 60 * 60 * 1e3, $ = I, E = d && !f && !v && g.length > 0 ? Mt(g, St) : [], L = p.map((B) => B.value).filter((B) => Number.isFinite(B)), R = E.map((B) => B.value).filter((B) => Number.isFinite(B)), m = [...L, ...R], P = m.length > 0 ? Math.min(...m) : null, k = m.length > 0 ? Math.max(...m) : null, F = P === null || k === null ? null : P === k ? P - 1 : P, A = P === null || k === null ? null : P === k ? k + 1 : k, O = F !== null && A !== null ? {
+ startTs: M,
+ endTs: $,
+ minValue: F,
+ maxValue: A
+ } : null, se = O !== null ? this._buildAlignedGraphLinePath(x, O, 320, 132, 10) : "", b = O !== null ? this._buildAlignedGraphAreaPath(x, O, 320, 132, 10) : "", C = O !== null && E.length > 0 ? this._buildAlignedGraphLinePath(E, O, 320, 132, 10) : "", W = this._buildGraphHourTicks(
+ {
+ startTs: M,
+ endTs: $
+ },
+ s
+ ), pe = this._buildGraphAxisTicks(W), K = S.max !== null && F !== null && A !== null ? this._toGraphY(S.max, F, A, 132, 10) : null, ye = S.median !== null && F !== null && A !== null ? this._toGraphY(S.median, F, A, 132, 10) : null, ve = S.min !== null && F !== null && A !== null ? this._toGraphY(S.min, F, A, 132, 10) : null, et = `spv-graph-${e.replace(/[^a-zA-Z0-9_-]/g, "-")}`, Ne = S.max !== null ? this._t("card.popup.history.max", {
+ value: this._formatGraphPower(S.max, t)
+ }) : null, we = S.median !== null ? this._t("card.popup.history.median", {
+ value: this._formatGraphPower(S.median, t)
+ }) : null, tt = S.min !== null ? this._t("card.popup.history.min", {
+ value: this._formatGraphPower(S.min, t)
+ }) : null;
+ return y`
+
+ ${Ne ? y`
${Ne}` : w}
+ ${we ? y`
+
+ ${we}
+
+ ` : w}
+ ${tt ? y`
${tt}` : w}
+
+
+
+ ${pe.map(
+ (B) => y`
+
+ ${B.label}
+
+ `
+ )}
+
+
+
+ ${this._t("card.popup.history.time_range", {
+ start: this._formatGraphTime(M),
+ end: this._formatGraphTime($)
+ })}
+
+
+ ${d && f ? y`${this._t("card.popup.history.loading")}
` : w}
+ ${d && !f && v ? y`${v}
` : w}
+ ${d && !f && !v && g.length === 0 ? y`${this._t("card.popup.history.no_data")}
` : w}
+ `;
+ }
+ _handlePopupGraphRangeChangeForEntity(e, t, r, s) {
+ e.preventDefault(), e.stopPropagation();
+ const n = this._captureScrollPositionsForPopupGraph(e);
+ this._popupGraphRangeHours = r, this._scheduleCapturedScrollRestore(n), t && this._ensurePopupGraphLoadedByEntity(t, r, n), s && this._ensurePopupGraphLoadedByEntity(s, r, n);
+ }
+ _toggleTelemetryGraph(e, t, r) {
+ e.preventDefault(), e.stopPropagation();
+ const s = this._captureScrollPositionsForPopupGraph(e);
+ if (this._telemetryGraphPanelId === t && this._telemetryGraphEntityId === r) {
+ this._clearTelemetryGraph(), this._scheduleCapturedScrollRestore(s);
+ return;
+ }
+ this._telemetryGraphPanelId = t, this._telemetryGraphEntityId = r, this._telemetryGraphRangeHours = 6, this._scheduleCapturedScrollRestore(s), this._ensurePopupGraphLoadedByEntity(r, this._telemetryGraphRangeHours, s);
+ }
+ _handleTelemetryGraphRangeChange(e, t, r) {
+ e.preventDefault(), e.stopPropagation();
+ const s = this._captureScrollPositionsForPopupGraph(e);
+ this._telemetryGraphRangeHours = r, this._scheduleCapturedScrollRestore(s), this._ensurePopupGraphLoadedByEntity(t, r, s);
+ }
+ _clearTelemetryGraph() {
+ this._telemetryGraphPanelId = null, this._telemetryGraphEntityId = null, this._telemetryGraphRangeHours = 6;
+ }
+ _getPopupGraphCacheKey(e, t) {
+ return `${e}|${t}`;
+ }
+ _getPanelConfig(e) {
+ return this._config?.panels.find((t) => t.id === e);
+ }
+ async _ensurePopupGraphLoaded(e, t, r) {
+ if (!this._config || !this.hass)
+ return;
+ const n = this._getPanelConfig(e)?.power_entity;
+ n && await this._ensurePopupGraphLoadedByEntity(n, t, r);
+ }
+ async _ensurePopupGraphLoadedByEntity(e, t, r) {
+ if (!this._config || !this.hass)
+ return;
+ const s = this._getPopupGraphCacheKey(e, t), n = this._popupGraphCache[s], o = Date.now();
+ if (n?.loading || n && !n.error && n.loadedAt && o - n.loadedAt < hn)
+ return;
+ const l = ++this._popupGraphRequestToken;
+ if (this._popupGraphLatestTokenByKey[s] = l, this._popupGraphCache = {
+ ...this._popupGraphCache,
+ [s]: {
+ loading: !0,
+ samples: n?.samples ?? [],
+ loadedAt: n?.loadedAt
+ }
+ }, this._scheduleCapturedScrollRestore(r), !this.hass.callApi && !this.hass.callWS) {
+ if (this._popupGraphLatestTokenByKey[s] !== l)
+ return;
+ this._popupGraphCache = {
+ ...this._popupGraphCache,
+ [s]: {
+ loading: !1,
+ samples: [],
+ error: "Unable to load panel history",
+ loadedAt: Date.now()
+ }
+ }, this._scheduleCapturedScrollRestore(r);
+ return;
+ }
+ try {
+ if (this._popupGraphLatestTokenByKey[s] !== l)
+ return;
+ const a = await this._loadPopupGraphRecorderSamples(e, t);
+ if (this._popupGraphLatestTokenByKey[s] !== l)
+ return;
+ this._popupGraphCache = {
+ ...this._popupGraphCache,
+ [s]: {
+ loading: !1,
+ samples: a,
+ loadedAt: Date.now()
+ }
+ }, this._scheduleCapturedScrollRestore(r);
+ } catch (a) {
+ if (this._popupGraphLatestTokenByKey[s] !== l)
+ return;
+ this._popupGraphCache = {
+ ...this._popupGraphCache,
+ [s]: {
+ loading: !1,
+ samples: [],
+ error: `Unable to load panel history (${Ct($t(a))})`,
+ loadedAt: Date.now()
+ }
+ }, this._scheduleCapturedScrollRestore(r);
+ }
+ }
+ async _loadPopupGraphRecorderSamples(e, t) {
+ if (!this.hass || !this.hass.callApi && !this.hass.callWS)
+ throw new Error("Recorder API unavailable");
+ const r = Date.now(), s = Math.min(168, t + 6), n = new Date(r - s * 60 * 60 * 1e3).toISOString(), o = new Date(r).toISOString(), l = this._getSharedHistorySamplesForRange(e, t);
+ if (l) {
+ const d = lt(l, r, t), h = this._stabilizePopupRangeSamples(
+ e,
+ l,
+ d,
+ r,
+ t
+ );
+ if (this._hasPopupRangeCoverage(h, r, t))
+ return h;
+ try {
+ const u = await this._fetchRecorderHistoryRaw(n, o, [e]), g = this._parseRecorderResponse(u, s, [e])[e] ?? [], f = this._mergeGraphSampleSets(l, g), v = lt(f, r, t);
+ return this._stabilizePopupRangeSamples(e, f, v, r, t);
+ } catch {
+ return h;
+ }
+ }
+ const a = await this._fetchRecorderHistoryRaw(n, o, [e]), c = this._parseRecorderResponse(a, s, [e])[e] ?? [], p = lt(c, r, t);
+ return this._stabilizePopupRangeSamples(e, c, p, r, t);
+ }
+ _getSharedHistorySamplesForRange(e, t) {
+ if (!(this._config?.enable_array_checks ?? !1) || this._getHistoryHours() < t)
+ return null;
+ const r = this._historyByEntityId[e];
+ return !r || r.length === 0 ? null : r;
+ }
+ _stabilizePopupRangeSamples(e, t, r, s, n) {
+ const o = s - n * 60 * 60 * 1e3, l = [...t].sort((u, g) => u.ts - g.ts), a = [...r].sort((u, g) => u.ts - g.ts), c = l.filter((u) => u.ts < o).pop() ?? null, p = this._isForecastDefaultEntity(e), d = Number(this.hass?.states?.[e]?.state);
+ if (a.length === 0)
+ p && Number.isFinite(d) ? a.push({ ts: o, value: d }) : c ? a.push({ ts: o, value: c.value }) : Number.isFinite(d) && a.push({ ts: o, value: d });
+ else if (a[0].ts > o && (c || p)) {
+ const u = p ? a[0].value : c?.value ?? a[0].value;
+ a.unshift({ ts: o, value: u });
+ }
+ const h = a[a.length - 1];
+ if (!h && Number.isFinite(d))
+ a.push({ ts: s, value: d });
+ else if (h && h.ts < s) {
+ const u = Number.isFinite(d) ? d : h.value;
+ a.push({ ts: s, value: u });
+ }
+ return a.filter((u) => Number.isFinite(u.ts) && Number.isFinite(u.value)).sort((u, g) => u.ts - g.ts);
+ }
+ _hasPopupRangeCoverage(e, t, r) {
+ if (e.length === 0)
+ return !1;
+ const s = t - r * 60 * 60 * 1e3, n = e[0].ts, o = e[e.length - 1].ts, l = 900 * 1e3;
+ return n <= s + l && o >= t - l;
+ }
+ _mergeGraphSampleSets(...e) {
+ const t = /* @__PURE__ */ new Map();
+ for (const r of e)
+ for (const s of r)
+ !Number.isFinite(s.ts) || !Number.isFinite(s.value) || t.set(s.ts, s);
+ return [...t.values()].sort((r, s) => r.ts - s.ts);
+ }
+ _getUnitForEntity(e, t) {
+ const r = this.hass?.states?.[e]?.attributes?.unit_of_measurement;
+ return typeof r == "string" && r.trim().length > 0 ? r : t;
+ }
+ _formatGraphPower(e, t) {
+ const r = t.trim();
+ if (e === null)
+ return r ? `0 ${t}` : "0";
+ const s = this.hass?.locale?.language ?? "en", n = re(e), o = r.toLowerCase(), l = o.includes("wh") || o.includes("kwh") ? this._config?.energy_decimals ?? 2 : this._config?.power_decimals ?? 0, a = new Intl.NumberFormat(s, {
+ minimumFractionDigits: l,
+ maximumFractionDigits: l
+ }).format(n);
+ return r ? `${a} ${t}` : a;
+ }
+ _computeGraphStats(e) {
+ const t = e.map((l) => l.value).filter((l) => Number.isFinite(l)).sort((l, a) => l - a);
+ if (t.length === 0)
+ return { min: null, max: null, median: null };
+ const r = t[0] ?? null, s = t[t.length - 1] ?? null, n = Math.floor(t.length / 2), o = t.length % 2 === 0 ? (t[n - 1] + t[n]) / 2 : t[n];
+ return { min: r, max: s, median: o };
+ }
+ _buildGraphHourTicks(e, t) {
+ if (e.startTs === null || e.endTs === null)
+ return [];
+ const r = e.startTs, s = e.endTs, n = Math.max(s - r, 1), o = 320, l = 10, a = o - l * 2, c = t === 1 ? 900 * 1e3 : t === 6 ? 3600 * 1e3 : 14400 * 1e3, p = [], d = (u) => {
+ const g = new Date(u), f = `${g.getHours()}`.padStart(2, "0"), v = `${g.getMinutes()}`.padStart(2, "0");
+ return t === 1 ? `${f}:${v}` : `${f}h`;
+ };
+ let h = Math.ceil(r / c) * c;
+ for (; h < s; ) {
+ const u = l + (h - r) / n * a;
+ p.push({
+ x: u,
+ label: d(h)
+ }), h += c;
+ }
+ if (t === 6 && p.length < 3) {
+ const u = [0.25, 0.5, 0.75];
+ for (const g of u) {
+ const f = r + n * g, v = l + (f - r) / n * a;
+ p.push({
+ x: v,
+ label: d(f)
+ });
+ }
+ }
+ return p.sort((u, g) => u.x - g.x), p;
+ }
+ _buildGraphAxisTicks(e) {
+ if (e.length === 0)
+ return [];
+ const t = 10, s = 320 - t * 2;
+ return e.map((n) => ({
+ label: n.label,
+ leftPercent: (n.x - t) / s * 100
+ }));
+ }
+ _toGraphY(e, t, r, s, n) {
+ const o = Math.max(r - t, 1), l = Math.max(s - n * 2, 1);
+ return s - n - (e - t) / o * l;
+ }
+ _buildAlignedGraphLinePath(e, t, r, s, n) {
+ const o = e.filter((h) => Number.isFinite(h.ts) && Number.isFinite(h.value)).sort((h, u) => h.ts - u.ts);
+ if (o.length === 0)
+ return "";
+ const l = Math.max(t.endTs - t.startTs, 1), a = Math.max(t.maxValue - t.minValue, 1), c = Math.max(r - n * 2, 1), p = Math.max(s - n * 2, 1), d = [];
+ for (const h of o) {
+ const u = Math.min(Math.max(h.ts, t.startTs), t.endTs), g = n + (u - t.startTs) / l * c, f = (h.value - t.minValue) / a, v = s - n - Math.min(Math.max(f, 0), 1) * p, x = d[d.length - 1];
+ x && Math.abs(x.x - g) < 0.01 && Math.abs(x.y - v) < 0.01 || d.push({ x: g, y: v });
+ }
+ return d.length === 0 ? "" : (d.length === 1 && d.push({ ...d[0] }), d.map(
+ (h, u) => `${u === 0 ? "M" : "L"}${h.x.toFixed(2)},${h.y.toFixed(2)}`
+ ).join(" "));
+ }
+ _buildAlignedGraphAreaPath(e, t, r, s, n) {
+ const o = this._buildAlignedGraphLinePath(e, t, r, s, n);
+ if (!o)
+ return "";
+ const l = e.filter((f) => Number.isFinite(f.ts) && Number.isFinite(f.value)).sort((f, v) => f.ts - v.ts);
+ if (l.length === 0)
+ return "";
+ const a = Math.max(t.endTs - t.startTs, 1), c = Math.max(r - n * 2, 1), p = Math.min(Math.max(l[0].ts, t.startTs), t.endTs), d = Math.min(
+ Math.max(l[l.length - 1].ts, t.startTs),
+ t.endTs
+ ), h = n + (p - t.startTs) / a * c, u = n + (d - t.startTs) / a * c, g = s - n;
+ return `${o} L${u.toFixed(2)},${g.toFixed(2)} L${h.toFixed(2)},${g.toFixed(2)} Z`;
+ }
+ _formatGraphTime(e) {
+ if (e === null)
+ return "--:--";
+ const t = this.hass?.locale?.language ?? "en";
+ return new Intl.DateTimeFormat(t, {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: !1,
+ hourCycle: "h23"
+ }).format(e);
+ }
+ _formatGraphDiagnosticTime(e) {
+ if (e === null)
+ return "--";
+ const t = this.hass?.locale?.language ?? "en";
+ return new Intl.DateTimeFormat(t, {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: !1,
+ hourCycle: "h23"
+ }).format(e);
+ }
+ _resolveThemeMode() {
+ const e = this._config?.theme_mode ?? "auto";
+ if (e === "dark" || e === "light")
+ return e;
+ const t = this.hass?.themes?.darkMode;
+ return typeof t == "boolean" ? t ? "dark" : "light" : typeof window < "u" && typeof window.matchMedia == "function" ? window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" : "dark";
+ }
+ _buildSystemHealthState(e) {
+ const t = {
+ error: this._config?.colors?.error ?? "#ff627f",
+ offline: this._config?.colors?.unavailable ?? "#586779",
+ deviation: this._config?.colors?.deviation ?? "#ff9667",
+ setup: "#41c9d8",
+ ok: "#8ed072"
+ }, r = e.inverterCount + e.errorCount;
+ return r > 0 ? {
+ label: `${r} Fault${r === 1 ? "" : "s"}`,
+ color: t.error
+ } : e.offlineCount > 0 ? {
+ label: `${e.offlineCount} Unavailable`,
+ color: t.offline
+ } : e.deviationCount > 0 ? {
+ label: `${e.deviationCount} Deviation`,
+ color: t.deviation
+ } : e.unconfiguredCount > 0 ? {
+ label: `${e.unconfiguredCount} Needs Setup`,
+ color: t.setup
+ } : { label: "System OK", color: t.ok };
+ }
+ _buildSystemHealthSections(e) {
+ const t = e.panels.filter((p) => !p.hiddenSlot), r = (p) => t.filter((d) => d.status === p).map((d) => `Panel on ${this._formatSlotLabel(d.slotIndex)} (${d.label}): ${d.reason}`), s = [], n = r("inverter");
+ n.length > 0 && s.push({ title: "Inverter", items: n });
+ const o = r("error");
+ o.length > 0 && s.push({ title: "Error", items: o });
+ const l = r("deviation");
+ l.length > 0 && s.push({ title: "Deviation", items: l });
+ const a = r("offline");
+ a.length > 0 && s.push({ title: "Unavailable", items: a });
+ const c = r("unconfigured");
+ return c.length > 0 && s.push({ title: "Needs setup", items: c }), s;
+ }
+ _computeRenderedColumns(e, t) {
+ const r = Math.max(1, Math.floor(e)), s = this._cardWidth > 0 ? this._cardWidth : t ?? 980, n = t ? Math.min(s, t) : s;
+ if (!Number.isFinite(n) || n <= 0)
+ return r;
+ const o = Math.max(120, n - 40), l = n <= 560 ? 8 : 10, a = n <= 760, c = a ? Math.min(r, 3) : r, p = this._getPanelWidthCapPx(), d = p !== null ? p : a ? 100 : Math.max(130, Math.min(220, o * 0.32)), h = Math.floor((o + l) / (d + l));
+ return Math.max(1, Math.min(c, h || 1));
+ }
+ _computeRenderedRows(e, t) {
+ const r = Math.max(1, e), s = Math.max(1, t);
+ return Math.max(1, Math.ceil(r / s));
+ }
+ _computeWidthBasedPanelHeight(e, t) {
+ const s = this._computeTileWidthPx(e, t) * 0.62;
+ return Math.round(Math.min(220, Math.max(92, s)));
+ }
+ _computePanelWidthPx(e, t) {
+ const r = this._computeTileWidthPx(e, t);
+ return Math.max(96, Math.round(r));
+ }
+ _computeTileWidthPx(e, t) {
+ const r = this._cardWidth > 0 ? this._cardWidth : t ?? 980, s = t ? Math.min(r, t) : r, n = s <= 560 ? 8 : 10, o = Math.max(120, s - 40), l = Math.max(1, e), a = (o - Math.max(0, l - 1) * n) / l;
+ return this._applyPanelWidthCapPx(a);
+ }
+ _getPanelWidthCapPx() {
+ if (!(this._config?.limit_panel_width ?? !1))
+ return null;
+ const e = this._config?.panel_max_width_px ?? 220;
+ return Math.min(Math.max(e, 120), 320);
+ }
+ _applyPanelWidthCapPx(e) {
+ const t = this._getPanelWidthCapPx();
+ return t ? Math.min(e, t) : e;
+ }
+ _computePanelHeightPx(e, t, r = this._config?.columns ?? 3, s) {
+ const n = this._computeWidthBasedPanelHeight(r, s);
+ if (!t || !Number.isFinite(t))
+ return n;
+ const o = Math.max(1, e), l = 250, a = 36, c = Math.max(o - 1, 0) * 10, d = (t - l - a - c) / o;
+ if (!Number.isFinite(d))
+ return n;
+ const h = Math.min(240, Math.max(96, d)), u = Math.min(h, n * 1.6);
+ return Math.round(Math.min(220, Math.max(92, u)));
+ }
+ _computePanelScale(e) {
+ const r = e / 128, s = Math.min(1.28, Math.max(0.76, r));
+ return Number(s.toFixed(3));
+ }
+ _startResizeObserver() {
+ if (typeof ResizeObserver > "u" || this._resizeObserver)
+ return;
+ const e = () => {
+ const t = Math.round(this.getBoundingClientRect().width);
+ !Number.isFinite(t) || t <= 0 || t !== this._cardWidth && (this._cardWidth = t);
+ };
+ this._resizeObserver = new ResizeObserver(() => {
+ e();
+ }), this._resizeObserver.observe(this), e();
+ }
+ _shouldShowPerformance(e, t, r) {
+ return e.performancePercent === null || e.ratedPowerW === null ? !1 : t >= 94 && r >= 110;
+ }
+ _formatPanelPerformanceText(e, t, r, s = this._buildPanelPerformanceLabelCandidates(e)) {
+ if (!s)
+ return "";
+ const n = this._computePanelScale(r), l = Math.min(0.96, Math.max(0.62, 0.72 * n)) * 16;
+ return tn({
+ candidates: s,
+ panelWidthPx: t,
+ panelHeightPx: r,
+ fontPx: l,
+ reservedRightPx: _n,
+ measureTextWidthPx: (a, c) => this._measureTextWidthPx(a, c)
+ }).text;
+ }
+ _buildPanelPerformanceLabelCandidates(e) {
+ if (e.performancePercent === null || e.ratedPowerW === null)
+ return null;
+ const t = e.performancePercent.toFixed(0), r = e.ratedPowerW.toFixed(0);
+ return [
+ {
+ variant: "full",
+ text: this._t("card.panel.performance_full", { percent: t, rated: r })
+ },
+ {
+ variant: "medium",
+ text: this._t("card.panel.performance_medium", { percent: t, rated: r })
+ },
+ {
+ variant: "compact",
+ text: this._t("card.panel.performance_compact", { percent: t })
+ }
+ ];
+ }
+ _measureTextWidthPx(e, t) {
+ if (typeof document > "u")
+ return e.length * t * 0.52;
+ if (_e._textMeasureContext === void 0) {
+ const s = document.createElement("canvas");
+ _e._textMeasureContext = s.getContext("2d");
+ }
+ const r = _e._textMeasureContext;
+ return r ? (r.font = `500 ${t}px system-ui, -apple-system, "Segoe UI", sans-serif`, r.measureText(e).width) : e.length * t * 0.52;
+ }
+ _handlePanelClick(e) {
+ e.hiddenSlot || (this._config?.panel_tap_action ?? "details") === "details" && (this._showLivePowerPopup = !1, this._showEnergyPopup = !1, this._showCustomKpiPopup = !1, this._showSystemHealthPopup = !1, this._selectedPanelId = e.id, this._popupGraphRangeHours = 6, this._clearTelemetryGraph(), this._kpiCompareExpanded = { power: !1, energy: !1 }, this._kpiCompareRangeHours = { power: 6, energy: 6 }, this._ensurePopupGraphLoaded(e.id, 6));
+ }
+ _resolveUnit(e, t) {
+ if (!this._config)
+ return t;
+ for (const r of this._config.panels) {
+ const s = r[e];
+ if (!s)
+ continue;
+ const n = this.hass?.states?.[s]?.attributes?.unit_of_measurement;
+ if (typeof n == "string" && n.trim().length > 0)
+ return n;
+ }
+ return t;
+ }
+ _getSystemPowerEntityId() {
+ if (!this._config || !(this._config.use_system_power_entity ?? !1))
+ return null;
+ const e = this._config.system_power_entity?.trim();
+ return e && e.length > 0 ? e : null;
+ }
+ _getSystemEnergyEntityId() {
+ if (!this._config || !(this._config.use_system_energy_entity ?? !1))
+ return null;
+ const e = this._config.system_energy_entity?.trim();
+ return e && e.length > 0 ? e : null;
+ }
+ _getSystemEntityIdForKind(e) {
+ return e === "energy" ? this._getSystemEnergyEntityId() : this._getSystemPowerEntityId();
+ }
+ _getForecastEntityId(e) {
+ return Si(this.hass, e);
+ }
+ _isForecastDefaultEntity(e) {
+ return e === at("power") || e === at("energy");
+ }
+ _isForecastOverlayEnabled() {
+ return this._config?.enable_forecast_overlay ?? !1;
+ }
+ _resolveForecastMetricDisplay(e, t, r) {
+ const s = at(e), n = this._getForecastEntityId(e);
+ if (!n)
+ return {
+ expectedEntityId: s,
+ entityId: null,
+ value: null,
+ unit: r,
+ display: this._t("common.not_configured")
+ };
+ const o = this._getUnitForEntity(n, r), l = Number(this.hass?.states?.[n]?.state), a = Number.isFinite(l) ? re(l) : null, c = this.hass?.locale?.language ?? "en";
+ return {
+ expectedEntityId: s,
+ entityId: n,
+ value: a,
+ unit: o,
+ display: He(a, t, o, this._t("common.unavailable"), c)
+ };
+ }
+ _resolveSummaryPower(e) {
+ const t = this._getSystemPowerEntityId();
+ if (t) {
+ const r = this.hass?.states?.[t], s = Number(r?.state), n = Number.isFinite(s) ? s : null;
+ return {
+ value: n === null ? null : this._config?.invert_system_power ? re(n * -1) : re(n),
+ unit: this._getUnitForEntity(t, "W")
+ };
+ }
+ return {
+ value: e.totalPower,
+ unit: this._resolveUnit("power_entity", "W")
+ };
+ }
+ _resolveSummaryEnergy(e) {
+ const t = this._getSystemEnergyEntityId();
+ if (!this._config)
+ return { value: null, unit: "kWh" };
+ if (t) {
+ const r = this.hass?.states?.[t], s = Number(r?.state);
+ return {
+ value: Number.isFinite(s) ? s : null,
+ unit: this._getUnitForEntity(t, "kWh")
+ };
+ }
+ return {
+ value: e.totalEnergy,
+ unit: this._resolveUnit("energy_entity", "kWh")
+ };
+ }
+ _isSummaryEnergyConfigured() {
+ return this._config ? this._config.use_system_energy_entity ?? !1 ? !!this._config.system_energy_entity?.trim() : this._config.panels.some((e) => !!e.energy_entity) : !1;
+ }
+ _resolveCustomKpi() {
+ const e = this._config?.custom_kpi_title, t = e !== void 0 ? e : "Custom KPI", r = this._getCustomKpiEntityId();
+ if (!r)
+ return { title: t, value: "Not configured" };
+ const s = this.hass?.states?.[r];
+ if (!s)
+ return { title: t, value: "Unavailable" };
+ const n = s.state?.toString().trim().toLowerCase();
+ if (!n || n === "unknown" || n === "unavailable")
+ return { title: t, value: "Unavailable" };
+ const o = Number(s.state);
+ if (Number.isFinite(o)) {
+ const l = this.hass?.locale?.language ?? "en", a = this._getUnitForEntity(r, ""), c = this._config?.invert_custom_kpi ? re(o * -1) : re(o), p = wi(
+ String(c),
+ l,
+ this._config?.custom_kpi_decimals ?? 0,
+ a
+ );
+ return {
+ title: t,
+ value: p ?? s.state
+ };
+ }
+ return { title: t, value: s.state };
+ }
+ _getCustomKpiEntityId() {
+ const e = this._config?.custom_kpi_entity?.trim();
+ return e && e.length > 0 ? e : null;
+ }
+ _handlePopupCloseClick(e, t) {
+ e.preventDefault(), e.stopPropagation(), t();
+ }
+ _closeAllPopups() {
+ this._selectedPanelId = null, this._showLivePowerPopup = !1, this._showEnergyPopup = !1, this._showCustomKpiPopup = !1, this._showSystemHealthPopup = !1, this._clearTelemetryGraph(), this._clearPopupScrollRestore();
+ }
+ _clearPopupScrollRestore() {
+ const e = this._popupScrollRestore;
+ e && e.frameId !== null && typeof window < "u" && window.cancelAnimationFrame(e.frameId), this._popupScrollRestore = null, this._popupScrollRestoreToken++;
+ }
+ _captureScrollPositionsForPopupGraph(e) {
+ const s = (e?.currentTarget instanceof HTMLElement ? e.currentTarget : e?.target instanceof HTMLElement ? e.target : null)?.closest(".spv-popup") ?? null ?? this.renderRoot.querySelector(".spv-popup");
+ if (!s)
+ return this._popupScrollRestore = null, null;
+ const n = ++this._popupScrollRestoreToken;
+ return this._popupScrollRestore = {
+ token: n,
+ popup: s,
+ top: s.scrollTop,
+ left: s.scrollLeft,
+ expiresAt: Date.now() + 2500,
+ frameId: null,
+ framesRemaining: 0
+ }, this._scheduleCapturedScrollRestore(n), n;
+ }
+ _scheduleCapturedScrollRestore(e, t = 2) {
+ if (!e || typeof window > "u")
+ return;
+ if (!this._hasPopupOpen()) {
+ this._popupScrollRestore = null;
+ return;
+ }
+ const r = this._popupScrollRestore;
+ if (!(!r || r.token !== e)) {
+ if (r.expiresAt < Date.now()) {
+ this._popupScrollRestore = null;
+ return;
+ }
+ r.framesRemaining = Math.max(r.framesRemaining, t), r.frameId === null && (r.frameId = window.requestAnimationFrame(() => {
+ const s = this._popupScrollRestore;
+ if (!(!s || s.token !== e)) {
+ if (s.frameId = null, !this._hasPopupOpen()) {
+ this._popupScrollRestore = null;
+ return;
+ }
+ if (s.expiresAt < Date.now()) {
+ this._popupScrollRestore = null;
+ return;
+ }
+ if (s.popup.isConnected && (s.popup.scrollTop = s.top, s.popup.scrollLeft = s.left), s.framesRemaining = Math.max(0, s.framesRemaining - 1), s.framesRemaining > 0) {
+ this._scheduleCapturedScrollRestore(e, s.framesRemaining);
+ return;
+ }
+ this._popupScrollRestore = null;
+ }
+ }));
+ }
+ }
+ _renderInlinePanelConfig(e) {
+ const t = this._config?.panels.find((n) => n.id === e.id);
+ if (!t)
+ return w;
+ const r = this._getAvailableQuickSetupPowerEntities(e.id), s = !(t.enabled ?? !0);
+ return y`
+
+
Quick Setup:
+
this._updatePanelConfigFromCard(e.id, "power_entity", n.detail.value)}
+ >
+ ${r.length === 0 ? y`
No available W sensors found.
` : w}
+
+ this._updatePanelConfigFromCard(
+ e.id,
+ "enabled",
+ !n.currentTarget.checked
+ )}
+ >Disable Panel (hide but keep slot when off)
+
+
+ `;
+ }
+ _getAvailableQuickSetupPowerEntities(e) {
+ if (!this._config || !this.hass)
+ return [];
+ const r = this._config.panels.find((o) => o.id === e)?.power_entity, s = this._config.enable_string_sensor_sharing ? /* @__PURE__ */ new Set() : new Set(
+ this._config.panels.filter((o) => o.id !== e).map((o) => o.power_entity).filter((o) => !!o)
+ ), n = [];
+ for (const o of Object.values(this.hass.states)) {
+ if (!o.entity_id.startsWith("sensor."))
+ continue;
+ const l = o.attributes?.unit_of_measurement;
+ typeof l != "string" || l.trim().toLowerCase() !== "w" || s.has(o.entity_id) && o.entity_id !== r || n.push(o.entity_id);
+ }
+ return typeof r == "string" && r.length > 0 && !n.includes(r) && n.push(r), n.sort(q);
+ }
+ _updatePanelConfigFromCard(e, t, r) {
+ if (!this._config)
+ return;
+ const s = this._config.panels.findIndex((l) => l.id === e);
+ if (s < 0)
+ return;
+ const n = this._config.panels.map((l, a) => {
+ if (a !== s)
+ return l;
+ const c = { ...l, [t]: r };
+ if (t === "power_entity") {
+ const p = l.power_entity, d = typeof r == "string" ? r.trim() : "", h = this._getEntityFriendlyName(d);
+ h && this._shouldAutoRenamePanelName(l.name, l.id, p) && (c.name = h);
+ }
+ return c;
+ }), o = Be({
+ ...this._config,
+ panels: n
+ });
+ this._commitConfigFromCard(o), t === "power_entity" && (typeof r == "string" ? r.trim() : "").length > 0 && (this._selectedPanelId = e, this._ensurePopupGraphLoaded(e, this._popupGraphRangeHours));
+ }
+ _getEntityFriendlyName(e) {
+ const t = e?.trim();
+ if (!t)
+ return;
+ const s = this.hass?.states?.[t]?.attributes?.friendly_name;
+ if (typeof s != "string")
+ return;
+ const n = s.trim();
+ return n.length > 0 ? n : void 0;
+ }
+ _shouldAutoRenamePanelName(e, t, r) {
+ const s = e?.trim() ?? "";
+ if (!s || s === t || /^panel\s+\d+$/i.test(s))
+ return !0;
+ const n = this._getEntityFriendlyName(r);
+ return !!(n && s === n);
+ }
+ _handleDragStart(e, t) {
+ this._dragSourceSlotIndex = t, e.dataTransfer && (e.dataTransfer.effectAllowed = "move", e.dataTransfer.setData("text/plain", String(t)));
+ }
+ _handleDrop(e, t) {
+ if (e.preventDefault(), !this._config)
+ return;
+ const r = this._dragSourceSlotIndex ?? Number(e.dataTransfer?.getData("text/plain") ?? Number.NaN);
+ if (!Number.isFinite(r) || r === t)
+ return;
+ const s = [...this._config.panels], n = s[r], o = s[t];
+ !n || !o || (s[r] = o, s[t] = n, this._commitConfigFromCard(
+ Be({
+ ...this._config,
+ panels: s
+ })
+ ));
+ }
+ _commitConfigFromCard(e) {
+ this._config = e, this._historySignature = "", this._historyQuerySignature = "", this._historyLastLoadMs = 0, this._historyLoadToken = 0, this._popupGraphCache = {}, this._popupGraphLatestTokenByKey = {}, this._kpiCompareCache = {}, this._kpiCompareLatestTokenByKey = {}, this._kpiCompareExpanded = { power: !1, energy: !1 }, this._kpiCompareRangeHours = { power: 6, energy: 6 }, this._ensureHistoryLoaded(), this._refreshDerived(), this.dispatchEvent(
+ new CustomEvent("config-changed", {
+ detail: { config: e },
+ bubbles: !0,
+ composed: !0
+ })
+ ), typeof window < "u" && window.dispatchEvent(
+ new CustomEvent(pn, {
+ detail: { config: e }
+ })
+ ), this._persistConfigToLovelace(e);
+ }
+ async _persistConfigToLovelace(e) {
+ if (!this.hass?.callWS)
+ return;
+ const t = ++this._persistConfigToken, r = this._findLovelaceContext(), s = this._resolveDashboardUrlPath(r);
+ try {
+ const n = r && typeof r.config == "object" ? r.config : await this.hass.callWS({
+ type: "lovelace/config",
+ ...s ? { url_path: s } : {}
+ });
+ if (t !== this._persistConfigToken)
+ return;
+ const { config: o, replaced: l } = this._replaceCardConfigInDashboardConfig(
+ n,
+ e
+ );
+ if (!l) {
+ console.warn("Solar Panel Visualizer: Could not locate card config to persist changes.");
+ return;
+ }
+ if (await this.hass.callWS({
+ type: "lovelace/config/save",
+ ...s ? { url_path: s } : {},
+ config: o
+ }), t !== this._persistConfigToken)
+ return;
+ r && (r.config = o), this._sourceConfigRef = void 0;
+ } catch (n) {
+ console.warn("Solar Panel Visualizer: Failed to persist dashboard config.", n);
+ }
+ }
+ _findLovelaceContext() {
+ const e = /* @__PURE__ */ new Set();
+ let t = this;
+ for (; t && !e.has(t); ) {
+ if (e.add(t), typeof t == "object" && t !== null && "lovelace" in t && t.lovelace && typeof t.lovelace == "object")
+ return t.lovelace;
+ const r = t, s = typeof r.getRootNode == "function" ? r.getRootNode() : null, n = s && "host" in s ? s.host : null;
+ if (n && n !== t) {
+ t = n;
+ continue;
+ }
+ t = r.parentElement ?? null;
+ }
+ return null;
+ }
+ _resolveDashboardUrlPath(e) {
+ const t = e?.urlPath?.trim();
+ if (t)
+ return t;
+ if (typeof window > "u")
+ return;
+ const r = window.location.pathname.replace(/^\/+/, "").split("/")[0]?.trim();
+ return r && r.length > 0 ? r : void 0;
+ }
+ _replaceCardConfigInDashboardConfig(e, t) {
+ const r = this._replaceFirstMatch(
+ e,
+ (n) => !!this._sourceConfigRef && n === this._sourceConfigRef,
+ t
+ );
+ if (r.replaced)
+ return {
+ config: r.value,
+ replaced: !0
+ };
+ const s = this._replaceFirstMatch(
+ e,
+ (n) => this._matchesCurrentCardSignature(n),
+ t
+ );
+ return {
+ config: s.value,
+ replaced: s.replaced
+ };
+ }
+ _replaceFirstMatch(e, t, r) {
+ let s = !1;
+ const n = (o) => {
+ if (s)
+ return o;
+ if (t(o))
+ return s = !0, r;
+ if (Array.isArray(o)) {
+ let c = !1;
+ const p = o.map((d) => {
+ const h = n(d);
+ return h !== d && (c = !0), h;
+ });
+ return c ? p : o;
+ }
+ if (typeof o != "object" || o === null)
+ return o;
+ let l = !1;
+ const a = {};
+ for (const [c, p] of Object.entries(o)) {
+ const d = n(p);
+ a[c] = d, d !== p && (l = !0);
+ }
+ return l ? a : o;
+ };
+ return { value: n(e), replaced: s };
+ }
+ _matchesCurrentCardSignature(e) {
+ if (!this._config || typeof e != "object" || e === null)
+ return !1;
+ const t = e;
+ if (t.type !== this._config.type)
+ return !1;
+ const r = Number(t.rows), s = Number(t.columns);
+ if (r !== this._config.rows || s !== this._config.columns)
+ return !1;
+ const o = (Array.isArray(t.panels) ? t.panels : []).map(
+ (a) => typeof a == "object" && a !== null && typeof a.id == "string" ? a.id : ""
+ ).sort().join("|"), l = this._config.panels.map((a) => a.id).sort().join("|");
+ return o.length > 0 && o === l;
+ }
+ _adjustGraphSamplesForEntity(e, t) {
+ const r = this._getSystemPowerEntityId(), s = (this._config?.invert_system_power ?? !1) && !!r && e === r;
+ return t.map((n) => ({
+ ts: n.ts,
+ value: re(s ? n.value * -1 : n.value)
+ }));
+ }
+ _scaleGraphSamples(e, t = 1) {
+ return t === 1 ? e : e.map((r) => ({
+ ts: r.ts,
+ value: re(r.value * t)
+ }));
+ }
+ _expandSinglePointRangeSamples(e, t) {
+ if (e.length !== 1)
+ return e;
+ const r = e[0].value, s = Date.now();
+ return [
+ { ts: s - t * 60 * 60 * 1e3, value: r },
+ { ts: s, value: r }
+ ];
+ }
+ _fillRangeStartWithFirstSample(e, t, r) {
+ if (!r || e.length === 0)
+ return e;
+ const n = Date.now() - t * 60 * 60 * 1e3, o = [...e].sort((a, c) => a.ts - c.ts), l = o[0];
+ return !l || l.ts <= n ? o : [{ ts: n, value: l.value }, ...o];
+ }
+ _getPowerEntities() {
+ if (!this._config)
+ return [];
+ const e = this._config.panels.map((t) => t.power_entity).filter((t) => !!t);
+ return [...new Set(e)];
+ }
+ _getHistoryHours() {
+ return this._config?.deviation_history_hours ?? 12;
+ }
+ _getHistoryQuerySignature() {
+ if (!this._config || !this.hass)
+ return "";
+ const e = this._getPowerEntities().sort().join("|");
+ return `${this._getHistoryHours()}:${e}`;
+ }
+ _getHistorySignature() {
+ if (!this._config || !this.hass)
+ return "";
+ const e = this._getHistoryQuerySignature(), t = this._getRestartToken();
+ return `${e}:${t ?? ""}`;
+ }
+ _getRestartToken() {
+ const e = this._config?.deviation_restart_entity;
+ if (!e)
+ return;
+ const t = this.hass?.states?.[e], r = t?.state?.toString().trim();
+ if (!r)
+ return;
+ const s = Date.parse(r);
+ if (Number.isFinite(s))
+ return String(s);
+ const n = [
+ t?.attributes?.last_restart,
+ t?.attributes?.last_reset,
+ t?.attributes?.boot_time,
+ t?.attributes?.started,
+ t?.attributes?.start_time
+ ];
+ for (const o of n) {
+ if (typeof o != "string" || o.trim().length === 0)
+ continue;
+ const l = Date.parse(o);
+ if (Number.isFinite(l))
+ return String(l);
+ }
+ if (!/^[-+]?\d+(\.\d+)?$/.test(r))
+ return r;
+ }
+ _historyCacheKey() {
+ return `${cn}${this._getHistorySignature()}`;
+ }
+ _pruneSamples(e, t = this._getHistoryHours()) {
+ const s = Date.now() - t * 60 * 60 * 1e3;
+ return e.filter((n) => n.ts >= s && Number.isFinite(n.value)).sort((n, o) => n.ts - o.ts);
+ }
+ _syncLiveSamplesFromHass() {
+ if (!this.hass || !this._config)
+ return;
+ let e = !1;
+ const t = Date.now();
+ for (const r of this._config.panels) {
+ const s = r.power_entity;
+ if (!s)
+ continue;
+ const n = this.hass.states[s], o = Number(n?.state);
+ if (!Number.isFinite(o))
+ continue;
+ const a = [...this._historyByEntityId[s] ?? []], c = a[a.length - 1];
+ (!c || Math.abs(t - c.ts) > 6e4 || Math.abs(c.value - o) > 0.01) && (a.push({ ts: t, value: o }), this._historyByEntityId[s] = this._pruneSamples(a), e = !0);
+ }
+ e && this._saveHistoryCache();
+ }
+ _saveHistoryCache() {
+ if (typeof window > "u" || !this._config)
+ return;
+ const e = {
+ v: vr,
+ savedAt: Date.now(),
+ samples: this._historyByEntityId,
+ reason: this._historyStateReason
+ };
+ try {
+ window.localStorage.setItem(this._historyCacheKey(), JSON.stringify(e));
+ } catch {
+ }
+ }
+ _loadHistoryCache() {
+ if (!(typeof window > "u"))
+ try {
+ const e = window.localStorage.getItem(this._historyCacheKey());
+ if (!e)
+ return;
+ const t = JSON.parse(e);
+ if (t.v !== vr || !t.samples)
+ return;
+ const r = {};
+ for (const [s, n] of Object.entries(t.samples))
+ r[s] = this._pruneSamples(n);
+ this._historyByEntityId = r;
+ } catch {
+ }
+ }
+ _ensureHistoryLoaded() {
+ if (!this.hass || !this._config)
+ return;
+ if (!(this._config.enable_array_checks ?? !1)) {
+ this._historyState = "idle", this._historyStateReason = void 0, this._historySignature = "", this._historyQuerySignature = "", this._historyLastLoadMs = 0, this._historyByEntityId = {};
+ return;
+ }
+ const e = this._getHistorySignature();
+ if (!e || e === this._historySignature && this._historyState !== "idle")
+ return;
+ const t = this._getHistoryQuerySignature(), r = Date.now();
+ if (this._historyState === "loading" && t === this._historyQuerySignature) {
+ this._historySignature = e;
+ return;
+ }
+ if (t === this._historyQuerySignature && this._historyState !== "idle" && r - this._historyLastLoadMs < dn) {
+ this._historySignature = e;
+ return;
+ }
+ this._historySignature = e, this._historyQuerySignature = t, this._historyLastLoadMs = r, this._historyByEntityId = {}, this._loadHistoryCache();
+ const s = ++this._historyLoadToken;
+ this._loadHistoryFromRecorder(s);
+ }
+ async _loadHistoryFromRecorder(e) {
+ if (!this.hass || !this._config)
+ return;
+ const t = this._getPowerEntities();
+ if (t.length === 0) {
+ this._historyState = "ready", this._historyStateReason = "No panel sensors configured.", this._refreshDerived(), this.requestUpdate();
+ return;
+ }
+ if (!this.hass.callApi && !this.hass.callWS) {
+ this._historyState = "unavailable", this._historyStateReason = "Solar panel history API unavailable in this Home Assistant build.", this._refreshDerived(), this.requestUpdate();
+ return;
+ }
+ const r = Date.now(), s = new Date(r - this._getHistoryHours() * 60 * 60 * 1e3).toISOString(), n = new Date(r).toISOString();
+ this._historyState = "loading", this._historyStateReason = void 0, this.requestUpdate();
+ try {
+ const o = await this._fetchRecorderHistoryRaw(s, n, t);
+ if (e !== this._historyLoadToken)
+ return;
+ const l = this._parseRecorderResponse(o, this._getHistoryHours(), t);
+ Object.keys(l).length === 0 ? (this._historyState = "fallback", this._historyStateReason = "Solar panel history returned no samples, using live warm-up only.") : (this._historyByEntityId = {
+ ...this._historyByEntityId,
+ ...l
+ }, this._historyState = "ready", this._historyStateReason = void 0);
+ } catch {
+ if (e !== this._historyLoadToken)
+ return;
+ this._historyState = "fallback", this._historyStateReason = "Failed to read solar panel history, using live warm-up only.";
+ }
+ this._syncLiveSamplesFromHass(), this._saveHistoryCache(), this._refreshDerived(), this.requestUpdate();
+ }
+ async _fetchRecorderHistoryRaw(e, t, r) {
+ if (!this.hass)
+ throw new Error("Home Assistant context unavailable");
+ const s = [], n = r.join(","), o = `history/period/${e}`, l = `history/period/${encodeURIComponent(e)}`, a = [], c = {
+ end_time: t,
+ filter_entity_id: n,
+ no_attributes: !0,
+ significant_changes_only: !1,
+ minimal_response: !0
+ }, p = {
+ end_time: t,
+ filter_entity_id: n
+ };
+ if (a.push({ path: o, params: p }), a.push({ path: o, params: c }), l !== o && (a.push({ path: l, params: p }), a.push({ path: l, params: c })), this.hass.callApi)
+ for (const d of a)
+ try {
+ return await this.hass.callApi("GET", d.path, d.params);
+ } catch (h) {
+ s.push(h);
+ }
+ if (this.hass.callWS) {
+ const d = [
+ {
+ type: "history/history_during_period",
+ start_time: e,
+ end_time: t,
+ entity_ids: r,
+ no_attributes: !0,
+ significant_changes_only: !1,
+ minimal_response: !0
+ },
+ {
+ type: "history/history_during_period",
+ start_time: e,
+ end_time: t,
+ entity_ids: r
+ }
+ ];
+ for (const h of d)
+ try {
+ return await this.hass.callWS(h);
+ } catch (u) {
+ s.push(u);
+ }
+ }
+ throw s.length > 0 ? s[s.length - 1] : new Error("Recorder API unavailable");
+ }
+ _parseRecorderResponse(e, t = this._getHistoryHours(), r = []) {
+ const s = {}, n = (a) => {
+ if (typeof a == "number" && Number.isFinite(a))
+ return a > 1e11 ? a : a * 1e3;
+ if (typeof a == "string" && a.trim().length > 0) {
+ const c = a.trim(), p = Number(c);
+ if (Number.isFinite(p) && /^\d+(\.\d+)?$/.test(c))
+ return p > 1e11 ? p : p * 1e3;
+ const d = Date.parse(c);
+ if (Number.isFinite(d))
+ return d;
+ }
+ return Number.NaN;
+ }, o = (a, c) => {
+ if (!Array.isArray(a) || a.length === 0)
+ return;
+ let p = c;
+ for (const d of a) {
+ if (Array.isArray(d)) {
+ if (!p || d.length < 2)
+ continue;
+ const x = n(d[0]), S = Number(d[1]), I = n(d[1]), M = Number(d[0]);
+ let $ = x, E = S;
+ if ((!Number.isFinite($) || !Number.isFinite(E)) && ($ = I, E = M), !Number.isFinite($) || !Number.isFinite(E))
+ continue;
+ s[p] || (s[p] = []), s[p].push({ ts: $, value: E });
+ continue;
+ }
+ if (typeof d != "object" || d === null)
+ continue;
+ const h = d, u = typeof h.entity_id == "string" && h.entity_id.length > 0 ? h.entity_id : typeof h.e == "string" && h.e.length > 0 ? h.e : p;
+ if (!u)
+ continue;
+ p = u;
+ const g = Number(h.state ?? h.s);
+ if (!Number.isFinite(g))
+ continue;
+ const f = typeof h.last_changed == "string" ? h.last_changed : typeof h.last_updated == "string" ? h.last_updated : h.lc ?? h.lu ?? h.last_changed_ts ?? h.last_updated_ts ?? h.ts, v = n(f);
+ Number.isFinite(v) && (s[u] || (s[u] = []), s[u].push({ ts: v, value: g }));
+ }
+ }, l = typeof e == "object" && e !== null && "result" in e ? e.result : e;
+ if (Array.isArray(l))
+ for (const [a, c] of l.entries())
+ o(c, r[a]);
+ else if (typeof l == "object" && l !== null)
+ for (const [a, c] of Object.entries(
+ l
+ ))
+ o(c, a);
+ else
+ return s;
+ for (const [a, c] of Object.entries(s))
+ s[a] = this._pruneSamples(c, t);
+ return s;
+ }
+};
+_e.properties = {
+ hass: { attribute: !1 },
+ _config: { state: !0 },
+ _derived: { state: !0 },
+ _cardWidth: { state: !0 },
+ _selectedPanelId: { state: !0 },
+ _showLivePowerPopup: { state: !0 },
+ _showEnergyPopup: { state: !0 },
+ _showCustomKpiPopup: { state: !0 },
+ _showSystemHealthPopup: { state: !0 },
+ _popupGraphRangeHours: { state: !0 },
+ _popupGraphCache: { state: !0 },
+ _telemetryGraphPanelId: { state: !0 },
+ _telemetryGraphEntityId: { state: !0 },
+ _telemetryGraphRangeHours: { state: !0 },
+ _kpiCompareExpanded: { state: !0 },
+ _kpiCompareRangeHours: { state: !0 },
+ _kpiCompareCache: { state: !0 }
+}, _e.styles = [Ot`
+ :host {
+ display: block;
+ width: 100%;
+ }
+
+ ha-card {
+ --spv-card-bg:
+ radial-gradient(circle at top left, rgba(255, 214, 92, 0.18), transparent 38%),
+ radial-gradient(circle at top right, rgba(111, 201, 255, 0.16), transparent 35%),
+ linear-gradient(150deg, rgba(15, 33, 53, 0.98), rgba(11, 19, 34, 0.96));
+ --spv-card-border: rgba(255, 255, 255, 0.1);
+ --spv-card-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.05),
+ 0 12px 30px rgba(0, 0, 0, 0.28);
+ --spv-chrome-bg:
+ linear-gradient(120deg, rgba(255, 255, 255, 0.02), transparent 30%),
+ repeating-linear-gradient(
+ 125deg,
+ rgba(255, 255, 255, 0.02) 0,
+ rgba(255, 255, 255, 0.02) 2px,
+ transparent 2px,
+ transparent 16px
+ );
+ --spv-text-primary: var(--primary-text-color, rgba(247, 250, 255, 0.96));
+ --spv-text-secondary: rgba(255, 255, 255, 0.72);
+ --spv-text-muted: rgba(255, 255, 255, 0.56);
+ --spv-text-soft: rgba(255, 255, 255, 0.78);
+ --spv-surface-bg: rgba(255, 255, 255, 0.055);
+ --spv-surface-bg-hover: rgba(255, 255, 255, 0.08);
+ --spv-surface-border: rgba(255, 255, 255, 0.08);
+ --spv-surface-border-hover: rgba(255, 255, 255, 0.18);
+ --spv-panel-bg:
+ linear-gradient(160deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.02)),
+ linear-gradient(180deg, rgba(10, 17, 29, 0.86), rgba(7, 12, 21, 0.92));
+ --spv-panel-highlight:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 30%),
+ linear-gradient(135deg, rgba(255, 255, 255, 0.05), transparent 48%);
+ --spv-panel-hidden-bg:
+ linear-gradient(160deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.01)),
+ linear-gradient(180deg, rgba(10, 17, 29, 0.6), rgba(7, 12, 21, 0.68));
+ --spv-panel-hidden-border: rgba(255, 255, 255, 0.18);
+ --spv-panel-accent-mix-base: rgba(255, 255, 255, 0.14);
+ --spv-panel-hover-mix-base: rgba(255, 255, 255, 0.24);
+ --spv-popup-backdrop: rgba(4, 8, 16, 0.72);
+ --spv-popup-bg:
+ radial-gradient(circle at top right, rgba(255, 212, 84, 0.18), transparent 34%),
+ linear-gradient(180deg, rgba(16, 26, 42, 0.98), rgba(10, 18, 30, 0.98));
+ --spv-popup-border: rgba(255, 255, 255, 0.09);
+ --spv-popup-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.06),
+ 0 20px 34px rgba(0, 0, 0, 0.36);
+ --spv-graph-bg: rgba(8, 14, 25, 0.5);
+ --spv-graph-grid: rgba(255, 255, 255, 0.18);
+ --spv-forecast-line: rgba(240, 245, 255, 0.84);
+ --spv-overlay-bg: rgba(8, 16, 28, 0.62);
+ position: relative;
+ container-type: inline-size;
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding: 20px;
+ width: min(100%, var(--spv-max-width, 980px));
+ max-height: var(--spv-max-height, none);
+ margin-inline: auto;
+ border: 1px solid var(--spv-card-border);
+ background: var(--spv-card-bg);
+ color: var(--spv-text-primary);
+ box-shadow: var(--spv-card-shadow);
+ backdrop-filter: blur(18px);
+ }
+
+ ha-card[data-spv-theme="light"] {
+ --spv-card-bg:
+ radial-gradient(circle at top left, rgba(255, 210, 92, 0.22), transparent 38%),
+ radial-gradient(circle at top right, rgba(52, 161, 218, 0.16), transparent 36%),
+ linear-gradient(150deg, rgba(250, 253, 255, 0.98), rgba(229, 238, 246, 0.96));
+ --spv-card-border: rgba(54, 76, 98, 0.18);
+ --spv-card-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.84),
+ 0 12px 28px rgba(31, 48, 68, 0.16);
+ --spv-chrome-bg:
+ linear-gradient(120deg, rgba(255, 255, 255, 0.34), transparent 32%),
+ radial-gradient(circle at bottom right, rgba(52, 161, 218, 0.08), transparent 42%);
+ --spv-text-primary: rgba(22, 31, 43, 0.96);
+ --spv-text-secondary: rgba(22, 31, 43, 0.74);
+ --spv-text-muted: rgba(22, 31, 43, 0.56);
+ --spv-text-soft: rgba(22, 31, 43, 0.78);
+ --spv-surface-bg: rgba(255, 255, 255, 0.56);
+ --spv-surface-bg-hover: rgba(255, 255, 255, 0.72);
+ --spv-surface-border: rgba(50, 72, 94, 0.14);
+ --spv-surface-border-hover: rgba(50, 72, 94, 0.28);
+ --spv-panel-bg:
+ linear-gradient(160deg, rgba(255, 255, 255, 0.82), rgba(238, 245, 251, 0.92)),
+ linear-gradient(180deg, rgba(248, 252, 255, 0.96), rgba(224, 235, 245, 0.92));
+ --spv-panel-highlight:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.48), transparent 34%),
+ linear-gradient(135deg, rgba(37, 58, 82, 0.04), transparent 48%);
+ --spv-panel-hidden-bg:
+ linear-gradient(180deg, rgba(240, 247, 252, 0.62), rgba(224, 235, 245, 0.72));
+ --spv-panel-hidden-border: rgba(55, 76, 98, 0.28);
+ --spv-panel-accent-mix-base: rgba(40, 62, 84, 0.18);
+ --spv-panel-hover-mix-base: rgba(40, 62, 84, 0.34);
+ --spv-popup-backdrop: rgba(32, 45, 60, 0.28);
+ --spv-popup-bg:
+ radial-gradient(circle at top right, rgba(255, 212, 84, 0.22), transparent 34%),
+ linear-gradient(180deg, rgba(250, 253, 255, 0.98), rgba(234, 242, 249, 0.98));
+ --spv-popup-border: rgba(50, 72, 94, 0.16);
+ --spv-popup-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.82),
+ 0 18px 34px rgba(31, 48, 68, 0.22);
+ --spv-graph-bg: rgba(237, 244, 250, 0.72);
+ --spv-graph-grid: rgba(36, 55, 74, 0.18);
+ --spv-forecast-line: rgba(48, 68, 92, 0.78);
+ --spv-overlay-bg: rgba(246, 251, 255, 0.78);
+ }
+
+ .chrome {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: var(--spv-chrome-bg);
+ opacity: 0.45;
+ }
+
+ .spv-overlay-anchor {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ pointer-events: none;
+ }
+
+ .header {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 18px;
+ }
+
+ .header-copy {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+ flex: 1 1 auto;
+ }
+
+ .topline {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ padding-right: 0;
+ }
+
+ .eyebrow {
+ flex: 1;
+ font-size: 0.72rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--spv-text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .system-health {
+ appearance: none;
+ font: inherit;
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 3;
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--health-color, rgba(142, 208, 114, 0.5));
+ background: color-mix(in srgb, var(--health-color, #8ed072) 18%, transparent);
+ color: var(--health-color, #8ed072);
+ font-size: 0.68rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ line-height: 1.35;
+ cursor: pointer;
+ transition: filter 160ms ease, transform 160ms ease;
+ }
+
+ .system-health:hover {
+ filter: brightness(1.08);
+ transform: translateY(-1px);
+ }
+
+ .title {
+ margin: 0;
+ font-size: 1.45rem;
+ line-height: 1.1;
+ letter-spacing: 0.01em;
+ font-weight: 700;
+ }
+
+ .subtitle {
+ margin: 0;
+ color: var(--spv-text-secondary);
+ font-size: 0.92rem;
+ }
+
+ .subtitle-hint {
+ color: var(--spv-text-muted);
+ font-size: 0.84rem;
+ }
+
+ .summary {
+ display: grid;
+ gap: 10px;
+ margin-top: 30px;
+ min-width: min(100%, 360px);
+ }
+
+ .summary.with-custom {
+ grid-template-columns:
+ minmax(84px, 1fr)
+ minmax(84px, 1fr)
+ minmax(70px, 0.72fr)
+ minmax(84px, 1fr);
+ }
+
+ .summary.without-custom {
+ grid-template-columns:
+ minmax(84px, 1fr)
+ minmax(84px, 1fr)
+ minmax(70px, 0.72fr);
+ }
+
+ .summary-chip {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+ gap: 3px;
+ text-align: left;
+ border-radius: 16px;
+ padding: 10px 10px 10px 12px;
+ background: var(--spv-surface-bg);
+ border: 1px solid var(--spv-surface-border);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
+ }
+
+ .summary-chip.summary-button {
+ appearance: none;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+ font: inherit;
+ color: inherit;
+ text-align: left;
+ margin: 0;
+ width: 100%;
+ padding: 10px 10px 10px 12px;
+ cursor: pointer;
+ transition: border-color 160ms ease, background 160ms ease;
+ }
+
+ .summary-chip.summary-button:hover {
+ border-color: var(--spv-surface-border-hover);
+ background: var(--spv-surface-bg-hover);
+ }
+
+ .summary-chip.alerts-chip {
+ padding: 10px 12px 10px 12px;
+ }
+
+ .summary-chip.alerts-chip .summary-label {
+ margin-bottom: 6px;
+ }
+
+ .summary-chip.alerts-chip .summary-value {
+ font-size: 0.96rem;
+ margin-top: 4px;
+ }
+
+ .summary-label {
+ display: block;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ line-height: 1.2;
+ color: var(--spv-text-muted);
+ margin-bottom: 4px;
+ }
+
+ .summary-value {
+ display: block;
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: 1.24;
+ max-width: 100%;
+ }
+
+ .summary-value.custom-kpi-value {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .grid {
+ position: relative;
+ display: grid;
+ gap: 10px;
+ margin-top: 14px;
+ align-items: stretch;
+ }
+
+ .panel {
+ --spv-slot-safe-width: 64px;
+ --spv-slot-dock-height: clamp(24px, calc(26px * var(--spv-panel-scale, 1)), 32px);
+ --spv-slot-dock-clearance: calc(var(--spv-slot-dock-height) + 8px);
+ position: relative;
+ container-type: inline-size;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ align-content: stretch;
+ overflow: hidden;
+ width: min(100%, var(--spv-panel-max-width, 100%));
+ justify-self: center;
+ height: var(--spv-panel-height, clamp(96px, 14vw, 136px));
+ border: 1px solid var(--panel-border-accent, var(--spv-panel-hidden-border));
+ border-radius: 18px;
+ padding: 10px;
+ text-align: left;
+ color: inherit;
+ background: var(--spv-panel-bg);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.08),
+ 0 10px 24px rgba(0, 0, 0, 0.18),
+ 0 0 calc(1px + var(--panel-intensity, 0.4) * 3px) var(--panel-glow-accent, transparent);
+ cursor: pointer;
+ transition:
+ transform 180ms ease,
+ border-color 180ms ease,
+ box-shadow 180ms ease;
+ }
+
+ .panel:hover {
+ transform: translateY(-2px);
+ border-color: color-mix(
+ in srgb,
+ var(--panel-border-accent, rgba(255, 255, 255, 0.16)) 86%,
+ var(--spv-panel-hover-mix-base)
+ );
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.09),
+ 0 14px 28px rgba(0, 0, 0, 0.24),
+ 0 0 calc(2px + var(--panel-intensity, 0.4) * 4px) var(--panel-glow-accent, transparent);
+ }
+
+ .panel::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: var(--spv-panel-highlight);
+ pointer-events: none;
+ }
+
+ .panel::after {
+ content: "";
+ position: absolute;
+ inset: auto -22% -56% auto;
+ width: 188px;
+ height: 188px;
+ border-radius: 50%;
+ background: radial-gradient(circle, var(--panel-fill-accent, transparent) 0%, transparent 72%);
+ opacity: 1;
+ filter: blur(14px);
+ pointer-events: none;
+ }
+
+ .panel.error,
+ .panel.inverter,
+ .panel.deviation,
+ .panel.offline,
+ .panel.unconfigured,
+ .panel.disabled {
+ cursor: pointer;
+ }
+
+ .panel.hidden-slot {
+ background: var(--spv-panel-hidden-bg);
+ border-style: dashed;
+ border-color: var(--spv-panel-hidden-border);
+ cursor: default;
+ }
+
+ .panel.hidden-slot:hover {
+ transform: none;
+ }
+
+ .panel.hidden-slot .panel-name,
+ .panel.hidden-slot .power,
+ .panel.hidden-slot .panel-detail-scroll,
+ .panel.hidden-slot .performance,
+ .panel.hidden-slot .energy,
+ .panel.hidden-slot .inverter-status {
+ visibility: hidden;
+ }
+
+ .slot {
+ position: absolute;
+ right: 7px;
+ bottom: 7px;
+ z-index: 3;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 5px;
+ box-sizing: border-box;
+ padding: 2px 3px 2px 10px;
+ font-size: clamp(0.66rem, calc(0.68rem * var(--spv-panel-scale, 1)), 0.84rem);
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ text-align: right;
+ color: var(--spv-text-muted);
+ text-shadow: 0 1px 8px color-mix(in srgb, var(--spv-surface-bg) 90%, transparent);
+ white-space: nowrap;
+ isolation: isolate;
+ }
+
+ .slot.has-string-label {
+ max-width: calc(100% - 14px);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-transform: none;
+ letter-spacing: 0.04em;
+ }
+
+ .status {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ display: inline-flex;
+ align-items: center;
+ z-index: 2;
+ padding: 4px 8px;
+ width: max-content;
+ max-width: calc(100% - 16px);
+ border-radius: 999px;
+ font-size: clamp(0.64rem, calc(0.66rem * var(--spv-panel-scale, 1)), 0.82rem);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ background: var(--spv-surface-bg);
+ border: 1px solid var(--spv-surface-border);
+ }
+
+ .panel-primary {
+ position: relative;
+ z-index: 1;
+ align-self: stretch;
+ min-width: 0;
+ }
+
+ .panel-name {
+ position: relative;
+ z-index: 1;
+ margin: 26px 0 5px;
+ font-size: clamp(0.8rem, calc(0.94rem * var(--spv-panel-scale, 1)), 1.26rem);
+ line-height: 1.15;
+ font-weight: 600;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .power {
+ position: relative;
+ z-index: 1;
+ font-size: clamp(0.96rem, calc(1.18rem * var(--spv-panel-scale, 1)), 1.76rem);
+ line-height: 1.05;
+ font-weight: 750;
+ margin: 0;
+ text-wrap: pretty;
+ }
+
+ .panel.has-performance .power {
+ font-size: clamp(0.92rem, calc(1.08rem * var(--spv-panel-scale, 1)), 1.64rem);
+ }
+
+ .panel-detail-scroll {
+ position: relative;
+ z-index: 1;
+ align-self: stretch;
+ box-sizing: border-box;
+ min-height: 0;
+ width: 100%;
+ max-width: 100%;
+ margin: 1px 0 0;
+ padding-right: 8px;
+ padding-bottom: 0;
+ overflow-x: hidden;
+ overflow-y: hidden;
+ overscroll-behavior: contain;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ }
+
+ .panel-detail-scroll::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
+ }
+
+ .panel-detail-scroll::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ .panel-detail-scroll::-webkit-scrollbar-thumb {
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--spv-text-muted) 36%, transparent);
+ }
+
+ .panel.has-detail-overflow .panel-detail-scroll {
+ padding-bottom: var(--spv-slot-dock-clearance);
+ overflow-y: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ -webkit-mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 24px), transparent 100%);
+ mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 24px), transparent 100%);
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ }
+
+ .panel.has-detail-overflow .panel-detail-scroll::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
+ }
+
+ .panel-detail-scroll:empty {
+ display: none;
+ }
+
+ .energy {
+ position: relative;
+ z-index: 1;
+ margin: 3px 0 0;
+ color: var(--spv-text-secondary);
+ font-size: clamp(0.66rem, calc(0.78rem * var(--spv-panel-scale, 1)), 1rem);
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .performance {
+ position: relative;
+ z-index: 1;
+ margin: 3px 0 0;
+ max-width: 100%;
+ color: var(--spv-text-soft);
+ font-size: clamp(0.62rem, calc(0.72rem * var(--spv-panel-scale, 1)), 0.96rem);
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .inverter-status {
+ position: relative;
+ z-index: 1;
+ margin: 4px 0 0;
+ color: var(--spv-text-soft);
+ font-size: clamp(0.62rem, calc(0.72rem * var(--spv-panel-scale, 1)), 0.92rem);
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .reason {
+ font-size: 0.78rem;
+ color: var(--spv-text-secondary);
+ }
+
+ .spv-popup-backdrop {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: clamp(8px, 2vw, 16px);
+ overflow: hidden;
+ background: var(--spv-popup-backdrop);
+ backdrop-filter: blur(10px);
+ z-index: 20;
+ }
+
+ .spv-popup {
+ box-sizing: border-box;
+ inline-size: min(560px, calc(100% - 8px));
+ max-block-size: min(760px, calc(100% - 8px));
+ overflow: auto;
+ overscroll-behavior: contain;
+ border-radius: 24px;
+ border: 1px solid var(--spv-popup-border);
+ background: var(--spv-popup-bg);
+ box-shadow: var(--spv-popup-shadow);
+ padding: 16px;
+ }
+
+ .spv-popup-close-anchor {
+ position: sticky;
+ top: 8px;
+ z-index: 30;
+ width: 36px;
+ height: 36px;
+ margin: 0 0 -36px auto;
+ display: flex;
+ justify-content: flex-end;
+ pointer-events: auto;
+ }
+
+ .spv-popup-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 14px;
+ margin-bottom: 14px;
+ padding-right: 48px;
+ }
+
+ .spv-popup-title {
+ margin: 0;
+ font-size: 1.2rem;
+ }
+
+ .spv-popup-close {
+ flex: 0 0 auto;
+ border: 0;
+ border-radius: 999px;
+ background: var(--spv-surface-bg);
+ color: inherit;
+ display: grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ margin-left: auto;
+ cursor: pointer;
+ touch-action: manipulation;
+ }
+
+ .detail-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin-top: 2px;
+ margin-bottom: 12px;
+ }
+
+ .detail-card {
+ padding: 12px;
+ border-radius: 16px;
+ background: var(--spv-surface-bg);
+ border: 1px solid var(--spv-surface-border);
+ }
+
+ .detail-card-source {
+ grid-column: 1 / -1;
+ }
+
+ .detail-label {
+ display: block;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--spv-text-muted);
+ margin-bottom: 4px;
+ }
+
+ .detail-value {
+ display: block;
+ font-size: 1rem;
+ font-weight: 700;
+ }
+
+ .detail-information {
+ margin-top: 12px;
+ }
+
+ .telemetry-section {
+ margin-top: 12px;
+ display: grid;
+ gap: 10px;
+ }
+
+ .telemetry-group {
+ display: grid;
+ gap: 8px;
+ }
+
+ .telemetry-title {
+ margin: 0;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--spv-text-muted);
+ }
+
+ .telemetry-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ }
+
+ .telemetry-card {
+ overflow: hidden;
+ }
+
+ .telemetry-card.is-open {
+ grid-column: 1 / -1;
+ }
+
+ .telemetry-card.is-expandable {
+ padding: 0;
+ }
+
+ .telemetry-card-toggle {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ width: 100%;
+ min-width: 0;
+ padding: 12px;
+ text-align: left;
+ font: inherit;
+ cursor: pointer;
+ }
+
+ .telemetry-card-main {
+ min-width: 0;
+ }
+
+ .telemetry-card-affordance {
+ display: inline-grid;
+ place-items: center;
+ flex: 0 0 auto;
+ width: 28px;
+ height: 28px;
+ border-radius: 999px;
+ border: 1px solid var(--spv-surface-border);
+ color: var(--spv-text-muted);
+ background: color-mix(in srgb, var(--spv-surface-bg) 62%, transparent);
+ line-height: 1;
+ }
+
+ .telemetry-card.is-open .telemetry-card-affordance {
+ color: var(--spv-text-primary);
+ border-color: var(--spv-surface-border-hover);
+ background: var(--spv-surface-bg-hover);
+ }
+
+ .telemetry-card-graph {
+ border-top: 1px solid var(--spv-surface-border);
+ padding: 10px 12px 12px;
+ display: grid;
+ gap: 8px;
+ }
+
+ .telemetry-empty {
+ margin: 0;
+ font-size: 0.82rem;
+ line-height: 1.35;
+ color: var(--spv-text-secondary);
+ }
+
+ .info-lines {
+ display: grid;
+ gap: 6px;
+ }
+
+ .info-line {
+ margin: 0;
+ font-size: 0.82rem;
+ line-height: 1.35;
+ color: var(--spv-text-soft);
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+
+ .system-health-item {
+ margin: 0;
+ font-size: 0.86rem;
+ line-height: 1.4;
+ color: var(--spv-text-soft);
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+
+ .graph-section {
+ margin: 0 0 14px;
+ border-radius: 16px;
+ border: 1px solid var(--spv-surface-border);
+ background: color-mix(in srgb, var(--spv-surface-bg) 54%, transparent);
+ padding: 10px;
+ display: grid;
+ gap: 8px;
+ }
+
+ .graph-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ }
+
+ .graph-title {
+ font-size: 0.76rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--spv-text-muted);
+ }
+
+ .range-chips {
+ display: inline-flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ }
+
+ .range-chip {
+ border: 1px solid var(--spv-surface-border);
+ background: var(--spv-surface-bg);
+ color: var(--spv-text-soft);
+ border-radius: 999px;
+ padding: 4px 8px;
+ font-size: 0.72rem;
+ line-height: 1;
+ cursor: pointer;
+ }
+
+ .range-chip.active {
+ border-color: var(--spv-surface-border-hover);
+ background: var(--spv-surface-bg-hover);
+ color: var(--spv-text-primary);
+ }
+
+ .graph-box {
+ position: relative;
+ border-radius: 12px;
+ overflow: hidden;
+ background: var(--spv-graph-bg);
+ border: 1px solid var(--spv-surface-border);
+ }
+
+ .graph-hour-line {
+ stroke: var(--spv-graph-grid);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+ opacity: 0.35;
+ }
+
+ .graph-svg {
+ width: 100%;
+ height: clamp(96px, 18vh, 132px);
+ display: block;
+ }
+
+ .graph-meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 0.74rem;
+ color: var(--spv-text-secondary);
+ }
+
+ .graph-stat-line {
+ stroke-width: 1.3;
+ stroke-dasharray: 5 4;
+ opacity: 0.64;
+ }
+
+ .graph-stat-max {
+ stroke: rgba(255, 150, 103, 0.95);
+ }
+
+ .graph-stat-median {
+ stroke: rgba(120, 205, 255, 0.95);
+ }
+
+ .graph-stat-min {
+ stroke: rgba(207, 220, 238, 0.75);
+ }
+
+ .graph-overlay {
+ position: absolute;
+ left: 10px;
+ display: inline-flex;
+ align-items: center;
+ min-height: 16px;
+ padding: 0 6px;
+ border-radius: 999px;
+ font-size: 0.66rem;
+ letter-spacing: 0.02em;
+ color: var(--spv-text-primary);
+ background: var(--spv-overlay-bg);
+ border: 1px solid var(--spv-surface-border);
+ backdrop-filter: blur(2px);
+ pointer-events: none;
+ z-index: 3;
+ }
+
+ .graph-overlay-max {
+ top: 8px;
+ }
+
+ .graph-overlay-median {
+ top: 50%;
+ transform: translateY(-50%);
+ background: color-mix(in srgb, var(--spv-overlay-bg) 88%, transparent);
+ }
+
+ .graph-overlay-min {
+ bottom: 8px;
+ }
+
+ .graph-axis {
+ position: relative;
+ height: 14px;
+ margin-top: 2px;
+ }
+
+ .graph-axis-label {
+ position: absolute;
+ transform: translateX(-50%);
+ font-size: 0.66rem;
+ letter-spacing: 0.03em;
+ color: var(--spv-text-muted);
+ white-space: nowrap;
+ line-height: 1;
+ user-select: none;
+ }
+
+ .graph-state {
+ box-sizing: border-box;
+ display: grid;
+ place-items: center;
+ min-height: clamp(96px, 18vh, 132px);
+ margin: 0;
+ border-radius: 12px;
+ border: 1px solid var(--spv-surface-border);
+ background: var(--spv-graph-bg);
+ font-size: 0.82rem;
+ color: var(--spv-text-secondary);
+ padding: 12px;
+ text-align: center;
+ }
+
+ .graph-forecast-state {
+ margin: 0;
+ font-size: 0.76rem;
+ color: var(--spv-text-muted);
+ padding: 0 2px 2px;
+ }
+
+ .forecast-enable-row {
+ display: flex;
+ justify-content: flex-start;
+ margin-top: 2px;
+ }
+
+ .inline-button {
+ appearance: none;
+ border: 1px solid var(--spv-surface-border);
+ border-radius: 999px;
+ background: var(--spv-surface-bg);
+ color: inherit;
+ padding: 6px 10px;
+ font: inherit;
+ font-size: 0.74rem;
+ line-height: 1;
+ cursor: pointer;
+ transition: background 160ms ease, border-color 160ms ease;
+ }
+
+ .inline-button:hover {
+ background: var(--spv-surface-bg-hover);
+ border-color: var(--spv-surface-border-hover);
+ }
+
+ .compare-toggle-row {
+ display: flex;
+ justify-content: flex-start;
+ margin-top: 12px;
+ margin-bottom: 10px;
+ }
+
+ .compare-legend {
+ display: grid;
+ gap: 6px;
+ margin-top: 2px;
+ }
+
+ .compare-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.78rem;
+ color: var(--spv-text-soft);
+ min-width: 0;
+ }
+
+ .compare-legend-chip {
+ flex: 0 0 auto;
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ border: 1px solid var(--spv-surface-border-hover);
+ }
+
+ .compare-legend-label {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .compare-diagnostics {
+ margin-top: 8px;
+ padding: 8px;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 147, 118, 0.24);
+ background: rgba(255, 147, 118, 0.08);
+ display: grid;
+ gap: 4px;
+ font-size: 0.72rem;
+ color: rgba(255, 232, 224, 0.9);
+ }
+
+ .compare-diagnostics-title {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: rgba(255, 188, 168, 0.92);
+ }
+
+ .compare-diagnostics-row {
+ line-height: 1.35;
+ color: rgba(255, 231, 221, 0.9);
+ word-break: break-word;
+ }
+
+ .inline-config {
+ display: grid;
+ gap: 10px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--spv-surface-border);
+ }
+
+ @container (max-width: 260px) {
+ .detail-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .telemetry-grid {
+ grid-template-columns: 1fr;
+ }
+ }
+
+ @container (max-width: 860px) {
+ .grid {
+ gap: 8px;
+ }
+
+ .panel {
+ height: max(104px, min(var(--spv-panel-height, 124px), 172px));
+ }
+ }
+
+ @container (max-width: 560px) {
+ .header {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 10px;
+ }
+
+ .summary {
+ margin-top: 0;
+ min-width: 0;
+ }
+
+ .summary.with-custom {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+
+ .summary.without-custom {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .summary-chip {
+ padding: 8px 8px 8px 10px;
+ }
+
+ .summary-chip.summary-button {
+ padding: 8px 8px 8px 10px;
+ }
+
+ .summary-chip.alerts-chip {
+ padding: 8px 8px 8px 10px;
+ }
+
+ .summary-value.custom-kpi-value {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ white-space: normal;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+ }
+
+ .header-copy > .subtitle,
+ .subtitle-hint {
+ display: none;
+ }
+
+ .eyebrow {
+ font-size: 0.66rem;
+ letter-spacing: 0.09em;
+ }
+
+ .topline {
+ padding-right: 118px;
+ }
+
+ .grid {
+ gap: 10px;
+ margin-top: 12px;
+ }
+
+ .panel {
+ height: max(100px, min(var(--spv-panel-height, 120px), 156px));
+ border-radius: 16px;
+ padding: 9px;
+ }
+ }
+
+ `, Ui];
+let At = _e;
+class hs extends At {
+}
+customElements.get("solar-panel-visualizer") || customElements.define("solar-panel-visualizer", hs);
+if (!customElements.get("solar-panel-visualizer-card")) {
+ class i extends hs {
+ }
+ customElements.define("solar-panel-visualizer-card", i);
+}
+const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i, fn = /^(?=.*[a-z])\d{3,}[a-z0-9]*$/i, yn = /^[a-z]+\d{1,2}$/i, Bt = /* @__PURE__ */ new Set([
+ "sensor",
+ "power",
+ "current",
+ "voltage",
+ "temperature",
+ "temp",
+ "device",
+ "serial",
+ "inverter",
+ "panel",
+ "map",
+ "ac",
+ "dc",
+ "pv"
+]), vn = /* @__PURE__ */ new Set([
+ "status",
+ "state",
+ "mode",
+ "operation",
+ "operating",
+ "work",
+ "working",
+ "run",
+ "running",
+ "fault",
+ "alarm",
+ "error"
+]), wn = /* @__PURE__ */ new Set(["sensor", "binary_sensor"]), bn = /* @__PURE__ */ new Set([
+ "battery",
+ "bms",
+ "cloud",
+ "connection",
+ "communication",
+ "firmware",
+ "grid",
+ "meter",
+ "network",
+ "online",
+ "update",
+ "wifi"
+]), fe = (i, e) => {
+ if (e === "friendlyName") {
+ const t = i.friendlyName?.trim();
+ if (t)
+ return t;
+ }
+ return i.entityId;
+}, Me = (i) => i.split(gn).map((e) => e.trim()).filter((e) => e.length > 0), xn = (i, e, t = {}) => {
+ if (i.length < (t.minCandidates ?? 4))
+ return null;
+ const r = i.map(
+ (n) => Me(fe(n, e))
+ ), s = Math.min(...r.map((n) => n.length));
+ if (s < 3)
+ return null;
+ for (let n = s - 1; n > 0; n -= 1) {
+ const o = r.map((p) => p[n].toLowerCase()), l = new Set(o);
+ if (!t.allowSingleGroup && l.size <= 1 || l.size >= i.length || [...l].every((p) => Bt.has(p)) || !t.allowNumericGroup && [...l].every((p) => us.test(p)))
+ continue;
+ const a = /* @__PURE__ */ new Map();
+ for (const p of o)
+ a.set(p, (a.get(p) ?? 0) + 1);
+ if (!(![...a.values()].some((p) => p > 1) || new Set(
+ r.map((p) => p[n - 1].toLowerCase())
+ ).size !== 1))
+ return { sortBy: e, tokenIndex: n };
+ }
+ return null;
+}, Pn = (i) => {
+ const e = i.toLowerCase();
+ return Bt.has(e) || us.test(e) || yn.test(e) ? !1 : zt.test(e) || fn.test(e);
+}, Sn = (i, e, t = {}) => {
+ if (i.length < (t.minCandidates ?? 4))
+ return null;
+ const r = i.map(
+ (c) => Me(fe(c, e))
+ ), s = Math.min(...r.map((c) => c.length));
+ if (s < 3)
+ return null;
+ const n = s - 1, o = r.map((c) => c[n].toLowerCase()), l = new Set(o);
+ if (!t.allowSingleGroup && l.size <= 1 || l.size >= i.length || ![...l].every(Pn))
+ return null;
+ const a = /* @__PURE__ */ new Map();
+ for (const c of o)
+ a.set(c, (a.get(c) ?? 0) + 1);
+ return [...a.values()].some((c) => c > 1) ? { sortBy: e, tokenIndex: n } : null;
+}, Qe = (i, e = {}) => {
+ const t = ["entityId", "friendlyName"];
+ for (const r of t) {
+ const s = Sn(i, r, e);
+ if (s)
+ return s;
+ const n = xn(i, r, e);
+ if (n)
+ return n;
+ }
+ return null;
+}, _s = (i, e) => [...i].sort((t, r) => {
+ const s = q(
+ fe(t, e),
+ fe(r, e)
+ );
+ return s !== 0 ? s : q(t.entityId, r.entityId);
+}), br = (i, e, t = {}) => {
+ const r = Qe(i, t);
+ return r ? [...i].sort((s, n) => {
+ const o = Me(fe(s, r.sortBy)), l = Me(fe(n, r.sortBy)), a = o[r.tokenIndex] ?? "", c = l[r.tokenIndex] ?? "", p = q(a, c);
+ if (p !== 0)
+ return p;
+ const d = o.filter((g, f) => f !== r.tokenIndex).join(" "), h = l.filter((g, f) => f !== r.tokenIndex).join(" "), u = q(d, h);
+ return u !== 0 ? u : q(s.entityId, n.entityId);
+ }) : _s(i, e);
+}, $n = (i, e = "auto", t = "entityId") => (e === "grouped" ? br(i, t, { allowNumericGroup: !0 }) : e === "auto" && Qe(i) ? br(i, t) : _s(
+ i,
+ e === "friendlyName" || e === "entityId" ? e : t
+)).map((s) => ({
+ entityId: s.entityId,
+ friendlyName: s.friendlyName
+})), ms = (i, e, t, r) => {
+ const s = e.trim().replace(/\*+$/, "");
+ if (s.length === 0)
+ return [];
+ const n = s.startsWith("sensor."), o = s.toLowerCase(), l = [];
+ for (const [a, c] of Object.entries(i)) {
+ if (!a.startsWith("sensor."))
+ continue;
+ const p = c.attributes?.unit_of_measurement, d = typeof p == "string" ? p.trim().toLowerCase() : "";
+ if (!t(d, c))
+ continue;
+ const h = typeof c.attributes?.friendly_name == "string" ? c.attributes.friendly_name : void 0, u = h?.trim().toLowerCase();
+ (n ? a.startsWith(s) : u?.includes(o)) && l.push({
+ entityId: a,
+ friendlyName: h
+ });
+ }
+ return $n(
+ l,
+ r,
+ n ? "entityId" : "friendlyName"
+ );
+}, gs = (i, e, t, r, s, n = "auto", o = {}) => {
+ const l = r.trim().replace(/\*+$/, "");
+ if (l.length === 0)
+ return {
+ panels: [...i],
+ matched: 0,
+ filled: 0,
+ skipped: i.length
+ };
+ const a = ms(
+ e,
+ l,
+ s,
+ n
+ ), c = new Set(
+ i.map((v) => v[t]).filter((v) => typeof v == "string" && v.length > 0)
+ ), p = Math.max(
+ 1,
+ Math.floor(o.repeatEachCandidate ?? 1)
+ ), d = a.flatMap(
+ (v) => Array.from({ length: p }, () => v.entityId)
+ ).filter((v) => !c.has(v));
+ let h = 0, u = 0, g = 0;
+ return {
+ panels: i.map((v) => {
+ const x = { ...v };
+ if (x.enabled === !1)
+ return g += 1, x;
+ const S = x[t];
+ if (typeof S == "string" && S.trim().length > 0)
+ return g += 1, x;
+ const I = d[h];
+ return I ? (x[t] = I, h += 1, u += 1, x) : (g += 1, x);
+ }),
+ matched: a.length,
+ filled: u,
+ skipped: g
+ };
+}, Cn = (i, e, t, r, s = "auto", n = {}) => {
+ const o = t.trim().replace(/\*+$/, "");
+ if (o.length === 0)
+ return {
+ panels: [...i],
+ matched: 0,
+ filled: 0,
+ skipped: i.length
+ };
+ const l = ms(
+ e,
+ o,
+ r,
+ s
+ ), a = i.filter((f) => f.power_entity?.trim()).map((f) => {
+ const v = f.power_entity ? e[f.power_entity] : void 0;
+ return {
+ entityId: f.power_entity ?? "",
+ friendlyName: ce(v)
+ };
+ }), c = Qe(a, {
+ allowNumericGroup: !0,
+ allowSingleGroup: !0,
+ minCandidates: 2
+ });
+ if (!c)
+ return gs(
+ i,
+ e,
+ "energy_entity",
+ o,
+ r,
+ s
+ );
+ let p = 0, d = 0;
+ const h = /* @__PURE__ */ new Set(), u = l.flatMap((f) => {
+ const v = e[f.entityId];
+ return v ? [{
+ entityId: f.entityId,
+ entity: v,
+ tokens: Ze(f.entityId, v)
+ }] : [];
+ });
+ return {
+ panels: i.map((f) => {
+ const v = { ...f };
+ if (v.enabled === !1)
+ return d += 1, v;
+ const x = Re(v, e, c), S = v.energy_entity;
+ if (typeof S == "string" && S.trim().length > 0)
+ return x && !n.shareSingleGroupEnergy && h.add(x), d += 1, v;
+ const I = An(
+ v,
+ i,
+ e,
+ c,
+ x
+ ), M = vs(u, x);
+ if (M.length === 1) {
+ const E = x ?? M[0].entityId;
+ return !n.shareSingleGroupEnergy && h.has(E) ? (d += 1, v) : (v.energy_entity = M[0].entityId, n.shareSingleGroupEnergy || h.add(E), p += 1, v);
+ }
+ const $ = qe(M, (E) => {
+ const L = Je(E.tokens, x ?? void 0);
+ return ys(I, L);
+ });
+ return $ ? (v.energy_entity = $.entityId, p += 1, v) : (d += 1, v);
+ }),
+ matched: l.length,
+ filled: p,
+ skipped: d
+ };
+}, yt = (i) => typeof i.attributes?.unit_of_measurement == "string" ? i.attributes.unit_of_measurement.trim().toLowerCase() : "", vt = (i) => typeof i.attributes?.device_class == "string" ? i.attributes.device_class.trim().toLowerCase() : "", xr = (i) => ["w", "kw"].includes(yt(i)) || vt(i) === "power", Pr = (i) => ["v", "kv", "mv"].includes(yt(i)) || vt(i) === "voltage", Sr = (i) => ["a", "ma"].includes(yt(i)) || vt(i) === "current", En = (i) => ["°c", "°f", "℃", "℉", "c", "f"].includes(yt(i)) || vt(i) === "temperature", ce = (i) => {
+ const e = i?.attributes?.friendly_name;
+ if (typeof e != "string")
+ return;
+ const t = e.trim();
+ return t.length > 0 ? t : void 0;
+}, fs = (i, e) => typeof e.entity_id == "string" && e.entity_id.trim().length > 0 ? e.entity_id : i, kn = (i) => i.split(".")[0] ?? "", Ze = (i, e) => Me(`${i} ${ce(e) ?? ""}`).map((t) => t.toLowerCase()), Je = (i, e) => i.filter((t) => e && t === e.toLowerCase() || Bt.has(t) ? !1 : t.length > 0), ys = (i, e) => {
+ const t = new Set(e);
+ return i.filter((r) => t.has(r)).length;
+}, _t = (i) => {
+ const e = /* @__PURE__ */ new Map();
+ for (const t of i)
+ e.set(t, (e.get(t) ?? 0) + 1);
+ return e;
+}, In = (i, e, t, r) => {
+ const s = i.filter((o) => o.enabled === !1 || !o.power_entity?.trim() ? !1 : !t || !r ? !0 : Re(o, e, t) === r);
+ if (s.length === 0)
+ return /* @__PURE__ */ new Map();
+ const n = _t(
+ Ae(s[0], e, r)
+ );
+ for (const o of s.slice(1)) {
+ const l = _t(Ae(o, e, r));
+ for (const [a, c] of [...n.entries()]) {
+ const p = Math.min(c, l.get(a) ?? 0);
+ p <= 0 ? n.delete(a) : n.set(a, p);
+ }
+ }
+ return n;
+}, ct = (i, e) => {
+ const t = new Set(i.tokens);
+ return e.some((r) => t.has(r));
+}, he = (i, e) => Object.entries(i).map(([t, r]) => ({
+ entityId: fs(t, r),
+ entity: r
+})).filter(({ entityId: t, entity: r }) => t.startsWith("sensor.") && e(r)).map(({ entityId: t, entity: r }) => ({
+ entityId: t,
+ entity: r,
+ tokens: Ze(t, r)
+})).sort((t, r) => q(t.entityId, r.entityId)), Fn = (i) => i.tokens.some((e) => vn.has(e)), Tn = (i, e = "") => Object.entries(i).map(([t, r]) => {
+ const s = fs(t, r);
+ return {
+ entityId: s,
+ entity: r,
+ tokens: Ze(s, r)
+ };
+}).filter((t) => wn.has(kn(t.entityId))).filter(Fn).filter((t) => {
+ const r = e.trim().replace(/\*+$/, "").toLowerCase();
+ return r ? r.startsWith("sensor.") || r.startsWith("binary_sensor.") ? t.entityId.toLowerCase().startsWith(r) : !!ce(t.entity)?.trim().toLowerCase().includes(r) : !0;
+}).sort((t, r) => q(t.entityId, r.entityId)), qe = (i, e) => {
+ const t = i.map((r) => ({
+ candidate: r,
+ score: e(r)
+ })).filter((r) => r.score > 0).sort((r, s) => s.score - r.score || q(r.candidate.entityId, s.candidate.entityId));
+ return t.length === 0 || t.length > 1 && t[0].score === t[1].score ? null : t[0].candidate;
+}, vs = (i, e) => {
+ if (!e)
+ return [];
+ const t = e.toLowerCase();
+ return i.filter((r) => r.tokens.includes(t));
+}, ws = (i, e) => {
+ const t = new Set(
+ e.map((r) => r.toLowerCase())
+ );
+ return t.size === 0 ? [] : i.filter(
+ (r) => r.tokens.some((s) => t.has(s))
+ );
+}, Re = (i, e, t) => {
+ if (!t || !i.power_entity)
+ return null;
+ const r = e[i.power_entity];
+ return Me(
+ fe(
+ {
+ entityId: i.power_entity,
+ friendlyName: ce(r)
+ },
+ t.sortBy
+ )
+ )[t.tokenIndex]?.toLowerCase() ?? null;
+}, Ae = (i, e, t) => i.power_entity ? Je(
+ Ze(i.power_entity, e[i.power_entity]),
+ t ?? void 0
+) : [], Mn = (i, e) => i.power_entity ? Je(
+ Ze(i.power_entity, e[i.power_entity])
+).filter((t) => zt.test(t)) : [], bs = (i, e, t) => {
+ const r = Re(i, e, t), s = Mn(i, e), n = r && zt.test(r) ? [r, ...s] : [...s, r];
+ return [
+ ...new Set(
+ n.filter((o) => !!o).map((o) => o.toLowerCase())
+ )
+ ];
+}, An = (i, e, t, r, s) => {
+ const n = Ae(i, t, s);
+ if (!s)
+ return n;
+ const o = /* @__PURE__ */ new Map();
+ for (const l of e) {
+ if (l.enabled === !1 || !l.power_entity?.trim() || Re(l, t, r) !== s)
+ continue;
+ const a = new Set(
+ Ae(l, t, s)
+ );
+ for (const c of a)
+ o.set(c, (o.get(c) ?? 0) + 1);
+ }
+ return n.filter((l) => o.get(l) === 1);
+}, Rn = (i, e, t, r, s) => {
+ if (i === "panel_power_entity")
+ return e.power_entity?.trim() || null;
+ const n = vs(t[i], r);
+ if (n.length === 0)
+ return null;
+ if (i === "inverter_temp_entity") {
+ const o = n.filter(
+ (l) => ct(l, ["temp", "temperature", "inverter"])
+ );
+ return o.length === 1 ? o[0].entityId : n.length === 1 ? n[0].entityId : null;
+ }
+ return i === "inverter_ac_power_entity" || i === "inverter_ac_voltage_entity" || i === "inverter_ac_current_entity" ? qe(n, (l) => ct(l, ["ac", "inverter"]) ? 2 : 0)?.entityId ?? null : i === "panel_current_entity" || i === "panel_voltage_entity" ? qe(n, (l) => {
+ const a = Je(l.tokens, r ?? void 0), c = ys(s, a);
+ if (c === 0)
+ return 0;
+ const p = ct(l, ["ac", "inverter"]) ? 1 : 0;
+ return c * 2 - p;
+ })?.entityId ?? null : null;
+}, Nn = (i) => {
+ let e = 0;
+ const t = `${i.entityId} ${ce(i.entity) ?? ""}`.toLowerCase();
+ /device[._\-\s]+status/.test(t) && (e += 120), /inverter[._\-\s]+status/.test(t) && (e += 100), ce(i.entity)?.trim().toLowerCase() === "device status" && (e += 80);
+ for (const r of i.tokens)
+ r === "status" ? e += 20 : r === "state" ? e += 12 : ["fault", "alarm", "error"].includes(r) ? e += 10 : ["mode", "operation", "operating"].includes(r) ? e += 5 : ["work", "working", "run", "running"].includes(r) && (e += 3);
+ ct(i, ["inverter"]) && (e += 2);
+ for (const r of i.tokens)
+ bn.has(r) && (e -= 8);
+ return e;
+}, Ln = (i, e, t, r, s, n) => {
+ const o = Re(i, t, s), l = bs(i, t, s), c = (l.length > 0 ? ws(r, l) : r).filter(
+ (u) => !n.has(u.entityId)
+ );
+ if (c.length === 0)
+ return null;
+ const p = _t(Ae(i, t, o)), d = In(
+ e,
+ t,
+ s,
+ o
+ ), h = /* @__PURE__ */ new Map();
+ for (const [u, g] of p.entries()) {
+ const f = g - (d.get(u) ?? 0);
+ f > 0 && h.set(u, f);
+ }
+ return h.size === 0 ? null : qe(c, (u) => {
+ const g = _t(
+ Je(u.tokens, o ?? void 0)
+ );
+ let f = 0;
+ for (const [v, x] of h.entries()) {
+ const S = Math.max(
+ 0,
+ (g.get(v) ?? 0) - (d.get(v) ?? 0)
+ );
+ f += Math.min(x, S);
+ }
+ return f > 0 ? f * 10 : 0;
+ });
+}, Dn = (i, e, t, r) => {
+ const s = new Set(
+ i.map((a) => a.inverter_status_entity).filter((a) => typeof a == "string" && a.trim().length > 0)
+ );
+ let n = 0, o = 0;
+ return {
+ panels: i.map((a) => {
+ if (a.enabled === !1 || !a.power_entity?.trim())
+ return o += 1, a;
+ if (typeof a.inverter_status_entity == "string" && a.inverter_status_entity.trim().length > 0)
+ return a;
+ const c = Ln(
+ a,
+ i,
+ e,
+ t,
+ r,
+ s
+ );
+ return c ? (s.add(c.entityId), n += 1, {
+ ...a,
+ inverter_status_entity: c.entityId
+ }) : (o += 1, a);
+ }),
+ filled: n,
+ skippedPanels: o
+ };
+}, On = (i, e, t = "") => {
+ const s = i.filter(
+ (h) => typeof h.power_entity == "string" && h.power_entity.trim().length > 0
+ ).map((h) => {
+ const u = h.power_entity ? e[h.power_entity] : void 0;
+ return {
+ entityId: h.power_entity ?? "",
+ friendlyName: ce(u)
+ };
+ }), n = Qe(s, {
+ allowNumericGroup: !0,
+ allowSingleGroup: !0,
+ minCandidates: 2
+ }), o = Tn(e, t), l = Dn(
+ i,
+ e,
+ o,
+ n
+ );
+ if (l.filled > 0)
+ return l;
+ const a = /* @__PURE__ */ new Map();
+ let c = 0, p = 0;
+ return {
+ panels: i.map((h) => {
+ if (h.enabled === !1 || !h.power_entity?.trim())
+ return p += 1, h;
+ if (typeof h.inverter_status_entity == "string" && h.inverter_status_entity.trim().length > 0)
+ return h;
+ const u = bs(h, e, n);
+ if (u.length === 0)
+ return p += 1, h;
+ const g = u[0];
+ if (!a.has(g)) {
+ const v = ws(
+ o,
+ u
+ ), x = v.length === 1 ? v[0] : qe(v, Nn);
+ a.set(g, x?.entityId ?? null);
+ }
+ const f = a.get(g);
+ return f ? (c += 1, {
+ ...h,
+ inverter_status_entity: f
+ }) : (p += 1, h);
+ }),
+ filled: c,
+ skippedPanels: p
+ };
+}, Gn = (i, e) => {
+ const r = i.filter(
+ (c) => c.enabled !== !1 && typeof c.power_entity == "string" && c.power_entity.trim().length > 0
+ ).map((c) => {
+ const p = c.power_entity ? e[c.power_entity] : void 0;
+ return {
+ entityId: c.power_entity ?? "",
+ friendlyName: ce(p)
+ };
+ }), s = Qe(r, {
+ allowNumericGroup: !0,
+ allowSingleGroup: !0,
+ minCandidates: 2
+ }), n = {
+ inverter_ac_power_entity: he(e, xr),
+ inverter_ac_voltage_entity: he(e, Pr),
+ inverter_ac_current_entity: he(e, Sr),
+ inverter_temp_entity: he(e, En),
+ panel_current_entity: he(e, Sr),
+ panel_voltage_entity: he(e, Pr),
+ panel_power_entity: he(e, xr)
+ };
+ let o = 0, l = 0;
+ return {
+ panels: i.map((c) => {
+ if (c.enabled === !1 || !c.power_entity?.trim())
+ return l += 1, c;
+ const p = Re(c, e, s), d = Ae(c, e, p), h = {
+ ...c.advanced_metrics ?? {}
+ };
+ let u = !1;
+ const g = [
+ "inverter_ac_power_entity",
+ "inverter_ac_voltage_entity",
+ "inverter_ac_current_entity",
+ "inverter_temp_entity",
+ "panel_current_entity",
+ "panel_voltage_entity",
+ "panel_power_entity"
+ ];
+ for (const f of g) {
+ if (typeof h[f] == "string" && h[f]?.trim())
+ continue;
+ const v = Rn(
+ f,
+ c,
+ n,
+ p,
+ d
+ );
+ v && (h[f] = v, o += 1, u = !0);
+ }
+ return u ? {
+ ...c,
+ advanced_metrics: h
+ } : c;
+ }),
+ filled: o,
+ skippedPanels: l
+ };
+}, Hn = (i, e, t, r) => {
+ const s = i?.trim() ?? "";
+ if (!s || s === e || /^panel\s+\d+$/i.test(s))
+ return !0;
+ const n = r(t);
+ return !!(n && s === n);
+}, zn = (i, e) => i.map((t) => {
+ const r = t.power_entity?.trim();
+ if (!r)
+ return t;
+ const s = e(r);
+ return s ? {
+ ...t,
+ name: s
+ } : t;
+}), $r = "spv-card-config-updated", Bn = (i, e) => {
+ i.dispatchEvent(
+ new CustomEvent("config-changed", {
+ detail: { config: e },
+ bubbles: !0,
+ composed: !0
+ })
+ );
+}, xs = (i) => typeof i == "string" ? i.trim().toLowerCase() : "", ue = (i, e) => {
+ const t = xs(i.attributes?.unit_of_measurement);
+ return t.length > 0 && e.includes(t);
+}, ie = (i, e) => {
+ const t = xs(i.attributes?.device_class);
+ return t.length > 0 && e.includes(t);
+}, Et = (i, e) => ["w", "kw"].includes(i) || ie(e, ["power"]), kt = (i, e) => ["wh", "kwh", "mwh"].includes(i) || ie(e, ["energy"]), It = (i, e) => typeof e.entity_id == "string" && e.entity_id.trim().length > 0 ? e.entity_id : i, Cr = [
+ {
+ key: "inverter_ac_power_entity",
+ labelKey: "editor.field.advanced_inverter_ac_power",
+ matcher: (i) => ue(i, ["w", "kw"]) || ie(i, ["power"])
+ },
+ {
+ key: "inverter_ac_voltage_entity",
+ labelKey: "editor.field.advanced_inverter_ac_voltage",
+ matcher: (i) => ue(i, ["v", "kv", "mv"]) || ie(i, ["voltage"])
+ },
+ {
+ key: "inverter_ac_current_entity",
+ labelKey: "editor.field.advanced_inverter_ac_current",
+ matcher: (i) => ue(i, ["a", "ma"]) || ie(i, ["current"])
+ },
+ {
+ key: "inverter_temp_entity",
+ labelKey: "editor.field.advanced_inverter_temp",
+ matcher: (i) => ie(i, ["temperature"]) || ue(i, ["°c", "°f", "℃", "℉", "c", "f"])
+ },
+ {
+ key: "panel_current_entity",
+ labelKey: "editor.field.advanced_panel_current",
+ matcher: (i) => ue(i, ["a", "ma"]) || ie(i, ["current"])
+ },
+ {
+ key: "panel_voltage_entity",
+ labelKey: "editor.field.advanced_panel_voltage",
+ matcher: (i) => ue(i, ["v", "kv", "mv"]) || ie(i, ["voltage"])
+ },
+ {
+ key: "panel_power_entity",
+ labelKey: "editor.field.advanced_panel_power",
+ matcher: (i) => ue(i, ["w", "kw"]) || ie(i, ["power"])
+ }
+], gt = class gt extends Ie {
+ constructor() {
+ super(...arguments), this._config = this._normalizeEditorConfig({
+ type: this._getCardType()
+ }), this._autoFillPowerPrefix = "", this._autoFillEnergyPrefix = "", this._autoFillStatusPrefix = "", this._autoFillSortMode = "auto", this._autoFillInverterStatus = !1, this._autoFillAdvancedTelemetry = !1, this._autoFillResultMessage = "", this._applyDefaultRatedPowerToAllPanels = () => {
+ const e = this._config.default_panel_rated_power_w;
+ if (!e)
+ return;
+ const t = this._config.panels.map((r) => ({
+ ...r,
+ rated_power_w: e
+ }));
+ this._commit(
+ this._normalizeEditorConfig({
+ ...this._config,
+ panels: t
+ })
+ );
+ }, this._handleAutoPopulateSensors = () => {
+ if (!this.hass)
+ return;
+ const e = this._autoFillPowerPrefix.trim(), t = this._autoFillEnergyPrefix.trim(), r = this._autoFillStatusPrefix.trim();
+ if (!e && !t && !this._autoFillInverterStatus && !this._autoFillAdvancedTelemetry) {
+ this._autoFillResultMessage = this._t("editor.autofill.enter_prefix");
+ return;
+ }
+ let s = [...this._config.panels];
+ const n = [];
+ if (e) {
+ s = s.map((l) => ({ ...l, power_entity: void 0 }));
+ const o = gs(
+ s,
+ this.hass.states,
+ "power_entity",
+ e,
+ Et,
+ this._autoFillSortMode
+ );
+ s = o.panels, n.push(
+ this._t("editor.autofill.power_summary", {
+ matched: o.matched,
+ filled: o.filled,
+ skipped: o.skipped
+ })
+ );
+ }
+ if (t) {
+ s = s.map((l) => ({ ...l, energy_entity: void 0 }));
+ const o = Cn(
+ s,
+ this.hass.states,
+ t,
+ kt,
+ this._autoFillSortMode,
+ {
+ shareSingleGroupEnergy: this._config.enable_string_sensor_sharing ?? !1
+ }
+ );
+ s = o.panels, n.push(
+ this._t("editor.autofill.energy_summary", {
+ matched: o.matched,
+ filled: o.filled,
+ skipped: o.skipped
+ })
+ );
+ }
+ if (s = zn(
+ s,
+ (o) => this._getEntityFriendlyName(o)
+ ), this._autoFillInverterStatus) {
+ const o = On(
+ s,
+ this.hass.states,
+ r
+ );
+ s = o.panels, n.push(
+ this._t("editor.autofill.inverter_status_summary", {
+ filled: o.filled,
+ skipped: o.skippedPanels
+ })
+ );
+ }
+ if (this._autoFillAdvancedTelemetry) {
+ const o = Gn(
+ s,
+ this.hass.states
+ );
+ s = o.panels, n.push(
+ this._t("editor.autofill.advanced_summary", {
+ filled: o.filled,
+ skipped: o.skippedPanels
+ })
+ );
+ }
+ this._autoFillResultMessage = n.join(" "), this._commit(
+ this._normalizeEditorConfig({
+ ...this._config,
+ panels: s
+ })
+ );
+ }, this._handleRemoveAllPanelSensors = () => {
+ const e = this._config.panels.map((t) => ({
+ ...t,
+ power_entity: void 0,
+ energy_entity: void 0,
+ show_energy: !1,
+ inverter_status_entity: void 0,
+ error_entity: void 0,
+ advanced_metrics: void 0
+ }));
+ this._autoFillResultMessage = this._t("editor.autofill.cleared"), this._commit(
+ this._normalizeEditorConfig({
+ ...this._config,
+ panels: e
+ })
+ );
+ }, this._handleCardConfigSync = (e) => {
+ const r = e.detail?.config;
+ if (!r || typeof r != "object")
+ return;
+ const s = this._normalizeEditorConfig(
+ r
+ );
+ this._isReorderOnlySync(s) && this._commit(s);
+ };
+ }
+ connectedCallback() {
+ super.connectedCallback(), window.addEventListener($r, this._handleCardConfigSync);
+ }
+ disconnectedCallback() {
+ window.removeEventListener($r, this._handleCardConfigSync), super.disconnectedCallback();
+ }
+ setConfig(e) {
+ this._config = this._normalizeEditorConfig(e ?? {});
+ }
+ _getCardType() {
+ return Rt;
+ }
+ _normalizeEditorConfig(e) {
+ return Be(e);
+ }
+ _renderExtraSections() {
+ return w;
+ }
+ _t(e, t) {
+ return rs(this.hass, e, t);
+ }
+ render() {
+ if (!this.hass)
+ return w;
+ const e = !!this.hass.states["sensor.power_production_now"], t = !!this.hass.states["sensor.energy_production_today"], r = e && t ? this._t("editor.forecast.detected") : this._t("editor.forecast.missing");
+ return y`
+
+
+
+
+
+ ${this._renderTextField("title", this._t("editor.field.title"), this._config.title ?? "")}
+ ${this._renderNumberField("rows", this._t("editor.field.rows"), this._config.rows, 1, Y)}
+ ${this._renderNumberField(
+ "columns",
+ this._t("editor.field.columns"),
+ this._config.columns,
+ 1,
+ Y
+ )}
+ ${this._renderNumberField(
+ "max_card_width_px",
+ this._t("editor.field.max_card_width"),
+ this._config.max_card_width_px ?? 980,
+ 300,
+ 2400
+ )}
+ ${this._renderOptionalNumberField(
+ "max_card_height_px",
+ this._t("editor.field.max_card_height"),
+ this._config.max_card_height_px,
+ 300,
+ 2600
+ )}
+
+
+
+
+
+
+ ${this._renderSelectField(
+ "theme_mode",
+ this._t("editor.field.theme_mode"),
+ this._config.theme_mode ?? "auto",
+ [
+ { value: "auto", label: this._t("editor.select.theme_auto") },
+ { value: "dark", label: this._t("editor.select.theme_dark") },
+ { value: "light", label: this._t("editor.select.theme_light") }
+ ]
+ )}
+
+
+
+
+
+
+ ${this._renderNumberField(
+ "power_decimals",
+ this._t("editor.field.power_decimals"),
+ this._config.power_decimals ?? 0,
+ 0,
+ 4
+ )}
+ ${this._renderNumberField(
+ "energy_decimals",
+ this._t("editor.field.energy_decimals"),
+ this._config.energy_decimals ?? 2,
+ 0,
+ 4
+ )}
+ ${this._renderNumberField(
+ "custom_kpi_decimals",
+ this._t("editor.field.custom_kpi_decimals"),
+ this._config.custom_kpi_decimals ?? 0,
+ 0,
+ 4
+ )}
+ ${this._renderSelectField(
+ "panel_tap_action",
+ this._t("editor.field.panel_tap_action"),
+ this._config.panel_tap_action ?? "details",
+ [
+ { value: "details", label: this._t("editor.select.panel_tap_details") },
+ { value: "none", label: this._t("editor.select.panel_tap_none") }
+ ]
+ )}
+
+
+ this._updateRootValue(
+ "use_system_power_entity",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.use_system_power")}
+
+ ${this._renderSelectorWithClear(
+ this._t("editor.field.system_power_sensor"),
+ this._config.system_power_entity,
+ {
+ entity: {
+ domain: "sensor"
+ }
+ },
+ (s) => this._updateRootValue("system_power_entity", s)
+ )}
+
+ this._updateRootValue(
+ "invert_system_power",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.invert_system_power")}
+
+
+ this._updateRootValue(
+ "use_system_energy_entity",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.use_system_energy")}
+
+ ${this._renderSelectorWithClear(
+ this._t("editor.field.system_energy_sensor"),
+ this._config.system_energy_entity,
+ {
+ entity: {
+ domain: "sensor"
+ }
+ },
+ (s) => this._updateRootValue("system_energy_entity", s)
+ )}
+ ${this._renderSelectorWithClear(
+ this._t("editor.field.custom_kpi_sensor"),
+ this._config.custom_kpi_entity,
+ {
+ entity: {
+ domain: "sensor"
+ }
+ },
+ (s) => this._updateRootValue("custom_kpi_entity", s)
+ )}
+
+ this._updateRootValue(
+ "invert_custom_kpi",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.invert_custom_kpi")}
+
+ ${this._renderTextField(
+ "custom_kpi_title",
+ this._t("editor.field.custom_kpi_heading"),
+ this._config.custom_kpi_title ?? ""
+ )}
+
+ this._updateRootValue(
+ "show_custom_kpi",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.show_custom_kpi")}
+
+
+ this._updateRootValue(
+ "limit_panel_width",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.limit_panel_width")}
+
+ ${this._renderEditorInput(
+ this._t("editor.field.max_panel_tile_width"),
+ String(this._config.panel_max_width_px ?? 220),
+ (s) => this._updateRootValue(
+ "panel_max_width_px",
+ this._parseNumberWithClamp(
+ s,
+ this._config.panel_max_width_px ?? 220,
+ 120,
+ 320
+ )
+ ),
+ {
+ type: "number",
+ min: 120,
+ max: 320,
+ disabled: !(this._config.limit_panel_width ?? !1),
+ updateOn: "change"
+ }
+ )}
+
+
+
+
+
+ this._updateRootValue(
+ "enable_forecast_overlay",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.enable_forecast_overlay")}
+
+ ${r}
+
+
+
+
+
+ this._updateRootValue(
+ "enable_array_checks",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.enable_array_health")}
+
+
+ ${this._t("editor.section.array_health_smoothing_help")}
+
+
+ ${this._renderNumberField(
+ "deviation_threshold_percent",
+ this._t("editor.field.deviation_threshold"),
+ this._config.deviation_threshold_percent ?? 15,
+ 1,
+ 100
+ )}
+ ${this._renderNumberField(
+ "deviation_absolute_w_threshold",
+ this._t("editor.field.deviation_absolute_shortfall"),
+ this._config.deviation_absolute_w_threshold ?? 50,
+ 0,
+ 5e3
+ )}
+ ${this._renderNumberField(
+ "deviation_min_runtime_minutes",
+ this._t("editor.field.deviation_check_time"),
+ this._config.deviation_min_runtime_minutes ?? 15,
+ 0,
+ 1440
+ )}
+ ${this._renderNumberField(
+ "deviation_min_active_panels",
+ this._t("editor.field.deviation_min_active_panels"),
+ this._config.deviation_min_active_panels ?? 3,
+ 2,
+ 30
+ )}
+ ${this._renderNumberField(
+ "deviation_min_samples",
+ this._t("editor.field.deviation_min_samples"),
+ this._config.deviation_min_samples ?? 3,
+ 1,
+ 120
+ )}
+ ${this._renderNumberField(
+ "deviation_smoothing_minutes",
+ this._t("editor.field.deviation_smoothing"),
+ this._config.deviation_smoothing_minutes ?? 0,
+ 0,
+ 1440
+ )}
+ ${this._renderNumberField(
+ "deviation_dynamic_floor_w",
+ this._t("editor.field.deviation_dynamic_floor"),
+ this._config.deviation_dynamic_floor_w ?? 20,
+ 0,
+ 5e3
+ )}
+ ${this._renderNumberField(
+ "deviation_history_hours",
+ this._t("editor.field.deviation_history_window"),
+ this._config.deviation_history_hours ?? 12,
+ 1,
+ 168
+ )}
+
+
+
+
+
+
+ this._updateRootValue(
+ "enable_inverter_status",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.enable_inverter_status")}
+ this._updateRootValue(
+ "show_inverter_status_on_tiles",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.show_inverter_status_tiles")}
+
+ ${this._renderEditorInput(
+ this._t("editor.field.fault_terms"),
+ (this._config.inverter_fault_terms ?? []).join(", "),
+ (s) => this._updateRootValue(
+ "inverter_fault_terms",
+ s
+ )
+ )}
+ ${this._renderEditorInput(
+ this._t("editor.field.working_terms"),
+ (this._config.inverter_working_terms ?? []).join(", "),
+ (s) => this._updateRootValue(
+ "inverter_working_terms",
+ s
+ )
+ )}
+
+ ${this._t("editor.helper.fault_example")}
+
+
+ ${this._t("editor.helper.working_example")}
+
+
+
+
+
+
+
+
+ this._updateRootValue(
+ "motion_enabled",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.motion_enabled")}
+
+
+ this._updateRootValue(
+ "motion_power_flow",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.motion_power_flow")}
+
+
+ this._updateRootValue(
+ "motion_update_shimmer",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.motion_update_shimmer")}
+
+
+ this._updateRootValue(
+ "motion_alert_ripple",
+ s.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.motion_alert_ripple")}
+
+
+
+
+
+
+ ${this._renderStringSetupSection()}
+
+
+
+
+ ${this._renderOptionalNumberField(
+ "default_panel_rated_power_w",
+ this._t("editor.field.default_panel_rated_power"),
+ this._config.default_panel_rated_power_w,
+ 1,
+ dt
+ )}
+
+
+
+
+
+
+
+
+
+ ${this._renderEditorInput(
+ this._t("editor.field.power_prefix"),
+ this._autoFillPowerPrefix,
+ (s) => {
+ this._autoFillPowerPrefix = s;
+ }
+ )}
+ ${this._renderEditorInput(
+ this._t("editor.field.energy_prefix"),
+ this._autoFillEnergyPrefix,
+ (s) => {
+ this._autoFillEnergyPrefix = s;
+ }
+ )}
+ ${this._renderEditorInput(
+ this._t("editor.field.status_prefix"),
+ this._autoFillStatusPrefix,
+ (s) => {
+ this._autoFillStatusPrefix = s;
+ }
+ )}
+ ${this._renderAutoFillSortModeField()}
+
+
+ ${this._t("editor.helper.autofill_search_help")}
+
+
+
+ ${this._t("editor.helper.autofill_inverter_status_help")}
+
+
+
+ ${this._t("editor.helper.autofill_advanced_help")}
+
+
+
+
+
+ ${this._autoFillResultMessage ? y`
${this._autoFillResultMessage}
` : w}
+
+
+
+ ${this._config.panels.map(
+ (s, n) => this._renderPanelEditor(s, n)
+ )}
+
+
+
+ ${this._renderExtraSections()}
+
+ `;
+ }
+ _renderStringSetupSection() {
+ const e = this._config.enable_string_sensor_sharing ?? !1, t = this._getStringGroupsForEditor();
+ return y`
+
+
+
+ this._handleStringSensorSharingToggle(
+ r.currentTarget.checked
+ )}
+ >${this._t("editor.toggle.enable_string_sensor_sharing")}
+
+
+ ${this._t("editor.helper.string_sensor_sharing_help")}
+
+ ${e ? y`
+
+ ${this._renderEditorInput(
+ this._t("editor.field.string_group_count"),
+ String(t.length),
+ (r) => this._updateStringGroupCount(r),
+ {
+ type: "number",
+ min: 1,
+ max: Y,
+ updateOn: "change"
+ }
+ )}
+
+
+ ${t.map(
+ (r, s) => this._renderStringGroupEditor(r, s)
+ )}
+
+ ` : w}
+
+ `;
+ }
+ _renderStringGroupEditor(e, t) {
+ const r = this._getStringSensorEntityIdsByUnit(
+ e.power_entity,
+ Et
+ ), s = this._getStringSensorEntityIdsByUnit(
+ e.energy_entity,
+ kt
+ ), n = e.name ?? `String ${t + 1}`;
+ return y`
+
+
+ ${n}
+
+ ${this._t("editor.string_group.panel_count_chip", {
+ count: e.panel_count
+ })}
+
+
+
+
+ `;
+ }
+ _renderPanelEditor(e, t) {
+ const r = this._getAvailableSensorEntityIdsByUnit(
+ t,
+ "power_entity",
+ Et
+ ), s = this._getAvailableSensorEntityIdsByUnit(
+ t,
+ "energy_entity",
+ kt
+ );
+ return y`
+
+
+ ${this._formatPanelSlotTitle(t)}
+ ${e.name ?? e.id}
+
+
+ ${this._renderPanelTextField(t, "name", this._t("editor.field.display_name"), e.name ?? "")}
+ ${this._renderPanelSensorSelector(
+ t,
+ "power_entity",
+ this._t("editor.field.power_sensor"),
+ e.power_entity,
+ r
+ )}
+ ${this._renderPanelSensorSelector(
+ t,
+ "energy_entity",
+ this._t("editor.field.energy_sensor"),
+ e.energy_entity,
+ s
+ )}
+
+ this._updatePanelValue(
+ t,
+ "show_energy",
+ n.currentTarget.checked
+ )}
+ >${this._t("editor.field.panel_energy_toggle")}
+
+ ${this._renderPanelOptionalNumberField(
+ t,
+ "rated_power_w",
+ this._t("editor.field.panel_rated_power"),
+ e.rated_power_w,
+ 1,
+ dt
+ )}
+ ${this._renderPanelNumberField(
+ t,
+ "deviation_derate_percent",
+ this._t("editor.field.deviation_derate"),
+ e.deviation_derate_percent ?? 100,
+ 1,
+ 100
+ )}
+
+ ${this._t("editor.helper.derate_help")}
+
+ ${this._renderPanelEntityPicker(
+ t,
+ "inverter_status_entity",
+ this._t("editor.field.inverter_status_sensor"),
+ e.inverter_status_entity,
+ ["sensor", "binary_sensor"]
+ )}
+ ${this._renderPanelAdvancedSection(t, e)}
+
+ this._updatePanelValue(
+ t,
+ "enabled",
+ n.currentTarget.checked
+ )}
+ >${this._t("editor.field.panel_enabled_toggle")}
+
+
+
+ `;
+ }
+ _renderPanelAdvancedSection(e, t) {
+ return y`
+
+ ${this._t("editor.section.advanced_title")}
+
+
+ ${this._t("editor.section.advanced_copy")}
+
+ ${Cr.map(
+ (r) => this._renderPanelAdvancedSensorSelector(
+ e,
+ r.key,
+ this._t(r.labelKey),
+ t.advanced_metrics?.[r.key],
+ this._getAdvancedSensorEntityIds(e, r.key, r.matcher)
+ )
+ )}
+
+
+ `;
+ }
+ _renderPanelAdvancedSensorSelector(e, t, r, s, n) {
+ return this._renderSelectorWithClear(
+ r,
+ s,
+ {
+ entity: {
+ domain: "sensor",
+ include_entities: n
+ }
+ },
+ (o) => this._updatePanelAdvancedMetricValue(e, t, o)
+ );
+ }
+ _formatPanelSlotTitle(e) {
+ const t = Math.floor(e / this._config.columns) + 1, r = e % this._config.columns + 1;
+ return this._t("editor.panel_slot_title", { row: t, column: r });
+ }
+ _renderPanelEntityPicker(e, t, r, s, n) {
+ return this._renderSelectorWithClear(
+ r,
+ s,
+ {
+ entity: {
+ domain: n
+ }
+ },
+ (o) => this._updatePanelValue(e, t, o)
+ );
+ }
+ _renderTextField(e, t, r) {
+ return this._renderEditorInput(
+ t,
+ r,
+ (s) => this._updateRootValue(e, s)
+ );
+ }
+ _renderPanelSensorSelector(e, t, r, s, n) {
+ return this._renderSelectorWithClear(
+ r,
+ s,
+ {
+ entity: {
+ domain: "sensor",
+ include_entities: n
+ }
+ },
+ (o) => this._updatePanelValue(e, t, o)
+ );
+ }
+ _renderSelectorWithClear(e, t, r, s) {
+ return y`
+ s(
+ typeof n.detail?.value == "string" ? n.detail.value : ""
+ )}
+ >
+ `;
+ }
+ _renderNumberField(e, t, r, s, n) {
+ return this._renderEditorInput(
+ t,
+ String(r),
+ (o) => this._updateRootValue(
+ e,
+ this._parseNumberWithClamp(o, r, s, n)
+ ),
+ { type: "number", min: s, max: n, updateOn: "change" }
+ );
+ }
+ _renderOptionalNumberField(e, t, r, s, n) {
+ return this._renderEditorInput(
+ t,
+ r !== void 0 ? String(r) : "",
+ (o) => this._updateRootValue(
+ e,
+ this._parseOptionalNumber(o, s, n)
+ ),
+ { type: "number", min: s, max: n, updateOn: "change" }
+ );
+ }
+ _renderPanelTextField(e, t, r, s) {
+ return this._renderEditorInput(
+ r,
+ s,
+ (n) => this._updatePanelValue(e, t, n)
+ );
+ }
+ _renderPanelOptionalNumberField(e, t, r, s, n, o) {
+ return this._renderEditorInput(
+ r,
+ s !== void 0 ? String(s) : "",
+ (l) => this._updatePanelValue(
+ e,
+ t,
+ this._parseOptionalNumber(l, n, o)
+ ),
+ { type: "number", min: n, max: o, updateOn: "change" }
+ );
+ }
+ _renderPanelNumberField(e, t, r, s, n, o) {
+ return this._renderEditorInput(
+ r,
+ String(s),
+ (l) => this._updatePanelValue(
+ e,
+ t,
+ this._parseNumberWithClamp(l, s, n, o)
+ ),
+ { type: "number", min: n, max: o, updateOn: "change" }
+ );
+ }
+ _renderEditorInput(e, t, r, s = {}) {
+ const n = s.updateOn ?? "input", o = (a) => {
+ n === "input" && r(a.currentTarget.value);
+ }, l = (a) => {
+ n === "change" && r(a.currentTarget.value);
+ };
+ return y`
+
+ `;
+ }
+ _renderSelectField(e, t, r, s) {
+ return y`
+
+ `;
+ }
+ _renderAutoFillSortModeField() {
+ const e = [
+ { value: "auto", label: this._t("editor.select.autofill_sort_auto") },
+ { value: "entityId", label: this._t("editor.select.autofill_sort_entity") },
+ { value: "friendlyName", label: this._t("editor.select.autofill_sort_friendly") },
+ { value: "grouped", label: this._t("editor.select.autofill_sort_grouped") }
+ ];
+ return y`
+
+ `;
+ }
+ _renderColorField(e, t) {
+ const r = this._config.colors?.[e] ?? "";
+ return y`
+
+ `;
+ }
+ _updateRootValue(e, t) {
+ const r = this._normalizeEditorConfig({
+ ...this._config,
+ [e]: t
+ });
+ this._commit(r);
+ }
+ _updateColor(e, t) {
+ const r = this._normalizeEditorConfig({
+ ...this._config,
+ colors: {
+ ...this._config.colors ?? {},
+ [e]: t
+ }
+ });
+ this._commit(r);
+ }
+ _getStringGroupsForEditor() {
+ const e = this._config.string_groups ?? [];
+ return e.length > 0 ? e : this._buildStringGroupsFromCurrentPanels();
+ }
+ _buildStringGroupsFromCurrentPanels() {
+ return Array.from({ length: Math.max(this._config.rows, 1) }, (e, t) => {
+ const r = t * this._config.columns, n = this._config.panels.slice(
+ r,
+ r + this._config.columns
+ ).filter((a) => a.enabled !== !1), o = n.find((a) => a.power_entity), l = n.find((a) => a.energy_entity);
+ return {
+ id: `string-${t + 1}`,
+ name: `String ${t + 1}`,
+ panel_count: Math.min(
+ Math.max(n.length || this._config.columns || 1, 1),
+ Y
+ ),
+ power_entity: o?.power_entity,
+ energy_entity: l?.energy_entity
+ };
+ });
+ }
+ _makeDefaultStringGroup(e) {
+ return {
+ id: `string-${e + 1}`,
+ name: `String ${e + 1}`,
+ panel_count: Math.min(Math.max(this._config.columns || 3, 1), Y)
+ };
+ }
+ _handleStringSensorSharingToggle(e) {
+ const t = e && (this._config.string_groups?.length ?? 0) === 0 ? this._buildStringGroupsFromCurrentPanels() : this._config.string_groups ?? [];
+ this._commit(
+ this._normalizeEditorConfig({
+ ...this._config,
+ enable_string_sensor_sharing: e,
+ string_groups: t
+ })
+ );
+ }
+ _updateStringGroupCount(e) {
+ const t = this._parseNumberWithClamp(
+ e,
+ this._getStringGroupsForEditor().length || 1,
+ 1,
+ Y
+ ), r = this._getStringGroupsForEditor(), s = Array.from(
+ { length: t },
+ (n, o) => r[o] ?? this._makeDefaultStringGroup(o)
+ );
+ this._commitStringGroups(s);
+ }
+ _updateStringGroupValue(e, t, r) {
+ const s = this._getStringGroupsForEditor().map((n, o) => o !== e ? n : {
+ ...n,
+ [t]: r
+ });
+ this._commitStringGroups(s);
+ }
+ _commitStringGroups(e) {
+ this._commit(
+ this._normalizeEditorConfig({
+ ...this._config,
+ enable_string_sensor_sharing: !0,
+ string_groups: e
+ })
+ );
+ }
+ _updatePanelValue(e, t, r) {
+ const s = this._config.panels.map((o, l) => {
+ if (l !== e)
+ return o;
+ const a = { ...o, [t]: r };
+ if (t === "power_entity") {
+ const c = o.power_entity, p = typeof r == "string" ? r.trim() : "";
+ if (p.length > 0) {
+ const d = this._getEntityFriendlyName(p);
+ d && this._shouldAutoRenamePanel(o.name, o.id, c) && (a.name = d);
+ }
+ }
+ return t === "enabled" && r === !1 && (a.power_entity = void 0, a.energy_entity = void 0, a.show_energy = !1, a.inverter_status_entity = void 0, a.error_entity = void 0, a.advanced_metrics = void 0), a;
+ }), n = this._normalizeEditorConfig({
+ ...this._config,
+ panels: s
+ });
+ this._commit(n);
+ }
+ _updatePanelAdvancedMetricValue(e, t, r) {
+ const s = typeof r == "string" && r.trim().length > 0 ? r.trim() : void 0, n = this._config.panels.map((l, a) => {
+ if (a !== e)
+ return l;
+ const c = {
+ ...l.advanced_metrics ?? {}
+ };
+ return c[t] = s, Cr.every(
+ (p) => !c[p.key] || c[p.key]?.trim().length === 0
+ ) ? {
+ ...l,
+ advanced_metrics: void 0
+ } : {
+ ...l,
+ advanced_metrics: c
+ };
+ }), o = this._normalizeEditorConfig({
+ ...this._config,
+ panels: n
+ });
+ this._commit(o);
+ }
+ _parseOptionalNumber(e, t, r) {
+ if (e.trim() === "")
+ return;
+ const s = Number(e);
+ if (Number.isFinite(s))
+ return Math.min(Math.max(s, t), r);
+ }
+ _parseNumberWithClamp(e, t, r, s) {
+ const n = Number(e);
+ return Number.isFinite(n) ? Math.min(Math.max(n, r), s) : t;
+ }
+ _getAvailableSensorEntityIdsByUnit(e, t, r) {
+ if (!this.hass)
+ return [];
+ const n = this._config.enable_string_sensor_sharing ?? !1 ? /* @__PURE__ */ new Set() : new Set(
+ this._config.panels.map((a, c) => c === e ? void 0 : a[t]).filter((a) => typeof a == "string" && a.length > 0)
+ ), o = this._config.panels[e]?.[t], l = [];
+ for (const [a, c] of Object.entries(this.hass.states)) {
+ const p = It(a, c);
+ if (!p.startsWith("sensor."))
+ continue;
+ const d = c.attributes?.unit_of_measurement, h = typeof d == "string" ? d.trim().toLowerCase() : "";
+ r(h, c) && (n.has(p) && p !== o || l.push(p));
+ }
+ return typeof o == "string" && o.length > 0 && !l.includes(o) && l.push(o), l.sort(q);
+ }
+ _getStringSensorEntityIdsByUnit(e, t) {
+ if (!this.hass)
+ return [];
+ const r = [];
+ for (const [s, n] of Object.entries(this.hass.states)) {
+ const o = It(s, n);
+ if (!o.startsWith("sensor."))
+ continue;
+ const l = n.attributes?.unit_of_measurement, a = typeof l == "string" ? l.trim().toLowerCase() : "";
+ t(a, n) && r.push(o);
+ }
+ return typeof e == "string" && e.length > 0 && !r.includes(e) && r.push(e), r.sort(q);
+ }
+ _getAdvancedSensorEntityIds(e, t, r) {
+ if (!this.hass)
+ return [];
+ const s = this._config.panels[e]?.advanced_metrics?.[t], n = [];
+ for (const [o, l] of Object.entries(this.hass.states)) {
+ const a = It(o, l);
+ a.startsWith("sensor.") && r(l) && n.push(a);
+ }
+ return typeof s == "string" && s.length > 0 && !n.includes(s) && n.push(s), n.sort(q);
+ }
+ _getEntityFriendlyName(e) {
+ const t = e?.trim();
+ if (!this.hass || !t)
+ return;
+ const s = this.hass.states[t]?.attributes?.friendly_name;
+ if (typeof s != "string")
+ return;
+ const n = s.trim();
+ return n.length > 0 ? n : void 0;
+ }
+ _shouldAutoRenamePanel(e, t, r) {
+ return Hn(
+ e,
+ t,
+ r,
+ (s) => this._getEntityFriendlyName(s)
+ );
+ }
+ _commit(e) {
+ this._config = e, Bn(this, e);
+ }
+ _isReorderOnlySync(e) {
+ if (e.type !== this._getCardType() || e.rows !== this._config.rows || e.columns !== this._config.columns || e.panels.length !== this._config.panels.length)
+ return !1;
+ const t = this._toPanelSignatureMap(this._config.panels), r = this._toPanelSignatureMap(e.panels);
+ if (t.size !== r.size)
+ return !1;
+ for (const [o, l] of t.entries())
+ if (r.get(o) !== l)
+ return !1;
+ const s = this._config.panels.map((o) => o.id).join("|"), n = e.panels.map((o) => o.id).join("|");
+ return s !== n;
+ }
+ _toPanelSignatureMap(e) {
+ const t = /* @__PURE__ */ new Map();
+ for (const r of e)
+ t.set(
+ r.id,
+ JSON.stringify({
+ name: r.name ?? "",
+ power_entity: r.power_entity ?? "",
+ energy_entity: r.energy_entity ?? "",
+ inverter_status_entity: r.inverter_status_entity ?? "",
+ error_entity: r.error_entity ?? "",
+ show_energy: r.show_energy ?? !1,
+ enabled: r.enabled ?? !0,
+ rated_power_w: r.rated_power_w ?? null,
+ deviation_derate_percent: r.deviation_derate_percent ?? 100,
+ advanced_metrics: r.advanced_metrics ?? {}
+ })
+ );
+ return t;
+ }
+};
+gt.properties = {
+ hass: { attribute: !1 },
+ _config: { state: !0 },
+ _autoFillPowerPrefix: { state: !0 },
+ _autoFillEnergyPrefix: { state: !0 },
+ _autoFillStatusPrefix: { state: !0 },
+ _autoFillSortMode: { state: !0 },
+ _autoFillInverterStatus: { state: !0 },
+ _autoFillAdvancedTelemetry: { state: !0 },
+ _autoFillResultMessage: { state: !0 }
+}, gt.styles = Ot`
+ :host {
+ display: block;
+ }
+
+ .editor {
+ display: grid;
+ gap: 20px;
+ }
+
+ .section {
+ display: grid;
+ gap: 12px;
+ padding: 16px;
+ border-radius: 18px;
+ border: 1px solid var(--divider-color);
+ background: rgba(127, 146, 171, 0.06);
+ }
+
+ .section-header {
+ display: grid;
+ gap: 4px;
+ }
+
+ .section-title {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 700;
+ }
+
+ .section-copy {
+ margin: 0;
+ color: var(--secondary-text-color);
+ font-size: 0.92rem;
+ }
+
+ .grid {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ }
+
+ .field {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+ color: var(--secondary-text-color);
+ font-size: 0.78rem;
+ font-weight: 500;
+ }
+
+ .field-label {
+ line-height: 1.2;
+ }
+
+ .text-input {
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 42px;
+ border-radius: 12px;
+ border: 1px solid var(--divider-color);
+ background: var(--card-background-color);
+ color: var(--primary-text-color);
+ padding: 0 12px;
+ font: inherit;
+ outline: none;
+ }
+
+ .text-input:focus {
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 1px var(--primary-color);
+ }
+
+ .text-input:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+ }
+
+ .panel-list {
+ display: grid;
+ gap: 12px;
+ }
+
+ details {
+ border-radius: 16px;
+ border: 1px solid var(--divider-color);
+ background: var(--card-background-color);
+ overflow: hidden;
+ }
+
+ summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 16px;
+ cursor: pointer;
+ font-weight: 600;
+ list-style: none;
+ }
+
+ summary::-webkit-details-marker {
+ display: none;
+ }
+
+ .panel-form {
+ display: grid;
+ gap: 12px;
+ padding: 0 16px 16px;
+ }
+
+ .panel-advanced {
+ border-radius: 12px;
+ border: 1px solid var(--divider-color);
+ background: rgba(127, 146, 171, 0.08);
+ overflow: hidden;
+ }
+
+ .panel-advanced summary {
+ padding: 10px 12px;
+ font-size: 0.86rem;
+ font-weight: 600;
+ }
+
+ .panel-advanced-grid {
+ display: grid;
+ gap: 10px;
+ padding: 0 12px 12px;
+ }
+
+ .toggle {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .autofill-option {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 32px;
+ color: var(--primary-text-color);
+ font-size: 0.95rem;
+ font-weight: 500;
+ cursor: pointer;
+ }
+
+ .autofill-option input {
+ width: 18px;
+ height: 18px;
+ margin: 0;
+ accent-color: var(--primary-color);
+ cursor: pointer;
+ }
+
+ .button-row {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ }
+
+ .secondary-button {
+ border: 1px solid var(--divider-color);
+ background: rgba(127, 146, 171, 0.08);
+ color: var(--primary-text-color);
+ border-radius: 12px;
+ min-height: 38px;
+ padding: 0 12px;
+ cursor: pointer;
+ font: inherit;
+ }
+
+ .color-grid {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ }
+
+ label.color-field {
+ display: grid;
+ gap: 8px;
+ font-size: 0.88rem;
+ color: var(--secondary-text-color);
+ }
+
+ input[type="color"] {
+ width: 100%;
+ height: 42px;
+ border-radius: 12px;
+ border: 1px solid var(--divider-color);
+ background: transparent;
+ padding: 4px;
+ cursor: pointer;
+ }
+
+ input[type="range"] {
+ width: 100%;
+ }
+
+ select {
+ width: 100%;
+ min-height: 42px;
+ border-radius: 12px;
+ border: 1px solid var(--divider-color);
+ background: var(--card-background-color);
+ color: var(--primary-text-color);
+ padding: 0 12px;
+ font: inherit;
+ }
+
+ .chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ font-size: 0.78rem;
+ background: rgba(127, 146, 171, 0.12);
+ color: var(--secondary-text-color);
+ }
+ `;
+let mt = gt;
+class Ps extends mt {
+}
+customElements.get("solar-panel-visualizer-card-editor") || customElements.define(
+ "solar-panel-visualizer-card-editor",
+ Ps
+);
+const Un = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
+ __proto__: null,
+ SolarPanelVisualizerBaseCardEditor: mt,
+ SolarPanelVisualizerCardEditor: Ps
+}, Symbol.toStringTag, { value: "Module" })), Er = typeof navigator < "u" ? navigator.language : "en";
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: kr,
+ name: ht(Er, "meta.card_name"),
+ description: ht(Er, "meta.card_description"),
+ icon: "mdi:solar-panel",
+ preview: !0
+});
diff --git a/src/compare-graph.ts b/src/compare-graph.ts
new file mode 100644
index 0000000..1e88e5a
--- /dev/null
+++ b/src/compare-graph.ts
@@ -0,0 +1,145 @@
+import {
+ bridgeZeroToProductionGaps,
+ downsampleSamples,
+ filterSamplesByRangeHours,
+ type PopupGraphRangeHours,
+} from "./popup-graph";
+import type { PowerSample } from "./types";
+
+interface Point2D {
+ x: number;
+ y: number;
+}
+
+export interface CompareSeriesInput {
+ id: string;
+ samples: PowerSample[];
+}
+
+export interface CompareSeriesModel {
+ id: string;
+ samples: PowerSample[];
+ points: Point2D[];
+ linePath: string;
+ firstPoint: Point2D | null;
+ lastPoint: Point2D | null;
+ sampleCount: number;
+ pointsCount: number;
+ firstTs: number | null;
+ lastTs: number | null;
+ minValue: number | null;
+ maxValue: number | null;
+}
+
+export interface CompareGraphModel {
+ startTs: number;
+ endTs: number;
+ hasData: boolean;
+ drawableCount: number;
+ series: CompareSeriesModel[];
+}
+
+const isFiniteSample = (sample: PowerSample): boolean =>
+ Number.isFinite(sample.ts) && Number.isFinite(sample.value);
+
+const buildPathFromPoints = (points: Point2D[]): string =>
+ points
+ .map((point, index) =>
+ `${index === 0 ? "M" : "L"}${point.x.toFixed(2)},${point.y.toFixed(2)}`,
+ )
+ .join(" ");
+
+export const buildCompareGraphModel = (
+ inputSeries: CompareSeriesInput[],
+ nowMs: number,
+ range: PopupGraphRangeHours,
+ maxPoints: number,
+ width = 320,
+ height = 132,
+ padding = 10,
+): CompareGraphModel => {
+ const rangeMs = range * 60 * 60 * 1000;
+ const startTs = nowMs - rangeMs;
+ const endTs = nowMs;
+ const xSpan = Math.max(endTs - startTs, 1);
+ const plotWidth = Math.max(width - padding * 2, 1);
+ const plotHeight = Math.max(height - padding * 2, 1);
+
+ const normalizedSeries = inputSeries.map((entry) => {
+ const filtered = filterSamplesByRangeHours(entry.samples, nowMs, range).filter(isFiniteSample);
+ const expanded =
+ filtered.length === 1
+ ? [
+ { ts: startTs, value: filtered[0].value },
+ { ts: endTs, value: filtered[0].value },
+ ]
+ : filtered;
+ const bridged = bridgeZeroToProductionGaps(expanded, { rangeHours: range });
+ const downsampled = downsampleSamples(bridged, maxPoints).filter(isFiniteSample);
+
+ const values = downsampled.map((sample) => sample.value);
+ return {
+ id: entry.id,
+ samples: downsampled,
+ sampleCount: downsampled.length,
+ firstTs: downsampled.length > 0 ? downsampled[0].ts : null,
+ lastTs: downsampled.length > 0 ? downsampled[downsampled.length - 1].ts : null,
+ minValue: values.length > 0 ? Math.min(...values) : null,
+ maxValue: values.length > 0 ? Math.max(...values) : null,
+ };
+ });
+
+ const allValues = normalizedSeries.flatMap((entry) => entry.samples.map((sample) => sample.value));
+ const hasData = allValues.length > 0;
+ let yMin = 0;
+ let yMax = 1;
+ if (hasData) {
+ yMin = Math.min(...allValues);
+ yMax = Math.max(...allValues);
+ if (yMin === yMax) {
+ yMin -= 1;
+ yMax += 1;
+ }
+ }
+ const ySpan = Math.max(yMax - yMin, 1);
+
+ const series: CompareSeriesModel[] = normalizedSeries.map((entry) => {
+ const points = entry.samples
+ .map((sample) => {
+ const xRatio = (sample.ts - startTs) / xSpan;
+ const x = padding + Math.min(Math.max(xRatio, 0), 1) * plotWidth;
+ const y = height - padding - ((sample.value - yMin) / ySpan) * plotHeight;
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
+ return null;
+ }
+ return { x, y };
+ })
+ .filter((point): point is Point2D => point !== null);
+
+ const linePath = points.length >= 2 ? buildPathFromPoints(points) : "";
+ return {
+ id: entry.id,
+ samples: entry.samples,
+ points,
+ linePath,
+ firstPoint: points.length > 0 ? points[0] : null,
+ lastPoint: points.length > 0 ? points[points.length - 1] : null,
+ sampleCount: entry.sampleCount,
+ pointsCount: points.length,
+ firstTs: entry.firstTs,
+ lastTs: entry.lastTs,
+ minValue: entry.minValue,
+ maxValue: entry.maxValue,
+ };
+ });
+
+ const drawableCount = series.filter((entry) => entry.pointsCount > 0).length;
+
+ return {
+ startTs,
+ endTs,
+ hasData,
+ drawableCount,
+ series,
+ };
+};
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..ce28613
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,733 @@
+import type {
+ PanelColors,
+ PanelConfig,
+ SolarPanelVisualizerConfig,
+ StringGroupConfig,
+} from "./types";
+
+export const CARD_ELEMENT_NAME = "solar-panel-visualizer";
+export const CARD_TYPE = `custom:${CARD_ELEMENT_NAME}`;
+
+export const DEFAULT_COLORS: PanelColors = {
+ production_start: "#8dcf72",
+ production_mid: "#bfe36a",
+ production_end: "#ffd35a",
+ deviation: "#ff9667",
+ error: "#ff627f",
+ unavailable: "#586779",
+};
+
+const DEFAULT_ROWS = 2;
+const DEFAULT_COLUMNS = 3;
+const DEFAULT_DEVIATION_THRESHOLD = 15;
+const DEFAULT_DEVIATION_ABSOLUTE_W_THRESHOLD = 50;
+const DEFAULT_DEVIATION_MIN_ACTIVE_PANELS = 3;
+const DEFAULT_DEVIATION_MIN_SAMPLES = 3;
+const DEFAULT_DEVIATION_MIN_RUNTIME_MINUTES = 20;
+const DEFAULT_DEVIATION_SMOOTHING_MINUTES = 0;
+const DEFAULT_DEVIATION_DYNAMIC_FLOOR_W = 30;
+const DEFAULT_DEVIATION_HISTORY_HOURS = 12;
+const DEFAULT_POWER_DECIMALS = 0;
+const DEFAULT_ENERGY_DECIMALS = 2;
+const DEFAULT_CUSTOM_KPI_DECIMALS = 0;
+const DEFAULT_LIMIT_PANEL_WIDTH = false;
+const DEFAULT_PANEL_MAX_WIDTH_PX = 220;
+const DEFAULT_MAX_CARD_WIDTH = 980;
+const DEFAULT_PRODUCTION_COLOR_INTENSITY = 1;
+const DEFAULT_ENABLE_FORECAST_OVERLAY = false;
+const DEFAULT_MOTION_ENABLED = true;
+const DEFAULT_MOTION_POWER_FLOW = true;
+const DEFAULT_MOTION_UPDATE_SHIMMER = true;
+const DEFAULT_MOTION_ALERT_RIPPLE = true;
+const DEFAULT_THEME_MODE: SolarPanelVisualizerConfig["theme_mode"] = "auto";
+export const MAX_PANEL_RATED_POWER_W = 10000;
+export const MAX_LAYOUT_DIMENSION = 30;
+const DEFAULT_INVERTER_FAULT_TERMS = ["fault", "alarm", "error", "failed", "failure", "trip"];
+const DEFAULT_INVERTER_WORKING_TERMS = [
+ "normal",
+ "ok",
+ "running",
+ "waiting",
+ "waiting for operation",
+ "producing",
+];
+
+const clampInteger = (
+ value: unknown,
+ fallback: number,
+ min = 1,
+ max = MAX_LAYOUT_DIMENSION,
+): number => {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) {
+ return fallback;
+ }
+ return Math.min(Math.max(Math.round(numeric), min), max);
+};
+
+const clampFloat = (
+ value: unknown,
+ fallback: number,
+ min = 0,
+ max = 100,
+): number => {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) {
+ return fallback;
+ }
+ return Math.min(Math.max(numeric, min), max);
+};
+
+const normalizeString = (value: unknown): string | undefined => {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+};
+
+const normalizeTrimmedString = (value: unknown): string | undefined => {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ return value.trim();
+};
+
+const normalizeBoolean = (value: unknown, fallback: boolean): boolean =>
+ typeof value === "boolean" ? value : fallback;
+
+const normalizeStringList = (
+ value: unknown,
+ fallback: string[],
+): string[] => {
+ const rawValues = Array.isArray(value)
+ ? value
+ : typeof value === "string"
+ ? value.split(",")
+ : [];
+
+ const normalized = rawValues
+ .map((item) => (typeof item === "string" ? item.trim().toLowerCase() : ""))
+ .filter((item) => item.length > 0);
+
+ if (normalized.length === 0) {
+ return [...fallback];
+ }
+
+ return [...new Set(normalized)];
+};
+
+const normalizeOptionalNumber = (
+ value: unknown,
+ min: number,
+ max: number,
+): number | undefined => {
+ if (value === undefined || value === null || value === "") {
+ return undefined;
+ }
+
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) {
+ return undefined;
+ }
+
+ return Math.min(Math.max(numeric, min), max);
+};
+
+const normalizeOptionalPositiveNumber = (
+ value: unknown,
+ min: number,
+ max: number,
+): number | undefined => {
+ if (value === undefined || value === null || value === "") {
+ return undefined;
+ }
+
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < min) {
+ return undefined;
+ }
+
+ return Math.min(numeric, max);
+};
+
+const slotId = (index: number): string => `panel-${index + 1}`;
+
+const normalizeTapAction = (value: unknown): "details" | "none" =>
+ value === "none" ? "none" : "details";
+
+const normalizeThemeMode = (value: unknown): SolarPanelVisualizerConfig["theme_mode"] =>
+ value === "dark" || value === "light" ? value : DEFAULT_THEME_MODE;
+
+const normalizeGridOptions = (
+ value: unknown,
+): SolarPanelVisualizerConfig["grid_options"] | undefined => {
+ if (typeof value !== "object" || value === null) {
+ return undefined;
+ }
+
+ const candidate = value as { columns?: unknown; rows?: unknown };
+ const normalizedColumns =
+ candidate.columns === "full" || typeof candidate.columns === "number"
+ ? candidate.columns
+ : undefined;
+ const normalizedRows =
+ candidate.rows === "auto" || typeof candidate.rows === "number"
+ ? candidate.rows
+ : undefined;
+
+ if (normalizedColumns === undefined && normalizedRows === undefined) {
+ return undefined;
+ }
+
+ return {
+ columns: normalizedColumns,
+ rows: normalizedRows,
+ };
+};
+
+const ADVANCED_METRIC_KEYS = [
+ "inverter_ac_power_entity",
+ "inverter_ac_voltage_entity",
+ "inverter_ac_current_entity",
+ "inverter_temp_entity",
+ "panel_current_entity",
+ "panel_voltage_entity",
+ "panel_power_entity",
+] as const;
+
+const normalizeAdvancedMetrics = (
+ value: unknown,
+ enabled: boolean,
+): PanelConfig["advanced_metrics"] | undefined => {
+ if (!enabled || typeof value !== "object" || value === null) {
+ return undefined;
+ }
+
+ const candidate = value as Record;
+ const normalized: PanelConfig["advanced_metrics"] = {};
+ let hasAny = false;
+
+ for (const key of ADVANCED_METRIC_KEYS) {
+ const normalizedValue = normalizeString(candidate[key]);
+ normalized[key] = normalizedValue;
+ if (normalizedValue) {
+ hasAny = true;
+ }
+ }
+
+ if (!hasAny) {
+ return undefined;
+ }
+
+ return normalized;
+};
+
+const normalizePanel = (panel: Partial | undefined, index: number): PanelConfig => {
+ const enabled = normalizeBoolean(panel?.enabled, true);
+ return {
+ id: normalizeString(panel?.id) ?? slotId(index),
+ name: normalizeString(panel?.name),
+ power_entity: enabled ? normalizeString(panel?.power_entity) : undefined,
+ energy_entity: enabled ? normalizeString(panel?.energy_entity) : undefined,
+ show_energy: normalizeBoolean(panel?.show_energy, false),
+ inverter_status_entity: enabled
+ ? normalizeString(panel?.inverter_status_entity) ??
+ normalizeString(panel?.error_entity)
+ : undefined,
+ error_entity: enabled ? normalizeString(panel?.error_entity) : undefined,
+ advanced_metrics: normalizeAdvancedMetrics(panel?.advanced_metrics, enabled),
+ enabled,
+ rated_power_w: normalizeOptionalPositiveNumber(panel?.rated_power_w, 1, MAX_PANEL_RATED_POWER_W),
+ deviation_derate_percent: clampFloat(panel?.deviation_derate_percent, 100, 1, 100),
+ };
+};
+
+const stringGroupId = (index: number): string => `string-${index + 1}`;
+
+const normalizeStringGroup = (
+ group: Partial | undefined,
+ index: number,
+): StringGroupConfig => ({
+ id: normalizeString(group?.id) ?? stringGroupId(index),
+ name: normalizeString(group?.name),
+ panel_count: clampInteger(
+ group?.panel_count,
+ DEFAULT_COLUMNS,
+ 1,
+ MAX_LAYOUT_DIMENSION,
+ ),
+ power_entity: normalizeString(group?.power_entity),
+ energy_entity: normalizeString(group?.energy_entity),
+});
+
+const normalizeStringGroups = (value: unknown): StringGroupConfig[] => {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ return value
+ .slice(0, MAX_LAYOUT_DIMENSION)
+ .map((group, index) =>
+ normalizeStringGroup(
+ typeof group === "object" && group !== null
+ ? (group as Partial)
+ : undefined,
+ index,
+ ),
+ );
+};
+
+const generatedStringPanelNameRegex = /^string\s+\d+\s+panel\s+\d+$/i;
+const generatedDefaultPanelNameRegex = /^panel\s+\d+$/i;
+
+const isGeneratedStringPanelName = (value: string | undefined): boolean =>
+ typeof value === "string" &&
+ (generatedStringPanelNameRegex.test(value.trim()) ||
+ generatedDefaultPanelNameRegex.test(value.trim()));
+
+export const syncPanelSlots = (
+ rows: number,
+ columns: number,
+ panels: Partial[] = [],
+): PanelConfig[] => {
+ const count = rows * columns;
+ return Array.from({ length: count }, (_, index) =>
+ normalizePanel(panels[index], index),
+ );
+};
+
+const syncStringGroupPanelSlots = (
+ stringGroups: StringGroupConfig[],
+ columns: number,
+ panels: Partial[] = [],
+ previousColumns = columns,
+): PanelConfig[] => {
+ const rows = stringGroups.length;
+ const count = rows * columns;
+ const normalizedPreviousColumns = clampInteger(
+ previousColumns,
+ columns,
+ 1,
+ MAX_LAYOUT_DIMENSION,
+ );
+ return Array.from({ length: count }, (_, index) => {
+ const rowIndex = Math.floor(index / columns);
+ const columnIndex = index % columns;
+ const previousIndex = rowIndex * normalizedPreviousColumns + columnIndex;
+ const group = stringGroups[rowIndex];
+ const base =
+ columnIndex < normalizedPreviousColumns
+ ? panels[previousIndex] ?? {}
+ : {};
+ const generatedName = `${group?.name ?? `String ${rowIndex + 1}`} Panel ${columnIndex + 1}`;
+ const baseName = normalizeString(base.name);
+
+ if (!group || columnIndex >= group.panel_count) {
+ return normalizePanel(
+ {
+ ...base,
+ name:
+ baseName && !isGeneratedStringPanelName(baseName)
+ ? baseName
+ : undefined,
+ enabled: false,
+ power_entity: undefined,
+ energy_entity: undefined,
+ show_energy: false,
+ },
+ index,
+ );
+ }
+
+ return normalizePanel(
+ {
+ ...base,
+ name:
+ baseName && !isGeneratedStringPanelName(baseName)
+ ? baseName
+ : generatedName,
+ enabled: true,
+ power_entity: group.power_entity,
+ energy_entity: group.energy_entity,
+ show_energy: group.energy_entity
+ ? true
+ : normalizeBoolean(base.show_energy, false),
+ },
+ index,
+ );
+ });
+};
+
+export const buildStubConfig = (
+ rows = DEFAULT_ROWS,
+ columns = DEFAULT_COLUMNS,
+): SolarPanelVisualizerConfig => ({
+ type: CARD_TYPE,
+ title: "Solar Array",
+ theme_mode: DEFAULT_THEME_MODE,
+ rows,
+ columns,
+ panels: syncPanelSlots(rows, columns),
+ enable_inverter_status: false,
+ enable_string_sensor_sharing: false,
+ string_groups: [],
+ inverter_fault_terms: [...DEFAULT_INVERTER_FAULT_TERMS],
+ inverter_working_terms: [...DEFAULT_INVERTER_WORKING_TERMS],
+ show_inverter_status_on_tiles: false,
+ enable_array_checks: false,
+ deviation_threshold_percent: DEFAULT_DEVIATION_THRESHOLD,
+ deviation_absolute_w_threshold: DEFAULT_DEVIATION_ABSOLUTE_W_THRESHOLD,
+ deviation_min_active_panels: DEFAULT_DEVIATION_MIN_ACTIVE_PANELS,
+ deviation_min_samples: DEFAULT_DEVIATION_MIN_SAMPLES,
+ deviation_min_runtime_minutes: DEFAULT_DEVIATION_MIN_RUNTIME_MINUTES,
+ deviation_smoothing_minutes: DEFAULT_DEVIATION_SMOOTHING_MINUTES,
+ deviation_dynamic_floor_w: DEFAULT_DEVIATION_DYNAMIC_FLOOR_W,
+ deviation_history_hours: DEFAULT_DEVIATION_HISTORY_HOURS,
+ colors: DEFAULT_COLORS,
+ production_color_intensity: DEFAULT_PRODUCTION_COLOR_INTENSITY,
+ show_energy: true,
+ use_system_power_entity: false,
+ system_power_entity: undefined,
+ invert_system_power: false,
+ use_system_energy_entity: false,
+ system_energy_entity: undefined,
+ enable_forecast_overlay: DEFAULT_ENABLE_FORECAST_OVERLAY,
+ motion_enabled: DEFAULT_MOTION_ENABLED,
+ motion_power_flow: DEFAULT_MOTION_POWER_FLOW,
+ motion_update_shimmer: DEFAULT_MOTION_UPDATE_SHIMMER,
+ motion_alert_ripple: DEFAULT_MOTION_ALERT_RIPPLE,
+ show_custom_kpi: true,
+ custom_kpi_title: "Custom KPI",
+ custom_kpi_entity: undefined,
+ custom_kpi_decimals: DEFAULT_CUSTOM_KPI_DECIMALS,
+ invert_custom_kpi: false,
+ panel_tap_action: "details",
+ power_decimals: DEFAULT_POWER_DECIMALS,
+ energy_decimals: DEFAULT_ENERGY_DECIMALS,
+ limit_panel_width: DEFAULT_LIMIT_PANEL_WIDTH,
+ panel_max_width_px: DEFAULT_PANEL_MAX_WIDTH_PX,
+ max_card_width_px: DEFAULT_MAX_CARD_WIDTH,
+});
+
+type SolarPanelVisualizerConfigInput = Omit<
+ Partial,
+ "inverter_fault_terms" | "inverter_working_terms"
+> & {
+ inverter_fault_terms?: string[] | string;
+ inverter_working_terms?: string[] | string;
+};
+
+export const normalizeConfig = (
+ config: SolarPanelVisualizerConfigInput = {},
+): SolarPanelVisualizerConfig => {
+ const enableStringSensorSharing = normalizeBoolean(
+ config.enable_string_sensor_sharing,
+ false,
+ );
+ const stringGroups = normalizeStringGroups(config.string_groups);
+ const useStringGroups = enableStringSensorSharing && stringGroups.length > 0;
+ const rows = useStringGroups
+ ? stringGroups.length
+ : clampInteger(config.rows, DEFAULT_ROWS);
+ const columns = useStringGroups
+ ? Math.max(...stringGroups.map((group) => group.panel_count), 1)
+ : clampInteger(config.columns, DEFAULT_COLUMNS);
+ const colors = {
+ ...DEFAULT_COLORS,
+ ...(config.colors ?? {}),
+ };
+
+ return {
+ type: normalizeString(config.type) ?? CARD_TYPE,
+ title: normalizeString(config.title),
+ theme_mode: normalizeThemeMode(config.theme_mode),
+ rows,
+ columns,
+ panels: useStringGroups
+ ? syncStringGroupPanelSlots(
+ stringGroups,
+ columns,
+ config.panels ?? [],
+ clampInteger(config.columns, DEFAULT_COLUMNS),
+ )
+ : syncPanelSlots(rows, columns, config.panels ?? []),
+ enable_inverter_status: normalizeBoolean(config.enable_inverter_status, false),
+ enable_string_sensor_sharing: enableStringSensorSharing,
+ string_groups: stringGroups,
+ inverter_fault_terms: normalizeStringList(
+ config.inverter_fault_terms,
+ DEFAULT_INVERTER_FAULT_TERMS,
+ ),
+ inverter_working_terms: normalizeStringList(
+ config.inverter_working_terms,
+ DEFAULT_INVERTER_WORKING_TERMS,
+ ),
+ show_inverter_status_on_tiles: normalizeBoolean(
+ config.show_inverter_status_on_tiles,
+ false,
+ ),
+ enable_array_checks: normalizeBoolean(config.enable_array_checks, false),
+ deviation_threshold_percent: clampFloat(
+ config.deviation_threshold_percent,
+ DEFAULT_DEVIATION_THRESHOLD,
+ 1,
+ 100,
+ ),
+ deviation_absolute_w_threshold: clampFloat(
+ config.deviation_absolute_w_threshold,
+ DEFAULT_DEVIATION_ABSOLUTE_W_THRESHOLD,
+ 0,
+ 5000,
+ ),
+ deviation_min_active_panels: clampInteger(
+ config.deviation_min_active_panels,
+ DEFAULT_DEVIATION_MIN_ACTIVE_PANELS,
+ 2,
+ 30,
+ ),
+ deviation_min_samples: clampInteger(
+ config.deviation_min_samples,
+ DEFAULT_DEVIATION_MIN_SAMPLES,
+ 1,
+ 120,
+ ),
+ deviation_min_runtime_minutes: clampInteger(
+ config.deviation_min_runtime_minutes,
+ DEFAULT_DEVIATION_MIN_RUNTIME_MINUTES,
+ 0,
+ 1440,
+ ),
+ deviation_smoothing_minutes: clampInteger(
+ config.deviation_smoothing_minutes,
+ DEFAULT_DEVIATION_SMOOTHING_MINUTES,
+ 0,
+ 1440,
+ ),
+ deviation_dynamic_floor_w: clampFloat(
+ config.deviation_dynamic_floor_w,
+ DEFAULT_DEVIATION_DYNAMIC_FLOOR_W,
+ 0,
+ 5000,
+ ),
+ deviation_restart_entity: normalizeString(config.deviation_restart_entity),
+ deviation_history_hours: clampInteger(
+ config.deviation_history_hours,
+ DEFAULT_DEVIATION_HISTORY_HOURS,
+ 1,
+ 168,
+ ),
+ colors,
+ production_color_intensity: clampFloat(
+ config.production_color_intensity,
+ DEFAULT_PRODUCTION_COLOR_INTENSITY,
+ 0.2,
+ 1.6,
+ ),
+ show_energy: true,
+ use_system_power_entity: normalizeBoolean(config.use_system_power_entity, false),
+ system_power_entity: normalizeString(config.system_power_entity),
+ invert_system_power: normalizeBoolean(config.invert_system_power, false),
+ use_system_energy_entity: normalizeBoolean(config.use_system_energy_entity, false),
+ system_energy_entity: normalizeString(config.system_energy_entity),
+ enable_forecast_overlay: normalizeBoolean(
+ config.enable_forecast_overlay,
+ DEFAULT_ENABLE_FORECAST_OVERLAY,
+ ),
+ motion_enabled: normalizeBoolean(config.motion_enabled, DEFAULT_MOTION_ENABLED),
+ motion_power_flow: normalizeBoolean(
+ config.motion_power_flow,
+ DEFAULT_MOTION_POWER_FLOW,
+ ),
+ motion_update_shimmer: normalizeBoolean(
+ config.motion_update_shimmer,
+ DEFAULT_MOTION_UPDATE_SHIMMER,
+ ),
+ motion_alert_ripple: normalizeBoolean(
+ config.motion_alert_ripple,
+ DEFAULT_MOTION_ALERT_RIPPLE,
+ ),
+ show_custom_kpi: normalizeBoolean(config.show_custom_kpi, true),
+ custom_kpi_title: normalizeTrimmedString(config.custom_kpi_title) ?? "Custom KPI",
+ custom_kpi_entity: normalizeString(config.custom_kpi_entity),
+ custom_kpi_decimals: clampInteger(
+ config.custom_kpi_decimals,
+ DEFAULT_CUSTOM_KPI_DECIMALS,
+ 0,
+ 4,
+ ),
+ invert_custom_kpi: normalizeBoolean(config.invert_custom_kpi, false),
+ panel_tap_action: normalizeTapAction(config.panel_tap_action),
+ power_decimals: clampInteger(
+ config.power_decimals,
+ DEFAULT_POWER_DECIMALS,
+ 0,
+ 4,
+ ),
+ energy_decimals: clampInteger(
+ config.energy_decimals,
+ DEFAULT_ENERGY_DECIMALS,
+ 0,
+ 4,
+ ),
+ limit_panel_width: normalizeBoolean(
+ config.limit_panel_width,
+ DEFAULT_LIMIT_PANEL_WIDTH,
+ ),
+ panel_max_width_px: clampInteger(
+ config.panel_max_width_px,
+ DEFAULT_PANEL_MAX_WIDTH_PX,
+ 120,
+ 320,
+ ),
+ default_panel_rated_power_w: normalizeOptionalPositiveNumber(
+ config.default_panel_rated_power_w,
+ 1,
+ MAX_PANEL_RATED_POWER_W,
+ ),
+ max_card_width_px:
+ normalizeOptionalNumber(config.max_card_width_px, 300, 2400) ??
+ DEFAULT_MAX_CARD_WIDTH,
+ max_card_height_px: normalizeOptionalNumber(config.max_card_height_px, 300, 2600),
+ grid_options: normalizeGridOptions(config.grid_options),
+ };
+};
+
+export const validateConfig = (
+ config: Partial,
+): string[] => {
+ const errors: string[] = [];
+
+ if (typeof config !== "object" || config === null) {
+ return ["Configuration must be an object."];
+ }
+
+ if (
+ config.rows !== undefined &&
+ (!Number.isFinite(Number(config.rows)) || Number(config.rows) < 1)
+ ) {
+ errors.push("`rows` must be a positive number.");
+ }
+
+ if (
+ config.columns !== undefined &&
+ (!Number.isFinite(Number(config.columns)) || Number(config.columns) < 1)
+ ) {
+ errors.push("`columns` must be a positive number.");
+ }
+
+ if (
+ config.max_card_width_px !== undefined &&
+ !Number.isFinite(Number(config.max_card_width_px))
+ ) {
+ errors.push("`max_card_width_px` must be a number if set.");
+ }
+
+ if (
+ config.panel_max_width_px !== undefined &&
+ (!Number.isFinite(Number(config.panel_max_width_px)) ||
+ Number(config.panel_max_width_px) < 120 ||
+ Number(config.panel_max_width_px) > 320)
+ ) {
+ errors.push("`panel_max_width_px` must be between 120 and 320.");
+ }
+
+ if (
+ config.max_card_height_px !== undefined &&
+ !Number.isFinite(Number(config.max_card_height_px))
+ ) {
+ errors.push("`max_card_height_px` must be a number if set.");
+ }
+
+ if (
+ config.custom_kpi_decimals !== undefined &&
+ (!Number.isFinite(Number(config.custom_kpi_decimals)) ||
+ Number(config.custom_kpi_decimals) < 0 ||
+ Number(config.custom_kpi_decimals) > 4)
+ ) {
+ errors.push("`custom_kpi_decimals` must be between 0 and 4.");
+ }
+
+ if (
+ config.production_color_intensity !== undefined &&
+ (!Number.isFinite(Number(config.production_color_intensity)) ||
+ Number(config.production_color_intensity) < 0.2 ||
+ Number(config.production_color_intensity) > 1.6)
+ ) {
+ errors.push("`production_color_intensity` must be between 0.2 and 1.6.");
+ }
+
+ if (
+ config.deviation_absolute_w_threshold !== undefined &&
+ (!Number.isFinite(Number(config.deviation_absolute_w_threshold)) ||
+ Number(config.deviation_absolute_w_threshold) < 0)
+ ) {
+ errors.push("`deviation_absolute_w_threshold` must be 0 or higher.");
+ }
+
+ if (
+ config.deviation_min_active_panels !== undefined &&
+ (!Number.isFinite(Number(config.deviation_min_active_panels)) ||
+ Number(config.deviation_min_active_panels) < 2)
+ ) {
+ errors.push("`deviation_min_active_panels` must be 2 or higher.");
+ }
+
+ if (
+ config.deviation_min_samples !== undefined &&
+ (!Number.isFinite(Number(config.deviation_min_samples)) ||
+ Number(config.deviation_min_samples) < 1)
+ ) {
+ errors.push("`deviation_min_samples` must be 1 or higher.");
+ }
+
+ if (
+ config.deviation_min_runtime_minutes !== undefined &&
+ (!Number.isFinite(Number(config.deviation_min_runtime_minutes)) ||
+ Number(config.deviation_min_runtime_minutes) < 0)
+ ) {
+ errors.push("`deviation_min_runtime_minutes` must be 0 or higher.");
+ }
+
+ if (
+ config.deviation_smoothing_minutes !== undefined &&
+ (!Number.isFinite(Number(config.deviation_smoothing_minutes)) ||
+ Number(config.deviation_smoothing_minutes) < 0)
+ ) {
+ errors.push("`deviation_smoothing_minutes` must be 0 or higher.");
+ }
+
+ if (
+ config.deviation_dynamic_floor_w !== undefined &&
+ (!Number.isFinite(Number(config.deviation_dynamic_floor_w)) ||
+ Number(config.deviation_dynamic_floor_w) < 0)
+ ) {
+ errors.push("`deviation_dynamic_floor_w` must be 0 or higher.");
+ }
+
+ if (
+ config.deviation_history_hours !== undefined &&
+ (!Number.isFinite(Number(config.deviation_history_hours)) ||
+ Number(config.deviation_history_hours) < 1)
+ ) {
+ errors.push("`deviation_history_hours` must be 1 or higher.");
+ }
+
+ if (Array.isArray(config.panels)) {
+ config.panels.forEach((panel, index) => {
+ const derate = panel?.deviation_derate_percent;
+ if (
+ derate !== undefined &&
+ (!Number.isFinite(Number(derate)) ||
+ Number(derate) < 1 ||
+ Number(derate) > 100)
+ ) {
+ errors.push(
+ `\`panels[${index}].deviation_derate_percent\` must be between 1 and 100.`,
+ );
+ }
+ });
+ }
+
+ return errors;
+};
diff --git a/src/custom-kpi.ts b/src/custom-kpi.ts
new file mode 100644
index 0000000..5a1777e
--- /dev/null
+++ b/src/custom-kpi.ts
@@ -0,0 +1,26 @@
+const normalizeNegativeZero = (value: number): number =>
+ Object.is(value, -0) ? 0 : value;
+
+const clampDecimals = (value: number): number =>
+ Math.min(Math.max(Math.round(value), 0), 4);
+
+export const formatCustomKpiStateValue = (
+ state: string,
+ locale: string,
+ decimals: number,
+ unit?: string,
+): string | null => {
+ const numeric = Number(state);
+ if (!Number.isFinite(numeric)) {
+ return null;
+ }
+
+ const safeDecimals = clampDecimals(decimals);
+ const formatted = new Intl.NumberFormat(locale, {
+ minimumFractionDigits: safeDecimals,
+ maximumFractionDigits: safeDecimals,
+ }).format(normalizeNegativeZero(numeric));
+
+ const safeUnit = unit?.trim();
+ return safeUnit ? `${formatted} ${safeUnit}` : formatted;
+};
diff --git a/src/editor-autofill.ts b/src/editor-autofill.ts
new file mode 100644
index 0000000..a1fb2c9
--- /dev/null
+++ b/src/editor-autofill.ts
@@ -0,0 +1,1316 @@
+import type { HassEntity, PanelAdvancedMetrics, PanelConfig } from "./types";
+import { naturalCompare } from "./natural-sort";
+
+export interface SensorCandidate {
+ entityId: string;
+ friendlyName?: string;
+}
+
+export interface AutoPopulateResult {
+ panels: PanelConfig[];
+ matched: number;
+ filled: number;
+ skipped: number;
+}
+
+export interface AdvancedTelemetryAutoFillResult {
+ panels: PanelConfig[];
+ filled: number;
+ skippedPanels: number;
+}
+
+export interface InverterStatusAutoFillResult {
+ panels: PanelConfig[];
+ filled: number;
+ skippedPanels: number;
+}
+
+type FriendlyNameResolver = (entityId: string | undefined) => string | undefined;
+export type AutoPopulateSortMode = "auto" | "entityId" | "friendlyName" | "grouped";
+
+interface AutoPopulateEntityOptions {
+ repeatEachCandidate?: number;
+}
+
+interface AutoPopulateEnergyOptions {
+ shareSingleGroupEnergy?: boolean;
+}
+type SensorTextSortMode = "entityId" | "friendlyName";
+type AdvancedMetricKey = keyof PanelAdvancedMetrics;
+
+interface GroupSortPlan {
+ sortBy: SensorTextSortMode;
+ tokenIndex: number;
+}
+
+interface AdvancedTelemetryCandidate {
+ entityId: string;
+ entity: HassEntity;
+ tokens: string[];
+}
+
+const SORT_TOKEN_SEPARATOR = /[._\-\s]+/;
+const NUMERIC_TOKEN_REGEX = /^\d+$/;
+const SERIAL_LIKE_TOKEN_REGEX = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i;
+const NUMERIC_PREFIX_SERIAL_TOKEN_REGEX = /^(?=.*[a-z])\d{3,}[a-z0-9]*$/i;
+const SHORT_LOCAL_CHANNEL_TOKEN_REGEX = /^[a-z]+\d{1,2}$/i;
+const GENERIC_TELEMETRY_TOKENS = new Set([
+ "sensor",
+ "power",
+ "current",
+ "voltage",
+ "temperature",
+ "temp",
+ "device",
+ "serial",
+ "inverter",
+ "panel",
+ "map",
+ "ac",
+ "dc",
+ "pv",
+]);
+const INVERTER_STATUS_TOKENS = new Set([
+ "status",
+ "state",
+ "mode",
+ "operation",
+ "operating",
+ "work",
+ "working",
+ "run",
+ "running",
+ "fault",
+ "alarm",
+ "error",
+]);
+const STATUS_ENTITY_DOMAINS = new Set(["sensor", "binary_sensor"]);
+const LESS_SPECIFIC_STATUS_TOKENS = new Set([
+ "battery",
+ "bms",
+ "cloud",
+ "connection",
+ "communication",
+ "firmware",
+ "grid",
+ "meter",
+ "network",
+ "online",
+ "update",
+ "wifi",
+]);
+
+const getCandidateSortText = (
+ candidate: SensorCandidate,
+ sortBy: SensorTextSortMode,
+): string => {
+ if (sortBy === "friendlyName") {
+ const friendlyName = candidate.friendlyName?.trim();
+ if (friendlyName) {
+ return friendlyName;
+ }
+ }
+
+ return candidate.entityId;
+};
+
+const tokenizeSortText = (value: string): string[] =>
+ value
+ .split(SORT_TOKEN_SEPARATOR)
+ .map((token) => token.trim())
+ .filter((token) => token.length > 0);
+
+const getGroupSortPlan = (
+ candidates: SensorCandidate[],
+ sortBy: SensorTextSortMode,
+ options: { allowNumericGroup?: boolean; allowSingleGroup?: boolean; minCandidates?: number } = {},
+): GroupSortPlan | null => {
+ if (candidates.length < (options.minCandidates ?? 4)) {
+ return null;
+ }
+
+ const tokenRows = candidates.map((candidate) =>
+ tokenizeSortText(getCandidateSortText(candidate, sortBy)),
+ );
+ const minTokenCount = Math.min(...tokenRows.map((row) => row.length));
+ if (minTokenCount < 3) {
+ return null;
+ }
+
+ for (let index = minTokenCount - 1; index > 0; index -= 1) {
+ const values = tokenRows.map((row) => row[index].toLowerCase());
+ const distinctValues = new Set(values);
+ if (
+ (!options.allowSingleGroup && distinctValues.size <= 1) ||
+ distinctValues.size >= candidates.length
+ ) {
+ continue;
+ }
+ if ([...distinctValues].every((value) => GENERIC_TELEMETRY_TOKENS.has(value))) {
+ continue;
+ }
+ if (
+ !options.allowNumericGroup &&
+ [...distinctValues].every((value) => NUMERIC_TOKEN_REGEX.test(value))
+ ) {
+ continue;
+ }
+
+ const valueCounts = new Map();
+ for (const value of values) {
+ valueCounts.set(value, (valueCounts.get(value) ?? 0) + 1);
+ }
+ if (![...valueCounts.values()].some((count) => count > 1)) {
+ continue;
+ }
+
+ const previousValues = new Set(
+ tokenRows.map((row) => row[index - 1].toLowerCase()),
+ );
+ if (previousValues.size !== 1) {
+ continue;
+ }
+
+ return { sortBy, tokenIndex: index };
+ }
+
+ return null;
+};
+
+const isTrailingSerialGroupToken = (token: string): boolean => {
+ const normalized = token.toLowerCase();
+ if (
+ GENERIC_TELEMETRY_TOKENS.has(normalized) ||
+ NUMERIC_TOKEN_REGEX.test(normalized) ||
+ SHORT_LOCAL_CHANNEL_TOKEN_REGEX.test(normalized)
+ ) {
+ return false;
+ }
+
+ return (
+ SERIAL_LIKE_TOKEN_REGEX.test(normalized) ||
+ NUMERIC_PREFIX_SERIAL_TOKEN_REGEX.test(normalized)
+ );
+};
+
+const getTrailingSerialGroupSortPlan = (
+ candidates: SensorCandidate[],
+ sortBy: SensorTextSortMode,
+ options: { allowSingleGroup?: boolean; minCandidates?: number } = {},
+): GroupSortPlan | null => {
+ if (candidates.length < (options.minCandidates ?? 4)) {
+ return null;
+ }
+
+ const tokenRows = candidates.map((candidate) =>
+ tokenizeSortText(getCandidateSortText(candidate, sortBy)),
+ );
+ const minTokenCount = Math.min(...tokenRows.map((row) => row.length));
+ if (minTokenCount < 3) {
+ return null;
+ }
+
+ const tokenIndex = minTokenCount - 1;
+ const values = tokenRows.map((row) => row[tokenIndex].toLowerCase());
+ const distinctValues = new Set(values);
+ if (
+ (!options.allowSingleGroup && distinctValues.size <= 1) ||
+ distinctValues.size >= candidates.length
+ ) {
+ return null;
+ }
+ if (![...distinctValues].every(isTrailingSerialGroupToken)) {
+ return null;
+ }
+
+ const valueCounts = new Map();
+ for (const value of values) {
+ valueCounts.set(value, (valueCounts.get(value) ?? 0) + 1);
+ }
+ if (![...valueCounts.values()].some((count) => count > 1)) {
+ return null;
+ }
+
+ return { sortBy, tokenIndex };
+};
+
+const getBestGroupSortPlan = (
+ candidates: SensorCandidate[],
+ options: { allowNumericGroup?: boolean; allowSingleGroup?: boolean; minCandidates?: number } = {},
+): GroupSortPlan | null => {
+ const sortSources: SensorTextSortMode[] = ["entityId", "friendlyName"];
+ for (const sortBy of sortSources) {
+ const trailingSerialPlan = getTrailingSerialGroupSortPlan(candidates, sortBy, options);
+ if (trailingSerialPlan) {
+ return trailingSerialPlan;
+ }
+ const plan = getGroupSortPlan(candidates, sortBy, options);
+ if (plan) {
+ return plan;
+ }
+ }
+
+ return null;
+};
+
+const sortByText = (
+ candidates: SensorCandidate[],
+ sortBy: SensorTextSortMode,
+): SensorCandidate[] =>
+ [...candidates].sort((a, b) => {
+ const primaryResult = naturalCompare(
+ getCandidateSortText(a, sortBy),
+ getCandidateSortText(b, sortBy),
+ );
+ if (primaryResult !== 0) {
+ return primaryResult;
+ }
+
+ return naturalCompare(a.entityId, b.entityId);
+ });
+
+const sortByGroupedToken = (
+ candidates: SensorCandidate[],
+ fallbackSortBy: SensorTextSortMode,
+ options: { allowNumericGroup?: boolean } = {},
+): SensorCandidate[] => {
+ const plan = getBestGroupSortPlan(candidates, options);
+ if (!plan) {
+ return sortByText(candidates, fallbackSortBy);
+ }
+
+ return [...candidates].sort((a, b) => {
+ const aTokens = tokenizeSortText(getCandidateSortText(a, plan.sortBy));
+ const bTokens = tokenizeSortText(getCandidateSortText(b, plan.sortBy));
+ const aGroup = aTokens[plan.tokenIndex] ?? "";
+ const bGroup = bTokens[plan.tokenIndex] ?? "";
+
+ const groupResult = naturalCompare(aGroup, bGroup);
+ if (groupResult !== 0) {
+ return groupResult;
+ }
+
+ const aLocal = aTokens
+ .filter((_, index) => index !== plan.tokenIndex)
+ .join(" ");
+ const bLocal = bTokens
+ .filter((_, index) => index !== plan.tokenIndex)
+ .join(" ");
+ const localResult = naturalCompare(aLocal, bLocal);
+ if (localResult !== 0) {
+ return localResult;
+ }
+
+ return naturalCompare(a.entityId, b.entityId);
+ });
+};
+
+export const sortSensorsForAutoPopulate = (
+ candidates: SensorCandidate[],
+ sortMode: AutoPopulateSortMode = "auto",
+ defaultSortBy: SensorTextSortMode = "entityId",
+): SensorCandidate[] => {
+ const sorted =
+ sortMode === "grouped"
+ ? sortByGroupedToken(candidates, defaultSortBy, { allowNumericGroup: true })
+ : sortMode === "auto" && getBestGroupSortPlan(candidates)
+ ? sortByGroupedToken(candidates, defaultSortBy)
+ : sortByText(
+ candidates,
+ sortMode === "friendlyName" || sortMode === "entityId"
+ ? sortMode
+ : defaultSortBy,
+ );
+
+ return sorted
+ .map((candidate) => ({
+ entityId: candidate.entityId,
+ friendlyName: candidate.friendlyName,
+ }));
+};
+
+const collectSensorsByPrefix = (
+ states: Record,
+ prefix: string,
+ unitMatcher: (normalizedUnit: string, entity: HassEntity) => boolean,
+ sortMode: AutoPopulateSortMode,
+): SensorCandidate[] => {
+ const normalizedPrefix = prefix.trim().replace(/\*+$/, "");
+ if (normalizedPrefix.length === 0) {
+ return [];
+ }
+ const searchByEntityId = normalizedPrefix.startsWith("sensor.");
+ const normalizedFriendlySearch = normalizedPrefix.toLowerCase();
+
+ const candidates: SensorCandidate[] = [];
+
+ for (const [entityId, entity] of Object.entries(states)) {
+ if (!entityId.startsWith("sensor.")) {
+ continue;
+ }
+
+ const unitRaw = entity.attributes?.unit_of_measurement;
+ const normalizedUnit = typeof unitRaw === "string" ? unitRaw.trim().toLowerCase() : "";
+ if (!unitMatcher(normalizedUnit, entity)) {
+ continue;
+ }
+
+ const friendlyName =
+ typeof entity.attributes?.friendly_name === "string"
+ ? entity.attributes.friendly_name
+ : undefined;
+ const normalizedFriendlyName = friendlyName?.trim().toLowerCase();
+ const matchesSearch = searchByEntityId
+ ? entityId.startsWith(normalizedPrefix)
+ : Boolean(normalizedFriendlyName?.includes(normalizedFriendlySearch));
+ if (!matchesSearch) {
+ continue;
+ }
+
+ candidates.push({
+ entityId,
+ friendlyName,
+ });
+ }
+
+ return sortSensorsForAutoPopulate(
+ candidates,
+ sortMode,
+ searchByEntityId ? "entityId" : "friendlyName",
+ );
+};
+
+export const autoPopulatePanelEntities = (
+ panels: PanelConfig[],
+ states: Record,
+ key: "power_entity" | "energy_entity",
+ prefix: string,
+ unitMatcher: (normalizedUnit: string, entity: HassEntity) => boolean,
+ sortMode: AutoPopulateSortMode = "auto",
+ options: AutoPopulateEntityOptions = {},
+): AutoPopulateResult => {
+ const normalizedPrefix = prefix.trim().replace(/\*+$/, "");
+ if (normalizedPrefix.length === 0) {
+ return {
+ panels: [...panels],
+ matched: 0,
+ filled: 0,
+ skipped: panels.length,
+ };
+ }
+
+ const candidates = collectSensorsByPrefix(
+ states,
+ normalizedPrefix,
+ unitMatcher,
+ sortMode,
+ );
+ const usedByExisting = new Set(
+ panels
+ .map((panel) => panel[key])
+ .filter((entityId): entityId is string => typeof entityId === "string" && entityId.length > 0),
+ );
+
+ const repeatEachCandidate = Math.max(
+ 1,
+ Math.floor(options.repeatEachCandidate ?? 1),
+ );
+ const availableEntityIds = candidates
+ .flatMap((candidate) =>
+ Array.from({ length: repeatEachCandidate }, () => candidate.entityId),
+ )
+ .filter((entityId) => !usedByExisting.has(entityId));
+
+ let candidateIndex = 0;
+ let filled = 0;
+ let skipped = 0;
+
+ const nextPanels = panels.map((panel) => {
+ const nextPanel = { ...panel };
+ if (nextPanel.enabled === false) {
+ skipped += 1;
+ return nextPanel;
+ }
+ const existing = nextPanel[key];
+ if (typeof existing === "string" && existing.trim().length > 0) {
+ skipped += 1;
+ return nextPanel;
+ }
+
+ const entityId = availableEntityIds[candidateIndex];
+ if (!entityId) {
+ skipped += 1;
+ return nextPanel;
+ }
+
+ nextPanel[key] = entityId;
+ candidateIndex += 1;
+ filled += 1;
+ return nextPanel;
+ });
+
+ return {
+ panels: nextPanels,
+ matched: candidates.length,
+ filled,
+ skipped,
+ };
+};
+
+export const autoPopulateEnergyEntitiesByPowerAnchor = (
+ panels: PanelConfig[],
+ states: Record,
+ prefix: string,
+ unitMatcher: (normalizedUnit: string, entity: HassEntity) => boolean,
+ sortMode: AutoPopulateSortMode = "auto",
+ options: AutoPopulateEnergyOptions = {},
+): AutoPopulateResult => {
+ const normalizedPrefix = prefix.trim().replace(/\*+$/, "");
+ if (normalizedPrefix.length === 0) {
+ return {
+ panels: [...panels],
+ matched: 0,
+ filled: 0,
+ skipped: panels.length,
+ };
+ }
+
+ const candidates = collectSensorsByPrefix(
+ states,
+ normalizedPrefix,
+ unitMatcher,
+ sortMode,
+ );
+ const powerAnchorCandidates = panels
+ .filter((panel) => panel.power_entity?.trim())
+ .map((panel) => {
+ const powerEntity = panel.power_entity ? states[panel.power_entity] : undefined;
+ return {
+ entityId: panel.power_entity ?? "",
+ friendlyName: getEntityFriendlyName(powerEntity),
+ };
+ });
+ const groupPlan = getBestGroupSortPlan(powerAnchorCandidates, {
+ allowNumericGroup: true,
+ allowSingleGroup: true,
+ minCandidates: 2,
+ });
+
+ if (!groupPlan) {
+ return autoPopulatePanelEntities(
+ panels,
+ states,
+ "energy_entity",
+ normalizedPrefix,
+ unitMatcher,
+ sortMode,
+ );
+ }
+
+ let filled = 0;
+ let skipped = 0;
+ const usedSharedEnergyGroups = new Set();
+ const energyCandidates: AdvancedTelemetryCandidate[] = candidates.flatMap((candidate) => {
+ const entity = states[candidate.entityId];
+ if (!entity) {
+ return [];
+ }
+ return [{
+ entityId: candidate.entityId,
+ entity,
+ tokens: getEntityTokens(candidate.entityId, entity),
+ }];
+ });
+
+ const nextPanels = panels.map((panel) => {
+ const nextPanel = { ...panel };
+ if (nextPanel.enabled === false) {
+ skipped += 1;
+ return nextPanel;
+ }
+ const groupValue = getAnchorGroupValue(nextPanel, states, groupPlan);
+ const existing = nextPanel.energy_entity;
+ if (typeof existing === "string" && existing.trim().length > 0) {
+ if (groupValue && !options.shareSingleGroupEnergy) {
+ usedSharedEnergyGroups.add(groupValue);
+ }
+ skipped += 1;
+ return nextPanel;
+ }
+
+ const localTokens = getDistinctiveAnchorLocalTokens(
+ nextPanel,
+ panels,
+ states,
+ groupPlan,
+ groupValue,
+ );
+ const sameGroup = findSameGroupCandidates(energyCandidates, groupValue);
+
+ if (sameGroup.length === 1) {
+ const groupKey = groupValue ?? sameGroup[0].entityId;
+ if (!options.shareSingleGroupEnergy && usedSharedEnergyGroups.has(groupKey)) {
+ skipped += 1;
+ return nextPanel;
+ }
+ nextPanel.energy_entity = sameGroup[0].entityId;
+ if (!options.shareSingleGroupEnergy) {
+ usedSharedEnergyGroups.add(groupKey);
+ }
+ filled += 1;
+ return nextPanel;
+ }
+
+ const selected = pickUniqueCandidate(sameGroup, (item) => {
+ const usefulTokens = getUsefulTokens(item.tokens, groupValue ?? undefined);
+ return countSharedTokens(localTokens, usefulTokens);
+ });
+ if (!selected) {
+ skipped += 1;
+ return nextPanel;
+ }
+
+ nextPanel.energy_entity = selected.entityId;
+ filled += 1;
+ return nextPanel;
+ });
+
+ return {
+ panels: nextPanels,
+ matched: candidates.length,
+ filled,
+ skipped,
+ };
+};
+
+const normalizeUnit = (entity: HassEntity): string =>
+ typeof entity.attributes?.unit_of_measurement === "string"
+ ? entity.attributes.unit_of_measurement.trim().toLowerCase()
+ : "";
+
+const normalizeDeviceClass = (entity: HassEntity): string =>
+ typeof entity.attributes?.device_class === "string"
+ ? entity.attributes.device_class.trim().toLowerCase()
+ : "";
+
+const entityMatchesPower = (entity: HassEntity): boolean =>
+ ["w", "kw"].includes(normalizeUnit(entity)) ||
+ normalizeDeviceClass(entity) === "power";
+
+const entityMatchesVoltage = (entity: HassEntity): boolean =>
+ ["v", "kv", "mv"].includes(normalizeUnit(entity)) ||
+ normalizeDeviceClass(entity) === "voltage";
+
+const entityMatchesCurrent = (entity: HassEntity): boolean =>
+ ["a", "ma"].includes(normalizeUnit(entity)) ||
+ normalizeDeviceClass(entity) === "current";
+
+const entityMatchesTemperature = (entity: HassEntity): boolean =>
+ ["\u00b0c", "\u00b0f", "\u2103", "\u2109", "c", "f"].includes(normalizeUnit(entity)) ||
+ normalizeDeviceClass(entity) === "temperature";
+
+const getEntityFriendlyName = (entity: HassEntity | undefined): string | undefined => {
+ const friendlyName = entity?.attributes?.friendly_name;
+ if (typeof friendlyName !== "string") {
+ return undefined;
+ }
+ const normalized = friendlyName.trim();
+ return normalized.length > 0 ? normalized : undefined;
+};
+
+const getStateEntityId = (entityId: string, entity: HassEntity): string =>
+ typeof entity.entity_id === "string" && entity.entity_id.trim().length > 0
+ ? entity.entity_id
+ : entityId;
+
+const getEntityDomain = (entityId: string): string => entityId.split(".")[0] ?? "";
+
+const getEntityTokens = (entityId: string, entity?: HassEntity): string[] =>
+ tokenizeSortText(`${entityId} ${getEntityFriendlyName(entity) ?? ""}`)
+ .map((token) => token.toLowerCase());
+
+const getUsefulTokens = (tokens: string[], groupValue?: string): string[] =>
+ tokens.filter((token) => {
+ if (groupValue && token === groupValue.toLowerCase()) {
+ return false;
+ }
+ if (GENERIC_TELEMETRY_TOKENS.has(token)) {
+ return false;
+ }
+ return token.length > 0;
+ });
+
+const countSharedTokens = (left: string[], right: string[]): number => {
+ const rightSet = new Set(right);
+ return left.filter((token) => rightSet.has(token)).length;
+};
+
+const countTokens = (tokens: string[]): Map => {
+ const counts = new Map();
+ for (const token of tokens) {
+ counts.set(token, (counts.get(token) ?? 0) + 1);
+ }
+ return counts;
+};
+
+const buildCommonAnchorLocalTokenCounts = (
+ panels: PanelConfig[],
+ states: Record,
+ groupPlan: GroupSortPlan | null,
+ groupValue: string | null,
+): Map => {
+ const anchorPanels = panels.filter((panel) => {
+ if (panel.enabled === false || !panel.power_entity?.trim()) {
+ return false;
+ }
+ if (!groupPlan || !groupValue) {
+ return true;
+ }
+ return getAnchorGroupValue(panel, states, groupPlan) === groupValue;
+ });
+ if (anchorPanels.length === 0) {
+ return new Map();
+ }
+
+ const commonCounts = countTokens(
+ getAnchorLocalTokens(anchorPanels[0], states, groupValue),
+ );
+ for (const panel of anchorPanels.slice(1)) {
+ const panelCounts = countTokens(getAnchorLocalTokens(panel, states, groupValue));
+ for (const [token, count] of [...commonCounts.entries()]) {
+ const nextCount = Math.min(count, panelCounts.get(token) ?? 0);
+ if (nextCount <= 0) {
+ commonCounts.delete(token);
+ } else {
+ commonCounts.set(token, nextCount);
+ }
+ }
+ }
+
+ return commonCounts;
+};
+
+const hasAnyToken = (candidate: AdvancedTelemetryCandidate, tokens: string[]): boolean => {
+ const tokenSet = new Set(candidate.tokens);
+ return tokens.some((token) => tokenSet.has(token));
+};
+
+const buildAdvancedTelemetryCandidates = (
+ states: Record,
+ matcher: (entity: HassEntity) => boolean,
+): AdvancedTelemetryCandidate[] =>
+ Object.entries(states)
+ .map(([entityId, entity]) => ({
+ entityId: getStateEntityId(entityId, entity),
+ entity,
+ }))
+ .filter(({ entityId, entity }) => entityId.startsWith("sensor.") && matcher(entity))
+ .map(({ entityId, entity }) => ({
+ entityId,
+ entity,
+ tokens: getEntityTokens(entityId, entity),
+ }))
+ .sort((a, b) => naturalCompare(a.entityId, b.entityId));
+
+const candidateHasStatusToken = (candidate: AdvancedTelemetryCandidate): boolean =>
+ candidate.tokens.some((token) => INVERTER_STATUS_TOKENS.has(token));
+
+const buildInverterStatusCandidates = (
+ states: Record,
+ searchText = "",
+): AdvancedTelemetryCandidate[] =>
+ Object.entries(states)
+ .map(([entityId, entity]) => {
+ const resolvedEntityId = getStateEntityId(entityId, entity);
+ return {
+ entityId: resolvedEntityId,
+ entity,
+ tokens: getEntityTokens(resolvedEntityId, entity),
+ };
+ })
+ .filter((candidate) => STATUS_ENTITY_DOMAINS.has(getEntityDomain(candidate.entityId)))
+ .filter(candidateHasStatusToken)
+ .filter((candidate) => {
+ const normalizedSearch = searchText.trim().replace(/\*+$/, "").toLowerCase();
+ if (!normalizedSearch) {
+ return true;
+ }
+ const searchByEntityId =
+ normalizedSearch.startsWith("sensor.") ||
+ normalizedSearch.startsWith("binary_sensor.");
+ if (searchByEntityId) {
+ return candidate.entityId.toLowerCase().startsWith(normalizedSearch);
+ }
+ return Boolean(
+ getEntityFriendlyName(candidate.entity)
+ ?.trim()
+ .toLowerCase()
+ .includes(normalizedSearch),
+ );
+ })
+ .sort((a, b) => naturalCompare(a.entityId, b.entityId));
+
+const pickUniqueCandidate = (
+ candidates: AdvancedTelemetryCandidate[],
+ scoreCandidate: (candidate: AdvancedTelemetryCandidate) => number,
+): AdvancedTelemetryCandidate | null => {
+ const scored = candidates
+ .map((candidate) => ({
+ candidate,
+ score: scoreCandidate(candidate),
+ }))
+ .filter((entry) => entry.score > 0)
+ .sort((a, b) => b.score - a.score || naturalCompare(a.candidate.entityId, b.candidate.entityId));
+
+ if (scored.length === 0) {
+ return null;
+ }
+ if (scored.length > 1 && scored[0].score === scored[1].score) {
+ return null;
+ }
+ return scored[0].candidate;
+};
+
+const findSameGroupCandidates = (
+ candidates: AdvancedTelemetryCandidate[],
+ groupValue: string | null,
+): AdvancedTelemetryCandidate[] => {
+ if (!groupValue) {
+ return [];
+ }
+ const normalizedGroup = groupValue.toLowerCase();
+ return candidates.filter((candidate) => candidate.tokens.includes(normalizedGroup));
+};
+
+const findSameGroupCandidatesByValues = (
+ candidates: AdvancedTelemetryCandidate[],
+ groupValues: string[],
+): AdvancedTelemetryCandidate[] => {
+ const normalizedGroups = new Set(
+ groupValues.map((groupValue) => groupValue.toLowerCase()),
+ );
+ if (normalizedGroups.size === 0) {
+ return [];
+ }
+ return candidates.filter((candidate) =>
+ candidate.tokens.some((token) => normalizedGroups.has(token)),
+ );
+};
+
+const getAnchorGroupValue = (
+ panel: PanelConfig,
+ states: Record,
+ groupPlan: GroupSortPlan | null,
+): string | null => {
+ if (!groupPlan || !panel.power_entity) {
+ return null;
+ }
+ const entity = states[panel.power_entity];
+ const tokens = tokenizeSortText(
+ getCandidateSortText(
+ {
+ entityId: panel.power_entity,
+ friendlyName: getEntityFriendlyName(entity),
+ },
+ groupPlan.sortBy,
+ ),
+ );
+ return tokens[groupPlan.tokenIndex]?.toLowerCase() ?? null;
+};
+
+const getAnchorLocalTokens = (
+ panel: PanelConfig,
+ states: Record,
+ groupValue: string | null,
+): string[] => {
+ if (!panel.power_entity) {
+ return [];
+ }
+ return getUsefulTokens(
+ getEntityTokens(panel.power_entity, states[panel.power_entity]),
+ groupValue ?? undefined,
+ );
+};
+
+const getAnchorSerialLikeTokens = (
+ panel: PanelConfig,
+ states: Record,
+): string[] => {
+ if (!panel.power_entity) {
+ return [];
+ }
+ return getUsefulTokens(
+ getEntityTokens(panel.power_entity, states[panel.power_entity]),
+ ).filter((token) => SERIAL_LIKE_TOKEN_REGEX.test(token));
+};
+
+const getAnchorGroupValues = (
+ panel: PanelConfig,
+ states: Record,
+ groupPlan: GroupSortPlan | null,
+): string[] => {
+ const groupValue = getAnchorGroupValue(panel, states, groupPlan);
+ const serialLikeTokens = getAnchorSerialLikeTokens(panel, states);
+ const values =
+ groupValue && SERIAL_LIKE_TOKEN_REGEX.test(groupValue)
+ ? [groupValue, ...serialLikeTokens]
+ : [...serialLikeTokens, groupValue];
+ return [
+ ...new Set(
+ values
+ .filter((value): value is string => Boolean(value))
+ .map((value) => value.toLowerCase()),
+ ),
+ ];
+};
+
+const getDistinctiveAnchorLocalTokens = (
+ panel: PanelConfig,
+ panels: PanelConfig[],
+ states: Record,
+ groupPlan: GroupSortPlan,
+ groupValue: string | null,
+): string[] => {
+ const localTokens = getAnchorLocalTokens(panel, states, groupValue);
+ if (!groupValue) {
+ return localTokens;
+ }
+
+ const tokenCounts = new Map();
+ for (const groupPanel of panels) {
+ if (groupPanel.enabled === false || !groupPanel.power_entity?.trim()) {
+ continue;
+ }
+ if (getAnchorGroupValue(groupPanel, states, groupPlan) !== groupValue) {
+ continue;
+ }
+ const uniqueTokens = new Set(
+ getAnchorLocalTokens(groupPanel, states, groupValue),
+ );
+ for (const token of uniqueTokens) {
+ tokenCounts.set(token, (tokenCounts.get(token) ?? 0) + 1);
+ }
+ }
+
+ return localTokens.filter((token) => tokenCounts.get(token) === 1);
+};
+
+const resolveAdvancedTelemetryValue = (
+ key: AdvancedMetricKey,
+ panel: PanelConfig,
+ candidatesByKey: Record,
+ groupValue: string | null,
+ localTokens: string[],
+): string | null => {
+ if (key === "panel_power_entity") {
+ return panel.power_entity?.trim() || null;
+ }
+
+ const sameGroup = findSameGroupCandidates(candidatesByKey[key], groupValue);
+ if (sameGroup.length === 0) {
+ return null;
+ }
+
+ if (key === "inverter_temp_entity") {
+ const temperatureCandidates = sameGroup.filter((candidate) =>
+ hasAnyToken(candidate, ["temp", "temperature", "inverter"]),
+ );
+ if (temperatureCandidates.length === 1) {
+ return temperatureCandidates[0].entityId;
+ }
+ if (sameGroup.length === 1) {
+ return sameGroup[0].entityId;
+ }
+ return null;
+ }
+
+ if (
+ key === "inverter_ac_power_entity" ||
+ key === "inverter_ac_voltage_entity" ||
+ key === "inverter_ac_current_entity"
+ ) {
+ const candidate = pickUniqueCandidate(sameGroup, (item) => {
+ const hasAcOrInverter = hasAnyToken(item, ["ac", "inverter"]);
+ return hasAcOrInverter ? 2 : 0;
+ });
+ return candidate?.entityId ?? null;
+ }
+
+ if (key === "panel_current_entity" || key === "panel_voltage_entity") {
+ const candidate = pickUniqueCandidate(sameGroup, (item) => {
+ const usefulTokens = getUsefulTokens(item.tokens, groupValue ?? undefined);
+ const overlap = countSharedTokens(localTokens, usefulTokens);
+ if (overlap === 0) {
+ return 0;
+ }
+ const inverterPenalty = hasAnyToken(item, ["ac", "inverter"]) ? 1 : 0;
+ return overlap * 2 - inverterPenalty;
+ });
+ return candidate?.entityId ?? null;
+ }
+
+ return null;
+};
+
+const scoreInverterStatusCandidate = (
+ candidate: AdvancedTelemetryCandidate,
+): number => {
+ let score = 0;
+ const statusText = `${candidate.entityId} ${getEntityFriendlyName(candidate.entity) ?? ""}`.toLowerCase();
+ if (/device[._\-\s]+status/.test(statusText)) {
+ score += 120;
+ }
+ if (/inverter[._\-\s]+status/.test(statusText)) {
+ score += 100;
+ }
+ if (getEntityFriendlyName(candidate.entity)?.trim().toLowerCase() === "device status") {
+ score += 80;
+ }
+ for (const token of candidate.tokens) {
+ if (token === "status") {
+ score += 20;
+ } else if (token === "state") {
+ score += 12;
+ } else if (["fault", "alarm", "error"].includes(token)) {
+ score += 10;
+ } else if (["mode", "operation", "operating"].includes(token)) {
+ score += 5;
+ } else if (["work", "working", "run", "running"].includes(token)) {
+ score += 3;
+ }
+ }
+ if (hasAnyToken(candidate, ["inverter"])) {
+ score += 2;
+ }
+ for (const token of candidate.tokens) {
+ if (LESS_SPECIFIC_STATUS_TOKENS.has(token)) {
+ score -= 8;
+ }
+ }
+ return score;
+};
+
+const selectOneToOneStatusCandidate = (
+ panel: PanelConfig,
+ panels: PanelConfig[],
+ states: Record,
+ statusCandidates: AdvancedTelemetryCandidate[],
+ groupPlan: GroupSortPlan | null,
+ usedStatusEntityIds: Set,
+): AdvancedTelemetryCandidate | null => {
+ const groupValue = getAnchorGroupValue(panel, states, groupPlan);
+ const groupValues = getAnchorGroupValues(panel, states, groupPlan);
+ const candidatePool =
+ groupValues.length > 0
+ ? findSameGroupCandidatesByValues(statusCandidates, groupValues)
+ : statusCandidates;
+ const availableCandidates = candidatePool.filter(
+ (candidate) => !usedStatusEntityIds.has(candidate.entityId),
+ );
+ if (availableCandidates.length === 0) {
+ return null;
+ }
+
+ const localCounts = countTokens(getAnchorLocalTokens(panel, states, groupValue));
+ const commonCounts = buildCommonAnchorLocalTokenCounts(
+ panels,
+ states,
+ groupPlan,
+ groupValue,
+ );
+ const distinctiveCounts = new Map();
+ for (const [token, count] of localCounts.entries()) {
+ const distinctiveCount = count - (commonCounts.get(token) ?? 0);
+ if (distinctiveCount > 0) {
+ distinctiveCounts.set(token, distinctiveCount);
+ }
+ }
+ if (distinctiveCounts.size === 0) {
+ return null;
+ }
+
+ return pickUniqueCandidate(availableCandidates, (candidate) => {
+ const usefulCounts = countTokens(
+ getUsefulTokens(candidate.tokens, groupValue ?? undefined),
+ );
+ let overlap = 0;
+ for (const [token, count] of distinctiveCounts.entries()) {
+ const candidateDistinctiveCount = Math.max(
+ 0,
+ (usefulCounts.get(token) ?? 0) - (commonCounts.get(token) ?? 0),
+ );
+ overlap += Math.min(count, candidateDistinctiveCount);
+ }
+ return overlap > 0 ? overlap * 10 : 0;
+ });
+};
+
+const tryOneToOneStatusAutoFill = (
+ panels: PanelConfig[],
+ states: Record,
+ statusCandidates: AdvancedTelemetryCandidate[],
+ groupPlan: GroupSortPlan | null,
+): InverterStatusAutoFillResult => {
+ const usedStatusEntityIds = new Set(
+ panels
+ .map((panel) => panel.inverter_status_entity)
+ .filter((entityId): entityId is string => typeof entityId === "string" && entityId.trim().length > 0),
+ );
+ let filled = 0;
+ let skippedPanels = 0;
+
+ const nextPanels = panels.map((panel) => {
+ if (panel.enabled === false || !panel.power_entity?.trim()) {
+ skippedPanels += 1;
+ return panel;
+ }
+ if (typeof panel.inverter_status_entity === "string" && panel.inverter_status_entity.trim().length > 0) {
+ return panel;
+ }
+
+ const selected = selectOneToOneStatusCandidate(
+ panel,
+ panels,
+ states,
+ statusCandidates,
+ groupPlan,
+ usedStatusEntityIds,
+ );
+ if (!selected) {
+ skippedPanels += 1;
+ return panel;
+ }
+
+ usedStatusEntityIds.add(selected.entityId);
+ filled += 1;
+ return {
+ ...panel,
+ inverter_status_entity: selected.entityId,
+ };
+ });
+
+ return {
+ panels: nextPanels,
+ filled,
+ skippedPanels,
+ };
+};
+
+export const autoPopulateInverterStatusEntities = (
+ panels: PanelConfig[],
+ states: Record,
+ searchText = "",
+): InverterStatusAutoFillResult => {
+ const powerPanels = panels.filter(
+ (panel) => typeof panel.power_entity === "string" && panel.power_entity.trim().length > 0,
+ );
+ const anchorCandidates = powerPanels.map((panel) => {
+ const entity = panel.power_entity ? states[panel.power_entity] : undefined;
+ return {
+ entityId: panel.power_entity ?? "",
+ friendlyName: getEntityFriendlyName(entity),
+ };
+ });
+ const groupPlan = getBestGroupSortPlan(anchorCandidates, {
+ allowNumericGroup: true,
+ allowSingleGroup: true,
+ minCandidates: 2,
+ });
+
+ const statusCandidates = buildInverterStatusCandidates(states, searchText);
+ const oneToOneResult = tryOneToOneStatusAutoFill(
+ panels,
+ states,
+ statusCandidates,
+ groupPlan,
+ );
+ if (oneToOneResult.filled > 0) {
+ return oneToOneResult;
+ }
+
+ const selectedByGroup = new Map();
+ let filled = 0;
+ let skippedPanels = 0;
+
+ const nextPanels = panels.map((panel) => {
+ if (panel.enabled === false || !panel.power_entity?.trim()) {
+ skippedPanels += 1;
+ return panel;
+ }
+ if (typeof panel.inverter_status_entity === "string" && panel.inverter_status_entity.trim().length > 0) {
+ return panel;
+ }
+
+ const groupValues = getAnchorGroupValues(panel, states, groupPlan);
+ if (groupValues.length === 0) {
+ skippedPanels += 1;
+ return panel;
+ }
+ const groupKey = groupValues[0];
+
+ if (!selectedByGroup.has(groupKey)) {
+ const sameGroup = findSameGroupCandidatesByValues(
+ statusCandidates,
+ groupValues,
+ );
+ const selected =
+ sameGroup.length === 1
+ ? sameGroup[0]
+ : pickUniqueCandidate(sameGroup, scoreInverterStatusCandidate);
+ selectedByGroup.set(groupKey, selected?.entityId ?? null);
+ }
+
+ const selectedEntityId = selectedByGroup.get(groupKey);
+ if (!selectedEntityId) {
+ skippedPanels += 1;
+ return panel;
+ }
+
+ filled += 1;
+ return {
+ ...panel,
+ inverter_status_entity: selectedEntityId,
+ };
+ });
+
+ return {
+ panels: nextPanels,
+ filled,
+ skippedPanels,
+ };
+};
+
+export const autoPopulateAdvancedTelemetry = (
+ panels: PanelConfig[],
+ states: Record,
+): AdvancedTelemetryAutoFillResult => {
+ const enabledPowerPanels = panels.filter(
+ (panel) => panel.enabled !== false && typeof panel.power_entity === "string" && panel.power_entity.trim().length > 0,
+ );
+ const anchorCandidates = enabledPowerPanels.map((panel) => {
+ const entity = panel.power_entity ? states[panel.power_entity] : undefined;
+ return {
+ entityId: panel.power_entity ?? "",
+ friendlyName: getEntityFriendlyName(entity),
+ };
+ });
+ const groupPlan = getBestGroupSortPlan(anchorCandidates, {
+ allowNumericGroup: true,
+ allowSingleGroup: true,
+ minCandidates: 2,
+ });
+
+ const candidatesByKey: Record = {
+ inverter_ac_power_entity: buildAdvancedTelemetryCandidates(states, entityMatchesPower),
+ inverter_ac_voltage_entity: buildAdvancedTelemetryCandidates(states, entityMatchesVoltage),
+ inverter_ac_current_entity: buildAdvancedTelemetryCandidates(states, entityMatchesCurrent),
+ inverter_temp_entity: buildAdvancedTelemetryCandidates(states, entityMatchesTemperature),
+ panel_current_entity: buildAdvancedTelemetryCandidates(states, entityMatchesCurrent),
+ panel_voltage_entity: buildAdvancedTelemetryCandidates(states, entityMatchesVoltage),
+ panel_power_entity: buildAdvancedTelemetryCandidates(states, entityMatchesPower),
+ };
+
+ let filled = 0;
+ let skippedPanels = 0;
+
+ const nextPanels = panels.map((panel) => {
+ if (panel.enabled === false || !panel.power_entity?.trim()) {
+ skippedPanels += 1;
+ return panel;
+ }
+
+ const groupValue = getAnchorGroupValue(panel, states, groupPlan);
+ const localTokens = getAnchorLocalTokens(panel, states, groupValue);
+ const nextAdvanced: PanelAdvancedMetrics = {
+ ...(panel.advanced_metrics ?? {}),
+ };
+ let changed = false;
+
+ const keys: AdvancedMetricKey[] = [
+ "inverter_ac_power_entity",
+ "inverter_ac_voltage_entity",
+ "inverter_ac_current_entity",
+ "inverter_temp_entity",
+ "panel_current_entity",
+ "panel_voltage_entity",
+ "panel_power_entity",
+ ];
+
+ for (const key of keys) {
+ if (typeof nextAdvanced[key] === "string" && nextAdvanced[key]?.trim()) {
+ continue;
+ }
+
+ const entityId = resolveAdvancedTelemetryValue(
+ key,
+ panel,
+ candidatesByKey,
+ groupValue,
+ localTokens,
+ );
+ if (!entityId) {
+ continue;
+ }
+
+ nextAdvanced[key] = entityId;
+ filled += 1;
+ changed = true;
+ }
+
+ return changed
+ ? {
+ ...panel,
+ advanced_metrics: nextAdvanced,
+ }
+ : panel;
+ });
+
+ return {
+ panels: nextPanels,
+ filled,
+ skippedPanels,
+ };
+};
+
+export const shouldAutoRenamePanelName = (
+ currentName: string | undefined,
+ panelId: string,
+ previousPowerEntity: string | undefined,
+ getEntityFriendlyName: FriendlyNameResolver,
+): boolean => {
+ const normalizedName = currentName?.trim() ?? "";
+ if (!normalizedName) {
+ return true;
+ }
+ if (normalizedName === panelId) {
+ return true;
+ }
+ if (/^panel\s+\d+$/i.test(normalizedName)) {
+ return true;
+ }
+
+ const previousFriendlyName = getEntityFriendlyName(previousPowerEntity);
+ return Boolean(previousFriendlyName && normalizedName === previousFriendlyName);
+};
+
+export const applyAutoPopulateFriendlyNames = (
+ panels: PanelConfig[],
+ getEntityFriendlyName: FriendlyNameResolver,
+): PanelConfig[] => {
+ return panels.map((panel) => {
+ const powerEntityId = panel.power_entity?.trim();
+ if (!powerEntityId) {
+ return panel;
+ }
+
+ const friendlyName = getEntityFriendlyName(powerEntityId);
+ if (!friendlyName) {
+ return panel;
+ }
+
+ return {
+ ...panel,
+ name: friendlyName,
+ };
+ });
+};
diff --git a/src/forecast.ts b/src/forecast.ts
new file mode 100644
index 0000000..8d2b2d3
--- /dev/null
+++ b/src/forecast.ts
@@ -0,0 +1,26 @@
+import type { HomeAssistant } from "./types";
+
+export type ForecastMetricKind = "power" | "energy";
+
+export const FORECAST_POWER_ENTITY_ID = "sensor.power_production_now";
+export const FORECAST_ENERGY_ENTITY_ID = "sensor.energy_production_today";
+
+const forecastEntityByKind: Record = {
+ power: FORECAST_POWER_ENTITY_ID,
+ energy: FORECAST_ENERGY_ENTITY_ID,
+};
+
+export const getForecastEntityId = (kind: ForecastMetricKind): string =>
+ forecastEntityByKind[kind];
+
+export const resolveForecastEntityId = (
+ hass: HomeAssistant | undefined,
+ kind: ForecastMetricKind,
+): string | null => {
+ const entityId = getForecastEntityId(kind);
+ if (!hass?.states?.[entityId]) {
+ return null;
+ }
+ return entityId;
+};
+
diff --git a/src/i18n.ts b/src/i18n.ts
new file mode 100644
index 0000000..b0033a4
--- /dev/null
+++ b/src/i18n.ts
@@ -0,0 +1,141 @@
+import enCatalog from "./translations/en.json";
+import type { HomeAssistant } from "./types";
+
+type Primitive = string | number | boolean | null;
+type AnyObject = Record;
+
+type LeafKeyPaths = T extends Primitive
+ ? never
+ : {
+ [K in keyof T & string]: T[K] extends string
+ ? `${Prefix}${K}`
+ : T[K] extends AnyObject
+ ? LeafKeyPaths
+ : never;
+ }[keyof T & string];
+
+export type TranslationCatalog = typeof enCatalog;
+export type TranslationKey = LeafKeyPaths;
+export type TranslationParams = Record;
+
+const BUILTIN_CATALOGS: Record = {
+ en: enCatalog,
+};
+
+const warnedMissingKeys = new Set();
+
+const isDevelopmentEnvironment = (): boolean => {
+ if (typeof import.meta !== "undefined") {
+ const env = (import.meta as { env?: { DEV?: unknown } }).env;
+ if (typeof env?.DEV === "boolean") {
+ return env.DEV;
+ }
+ }
+ if (typeof process !== "undefined") {
+ return process.env.NODE_ENV !== "production";
+ }
+ return false;
+};
+
+const warnMissingKey = (key: string, locale?: string): void => {
+ if (!isDevelopmentEnvironment()) {
+ return;
+ }
+ const warningKey = `${locale ?? "unknown"}:${key}`;
+ if (warnedMissingKeys.has(warningKey)) {
+ return;
+ }
+ warnedMissingKeys.add(warningKey);
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[Solar Panel Visualizer i18n] Missing translation key "${key}" for locale "${locale ?? "unknown"}".`,
+ );
+};
+
+const readPath = (catalog: TranslationCatalog | undefined, key: string): unknown => {
+ if (!catalog) {
+ return undefined;
+ }
+ return key
+ .split(".")
+ .reduce(
+ (current, segment) =>
+ typeof current === "object" && current !== null
+ ? (current as Record)[segment]
+ : undefined,
+ catalog,
+ );
+};
+
+const interpolate = (
+ template: string,
+ params?: TranslationParams,
+): string => {
+ if (!params) {
+ return template;
+ }
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, token: string) => {
+ const value = params[token];
+ return value === undefined ? `{${token}}` : String(value);
+ });
+};
+
+export const getLocaleCandidates = (locale?: string): string[] => {
+ if (!locale) {
+ return ["en"];
+ }
+
+ const normalized = locale.trim().toLowerCase();
+ if (normalized.length === 0) {
+ return ["en"];
+ }
+
+ const base = normalized.split("-")[0];
+ const candidates = [normalized, base, "en"];
+ return [...new Set(candidates)];
+};
+
+export const getBuiltInCatalogs = (): Record => ({
+ ...BUILTIN_CATALOGS,
+});
+
+export const translateWithCatalogs = (
+ catalogs: Record,
+ locale: string | undefined,
+ key: string,
+ params?: TranslationParams,
+): string => {
+ for (const candidate of getLocaleCandidates(locale)) {
+ const resolved = readPath(catalogs[candidate], key);
+ if (typeof resolved === "string") {
+ return interpolate(resolved, params);
+ }
+ }
+
+ const englishFallback = readPath(catalogs.en ?? enCatalog, key);
+ if (typeof englishFallback === "string") {
+ return interpolate(englishFallback, params);
+ }
+
+ warnMissingKey(key, locale);
+ return "";
+};
+
+export const translateByLocale = (
+ locale: string | undefined,
+ key: TranslationKey,
+ params?: TranslationParams,
+): string =>
+ translateWithCatalogs(
+ BUILTIN_CATALOGS,
+ locale,
+ key,
+ params,
+ );
+
+export const translate = (
+ hass: HomeAssistant | undefined,
+ key: TranslationKey,
+ params?: TranslationParams,
+): string => translateByLocale(hass?.locale?.language, key, params);
+
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..b6d9988
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,16 @@
+import { CARD_ELEMENT_NAME } from "./config";
+import { translateByLocale } from "./i18n";
+import "./solar-panel-visualizer-card";
+import "./solar-panel-visualizer-editor";
+
+const locale =
+ typeof navigator !== "undefined" ? navigator.language : "en";
+
+window.customCards = window.customCards || [];
+window.customCards.push({
+ type: CARD_ELEMENT_NAME,
+ name: translateByLocale(locale, "meta.card_name"),
+ description: translateByLocale(locale, "meta.card_description"),
+ icon: "mdi:solar-panel",
+ preview: true,
+});
diff --git a/src/motion-engine.ts b/src/motion-engine.ts
new file mode 100644
index 0000000..39c2839
--- /dev/null
+++ b/src/motion-engine.ts
@@ -0,0 +1,395 @@
+import type { PanelStatus } from "./types";
+
+const SHIMMER_MS = 1_100;
+const POWER_IMPACT_MS = 900;
+const ALERT_RIPPLE_MS = 900;
+const ALERT_RIPPLE_REPEAT_MS = 6_000;
+const FLOW_PULSE_STEP_MS = 5_000;
+const FLOW_PULSE_FAILSAFE_MS = 16_000;
+const ALERT_STATUSES = new Set(["deviation", "inverter", "error"]);
+
+export interface MotionFlags {
+ enabled: boolean;
+ powerFlow: boolean;
+ updateShimmer: boolean;
+ alertRipple: boolean;
+}
+
+export interface MotionSnapshot {
+ totalPower: number;
+ popupOpen: boolean;
+ powerKpiValue: string;
+ energyKpiValue: string;
+ panelPowers: Record;
+ panelStatuses: Record;
+}
+
+export interface MotionState {
+ panelShimmers: Set;
+ kpiShimmers: Set<"power" | "energy" | "custom">;
+ alertRipples: Set;
+ powerFlowActive: boolean;
+ flowPulsePanelId: string | null;
+ reducedMotion: boolean;
+}
+
+type KpiKey = "power" | "energy" | "custom";
+
+export class MotionEngine {
+ private readonly _notify: () => void;
+ private _snapshot?: MotionSnapshot;
+ private _flags: MotionFlags = {
+ enabled: false,
+ powerFlow: false,
+ updateShimmer: false,
+ alertRipple: false,
+ };
+ private _panelShimmers = new Set();
+ private _kpiShimmers = new Set();
+ private _alertRipples = new Set();
+ private _timers = new Map();
+ private _flowPulsePanelId: string | null = null;
+ private _flowPulseIndex = 0;
+ private _flowPulsePanelOrder: string[] = [];
+ private _reducedMotion = false;
+ private _visibilityHandler = () => {
+ if (this._snapshot) {
+ this._refreshFlowPulseLoop(this._snapshot);
+ }
+ };
+ private _mediaQuery?: MediaQueryList;
+ private _mediaQueryHandler = (event: MediaQueryListEvent) => {
+ this._reducedMotion = event.matches;
+ this._clearAllMotion();
+ this._notify();
+ };
+
+ public constructor(notify: () => void) {
+ this._notify = notify;
+
+ if (typeof window !== "undefined") {
+ window.document.addEventListener("visibilitychange", this._visibilityHandler);
+ if (typeof window.matchMedia === "function") {
+ this._mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
+ this._reducedMotion = this._mediaQuery.matches;
+ this._mediaQuery.addEventListener("change", this._mediaQueryHandler);
+ }
+ }
+ }
+
+ public destroy(): void {
+ if (typeof window !== "undefined") {
+ window.document.removeEventListener("visibilitychange", this._visibilityHandler);
+ }
+ this._mediaQuery?.removeEventListener("change", this._mediaQueryHandler);
+ this._clearAllMotion();
+ this._clearTimeoutByKey("flow-pulse-step");
+ this._clearTimeoutByKey("flow-pulse-timeout");
+ }
+
+ public update(flags: MotionFlags, snapshot: MotionSnapshot): void {
+ const previous = this._snapshot;
+ this._flags = flags;
+ this._snapshot = snapshot;
+
+ if (!flags.enabled || this._reducedMotion) {
+ this._clearAllMotion();
+ this._refreshFlowPulseLoop(snapshot);
+ return;
+ }
+
+ if (flags.updateShimmer && previous) {
+ this._triggerKpiShimmers(previous, snapshot);
+ }
+
+ if (flags.alertRipple && previous) {
+ this._triggerAlertRipples(previous, snapshot);
+ }
+ this._syncAlertRippleLoops(snapshot);
+ this._refreshFlowPulseLoop(snapshot);
+ }
+
+ public getState(): MotionState {
+ const snapshot = this._snapshot;
+ return {
+ panelShimmers: new Set(this._panelShimmers),
+ kpiShimmers: new Set(this._kpiShimmers),
+ alertRipples: new Set(this._alertRipples),
+ powerFlowActive: Boolean(
+ this._flags.enabled &&
+ this._flags.powerFlow &&
+ !this._reducedMotion &&
+ snapshot &&
+ snapshot.totalPower > 0 &&
+ !snapshot.popupOpen,
+ ),
+ flowPulsePanelId: this._flowPulsePanelId,
+ reducedMotion: this._reducedMotion,
+ };
+ }
+
+ public triggerPowerImpactFromPulse(): void {
+ const snapshot = this._snapshot;
+ if (!snapshot) {
+ return;
+ }
+ if (!this._flags.enabled || !this._flags.powerFlow || this._reducedMotion) {
+ return;
+ }
+ if (snapshot.popupOpen || snapshot.totalPower <= 0) {
+ return;
+ }
+ this._armTransient("power", this._kpiShimmers, POWER_IMPACT_MS, "kpi-shimmer");
+ }
+
+ public completeFlowPulse(): void {
+ const activePanelId = this._flowPulsePanelId;
+ if (!activePanelId) {
+ return;
+ }
+ this._finalizeActiveFlowPulse(activePanelId);
+ }
+
+ private _refreshFlowPulseLoop(snapshot: MotionSnapshot): void {
+ const activePanelIds = Object.entries(snapshot.panelPowers)
+ .filter(([, power]) => typeof power === "number" && power > 0)
+ .map(([panelId]) => panelId);
+
+ const canRun = this._canRunFlowPulse(snapshot, activePanelIds.length);
+ if (!canRun) {
+ this._clearFlowPulseLoop();
+ return;
+ }
+
+ const nextSignature = activePanelIds.join("|");
+ const previousSignature = this._flowPulsePanelOrder.join("|");
+ if (nextSignature !== previousSignature) {
+ this._flowPulsePanelOrder = activePanelIds;
+ this._flowPulseIndex = 0;
+ this._clearTimeoutByKey("flow-pulse-step");
+ this._clearTimeoutByKey("flow-pulse-timeout");
+ if (this._flowPulsePanelId !== null) {
+ this._flowPulsePanelId = null;
+ this._notify();
+ }
+ }
+
+ if (
+ this._timers.has("flow-pulse-step") ||
+ this._timers.has("flow-pulse-timeout") ||
+ this._flowPulsePanelId !== null
+ ) {
+ return;
+ }
+
+ this._triggerNextFlowPulse();
+ }
+
+ private _canRunFlowPulse(
+ snapshot: MotionSnapshot | undefined,
+ activeCount: number,
+ ): boolean {
+ const documentHidden = typeof document !== "undefined" ? document.hidden : false;
+ return Boolean(
+ this._flags.enabled &&
+ this._flags.powerFlow &&
+ !this._reducedMotion &&
+ !documentHidden &&
+ snapshot &&
+ snapshot.totalPower > 0 &&
+ !snapshot.popupOpen &&
+ activeCount > 0,
+ );
+ }
+
+ private _triggerNextFlowPulse(): void {
+ if (!this._canRunFlowPulse(this._snapshot, this._flowPulsePanelOrder.length)) {
+ this._clearFlowPulseLoop();
+ return;
+ }
+
+ const activePanelId =
+ this._flowPulsePanelOrder[this._flowPulseIndex % this._flowPulsePanelOrder.length];
+ this._flowPulseIndex =
+ (this._flowPulseIndex + 1) % Math.max(this._flowPulsePanelOrder.length, 1);
+
+ this._flowPulsePanelId = activePanelId;
+ this._notify();
+
+ this._clearTimeoutByKey("flow-pulse-timeout");
+ const timeout = window.setTimeout(() => {
+ this._timers.delete("flow-pulse-timeout");
+ this._finalizeActiveFlowPulse(activePanelId);
+ }, FLOW_PULSE_FAILSAFE_MS);
+ this._timers.set("flow-pulse-timeout", timeout);
+ }
+
+ private _scheduleNextFlowPulse(): void {
+ this._clearTimeoutByKey("flow-pulse-step");
+ const stepTimer = window.setTimeout(() => {
+ this._timers.delete("flow-pulse-step");
+ this._triggerNextFlowPulse();
+ }, FLOW_PULSE_STEP_MS);
+ this._timers.set("flow-pulse-step", stepTimer);
+ }
+
+ private _finalizeActiveFlowPulse(panelId: string): void {
+ if (this._flowPulsePanelId !== panelId) {
+ return;
+ }
+
+ this._clearTimeoutByKey("flow-pulse-timeout");
+ this._flowPulsePanelId = null;
+ this._notify();
+ this.triggerPowerImpactFromPulse();
+ this._scheduleNextFlowPulse();
+ }
+
+ private _clearFlowPulseLoop(): void {
+ this._clearTimeoutByKey("flow-pulse-step");
+ this._clearTimeoutByKey("flow-pulse-timeout");
+ this._flowPulsePanelOrder = [];
+ this._flowPulseIndex = 0;
+ if (this._flowPulsePanelId !== null) {
+ this._flowPulsePanelId = null;
+ this._notify();
+ }
+ }
+
+ private _triggerKpiShimmers(
+ previous: MotionSnapshot,
+ current: MotionSnapshot,
+ ): void {
+ if (previous.energyKpiValue !== current.energyKpiValue) {
+ this._armTransient("energy", this._kpiShimmers, SHIMMER_MS, "kpi-shimmer");
+ }
+ }
+
+ private _triggerAlertRipples(
+ previous: MotionSnapshot,
+ current: MotionSnapshot,
+ ): void {
+ for (const [panelId, status] of Object.entries(current.panelStatuses)) {
+ const previousStatus = previous.panelStatuses[panelId];
+ if (status === previousStatus) {
+ continue;
+ }
+ if (ALERT_STATUSES.has(status)) {
+ this._armTransient(panelId, this._alertRipples, ALERT_RIPPLE_MS, "alert-ripple");
+ }
+ }
+ }
+
+ private _syncAlertRippleLoops(current: MotionSnapshot): void {
+ if (!this._flags.alertRipple) {
+ for (const key of [...this._timers.keys()]) {
+ if (key.startsWith("alert-ripple-loop:")) {
+ this._clearTimeoutByKey(key);
+ }
+ }
+ return;
+ }
+
+ const activeAlertPanels = new Set(
+ Object.entries(current.panelStatuses)
+ .filter(([, status]) => ALERT_STATUSES.has(status))
+ .map(([panelId]) => panelId),
+ );
+
+ for (const panelId of activeAlertPanels) {
+ const timerKey = this._alertRippleLoopKey(panelId);
+ if (this._timers.has(timerKey)) {
+ continue;
+ }
+ this._armAlertRippleLoop(panelId);
+ }
+
+ for (const key of [...this._timers.keys()]) {
+ if (!key.startsWith("alert-ripple-loop:")) {
+ continue;
+ }
+ const panelId = key.slice("alert-ripple-loop:".length);
+ if (!activeAlertPanels.has(panelId)) {
+ this._clearTimeoutByKey(key);
+ }
+ }
+ }
+
+ private _armAlertRippleLoop(panelId: string): void {
+ const timerKey = this._alertRippleLoopKey(panelId);
+ this._clearTimeoutByKey(timerKey);
+ const timeout = window.setTimeout(() => {
+ if (!this._canRepeatAlertRipple(panelId)) {
+ this._clearTimeoutByKey(timerKey);
+ return;
+ }
+ this._armTransient(panelId, this._alertRipples, ALERT_RIPPLE_MS, "alert-ripple");
+ this._armAlertRippleLoop(panelId);
+ }, ALERT_RIPPLE_REPEAT_MS);
+ this._timers.set(timerKey, timeout);
+ }
+
+ private _canRepeatAlertRipple(panelId: string): boolean {
+ const snapshot = this._snapshot;
+ if (!snapshot) {
+ return false;
+ }
+ if (!this._flags.enabled || !this._flags.alertRipple || this._reducedMotion) {
+ return false;
+ }
+ const status = snapshot.panelStatuses[panelId];
+ return ALERT_STATUSES.has(status);
+ }
+
+ private _alertRippleLoopKey(panelId: string): string {
+ return `alert-ripple-loop:${panelId}`;
+ }
+
+ private _armTransient(
+ key: T,
+ target: Set,
+ durationMs: number,
+ timerPrefix: string,
+ ): void {
+ target.add(key);
+ this._notify();
+
+ const timerKey = `${timerPrefix}:${key}`;
+ this._clearTimeoutByKey(timerKey);
+ const timeout = window.setTimeout(() => {
+ target.delete(key);
+ this._timers.delete(timerKey);
+ this._notify();
+ }, durationMs);
+ this._timers.set(timerKey, timeout);
+ }
+
+ private _clearAllMotion(): void {
+ this._panelShimmers.clear();
+ this._kpiShimmers.clear();
+ this._alertRipples.clear();
+ this._flowPulsePanelOrder = [];
+ this._flowPulseIndex = 0;
+ this._flowPulsePanelId = null;
+
+ for (const key of [...this._timers.keys()]) {
+ if (
+ key.startsWith("panel-shimmer:") ||
+ key.startsWith("kpi-shimmer:") ||
+ key.startsWith("alert-ripple:") ||
+ key.startsWith("alert-ripple-loop:") ||
+ key.startsWith("flow-pulse-")
+ ) {
+ this._clearTimeoutByKey(key);
+ }
+ }
+ }
+
+ private _clearTimeoutByKey(key: string): void {
+ const timeout = this._timers.get(key);
+ if (timeout !== undefined) {
+ window.clearTimeout(timeout);
+ this._timers.delete(key);
+ }
+ }
+}
diff --git a/src/motion-overlay.ts b/src/motion-overlay.ts
new file mode 100644
index 0000000..ecfc5de
--- /dev/null
+++ b/src/motion-overlay.ts
@@ -0,0 +1,732 @@
+import { html, nothing, svg } from "lit";
+import type {
+ DerivedArrayState,
+ SolarPanelVisualizerConfig,
+} from "./types";
+
+export interface MotionPath {
+ id: string;
+ d: string;
+ opacity: number;
+ delayMs: number;
+ durationMs: number;
+ color: string;
+ travelPx: number;
+ offsetPx?: number;
+ panelId?: string;
+}
+
+export interface MotionNode {
+ id: string;
+ cx: number;
+ cy: number;
+ r: number;
+ color: string;
+ opacity: number;
+}
+
+export interface Point {
+ x: number;
+ y: number;
+}
+
+interface RoundedPath {
+ d: string;
+ length: number;
+}
+
+interface PanelPoint {
+ panel: DerivedArrayState["panels"][number];
+ row: number;
+ center: Point;
+ statusCenterX: number;
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+}
+
+interface RowStats {
+ row: number;
+ busY: number;
+ minTop: number;
+ maxBottom: number;
+}
+
+interface KpiConnector {
+ entry: Point;
+}
+
+export type PowerRailRouteMode = "left-collector" | "direct-first-row";
+
+export interface LeftCollectorGridPanelInput {
+ id: string;
+ row: number;
+ anchorX: number;
+ anchorY: number;
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+ accentColor: string;
+ intensity: number;
+ producing: boolean;
+}
+
+export interface LeftCollectorGridRoutes {
+ flowPaths: MotionPath[];
+ topologyPaths: MotionPath[];
+ topologyNodes: MotionNode[];
+ collectorX: number;
+ rowBuses: Array<{
+ row: number;
+ y: number;
+ startX: number;
+ endX: number;
+ }>;
+}
+
+export interface RenderedRowInput {
+ top: number;
+ bottom: number;
+ left: number;
+}
+
+export interface MotionOverlayModel {
+ width: number;
+ height: number;
+ flowPaths: MotionPath[];
+ topologyPaths: MotionPath[];
+ topologyNodes: MotionNode[];
+}
+
+const clamp = (value: number, min: number, max: number): number =>
+ Math.max(min, Math.min(max, value));
+
+const approxEqual = (a: number, b: number): boolean => Math.abs(a - b) < 0.01;
+
+const distance = (a: Point, b: Point): number =>
+ Math.hypot(b.x - a.x, b.y - a.y);
+
+const centerOf = (rect: DOMRect, hostRect: DOMRect): Point => ({
+ x: rect.left - hostRect.left + rect.width / 2,
+ y: rect.top - hostRect.top + rect.height / 2,
+});
+
+const BUSBAR_COLOR = "rgba(224, 232, 242, 1)";
+const FLOW_MS_PER_PX = 8;
+const FLOW_PULSE_LENGTH_PX = 28;
+const GRID_CARD_PADDING = 14;
+
+const normalizePoints = (points: Point[]): Point[] => {
+ const normalized: Point[] = [];
+ for (const point of points) {
+ const previous = normalized[normalized.length - 1];
+ if (previous && approxEqual(previous.x, point.x) && approxEqual(previous.y, point.y)) {
+ continue;
+ }
+ normalized.push(point);
+ }
+ return normalized;
+};
+
+const buildOrthogonalPath = (points: Point[]): RoundedPath => {
+ const normalized = normalizePoints(points);
+ if (normalized.length < 2) {
+ return { d: "", length: 0 };
+ }
+
+ let d = `M ${normalized[0].x.toFixed(2)} ${normalized[0].y.toFixed(2)}`;
+ let length = 0;
+
+ for (let index = 1; index < normalized.length; index += 1) {
+ const previous = normalized[index - 1];
+ const current = normalized[index];
+ d += ` L ${current.x.toFixed(2)} ${current.y.toFixed(2)}`;
+ length += distance(previous, current);
+ }
+
+ return { d, length };
+};
+
+export const shouldUseBottomPowerEntry = (
+ cardWidth: number,
+ gridLeft: number,
+ gridRight: number,
+ powerCenterX: number,
+): boolean => {
+ const gridWidth = gridRight - gridLeft;
+ if (
+ !Number.isFinite(cardWidth) ||
+ !Number.isFinite(gridLeft) ||
+ !Number.isFinite(gridRight) ||
+ !Number.isFinite(powerCenterX) ||
+ cardWidth <= 0 ||
+ gridWidth <= 0
+ ) {
+ return false;
+ }
+
+ const middleGridStart = gridLeft + gridWidth * 0.28;
+ const middleCardStart = cardWidth * 0.36;
+ return powerCenterX >= Math.max(middleGridStart, middleCardStart) && powerCenterX <= gridRight;
+};
+
+const resolveConnector = (
+ hostRect: DOMRect,
+ targetRect: DOMRect,
+ useBottomEntry = false,
+ scrollOffset: Point = { x: 0, y: 0 },
+): KpiConnector => {
+ const center = centerOf(targetRect, hostRect);
+ const left = targetRect.left - hostRect.left;
+ const bottom = targetRect.bottom - hostRect.top;
+ return {
+ entry: {
+ x: (useBottomEntry ? center.x : left - 0.5) + scrollOffset.x,
+ y: (useBottomEntry ? bottom + 0.5 : center.y) + scrollOffset.y,
+ },
+ };
+};
+
+const resolveAnchorX = (panelPoint: PanelPoint): number => {
+ const fallbackX = panelPoint.left + (panelPoint.right - panelPoint.left) * 0.22;
+ return clamp(panelPoint.statusCenterX || fallbackX, panelPoint.left + 10, panelPoint.right - 10);
+};
+
+export const assignRenderedRowIndexes = (items: RenderedRowInput[]): number[] => {
+ if (items.length === 0) {
+ return [];
+ }
+
+ const averageHeight =
+ items.reduce((sum, item) => sum + Math.max(1, item.bottom - item.top), 0) / items.length;
+ const rowTolerance = clamp(averageHeight * 0.32, 6, 28);
+ const rowCenters: number[] = [];
+ const rowCounts: number[] = [];
+ const rowIndexes = new Array(items.length).fill(0);
+
+ const sorted = items
+ .map((item, index) => ({ item, index }))
+ .sort((a, b) => a.item.top - b.item.top || a.item.left - b.item.left);
+
+ for (const { item, index } of sorted) {
+ const centerY = item.top + (item.bottom - item.top) / 2;
+ let rowIndex = rowCenters.findIndex((center) => Math.abs(center - centerY) <= rowTolerance);
+ if (rowIndex < 0) {
+ rowIndex = rowCenters.length;
+ rowCenters.push(centerY);
+ rowCounts.push(1);
+ } else {
+ rowCounts[rowIndex] += 1;
+ rowCenters[rowIndex] =
+ rowCenters[rowIndex] + (centerY - rowCenters[rowIndex]) / rowCounts[rowIndex];
+ }
+ rowIndexes[index] = rowIndex;
+ }
+
+ return rowIndexes;
+};
+
+export const buildLeftCollectorGridRoutes = (
+ panels: LeftCollectorGridPanelInput[],
+ powerEntry: Point,
+ cornerRadius: number,
+ cardWidth: number,
+ gridLeft = Math.min(...panels.map((item) => item.left)),
+ routeMode: PowerRailRouteMode = "left-collector",
+): LeftCollectorGridRoutes => {
+ const flowPaths: MotionPath[] = [];
+ const topologyPaths: MotionPath[] = [];
+ const topologyNodes: MotionNode[] = [];
+ const rowBuses: LeftCollectorGridRoutes["rowBuses"] = [];
+ const pushTopologyPath = (id: string, points: Point[], opacity: number): void => {
+ const path = buildOrthogonalPath(points);
+ if (!path.d) {
+ return;
+ }
+ topologyPaths.push({
+ id,
+ d: path.d,
+ color: BUSBAR_COLOR,
+ opacity,
+ delayMs: 0,
+ durationMs: 1200,
+ travelPx: Math.max(24, path.length),
+ });
+ };
+ const pushTopologyNode = (node: MotionNode): void => {
+ const duplicate = topologyNodes.some((existing) => {
+ const dx = existing.cx - node.cx;
+ const dy = existing.cy - node.cy;
+ return Math.hypot(dx, dy) < Math.max(existing.r, node.r) * 0.9;
+ });
+ if (!duplicate) {
+ topologyNodes.push(node);
+ }
+ };
+
+ const rowGroups = new Map();
+ for (const panel of panels) {
+ const rowGroup = rowGroups.get(panel.row) ?? [];
+ rowGroup.push(panel);
+ rowGroups.set(panel.row, rowGroup);
+ }
+
+ const rows = [...rowGroups.entries()]
+ .sort((a, b) => a[0] - b[0])
+ .map(([row, group]) => {
+ return {
+ row,
+ busY: 0,
+ minTop: Math.min(...group.map((item) => item.top)),
+ maxBottom: Math.max(...group.map((item) => item.bottom)),
+ };
+ });
+ if (rows.length === 0) {
+ return { flowPaths, topologyPaths, topologyNodes, collectorX: GRID_CARD_PADDING, rowBuses };
+ }
+
+ const contentLeft = Math.max(0, Math.min(gridLeft, ...panels.map((item) => item.left)));
+ const minCollectorX = Math.max(6, GRID_CARD_PADDING * 0.5);
+ const maxCollectorX = Math.max(minCollectorX, contentLeft - 8);
+ const collectorX = clamp(
+ contentLeft / 2,
+ minCollectorX,
+ Math.min(maxCollectorX, Math.max(GRID_CARD_PADDING, cardWidth - GRID_CARD_PADDING)),
+ );
+ const minBusY = powerEntry.y + 8;
+
+ for (let index = 0; index < rows.length; index += 1) {
+ const row = rows[index];
+ const previous = rows[index - 1];
+ const preferred = row.minTop - 12;
+ const minAllowed =
+ index === 0
+ ? minBusY
+ : Math.max(previous.maxBottom + 8, previous.busY + 8);
+ const maxAllowed = row.minTop - 6;
+
+ row.busY =
+ maxAllowed > minAllowed
+ ? clamp(preferred, minAllowed, maxAllowed)
+ : Math.max(minBusY, row.minTop - 6);
+ }
+
+ const railOpacity = clamp(0.24 / Math.sqrt(Math.max(rows.length, 1)), 0.15, 0.22);
+ const tapOpacity = clamp(0.24 / Math.sqrt(Math.max(panels.length, 1)), 0.09, 0.16);
+ pushTopologyNode({
+ id: "power-socket-node",
+ cx: powerEntry.x,
+ cy: powerEntry.y,
+ r: clamp(cornerRadius * 0.18, 2.8, 4.8),
+ color: BUSBAR_COLOR,
+ opacity: 0.36,
+ });
+
+ const firstRow = rows[0];
+ const maxBusY = Math.max(...rows.map((row) => row.busY));
+ const directFirstRowMode = routeMode === "direct-first-row";
+ const hasLowerRows = rows.length > 1;
+ if (directFirstRowMode) {
+ pushTopologyPath(
+ "power-drop-rail",
+ [
+ powerEntry,
+ { x: powerEntry.x, y: firstRow.busY },
+ ],
+ railOpacity,
+ );
+ pushTopologyNode({
+ id: "power-drop-node",
+ cx: powerEntry.x,
+ cy: firstRow.busY,
+ r: clamp(cornerRadius * 0.18, 2.8, 4.8),
+ color: BUSBAR_COLOR,
+ opacity: 0.36,
+ });
+ if (hasLowerRows) {
+ pushTopologyPath(
+ "collector-rail",
+ [
+ { x: collectorX, y: firstRow.busY },
+ { x: collectorX, y: maxBusY },
+ ],
+ railOpacity,
+ );
+ }
+ } else {
+ pushTopologyPath(
+ "collector-rail",
+ [
+ { x: collectorX, y: powerEntry.y },
+ { x: collectorX, y: maxBusY },
+ ],
+ railOpacity,
+ );
+ pushTopologyPath(
+ "power-entry-rail",
+ [
+ { x: collectorX, y: powerEntry.y },
+ powerEntry,
+ ],
+ railOpacity,
+ );
+ }
+
+ for (const row of rows) {
+ const rowPanels = rowGroups.get(row.row) ?? [];
+ if (rowPanels.length === 0) {
+ continue;
+ }
+
+ const rowAnchors = rowPanels.map((panel) => panel.anchorX);
+ const isFirstRow = row.row === firstRow.row;
+ const rowBusXs =
+ directFirstRowMode && isFirstRow
+ ? [powerEntry.x, ...rowAnchors, ...(hasLowerRows ? [collectorX] : [])]
+ : [collectorX, ...rowAnchors];
+ const rowStartX = Math.min(...rowBusXs);
+ const rowEndX = Math.max(...rowBusXs);
+ rowBuses.push({
+ row: row.row,
+ y: row.busY,
+ startX: rowStartX,
+ endX: rowEndX,
+ });
+ pushTopologyPath(
+ `row-rail-${row.row}`,
+ [
+ { x: rowStartX, y: row.busY },
+ { x: rowEndX, y: row.busY },
+ ],
+ railOpacity,
+ );
+ if (!directFirstRowMode || !isFirstRow || hasLowerRows) {
+ pushTopologyNode({
+ id: `row-node-${row.row}`,
+ cx: collectorX,
+ cy: row.busY,
+ r: clamp(cornerRadius * 0.2, 2.8, 4.8),
+ color: BUSBAR_COLOR,
+ opacity: 0.32,
+ });
+ }
+
+ for (const panel of rowPanels) {
+ const routePoints =
+ directFirstRowMode && isFirstRow
+ ? [
+ { x: panel.anchorX, y: panel.anchorY },
+ { x: panel.anchorX, y: row.busY },
+ { x: powerEntry.x, y: row.busY },
+ powerEntry,
+ ]
+ : directFirstRowMode
+ ? [
+ { x: panel.anchorX, y: panel.anchorY },
+ { x: panel.anchorX, y: row.busY },
+ { x: collectorX, y: row.busY },
+ { x: collectorX, y: firstRow.busY },
+ { x: powerEntry.x, y: firstRow.busY },
+ powerEntry,
+ ]
+ : [
+ { x: panel.anchorX, y: panel.anchorY },
+ { x: panel.anchorX, y: row.busY },
+ { x: collectorX, y: row.busY },
+ { x: collectorX, y: powerEntry.y },
+ powerEntry,
+ ];
+ const routePath = buildOrthogonalPath(
+ routePoints,
+ );
+ pushTopologyPath(
+ `tap-rail-${panel.id}`,
+ [
+ { x: panel.anchorX, y: panel.anchorY },
+ { x: panel.anchorX, y: row.busY },
+ ],
+ tapOpacity,
+ );
+ pushTopologyNode({
+ id: `tap-node-${panel.id}`,
+ cx: panel.anchorX,
+ cy: row.busY,
+ r: clamp(cornerRadius * 0.18, 2.6, 4.4),
+ color: BUSBAR_COLOR,
+ opacity: 0.3,
+ });
+
+ if (!panel.producing) {
+ continue;
+ }
+
+ if (!routePath.d) {
+ continue;
+ }
+
+ const travel = Math.max(FLOW_PULSE_LENGTH_PX, routePath.length);
+ const travelWithEndpointClearance = travel + FLOW_PULSE_LENGTH_PX;
+ flowPaths.push({
+ id: `flow-${panel.id}`,
+ panelId: panel.id,
+ d: routePath.d,
+ color: panel.accentColor,
+ opacity: 0.62 + Math.min(panel.intensity, 1) * 0.2,
+ delayMs: 0,
+ durationMs: Math.round(travelWithEndpointClearance * FLOW_MS_PER_PX),
+ travelPx: travel,
+ offsetPx: -travelWithEndpointClearance,
+ });
+ }
+ }
+
+ return { flowPaths, topologyPaths, topologyNodes, collectorX, rowBuses };
+};
+
+const buildGridPaths = (
+ panelPoints: PanelPoint[],
+ producingPanelIds: Set,
+ connector: KpiConnector,
+ cornerRadius: number,
+ cardWidth: number,
+ gridLeft: number,
+ routeMode: PowerRailRouteMode,
+): Pick => {
+ const routePanels = panelPoints.map((panelPoint): LeftCollectorGridPanelInput => ({
+ id: panelPoint.panel.id,
+ row: panelPoint.row,
+ anchorX: resolveAnchorX(panelPoint),
+ anchorY: panelPoint.top + 1.5,
+ left: panelPoint.left,
+ right: panelPoint.right,
+ top: panelPoint.top,
+ bottom: panelPoint.bottom,
+ accentColor: panelPoint.panel.accentColor,
+ intensity: panelPoint.panel.intensity,
+ producing: producingPanelIds.has(panelPoint.panel.id),
+ }));
+ const routes = buildLeftCollectorGridRoutes(
+ routePanels,
+ connector.entry,
+ cornerRadius,
+ cardWidth,
+ gridLeft,
+ routeMode,
+ );
+ return {
+ flowPaths: routes.flowPaths,
+ topologyPaths: routes.topologyPaths,
+ topologyNodes: routes.topologyNodes,
+ };
+};
+
+export const buildMotionOverlayModel = (
+ host: HTMLElement,
+ root: ShadowRoot,
+ config: SolarPanelVisualizerConfig,
+ derived: DerivedArrayState,
+): MotionOverlayModel | null => {
+ const cardElement = root.querySelector("ha-card");
+ if (!cardElement) {
+ return null;
+ }
+ const cardRect = cardElement.getBoundingClientRect();
+ if (cardRect.width <= 0 || cardRect.height <= 0) {
+ return null;
+ }
+ const scrollOffset = {
+ x: cardElement.scrollLeft,
+ y: cardElement.scrollTop,
+ };
+
+ const powerTarget = root.querySelector('[data-kpi="power"]');
+ if (!powerTarget) {
+ return null;
+ }
+
+ const allPanelBounds = derived.panels
+ .map((panel) => root.querySelector(`[data-panel-id="${panel.id}"]`))
+ .filter((element): element is HTMLElement => Boolean(element))
+ .map((element) => {
+ const rect = element.getBoundingClientRect();
+ return {
+ left: rect.left - cardRect.left + scrollOffset.x,
+ right: rect.right - cardRect.left + scrollOffset.x,
+ top: rect.top - cardRect.top + scrollOffset.y,
+ bottom: rect.bottom - cardRect.top + scrollOffset.y,
+ };
+ })
+ .filter((bounds) => Number.isFinite(bounds.left) && Number.isFinite(bounds.right));
+ const gridLeft = allPanelBounds.length > 0 ? Math.min(...allPanelBounds.map((item) => item.left)) : 0;
+ const gridRight =
+ allPanelBounds.length > 0 ? Math.max(...allPanelBounds.map((item) => item.right)) : cardRect.width;
+
+ const connectedPanels = derived.panels.filter(
+ (panel) => !panel.hiddenSlot && panel.enabled && Boolean(panel.powerEntityName),
+ );
+ if (connectedPanels.length === 0) {
+ return null;
+ }
+
+ const producingPanelIds = new Set(
+ connectedPanels
+ .filter((panel) => typeof panel.power === "number" && panel.power > 0)
+ .map((panel) => panel.id),
+ );
+
+ const measuredPanelPoints = connectedPanels
+ .map((panel) => ({
+ panel,
+ element: root.querySelector(`[data-panel-id="${panel.id}"]`),
+ }))
+ .filter(
+ (
+ panelEntry,
+ ): panelEntry is {
+ panel: (typeof connectedPanels)[number];
+ element: HTMLElement;
+ } => Boolean(panelEntry.element),
+ )
+ .map((panelEntry) => {
+ const rect = panelEntry.element.getBoundingClientRect();
+ const center = {
+ x: rect.left - cardRect.left + scrollOffset.x + rect.width / 2,
+ y: rect.top - cardRect.top + scrollOffset.y + rect.height / 2,
+ };
+ const statusElement = panelEntry.element.querySelector(".status");
+ const statusRect = statusElement?.getBoundingClientRect();
+ const statusCenterX =
+ statusRect && statusRect.width > 0
+ ? statusRect.left - cardRect.left + scrollOffset.x + statusRect.width * 0.33
+ : center.x;
+ return {
+ panel: panelEntry.panel,
+ row: 0,
+ center,
+ statusCenterX,
+ left: rect.left - cardRect.left + scrollOffset.x,
+ right: rect.right - cardRect.left + scrollOffset.x,
+ top: rect.top - cardRect.top + scrollOffset.y,
+ bottom: rect.bottom - cardRect.top + scrollOffset.y,
+ };
+ });
+
+ const renderedRows = assignRenderedRowIndexes(measuredPanelPoints);
+ const panelPoints = measuredPanelPoints.map((panelPoint, index) => ({
+ ...panelPoint,
+ row: renderedRows[index] ?? 0,
+ }));
+ if (panelPoints.length === 0) {
+ return null;
+ }
+
+ const averagePanelSize =
+ panelPoints.reduce((sum, panelPoint) => {
+ const width = panelPoint.right - panelPoint.left;
+ const height = panelPoint.bottom - panelPoint.top;
+ return sum + Math.min(width, height);
+ }, 0) / panelPoints.length;
+ const cornerRadius = clamp(Math.round(averagePanelSize * 0.2), 18, 34);
+ const powerRect = powerTarget.getBoundingClientRect();
+ const powerCenterX = powerRect.left - cardRect.left + scrollOffset.x + powerRect.width / 2;
+ const useDirectFirstRowRoute = shouldUseBottomPowerEntry(
+ cardRect.width,
+ gridLeft,
+ gridRight,
+ powerCenterX,
+ );
+ const connector = resolveConnector(
+ cardRect,
+ powerRect,
+ useDirectFirstRowRoute,
+ scrollOffset,
+ );
+ const { flowPaths, topologyPaths, topologyNodes } = buildGridPaths(
+ panelPoints,
+ producingPanelIds,
+ connector,
+ cornerRadius,
+ cardRect.width,
+ gridLeft,
+ useDirectFirstRowRoute ? "direct-first-row" : "left-collector",
+ );
+
+ return {
+ width: Math.max(
+ cardElement.clientWidth,
+ cardRect.width,
+ gridRight,
+ connector.entry.x,
+ ),
+ height: Math.max(
+ cardElement.clientHeight,
+ ...panelPoints.map((panelPoint) => panelPoint.bottom),
+ connector.entry.y,
+ ),
+ flowPaths,
+ topologyPaths,
+ topologyNodes,
+ };
+};
+
+export const renderMotionOverlay = (
+ model: MotionOverlayModel | null,
+ options: {
+ showTopology: boolean;
+ showFlow: boolean;
+ activeFlowPanelId: string | null;
+ powerImpactActive: boolean;
+ },
+) => {
+ if (!model || (model.flowPaths.length === 0 && model.topologyPaths.length === 0)) {
+ return nothing;
+ }
+
+ const visibleFlowPaths = model.flowPaths.filter(
+ (path) =>
+ options.showFlow &&
+ options.activeFlowPanelId !== null &&
+ path.panelId === options.activeFlowPanelId,
+ );
+
+ return html`
+
+ `;
+};
diff --git a/src/motion-styles.ts b/src/motion-styles.ts
new file mode 100644
index 0000000..754ef5c
--- /dev/null
+++ b/src/motion-styles.ts
@@ -0,0 +1,305 @@
+import { css } from "lit";
+
+export const motionStyles = css`
+ .chrome {
+ display: none;
+ }
+
+ .panel {
+ background: linear-gradient(180deg, rgba(8, 17, 32, 0.94), rgba(8, 17, 32, 0.98));
+ border-color: var(--panel-border-accent, rgba(255, 255, 255, 0.2));
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.07),
+ 0 0 calc(1px + var(--panel-intensity, 0.4) * 2.5px) var(--panel-glow-accent, transparent);
+ transition: border-color 180ms ease, box-shadow 180ms ease;
+ }
+
+ .panel::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ pointer-events: none;
+ background:
+ radial-gradient(circle at 70% 78%, var(--panel-fill-accent, transparent) 0%, transparent 62%),
+ radial-gradient(
+ circle at 36% 62%,
+ color-mix(in srgb, var(--panel-fill-accent, transparent) 65%, transparent) 0%,
+ transparent 58%
+ );
+ opacity: 1;
+ filter: blur(1.5px);
+ }
+
+ .panel::before {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 36%);
+ opacity: 0.42;
+ }
+
+ .panel:hover {
+ transform: none;
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.09),
+ 0 0 calc(2px + var(--panel-intensity, 0.4) * 3.5px) var(--panel-glow-accent, transparent);
+ }
+
+ .panel.hidden-slot {
+ background: linear-gradient(180deg, rgba(10, 17, 29, 0.78), rgba(7, 12, 21, 0.82));
+ border-color: rgba(255, 255, 255, 0.18) !important;
+ box-shadow: none;
+ }
+
+ ha-card[data-spv-theme="light"] .panel {
+ background: linear-gradient(180deg, rgba(251, 254, 255, 0.96), rgba(230, 240, 249, 0.98));
+ border-color: var(--panel-border-accent, rgba(45, 67, 90, 0.24));
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.9),
+ 0 0 calc(1px + var(--panel-intensity, 0.4) * 2px) var(--panel-glow-accent, transparent);
+ }
+
+ ha-card[data-spv-theme="light"] .panel::before {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.42), transparent 38%);
+ opacity: 0.36;
+ }
+
+ ha-card[data-spv-theme="light"] .panel::after {
+ background:
+ radial-gradient(circle at 70% 78%, var(--panel-fill-accent, transparent) 0%, transparent 64%),
+ radial-gradient(
+ circle at 36% 62%,
+ color-mix(in srgb, var(--panel-fill-accent, transparent) 48%, transparent) 0%,
+ transparent 58%
+ );
+ filter: blur(1px);
+ opacity: 0.72;
+ }
+
+ ha-card[data-spv-theme="light"] .panel:hover {
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.94),
+ 0 0 calc(2px + var(--panel-intensity, 0.4) * 3px) var(--panel-glow-accent, transparent);
+ }
+
+ ha-card[data-spv-theme="light"] .panel.hidden-slot {
+ background: linear-gradient(180deg, rgba(238, 246, 252, 0.72), rgba(222, 234, 244, 0.82));
+ border-color: rgba(58, 80, 104, 0.32) !important;
+ }
+
+ ha-card[data-spv-theme="light"] .spv-motion-backbone {
+ stroke: rgba(74, 171, 204, 0.78);
+ stroke-width: 3.1;
+ opacity: calc(var(--spv-motion-opacity, 0.18) * 2.25 + 0.34);
+ mix-blend-mode: normal;
+ filter:
+ drop-shadow(0 1px 0 rgba(255, 255, 255, 0.72))
+ drop-shadow(0 0 2px rgba(74, 171, 204, 0.24));
+ }
+
+ ha-card[data-spv-theme="light"] .spv-motion-node {
+ fill: rgba(74, 171, 204, 0.78);
+ opacity: 0.92;
+ mix-blend-mode: normal;
+ filter:
+ drop-shadow(0 1px 0 rgba(255, 255, 255, 0.75))
+ drop-shadow(0 0 2px rgba(74, 171, 204, 0.24));
+ }
+
+ :host([data-spv-busbars]) .grid {
+ column-gap: 16px;
+ row-gap: 22px;
+ margin-top: 20px;
+ }
+
+ .spv-motion-overlay-svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+ overflow: visible;
+ }
+
+ .spv-motion-flow,
+ .spv-motion-backbone {
+ fill: none;
+ stroke-linejoin: miter;
+ pointer-events: none;
+ }
+
+ .spv-motion-backbone {
+ stroke: color-mix(in srgb, var(--secondary-text-color, #cdd7e4) 82%, white 18%);
+ stroke-linecap: butt;
+ stroke-width: 2.8;
+ opacity: var(--spv-motion-opacity, 0.22);
+ mix-blend-mode: screen;
+ filter: drop-shadow(
+ 0 0 2px color-mix(in srgb, var(--secondary-text-color, #cdd7e4) 24%, transparent)
+ );
+ }
+
+ .spv-motion-node {
+ fill: color-mix(in srgb, var(--secondary-text-color, #d7e0eb) 88%, white 12%);
+ opacity: var(--spv-motion-opacity, 0.75);
+ filter: drop-shadow(
+ 0 0 2px color-mix(in srgb, var(--secondary-text-color, #d7e0eb) 32%, transparent)
+ );
+ }
+
+ .spv-motion-node.is-socket-impact {
+ animation: spv-socket-impact 900ms ease-out;
+ }
+
+ .spv-motion-flow {
+ stroke-linecap: round;
+ stroke: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--spv-motion-color, rgba(111, 201, 255, 0.9)) 62%, #ff9d35) 82%,
+ #ffd08a
+ );
+ stroke-width: 8.4;
+ stroke-dasharray: 28 2500;
+ opacity: var(--spv-motion-opacity, 0.68);
+ mix-blend-mode: screen;
+ filter:
+ drop-shadow(0 0 10px color-mix(in srgb, var(--spv-motion-color) 48%, #ff9d35 52%))
+ drop-shadow(0 0 20px color-mix(in srgb, var(--spv-motion-color) 36%, #ffb04e 64%));
+ animation: spv-flow-pulse var(--spv-flow-duration, 1800ms) linear;
+ animation-delay: var(--spv-flow-delay, 0ms);
+ animation-fill-mode: both;
+ }
+
+ ha-card[data-spv-theme="light"] .spv-motion-flow {
+ stroke: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--spv-motion-color, #11a36a) 66%, #f59f22) 82%,
+ #4f3406
+ );
+ stroke-width: 9.4;
+ opacity: 0.96;
+ mix-blend-mode: normal;
+ filter:
+ drop-shadow(0 0 4px color-mix(in srgb, var(--spv-motion-color, #11a36a) 64%, #f59f22 36%))
+ drop-shadow(0 0 10px rgba(138, 91, 12, 0.38))
+ drop-shadow(0 1px 0 rgba(255, 255, 255, 0.52));
+ }
+
+ .summary-chip.spv-motion-shimmer {
+ background-size: 220% 100%, auto, auto;
+ animation: spv-shimmer 1100ms cubic-bezier(0.2, 0.85, 0.2, 1);
+ background-image:
+ linear-gradient(
+ 112deg,
+ transparent 18%,
+ rgba(255, 255, 255, 0.14) 46%,
+ rgba(255, 255, 255, 0.03) 58%,
+ transparent 76%
+ ),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.05));
+ }
+
+ .summary-chip.spv-motion-power-impact {
+ --spv-impact-color: color-mix(
+ in srgb,
+ var(--spv-shimmer-color, rgba(111, 201, 255, 0.6)) 70%,
+ transparent
+ );
+ animation: spv-alert-ripple 900ms ease-out;
+ }
+
+ .summary-chip.spv-motion-neutral-impact {
+ --spv-impact-color: rgba(214, 223, 235, 0.36);
+ animation: spv-alert-ripple 900ms ease-out;
+ }
+
+ .panel.spv-motion-alert-ripple {
+ --spv-impact-color: rgba(255, 98, 127, 0.42);
+ animation: spv-alert-ripple 900ms ease-out;
+ }
+
+ .panel.spv-motion-alert-ripple .status {
+ animation: spv-alert-badge 900ms ease-out;
+ }
+
+ .panel .inverter-status {
+ position: relative;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--spv-text-soft);
+ }
+
+ @keyframes spv-flow-pulse {
+ 0% {
+ opacity: var(--spv-motion-opacity, 0.82);
+ stroke-dashoffset: 0;
+ }
+ 100% {
+ opacity: var(--spv-motion-opacity, 0.82);
+ stroke-dashoffset: var(--spv-flow-offset, calc(-1 * var(--spv-flow-travel, 220px)));
+ }
+ }
+
+ @keyframes spv-shimmer {
+ 0% {
+ background-position: 140% 0, 0 0, 0 0;
+ }
+ 100% {
+ background-position: -120% 0, 0 0, 0 0;
+ }
+ }
+
+ @keyframes spv-socket-impact {
+ 0% {
+ opacity: 0.95;
+ transform: scale(1);
+ transform-origin: center;
+ filter: drop-shadow(0 0 4px color-mix(in srgb, var(--spv-motion-color) 48%, transparent));
+ }
+ 60% {
+ opacity: 0.25;
+ transform: scale(1.85);
+ transform-origin: center;
+ filter: drop-shadow(0 0 14px color-mix(in srgb, var(--spv-motion-color) 62%, transparent));
+ }
+ 100% {
+ opacity: 0.95;
+ transform: scale(1);
+ transform-origin: center;
+ filter: drop-shadow(0 0 4px color-mix(in srgb, var(--spv-motion-color) 48%, transparent));
+ }
+ }
+
+ @keyframes spv-alert-ripple {
+ 0% {
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.08),
+ 0 0 0 0 var(--spv-impact-color, rgba(255, 98, 127, 0.42)),
+ 0 10px 24px rgba(0, 0, 0, 0.18);
+ }
+ 55% {
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.08),
+ 0 0 0 7px color-mix(in srgb, var(--spv-impact-color, rgba(255, 98, 127, 0.42)) 0%, transparent),
+ 0 10px 24px rgba(0, 0, 0, 0.18);
+ }
+ 100% {
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.08),
+ 0 10px 24px rgba(0, 0, 0, 0.18);
+ }
+ }
+
+ @keyframes spv-alert-badge {
+ 0% {
+ transform: scale(1);
+ box-shadow: 0 0 0 0 rgba(255, 98, 127, 0.32);
+ }
+ 50% {
+ transform: scale(1.045);
+ box-shadow: 0 0 0 5px rgba(255, 98, 127, 0);
+ }
+ 100% {
+ transform: scale(1);
+ box-shadow: 0 0 0 0 rgba(255, 98, 127, 0);
+ }
+ }
+`;
diff --git a/src/natural-sort.ts b/src/natural-sort.ts
new file mode 100644
index 0000000..37ee034
--- /dev/null
+++ b/src/natural-sort.ts
@@ -0,0 +1,17 @@
+const NATURAL_COLLATOR = new Intl.Collator("en", {
+ numeric: true,
+ sensitivity: "base",
+});
+
+export const naturalCompare = (left: string, right: string): number => {
+ const collatorResult = NATURAL_COLLATOR.compare(left, right);
+ if (collatorResult !== 0) {
+ return collatorResult;
+ }
+
+ if (left === right) {
+ return 0;
+ }
+
+ return left < right ? -1 : 1;
+};
diff --git a/src/panel-performance-label.ts b/src/panel-performance-label.ts
new file mode 100644
index 0000000..f30e7c7
--- /dev/null
+++ b/src/panel-performance-label.ts
@@ -0,0 +1,52 @@
+export type PanelPerformanceLabelVariant = "full" | "medium" | "compact";
+
+export interface PanelPerformanceLabelCandidate {
+ variant: PanelPerformanceLabelVariant;
+ text: string;
+}
+
+interface ChoosePanelPerformanceLabelOptions {
+ candidates: PanelPerformanceLabelCandidate[];
+ panelWidthPx: number;
+ panelHeightPx: number;
+ fontPx: number;
+ reservedRightPx?: number;
+ measureTextWidthPx: (text: string, fontPx: number) => number;
+}
+
+export const getPanelPerformanceTextWidth = (
+ panelWidthPx: number,
+ reservedRightPx = 0,
+): number => Math.max(36, panelWidthPx - 36 - Math.max(0, reservedRightPx));
+
+export const choosePanelPerformanceLabel = ({
+ candidates,
+ panelWidthPx,
+ panelHeightPx,
+ fontPx,
+ reservedRightPx = 0,
+ measureTextWidthPx,
+}: ChoosePanelPerformanceLabelOptions): PanelPerformanceLabelCandidate => {
+ const compact = candidates.find((candidate) => candidate.variant === "compact");
+ if (!compact) {
+ throw new Error("Panel performance label requires a compact candidate.");
+ }
+
+ if (panelHeightPx < 96 || panelWidthPx < 112) {
+ return compact;
+ }
+
+ const full = candidates.find((candidate) => candidate.variant === "full");
+ const slotSafeWidth = getPanelPerformanceTextWidth(panelWidthPx, reservedRightPx);
+ if (full && measureTextWidthPx(full.text, fontPx) <= slotSafeWidth) {
+ return full;
+ }
+
+ const normalWidth = getPanelPerformanceTextWidth(panelWidthPx);
+ const medium = candidates.find((candidate) => candidate.variant === "medium");
+ if (medium && measureTextWidthPx(medium.text, fontPx) <= normalWidth) {
+ return medium;
+ }
+
+ return compact;
+};
diff --git a/src/panel-state.ts b/src/panel-state.ts
new file mode 100644
index 0000000..2c630d5
--- /dev/null
+++ b/src/panel-state.ts
@@ -0,0 +1,940 @@
+import { DEFAULT_COLORS } from "./config";
+import { translateByLocale } from "./i18n";
+import type {
+ DerivedArrayState,
+ DerivedPanelState,
+ HassEntity,
+ HomeAssistant,
+ PanelConfig,
+ PanelStatus,
+ PowerSample,
+ SolarPanelVisualizerConfig,
+} from "./types";
+
+const UTILIZATION_MAX = 1.2;
+
+const INVALID_STATES = new Set(["unknown", "unavailable", "none", "null", ""]);
+
+const clamp = (value: number, min = 0, max = 1): number =>
+ Math.min(Math.max(value, min), max);
+
+const parseNumericState = (entity?: HassEntity): number | null => {
+ if (!entity) {
+ return null;
+ }
+ const raw = entity.state?.toString().trim().toLowerCase();
+ if (INVALID_STATES.has(raw)) {
+ return null;
+ }
+ const numeric = Number(entity.state);
+ return Number.isFinite(numeric) ? numeric : null;
+};
+
+const normalizeStatusText = (value: unknown): string | undefined => {
+ if (value === undefined || value === null) {
+ return undefined;
+ }
+ const asString = value
+ .toString()
+ .replace(/[\u200B-\u200D\uFEFF]/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+ return asString.length > 0 ? asString : undefined;
+};
+
+const tokenizeStatus = (value: string): string[] =>
+ value.toLowerCase().match(/[a-z0-9]+/g) ?? [];
+
+const statusMatchesTerms = (statusText: string, terms: string[]): boolean => {
+ const statusTokens = tokenizeStatus(statusText);
+ if (statusTokens.length === 0) {
+ return false;
+ }
+
+ return terms.some((term) => {
+ const termTokens = tokenizeStatus(term);
+ if (termTokens.length === 0 || termTokens.length > statusTokens.length) {
+ return false;
+ }
+
+ for (let startIndex = 0; startIndex <= statusTokens.length - termTokens.length; startIndex += 1) {
+ let matches = true;
+ for (let offset = 0; offset < termTokens.length; offset += 1) {
+ if (statusTokens[startIndex + offset] !== termTokens[offset]) {
+ matches = false;
+ break;
+ }
+ }
+ if (matches) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+};
+
+const entityLabel = (entity?: HassEntity, fallback?: string): string | undefined => {
+ const friendlyName = entity?.attributes?.friendly_name;
+ if (typeof friendlyName === "string" && friendlyName.trim().length > 0) {
+ return friendlyName;
+ }
+ return fallback;
+};
+
+const formatValue = (
+ value: number | null,
+ unit: string | undefined,
+ decimals: number,
+ unavailableText: string,
+ locale = "en",
+): string => {
+ if (value === null) {
+ return unavailableText;
+ }
+
+ const safeValue = Object.is(value, -0) ? 0 : value;
+
+ const formatted = new Intl.NumberFormat(locale, {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ }).format(safeValue);
+
+ return unit ? `${formatted} ${unit}` : formatted;
+};
+
+const toRgb = (value: string): [number, number, number] => {
+ const clean = value.replace("#", "");
+ const normalized =
+ clean.length === 3
+ ? clean
+ .split("")
+ .map((part) => `${part}${part}`)
+ .join("")
+ : clean;
+
+ return [
+ parseInt(normalized.slice(0, 2), 16),
+ parseInt(normalized.slice(2, 4), 16),
+ parseInt(normalized.slice(4, 6), 16),
+ ];
+};
+
+const blendColor = (start: string, end: string, ratio: number): string => {
+ const normalizedRatio = clamp(ratio);
+ const [sr, sg, sb] = toRgb(start);
+ const [er, eg, eb] = toRgb(end);
+ const blend = (from: number, to: number): number =>
+ Math.round(from + (to - from) * normalizedRatio);
+
+ return `rgb(${blend(sr, er)}, ${blend(sg, eg)}, ${blend(sb, eb)})`;
+};
+
+const mixThreeColor = (
+ start: string,
+ mid: string,
+ end: string,
+ ratio: number,
+): string => {
+ const normalizedRatio = clamp(ratio);
+ if (normalizedRatio <= 0.5) {
+ return blendColor(start, mid, normalizedRatio * 2);
+ }
+ return blendColor(mid, end, (normalizedRatio - 0.5) * 2);
+};
+
+const computeTrimmedUtilization = (ratios: number[]): number => {
+ if (ratios.length === 0) {
+ return 0;
+ }
+ const sorted = [...ratios].sort((a, b) => a - b);
+ const keepFrom = Math.floor(sorted.length * 0.4);
+ const highBand = sorted.slice(keepFrom);
+ const average =
+ highBand.reduce((sum, value) => sum + value, 0) / Math.max(highBand.length, 1);
+ return clamp(average, 0, UTILIZATION_MAX);
+};
+
+interface BasePanelState {
+ config: PanelConfig;
+ slotIndex: number;
+ label: string;
+ power: number | null;
+ energy: number | null;
+ powerShareFactor: number;
+ energyShareFactor: number;
+ stringGroupLabel?: string;
+ stringPowerSourceEntityId?: string;
+ stringPowerSharePercent?: number;
+ powerEntityName?: string;
+ inverterStatusEntityName?: string;
+ inverterStatusDisplay?: string;
+ inverterFaultMatched: boolean;
+ inverterWorkingMatched: boolean;
+ reason: string;
+ status: PanelStatus;
+ enabled: boolean;
+ hiddenSlot: boolean;
+}
+
+interface SharedSensorInfo {
+ entityId: string;
+ factor: number;
+ groupLabel?: string;
+}
+
+interface SharedSensorShares {
+ powerByPanelId: Map;
+ energyByPanelId: Map;
+}
+
+const applyShareFactor = (value: number | null, factor: number): number | null =>
+ value === null ? null : value * factor;
+
+const scaleSamples = (
+ samples: PowerSample[] | undefined,
+ factor: number,
+): PowerSample[] | undefined => {
+ if (!samples || factor === 1) {
+ return samples;
+ }
+ return samples.map((sample) => ({
+ ts: sample.ts,
+ value: sample.value * factor,
+ }));
+};
+
+const resolvePanelRatedPower = (
+ panel: PanelConfig,
+ config: SolarPanelVisualizerConfig,
+): number | null => {
+ const rated = panel.rated_power_w ?? config.default_panel_rated_power_w ?? null;
+ return rated !== null && Number.isFinite(rated) && rated > 0 ? rated : null;
+};
+
+const buildSharedEntityShares = (
+ config: SolarPanelVisualizerConfig,
+ key: "power_entity" | "energy_entity",
+ labelPowerGroups: boolean,
+): Map => {
+ if (!(config.enable_string_sensor_sharing ?? false)) {
+ return new Map();
+ }
+
+ const groups = new Map<
+ string,
+ Array<{ panel: PanelConfig; slotIndex: number; ratedPower: number | null }>
+ >();
+
+ config.panels.forEach((panel, slotIndex) => {
+ if (panel.enabled === false) {
+ return;
+ }
+ const entityId = panel[key]?.trim();
+ if (!entityId) {
+ return;
+ }
+ const group = groups.get(entityId) ?? [];
+ group.push({
+ panel,
+ slotIndex,
+ ratedPower: resolvePanelRatedPower(panel, config),
+ });
+ groups.set(entityId, group);
+ });
+
+ const sortedGroups = [...groups.entries()]
+ .filter(([, group]) => group.length > 1)
+ .sort(([, a], [, b]) => a[0].slotIndex - b[0].slotIndex);
+
+ const result = new Map();
+ sortedGroups.forEach(([entityId, group], index) => {
+ const useEqualSplit = group.some(
+ (entry) => entry.ratedPower === null || entry.ratedPower <= 0,
+ );
+ const totalRated = group.reduce(
+ (sum, entry) => sum + (entry.ratedPower ?? 0),
+ 0,
+ );
+ const groupLabel = labelPowerGroups ? `String ${index + 1}` : undefined;
+ const equalFactor = 1 / group.length;
+
+ group.forEach((entry) => {
+ const factor =
+ useEqualSplit || totalRated <= 0
+ ? equalFactor
+ : (entry.ratedPower ?? 0) / totalRated;
+ result.set(entry.panel.id, {
+ entityId,
+ factor,
+ groupLabel,
+ });
+ });
+ });
+
+ return result;
+};
+
+const buildSharedSensorShares = (
+ config: SolarPanelVisualizerConfig,
+): SharedSensorShares => ({
+ powerByPanelId: buildSharedEntityShares(config, "power_entity", true),
+ energyByPanelId: buildSharedEntityShares(config, "energy_entity", false),
+});
+
+interface EligibleDeviationPanel {
+ id: string;
+ effectivePower: number;
+ livePower: number;
+ ratedPowerW: number;
+ ratedForDeviationW: number;
+ isDerated: boolean;
+ sampleCount: number;
+ firstSampleTs: number | null;
+}
+
+interface DeriveStateOptions {
+ deviationEnabled?: boolean;
+ historyByEntityId?: Record;
+ nowMs?: number;
+}
+
+const getWindowedSamples = (
+ samples: PowerSample[],
+ nowMs: number,
+ smoothingMinutes: number,
+): PowerSample[] => {
+ if (smoothingMinutes <= 0) {
+ return samples;
+ }
+ const threshold = nowMs - smoothingMinutes * 60 * 1000;
+ return samples.filter((sample) => sample.ts >= threshold);
+};
+
+const getMedianIntervalMinutes = (samples: PowerSample[]): number => {
+ if (samples.length < 2) {
+ return 1;
+ }
+
+ const intervals: number[] = [];
+ for (let index = 1; index < samples.length; index += 1) {
+ const diffMinutes = (samples[index].ts - samples[index - 1].ts) / 60_000;
+ if (Number.isFinite(diffMinutes) && diffMinutes > 0) {
+ intervals.push(diffMinutes);
+ }
+ }
+
+ if (intervals.length === 0) {
+ return 1;
+ }
+
+ intervals.sort((a, b) => a - b);
+ const mid = Math.floor(intervals.length / 2);
+ if (intervals.length % 2 === 0) {
+ return (intervals[mid - 1] + intervals[mid]) / 2;
+ }
+ return intervals[mid];
+};
+
+const getEffectivePower = (
+ livePower: number,
+ samples: PowerSample[] | undefined,
+ nowMs: number,
+ smoothingMinutes: number,
+): { value: number; sampleCount: number; firstSampleTs: number | null } => {
+ if (!samples || samples.length === 0) {
+ return {
+ value: livePower,
+ sampleCount: 1,
+ firstSampleTs: nowMs,
+ };
+ }
+
+ const normalizedSamples = samples
+ .filter((sample) => Number.isFinite(sample.value))
+ .sort((a, b) => a.ts - b.ts);
+ if (normalizedSamples.length === 0) {
+ return {
+ value: livePower,
+ sampleCount: 1,
+ firstSampleTs: nowMs,
+ };
+ }
+
+ const windowed = getWindowedSamples(normalizedSamples, nowMs, smoothingMinutes);
+ const medianIntervalMinutes = getMedianIntervalMinutes(normalizedSamples);
+ const targetSampleCount =
+ smoothingMinutes > 0
+ ? Math.max(2, Math.ceil(smoothingMinutes / Math.max(medianIntervalMinutes, 0.1)) + 1)
+ : 1;
+
+ let effectiveSet = windowed.length > 0 ? windowed : normalizedSamples;
+ if (
+ smoothingMinutes > 0 &&
+ effectiveSet.length < targetSampleCount &&
+ normalizedSamples.length > effectiveSet.length
+ ) {
+ effectiveSet = normalizedSamples.slice(-targetSampleCount);
+ }
+
+ const average =
+ effectiveSet.reduce((sum, sample) => sum + sample.value, 0) /
+ Math.max(effectiveSet.length, 1);
+
+ return {
+ value: average,
+ sampleCount: effectiveSet.length,
+ firstSampleTs: normalizedSamples[0].ts ?? null,
+ };
+};
+
+export const deriveArrayState = (
+ config: SolarPanelVisualizerConfig,
+ hass?: HomeAssistant,
+ options?: DeriveStateOptions,
+): DerivedArrayState => {
+ const locale = hass?.locale?.language ?? "en";
+ const t = (
+ key: Parameters[1],
+ params?: Parameters[2],
+ ): string => translateByLocale(locale, key, params);
+ const unavailableText = t("common.unavailable");
+ const nowMs = options?.nowMs ?? Date.now();
+ const colors = { ...DEFAULT_COLORS, ...(config.colors ?? {}) };
+ const inverterStatusEnabled = config.enable_inverter_status ?? false;
+ const inverterFaultTerms = (config.inverter_fault_terms ?? [])
+ .map((term) => term.trim().toLowerCase())
+ .filter((term) => term.length > 0);
+ const inverterWorkingTerms = (config.inverter_working_terms ?? [])
+ .map((term) => term.trim().toLowerCase())
+ .filter((term) => term.length > 0);
+ const productionColorIntensity = config.production_color_intensity ?? 1;
+ const deviationEnabled = options?.deviationEnabled ?? true;
+ const minActivePanels = config.deviation_min_active_panels ?? 3;
+ const minSamples = config.deviation_min_samples ?? 3;
+ const minRuntimeMinutes = config.deviation_min_runtime_minutes ?? 15;
+ const smoothingMinutes = config.deviation_smoothing_minutes ?? 0;
+ const dynamicFloorW = config.deviation_dynamic_floor_w ?? 20;
+ const deviationThresholdPercent = config.deviation_threshold_percent ?? 15;
+ const deviationAbsoluteThresholdW = config.deviation_absolute_w_threshold ?? 50;
+ const historyByEntityId = options?.historyByEntityId ?? {};
+ const sharedSensorShares = buildSharedSensorShares(config);
+
+ const baseStates: BasePanelState[] = config.panels.map((panel, index) => {
+ const enabled = panel.enabled ?? true;
+ const label = panel.name ?? `Panel ${index + 1}`;
+ const powerShare = sharedSensorShares.powerByPanelId.get(panel.id);
+ const energyShare = sharedSensorShares.energyByPanelId.get(panel.id);
+ const powerShareFactor = powerShare?.factor ?? 1;
+ const energyShareFactor = energyShare?.factor ?? 1;
+ const sharedFields = {
+ powerShareFactor,
+ energyShareFactor,
+ stringGroupLabel: powerShare?.groupLabel,
+ stringPowerSourceEntityId: powerShare?.entityId,
+ stringPowerSharePercent: powerShare ? powerShare.factor * 100 : undefined,
+ };
+ const inverterStatusEntityId =
+ panel.inverter_status_entity ?? panel.error_entity;
+ const inverterStatusEntity = inverterStatusEntityId
+ ? hass?.states?.[inverterStatusEntityId]
+ : undefined;
+ const inverterStatusDisplay = inverterStatusEntityId
+ ? normalizeStatusText(inverterStatusEntity?.state) ?? unavailableText
+ : undefined;
+ const inverterStatusEntityName = entityLabel(
+ inverterStatusEntity,
+ inverterStatusEntityId,
+ );
+ const inverterFaultMatched =
+ inverterStatusDisplay !== undefined &&
+ inverterStatusDisplay !== unavailableText &&
+ statusMatchesTerms(inverterStatusDisplay, inverterFaultTerms);
+ const inverterWorkingMatched =
+ inverterStatusDisplay !== undefined &&
+ inverterStatusDisplay !== unavailableText &&
+ statusMatchesTerms(inverterStatusDisplay, inverterWorkingTerms);
+
+ if (!enabled) {
+ return {
+ config: panel,
+ slotIndex: index,
+ label,
+ power: null,
+ energy: null,
+ ...sharedFields,
+ inverterStatusEntityName,
+ inverterStatusDisplay,
+ inverterFaultMatched,
+ inverterWorkingMatched,
+ reason: t("state.reason.slot_hidden"),
+ status: "disabled",
+ enabled: false,
+ hiddenSlot: true,
+ };
+ }
+
+ if (!panel.power_entity) {
+ return {
+ config: panel,
+ slotIndex: index,
+ label,
+ power: null,
+ energy: null,
+ ...sharedFields,
+ inverterStatusEntityName,
+ inverterStatusDisplay,
+ inverterFaultMatched,
+ inverterWorkingMatched,
+ reason: t("state.reason.select_power_sensor"),
+ status: "unconfigured",
+ enabled: true,
+ hiddenSlot: false,
+ };
+ }
+
+ const powerEntity = hass?.states?.[panel.power_entity];
+ const energyEntity = panel.energy_entity
+ ? hass?.states?.[panel.energy_entity]
+ : undefined;
+
+ if (!powerEntity) {
+ return {
+ config: panel,
+ slotIndex: index,
+ label,
+ power: null,
+ energy: null,
+ ...sharedFields,
+ inverterStatusEntityName,
+ inverterStatusDisplay,
+ inverterFaultMatched,
+ inverterWorkingMatched,
+ reason: t("state.reason.power_entity_missing", {
+ entity: panel.power_entity,
+ }),
+ status: "offline",
+ enabled: true,
+ hiddenSlot: false,
+ };
+ }
+
+ if (
+ inverterStatusEnabled &&
+ inverterStatusDisplay &&
+ inverterStatusDisplay !== unavailableText &&
+ inverterFaultMatched
+ ) {
+ return {
+ config: panel,
+ slotIndex: index,
+ label,
+ power: applyShareFactor(parseNumericState(powerEntity), powerShareFactor),
+ energy: applyShareFactor(parseNumericState(energyEntity), energyShareFactor),
+ ...sharedFields,
+ powerEntityName: entityLabel(powerEntity, panel.power_entity),
+ inverterStatusEntityName,
+ inverterStatusDisplay,
+ inverterFaultMatched,
+ inverterWorkingMatched,
+ reason: t("state.reason.inverter_fault_match", {
+ status: inverterStatusDisplay,
+ }),
+ status: "error",
+ enabled: true,
+ hiddenSlot: false,
+ };
+ }
+
+ if (
+ inverterStatusEnabled &&
+ inverterStatusDisplay &&
+ inverterStatusDisplay !== unavailableText &&
+ inverterWorkingTerms.length > 0 &&
+ !inverterWorkingMatched
+ ) {
+ return {
+ config: panel,
+ slotIndex: index,
+ label,
+ power: applyShareFactor(parseNumericState(powerEntity), powerShareFactor),
+ energy: applyShareFactor(parseNumericState(energyEntity), energyShareFactor),
+ ...sharedFields,
+ powerEntityName: entityLabel(powerEntity, panel.power_entity),
+ inverterStatusEntityName,
+ inverterStatusDisplay,
+ inverterFaultMatched,
+ inverterWorkingMatched,
+ reason: t("state.reason.inverter_working_mismatch", {
+ status: inverterStatusDisplay,
+ }),
+ status: "inverter",
+ enabled: true,
+ hiddenSlot: false,
+ };
+ }
+
+ const power = applyShareFactor(parseNumericState(powerEntity), powerShareFactor);
+ const energy = applyShareFactor(parseNumericState(energyEntity), energyShareFactor);
+ if (power === null) {
+ return {
+ config: panel,
+ slotIndex: index,
+ label,
+ power: null,
+ energy,
+ ...sharedFields,
+ powerEntityName: entityLabel(powerEntity, panel.power_entity),
+ inverterStatusEntityName,
+ inverterStatusDisplay,
+ inverterFaultMatched,
+ inverterWorkingMatched,
+ reason: t("state.reason.power_entity_unavailable", {
+ entity: panel.power_entity,
+ }),
+ status: "offline",
+ enabled: true,
+ hiddenSlot: false,
+ };
+ }
+
+ return {
+ config: panel,
+ slotIndex: index,
+ label,
+ power,
+ energy,
+ ...sharedFields,
+ powerEntityName: entityLabel(powerEntity, panel.power_entity),
+ inverterStatusEntityName,
+ inverterStatusDisplay,
+ inverterFaultMatched,
+ inverterWorkingMatched,
+ reason: t("state.reason.producing_expected"),
+ status: "normal",
+ enabled: true,
+ hiddenSlot: false,
+ };
+ });
+
+ const productionCandidates = baseStates.filter(
+ (panel) => panel.status === "normal" && panel.power !== null,
+ );
+ const activePowers = productionCandidates.map((panel) => panel.power ?? 0);
+ const maxPower = activePowers.length > 0 ? Math.max(...activePowers) : 0;
+
+ let deviationCount = 0;
+ let inverterCount = 0;
+ let errorCount = 0;
+ let offlineCount = 0;
+ let normalCount = 0;
+ let unconfiguredCount = 0;
+ let disabledCount = 0;
+ let deviationSuppressedReason: string | undefined;
+ let deviationReady = false;
+
+ const eligibleDeviationPanels: EligibleDeviationPanel[] = baseStates.flatMap((panel) => {
+ if (panel.status !== "normal" || panel.power === null) {
+ return [];
+ }
+ const ratedPowerW =
+ panel.config.rated_power_w ?? config.default_panel_rated_power_w ?? null;
+ if (ratedPowerW === null || ratedPowerW <= 0 || !panel.config.power_entity) {
+ return [];
+ }
+ const deratePercent = Math.min(
+ Math.max(panel.config.deviation_derate_percent ?? 100, 1),
+ 100,
+ );
+ const ratedForDeviationW = ratedPowerW * (deratePercent / 100);
+ if (ratedForDeviationW <= 0) {
+ return [];
+ }
+
+ const effective = getEffectivePower(
+ panel.power,
+ scaleSamples(
+ historyByEntityId[panel.config.power_entity],
+ panel.powerShareFactor,
+ ),
+ nowMs,
+ smoothingMinutes,
+ );
+
+ return [
+ {
+ id: panel.config.id,
+ effectivePower: effective.value,
+ livePower: panel.power,
+ ratedPowerW,
+ ratedForDeviationW,
+ isDerated: deratePercent < 100,
+ sampleCount: effective.sampleCount,
+ firstSampleTs: effective.firstSampleTs,
+ },
+ ];
+ });
+
+ const baselinePanels = eligibleDeviationPanels.filter((panel) => !panel.isDerated);
+
+ const minSampleCount =
+ baselinePanels.length > 0
+ ? Math.min(...baselinePanels.map((panel) => panel.sampleCount))
+ : 0;
+ const minRuntimeAgeMinutes =
+ baselinePanels.length > 0
+ ? Math.min(
+ ...baselinePanels.map((panel) =>
+ panel.firstSampleTs === null
+ ? 0
+ : (nowMs - panel.firstSampleTs) / 60000,
+ ),
+ )
+ : 0;
+
+ if (!deviationEnabled) {
+ deviationSuppressedReason = t("state.reason.array_check_disabled");
+ } else if (baselinePanels.length < minActivePanels) {
+ deviationSuppressedReason = t("state.reason.need_non_derated_panels", {
+ count: minActivePanels,
+ });
+ } else if (minSampleCount < minSamples) {
+ deviationSuppressedReason = t("state.reason.collecting_samples", {
+ current: minSampleCount,
+ required: minSamples,
+ });
+ } else if (minRuntimeAgeMinutes < minRuntimeMinutes) {
+ deviationSuppressedReason = t("state.reason.warmup_progress", {
+ current: Math.floor(minRuntimeAgeMinutes),
+ required: minRuntimeMinutes,
+ });
+ } else {
+ deviationReady = true;
+ }
+
+ const utilizationRatios = baselinePanels.map((panel) =>
+ clamp(panel.effectivePower / panel.ratedForDeviationW, 0, UTILIZATION_MAX),
+ );
+ const arrayUtilizationFactor = computeTrimmedUtilization(utilizationRatios);
+
+ if (deviationReady) {
+ const representativeTarget = Math.max(
+ ...baselinePanels.map(
+ (panel) => panel.ratedForDeviationW * arrayUtilizationFactor,
+ ),
+ 0,
+ );
+ if (representativeTarget < dynamicFloorW) {
+ deviationReady = false;
+ deviationSuppressedReason = t("state.reason.low_light_pause", {
+ floor: dynamicFloorW.toFixed(0),
+ });
+ }
+ }
+
+ const panels: DerivedPanelState[] = baseStates.map((panel) => {
+ const powerUnit =
+ (panel.config.power_entity
+ ? (hass?.states?.[panel.config.power_entity]?.attributes
+ ?.unit_of_measurement as string | undefined)
+ : undefined) ??
+ t("state.power.default_unit");
+
+ const energyUnit =
+ (panel.config.energy_entity
+ ? (hass?.states?.[panel.config.energy_entity]?.attributes
+ ?.unit_of_measurement as string | undefined)
+ : undefined);
+ const hasEnergySensor = Boolean(panel.config.energy_entity);
+ const showPanelEnergy = panel.config.show_energy ?? false;
+ const stringPowerSourceName = panel.stringPowerSourceEntityId
+ ? entityLabel(
+ hass?.states?.[panel.stringPowerSourceEntityId],
+ panel.stringPowerSourceEntityId,
+ )
+ : undefined;
+
+ let status = panel.status;
+ let deviationPercent: number | null = null;
+ const ratedPowerW =
+ panel.config.rated_power_w ?? config.default_panel_rated_power_w ?? null;
+ const performancePercent =
+ ratedPowerW !== null && panel.power !== null && ratedPowerW > 0
+ ? clamp((panel.power / ratedPowerW) * 100, 0, 999)
+ : null;
+
+ if (status === "normal" && ratedPowerW === null) {
+ panel.reason = t("state.reason.rated_not_configured");
+ }
+
+ if (deviationReady && status === "normal" && ratedPowerW !== null && panel.power !== null) {
+ const matching = eligibleDeviationPanels.find(
+ (eligiblePanel) => eligiblePanel.id === panel.config.id,
+ );
+ if (matching) {
+ const targetPower = matching.ratedForDeviationW * arrayUtilizationFactor;
+ const shortfallW = Math.max(targetPower - matching.effectivePower, 0);
+ const shortfallPercent =
+ targetPower > 0 ? (shortfallW / targetPower) * 100 : 0;
+ if (
+ shortfallPercent >= deviationThresholdPercent &&
+ shortfallW >= deviationAbsoluteThresholdW
+ ) {
+ status = "deviation";
+ deviationPercent = clamp(shortfallPercent, 0, 100);
+ panel.reason = t("state.reason.output_below_target", {
+ percent: deviationPercent.toFixed(0),
+ shortfall: shortfallW.toFixed(0),
+ });
+ } else {
+ panel.reason = t("state.reason.producing_adjusted");
+ }
+ }
+ } else if (status === "normal" && deviationSuppressedReason) {
+ panel.reason = deviationSuppressedReason;
+ }
+
+ if (status === "error") {
+ errorCount += 1;
+ }
+
+ if (status === "inverter") {
+ inverterCount += 1;
+ }
+
+ if (status === "deviation") {
+ deviationCount += 1;
+ }
+
+ if (status === "offline") {
+ offlineCount += 1;
+ }
+
+ if (status === "normal") {
+ normalCount += 1;
+ }
+
+ if (status === "unconfigured") {
+ unconfiguredCount += 1;
+ }
+
+ if (status === "disabled") {
+ disabledCount += 1;
+ }
+
+ const ratedScale =
+ ratedPowerW !== null && ratedPowerW > 0 && panel.power !== null
+ ? panel.power / ratedPowerW
+ : null;
+ const fallbackScale =
+ panel.power !== null && maxPower > 0 ? panel.power / maxPower : 0;
+ const productionRatio = clamp(ratedScale ?? fallbackScale, 0, 1);
+ const normalizedStrength = clamp((productionColorIntensity - 0.2) / 1.4, 0, 1);
+ const productionIntensity = clamp(
+ (0.1 + normalizedStrength * 0.9) * (0.15 + productionRatio * 0.85),
+ 0.06,
+ 1,
+ );
+ const intensity =
+ status === "deviation" || status === "error" || status === "inverter"
+ ? 1
+ : productionIntensity;
+ const isZeroProduction =
+ status === "normal" && panel.power !== null && panel.power <= 0;
+ const accentColor =
+ status === "error"
+ ? colors.error
+ : status === "inverter"
+ ? colors.error
+ : isZeroProduction
+ ? colors.unavailable
+ : status === "offline" || status === "unconfigured" || status === "disabled"
+ ? colors.unavailable
+ : status === "deviation"
+ ? colors.deviation
+ : mixThreeColor(
+ colors.production_start,
+ colors.production_mid,
+ colors.production_end,
+ productionRatio,
+ );
+
+ return {
+ id: panel.config.id,
+ slotIndex: panel.slotIndex,
+ label: panel.label,
+ status,
+ power: panel.power,
+ powerDisplay:
+ status === "disabled"
+ ? t("state.status_display.disabled")
+ : status === "unconfigured"
+ ? t("state.status_display.not_configured")
+ : formatValue(
+ panel.power,
+ powerUnit,
+ config.power_decimals ?? 0,
+ unavailableText,
+ locale,
+ ),
+ energy: panel.energy,
+ energyDisplay:
+ panel.hiddenSlot
+ ? undefined
+ : !hasEnergySensor || !showPanelEnergy
+ ? undefined
+ : panel.energy !== null
+ ? formatValue(
+ panel.energy,
+ energyUnit ?? t("state.energy.default_unit"),
+ config.energy_decimals ?? 2,
+ unavailableText,
+ locale,
+ )
+ : unavailableText,
+ powerEntityName: panel.powerEntityName,
+ inverterStatusEntityName: panel.inverterStatusEntityName,
+ inverterStatusDisplay: panel.inverterStatusDisplay,
+ inverterFaultMatched: panel.inverterFaultMatched,
+ inverterWorkingMatched: panel.inverterWorkingMatched,
+ deviationPercent,
+ reason: panel.reason,
+ accentColor,
+ intensity,
+ enabled: panel.enabled,
+ hiddenSlot: panel.hiddenSlot,
+ ratedPowerW,
+ performancePercent,
+ powerShareFactor: panel.powerShareFactor,
+ energyShareFactor: panel.energyShareFactor,
+ stringGroupLabel: panel.stringGroupLabel,
+ stringPowerSourceName,
+ stringPowerSharePercent: panel.stringPowerSharePercent,
+ };
+ });
+
+ const totalPower = panels.reduce((sum, panel) => sum + (panel.power ?? 0), 0);
+ const energyValues = panels
+ .map((panel) => panel.energy)
+ .filter((value): value is number => value !== null);
+ const totalEnergy =
+ energyValues.length > 0
+ ? energyValues.reduce((sum, value) => sum + value, 0)
+ : null;
+
+ return {
+ panels,
+ totalPower,
+ totalEnergy,
+ maxPower,
+ deviationCount,
+ inverterCount,
+ errorCount,
+ offlineCount,
+ normalCount,
+ unconfiguredCount,
+ disabledCount,
+ deviationReady,
+ deviationSuppressedReason,
+ };
+};
diff --git a/src/popup-graph.ts b/src/popup-graph.ts
new file mode 100644
index 0000000..569a03d
--- /dev/null
+++ b/src/popup-graph.ts
@@ -0,0 +1,268 @@
+import type { PowerSample } from "./types";
+
+export type PopupGraphRangeHours = 1 | 6 | 24;
+
+export interface PopupGraphModel {
+ pointCount: number;
+ linePath: string;
+ areaPath: string;
+ minValue: number | null;
+ maxValue: number | null;
+ startTs: number | null;
+ endTs: number | null;
+}
+
+const isFiniteSample = (sample: PowerSample): boolean =>
+ Number.isFinite(sample.ts) && Number.isFinite(sample.value);
+
+const DEFAULT_ZERO_TOLERANCE = 0.0001;
+
+const getDefaultBridgeStepMs = (rangeHours?: number): number => {
+ if (rangeHours === 1) {
+ return 60_000;
+ }
+ if (rangeHours === 6) {
+ return 3 * 60_000;
+ }
+ if (rangeHours === 24) {
+ return 10 * 60_000;
+ }
+ return 5 * 60_000;
+};
+
+export const filterSamplesByRangeHours = (
+ samples: PowerSample[],
+ nowMs: number,
+ hours: number,
+): PowerSample[] => {
+ const threshold = nowMs - hours * 60 * 60 * 1000;
+ return samples
+ .filter(isFiniteSample)
+ .filter((sample) => sample.ts >= threshold && sample.ts <= nowMs)
+ .sort((a, b) => a.ts - b.ts);
+};
+
+export const bridgeZeroToProductionGaps = (
+ samples: PowerSample[],
+ options?: {
+ zeroTolerance?: number;
+ rangeHours?: number;
+ stepMs?: number;
+ },
+): PowerSample[] => {
+ if (samples.length < 2) {
+ return [...samples].sort((a, b) => a.ts - b.ts);
+ }
+
+ const zeroTolerance = options?.zeroTolerance ?? DEFAULT_ZERO_TOLERANCE;
+
+ const sorted = [...samples]
+ .filter(isFiniteSample)
+ .sort((a, b) => a.ts - b.ts);
+ if (sorted.length < 2) {
+ return sorted;
+ }
+
+ const diffs = sorted
+ .slice(1)
+ .map((sample, index) => sample.ts - sorted[index].ts)
+ .filter((diff) => Number.isFinite(diff) && diff > 0)
+ .sort((a, b) => a - b);
+ const medianDiff =
+ diffs.length > 0 ? diffs[Math.floor(diffs.length / 2)] : Number.NaN;
+ const defaultStepMs = getDefaultBridgeStepMs(options?.rangeHours);
+ const stepMs = Math.min(
+ Math.max(
+ Number.isFinite(options?.stepMs)
+ ? (options?.stepMs as number)
+ : Number.isFinite(medianDiff)
+ ? medianDiff
+ : defaultStepMs,
+ 30_000,
+ ),
+ defaultStepMs * 2,
+ );
+ const gapThresholdMs = stepMs * 2;
+
+ const bridged: PowerSample[] = [sorted[0]];
+ for (let index = 1; index < sorted.length; index += 1) {
+ const previous = sorted[index - 1];
+ const current = sorted[index];
+ const gapMs = current.ts - previous.ts;
+ const previousIsZero = Math.abs(previous.value) <= zeroTolerance;
+ const currentIsProducing = current.value > zeroTolerance;
+
+ if (previousIsZero && currentIsProducing && gapMs >= gapThresholdMs) {
+ const bridgeTs = Math.max(previous.ts + 1, current.ts - stepMs);
+ if (bridgeTs > previous.ts && bridgeTs < current.ts) {
+ bridged.push({ ts: bridgeTs, value: 0 });
+ }
+ }
+
+ bridged.push(current);
+ }
+
+ return bridged.filter((sample, index, array) =>
+ index === 0 ? true : sample.ts !== array[index - 1].ts || sample.value !== array[index - 1].value,
+ );
+};
+
+export const downsampleSamples = (
+ samples: PowerSample[],
+ maxPoints: number,
+): PowerSample[] => {
+ if (samples.length <= maxPoints || maxPoints <= 2) {
+ return [...samples];
+ }
+
+ const first = samples[0];
+ const last = samples[samples.length - 1];
+ const interior = samples.slice(1, samples.length - 1);
+ const interiorTarget = maxPoints - 2;
+ const step = interior.length / interiorTarget;
+
+ const picked: PowerSample[] = [first];
+ let previousIndex = -1;
+ for (let index = 0; index < interiorTarget; index += 1) {
+ const interiorIndex = Math.min(
+ interior.length - 1,
+ Math.floor(index * step),
+ );
+ if (interiorIndex === previousIndex) {
+ continue;
+ }
+ previousIndex = interiorIndex;
+ picked.push(interior[interiorIndex]);
+ }
+ picked.push(last);
+
+ return picked
+ .sort((a, b) => a.ts - b.ts)
+ .filter((sample, index, array) =>
+ index === 0 ? true : sample.ts !== array[index - 1].ts || sample.value !== array[index - 1].value,
+ )
+ .slice(0, maxPoints);
+};
+
+export const aggregateStepwiseSamples = (
+ sampleSets: PowerSample[][],
+): PowerSample[] => {
+ const normalizedSets = sampleSets
+ .map((samples) =>
+ [...samples]
+ .filter(isFiniteSample)
+ .sort((a, b) => a.ts - b.ts)
+ .filter((sample, index, array) =>
+ index === array.length - 1 ? true : sample.ts !== array[index + 1].ts,
+ ),
+ )
+ .filter((samples) => samples.length > 0);
+
+ if (normalizedSets.length === 0) {
+ return [];
+ }
+
+ const timestamps = [
+ ...new Set(normalizedSets.flatMap((samples) => samples.map((sample) => sample.ts))),
+ ].sort((a, b) => a - b);
+ const indexes = normalizedSets.map(() => 0);
+ const latestValues = normalizedSets.map(() => null);
+
+ const aggregated: PowerSample[] = [];
+ for (const ts of timestamps) {
+ let knownCount = 0;
+ for (let setIndex = 0; setIndex < normalizedSets.length; setIndex += 1) {
+ const samples = normalizedSets[setIndex];
+ while (
+ indexes[setIndex] < samples.length &&
+ samples[indexes[setIndex]].ts <= ts
+ ) {
+ latestValues[setIndex] = samples[indexes[setIndex]].value;
+ indexes[setIndex] += 1;
+ }
+ if (latestValues[setIndex] !== null) {
+ knownCount += 1;
+ }
+ }
+
+ if (knownCount === 0) {
+ continue;
+ }
+
+ const value = latestValues.reduce(
+ (sum, latest) => sum + (latest ?? 0),
+ 0,
+ );
+ aggregated.push({
+ ts,
+ value: Object.is(value, -0) ? 0 : value,
+ });
+ }
+
+ return aggregated;
+};
+
+export const buildPopupGraphModel = (
+ samples: PowerSample[],
+ width: number,
+ height: number,
+ padding = 10,
+): PopupGraphModel => {
+ if (samples.length === 0) {
+ return {
+ pointCount: 0,
+ linePath: "",
+ areaPath: "",
+ minValue: null,
+ maxValue: null,
+ startTs: null,
+ endTs: null,
+ };
+ }
+
+ const sorted = [...samples].sort((a, b) => a.ts - b.ts);
+ const values = sorted.map((sample) => sample.value);
+ let minValue = Math.min(...values);
+ let maxValue = Math.max(...values);
+ if (minValue === maxValue) {
+ minValue -= 1;
+ maxValue += 1;
+ }
+
+ const startTs = sorted[0].ts;
+ const endTs = sorted[sorted.length - 1].ts;
+ const xSpan = Math.max(endTs - startTs, 1);
+ const ySpan = Math.max(maxValue - minValue, 1);
+ const plotWidth = Math.max(width - padding * 2, 1);
+ const plotHeight = Math.max(height - padding * 2, 1);
+
+ const points = sorted.map((sample) => {
+ const x = padding + ((sample.ts - startTs) / xSpan) * plotWidth;
+ const y =
+ height - padding - ((sample.value - minValue) / ySpan) * plotHeight;
+ return { x, y };
+ });
+
+ const linePath = points
+ .map((point, index) =>
+ `${index === 0 ? "M" : "L"}${point.x.toFixed(2)},${point.y.toFixed(2)}`,
+ )
+ .join(" ");
+
+ const first = points[0];
+ const last = points[points.length - 1];
+ const baselineY = height - padding;
+ const areaPath = `${linePath} L${last.x.toFixed(2)},${baselineY.toFixed(2)} L${first.x.toFixed(
+ 2,
+ )},${baselineY.toFixed(2)} Z`;
+
+ return {
+ pointCount: points.length,
+ linePath,
+ areaPath,
+ minValue,
+ maxValue,
+ startTs,
+ endTs,
+ };
+};
diff --git a/src/solar-panel-visualizer-card.ts b/src/solar-panel-visualizer-card.ts
new file mode 100644
index 0000000..19428c1
--- /dev/null
+++ b/src/solar-panel-visualizer-card.ts
@@ -0,0 +1,6335 @@
+import { LitElement, PropertyValues, css, html, nothing, svg } from "lit";
+import { buildStubConfig, normalizeConfig, validateConfig } from "./config";
+import { formatCustomKpiStateValue } from "./custom-kpi";
+import { getForecastEntityId, resolveForecastEntityId } from "./forecast";
+import { translate, type TranslationKey } from "./i18n";
+import { MotionEngine, type MotionSnapshot } from "./motion-engine";
+import { buildMotionOverlayModel, renderMotionOverlay, type MotionOverlayModel } from "./motion-overlay";
+import { motionStyles } from "./motion-styles";
+import { naturalCompare } from "./natural-sort";
+import { deriveArrayState } from "./panel-state";
+import {
+ choosePanelPerformanceLabel,
+ type PanelPerformanceLabelCandidate,
+} from "./panel-performance-label";
+import {
+ aggregateStepwiseSamples,
+ bridgeZeroToProductionGaps,
+ downsampleSamples,
+ filterSamplesByRangeHours,
+ type PopupGraphRangeHours,
+} from "./popup-graph";
+import { buildCompareGraphModel } from "./compare-graph";
+import type {
+ DerivedArrayState,
+ PanelAdvancedMetrics,
+ DerivedPanelState,
+ HomeAssistant,
+ PanelConfig,
+ PowerSample,
+ SolarPanelVisualizerConfig,
+} from "./types";
+
+const formatSummaryValue = (
+ value: number | null,
+ decimals: number,
+ unit: string,
+ unavailableText: string,
+ locale = "en",
+): string => {
+ if (value === null) {
+ return unavailableText;
+ }
+
+ const safeValue = Object.is(value, -0) ? 0 : value;
+
+ return `${new Intl.NumberFormat(locale, {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ }).format(safeValue)} ${unit}`;
+};
+
+const normalizeNegativeZero = (value: number): number =>
+ Object.is(value, -0) ? 0 : value;
+
+const normalizeInlineStatusText = (value: string | undefined): string | undefined => {
+ if (!value) {
+ return undefined;
+ }
+ const normalized = value
+ .replace(/[\u200B-\u200D\uFEFF]/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+ return normalized.length > 0 ? normalized : undefined;
+};
+
+type HistoryState = "idle" | "loading" | "ready" | "fallback" | "unavailable";
+const HISTORY_CACHE_VERSION = 1;
+const HISTORY_CACHE_PREFIX = "spv:history:";
+const CARD_CONFIG_SYNC_EVENT = "spv-card-config-updated";
+const HISTORY_QUERY_RELOAD_THROTTLE_MS = 60_000;
+const POPUP_GRAPH_RANGES: PopupGraphRangeHours[] = [1, 6, 24];
+const POPUP_GRAPH_MAX_POINTS = 150;
+const POPUP_GRAPH_CACHE_TTL_MS = 45_000;
+const PANEL_COMPARE_CACHE_TTL_MS = 45_000;
+const PANEL_SLOT_LABEL_SAFE_WIDTH_PX = 64;
+type PanelCompareMetricKind = "power" | "energy";
+const INVALID_ENTITY_STATES = new Set(["unknown", "unavailable", "none", "null", ""]);
+const PANEL_COMPARE_COLORS = [
+ "#8ed072",
+ "#6fc9ff",
+ "#ffbf66",
+ "#b391ff",
+ "#4de3c1",
+ "#ff8fb3",
+ "#ffd56a",
+ "#84a8ff",
+ "#7be56e",
+ "#f58cff",
+];
+
+interface PopupGraphCacheEntry {
+ loading: boolean;
+ samples: PowerSample[];
+ error?: string;
+ loadedAt?: number;
+}
+
+interface PanelCompareCacheEntry {
+ loading: boolean;
+ samplesByPanelId: Record;
+ error?: string;
+ loadedAt?: number;
+}
+
+interface PanelCompareTarget {
+ panelId: string;
+ label: string;
+ entityId: string;
+ scale: number;
+}
+
+interface PanelTelemetryMetric {
+ label: string;
+ entityId?: string;
+}
+
+interface PopupGraphRenderOptions {
+ overlayEntityId?: string | null;
+ overlayStateMessage?: string | null;
+ showEnableForecastButton?: boolean;
+ fillStartWithFirstSample?: boolean;
+ invertPrimaryValues?: boolean;
+ primaryValueScale?: number;
+}
+
+interface GraphStats {
+ min: number | null;
+ max: number | null;
+ median: number | null;
+}
+
+interface GraphHourTick {
+ x: number;
+ label: string;
+}
+
+interface GraphAxisTick {
+ leftPercent: number;
+ label: string;
+}
+
+const toErrorMessage = (error: unknown, fallback = "Unknown recorder error"): string => {
+ if (error instanceof Error && error.message.trim().length > 0) {
+ return error.message.trim();
+ }
+ if (typeof error === "string" && error.trim().length > 0) {
+ return error.trim();
+ }
+ if (typeof error === "object" && error !== null) {
+ const candidate = error as {
+ message?: unknown;
+ error?: unknown;
+ body?: { message?: unknown };
+ };
+ if (typeof candidate.message === "string" && candidate.message.trim().length > 0) {
+ return candidate.message.trim();
+ }
+ if (typeof candidate.error === "string" && candidate.error.trim().length > 0) {
+ return candidate.error.trim();
+ }
+ if (
+ typeof candidate.body?.message === "string" &&
+ candidate.body.message.trim().length > 0
+ ) {
+ return candidate.body.message.trim();
+ }
+ }
+ return fallback;
+};
+
+const compactError = (value: string, maxLength = 120): string =>
+ value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`;
+
+const isFiniteNumber = (value: unknown): value is number =>
+ typeof value === "number" && Number.isFinite(value);
+
+export class SolarPanelVisualizerBaseCard extends LitElement {
+ private static _textMeasureContext: CanvasRenderingContext2D | null | undefined;
+
+ static properties = {
+ hass: { attribute: false },
+ _config: { state: true },
+ _derived: { state: true },
+ _cardWidth: { state: true },
+ _selectedPanelId: { state: true },
+ _showLivePowerPopup: { state: true },
+ _showEnergyPopup: { state: true },
+ _showCustomKpiPopup: { state: true },
+ _showSystemHealthPopup: { state: true },
+ _popupGraphRangeHours: { state: true },
+ _popupGraphCache: { state: true },
+ _telemetryGraphPanelId: { state: true },
+ _telemetryGraphEntityId: { state: true },
+ _telemetryGraphRangeHours: { state: true },
+ _kpiCompareExpanded: { state: true },
+ _kpiCompareRangeHours: { state: true },
+ _kpiCompareCache: { state: true },
+ };
+
+ static styles = [css`
+ :host {
+ display: block;
+ width: 100%;
+ }
+
+ ha-card {
+ --spv-card-bg:
+ radial-gradient(circle at top left, rgba(255, 214, 92, 0.18), transparent 38%),
+ radial-gradient(circle at top right, rgba(111, 201, 255, 0.16), transparent 35%),
+ linear-gradient(150deg, rgba(15, 33, 53, 0.98), rgba(11, 19, 34, 0.96));
+ --spv-card-border: rgba(255, 255, 255, 0.1);
+ --spv-card-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.05),
+ 0 12px 30px rgba(0, 0, 0, 0.28);
+ --spv-chrome-bg:
+ linear-gradient(120deg, rgba(255, 255, 255, 0.02), transparent 30%),
+ repeating-linear-gradient(
+ 125deg,
+ rgba(255, 255, 255, 0.02) 0,
+ rgba(255, 255, 255, 0.02) 2px,
+ transparent 2px,
+ transparent 16px
+ );
+ --spv-text-primary: var(--primary-text-color, rgba(247, 250, 255, 0.96));
+ --spv-text-secondary: rgba(255, 255, 255, 0.72);
+ --spv-text-muted: rgba(255, 255, 255, 0.56);
+ --spv-text-soft: rgba(255, 255, 255, 0.78);
+ --spv-surface-bg: rgba(255, 255, 255, 0.055);
+ --spv-surface-bg-hover: rgba(255, 255, 255, 0.08);
+ --spv-surface-border: rgba(255, 255, 255, 0.08);
+ --spv-surface-border-hover: rgba(255, 255, 255, 0.18);
+ --spv-panel-bg:
+ linear-gradient(160deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.02)),
+ linear-gradient(180deg, rgba(10, 17, 29, 0.86), rgba(7, 12, 21, 0.92));
+ --spv-panel-highlight:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 30%),
+ linear-gradient(135deg, rgba(255, 255, 255, 0.05), transparent 48%);
+ --spv-panel-hidden-bg:
+ linear-gradient(160deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.01)),
+ linear-gradient(180deg, rgba(10, 17, 29, 0.6), rgba(7, 12, 21, 0.68));
+ --spv-panel-hidden-border: rgba(255, 255, 255, 0.18);
+ --spv-panel-accent-mix-base: rgba(255, 255, 255, 0.14);
+ --spv-panel-hover-mix-base: rgba(255, 255, 255, 0.24);
+ --spv-popup-backdrop: rgba(4, 8, 16, 0.72);
+ --spv-popup-bg:
+ radial-gradient(circle at top right, rgba(255, 212, 84, 0.18), transparent 34%),
+ linear-gradient(180deg, rgba(16, 26, 42, 0.98), rgba(10, 18, 30, 0.98));
+ --spv-popup-border: rgba(255, 255, 255, 0.09);
+ --spv-popup-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.06),
+ 0 20px 34px rgba(0, 0, 0, 0.36);
+ --spv-graph-bg: rgba(8, 14, 25, 0.5);
+ --spv-graph-grid: rgba(255, 255, 255, 0.18);
+ --spv-forecast-line: rgba(240, 245, 255, 0.84);
+ --spv-overlay-bg: rgba(8, 16, 28, 0.62);
+ position: relative;
+ container-type: inline-size;
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding: 20px;
+ width: min(100%, var(--spv-max-width, 980px));
+ max-height: var(--spv-max-height, none);
+ margin-inline: auto;
+ border: 1px solid var(--spv-card-border);
+ background: var(--spv-card-bg);
+ color: var(--spv-text-primary);
+ box-shadow: var(--spv-card-shadow);
+ backdrop-filter: blur(18px);
+ }
+
+ ha-card[data-spv-theme="light"] {
+ --spv-card-bg:
+ radial-gradient(circle at top left, rgba(255, 210, 92, 0.22), transparent 38%),
+ radial-gradient(circle at top right, rgba(52, 161, 218, 0.16), transparent 36%),
+ linear-gradient(150deg, rgba(250, 253, 255, 0.98), rgba(229, 238, 246, 0.96));
+ --spv-card-border: rgba(54, 76, 98, 0.18);
+ --spv-card-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.84),
+ 0 12px 28px rgba(31, 48, 68, 0.16);
+ --spv-chrome-bg:
+ linear-gradient(120deg, rgba(255, 255, 255, 0.34), transparent 32%),
+ radial-gradient(circle at bottom right, rgba(52, 161, 218, 0.08), transparent 42%);
+ --spv-text-primary: rgba(22, 31, 43, 0.96);
+ --spv-text-secondary: rgba(22, 31, 43, 0.74);
+ --spv-text-muted: rgba(22, 31, 43, 0.56);
+ --spv-text-soft: rgba(22, 31, 43, 0.78);
+ --spv-surface-bg: rgba(255, 255, 255, 0.56);
+ --spv-surface-bg-hover: rgba(255, 255, 255, 0.72);
+ --spv-surface-border: rgba(50, 72, 94, 0.14);
+ --spv-surface-border-hover: rgba(50, 72, 94, 0.28);
+ --spv-panel-bg:
+ linear-gradient(160deg, rgba(255, 255, 255, 0.82), rgba(238, 245, 251, 0.92)),
+ linear-gradient(180deg, rgba(248, 252, 255, 0.96), rgba(224, 235, 245, 0.92));
+ --spv-panel-highlight:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.48), transparent 34%),
+ linear-gradient(135deg, rgba(37, 58, 82, 0.04), transparent 48%);
+ --spv-panel-hidden-bg:
+ linear-gradient(180deg, rgba(240, 247, 252, 0.62), rgba(224, 235, 245, 0.72));
+ --spv-panel-hidden-border: rgba(55, 76, 98, 0.28);
+ --spv-panel-accent-mix-base: rgba(40, 62, 84, 0.18);
+ --spv-panel-hover-mix-base: rgba(40, 62, 84, 0.34);
+ --spv-popup-backdrop: rgba(32, 45, 60, 0.28);
+ --spv-popup-bg:
+ radial-gradient(circle at top right, rgba(255, 212, 84, 0.22), transparent 34%),
+ linear-gradient(180deg, rgba(250, 253, 255, 0.98), rgba(234, 242, 249, 0.98));
+ --spv-popup-border: rgba(50, 72, 94, 0.16);
+ --spv-popup-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.82),
+ 0 18px 34px rgba(31, 48, 68, 0.22);
+ --spv-graph-bg: rgba(237, 244, 250, 0.72);
+ --spv-graph-grid: rgba(36, 55, 74, 0.18);
+ --spv-forecast-line: rgba(48, 68, 92, 0.78);
+ --spv-overlay-bg: rgba(246, 251, 255, 0.78);
+ }
+
+ .chrome {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: var(--spv-chrome-bg);
+ opacity: 0.45;
+ }
+
+ .spv-overlay-anchor {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ pointer-events: none;
+ }
+
+ .header {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 18px;
+ }
+
+ .header-copy {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+ flex: 1 1 auto;
+ }
+
+ .topline {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ padding-right: 0;
+ }
+
+ .eyebrow {
+ flex: 1;
+ font-size: 0.72rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--spv-text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .system-health {
+ appearance: none;
+ font: inherit;
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 3;
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--health-color, rgba(142, 208, 114, 0.5));
+ background: color-mix(in srgb, var(--health-color, #8ed072) 18%, transparent);
+ color: var(--health-color, #8ed072);
+ font-size: 0.68rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ line-height: 1.35;
+ cursor: pointer;
+ transition: filter 160ms ease, transform 160ms ease;
+ }
+
+ .system-health:hover {
+ filter: brightness(1.08);
+ transform: translateY(-1px);
+ }
+
+ .title {
+ margin: 0;
+ font-size: 1.45rem;
+ line-height: 1.1;
+ letter-spacing: 0.01em;
+ font-weight: 700;
+ }
+
+ .subtitle {
+ margin: 0;
+ color: var(--spv-text-secondary);
+ font-size: 0.92rem;
+ }
+
+ .subtitle-hint {
+ color: var(--spv-text-muted);
+ font-size: 0.84rem;
+ }
+
+ .summary {
+ display: grid;
+ gap: 10px;
+ margin-top: 30px;
+ min-width: min(100%, 360px);
+ }
+
+ .summary.with-custom {
+ grid-template-columns:
+ minmax(84px, 1fr)
+ minmax(84px, 1fr)
+ minmax(70px, 0.72fr)
+ minmax(84px, 1fr);
+ }
+
+ .summary.without-custom {
+ grid-template-columns:
+ minmax(84px, 1fr)
+ minmax(84px, 1fr)
+ minmax(70px, 0.72fr);
+ }
+
+ .summary-chip {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+ gap: 3px;
+ text-align: left;
+ border-radius: 16px;
+ padding: 10px 10px 10px 12px;
+ background: var(--spv-surface-bg);
+ border: 1px solid var(--spv-surface-border);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
+ }
+
+ .summary-chip.summary-button {
+ appearance: none;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+ font: inherit;
+ color: inherit;
+ text-align: left;
+ margin: 0;
+ width: 100%;
+ padding: 10px 10px 10px 12px;
+ cursor: pointer;
+ transition: border-color 160ms ease, background 160ms ease;
+ }
+
+ .summary-chip.summary-button:hover {
+ border-color: var(--spv-surface-border-hover);
+ background: var(--spv-surface-bg-hover);
+ }
+
+ .summary-chip.alerts-chip {
+ padding: 10px 12px 10px 12px;
+ }
+
+ .summary-chip.alerts-chip .summary-label {
+ margin-bottom: 6px;
+ }
+
+ .summary-chip.alerts-chip .summary-value {
+ font-size: 0.96rem;
+ margin-top: 4px;
+ }
+
+ .summary-label {
+ display: block;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ line-height: 1.2;
+ color: var(--spv-text-muted);
+ margin-bottom: 4px;
+ }
+
+ .summary-value {
+ display: block;
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: 1.24;
+ max-width: 100%;
+ }
+
+ .summary-value.custom-kpi-value {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .grid {
+ position: relative;
+ display: grid;
+ gap: 10px;
+ margin-top: 14px;
+ align-items: stretch;
+ }
+
+ .panel {
+ --spv-slot-safe-width: 64px;
+ --spv-slot-dock-height: clamp(24px, calc(26px * var(--spv-panel-scale, 1)), 32px);
+ --spv-slot-dock-clearance: calc(var(--spv-slot-dock-height) + 8px);
+ position: relative;
+ container-type: inline-size;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ align-content: stretch;
+ overflow: hidden;
+ width: min(100%, var(--spv-panel-max-width, 100%));
+ justify-self: center;
+ height: var(--spv-panel-height, clamp(96px, 14vw, 136px));
+ border: 1px solid var(--panel-border-accent, var(--spv-panel-hidden-border));
+ border-radius: 18px;
+ padding: 10px;
+ text-align: left;
+ color: inherit;
+ background: var(--spv-panel-bg);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.08),
+ 0 10px 24px rgba(0, 0, 0, 0.18),
+ 0 0 calc(1px + var(--panel-intensity, 0.4) * 3px) var(--panel-glow-accent, transparent);
+ cursor: pointer;
+ transition:
+ transform 180ms ease,
+ border-color 180ms ease,
+ box-shadow 180ms ease;
+ }
+
+ .panel:hover {
+ transform: translateY(-2px);
+ border-color: color-mix(
+ in srgb,
+ var(--panel-border-accent, rgba(255, 255, 255, 0.16)) 86%,
+ var(--spv-panel-hover-mix-base)
+ );
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.09),
+ 0 14px 28px rgba(0, 0, 0, 0.24),
+ 0 0 calc(2px + var(--panel-intensity, 0.4) * 4px) var(--panel-glow-accent, transparent);
+ }
+
+ .panel::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: var(--spv-panel-highlight);
+ pointer-events: none;
+ }
+
+ .panel::after {
+ content: "";
+ position: absolute;
+ inset: auto -22% -56% auto;
+ width: 188px;
+ height: 188px;
+ border-radius: 50%;
+ background: radial-gradient(circle, var(--panel-fill-accent, transparent) 0%, transparent 72%);
+ opacity: 1;
+ filter: blur(14px);
+ pointer-events: none;
+ }
+
+ .panel.error,
+ .panel.inverter,
+ .panel.deviation,
+ .panel.offline,
+ .panel.unconfigured,
+ .panel.disabled {
+ cursor: pointer;
+ }
+
+ .panel.hidden-slot {
+ background: var(--spv-panel-hidden-bg);
+ border-style: dashed;
+ border-color: var(--spv-panel-hidden-border);
+ cursor: default;
+ }
+
+ .panel.hidden-slot:hover {
+ transform: none;
+ }
+
+ .panel.hidden-slot .panel-name,
+ .panel.hidden-slot .power,
+ .panel.hidden-slot .panel-detail-scroll,
+ .panel.hidden-slot .performance,
+ .panel.hidden-slot .energy,
+ .panel.hidden-slot .inverter-status {
+ visibility: hidden;
+ }
+
+ .slot {
+ position: absolute;
+ right: 7px;
+ bottom: 7px;
+ z-index: 3;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 5px;
+ box-sizing: border-box;
+ padding: 2px 3px 2px 10px;
+ font-size: clamp(0.66rem, calc(0.68rem * var(--spv-panel-scale, 1)), 0.84rem);
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ text-align: right;
+ color: var(--spv-text-muted);
+ text-shadow: 0 1px 8px color-mix(in srgb, var(--spv-surface-bg) 90%, transparent);
+ white-space: nowrap;
+ isolation: isolate;
+ }
+
+ .slot.has-string-label {
+ max-width: calc(100% - 14px);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-transform: none;
+ letter-spacing: 0.04em;
+ }
+
+ .status {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ display: inline-flex;
+ align-items: center;
+ z-index: 2;
+ padding: 4px 8px;
+ width: max-content;
+ max-width: calc(100% - 16px);
+ border-radius: 999px;
+ font-size: clamp(0.64rem, calc(0.66rem * var(--spv-panel-scale, 1)), 0.82rem);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ background: var(--spv-surface-bg);
+ border: 1px solid var(--spv-surface-border);
+ }
+
+ .panel-primary {
+ position: relative;
+ z-index: 1;
+ align-self: stretch;
+ min-width: 0;
+ }
+
+ .panel-name {
+ position: relative;
+ z-index: 1;
+ margin: 26px 0 5px;
+ font-size: clamp(0.8rem, calc(0.94rem * var(--spv-panel-scale, 1)), 1.26rem);
+ line-height: 1.15;
+ font-weight: 600;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .power {
+ position: relative;
+ z-index: 1;
+ font-size: clamp(0.96rem, calc(1.18rem * var(--spv-panel-scale, 1)), 1.76rem);
+ line-height: 1.05;
+ font-weight: 750;
+ margin: 0;
+ text-wrap: pretty;
+ }
+
+ .panel.has-performance .power {
+ font-size: clamp(0.92rem, calc(1.08rem * var(--spv-panel-scale, 1)), 1.64rem);
+ }
+
+ .panel-detail-scroll {
+ position: relative;
+ z-index: 1;
+ align-self: stretch;
+ box-sizing: border-box;
+ min-height: 0;
+ width: 100%;
+ max-width: 100%;
+ margin: 1px 0 0;
+ padding-right: 8px;
+ padding-bottom: 0;
+ overflow-x: hidden;
+ overflow-y: hidden;
+ overscroll-behavior: contain;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ }
+
+ .panel-detail-scroll::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
+ }
+
+ .panel-detail-scroll::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ .panel-detail-scroll::-webkit-scrollbar-thumb {
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--spv-text-muted) 36%, transparent);
+ }
+
+ .panel.has-detail-overflow .panel-detail-scroll {
+ padding-bottom: var(--spv-slot-dock-clearance);
+ overflow-y: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ -webkit-mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 24px), transparent 100%);
+ mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 24px), transparent 100%);
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ }
+
+ .panel.has-detail-overflow .panel-detail-scroll::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
+ }
+
+ .panel-detail-scroll:empty {
+ display: none;
+ }
+
+ .energy {
+ position: relative;
+ z-index: 1;
+ margin: 3px 0 0;
+ color: var(--spv-text-secondary);
+ font-size: clamp(0.66rem, calc(0.78rem * var(--spv-panel-scale, 1)), 1rem);
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .performance {
+ position: relative;
+ z-index: 1;
+ margin: 3px 0 0;
+ max-width: 100%;
+ color: var(--spv-text-soft);
+ font-size: clamp(0.62rem, calc(0.72rem * var(--spv-panel-scale, 1)), 0.96rem);
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .inverter-status {
+ position: relative;
+ z-index: 1;
+ margin: 4px 0 0;
+ color: var(--spv-text-soft);
+ font-size: clamp(0.62rem, calc(0.72rem * var(--spv-panel-scale, 1)), 0.92rem);
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .reason {
+ font-size: 0.78rem;
+ color: var(--spv-text-secondary);
+ }
+
+ .spv-popup-backdrop {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: clamp(8px, 2vw, 16px);
+ overflow: hidden;
+ background: var(--spv-popup-backdrop);
+ backdrop-filter: blur(10px);
+ z-index: 20;
+ }
+
+ .spv-popup {
+ box-sizing: border-box;
+ inline-size: min(560px, calc(100% - 8px));
+ max-block-size: min(760px, calc(100% - 8px));
+ overflow: auto;
+ overscroll-behavior: contain;
+ border-radius: 24px;
+ border: 1px solid var(--spv-popup-border);
+ background: var(--spv-popup-bg);
+ box-shadow: var(--spv-popup-shadow);
+ padding: 16px;
+ }
+
+ .spv-popup-close-anchor {
+ position: sticky;
+ top: 8px;
+ z-index: 30;
+ width: 36px;
+ height: 36px;
+ margin: 0 0 -36px auto;
+ display: flex;
+ justify-content: flex-end;
+ pointer-events: auto;
+ }
+
+ .spv-popup-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 14px;
+ margin-bottom: 14px;
+ padding-right: 48px;
+ }
+
+ .spv-popup-title {
+ margin: 0;
+ font-size: 1.2rem;
+ }
+
+ .spv-popup-close {
+ flex: 0 0 auto;
+ border: 0;
+ border-radius: 999px;
+ background: var(--spv-surface-bg);
+ color: inherit;
+ display: grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ margin-left: auto;
+ cursor: pointer;
+ touch-action: manipulation;
+ }
+
+ .detail-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin-top: 2px;
+ margin-bottom: 12px;
+ }
+
+ .detail-card {
+ padding: 12px;
+ border-radius: 16px;
+ background: var(--spv-surface-bg);
+ border: 1px solid var(--spv-surface-border);
+ }
+
+ .detail-card-source {
+ grid-column: 1 / -1;
+ }
+
+ .detail-label {
+ display: block;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--spv-text-muted);
+ margin-bottom: 4px;
+ }
+
+ .detail-value {
+ display: block;
+ font-size: 1rem;
+ font-weight: 700;
+ }
+
+ .detail-information {
+ margin-top: 12px;
+ }
+
+ .telemetry-section {
+ margin-top: 12px;
+ display: grid;
+ gap: 10px;
+ }
+
+ .telemetry-group {
+ display: grid;
+ gap: 8px;
+ }
+
+ .telemetry-title {
+ margin: 0;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--spv-text-muted);
+ }
+
+ .telemetry-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ }
+
+ .telemetry-card {
+ overflow: hidden;
+ }
+
+ .telemetry-card.is-open {
+ grid-column: 1 / -1;
+ }
+
+ .telemetry-card.is-expandable {
+ padding: 0;
+ }
+
+ .telemetry-card-toggle {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ width: 100%;
+ min-width: 0;
+ padding: 12px;
+ text-align: left;
+ font: inherit;
+ cursor: pointer;
+ }
+
+ .telemetry-card-main {
+ min-width: 0;
+ }
+
+ .telemetry-card-affordance {
+ display: inline-grid;
+ place-items: center;
+ flex: 0 0 auto;
+ width: 28px;
+ height: 28px;
+ border-radius: 999px;
+ border: 1px solid var(--spv-surface-border);
+ color: var(--spv-text-muted);
+ background: color-mix(in srgb, var(--spv-surface-bg) 62%, transparent);
+ line-height: 1;
+ }
+
+ .telemetry-card.is-open .telemetry-card-affordance {
+ color: var(--spv-text-primary);
+ border-color: var(--spv-surface-border-hover);
+ background: var(--spv-surface-bg-hover);
+ }
+
+ .telemetry-card-graph {
+ border-top: 1px solid var(--spv-surface-border);
+ padding: 10px 12px 12px;
+ display: grid;
+ gap: 8px;
+ }
+
+ .telemetry-empty {
+ margin: 0;
+ font-size: 0.82rem;
+ line-height: 1.35;
+ color: var(--spv-text-secondary);
+ }
+
+ .info-lines {
+ display: grid;
+ gap: 6px;
+ }
+
+ .info-line {
+ margin: 0;
+ font-size: 0.82rem;
+ line-height: 1.35;
+ color: var(--spv-text-soft);
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+
+ .system-health-item {
+ margin: 0;
+ font-size: 0.86rem;
+ line-height: 1.4;
+ color: var(--spv-text-soft);
+ white-space: normal;
+ overflow-wrap: anywhere;
+ }
+
+ .graph-section {
+ margin: 0 0 14px;
+ border-radius: 16px;
+ border: 1px solid var(--spv-surface-border);
+ background: color-mix(in srgb, var(--spv-surface-bg) 54%, transparent);
+ padding: 10px;
+ display: grid;
+ gap: 8px;
+ }
+
+ .graph-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ }
+
+ .graph-title {
+ font-size: 0.76rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--spv-text-muted);
+ }
+
+ .range-chips {
+ display: inline-flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ }
+
+ .range-chip {
+ border: 1px solid var(--spv-surface-border);
+ background: var(--spv-surface-bg);
+ color: var(--spv-text-soft);
+ border-radius: 999px;
+ padding: 4px 8px;
+ font-size: 0.72rem;
+ line-height: 1;
+ cursor: pointer;
+ }
+
+ .range-chip.active {
+ border-color: var(--spv-surface-border-hover);
+ background: var(--spv-surface-bg-hover);
+ color: var(--spv-text-primary);
+ }
+
+ .graph-box {
+ position: relative;
+ border-radius: 12px;
+ overflow: hidden;
+ background: var(--spv-graph-bg);
+ border: 1px solid var(--spv-surface-border);
+ }
+
+ .graph-hour-line {
+ stroke: var(--spv-graph-grid);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+ opacity: 0.35;
+ }
+
+ .graph-svg {
+ width: 100%;
+ height: clamp(96px, 18vh, 132px);
+ display: block;
+ }
+
+ .graph-meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 0.74rem;
+ color: var(--spv-text-secondary);
+ }
+
+ .graph-stat-line {
+ stroke-width: 1.3;
+ stroke-dasharray: 5 4;
+ opacity: 0.64;
+ }
+
+ .graph-stat-max {
+ stroke: rgba(255, 150, 103, 0.95);
+ }
+
+ .graph-stat-median {
+ stroke: rgba(120, 205, 255, 0.95);
+ }
+
+ .graph-stat-min {
+ stroke: rgba(207, 220, 238, 0.75);
+ }
+
+ .graph-overlay {
+ position: absolute;
+ left: 10px;
+ display: inline-flex;
+ align-items: center;
+ min-height: 16px;
+ padding: 0 6px;
+ border-radius: 999px;
+ font-size: 0.66rem;
+ letter-spacing: 0.02em;
+ color: var(--spv-text-primary);
+ background: var(--spv-overlay-bg);
+ border: 1px solid var(--spv-surface-border);
+ backdrop-filter: blur(2px);
+ pointer-events: none;
+ z-index: 3;
+ }
+
+ .graph-overlay-max {
+ top: 8px;
+ }
+
+ .graph-overlay-median {
+ top: 50%;
+ transform: translateY(-50%);
+ background: color-mix(in srgb, var(--spv-overlay-bg) 88%, transparent);
+ }
+
+ .graph-overlay-min {
+ bottom: 8px;
+ }
+
+ .graph-axis {
+ position: relative;
+ height: 14px;
+ margin-top: 2px;
+ }
+
+ .graph-axis-label {
+ position: absolute;
+ transform: translateX(-50%);
+ font-size: 0.66rem;
+ letter-spacing: 0.03em;
+ color: var(--spv-text-muted);
+ white-space: nowrap;
+ line-height: 1;
+ user-select: none;
+ }
+
+ .graph-state {
+ box-sizing: border-box;
+ display: grid;
+ place-items: center;
+ min-height: clamp(96px, 18vh, 132px);
+ margin: 0;
+ border-radius: 12px;
+ border: 1px solid var(--spv-surface-border);
+ background: var(--spv-graph-bg);
+ font-size: 0.82rem;
+ color: var(--spv-text-secondary);
+ padding: 12px;
+ text-align: center;
+ }
+
+ .graph-forecast-state {
+ margin: 0;
+ font-size: 0.76rem;
+ color: var(--spv-text-muted);
+ padding: 0 2px 2px;
+ }
+
+ .forecast-enable-row {
+ display: flex;
+ justify-content: flex-start;
+ margin-top: 2px;
+ }
+
+ .inline-button {
+ appearance: none;
+ border: 1px solid var(--spv-surface-border);
+ border-radius: 999px;
+ background: var(--spv-surface-bg);
+ color: inherit;
+ padding: 6px 10px;
+ font: inherit;
+ font-size: 0.74rem;
+ line-height: 1;
+ cursor: pointer;
+ transition: background 160ms ease, border-color 160ms ease;
+ }
+
+ .inline-button:hover {
+ background: var(--spv-surface-bg-hover);
+ border-color: var(--spv-surface-border-hover);
+ }
+
+ .compare-toggle-row {
+ display: flex;
+ justify-content: flex-start;
+ margin-top: 12px;
+ margin-bottom: 10px;
+ }
+
+ .compare-legend {
+ display: grid;
+ gap: 6px;
+ margin-top: 2px;
+ }
+
+ .compare-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.78rem;
+ color: var(--spv-text-soft);
+ min-width: 0;
+ }
+
+ .compare-legend-chip {
+ flex: 0 0 auto;
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ border: 1px solid var(--spv-surface-border-hover);
+ }
+
+ .compare-legend-label {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .compare-diagnostics {
+ margin-top: 8px;
+ padding: 8px;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 147, 118, 0.24);
+ background: rgba(255, 147, 118, 0.08);
+ display: grid;
+ gap: 4px;
+ font-size: 0.72rem;
+ color: rgba(255, 232, 224, 0.9);
+ }
+
+ .compare-diagnostics-title {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: rgba(255, 188, 168, 0.92);
+ }
+
+ .compare-diagnostics-row {
+ line-height: 1.35;
+ color: rgba(255, 231, 221, 0.9);
+ word-break: break-word;
+ }
+
+ .inline-config {
+ display: grid;
+ gap: 10px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--spv-surface-border);
+ }
+
+ @container (max-width: 260px) {
+ .detail-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .telemetry-grid {
+ grid-template-columns: 1fr;
+ }
+ }
+
+ @container (max-width: 860px) {
+ .grid {
+ gap: 8px;
+ }
+
+ .panel {
+ height: max(104px, min(var(--spv-panel-height, 124px), 172px));
+ }
+ }
+
+ @container (max-width: 560px) {
+ .header {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 10px;
+ }
+
+ .summary {
+ margin-top: 0;
+ min-width: 0;
+ }
+
+ .summary.with-custom {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+
+ .summary.without-custom {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .summary-chip {
+ padding: 8px 8px 8px 10px;
+ }
+
+ .summary-chip.summary-button {
+ padding: 8px 8px 8px 10px;
+ }
+
+ .summary-chip.alerts-chip {
+ padding: 8px 8px 8px 10px;
+ }
+
+ .summary-value.custom-kpi-value {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ white-space: normal;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+ }
+
+ .header-copy > .subtitle,
+ .subtitle-hint {
+ display: none;
+ }
+
+ .eyebrow {
+ font-size: 0.66rem;
+ letter-spacing: 0.09em;
+ }
+
+ .topline {
+ padding-right: 118px;
+ }
+
+ .grid {
+ gap: 10px;
+ margin-top: 12px;
+ }
+
+ .panel {
+ height: max(100px, min(var(--spv-panel-height, 120px), 156px));
+ border-radius: 16px;
+ padding: 9px;
+ }
+ }
+
+ `, motionStyles];
+
+ declare hass?: HomeAssistant;
+ private _config?: SolarPanelVisualizerConfig;
+ private _derived?: DerivedArrayState;
+ private _cardWidth = 0;
+ private _selectedPanelId: string | null = null;
+ private _showLivePowerPopup = false;
+ private _showEnergyPopup = false;
+ private _showCustomKpiPopup = false;
+ private _showSystemHealthPopup = false;
+ private _dragSourceSlotIndex: number | null = null;
+ private _resizeObserver?: ResizeObserver;
+ private _historyByEntityId: Record = {};
+ private _historyState: HistoryState = "idle";
+ private _historyStateReason?: string;
+ private _historySignature = "";
+ private _historyQuerySignature = "";
+ private _historyLastLoadMs = 0;
+ private _historyLoadToken = 0;
+ private _popupGraphRangeHours: PopupGraphRangeHours = 6;
+ private _popupGraphCache: Record = {};
+ private _popupGraphRequestToken = 0;
+ private _popupGraphLatestTokenByKey: Record = {};
+ private _telemetryGraphPanelId: string | null = null;
+ private _telemetryGraphEntityId: string | null = null;
+ private _telemetryGraphRangeHours: PopupGraphRangeHours = 6;
+ private _kpiCompareExpanded: Record = {
+ power: false,
+ energy: false,
+ };
+ private _kpiCompareRangeHours: Record = {
+ power: 6,
+ energy: 6,
+ };
+ private _kpiCompareCache: Record = {};
+ private _kpiCompareRequestToken = 0;
+ private _kpiCompareLatestTokenByKey: Record = {};
+ private _persistConfigToken = 0;
+ private _sourceConfigRef: unknown;
+ private _popupScrollRestoreToken = 0;
+ private _popupScrollRestore:
+ | {
+ token: number;
+ popup: HTMLElement;
+ top: number;
+ left: number;
+ expiresAt: number;
+ frameId: number | null;
+ framesRemaining: number;
+ }
+ | null = null;
+ private readonly _motionEngine = new MotionEngine(() => this.requestUpdate());
+ private _motionOverlayModel: MotionOverlayModel | null = null;
+ private _motionOverlaySignature = "";
+ private _overlayMeasureFrame: number | null = null;
+ private _panelDetailOverflowFrame: number | null = null;
+ private _activePulseColor = "rgba(111, 201, 255, 0.72)";
+ private _overlayResizeObserver?: ResizeObserver;
+ private readonly _onMotionAnimationEnd = (event: Event): void => {
+ const animationEvent = event as AnimationEvent;
+ if (animationEvent.animationName !== "spv-flow-pulse") {
+ return;
+ }
+ const target = animationEvent.target;
+ if (!(target instanceof SVGPathElement)) {
+ return;
+ }
+ if (!target.classList.contains("spv-motion-flow")) {
+ return;
+ }
+ if (this._hasPopupOpen()) {
+ return;
+ }
+ this._motionEngine.completeFlowPulse();
+ };
+
+ public static async getConfigElement(): Promise {
+ await import("./solar-panel-visualizer-editor");
+ return document.createElement("solar-panel-visualizer-card-editor");
+ }
+
+ public static getStubConfig(): SolarPanelVisualizerConfig {
+ return buildStubConfig();
+ }
+
+ protected _normalizeCardConfig(
+ config: Partial,
+ ): SolarPanelVisualizerConfig {
+ return normalizeConfig(config);
+ }
+
+ protected _renderCardOverlay(_derived: DerivedArrayState) {
+ const config = this._getCurrentConfig();
+ const motionState = this._motionEngine.getState();
+ return renderMotionOverlay(this._motionOverlayModel, {
+ showTopology:
+ Boolean(config?.motion_enabled ?? true) &&
+ Boolean(config?.motion_power_flow ?? true),
+ showFlow: motionState.powerFlowActive,
+ activeFlowPanelId: motionState.flowPulsePanelId,
+ powerImpactActive: motionState.kpiShimmers.has("power"),
+ });
+ }
+
+ protected _afterBaseUpdated(_changedProperties: PropertyValues): void {
+ const config = this._getCurrentConfig();
+ const derived = this._getCurrentDerived();
+ if (!config || !derived) {
+ return;
+ }
+
+ const busbarsEnabled =
+ (config.motion_enabled ?? true) &&
+ (config.motion_power_flow ?? true);
+ this.toggleAttribute("data-spv-busbars", busbarsEnabled);
+ this._motionEngine.update(this._buildMotionFlags(config), this._buildMotionSnapshot(derived));
+ this._syncActivePulseColor(derived);
+ this._scheduleOverlayMeasure();
+ }
+
+ protected _getPanelExtraClass(panel: DerivedPanelState): string {
+ const classes: string[] = [];
+ if (this._motionEngine.getState().alertRipples.has(panel.id)) {
+ classes.push("spv-motion-alert-ripple");
+ }
+ return classes.join(" ");
+ }
+
+ protected _getSummaryExtraClass(
+ kind: "power" | "energy" | "alerts" | "custom",
+ ): string {
+ if (kind === "alerts" || kind === "custom") {
+ return "";
+ }
+ if (!this._motionEngine.getState().kpiShimmers.has(kind)) {
+ return "";
+ }
+ return kind === "power"
+ ? "spv-motion-power-impact"
+ : "spv-motion-neutral-impact";
+ }
+
+ protected _getSummaryExtraStyle(
+ kind: "power" | "energy" | "alerts" | "custom",
+ ): string {
+ if (kind !== "power") {
+ return "";
+ }
+ return `--spv-shimmer-color:${this._activePulseColor};`;
+ }
+
+ protected _getCurrentConfig(): SolarPanelVisualizerConfig | undefined {
+ return this._config;
+ }
+
+ protected _getCurrentDerived(): DerivedArrayState | undefined {
+ return this._derived;
+ }
+
+ protected _hasPopupOpen(): boolean {
+ return Boolean(
+ this._selectedPanelId ||
+ this._showLivePowerPopup ||
+ this._showEnergyPopup ||
+ this._showCustomKpiPopup ||
+ this._showSystemHealthPopup,
+ );
+ }
+
+ protected _t(
+ key: TranslationKey,
+ params?: Record,
+ ): string {
+ return translate(this.hass, key, params);
+ }
+
+ private _buildMotionFlags(config: SolarPanelVisualizerConfig) {
+ return {
+ enabled: config.motion_enabled ?? true,
+ powerFlow: config.motion_power_flow ?? true,
+ updateShimmer: config.motion_update_shimmer ?? true,
+ alertRipple: config.motion_alert_ripple ?? true,
+ };
+ }
+
+ private _buildMotionSnapshot(derived: DerivedArrayState): MotionSnapshot {
+ const panelPowers: MotionSnapshot["panelPowers"] = {};
+ const panelStatuses: MotionSnapshot["panelStatuses"] = {};
+
+ for (const panel of derived.panels) {
+ panelPowers[panel.id] = panel.power;
+ panelStatuses[panel.id] = panel.status;
+ }
+
+ return {
+ totalPower: derived.totalPower,
+ popupOpen: this._hasPopupOpen(),
+ powerKpiValue:
+ this._resolveSummaryPower(derived).value?.toString() ?? this._t("common.unavailable"),
+ energyKpiValue:
+ this._resolveSummaryEnergy(derived).value?.toString() ?? this._t("common.unavailable"),
+ panelPowers,
+ panelStatuses,
+ };
+ }
+
+ private _scheduleOverlayMeasure(): void {
+ if (this._overlayMeasureFrame !== null || typeof window === "undefined") {
+ return;
+ }
+ this._overlayMeasureFrame = window.requestAnimationFrame(() => {
+ this._overlayMeasureFrame = null;
+ this._measureOverlay();
+ });
+ }
+
+ private _syncActivePulseColor(derived: DerivedArrayState): void {
+ const pulsePanelId = this._motionEngine.getState().flowPulsePanelId;
+ const panel = derived.panels.find((entry) => entry.id === pulsePanelId);
+ if (panel?.accentColor) {
+ this._activePulseColor = panel.accentColor;
+ }
+ }
+
+ private _measureOverlay(): void {
+ const config = this._getCurrentConfig();
+ const derived = this._getCurrentDerived();
+ const shadowRoot = this.shadowRoot;
+ if (!config || !derived || !shadowRoot) {
+ return;
+ }
+
+ const nextModel = buildMotionOverlayModel(this, shadowRoot, config, derived);
+ const nextSignature = JSON.stringify(nextModel);
+ if (nextSignature === this._motionOverlaySignature) {
+ return;
+ }
+ this._motionOverlayModel = nextModel;
+ this._motionOverlaySignature = nextSignature;
+ this.requestUpdate();
+ }
+
+ private _startOverlayResizeObserver(): void {
+ if (typeof ResizeObserver === "undefined") {
+ return;
+ }
+ this._overlayResizeObserver?.disconnect();
+ this._overlayResizeObserver = new ResizeObserver(() => this._scheduleOverlayMeasure());
+ this._overlayResizeObserver.observe(this);
+ }
+
+ private _resolveDefaultTitle(configTitle: string | undefined): string {
+ if (!configTitle || configTitle.trim().length === 0) {
+ return this._t("card.default_title");
+ }
+ return configTitle === "Solar Array" ? this._t("card.default_title") : configTitle;
+ }
+
+ private _resolveCustomKpiTitle(
+ value: string,
+ ): string {
+ return value === "Custom KPI" ? this._t("card.summary.custom_default_title") : value;
+ }
+
+ private _panelStatusLabel(status: DerivedPanelState["status"]): string {
+ return this._t(`card.panel.status.${status}` as TranslationKey);
+ }
+
+ public connectedCallback(): void {
+ super.connectedCallback();
+ this._startResizeObserver();
+ this._startOverlayResizeObserver();
+ }
+
+ public disconnectedCallback(): void {
+ if (this._overlayMeasureFrame !== null && typeof window !== "undefined") {
+ window.cancelAnimationFrame(this._overlayMeasureFrame);
+ this._overlayMeasureFrame = null;
+ }
+ if (this._panelDetailOverflowFrame !== null && typeof window !== "undefined") {
+ window.cancelAnimationFrame(this._panelDetailOverflowFrame);
+ this._panelDetailOverflowFrame = null;
+ }
+ this._overlayResizeObserver?.disconnect();
+ this._overlayResizeObserver = undefined;
+ this.renderRoot.removeEventListener("animationend", this._onMotionAnimationEnd, true);
+ this._clearPopupScrollRestore();
+ this._motionEngine.destroy();
+ this._resizeObserver?.disconnect();
+ this._resizeObserver = undefined;
+ super.disconnectedCallback();
+ }
+
+ protected firstUpdated(changedProperties: PropertyValues): void {
+ super.firstUpdated(changedProperties);
+ this.renderRoot.addEventListener("animationend", this._onMotionAnimationEnd, true);
+ }
+
+ public setConfig(config: Partial): void {
+ const errors = validateConfig(config);
+ if (errors.length > 0) {
+ throw new Error(errors.join(" "));
+ }
+
+ const normalizedConfig = this._normalizeCardConfig(config);
+ const sameConfigEcho =
+ this._config !== undefined && this._configsEqual(this._config, normalizedConfig);
+
+ this._sourceConfigRef = config;
+ this._config = normalizedConfig;
+ if (!sameConfigEcho) {
+ this._historySignature = "";
+ this._historyQuerySignature = "";
+ this._historyLastLoadMs = 0;
+ this._historyLoadToken = 0;
+ this._popupGraphRangeHours = 6;
+ this._popupGraphCache = {};
+ this._popupGraphLatestTokenByKey = {};
+ this._kpiCompareExpanded = { power: false, energy: false };
+ this._kpiCompareRangeHours = { power: 6, energy: 6 };
+ this._kpiCompareCache = {};
+ this._kpiCompareLatestTokenByKey = {};
+ this._showLivePowerPopup = false;
+ this._showEnergyPopup = false;
+ this._showCustomKpiPopup = false;
+ this._showSystemHealthPopup = false;
+ this._selectedPanelId = null;
+ }
+ this._refreshDerived();
+ this._ensureHistoryLoaded();
+ }
+
+ private _configsEqual(
+ currentConfig: SolarPanelVisualizerConfig,
+ nextConfig: SolarPanelVisualizerConfig,
+ ): boolean {
+ return JSON.stringify(currentConfig) === JSON.stringify(nextConfig);
+ }
+
+ public getCardSize(): number {
+ const panelsCount = this._config?.panels.length ?? 6;
+ const configColumns = this._config?.columns ?? 3;
+ const renderedColumns = this._computeRenderedColumns(
+ configColumns,
+ this._config?.max_card_width_px,
+ );
+ const renderedRows = this._computeRenderedRows(panelsCount, renderedColumns);
+ const panelHeightPx = this._computePanelHeightPx(
+ renderedRows,
+ this._config?.max_card_height_px,
+ renderedColumns,
+ this._config?.max_card_width_px,
+ );
+ const estimatedHeight = 220 + renderedRows * panelHeightPx + Math.max(0, renderedRows - 1) * 10;
+ const cappedHeight = this._config?.max_card_height_px
+ ? Math.min(estimatedHeight, this._config.max_card_height_px)
+ : estimatedHeight;
+ return Math.max(5, Math.ceil(cappedHeight / 50));
+ }
+
+ protected updated(changedProperties: PropertyValues): void {
+ if (changedProperties.has("hass")) {
+ this._syncLiveSamplesFromHass();
+ this._ensureHistoryLoaded();
+ this._refreshDerived();
+ }
+ if (this._derived) {
+ if (this._showLivePowerPopup && this._kpiCompareExpanded.power) {
+ void this._ensurePanelCompareLoaded(
+ this._derived,
+ "power",
+ this._kpiCompareRangeHours.power,
+ );
+ }
+ if (this._showEnergyPopup && this._kpiCompareExpanded.energy) {
+ void this._ensurePanelCompareLoaded(
+ this._derived,
+ "energy",
+ this._kpiCompareRangeHours.energy,
+ );
+ }
+ }
+ this._afterBaseUpdated(changedProperties);
+ this._schedulePanelDetailOverflowSync();
+ }
+
+ private _schedulePanelDetailOverflowSync(): void {
+ if (this._panelDetailOverflowFrame !== null || typeof window === "undefined") {
+ return;
+ }
+
+ this._panelDetailOverflowFrame = window.requestAnimationFrame(() => {
+ this._panelDetailOverflowFrame = null;
+ this._syncPanelDetailOverflow();
+ });
+ }
+
+ private _syncPanelDetailOverflow(): void {
+ const detailAreas = this.renderRoot.querySelectorAll(".panel-detail-scroll");
+ detailAreas.forEach((detailArea) => {
+ const panel = detailArea.closest(".panel");
+ if (!panel) {
+ return;
+ }
+
+ if (panel.classList.contains("hidden-slot") || detailArea.childElementCount === 0) {
+ panel.classList.remove("has-detail-overflow");
+ detailArea.scrollTop = 0;
+ return;
+ }
+
+ const previousScrollTop = detailArea.scrollTop;
+ panel.classList.remove("has-detail-overflow");
+ this._syncPerformanceLabelFit(detailArea);
+ const overflowsVertically = detailArea.scrollHeight > detailArea.clientHeight + 1;
+ panel.classList.toggle("has-detail-overflow", overflowsVertically);
+ if (overflowsVertically) {
+ this._syncPerformanceLabelFit(detailArea);
+ const maxScrollTop = this._getPanelDetailMaxScrollTop(detailArea);
+ detailArea.scrollTop = Math.min(previousScrollTop, maxScrollTop);
+ } else {
+ detailArea.scrollTop = 0;
+ }
+ });
+ }
+
+ private _getPanelDetailMaxScrollTop(detailArea: HTMLElement): number {
+ const naturalMaxScrollTop = Math.max(0, detailArea.scrollHeight - detailArea.clientHeight);
+ const visibleChildren = Array.from(detailArea.children).filter(
+ (child): child is HTMLElement =>
+ child instanceof HTMLElement &&
+ child.offsetParent !== null &&
+ child.offsetHeight > 0,
+ );
+ const lastChild = visibleChildren[visibleChildren.length - 1];
+ if (!lastChild) {
+ return naturalMaxScrollTop;
+ }
+ return Math.min(naturalMaxScrollTop, Math.max(0, lastChild.offsetTop));
+ }
+
+ private _handlePanelDetailScroll = (event: Event): void => {
+ const detailArea = event.currentTarget as HTMLElement | null;
+ if (!detailArea) {
+ return;
+ }
+ const maxScrollTop = this._getPanelDetailMaxScrollTop(detailArea);
+ if (detailArea.scrollTop > maxScrollTop) {
+ detailArea.scrollTop = maxScrollTop;
+ }
+ };
+
+ private _syncPerformanceLabelFit(detailArea: HTMLElement): void {
+ const performance = detailArea.querySelector(".performance");
+ if (!performance) {
+ return;
+ }
+
+ const full = performance.dataset.performanceFull;
+ const medium = performance.dataset.performanceMedium;
+ const compact = performance.dataset.performanceCompact;
+ const candidates = [full, medium, compact].filter(
+ (candidate): candidate is string => Boolean(candidate),
+ );
+ if (candidates.length === 0) {
+ return;
+ }
+
+ const currentText = performance.textContent?.trim();
+ const currentIndex = Math.max(
+ 0,
+ candidates.findIndex((candidate) => candidate === currentText),
+ );
+ for (const candidate of candidates.slice(currentIndex)) {
+ performance.textContent = candidate;
+ if (performance.scrollWidth <= performance.clientWidth + 1) {
+ return;
+ }
+ }
+ performance.textContent = candidates[candidates.length - 1];
+ }
+
+ protected render() {
+ if (!this._config) {
+ return nothing;
+ }
+
+ const derived = this._derived ?? deriveArrayState(this._config, this.hass);
+ const locale = this.hass?.locale?.language ?? "en";
+ const unavailableText = this._t("common.unavailable");
+ const notConfiguredText = this._t("common.not_configured");
+ const title = this._resolveDefaultTitle(this._config.title);
+ const resolvedTheme = this._resolveThemeMode();
+ const summaryPower = this._resolveSummaryPower(derived);
+ const summaryEnergy = this._resolveSummaryEnergy(derived);
+ const customKpi = this._resolveCustomKpi();
+ const energyConfigured = this._isSummaryEnergyConfigured();
+ const showCustomKpi = this._config.show_custom_kpi ?? true;
+ const renderedColumns = this._computeRenderedColumns(
+ this._config.columns,
+ this._config.max_card_width_px,
+ );
+ const renderedRows = this._computeRenderedRows(
+ this._config.panels.length,
+ renderedColumns,
+ );
+ const panelHeightPx = this._computePanelHeightPx(
+ renderedRows,
+ this._config.max_card_height_px,
+ renderedColumns,
+ this._config.max_card_width_px,
+ );
+ const panelWidthPx = this._computePanelWidthPx(
+ renderedColumns,
+ this._config.max_card_width_px,
+ );
+ const panelScale = this._computePanelScale(panelHeightPx);
+ const panelWidthCapPx = this._getPanelWidthCapPx();
+ const gridStyle =
+ panelWidthCapPx !== null
+ ? `grid-template-columns: repeat(${renderedColumns}, minmax(0, ${panelWidthCapPx}px)); justify-content: center;`
+ : `grid-template-columns: repeat(${renderedColumns}, minmax(0, 1fr));`;
+ const sharedHistoryHours = this._config.deviation_history_hours ?? 12;
+ const cardStyle = `--spv-max-width:${this._config.max_card_width_px ?? 980}px; ${
+ this._config.max_card_height_px
+ ? `--spv-max-height:${this._config.max_card_height_px}px;`
+ : "--spv-max-height:none;"
+ } --spv-panel-height:${panelHeightPx}px; --spv-panel-scale:${panelScale}; --spv-panel-max-width:${
+ panelWidthCapPx ? `${panelWidthCapPx}px` : "100%"
+ };`;
+ const systemHealth = this._buildSystemHealthState(derived);
+ const alertCount =
+ derived.inverterCount + derived.errorCount + derived.offlineCount + derived.deviationCount;
+ const overlayAnchorStyle = this._motionOverlayModel
+ ? `width:${this._motionOverlayModel.width}px; height:${this._motionOverlayModel.height}px;`
+ : "";
+ const subtitle =
+ (this._config.enable_array_checks ?? false) && this._historyState === "loading"
+ ? this._t("card.subtitle.loading_history", { hours: sharedHistoryHours })
+ : !derived.deviationReady
+ ? this._historyStateReason ??
+ derived.deviationSuppressedReason ??
+ this._t("card.subtitle.warmup")
+ : derived.deviationCount > 0
+ ? this._t("card.subtitle.deviation_detected", {
+ count: derived.deviationCount,
+ suffix: derived.deviationCount === 1 ? "" : "s",
+ })
+ : this._t("card.subtitle.tap_diagnostics");
+ const selectedPanel = derived.panels.find(
+ (panel) => panel.id === this._selectedPanelId,
+ );
+
+ return html`
+
+
+
+ ${this._renderCardOverlay(derived)}
+
+
+
+
+ ${derived.panels.map((panel) => this._renderPanel(panel, panelHeightPx, panelWidthPx))}
+
+
+ ${this._showLivePowerPopup ? this._renderLivePowerDialog(derived) : nothing}
+ ${this._showEnergyPopup ? this._renderEnergyDialog(derived) : nothing}
+ ${this._showCustomKpiPopup ? this._renderCustomKpiDialog() : nothing}
+ ${this._showSystemHealthPopup ? this._renderSystemHealthDialog(derived) : nothing}
+ ${selectedPanel ? this._renderPanelDialog(selectedPanel) : nothing}
+
+ `;
+ }
+
+ private _refreshDerived(): void {
+ if (!this._config) {
+ this._derived = undefined;
+ return;
+ }
+
+ this._derived = deriveArrayState(this._config, this.hass, {
+ deviationEnabled: this._config.enable_array_checks ?? false,
+ historyByEntityId: this._historyByEntityId,
+ nowMs: Date.now(),
+ });
+ }
+
+ private _formatSlotLabel(slotIndex: number): string {
+ const columns = this._config?.columns ?? 1;
+ const row = Math.floor(slotIndex / columns) + 1;
+ const column = (slotIndex % columns) + 1;
+ return this._t("card.panel.slot_label", { row, column });
+ }
+
+ private _formatPanelSlotLabel(panel: DerivedPanelState): string {
+ const slotLabel = this._formatSlotLabel(panel.slotIndex);
+ return panel.stringGroupLabel
+ ? `${panel.stringGroupLabel} · ${slotLabel}`
+ : slotLabel;
+ }
+
+ private _renderPanel(
+ panel: DerivedPanelState,
+ panelHeightPx: number,
+ panelWidthPx: number,
+ ) {
+ const showPerformance = this._shouldShowPerformance(panel, panelHeightPx, panelWidthPx);
+ const extraClass = this._getPanelExtraClass(panel);
+ const inverterStatusInline = normalizeInlineStatusText(panel.inverterStatusDisplay);
+ const inverterTileSummary = this._formatInverterTileSummary(panel, inverterStatusInline);
+ const panelIntensity = Math.max(0, Math.min(panel.intensity, 1));
+ const borderMix = Math.round(24 + panelIntensity * 62);
+ const fillMix = Math.round(6 + panelIntensity * 24);
+ const glowMix = Math.round(4 + panelIntensity * 18);
+ const panelStyle = [
+ `--panel-accent:${panel.accentColor}`,
+ `--panel-intensity:${panelIntensity.toFixed(3)}`,
+ `--panel-border-accent:color-mix(in srgb, ${panel.accentColor} ${borderMix}%, var(--spv-panel-accent-mix-base))`,
+ `--panel-fill-accent:color-mix(in srgb, ${panel.accentColor} ${fillMix}%, transparent)`,
+ `--panel-glow-accent:color-mix(in srgb, ${panel.accentColor} ${glowMix}%, transparent)`,
+ ].join("; ");
+ const performanceCandidates = showPerformance
+ ? this._buildPanelPerformanceLabelCandidates(panel)
+ : null;
+ if (panel.hiddenSlot) {
+ return html`
+
+ `;
+ }
+
+ return html`
+
+ `;
+ }
+
+ private _renderPopupCloseButton(onClick: () => void, ariaLabel: string) {
+ return html`
+
+ `;
+ }
+
+ private _formatInverterTileSummary(
+ panel: DerivedPanelState,
+ normalizedInlineStatus?: string,
+ ): string | undefined {
+ if (!normalizedInlineStatus) {
+ return undefined;
+ }
+ if (panel.status === "deviation") {
+ return this._t("card.panel.inverter_short.deviation");
+ }
+ if (panel.status === "error" || panel.status === "inverter" || panel.status === "offline") {
+ return this._t("card.panel.inverter_short.error");
+ }
+ if (panel.inverterWorkingMatched) {
+ return this._t("card.panel.inverter_short.ok");
+ }
+ return this._t("card.panel.inverter_prefix", {
+ status: normalizedInlineStatus,
+ });
+ }
+
+ private _renderPanelDialog(panel: DerivedPanelState) {
+ const panelConfig = this._getPanelConfig(panel.id);
+ const energyValue = panelConfig?.energy_entity
+ ? panel.energyDisplay ?? this._t("common.unavailable")
+ : this._t("common.not_configured");
+
+ if (panel.status === "unconfigured") {
+ return html`
+
+ `;
+ }
+
+ return html`
+
+ `;
+ }
+
+ private _renderPanelTelemetrySection(panel: DerivedPanelState) {
+ const panelConfig = this._getPanelConfig(panel.id);
+ if (!panelConfig) {
+ return nothing;
+ }
+
+ const metrics = this._getPanelTelemetryMetrics(panelConfig);
+ const configuredMetrics = metrics.filter((metric) => Boolean(metric.entityId));
+ const unconfiguredMetrics = metrics.filter((metric) => !metric.entityId);
+ const hasConfiguredMetrics = configuredMetrics.length > 0;
+ const activeTelemetryEntityId =
+ this._telemetryGraphPanelId === panel.id ? this._telemetryGraphEntityId : null;
+
+ return html`
+
+ ${this._t("card.popup.telemetry.title")}
+
+ ${hasConfiguredMetrics
+ ? html`
+
+
${this._t("card.popup.telemetry.configured_title")}
+
+ ${configuredMetrics.map(
+ (metric) => this._renderTelemetryMetricCard(
+ panel.id,
+ metric,
+ metric.entityId === activeTelemetryEntityId,
+ ),
+ )}
+
+
+ `
+ : nothing}
+
+ ${unconfiguredMetrics.length > 0
+ ? html`
+
+
${this._t("card.popup.telemetry.unconfigured_title")}
+
${this._t("card.popup.telemetry.setup_hint")}
+
+ ${unconfiguredMetrics.map(
+ (metric) => html`
+
+ ${metric.label}
+ ${this._t("common.not_configured")}
+
+ `,
+ )}
+
+
+ `
+ : !hasConfiguredMetrics
+ ? html`${this._t("card.popup.telemetry.setup_hint")}
`
+ : nothing}
+
+ `;
+ }
+
+ private _renderTelemetryMetricCard(
+ panelId: string,
+ metric: PanelTelemetryMetric,
+ isOpen: boolean,
+ ) {
+ const entityId = metric.entityId;
+ if (!entityId) {
+ return nothing;
+ }
+
+ return html`
+
+
+ ${isOpen ? this._renderTelemetryMetricGraph(metric) : nothing}
+
+ `;
+ }
+
+ private _renderTelemetryMetricGraph(metric: PanelTelemetryMetric) {
+ const entityId = metric.entityId;
+ if (!entityId) {
+ return nothing;
+ }
+ const range = this._telemetryGraphRangeHours;
+ const cacheKey = this._getPopupGraphCacheKey(entityId, range);
+ const cache = this._popupGraphCache[cacheKey];
+ const rawSamples = cache?.samples ?? [];
+ const samples = this._adjustGraphSamplesForEntity(entityId, rawSamples);
+ const unit = this._getUnitForEntity(entityId, "");
+
+ return html`
+
+
+ ${this._renderPopupGraphBodyFromSamples(
+ cacheKey,
+ unit,
+ samples,
+ range,
+ cache?.loading ?? false,
+ cache?.error,
+ { fillStartWithFirstSample: true },
+ )}
+
+ `;
+ }
+
+ private _getPanelTelemetryMetrics(panel: PanelConfig): PanelTelemetryMetric[] {
+ const advanced: PanelAdvancedMetrics = panel.advanced_metrics ?? {};
+ return [
+ {
+ label: this._t("card.popup.telemetry.label.inverter_status"),
+ entityId: panel.inverter_status_entity ?? panel.error_entity,
+ },
+ {
+ label: this._t("card.popup.telemetry.label.inverter_ac_power"),
+ entityId: advanced.inverter_ac_power_entity,
+ },
+ {
+ label: this._t("card.popup.telemetry.label.inverter_ac_voltage"),
+ entityId: advanced.inverter_ac_voltage_entity,
+ },
+ {
+ label: this._t("card.popup.telemetry.label.inverter_ac_current"),
+ entityId: advanced.inverter_ac_current_entity,
+ },
+ {
+ label: this._t("card.popup.telemetry.label.inverter_temp"),
+ entityId: advanced.inverter_temp_entity,
+ },
+ {
+ label: this._t("card.popup.telemetry.label.panel_current"),
+ entityId: advanced.panel_current_entity,
+ },
+ {
+ label: this._t("card.popup.telemetry.label.panel_voltage"),
+ entityId: advanced.panel_voltage_entity,
+ },
+ {
+ label: this._t("card.popup.telemetry.label.panel_power"),
+ entityId: advanced.panel_power_entity,
+ },
+ ];
+ }
+
+ private _formatTelemetryMetricValue(entityId: string): string {
+ const entity = this.hass?.states?.[entityId];
+ if (!entity) {
+ return this._t("common.unavailable");
+ }
+
+ const rawState = entity.state?.toString().trim() ?? "";
+ const normalized = rawState.toLowerCase();
+ if (normalized.length === 0 || INVALID_ENTITY_STATES.has(normalized)) {
+ return this._t("common.unavailable");
+ }
+
+ const numeric = Number(entity.state);
+ const unit =
+ typeof entity.attributes?.unit_of_measurement === "string"
+ ? entity.attributes.unit_of_measurement.trim()
+ : "";
+ if (Number.isFinite(numeric)) {
+ const locale = this.hass?.locale?.language ?? "en";
+ const safeValue = Object.is(numeric, -0) ? 0 : numeric;
+ const decimals = Number.isInteger(safeValue) ? 0 : 2;
+ const formatted = new Intl.NumberFormat(locale, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: decimals,
+ }).format(safeValue);
+ return unit ? `${formatted} ${unit}` : formatted;
+ }
+
+ return unit ? `${rawState} ${unit}` : rawState;
+ }
+
+ private _renderKpiCompareControls(
+ derived: DerivedArrayState,
+ kind: PanelCompareMetricKind,
+ ) {
+ const expanded = this._kpiCompareExpanded[kind];
+ const toggleLabel =
+ kind === "power"
+ ? this._t("card.popup.panel_compare.toggle_power")
+ : this._t("card.popup.panel_compare.toggle_energy");
+ return html`
+
+
+
+ ${expanded ? this._renderPanelCompareGraph(derived, kind) : nothing}
+ `;
+ }
+
+ private _togglePanelCompareGraph(
+ event: Event,
+ kind: PanelCompareMetricKind,
+ derived: DerivedArrayState,
+ ): void {
+ const scrollToken = this._captureScrollPositionsForPopupGraph(event);
+ const nextExpanded = !this._kpiCompareExpanded[kind];
+ this._kpiCompareExpanded = {
+ ...this._kpiCompareExpanded,
+ [kind]: nextExpanded,
+ };
+ this._scheduleCapturedScrollRestore(scrollToken);
+ if (nextExpanded) {
+ void this._ensurePanelCompareLoaded(
+ derived,
+ kind,
+ this._kpiCompareRangeHours[kind],
+ scrollToken,
+ );
+ }
+ }
+
+ private _renderPanelCompareGraph(
+ derived: DerivedArrayState,
+ kind: PanelCompareMetricKind,
+ ) {
+ const targets = this._buildPanelCompareTargets(derived, kind);
+ const range = this._kpiCompareRangeHours[kind];
+ if (targets.length === 0) {
+ return html`
+
+ ${kind === "energy"
+ ? this._t("card.popup.panel_compare.no_panels_energy")
+ : this._t("card.popup.panel_compare.no_panels_power")}
+
+ `;
+ }
+
+ const signature = this._getPanelCompareSignature(kind, targets);
+ const cacheKey = this._getPanelCompareCacheKey(kind, signature, range);
+ const cache = this._kpiCompareCache[cacheKey];
+ const loading = cache?.loading ?? false;
+ const error = cache?.error;
+
+ const series = targets.map((target, index) => ({
+ ...target,
+ color: PANEL_COMPARE_COLORS[index % PANEL_COMPARE_COLORS.length],
+ samples: this._scaleGraphSamples(
+ this._adjustGraphSamplesForEntity(
+ target.entityId,
+ cache?.samplesByPanelId[target.panelId] ?? [],
+ ),
+ target.scale,
+ ),
+ }));
+
+ const graphModel = buildCompareGraphModel(
+ series.map((item) => ({
+ id: item.panelId,
+ samples: item.samples,
+ })),
+ Date.now(),
+ range,
+ POPUP_GRAPH_MAX_POINTS,
+ 320,
+ 132,
+ 10,
+ );
+ const seriesById = new Map(graphModel.series.map((item) => [item.id, item]));
+ const hasSuspectSeries = graphModel.series.some(
+ (item) =>
+ item.sampleCount > 0 &&
+ (item.pointsCount === 0 || (item.sampleCount > 1 && item.pointsCount < 2)),
+ );
+ const hasRenderFailure = graphModel.hasData && graphModel.drawableCount === 0;
+ const showDiagnostics = hasRenderFailure || hasSuspectSeries;
+ const compareUnit = kind === "energy" ? "kWh" : "W";
+ const compareValues = graphModel.series.flatMap((item) =>
+ item.samples
+ .map((sample) => sample.value)
+ .filter((value) => Number.isFinite(value)),
+ );
+ const compareStats =
+ compareValues.length > 0
+ ? this._computeGraphStats(
+ compareValues.map((value, index) => ({
+ ts: index,
+ value,
+ })),
+ )
+ : { min: null, max: null, median: null };
+ const compareMinRaw = compareValues.length > 0 ? Math.min(...compareValues) : null;
+ const compareMaxRaw = compareValues.length > 0 ? Math.max(...compareValues) : null;
+ const compareScaleMin =
+ compareMinRaw === null || compareMaxRaw === null
+ ? null
+ : compareMinRaw === compareMaxRaw
+ ? compareMinRaw - 1
+ : compareMinRaw;
+ const compareScaleMax =
+ compareMinRaw === null || compareMaxRaw === null
+ ? null
+ : compareMinRaw === compareMaxRaw
+ ? compareMaxRaw + 1
+ : compareMaxRaw;
+ const compareMaxY =
+ compareStats.max !== null && compareScaleMin !== null && compareScaleMax !== null
+ ? this._toGraphY(compareStats.max, compareScaleMin, compareScaleMax, 132, 10)
+ : null;
+ const compareMedianY =
+ compareStats.median !== null && compareScaleMin !== null && compareScaleMax !== null
+ ? this._toGraphY(compareStats.median, compareScaleMin, compareScaleMax, 132, 10)
+ : null;
+ const compareMinY =
+ compareStats.min !== null && compareScaleMin !== null && compareScaleMax !== null
+ ? this._toGraphY(compareStats.min, compareScaleMin, compareScaleMax, 132, 10)
+ : null;
+ const compareMaxLabel =
+ compareStats.max !== null
+ ? this._t("card.popup.history.max", {
+ value: this._formatGraphPower(compareStats.max, compareUnit),
+ })
+ : null;
+ const compareMedianLabel =
+ compareStats.median !== null
+ ? this._t("card.popup.history.median", {
+ value: this._formatGraphPower(compareStats.median, compareUnit),
+ })
+ : null;
+ const compareMinLabel =
+ compareStats.min !== null
+ ? this._t("card.popup.history.min", {
+ value: this._formatGraphPower(compareStats.min, compareUnit),
+ })
+ : null;
+
+ const hourTicks = this._buildGraphHourTicks(
+ {
+ startTs: graphModel.startTs,
+ endTs: graphModel.endTs,
+ },
+ range,
+ );
+ const axisTicks = this._buildGraphAxisTicks(hourTicks);
+ const compareTitle =
+ kind === "energy"
+ ? this._t("card.popup.history.panel_compare_energy")
+ : this._t("card.popup.history.panel_compare_power");
+
+ return html`
+
+
+
+ ${loading
+ ? html`
${this._t("card.popup.panel_compare.loading")}
`
+ : error
+ ? html`
${error}
`
+ : !graphModel.hasData
+ ? html`
${this._t("card.popup.panel_compare.no_data")}
`
+ : hasRenderFailure
+ ? html`
${this._t("card.popup.panel_compare.render_failure")}
`
+ : html`
+
+ ${compareMaxLabel
+ ? html`
${compareMaxLabel}`
+ : nothing}
+ ${compareMedianLabel
+ ? html`
+
+ ${compareMedianLabel}
+
+ `
+ : nothing}
+ ${compareMinLabel
+ ? html`
${compareMinLabel}`
+ : nothing}
+
+
+
+ ${axisTicks.map(
+ (tick) => html`
+
+ ${tick.label}
+
+ `,
+ )}
+
+
+
+ ${this._t("card.popup.history.time_range", {
+ start: this._formatGraphTime(graphModel.startTs),
+ end: this._formatGraphTime(graphModel.endTs),
+ })}
+
+
+ `}
+
+ ${showDiagnostics
+ ? html`
+
+
+ ${this._t("card.popup.panel_compare.diagnostics_title")}
+
+
+ ${this._t("card.popup.panel_compare.diagnostics_summary", {
+ hasData: graphModel.hasData ? "true" : "false",
+ drawable: graphModel.drawableCount,
+ series: graphModel.series.length,
+ range,
+ })}
+
+
+ ${hasRenderFailure
+ ? this._t("card.popup.panel_compare.diagnostics_reason_render_failure")
+ : this._t("card.popup.panel_compare.diagnostics_reason_suspect")}
+
+ ${series.map((item) => {
+ const graphSeries = seriesById.get(item.panelId);
+ const entityUnit = this._getUnitForEntity(item.entityId, compareUnit);
+ return html`
+
+ ${this._t("card.popup.panel_compare.diagnostics_row", {
+ label: item.label,
+ samples: graphSeries?.sampleCount ?? 0,
+ points: graphSeries?.pointsCount ?? 0,
+ pathLen: graphSeries?.linePath.length ?? 0,
+ first: this._formatGraphDiagnosticTime(graphSeries?.firstTs ?? null),
+ last: this._formatGraphDiagnosticTime(graphSeries?.lastTs ?? null),
+ min:
+ graphSeries?.minValue === null || graphSeries?.minValue === undefined
+ ? "--"
+ : this._formatGraphPower(graphSeries.minValue, entityUnit),
+ max:
+ graphSeries?.maxValue === null || graphSeries?.maxValue === undefined
+ ? "--"
+ : this._formatGraphPower(graphSeries.maxValue, entityUnit),
+ })}
+
+ `;
+ })}
+
+ `
+ : nothing}
+
+
+ ${series.map(
+ (item) => html`
+
+
+ ${item.label}
+
+ `,
+ )}
+
+
+ `;
+ }
+
+ private _renderAggregatedPanelGraph(
+ derived: DerivedArrayState,
+ kind: PanelCompareMetricKind,
+ options: PopupGraphRenderOptions = {},
+ ) {
+ const targets = this._buildPanelCompareTargets(derived, kind);
+ const range = this._kpiCompareRangeHours[kind];
+ if (targets.length === 0) {
+ return html`
+
+ ${kind === "energy"
+ ? this._t("card.popup.panel_compare.no_panels_energy")
+ : this._t("card.popup.panel_compare.no_panels_power")}
+
+ `;
+ }
+
+ const signature = this._getPanelCompareSignature(kind, targets);
+ const cacheKey = this._getPanelCompareCacheKey(kind, signature, range);
+ const cache = this._kpiCompareCache[cacheKey];
+ const loading = cache?.loading ?? false;
+ const error = cache?.error;
+ const aggregateSamples = aggregateStepwiseSamples(
+ targets.map((target) =>
+ this._scaleGraphSamples(
+ this._adjustGraphSamplesForEntity(
+ target.entityId,
+ cache?.samplesByPanelId[target.panelId] ?? [],
+ ),
+ target.scale,
+ ),
+ ),
+ );
+ const unit =
+ kind === "energy"
+ ? this._resolveUnit("energy_entity", "kWh")
+ : this._resolveUnit("power_entity", "W");
+ const title =
+ kind === "energy"
+ ? this._t("card.popup.history.total_panel_energy")
+ : this._t("card.popup.history.total_panel_power");
+
+ return html`
+
+
+ ${this._renderPopupGraphBodyFromSamples(
+ `panel-total-${kind}|${signature}|${range}`,
+ unit,
+ aggregateSamples,
+ range,
+ loading,
+ error,
+ options,
+ )}
+ ${options.overlayStateMessage
+ ? html`
${options.overlayStateMessage}
`
+ : nothing}
+ ${options.showEnableForecastButton
+ ? html`
+
+
+
+ `
+ : nothing}
+
+ `;
+ }
+
+ private _handleAggregatedPanelRangeChange(
+ event: Event,
+ derived: DerivedArrayState,
+ kind: PanelCompareMetricKind,
+ range: PopupGraphRangeHours,
+ overlayEntityId?: string,
+ ): void {
+ event.preventDefault();
+ event.stopPropagation();
+ const scrollToken = this._captureScrollPositionsForPopupGraph(event);
+ this._kpiCompareRangeHours = {
+ ...this._kpiCompareRangeHours,
+ [kind]: range,
+ };
+ this._scheduleCapturedScrollRestore(scrollToken);
+ void this._ensurePanelCompareLoaded(derived, kind, range, scrollToken);
+ if (overlayEntityId) {
+ void this._ensurePopupGraphLoadedByEntity(overlayEntityId, range, scrollToken);
+ }
+ }
+
+ private _handlePanelCompareRangeChange(
+ event: Event,
+ derived: DerivedArrayState,
+ kind: PanelCompareMetricKind,
+ range: PopupGraphRangeHours,
+ ): void {
+ event.preventDefault();
+ event.stopPropagation();
+ const scrollToken = this._captureScrollPositionsForPopupGraph(event);
+ this._kpiCompareRangeHours = {
+ ...this._kpiCompareRangeHours,
+ [kind]: range,
+ };
+ this._scheduleCapturedScrollRestore(scrollToken);
+ if (this._kpiCompareExpanded[kind]) {
+ void this._ensurePanelCompareLoaded(derived, kind, range, scrollToken);
+ }
+ if (!this._getSystemEntityIdForKind(kind) && this._isForecastOverlayEnabled()) {
+ const forecastEntityId = this._getForecastEntityId(kind);
+ if (forecastEntityId) {
+ void this._ensurePopupGraphLoadedByEntity(forecastEntityId, range, scrollToken);
+ }
+ }
+ }
+
+ private _buildPanelCompareTargets(
+ derived: DerivedArrayState,
+ kind: PanelCompareMetricKind,
+ ): PanelCompareTarget[] {
+ const configByPanelId = new Map(
+ (this._config?.panels ?? []).map((panel) => [panel.id, panel]),
+ );
+
+ return derived.panels
+ .filter((panel) => !panel.hiddenSlot && panel.enabled)
+ .map((panel) => {
+ const configPanel = configByPanelId.get(panel.id);
+ const entityId =
+ kind === "energy"
+ ? configPanel?.energy_entity?.trim()
+ : configPanel?.power_entity?.trim();
+ if (!entityId) {
+ return null;
+ }
+ return {
+ panelId: panel.id,
+ label: panel.label,
+ entityId,
+ scale: kind === "energy"
+ ? panel.energyShareFactor ?? 1
+ : panel.powerShareFactor ?? 1,
+ };
+ })
+ .filter((value): value is PanelCompareTarget => Boolean(value));
+ }
+
+ private _getPanelCompareSignature(
+ kind: PanelCompareMetricKind,
+ targets: PanelCompareTarget[],
+ ): string {
+ return `${kind}|${targets
+ .map((target) => `${target.panelId}:${target.entityId}:${target.scale}`)
+ .join("|")}`;
+ }
+
+ private _getPanelCompareCacheKey(
+ kind: PanelCompareMetricKind,
+ signature: string,
+ range: PopupGraphRangeHours,
+ ): string {
+ return `${kind}|${signature}|${range}`;
+ }
+
+ private async _ensurePanelCompareLoaded(
+ derived: DerivedArrayState,
+ kind: PanelCompareMetricKind,
+ range: PopupGraphRangeHours,
+ scrollToken?: number | null,
+ ): Promise {
+ if (!this._config || !this.hass) {
+ return;
+ }
+
+ const targets = this._buildPanelCompareTargets(derived, kind);
+ if (targets.length === 0) {
+ return;
+ }
+
+ const signature = this._getPanelCompareSignature(kind, targets);
+ const cacheKey = this._getPanelCompareCacheKey(kind, signature, range);
+ const existing = this._kpiCompareCache[cacheKey];
+ const nowMs = Date.now();
+ if (existing?.loading) {
+ return;
+ }
+ if (
+ existing &&
+ !existing.error &&
+ existing.loadedAt &&
+ nowMs - existing.loadedAt < PANEL_COMPARE_CACHE_TTL_MS
+ ) {
+ return;
+ }
+
+ const token = ++this._kpiCompareRequestToken;
+ this._kpiCompareLatestTokenByKey[cacheKey] = token;
+ this._kpiCompareCache = {
+ ...this._kpiCompareCache,
+ [cacheKey]: {
+ loading: true,
+ samplesByPanelId: existing?.samplesByPanelId ?? {},
+ loadedAt: existing?.loadedAt,
+ },
+ };
+ this._scheduleCapturedScrollRestore(scrollToken);
+
+ if (!this.hass.callApi && !this.hass.callWS) {
+ if (this._kpiCompareLatestTokenByKey[cacheKey] !== token) {
+ return;
+ }
+ this._kpiCompareCache = {
+ ...this._kpiCompareCache,
+ [cacheKey]: {
+ loading: false,
+ samplesByPanelId: {},
+ error: this._t("card.popup.panel_compare.unable_load", {
+ error: this._t("common.unknown_recorder_error"),
+ }),
+ loadedAt: Date.now(),
+ },
+ };
+ this._scheduleCapturedScrollRestore(scrollToken);
+ return;
+ }
+
+ try {
+ const uniqueEntityIds = [...new Set(targets.map((target) => target.entityId))];
+ const samplesByEntityId: Record = {};
+ let firstLoadError: string | null = null;
+
+ const settled = await Promise.allSettled(
+ uniqueEntityIds.map(async (entityId) => ({
+ entityId,
+ samples: await this._loadPopupGraphRecorderSamples(entityId, range),
+ })),
+ );
+
+ for (const result of settled) {
+ if (result.status === "fulfilled") {
+ samplesByEntityId[result.value.entityId] = result.value.samples;
+ continue;
+ }
+ if (!firstLoadError) {
+ firstLoadError = compactError(
+ toErrorMessage(result.reason, this._t("common.unknown_recorder_error")),
+ );
+ }
+ }
+
+ if (this._kpiCompareLatestTokenByKey[cacheKey] !== token) {
+ return;
+ }
+
+ const hasAnyLoadedSamples = Object.values(samplesByEntityId).some(
+ (samples) => samples.length > 0,
+ );
+ const loadedEntityCount = Object.keys(samplesByEntityId).length;
+ if (loadedEntityCount === 0 && firstLoadError) {
+ this._kpiCompareCache = {
+ ...this._kpiCompareCache,
+ [cacheKey]: {
+ loading: false,
+ samplesByPanelId: {},
+ error: this._t("card.popup.panel_compare.unable_load", {
+ error: firstLoadError,
+ }),
+ loadedAt: Date.now(),
+ },
+ };
+ this._scheduleCapturedScrollRestore(scrollToken);
+ return;
+ }
+
+ const samplesByPanelId: Record = {};
+ for (const target of targets) {
+ samplesByPanelId[target.panelId] = samplesByEntityId[target.entityId] ?? [];
+ }
+
+ this._kpiCompareCache = {
+ ...this._kpiCompareCache,
+ [cacheKey]: {
+ loading: false,
+ samplesByPanelId,
+ error:
+ !hasAnyLoadedSamples && firstLoadError
+ ? this._t("card.popup.panel_compare.unable_load", {
+ error: firstLoadError,
+ })
+ : undefined,
+ loadedAt: Date.now(),
+ },
+ };
+ this._scheduleCapturedScrollRestore(scrollToken);
+ } catch (error) {
+ if (this._kpiCompareLatestTokenByKey[cacheKey] !== token) {
+ return;
+ }
+ this._kpiCompareCache = {
+ ...this._kpiCompareCache,
+ [cacheKey]: {
+ loading: false,
+ samplesByPanelId: {},
+ error: this._t("card.popup.panel_compare.unable_load", {
+ error: compactError(toErrorMessage(error, this._t("common.unknown_recorder_error"))),
+ }),
+ loadedAt: Date.now(),
+ },
+ };
+ this._scheduleCapturedScrollRestore(scrollToken);
+ }
+ }
+
+ private _renderInverterEvaluation(panel: DerivedPanelState): string {
+ if (!panel.inverterStatusDisplay || panel.inverterStatusDisplay === this._t("common.unavailable")) {
+ return this._t("card.popup.inverter_eval.no_status");
+ }
+ if (panel.inverterFaultMatched) {
+ return this._t("card.popup.inverter_eval.fault_match");
+ }
+ if (panel.inverterWorkingMatched) {
+ return this._t("card.popup.inverter_eval.working_match");
+ }
+ return this._t("card.popup.inverter_eval.no_match");
+ }
+
+ private _renderLivePowerDialog(derived: DerivedArrayState) {
+ const systemPowerEntityId = this._getSystemPowerEntityId();
+ const summaryPower = this._resolveSummaryPower(derived);
+ const forecastPower = this._resolveForecastMetricDisplay(
+ "power",
+ this._config?.power_decimals ?? 0,
+ "W",
+ );
+ const forecastOverlayEnabled =
+ this._isForecastOverlayEnabled() && Boolean(forecastPower.entityId);
+ const forecastOverlayMessage = !forecastPower.entityId
+ ? this._t("card.popup.forecast.default_sensor_not_found", {
+ entity: forecastPower.expectedEntityId,
+ })
+ : !this._isForecastOverlayEnabled()
+ ? this._t("card.popup.forecast.disabled_hint")
+ : null;
+ const canEnableForecastFromPowerPopup =
+ !this._isForecastOverlayEnabled() &&
+ Boolean(forecastPower.entityId);
+ const locale = this.hass?.locale?.language ?? "en";
+
+ return html`
+
+ `;
+ }
+
+ private _renderEnergyDialog(derived: DerivedArrayState) {
+ const systemEnergyEntityId = this._getSystemEnergyEntityId();
+ const summaryEnergy = this._resolveSummaryEnergy(derived);
+ const forecastEnergy = this._resolveForecastMetricDisplay(
+ "energy",
+ this._config?.energy_decimals ?? 2,
+ "kWh",
+ );
+ const forecastOverlayEnabled =
+ this._isForecastOverlayEnabled() && Boolean(forecastEnergy.entityId);
+ const forecastOverlayMessage = !forecastEnergy.entityId
+ ? this._t("card.popup.forecast.default_sensor_not_found", {
+ entity: forecastEnergy.expectedEntityId,
+ })
+ : !this._isForecastOverlayEnabled()
+ ? this._t("card.popup.forecast.disabled_hint")
+ : null;
+ const canEnableForecastFromEnergyPopup =
+ !this._isForecastOverlayEnabled() &&
+ Boolean(forecastEnergy.entityId);
+ const locale = this.hass?.locale?.language ?? "en";
+
+ return html`
+
+ `;
+ }
+
+ private _renderCustomKpiDialog() {
+ const customKpi = this._resolveCustomKpi();
+ const popupTitle =
+ customKpi.title.length > 0
+ ? this._resolveCustomKpiTitle(customKpi.title)
+ : this._t("card.summary.custom_default_title");
+ const customKpiEntityId = this._getCustomKpiEntityId();
+
+ return html`
+
+ `;
+ }
+
+ private _renderSystemHealthDialog(derived: DerivedArrayState) {
+ const sections = this._buildSystemHealthSections(derived);
+ const hasIssues = sections.length > 0;
+
+ return html`
+
+ `;
+ }
+
+ private _renderMetricList(
+ title: string,
+ entries: Array<{ label: string; value: string }>,
+ ) {
+ return html`
+
+
+ ${entries.map(
+ (entry) => html`
+
+ ${entry.label}: ${entry.value}
+
+ `,
+ )}
+
+ `;
+ }
+
+ private _renderPopupGraph(panel: DerivedPanelState) {
+ const panelConfig = this._getPanelConfig(panel.id);
+ return this._renderPopupGraphForEntity(
+ panelConfig?.power_entity,
+ this._t("card.popup.history.power"),
+ {
+ primaryValueScale: panel.powerShareFactor ?? 1,
+ },
+ );
+ }
+
+ private _renderPopupGraphForEntity(
+ entityId: string | undefined,
+ title: string,
+ options: PopupGraphRenderOptions = {},
+ ) {
+ return html`
+
+
+ ${!entityId
+ ? html`
${this._t("card.popup.history.graph_not_configured")}
`
+ : this._renderPopupGraphBody(entityId, options)}
+ ${options.overlayStateMessage
+ ? html`
${options.overlayStateMessage}
`
+ : nothing}
+ ${options.showEnableForecastButton
+ ? html`
+
+
+
+ `
+ : nothing}
+
+ `;
+ }
+
+ private _renderPopupGraphBody(
+ powerEntity: string,
+ options: PopupGraphRenderOptions = {},
+ ) {
+ const cacheKey = this._getPopupGraphCacheKey(powerEntity, this._popupGraphRangeHours);
+ const cache = this._popupGraphCache[cacheKey];
+ const loading = cache?.loading ?? false;
+ const error = cache?.error;
+ const rawSamples = cache?.samples ?? [];
+ const adjustedSamples = this._adjustGraphSamplesForEntity(powerEntity, rawSamples);
+ const primarySamplesBeforeScale = options.invertPrimaryValues
+ ? adjustedSamples.map((sample) => ({
+ ts: sample.ts,
+ value: normalizeNegativeZero(sample.value * -1),
+ }))
+ : adjustedSamples;
+ const primarySamples = this._scaleGraphSamples(
+ primarySamplesBeforeScale,
+ options.primaryValueScale ?? 1,
+ );
+ const unit = this._getUnitForEntity(powerEntity, "W");
+
+ return this._renderPopupGraphBodyFromSamples(
+ cacheKey,
+ unit,
+ primarySamples,
+ this._popupGraphRangeHours,
+ loading,
+ error,
+ options,
+ );
+ }
+
+ private _renderPopupGraphBodyFromSamples(
+ graphKey: string,
+ unit: string,
+ primarySamples: PowerSample[],
+ range: PopupGraphRangeHours,
+ loading: boolean,
+ error: string | undefined,
+ options: PopupGraphRenderOptions = {},
+ ) {
+ const startFilledSamples = this._fillRangeStartWithFirstSample(
+ primarySamples,
+ range,
+ options.fillStartWithFirstSample ?? false,
+ );
+ const bridgedPrimarySamples = bridgeZeroToProductionGaps(startFilledSamples, {
+ rangeHours: range,
+ });
+ const samples = this._expandSinglePointRangeSamples(
+ bridgedPrimarySamples,
+ range,
+ );
+ const overlayEntityId = options.overlayEntityId ?? null;
+ const overlayCache = overlayEntityId
+ ? this._popupGraphCache[this._getPopupGraphCacheKey(overlayEntityId, range)]
+ : undefined;
+ const overlayRawSamples = overlayCache?.samples ?? [];
+ const overlaySamples = overlayEntityId
+ ? this._expandSinglePointRangeSamples(
+ this._adjustGraphSamplesForEntity(overlayEntityId, overlayRawSamples),
+ range,
+ )
+ : [];
+ const overlayLoading = overlayEntityId ? (overlayCache?.loading ?? false) : false;
+ const overlayError = overlayEntityId ? overlayCache?.error : undefined;
+
+ if (loading) {
+ return html`${this._t("card.popup.history.loading")}
`;
+ }
+
+ if (error) {
+ return html`${error}
`;
+ }
+
+ if (samples.length === 0) {
+ return html`${this._t("card.popup.history.no_data")}
`;
+ }
+
+ const downsampled = downsampleSamples(samples, POPUP_GRAPH_MAX_POINTS);
+ const stats = this._computeGraphStats(samples);
+ const nowMs = Date.now();
+ const frameStartTs = nowMs - range * 60 * 60 * 1000;
+ const frameEndTs = nowMs;
+ const overlayDownsampled =
+ overlayEntityId && !overlayLoading && !overlayError && overlaySamples.length > 0
+ ? downsampleSamples(overlaySamples, POPUP_GRAPH_MAX_POINTS)
+ : [];
+ const actualValues = samples
+ .map((sample) => sample.value)
+ .filter((value) => Number.isFinite(value));
+ const overlayValues = overlayDownsampled
+ .map((sample) => sample.value)
+ .filter((value) => Number.isFinite(value));
+ const allValues = [...actualValues, ...overlayValues];
+ const graphMinRaw = allValues.length > 0 ? Math.min(...allValues) : null;
+ const graphMaxRaw = allValues.length > 0 ? Math.max(...allValues) : null;
+ const graphMin =
+ graphMinRaw === null || graphMaxRaw === null
+ ? null
+ : graphMinRaw === graphMaxRaw
+ ? graphMinRaw - 1
+ : graphMinRaw;
+ const graphMax =
+ graphMinRaw === null || graphMaxRaw === null
+ ? null
+ : graphMinRaw === graphMaxRaw
+ ? graphMaxRaw + 1
+ : graphMaxRaw;
+ const graphFrame =
+ graphMin !== null && graphMax !== null
+ ? {
+ startTs: frameStartTs,
+ endTs: frameEndTs,
+ minValue: graphMin,
+ maxValue: graphMax,
+ }
+ : null;
+ const actualLinePath =
+ graphFrame !== null
+ ? this._buildAlignedGraphLinePath(downsampled, graphFrame, 320, 132, 10)
+ : "";
+ const actualAreaPath =
+ graphFrame !== null
+ ? this._buildAlignedGraphAreaPath(downsampled, graphFrame, 320, 132, 10)
+ : "";
+ const overlayLinePath =
+ graphFrame !== null && overlayDownsampled.length > 0
+ ? this._buildAlignedGraphLinePath(overlayDownsampled, graphFrame, 320, 132, 10)
+ : "";
+ const hourTicks = this._buildGraphHourTicks(
+ {
+ startTs: frameStartTs,
+ endTs: frameEndTs,
+ },
+ range,
+ );
+ const axisTicks = this._buildGraphAxisTicks(hourTicks);
+ const maxY =
+ stats.max !== null && graphMin !== null && graphMax !== null
+ ? this._toGraphY(stats.max, graphMin, graphMax, 132, 10)
+ : null;
+ const medianY =
+ stats.median !== null && graphMin !== null && graphMax !== null
+ ? this._toGraphY(stats.median, graphMin, graphMax, 132, 10)
+ : null;
+ const minY =
+ stats.min !== null && graphMin !== null && graphMax !== null
+ ? this._toGraphY(stats.min, graphMin, graphMax, 132, 10)
+ : null;
+ const gradientId = `spv-graph-${graphKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
+ const maxLabel =
+ stats.max !== null
+ ? this._t("card.popup.history.max", {
+ value: this._formatGraphPower(stats.max, unit),
+ })
+ : null;
+ const medianLabel =
+ stats.median !== null
+ ? this._t("card.popup.history.median", {
+ value: this._formatGraphPower(stats.median, unit),
+ })
+ : null;
+ const minLabel =
+ stats.min !== null
+ ? this._t("card.popup.history.min", {
+ value: this._formatGraphPower(stats.min, unit),
+ })
+ : null;
+
+ return html`
+
+ ${maxLabel ? html`
${maxLabel}` : nothing}
+ ${medianLabel
+ ? html`
+
+ ${medianLabel}
+
+ `
+ : nothing}
+ ${minLabel
+ ? html`
${minLabel}`
+ : nothing}
+
+
+
+ ${axisTicks.map(
+ (tick) => html`
+
+ ${tick.label}
+
+ `,
+ )}
+
+
+
+ ${this._t("card.popup.history.time_range", {
+ start: this._formatGraphTime(frameStartTs),
+ end: this._formatGraphTime(frameEndTs),
+ })}
+
+
+ ${overlayEntityId && overlayLoading
+ ? html`${this._t("card.popup.history.loading")}
`
+ : nothing}
+ ${overlayEntityId && !overlayLoading && overlayError
+ ? html`${overlayError}
`
+ : nothing}
+ ${overlayEntityId &&
+ !overlayLoading &&
+ !overlayError &&
+ overlaySamples.length === 0
+ ? html`${this._t("card.popup.history.no_data")}
`
+ : nothing}
+ `;
+ }
+
+ private _handlePopupGraphRangeChangeForEntity(
+ event: Event,
+ powerEntity: string | undefined,
+ range: PopupGraphRangeHours,
+ overlayEntityId?: string,
+ ): void {
+ event.preventDefault();
+ event.stopPropagation();
+ const scrollToken = this._captureScrollPositionsForPopupGraph(event);
+ this._popupGraphRangeHours = range;
+ this._scheduleCapturedScrollRestore(scrollToken);
+ if (powerEntity) {
+ void this._ensurePopupGraphLoadedByEntity(powerEntity, range, scrollToken);
+ }
+ if (overlayEntityId) {
+ void this._ensurePopupGraphLoadedByEntity(overlayEntityId, range, scrollToken);
+ }
+ }
+
+ private _toggleTelemetryGraph(
+ event: Event,
+ panelId: string,
+ entityId: string,
+ ): void {
+ event.preventDefault();
+ event.stopPropagation();
+ const scrollToken = this._captureScrollPositionsForPopupGraph(event);
+ const isCurrent =
+ this._telemetryGraphPanelId === panelId &&
+ this._telemetryGraphEntityId === entityId;
+
+ if (isCurrent) {
+ this._clearTelemetryGraph();
+ this._scheduleCapturedScrollRestore(scrollToken);
+ return;
+ }
+
+ this._telemetryGraphPanelId = panelId;
+ this._telemetryGraphEntityId = entityId;
+ this._telemetryGraphRangeHours = 6;
+ this._scheduleCapturedScrollRestore(scrollToken);
+ void this._ensurePopupGraphLoadedByEntity(entityId, this._telemetryGraphRangeHours, scrollToken);
+ }
+
+ private _handleTelemetryGraphRangeChange(
+ event: Event,
+ entityId: string,
+ range: PopupGraphRangeHours,
+ ): void {
+ event.preventDefault();
+ event.stopPropagation();
+ const scrollToken = this._captureScrollPositionsForPopupGraph(event);
+ this._telemetryGraphRangeHours = range;
+ this._scheduleCapturedScrollRestore(scrollToken);
+ void this._ensurePopupGraphLoadedByEntity(entityId, range, scrollToken);
+ }
+
+ private _clearTelemetryGraph(): void {
+ this._telemetryGraphPanelId = null;
+ this._telemetryGraphEntityId = null;
+ this._telemetryGraphRangeHours = 6;
+ }
+
+ private _getPopupGraphCacheKey(
+ entityId: string,
+ range: PopupGraphRangeHours,
+ ): string {
+ return `${entityId}|${range}`;
+ }
+
+ private _getPanelConfig(panelId: string): PanelConfig | undefined {
+ return this._config?.panels.find((panel) => panel.id === panelId);
+ }
+
+ private async _ensurePopupGraphLoaded(
+ panelId: string,
+ range: PopupGraphRangeHours,
+ scrollToken?: number | null,
+ ): Promise {
+ if (!this._config || !this.hass) {
+ return;
+ }
+
+ const panelConfig = this._getPanelConfig(panelId);
+ const powerEntity = panelConfig?.power_entity;
+ if (!powerEntity) {
+ return;
+ }
+
+ await this._ensurePopupGraphLoadedByEntity(powerEntity, range, scrollToken);
+ }
+
+ private async _ensurePopupGraphLoadedByEntity(
+ powerEntity: string,
+ range: PopupGraphRangeHours,
+ scrollToken?: number | null,
+ ): Promise {
+ if (!this._config || !this.hass) {
+ return;
+ }
+
+ const cacheKey = this._getPopupGraphCacheKey(powerEntity, range);
+ const existing = this._popupGraphCache[cacheKey];
+ const nowMs = Date.now();
+ if (existing?.loading) {
+ return;
+ }
+ if (
+ existing &&
+ !existing.error &&
+ existing.loadedAt &&
+ nowMs - existing.loadedAt < POPUP_GRAPH_CACHE_TTL_MS
+ ) {
+ return;
+ }
+
+ const token = ++this._popupGraphRequestToken;
+ this._popupGraphLatestTokenByKey[cacheKey] = token;
+ this._popupGraphCache = {
+ ...this._popupGraphCache,
+ [cacheKey]: {
+ loading: true,
+ samples: existing?.samples ?? [],
+ loadedAt: existing?.loadedAt,
+ },
+ };
+ this._scheduleCapturedScrollRestore(scrollToken);
+
+ if (!this.hass.callApi && !this.hass.callWS) {
+ if (this._popupGraphLatestTokenByKey[cacheKey] !== token) {
+ return;
+ }
+ this._popupGraphCache = {
+ ...this._popupGraphCache,
+ [cacheKey]: {
+ loading: false,
+ samples: [],
+ error: "Unable to load panel history",
+ loadedAt: Date.now(),
+ },
+ };
+ this._scheduleCapturedScrollRestore(scrollToken);
+ return;
+ }
+
+ try {
+ if (this._popupGraphLatestTokenByKey[cacheKey] !== token) {
+ return;
+ }
+
+ const samples = await this._loadPopupGraphRecorderSamples(powerEntity, range);
+ if (this._popupGraphLatestTokenByKey[cacheKey] !== token) {
+ return;
+ }
+ this._popupGraphCache = {
+ ...this._popupGraphCache,
+ [cacheKey]: {
+ loading: false,
+ samples,
+ loadedAt: Date.now(),
+ },
+ };
+ this._scheduleCapturedScrollRestore(scrollToken);
+ } catch (error) {
+ if (this._popupGraphLatestTokenByKey[cacheKey] !== token) {
+ return;
+ }
+ this._popupGraphCache = {
+ ...this._popupGraphCache,
+ [cacheKey]: {
+ loading: false,
+ samples: [],
+ error: `Unable to load panel history (${compactError(toErrorMessage(error))})`,
+ loadedAt: Date.now(),
+ },
+ };
+ this._scheduleCapturedScrollRestore(scrollToken);
+ }
+ }
+
+ private async _loadPopupGraphRecorderSamples(
+ entityId: string,
+ range: PopupGraphRangeHours,
+ ): Promise {
+ if (!this.hass || (!this.hass.callApi && !this.hass.callWS)) {
+ throw new Error("Recorder API unavailable");
+ }
+
+ const now = Date.now();
+ const fetchHours = Math.min(168, range + 6);
+ const startIso = new Date(now - fetchHours * 60 * 60 * 1000).toISOString();
+ const endIso = new Date(now).toISOString();
+ const sharedHistorySamples = this._getSharedHistorySamplesForRange(entityId, range);
+ if (sharedHistorySamples) {
+ const sharedRangeSamples = filterSamplesByRangeHours(sharedHistorySamples, now, range);
+ const stabilizedShared = this._stabilizePopupRangeSamples(
+ entityId,
+ sharedHistorySamples,
+ sharedRangeSamples,
+ now,
+ range,
+ );
+ if (this._hasPopupRangeCoverage(stabilizedShared, now, range)) {
+ return stabilizedShared;
+ }
+
+ try {
+ const response = await this._fetchRecorderHistoryRaw(startIso, endIso, [entityId]);
+ const parsed =
+ this._parseRecorderResponse(response, fetchHours, [entityId])[entityId] ?? [];
+ const mergedAll = this._mergeGraphSampleSets(sharedHistorySamples, parsed);
+ const mergedRange = filterSamplesByRangeHours(mergedAll, now, range);
+ return this._stabilizePopupRangeSamples(entityId, mergedAll, mergedRange, now, range);
+ } catch (_error) {
+ return stabilizedShared;
+ }
+ }
+
+ const response = await this._fetchRecorderHistoryRaw(startIso, endIso, [entityId]);
+
+ const parsed = this._parseRecorderResponse(response, fetchHours, [entityId])[entityId] ?? [];
+ const rangeSamples = filterSamplesByRangeHours(parsed, now, range);
+ return this._stabilizePopupRangeSamples(entityId, parsed, rangeSamples, now, range);
+ }
+
+ private _getSharedHistorySamplesForRange(
+ entityId: string,
+ range: PopupGraphRangeHours,
+ ): PowerSample[] | null {
+ if (!(this._config?.enable_array_checks ?? false)) {
+ return null;
+ }
+ if (this._getHistoryHours() < range) {
+ return null;
+ }
+ const samples = this._historyByEntityId[entityId];
+ if (!samples || samples.length === 0) {
+ return null;
+ }
+ return samples;
+ }
+
+ private _stabilizePopupRangeSamples(
+ entityId: string,
+ allSamples: PowerSample[],
+ rangeSamples: PowerSample[],
+ nowMs: number,
+ rangeHours: PopupGraphRangeHours,
+ ): PowerSample[] {
+ const threshold = nowMs - rangeHours * 60 * 60 * 1000;
+ const sortedAll = [...allSamples].sort((a, b) => a.ts - b.ts);
+ const sortedRange = [...rangeSamples].sort((a, b) => a.ts - b.ts);
+ const previousSample = sortedAll.filter((sample) => sample.ts < threshold).pop() ?? null;
+ const isForecastEntity = this._isForecastDefaultEntity(entityId);
+ const liveValue = Number(this.hass?.states?.[entityId]?.state);
+
+ if (sortedRange.length === 0) {
+ if (isForecastEntity && Number.isFinite(liveValue)) {
+ sortedRange.push({ ts: threshold, value: liveValue });
+ } else if (previousSample) {
+ sortedRange.push({ ts: threshold, value: previousSample.value });
+ } else if (Number.isFinite(liveValue)) {
+ sortedRange.push({ ts: threshold, value: liveValue });
+ }
+ } else if (sortedRange[0].ts > threshold && (previousSample || isForecastEntity)) {
+ const startValue =
+ isForecastEntity ? sortedRange[0].value : previousSample?.value ?? sortedRange[0].value;
+ sortedRange.unshift({ ts: threshold, value: startValue });
+ }
+
+ const latest = sortedRange[sortedRange.length - 1];
+ if (!latest && Number.isFinite(liveValue)) {
+ sortedRange.push({ ts: nowMs, value: liveValue });
+ } else if (latest && latest.ts < nowMs) {
+ const endValue = Number.isFinite(liveValue) ? liveValue : latest.value;
+ sortedRange.push({ ts: nowMs, value: endValue });
+ }
+
+ return sortedRange
+ .filter((sample) => Number.isFinite(sample.ts) && Number.isFinite(sample.value))
+ .sort((a, b) => a.ts - b.ts);
+ }
+
+ private _hasPopupRangeCoverage(
+ samples: PowerSample[],
+ nowMs: number,
+ rangeHours: PopupGraphRangeHours,
+ ): boolean {
+ if (samples.length === 0) {
+ return false;
+ }
+ const threshold = nowMs - rangeHours * 60 * 60 * 1000;
+ const firstTs = samples[0].ts;
+ const lastTs = samples[samples.length - 1].ts;
+ const toleranceMs = 15 * 60 * 1000;
+ return firstTs <= threshold + toleranceMs && lastTs >= nowMs - toleranceMs;
+ }
+
+ private _mergeGraphSampleSets(...sets: PowerSample[][]): PowerSample[] {
+ const byTimestamp = new Map();
+ for (const set of sets) {
+ for (const sample of set) {
+ if (!Number.isFinite(sample.ts) || !Number.isFinite(sample.value)) {
+ continue;
+ }
+ byTimestamp.set(sample.ts, sample);
+ }
+ }
+ return [...byTimestamp.values()].sort((a, b) => a.ts - b.ts);
+ }
+
+ private _getUnitForEntity(entityId: string, fallback: string): string {
+ const unit = this.hass?.states?.[entityId]?.attributes?.unit_of_measurement;
+ if (typeof unit === "string" && unit.trim().length > 0) {
+ return unit;
+ }
+ return fallback;
+ }
+
+ private _formatGraphPower(value: number | null, unit: string): string {
+ const trimmedUnit = unit.trim();
+ if (value === null) {
+ return trimmedUnit ? `0 ${unit}` : "0";
+ }
+ const locale = this.hass?.locale?.language ?? "en";
+ const safeValue = normalizeNegativeZero(value);
+ const normalizedUnit = trimmedUnit.toLowerCase();
+ const decimals =
+ normalizedUnit.includes("wh") || normalizedUnit.includes("kwh")
+ ? this._config?.energy_decimals ?? 2
+ : this._config?.power_decimals ?? 0;
+ const formatted = new Intl.NumberFormat(locale, {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ }).format(safeValue);
+ return trimmedUnit ? `${formatted} ${unit}` : formatted;
+ }
+
+ private _computeGraphStats(samples: PowerSample[]): GraphStats {
+ const values = samples
+ .map((sample) => sample.value)
+ .filter((value) => Number.isFinite(value))
+ .sort((a, b) => a - b);
+ if (values.length === 0) {
+ return { min: null, max: null, median: null };
+ }
+ const min = values[0] ?? null;
+ const max = values[values.length - 1] ?? null;
+ const middle = Math.floor(values.length / 2);
+ const median =
+ values.length % 2 === 0
+ ? (values[middle - 1] + values[middle]) / 2
+ : values[middle];
+ return { min, max, median };
+ }
+
+ private _buildGraphHourTicks(
+ graphFrame: { startTs: number | null; endTs: number | null },
+ range: PopupGraphRangeHours,
+ ): GraphHourTick[] {
+ if (graphFrame.startTs === null || graphFrame.endTs === null) {
+ return [];
+ }
+
+ const startTs = graphFrame.startTs;
+ const endTs = graphFrame.endTs;
+ const xSpan = Math.max(endTs - startTs, 1);
+ const width = 320;
+ const padding = 10;
+ const plotWidth = width - padding * 2;
+ const intervalMs =
+ range === 1
+ ? 15 * 60 * 1000
+ : range === 6
+ ? 60 * 60 * 1000
+ : 4 * 60 * 60 * 1000;
+ const ticks: GraphHourTick[] = [];
+ const formatLabel = (timestampMs: number): string => {
+ const date = new Date(timestampMs);
+ const hour = `${date.getHours()}`.padStart(2, "0");
+ const minute = `${date.getMinutes()}`.padStart(2, "0");
+ return range === 1 ? `${hour}:${minute}` : `${hour}h`;
+ };
+
+ let tickTs = Math.ceil(startTs / intervalMs) * intervalMs;
+ while (tickTs < endTs) {
+ const x = padding + ((tickTs - startTs) / xSpan) * plotWidth;
+ ticks.push({
+ x,
+ label: formatLabel(tickTs),
+ });
+ tickTs += intervalMs;
+ }
+
+ if (range === 6 && ticks.length < 3) {
+ const fallbackRatios = [0.25, 0.5, 0.75];
+ for (const ratio of fallbackRatios) {
+ const ts = startTs + xSpan * ratio;
+ const x = padding + ((ts - startTs) / xSpan) * plotWidth;
+ ticks.push({
+ x,
+ label: formatLabel(ts),
+ });
+ }
+ }
+
+ ticks.sort((a, b) => a.x - b.x);
+ return ticks;
+ }
+
+ private _buildGraphAxisTicks(hourTicks: GraphHourTick[]): GraphAxisTick[] {
+ if (hourTicks.length === 0) {
+ return [];
+ }
+ const leftBound = 10;
+ const width = 320;
+ const plotWidth = width - leftBound * 2;
+ return hourTicks.map((tick) => ({
+ label: tick.label,
+ leftPercent: ((tick.x - leftBound) / plotWidth) * 100,
+ }));
+ }
+
+ private _toGraphY(
+ value: number,
+ minValue: number,
+ maxValue: number,
+ height: number,
+ padding: number,
+ ): number {
+ const ySpan = Math.max(maxValue - minValue, 1);
+ const plotHeight = Math.max(height - padding * 2, 1);
+ return height - padding - ((value - minValue) / ySpan) * plotHeight;
+ }
+
+ private _buildAlignedGraphLinePath(
+ samples: PowerSample[],
+ frame: { startTs: number; endTs: number; minValue: number; maxValue: number },
+ width: number,
+ height: number,
+ padding: number,
+ ): string {
+ const finite = samples
+ .filter((sample) => Number.isFinite(sample.ts) && Number.isFinite(sample.value))
+ .sort((a, b) => a.ts - b.ts);
+ if (finite.length === 0) {
+ return "";
+ }
+
+ const xSpan = Math.max(frame.endTs - frame.startTs, 1);
+ const ySpan = Math.max(frame.maxValue - frame.minValue, 1);
+ const plotWidth = Math.max(width - padding * 2, 1);
+ const plotHeight = Math.max(height - padding * 2, 1);
+ const points: Array<{ x: number; y: number }> = [];
+
+ for (const sample of finite) {
+ const ts = Math.min(Math.max(sample.ts, frame.startTs), frame.endTs);
+ const x = padding + ((ts - frame.startTs) / xSpan) * plotWidth;
+ const normalizedY = (sample.value - frame.minValue) / ySpan;
+ const y =
+ height - padding - Math.min(Math.max(normalizedY, 0), 1) * plotHeight;
+ const previousPoint = points[points.length - 1];
+ if (previousPoint && Math.abs(previousPoint.x - x) < 0.01 && Math.abs(previousPoint.y - y) < 0.01) {
+ continue;
+ }
+ points.push({ x, y });
+ }
+
+ if (points.length === 0) {
+ return "";
+ }
+
+ if (points.length === 1) {
+ points.push({ ...points[0] });
+ }
+
+ return points
+ .map((point, index) =>
+ `${index === 0 ? "M" : "L"}${point.x.toFixed(2)},${point.y.toFixed(2)}`,
+ )
+ .join(" ");
+ }
+
+ private _buildAlignedGraphAreaPath(
+ samples: PowerSample[],
+ frame: { startTs: number; endTs: number; minValue: number; maxValue: number },
+ width: number,
+ height: number,
+ padding: number,
+ ): string {
+ const linePath = this._buildAlignedGraphLinePath(samples, frame, width, height, padding);
+ if (!linePath) {
+ return "";
+ }
+
+ const finite = samples
+ .filter((sample) => Number.isFinite(sample.ts) && Number.isFinite(sample.value))
+ .sort((a, b) => a.ts - b.ts);
+ if (finite.length === 0) {
+ return "";
+ }
+
+ const xSpan = Math.max(frame.endTs - frame.startTs, 1);
+ const plotWidth = Math.max(width - padding * 2, 1);
+ const firstTs = Math.min(Math.max(finite[0].ts, frame.startTs), frame.endTs);
+ const lastTs = Math.min(
+ Math.max(finite[finite.length - 1].ts, frame.startTs),
+ frame.endTs,
+ );
+ const firstX = padding + ((firstTs - frame.startTs) / xSpan) * plotWidth;
+ const lastX = padding + ((lastTs - frame.startTs) / xSpan) * plotWidth;
+ const baselineY = height - padding;
+
+ return `${linePath} L${lastX.toFixed(2)},${baselineY.toFixed(2)} L${firstX.toFixed(2)},${baselineY.toFixed(2)} Z`;
+ }
+
+ private _formatGraphTime(timestamp: number | null): string {
+ if (timestamp === null) {
+ return "--:--";
+ }
+ const locale = this.hass?.locale?.language ?? "en";
+ return new Intl.DateTimeFormat(locale, {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ hourCycle: "h23",
+ }).format(timestamp);
+ }
+
+ private _formatGraphDiagnosticTime(timestamp: number | null): string {
+ if (timestamp === null) {
+ return "--";
+ }
+ const locale = this.hass?.locale?.language ?? "en";
+ return new Intl.DateTimeFormat(locale, {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ hourCycle: "h23",
+ }).format(timestamp);
+ }
+
+ private _resolveThemeMode(): "dark" | "light" {
+ const configuredMode = this._config?.theme_mode ?? "auto";
+ if (configuredMode === "dark" || configuredMode === "light") {
+ return configuredMode;
+ }
+
+ const haDarkMode = this.hass?.themes?.darkMode;
+ if (typeof haDarkMode === "boolean") {
+ return haDarkMode ? "dark" : "light";
+ }
+
+ if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+ }
+
+ return "dark";
+ }
+
+ protected _buildSystemHealthState(derived: DerivedArrayState): { label: string; color: string } {
+ const colors = {
+ error: this._config?.colors?.error ?? "#ff627f",
+ offline: this._config?.colors?.unavailable ?? "#586779",
+ deviation: this._config?.colors?.deviation ?? "#ff9667",
+ setup: "#41c9d8",
+ ok: "#8ed072",
+ };
+
+ const criticalCount = derived.inverterCount + derived.errorCount;
+ if (criticalCount > 0) {
+ return {
+ label: `${criticalCount} Fault${criticalCount === 1 ? "" : "s"}`,
+ color: colors.error,
+ };
+ }
+ if (derived.offlineCount > 0) {
+ return {
+ label: `${derived.offlineCount} Unavailable`,
+ color: colors.offline,
+ };
+ }
+ if (derived.deviationCount > 0) {
+ return {
+ label: `${derived.deviationCount} Deviation`,
+ color: colors.deviation,
+ };
+ }
+ if (derived.unconfiguredCount > 0) {
+ return {
+ label: `${derived.unconfiguredCount} Needs Setup`,
+ color: colors.setup,
+ };
+ }
+
+ return { label: "System OK", color: colors.ok };
+ }
+
+ private _buildSystemHealthSections(
+ derived: DerivedArrayState,
+ ): Array<{ title: string; items: string[] }> {
+ const visiblePanels = derived.panels.filter((panel) => !panel.hiddenSlot);
+ const buildItems = (status: DerivedPanelState["status"]): string[] =>
+ visiblePanels
+ .filter((panel) => panel.status === status)
+ .map((panel) => {
+ const slot = this._formatSlotLabel(panel.slotIndex);
+ return `Panel on ${slot} (${panel.label}): ${panel.reason}`;
+ });
+
+ const sections: Array<{ title: string; items: string[] }> = [];
+ const inverterItems = buildItems("inverter");
+ if (inverterItems.length > 0) {
+ sections.push({ title: "Inverter", items: inverterItems });
+ }
+ const errorItems = buildItems("error");
+ if (errorItems.length > 0) {
+ sections.push({ title: "Error", items: errorItems });
+ }
+ const deviationItems = buildItems("deviation");
+ if (deviationItems.length > 0) {
+ sections.push({ title: "Deviation", items: deviationItems });
+ }
+ const offlineItems = buildItems("offline");
+ if (offlineItems.length > 0) {
+ sections.push({ title: "Unavailable", items: offlineItems });
+ }
+ const setupItems = buildItems("unconfigured");
+ if (setupItems.length > 0) {
+ sections.push({ title: "Needs setup", items: setupItems });
+ }
+
+ return sections;
+ }
+
+ private _computeRenderedColumns(configColumns: number, maxCardWidthPx?: number): number {
+ const safeConfigColumns = Math.max(1, Math.floor(configColumns));
+ const hostWidth = this._cardWidth > 0 ? this._cardWidth : maxCardWidthPx ?? 980;
+ const effectiveCardWidth = maxCardWidthPx
+ ? Math.min(hostWidth, maxCardWidthPx)
+ : hostWidth;
+
+ if (!Number.isFinite(effectiveCardWidth) || effectiveCardWidth <= 0) {
+ return safeConfigColumns;
+ }
+
+ const innerWidth = Math.max(120, effectiveCardWidth - 40);
+ const gap = effectiveCardWidth <= 560 ? 8 : 10;
+ const mobileLayout = effectiveCardWidth <= 760;
+ const configuredColumns = mobileLayout
+ ? Math.min(safeConfigColumns, 3)
+ : safeConfigColumns;
+ const panelWidthCapPx = this._getPanelWidthCapPx();
+ const minPanelWidth =
+ panelWidthCapPx !== null
+ ? panelWidthCapPx
+ : mobileLayout
+ ? 100
+ : Math.max(130, Math.min(220, innerWidth * 0.32));
+ const fitColumns = Math.floor((innerWidth + gap) / (minPanelWidth + gap));
+ return Math.max(1, Math.min(configuredColumns, fitColumns || 1));
+ }
+
+ private _computeRenderedRows(panelCount: number, renderedColumns: number): number {
+ const safePanels = Math.max(1, panelCount);
+ const safeColumns = Math.max(1, renderedColumns);
+ return Math.max(1, Math.ceil(safePanels / safeColumns));
+ }
+
+ private _computeWidthBasedPanelHeight(
+ renderedColumns: number,
+ maxCardWidthPx?: number,
+ ): number {
+ const tileWidth = this._computeTileWidthPx(renderedColumns, maxCardWidthPx);
+ const heightByWidth = tileWidth * 0.62;
+ return Math.round(Math.min(220, Math.max(92, heightByWidth)));
+ }
+
+ private _computePanelWidthPx(
+ renderedColumns: number,
+ maxCardWidthPx?: number,
+ ): number {
+ const tileWidth = this._computeTileWidthPx(renderedColumns, maxCardWidthPx);
+ return Math.max(96, Math.round(tileWidth));
+ }
+
+ private _computeTileWidthPx(
+ renderedColumns: number,
+ maxCardWidthPx?: number,
+ ): number {
+ const hostWidth = this._cardWidth > 0 ? this._cardWidth : maxCardWidthPx ?? 980;
+ const effectiveCardWidth = maxCardWidthPx
+ ? Math.min(hostWidth, maxCardWidthPx)
+ : hostWidth;
+ const gap = effectiveCardWidth <= 560 ? 8 : 10;
+ const innerWidth = Math.max(120, effectiveCardWidth - 40);
+ const safeColumns = Math.max(1, renderedColumns);
+ const tileWidth =
+ (innerWidth - Math.max(0, safeColumns - 1) * gap) / safeColumns;
+ return this._applyPanelWidthCapPx(tileWidth);
+ }
+
+ private _getPanelWidthCapPx(): number | null {
+ if (!(this._config?.limit_panel_width ?? false)) {
+ return null;
+ }
+ const raw = this._config?.panel_max_width_px ?? 220;
+ return Math.min(Math.max(raw, 120), 320);
+ }
+
+ private _applyPanelWidthCapPx(tileWidth: number): number {
+ const cap = this._getPanelWidthCapPx();
+ return cap ? Math.min(tileWidth, cap) : tileWidth;
+ }
+
+ private _computePanelHeightPx(
+ renderedRows: number,
+ maxCardHeightPx?: number,
+ renderedColumns = this._config?.columns ?? 3,
+ maxCardWidthPx?: number,
+ ): number {
+ const widthBased = this._computeWidthBasedPanelHeight(renderedColumns, maxCardWidthPx);
+ if (!maxCardHeightPx || !Number.isFinite(maxCardHeightPx)) {
+ return widthBased;
+ }
+
+ const safeRows = Math.max(1, renderedRows);
+ const estimatedHeaderHeight = 250;
+ const verticalPadding = 36;
+ const gridGapTotal = Math.max(safeRows - 1, 0) * 10;
+ const availableForPanels =
+ maxCardHeightPx - estimatedHeaderHeight - verticalPadding - gridGapTotal;
+ const perRow = availableForPanels / safeRows;
+ if (!Number.isFinite(perRow)) {
+ return widthBased;
+ }
+
+ const heightFromMax = Math.min(240, Math.max(96, perRow));
+ const blended = Math.min(heightFromMax, widthBased * 1.6);
+ return Math.round(Math.min(220, Math.max(92, blended)));
+ }
+
+ private _computePanelScale(panelHeightPx: number): number {
+ const baseline = 128;
+ const normalized = panelHeightPx / baseline;
+ const clamped = Math.min(1.28, Math.max(0.76, normalized));
+ return Number(clamped.toFixed(3));
+ }
+
+ private _startResizeObserver(): void {
+ if (typeof ResizeObserver === "undefined" || this._resizeObserver) {
+ return;
+ }
+
+ const updateWidth = (): void => {
+ const width = Math.round(this.getBoundingClientRect().width);
+ if (!Number.isFinite(width) || width <= 0) {
+ return;
+ }
+ if (width !== this._cardWidth) {
+ this._cardWidth = width;
+ }
+ };
+
+ this._resizeObserver = new ResizeObserver(() => {
+ updateWidth();
+ });
+ this._resizeObserver.observe(this);
+ updateWidth();
+ }
+
+ private _shouldShowPerformance(
+ panel: DerivedPanelState,
+ panelHeightPx: number,
+ panelWidthPx: number,
+ ): boolean {
+ if (panel.performancePercent === null || panel.ratedPowerW === null) {
+ return false;
+ }
+
+ return panelHeightPx >= 94 && panelWidthPx >= 110;
+ }
+
+ private _formatPanelPerformanceText(
+ panel: DerivedPanelState,
+ panelWidthPx: number,
+ panelHeightPx: number,
+ candidates = this._buildPanelPerformanceLabelCandidates(panel),
+ ): string {
+ if (!candidates) {
+ return "";
+ }
+
+ const scale = this._computePanelScale(panelHeightPx);
+ const rem = Math.min(0.96, Math.max(0.62, 0.72 * scale));
+ const fontPx = rem * 16;
+
+ return choosePanelPerformanceLabel({
+ candidates,
+ panelWidthPx,
+ panelHeightPx,
+ fontPx,
+ reservedRightPx: PANEL_SLOT_LABEL_SAFE_WIDTH_PX,
+ measureTextWidthPx: (text, size) => this._measureTextWidthPx(text, size),
+ }).text;
+ }
+
+ private _buildPanelPerformanceLabelCandidates(
+ panel: DerivedPanelState,
+ ): PanelPerformanceLabelCandidate[] | null {
+ if (panel.performancePercent === null || panel.ratedPowerW === null) {
+ return null;
+ }
+
+ const percent = panel.performancePercent.toFixed(0);
+ const rated = panel.ratedPowerW.toFixed(0);
+
+ return [
+ {
+ variant: "full",
+ text: this._t("card.panel.performance_full", { percent, rated }),
+ },
+ {
+ variant: "medium",
+ text: this._t("card.panel.performance_medium", { percent, rated }),
+ },
+ {
+ variant: "compact",
+ text: this._t("card.panel.performance_compact", { percent }),
+ },
+ ];
+ }
+
+ private _measureTextWidthPx(text: string, fontPx: number): number {
+ if (typeof document === "undefined") {
+ return text.length * fontPx * 0.52;
+ }
+ if (SolarPanelVisualizerBaseCard._textMeasureContext === undefined) {
+ const canvas = document.createElement("canvas");
+ SolarPanelVisualizerBaseCard._textMeasureContext = canvas.getContext("2d");
+ }
+ const ctx = SolarPanelVisualizerBaseCard._textMeasureContext;
+ if (!ctx) {
+ return text.length * fontPx * 0.52;
+ }
+ ctx.font = `500 ${fontPx}px system-ui, -apple-system, "Segoe UI", sans-serif`;
+ return ctx.measureText(text).width;
+ }
+
+ private _handlePanelClick(panel: DerivedPanelState): void {
+ if (panel.hiddenSlot) {
+ return;
+ }
+
+ if ((this._config?.panel_tap_action ?? "details") === "details") {
+ this._showLivePowerPopup = false;
+ this._showEnergyPopup = false;
+ this._showCustomKpiPopup = false;
+ this._showSystemHealthPopup = false;
+ this._selectedPanelId = panel.id;
+ this._popupGraphRangeHours = 6;
+ this._clearTelemetryGraph();
+ this._kpiCompareExpanded = { power: false, energy: false };
+ this._kpiCompareRangeHours = { power: 6, energy: 6 };
+ void this._ensurePopupGraphLoaded(panel.id, 6);
+ }
+ }
+
+ private _resolveUnit(
+ key: "power_entity" | "energy_entity",
+ fallback: string,
+ ): string {
+ if (!this._config) {
+ return fallback;
+ }
+
+ for (const panel of this._config.panels) {
+ const entityId = panel[key];
+ if (!entityId) {
+ continue;
+ }
+ const unit = this.hass?.states?.[entityId]?.attributes?.unit_of_measurement;
+ if (typeof unit === "string" && unit.trim().length > 0) {
+ return unit;
+ }
+ }
+
+ return fallback;
+ }
+
+ private _getSystemPowerEntityId(): string | null {
+ if (!this._config) {
+ return null;
+ }
+ if (!(this._config.use_system_power_entity ?? false)) {
+ return null;
+ }
+ const entityId = this._config.system_power_entity?.trim();
+ return entityId && entityId.length > 0 ? entityId : null;
+ }
+
+ private _getSystemEnergyEntityId(): string | null {
+ if (!this._config) {
+ return null;
+ }
+ if (!(this._config.use_system_energy_entity ?? false)) {
+ return null;
+ }
+ const entityId = this._config.system_energy_entity?.trim();
+ return entityId && entityId.length > 0 ? entityId : null;
+ }
+
+ private _getSystemEntityIdForKind(kind: PanelCompareMetricKind): string | null {
+ return kind === "energy"
+ ? this._getSystemEnergyEntityId()
+ : this._getSystemPowerEntityId();
+ }
+
+ private _getForecastEntityId(kind: "power" | "energy"): string | null {
+ return resolveForecastEntityId(this.hass, kind);
+ }
+
+ private _isForecastDefaultEntity(entityId: string): boolean {
+ return (
+ entityId === getForecastEntityId("power") || entityId === getForecastEntityId("energy")
+ );
+ }
+
+ private _isForecastOverlayEnabled(): boolean {
+ return this._config?.enable_forecast_overlay ?? false;
+ }
+
+ private _resolveForecastMetricDisplay(
+ kind: "power" | "energy",
+ decimals: number,
+ fallbackUnit: string,
+ ): {
+ expectedEntityId: string;
+ entityId: string | null;
+ value: number | null;
+ unit: string;
+ display: string;
+ } {
+ const expectedEntityId = getForecastEntityId(kind);
+ const entityId = this._getForecastEntityId(kind);
+ if (!entityId) {
+ return {
+ expectedEntityId,
+ entityId: null,
+ value: null,
+ unit: fallbackUnit,
+ display: this._t("common.not_configured"),
+ };
+ }
+
+ const unit = this._getUnitForEntity(entityId, fallbackUnit);
+ const raw = Number(this.hass?.states?.[entityId]?.state);
+ const value = Number.isFinite(raw) ? normalizeNegativeZero(raw) : null;
+ const locale = this.hass?.locale?.language ?? "en";
+
+ return {
+ expectedEntityId,
+ entityId,
+ value,
+ unit,
+ display: formatSummaryValue(value, decimals, unit, this._t("common.unavailable"), locale),
+ };
+ }
+
+ protected _resolveSummaryPower(derived: DerivedArrayState): {
+ value: number | null;
+ unit: string;
+ } {
+ const systemPowerEntityId = this._getSystemPowerEntityId();
+ if (systemPowerEntityId) {
+ const entity = this.hass?.states?.[systemPowerEntityId];
+ const numeric = Number(entity?.state);
+ const value = Number.isFinite(numeric) ? numeric : null;
+ const adjustedValue =
+ value === null
+ ? null
+ : this._config?.invert_system_power
+ ? normalizeNegativeZero(value * -1)
+ : normalizeNegativeZero(value);
+ return {
+ value: adjustedValue,
+ unit: this._getUnitForEntity(systemPowerEntityId, "W"),
+ };
+ }
+
+ return {
+ value: derived.totalPower,
+ unit: this._resolveUnit("power_entity", "W"),
+ };
+ }
+
+ protected _resolveSummaryEnergy(derived: DerivedArrayState): {
+ value: number | null;
+ unit: string;
+ } {
+ const systemEnergyEntityId = this._getSystemEnergyEntityId();
+ if (!this._config) {
+ return { value: null, unit: "kWh" };
+ }
+
+ if (systemEnergyEntityId) {
+ const entity = this.hass?.states?.[systemEnergyEntityId];
+ const numeric = Number(entity?.state);
+ return {
+ value: Number.isFinite(numeric) ? numeric : null,
+ unit: this._getUnitForEntity(systemEnergyEntityId, "kWh"),
+ };
+ }
+
+ return {
+ value: derived.totalEnergy,
+ unit: this._resolveUnit("energy_entity", "kWh"),
+ };
+ }
+
+ private _isSummaryEnergyConfigured(): boolean {
+ if (!this._config) {
+ return false;
+ }
+
+ if (this._config.use_system_energy_entity ?? false) {
+ return Boolean(this._config.system_energy_entity?.trim());
+ }
+
+ return this._config.panels.some((panel) => Boolean(panel.energy_entity));
+ }
+
+ protected _resolveCustomKpi(): { title: string; value: string } {
+ const rawTitle = this._config?.custom_kpi_title;
+ const title = rawTitle !== undefined ? rawTitle : "Custom KPI";
+ const entityId = this._getCustomKpiEntityId();
+ if (!entityId) {
+ return { title, value: "Not configured" };
+ }
+
+ const entity = this.hass?.states?.[entityId];
+ if (!entity) {
+ return { title, value: "Unavailable" };
+ }
+
+ const rawState = entity.state?.toString().trim().toLowerCase();
+ if (!rawState || rawState === "unknown" || rawState === "unavailable") {
+ return { title, value: "Unavailable" };
+ }
+
+ const numeric = Number(entity.state);
+ if (Number.isFinite(numeric)) {
+ const locale = this.hass?.locale?.language ?? "en";
+ const unit = this._getUnitForEntity(entityId, "");
+ const adjustedNumeric = this._config?.invert_custom_kpi
+ ? normalizeNegativeZero(numeric * -1)
+ : normalizeNegativeZero(numeric);
+ const formatted = formatCustomKpiStateValue(
+ String(adjustedNumeric),
+ locale,
+ this._config?.custom_kpi_decimals ?? 0,
+ unit,
+ );
+ return {
+ title,
+ value: formatted ?? entity.state,
+ };
+ }
+
+ return { title, value: entity.state };
+ }
+
+ private _getCustomKpiEntityId(): string | null {
+ const entityId = this._config?.custom_kpi_entity?.trim();
+ return entityId && entityId.length > 0 ? entityId : null;
+ }
+
+ private _enableForecastOverlayFromPopup = (): void => {
+ if (!this._config || this._isForecastOverlayEnabled()) {
+ return;
+ }
+ const forecastPowerEntityId = this._getForecastEntityId("power");
+ const forecastEnergyEntityId = this._getForecastEntityId("energy");
+ this._commitConfigFromCard(
+ normalizeConfig({
+ ...this._config,
+ enable_forecast_overlay: true,
+ }),
+ );
+ if (this._showLivePowerPopup && forecastPowerEntityId) {
+ const systemPowerEntityId = this._getSystemPowerEntityId();
+ const range = systemPowerEntityId
+ ? this._popupGraphRangeHours
+ : this._kpiCompareRangeHours.power;
+ if (systemPowerEntityId) {
+ void this._ensurePopupGraphLoadedByEntity(systemPowerEntityId, range);
+ } else {
+ const derived =
+ this._derived ??
+ (this._config && this.hass ? deriveArrayState(this._config, this.hass) : null);
+ if (derived) {
+ void this._ensurePanelCompareLoaded(derived, "power", range);
+ }
+ }
+ void this._ensurePopupGraphLoadedByEntity(forecastPowerEntityId, range);
+ }
+ if (this._showEnergyPopup && forecastEnergyEntityId) {
+ const systemEnergyEntityId = this._getSystemEnergyEntityId();
+ const range = systemEnergyEntityId
+ ? this._popupGraphRangeHours
+ : this._kpiCompareRangeHours.energy;
+ if (systemEnergyEntityId) {
+ void this._ensurePopupGraphLoadedByEntity(systemEnergyEntityId, range);
+ } else {
+ const derived =
+ this._derived ??
+ (this._config && this.hass ? deriveArrayState(this._config, this.hass) : null);
+ if (derived) {
+ void this._ensurePanelCompareLoaded(derived, "energy", range);
+ }
+ }
+ void this._ensurePopupGraphLoadedByEntity(forecastEnergyEntityId, range);
+ }
+ };
+
+ private _handlePopupUserInteraction = (event: Event): void => {
+ event.stopPropagation();
+ this._clearPopupScrollRestore();
+ };
+
+ private _stopPopupEventPropagation = (event: Event): void => {
+ event.stopPropagation();
+ };
+
+ private _handlePopupCloseClick(event: Event, onClick: () => void): void {
+ event.preventDefault();
+ event.stopPropagation();
+ onClick();
+ }
+
+ private _closeAllPopups(): void {
+ this._selectedPanelId = null;
+ this._showLivePowerPopup = false;
+ this._showEnergyPopup = false;
+ this._showCustomKpiPopup = false;
+ this._showSystemHealthPopup = false;
+ this._clearTelemetryGraph();
+ this._clearPopupScrollRestore();
+ }
+
+ private _closeDialog = (): void => {
+ this._closeAllPopups();
+ this._kpiCompareExpanded = { power: false, energy: false };
+ this._kpiCompareRangeHours = { power: 6, energy: 6 };
+ };
+
+ private _openLivePowerPopup = (): void => {
+ this._selectedPanelId = null;
+ this._clearTelemetryGraph();
+ const systemPowerEntityId = this._getSystemPowerEntityId();
+ this._kpiCompareExpanded = { power: false, energy: false };
+ this._kpiCompareRangeHours = { power: 6, energy: 6 };
+ this._showLivePowerPopup = true;
+ this._showEnergyPopup = false;
+ this._showCustomKpiPopup = false;
+ this._showSystemHealthPopup = false;
+ this._popupGraphRangeHours = 6;
+ if (systemPowerEntityId) {
+ void this._ensurePopupGraphLoadedByEntity(systemPowerEntityId, 6);
+ if (this._isForecastOverlayEnabled()) {
+ const forecastPowerEntityId = this._getForecastEntityId("power");
+ if (forecastPowerEntityId) {
+ void this._ensurePopupGraphLoadedByEntity(forecastPowerEntityId, 6);
+ }
+ }
+ } else {
+ const derived = this._derived ?? (this._config && this.hass ? deriveArrayState(this._config, this.hass) : null);
+ if (derived) {
+ this._kpiCompareExpanded = {
+ ...this._kpiCompareExpanded,
+ power: this._buildPanelCompareTargets(derived, "power").length > 0,
+ };
+ void this._ensurePanelCompareLoaded(derived, "power", this._kpiCompareRangeHours.power);
+ }
+ if (this._isForecastOverlayEnabled()) {
+ const forecastPowerEntityId = this._getForecastEntityId("power");
+ if (forecastPowerEntityId) {
+ void this._ensurePopupGraphLoadedByEntity(
+ forecastPowerEntityId,
+ this._kpiCompareRangeHours.power,
+ );
+ }
+ }
+ }
+ };
+
+ private _closeLivePowerPopup = (): void => {
+ this._closeAllPopups();
+ };
+
+ private _openEnergyPopup = (): void => {
+ this._selectedPanelId = null;
+ this._clearTelemetryGraph();
+ const systemEnergyEntityId = this._getSystemEnergyEntityId();
+ this._kpiCompareExpanded = { power: false, energy: false };
+ this._kpiCompareRangeHours = { power: 6, energy: 6 };
+ this._showEnergyPopup = true;
+ this._showLivePowerPopup = false;
+ this._showCustomKpiPopup = false;
+ this._showSystemHealthPopup = false;
+ this._popupGraphRangeHours = 6;
+ if (systemEnergyEntityId) {
+ void this._ensurePopupGraphLoadedByEntity(systemEnergyEntityId, 6);
+ if (this._isForecastOverlayEnabled()) {
+ const forecastEnergyEntityId = this._getForecastEntityId("energy");
+ if (forecastEnergyEntityId) {
+ void this._ensurePopupGraphLoadedByEntity(forecastEnergyEntityId, 6);
+ }
+ }
+ } else {
+ const derived = this._derived ?? (this._config && this.hass ? deriveArrayState(this._config, this.hass) : null);
+ if (derived) {
+ this._kpiCompareExpanded = {
+ ...this._kpiCompareExpanded,
+ energy: this._buildPanelCompareTargets(derived, "energy").length > 0,
+ };
+ void this._ensurePanelCompareLoaded(derived, "energy", this._kpiCompareRangeHours.energy);
+ }
+ if (this._isForecastOverlayEnabled()) {
+ const forecastEnergyEntityId = this._getForecastEntityId("energy");
+ if (forecastEnergyEntityId) {
+ void this._ensurePopupGraphLoadedByEntity(
+ forecastEnergyEntityId,
+ this._kpiCompareRangeHours.energy,
+ );
+ }
+ }
+ }
+ };
+
+ private _closeEnergyPopup = (): void => {
+ this._closeAllPopups();
+ };
+
+ private _openCustomKpiPopup = (): void => {
+ this._selectedPanelId = null;
+ this._clearTelemetryGraph();
+ this._kpiCompareExpanded = { power: false, energy: false };
+ this._kpiCompareRangeHours = { power: 6, energy: 6 };
+ this._showCustomKpiPopup = true;
+ this._showLivePowerPopup = false;
+ this._showEnergyPopup = false;
+ this._showSystemHealthPopup = false;
+ this._popupGraphRangeHours = 6;
+ const customKpiEntityId = this._getCustomKpiEntityId();
+ if (customKpiEntityId) {
+ void this._ensurePopupGraphLoadedByEntity(customKpiEntityId, 6);
+ }
+ };
+
+ private _closeCustomKpiPopup = (): void => {
+ this._closeAllPopups();
+ };
+
+ private _openSystemHealthPopup = (): void => {
+ this._showSystemHealthPopup = true;
+ this._showLivePowerPopup = false;
+ this._showEnergyPopup = false;
+ this._showCustomKpiPopup = false;
+ this._selectedPanelId = null;
+ this._clearTelemetryGraph();
+ this._kpiCompareExpanded = { power: false, energy: false };
+ this._kpiCompareRangeHours = { power: 6, energy: 6 };
+ };
+
+ private _closeSystemHealthPopup = (): void => {
+ this._closeAllPopups();
+ };
+
+ private _preventRangeChipFocusScroll = (event: Event): void => {
+ event.preventDefault();
+ };
+
+ private _clearPopupScrollRestore(): void {
+ const restore = this._popupScrollRestore;
+ if (restore && restore.frameId !== null && typeof window !== "undefined") {
+ window.cancelAnimationFrame(restore.frameId);
+ }
+ this._popupScrollRestore = null;
+ this._popupScrollRestoreToken++;
+ }
+
+ private _captureScrollPositionsForPopupGraph(event?: Event): number | null {
+ const eventTarget =
+ event?.currentTarget instanceof HTMLElement
+ ? event.currentTarget
+ : event?.target instanceof HTMLElement
+ ? event.target
+ : null;
+ const popupFromEvent = eventTarget?.closest(".spv-popup") ?? null;
+ const popup = popupFromEvent ?? this.renderRoot.querySelector(".spv-popup");
+ if (!popup) {
+ this._popupScrollRestore = null;
+ return null;
+ }
+
+ const token = ++this._popupScrollRestoreToken;
+ this._popupScrollRestore = {
+ token,
+ popup,
+ top: popup.scrollTop,
+ left: popup.scrollLeft,
+ expiresAt: Date.now() + 2500,
+ frameId: null,
+ framesRemaining: 0,
+ };
+ this._scheduleCapturedScrollRestore(token);
+ return token;
+ }
+
+ private _scheduleCapturedScrollRestore(
+ token: number | null | undefined,
+ frames = 2,
+ ): void {
+ if (!token || typeof window === "undefined") {
+ return;
+ }
+ if (!this._hasPopupOpen()) {
+ this._popupScrollRestore = null;
+ return;
+ }
+ const restore = this._popupScrollRestore;
+ if (!restore || restore.token !== token) {
+ return;
+ }
+ if (restore.expiresAt < Date.now()) {
+ this._popupScrollRestore = null;
+ return;
+ }
+
+ restore.framesRemaining = Math.max(restore.framesRemaining, frames);
+ if (restore.frameId !== null) {
+ return;
+ }
+
+ restore.frameId = window.requestAnimationFrame(() => {
+ const current = this._popupScrollRestore;
+ if (!current || current.token !== token) {
+ return;
+ }
+
+ current.frameId = null;
+ if (!this._hasPopupOpen()) {
+ this._popupScrollRestore = null;
+ return;
+ }
+ if (current.expiresAt < Date.now()) {
+ this._popupScrollRestore = null;
+ return;
+ }
+
+ if (current.popup.isConnected) {
+ current.popup.scrollTop = current.top;
+ current.popup.scrollLeft = current.left;
+ }
+
+ current.framesRemaining = Math.max(0, current.framesRemaining - 1);
+ if (current.framesRemaining > 0) {
+ this._scheduleCapturedScrollRestore(token, current.framesRemaining);
+ return;
+ }
+
+ this._popupScrollRestore = null;
+ });
+ }
+
+ private _renderInlinePanelConfig(panel: DerivedPanelState) {
+ const panelConfig = this._config?.panels.find((item) => item.id === panel.id);
+ if (!panelConfig) {
+ return nothing;
+ }
+ const availablePowerEntities = this._getAvailableQuickSetupPowerEntities(panel.id);
+ const disableChecked = !(panelConfig.enabled ?? true);
+
+ return html`
+
+
Quick Setup:
+
+ this._updatePanelConfigFromCard(panel.id, "power_entity", event.detail.value)}
+ >
+ ${availablePowerEntities.length === 0
+ ? html`
No available W sensors found.
`
+ : nothing}
+
+
+ this._updatePanelConfigFromCard(
+ panel.id,
+ "enabled",
+ !(event.currentTarget as HTMLInputElement).checked,
+ )}
+ >Disable Panel (hide but keep slot when off)
+
+
+ `;
+ }
+
+ private _getAvailableQuickSetupPowerEntities(panelId: string): string[] {
+ if (!this._config || !this.hass) {
+ return [];
+ }
+
+ const currentPanel = this._config.panels.find((panel) => panel.id === panelId);
+ const currentEntityId = currentPanel?.power_entity;
+ const usedByOthers = this._config.enable_string_sensor_sharing
+ ? new Set()
+ : new Set(
+ this._config.panels
+ .filter((panel) => panel.id !== panelId)
+ .map((panel) => panel.power_entity)
+ .filter((entityId): entityId is string => Boolean(entityId)),
+ );
+
+ const options: string[] = [];
+ for (const entity of Object.values(this.hass.states)) {
+ if (!entity.entity_id.startsWith("sensor.")) {
+ continue;
+ }
+ const unitRaw = entity.attributes?.unit_of_measurement;
+ if (typeof unitRaw !== "string" || unitRaw.trim().toLowerCase() !== "w") {
+ continue;
+ }
+ if (usedByOthers.has(entity.entity_id) && entity.entity_id !== currentEntityId) {
+ continue;
+ }
+ options.push(entity.entity_id);
+ }
+
+ if (
+ typeof currentEntityId === "string" &&
+ currentEntityId.length > 0 &&
+ !options.includes(currentEntityId)
+ ) {
+ options.push(currentEntityId);
+ }
+
+ return options.sort(naturalCompare);
+ }
+
+ private _updatePanelConfigFromCard(
+ panelId: string,
+ key: keyof PanelConfig,
+ value: unknown,
+ ): void {
+ if (!this._config) {
+ return;
+ }
+
+ const panelIndex = this._config.panels.findIndex((panel) => panel.id === panelId);
+ if (panelIndex < 0) {
+ return;
+ }
+
+ const panels = this._config.panels.map((panel, index) => {
+ if (index !== panelIndex) {
+ return panel;
+ }
+
+ const nextPanel: PanelConfig = { ...panel, [key]: value };
+ if (key === "power_entity") {
+ const previousEntityId = panel.power_entity;
+ const nextEntityId = typeof value === "string" ? value.trim() : "";
+ const nextFriendlyName = this._getEntityFriendlyName(nextEntityId);
+ if (
+ nextFriendlyName &&
+ this._shouldAutoRenamePanelName(panel.name, panel.id, previousEntityId)
+ ) {
+ nextPanel.name = nextFriendlyName;
+ }
+ }
+ return nextPanel;
+ });
+ const updatedConfig = normalizeConfig({
+ ...this._config,
+ panels,
+ });
+
+ this._commitConfigFromCard(updatedConfig);
+ if (key === "power_entity") {
+ const nextEntityId = typeof value === "string" ? value.trim() : "";
+ if (nextEntityId.length > 0) {
+ this._selectedPanelId = panelId;
+ void this._ensurePopupGraphLoaded(panelId, this._popupGraphRangeHours);
+ }
+ }
+ }
+
+ private _getEntityFriendlyName(entityId: string | undefined): string | undefined {
+ const normalizedEntityId = entityId?.trim();
+ if (!normalizedEntityId) {
+ return undefined;
+ }
+ const state = this.hass?.states?.[normalizedEntityId];
+ const friendlyName = state?.attributes?.friendly_name;
+ if (typeof friendlyName !== "string") {
+ return undefined;
+ }
+ const normalized = friendlyName.trim();
+ return normalized.length > 0 ? normalized : undefined;
+ }
+
+ private _shouldAutoRenamePanelName(
+ currentName: string | undefined,
+ panelId: string,
+ previousPowerEntity: string | undefined,
+ ): boolean {
+ const normalizedName = currentName?.trim() ?? "";
+ if (!normalizedName) {
+ return true;
+ }
+ if (normalizedName === panelId) {
+ return true;
+ }
+ if (/^panel\s+\d+$/i.test(normalizedName)) {
+ return true;
+ }
+
+ const previousFriendlyName = this._getEntityFriendlyName(previousPowerEntity);
+ return Boolean(previousFriendlyName && normalizedName === previousFriendlyName);
+ }
+
+ private _handleDragStart(event: DragEvent, slotIndex: number): void {
+ this._dragSourceSlotIndex = slotIndex;
+ if (event.dataTransfer) {
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("text/plain", String(slotIndex));
+ }
+ }
+
+ private _handleDragOver = (event: DragEvent): void => {
+ event.preventDefault();
+ if (event.dataTransfer) {
+ event.dataTransfer.dropEffect = "move";
+ }
+ };
+
+ private _handleDrop(event: DragEvent, targetSlotIndex: number): void {
+ event.preventDefault();
+ if (!this._config) {
+ return;
+ }
+
+ const sourceSlotIndex =
+ this._dragSourceSlotIndex ??
+ Number(event.dataTransfer?.getData("text/plain") ?? Number.NaN);
+ if (!Number.isFinite(sourceSlotIndex) || sourceSlotIndex === targetSlotIndex) {
+ return;
+ }
+
+ const panels = [...this._config.panels];
+ const sourcePanel = panels[sourceSlotIndex];
+ const targetPanel = panels[targetSlotIndex];
+ if (!sourcePanel || !targetPanel) {
+ return;
+ }
+
+ panels[sourceSlotIndex] = targetPanel;
+ panels[targetSlotIndex] = sourcePanel;
+
+ this._commitConfigFromCard(
+ normalizeConfig({
+ ...this._config,
+ panels,
+ }),
+ );
+ }
+
+ private _handleDragEnd = (): void => {
+ this._dragSourceSlotIndex = null;
+ };
+
+ private _commitConfigFromCard(config: SolarPanelVisualizerConfig): void {
+ this._config = config;
+ this._historySignature = "";
+ this._historyQuerySignature = "";
+ this._historyLastLoadMs = 0;
+ this._historyLoadToken = 0;
+ this._popupGraphCache = {};
+ this._popupGraphLatestTokenByKey = {};
+ this._kpiCompareCache = {};
+ this._kpiCompareLatestTokenByKey = {};
+ this._kpiCompareExpanded = { power: false, energy: false };
+ this._kpiCompareRangeHours = { power: 6, energy: 6 };
+ this._ensureHistoryLoaded();
+ this._refreshDerived();
+ this.dispatchEvent(
+ new CustomEvent("config-changed", {
+ detail: { config },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(
+ new CustomEvent(CARD_CONFIG_SYNC_EVENT, {
+ detail: { config },
+ }),
+ );
+ }
+ void this._persistConfigToLovelace(config);
+ }
+
+ private async _persistConfigToLovelace(
+ cardConfig: SolarPanelVisualizerConfig,
+ ): Promise {
+ if (!this.hass?.callWS) {
+ return;
+ }
+
+ const token = ++this._persistConfigToken;
+ const lovelaceContext = this._findLovelaceContext();
+ const urlPath = this._resolveDashboardUrlPath(lovelaceContext);
+
+ try {
+ const dashboardConfig =
+ lovelaceContext && typeof lovelaceContext.config === "object"
+ ? (lovelaceContext.config as Record)
+ : ((await this.hass.callWS({
+ type: "lovelace/config",
+ ...(urlPath ? { url_path: urlPath } : {}),
+ })) as Record);
+
+ if (token !== this._persistConfigToken) {
+ return;
+ }
+
+ const { config, replaced } = this._replaceCardConfigInDashboardConfig(
+ dashboardConfig,
+ cardConfig,
+ );
+ if (!replaced) {
+ // eslint-disable-next-line no-console
+ console.warn("Solar Panel Visualizer: Could not locate card config to persist changes.");
+ return;
+ }
+
+ await this.hass.callWS({
+ type: "lovelace/config/save",
+ ...(urlPath ? { url_path: urlPath } : {}),
+ config,
+ });
+
+ if (token !== this._persistConfigToken) {
+ return;
+ }
+
+ if (lovelaceContext) {
+ lovelaceContext.config = config;
+ }
+ this._sourceConfigRef = undefined;
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.warn("Solar Panel Visualizer: Failed to persist dashboard config.", error);
+ }
+ }
+
+ private _findLovelaceContext():
+ | {
+ config?: Record;
+ urlPath?: string;
+ }
+ | null {
+ const visited = new Set();
+ let node: unknown = this;
+
+ while (node && !visited.has(node)) {
+ visited.add(node);
+
+ if (
+ typeof node === "object" &&
+ node !== null &&
+ "lovelace" in node &&
+ (node as { lovelace?: unknown }).lovelace &&
+ typeof (node as { lovelace?: unknown }).lovelace === "object"
+ ) {
+ return (node as { lovelace: { config?: Record; urlPath?: string } })
+ .lovelace;
+ }
+
+ const asNode = node as Node & { parentElement?: Element | null };
+ const rootNode =
+ typeof asNode.getRootNode === "function"
+ ? (asNode.getRootNode() as ShadowRoot | Document)
+ : null;
+ const host = rootNode && "host" in rootNode ? (rootNode as ShadowRoot).host : null;
+ if (host && host !== node) {
+ node = host;
+ continue;
+ }
+
+ node = asNode.parentElement ?? null;
+ }
+
+ return null;
+ }
+
+ private _resolveDashboardUrlPath(
+ lovelaceContext: { urlPath?: string } | null,
+ ): string | undefined {
+ const fromContext = lovelaceContext?.urlPath?.trim();
+ if (fromContext) {
+ return fromContext;
+ }
+
+ if (typeof window === "undefined") {
+ return undefined;
+ }
+ const firstSegment = window.location.pathname
+ .replace(/^\/+/, "")
+ .split("/")[0]
+ ?.trim();
+ return firstSegment && firstSegment.length > 0 ? firstSegment : undefined;
+ }
+
+ private _replaceCardConfigInDashboardConfig(
+ dashboardConfig: Record,
+ nextCardConfig: SolarPanelVisualizerConfig,
+ ): { config: Record; replaced: boolean } {
+ const byReference = this._replaceFirstMatch(dashboardConfig, (candidate) =>
+ Boolean(this._sourceConfigRef) && candidate === this._sourceConfigRef,
+ nextCardConfig,
+ );
+ if (byReference.replaced) {
+ return {
+ config: byReference.value as Record,
+ replaced: true,
+ };
+ }
+
+ const bySignature = this._replaceFirstMatch(
+ dashboardConfig,
+ (candidate) => this._matchesCurrentCardSignature(candidate),
+ nextCardConfig,
+ );
+ return {
+ config: bySignature.value as Record,
+ replaced: bySignature.replaced,
+ };
+ }
+
+ private _replaceFirstMatch(
+ root: unknown,
+ matcher: (candidate: unknown) => boolean,
+ replacement: SolarPanelVisualizerConfig,
+ ): { value: unknown; replaced: boolean } {
+ let replaced = false;
+
+ const visit = (value: unknown): unknown => {
+ if (replaced) {
+ return value;
+ }
+
+ if (matcher(value)) {
+ replaced = true;
+ return replacement;
+ }
+
+ if (Array.isArray(value)) {
+ let changed = false;
+ const next = value.map((item) => {
+ const updated = visit(item);
+ if (updated !== item) {
+ changed = true;
+ }
+ return updated;
+ });
+ return changed ? next : value;
+ }
+
+ if (typeof value !== "object" || value === null) {
+ return value;
+ }
+
+ let changed = false;
+ const nextObject: Record = {};
+ for (const [key, child] of Object.entries(value as Record)) {
+ const updated = visit(child);
+ nextObject[key] = updated;
+ if (updated !== child) {
+ changed = true;
+ }
+ }
+ return changed ? nextObject : value;
+ };
+
+ return { value: visit(root), replaced };
+ }
+
+ private _matchesCurrentCardSignature(candidate: unknown): boolean {
+ if (!this._config || typeof candidate !== "object" || candidate === null) {
+ return false;
+ }
+ const configCandidate = candidate as Partial;
+ if (configCandidate.type !== this._config.type) {
+ return false;
+ }
+
+ const rows = Number(configCandidate.rows);
+ const columns = Number(configCandidate.columns);
+ if (rows !== this._config.rows || columns !== this._config.columns) {
+ return false;
+ }
+
+ const candidatePanels = Array.isArray(configCandidate.panels)
+ ? configCandidate.panels
+ : [];
+ const candidatePanelIds = candidatePanels
+ .map((panel) =>
+ typeof panel === "object" &&
+ panel !== null &&
+ typeof (panel as { id?: unknown }).id === "string"
+ ? (panel as { id: string }).id
+ : "",
+ )
+ .sort()
+ .join("|");
+ const currentPanelIds = this._config.panels
+ .map((panel) => panel.id)
+ .sort()
+ .join("|");
+
+ return candidatePanelIds.length > 0 && candidatePanelIds === currentPanelIds;
+ }
+
+ private _adjustGraphSamplesForEntity(
+ entityId: string,
+ samples: PowerSample[],
+ ): PowerSample[] {
+ const systemPowerEntityId = this._getSystemPowerEntityId();
+ const shouldInvert =
+ (this._config?.invert_system_power ?? false) &&
+ !!systemPowerEntityId &&
+ entityId === systemPowerEntityId;
+
+ return samples.map((sample) => ({
+ ts: sample.ts,
+ value: shouldInvert
+ ? normalizeNegativeZero(sample.value * -1)
+ : normalizeNegativeZero(sample.value),
+ }));
+ }
+
+ private _scaleGraphSamples(
+ samples: PowerSample[],
+ scale = 1,
+ ): PowerSample[] {
+ if (scale === 1) {
+ return samples;
+ }
+ return samples.map((sample) => ({
+ ts: sample.ts,
+ value: normalizeNegativeZero(sample.value * scale),
+ }));
+ }
+
+ private _expandSinglePointRangeSamples(
+ samples: PowerSample[],
+ rangeHours: PopupGraphRangeHours,
+ ): PowerSample[] {
+ if (samples.length !== 1) {
+ return samples;
+ }
+ const value = samples[0].value;
+ const nowMs = Date.now();
+ const startTs = nowMs - rangeHours * 60 * 60 * 1000;
+ return [
+ { ts: startTs, value },
+ { ts: nowMs, value },
+ ];
+ }
+
+ private _fillRangeStartWithFirstSample(
+ samples: PowerSample[],
+ rangeHours: PopupGraphRangeHours,
+ enabled: boolean,
+ ): PowerSample[] {
+ if (!enabled || samples.length === 0) {
+ return samples;
+ }
+
+ const nowMs = Date.now();
+ const threshold = nowMs - rangeHours * 60 * 60 * 1000;
+ const sorted = [...samples].sort((a, b) => a.ts - b.ts);
+ const first = sorted[0];
+ if (!first || first.ts <= threshold) {
+ return sorted;
+ }
+
+ return [{ ts: threshold, value: first.value }, ...sorted];
+ }
+
+ private _getPowerEntities(): string[] {
+ if (!this._config) {
+ return [];
+ }
+ const entities = this._config.panels
+ .map((panel) => panel.power_entity)
+ .filter((entityId): entityId is string => Boolean(entityId));
+ return [...new Set(entities)];
+ }
+
+ private _getHistoryHours(): number {
+ return this._config?.deviation_history_hours ?? 12;
+ }
+
+ private _getHistoryQuerySignature(): string {
+ if (!this._config || !this.hass) {
+ return "";
+ }
+ const entities = this._getPowerEntities().sort().join("|");
+ return `${this._getHistoryHours()}:${entities}`;
+ }
+
+ private _getHistorySignature(): string {
+ if (!this._config || !this.hass) {
+ return "";
+ }
+ const entities = this._getHistoryQuerySignature();
+ const restartToken = this._getRestartToken();
+ return `${entities}:${restartToken ?? ""}`;
+ }
+
+ private _getRestartToken(): string | undefined {
+ const restartEntity = this._config?.deviation_restart_entity;
+ if (!restartEntity) {
+ return undefined;
+ }
+ const entity = this.hass?.states?.[restartEntity];
+ const state = entity?.state?.toString().trim();
+ if (!state) {
+ return undefined;
+ }
+ const directTimestamp = Date.parse(state);
+ if (Number.isFinite(directTimestamp)) {
+ return String(directTimestamp);
+ }
+ const restartAttributeCandidates = [
+ entity?.attributes?.last_restart,
+ entity?.attributes?.last_reset,
+ entity?.attributes?.boot_time,
+ entity?.attributes?.started,
+ entity?.attributes?.start_time,
+ ];
+ for (const candidate of restartAttributeCandidates) {
+ if (typeof candidate !== "string" || candidate.trim().length === 0) {
+ continue;
+ }
+ const parsed = Date.parse(candidate);
+ if (Number.isFinite(parsed)) {
+ return String(parsed);
+ }
+ }
+ if (/^[-+]?\d+(\.\d+)?$/.test(state)) {
+ return undefined;
+ }
+ return state;
+ }
+
+ private _historyCacheKey(): string {
+ return `${HISTORY_CACHE_PREFIX}${this._getHistorySignature()}`;
+ }
+
+ private _pruneSamples(samples: PowerSample[], keepHours = this._getHistoryHours()): PowerSample[] {
+ const now = Date.now();
+ const threshold = now - keepHours * 60 * 60 * 1000;
+ return samples
+ .filter((sample) => sample.ts >= threshold && Number.isFinite(sample.value))
+ .sort((a, b) => a.ts - b.ts);
+ }
+
+ private _syncLiveSamplesFromHass(): void {
+ if (!this.hass || !this._config) {
+ return;
+ }
+
+ let touched = false;
+ const now = Date.now();
+ for (const panel of this._config.panels) {
+ const entityId = panel.power_entity;
+ if (!entityId) {
+ continue;
+ }
+ const entity = this.hass.states[entityId];
+ const numeric = Number(entity?.state);
+ if (!Number.isFinite(numeric)) {
+ continue;
+ }
+
+ const existing = this._historyByEntityId[entityId] ?? [];
+ const next = [...existing];
+ const last = next[next.length - 1];
+ if (!last || Math.abs(now - last.ts) > 60_000 || Math.abs(last.value - numeric) > 0.01) {
+ next.push({ ts: now, value: numeric });
+ this._historyByEntityId[entityId] = this._pruneSamples(next);
+ touched = true;
+ }
+ }
+
+ if (touched) {
+ this._saveHistoryCache();
+ }
+ }
+
+ private _saveHistoryCache(): void {
+ if (typeof window === "undefined" || !this._config) {
+ return;
+ }
+ const payload = {
+ v: HISTORY_CACHE_VERSION,
+ savedAt: Date.now(),
+ samples: this._historyByEntityId,
+ reason: this._historyStateReason,
+ };
+ try {
+ window.localStorage.setItem(this._historyCacheKey(), JSON.stringify(payload));
+ } catch (_error) {
+ // Ignore storage quota/runtime issues.
+ }
+ }
+
+ private _loadHistoryCache(): void {
+ if (typeof window === "undefined") {
+ return;
+ }
+ try {
+ const raw = window.localStorage.getItem(this._historyCacheKey());
+ if (!raw) {
+ return;
+ }
+ const parsed = JSON.parse(raw) as {
+ v?: number;
+ samples?: Record;
+ };
+ if (parsed.v !== HISTORY_CACHE_VERSION || !parsed.samples) {
+ return;
+ }
+ const normalized: Record = {};
+ for (const [entityId, samples] of Object.entries(parsed.samples)) {
+ normalized[entityId] = this._pruneSamples(samples);
+ }
+ this._historyByEntityId = normalized;
+ } catch (_error) {
+ // Ignore cache parsing/runtime issues.
+ }
+ }
+
+ private _ensureHistoryLoaded(): void {
+ if (!this.hass || !this._config) {
+ return;
+ }
+ if (!(this._config.enable_array_checks ?? false)) {
+ this._historyState = "idle";
+ this._historyStateReason = undefined;
+ this._historySignature = "";
+ this._historyQuerySignature = "";
+ this._historyLastLoadMs = 0;
+ this._historyByEntityId = {};
+ return;
+ }
+ const signature = this._getHistorySignature();
+ if (!signature) {
+ return;
+ }
+ if (signature === this._historySignature && this._historyState !== "idle") {
+ return;
+ }
+ const querySignature = this._getHistoryQuerySignature();
+ const nowMs = Date.now();
+ if (
+ this._historyState === "loading" &&
+ querySignature === this._historyQuerySignature
+ ) {
+ this._historySignature = signature;
+ return;
+ }
+ if (
+ querySignature === this._historyQuerySignature &&
+ this._historyState !== "idle" &&
+ nowMs - this._historyLastLoadMs < HISTORY_QUERY_RELOAD_THROTTLE_MS
+ ) {
+ this._historySignature = signature;
+ return;
+ }
+
+ this._historySignature = signature;
+ this._historyQuerySignature = querySignature;
+ this._historyLastLoadMs = nowMs;
+ this._historyByEntityId = {};
+ this._loadHistoryCache();
+ const token = ++this._historyLoadToken;
+ void this._loadHistoryFromRecorder(token);
+ }
+
+ private async _loadHistoryFromRecorder(token: number): Promise {
+ if (!this.hass || !this._config) {
+ return;
+ }
+
+ const entityIds = this._getPowerEntities();
+ if (entityIds.length === 0) {
+ this._historyState = "ready";
+ this._historyStateReason = "No panel sensors configured.";
+ this._refreshDerived();
+ this.requestUpdate();
+ return;
+ }
+
+ if (!this.hass.callApi && !this.hass.callWS) {
+ this._historyState = "unavailable";
+ this._historyStateReason = "Solar panel history API unavailable in this Home Assistant build.";
+ this._refreshDerived();
+ this.requestUpdate();
+ return;
+ }
+
+ const now = Date.now();
+ const startIso = new Date(now - this._getHistoryHours() * 60 * 60 * 1000).toISOString();
+ const endIso = new Date(now).toISOString();
+ this._historyState = "loading";
+ this._historyStateReason = undefined;
+ this.requestUpdate();
+
+ try {
+ const response = await this._fetchRecorderHistoryRaw(startIso, endIso, entityIds);
+ if (token !== this._historyLoadToken) {
+ return;
+ }
+
+ const samples = this._parseRecorderResponse(response, this._getHistoryHours(), entityIds);
+ if (Object.keys(samples).length === 0) {
+ this._historyState = "fallback";
+ this._historyStateReason =
+ "Solar panel history returned no samples, using live warm-up only.";
+ } else {
+ this._historyByEntityId = {
+ ...this._historyByEntityId,
+ ...samples,
+ };
+ this._historyState = "ready";
+ this._historyStateReason = undefined;
+ }
+ } catch (_error) {
+ if (token !== this._historyLoadToken) {
+ return;
+ }
+ this._historyState = "fallback";
+ this._historyStateReason =
+ "Failed to read solar panel history, using live warm-up only.";
+ }
+
+ this._syncLiveSamplesFromHass();
+ this._saveHistoryCache();
+ this._refreshDerived();
+ this.requestUpdate();
+ }
+
+ private async _fetchRecorderHistoryRaw(
+ startIso: string,
+ endIso: string,
+ entityIds: string[],
+ ): Promise {
+ if (!this.hass) {
+ throw new Error("Home Assistant context unavailable");
+ }
+
+ const errors: unknown[] = [];
+ const filterEntityId = entityIds.join(",");
+ const rawPath = `history/period/${startIso}`;
+ const encodedPath = `history/period/${encodeURIComponent(startIso)}`;
+ const apiAttempts: Array<{ path: string; params: Record }> = [];
+ const minimalParams = {
+ end_time: endIso,
+ filter_entity_id: filterEntityId,
+ no_attributes: true,
+ significant_changes_only: false,
+ minimal_response: true,
+ };
+ const basicParams = {
+ end_time: endIso,
+ filter_entity_id: filterEntityId,
+ };
+ apiAttempts.push({ path: rawPath, params: basicParams });
+ apiAttempts.push({ path: rawPath, params: minimalParams });
+ if (encodedPath !== rawPath) {
+ apiAttempts.push({ path: encodedPath, params: basicParams });
+ apiAttempts.push({ path: encodedPath, params: minimalParams });
+ }
+
+ if (this.hass.callApi) {
+ for (const attempt of apiAttempts) {
+ try {
+ return await this.hass.callApi("GET", attempt.path, attempt.params);
+ } catch (error) {
+ errors.push(error);
+ }
+ }
+ }
+
+ if (this.hass.callWS) {
+ const wsAttempts: Record[] = [
+ {
+ type: "history/history_during_period",
+ start_time: startIso,
+ end_time: endIso,
+ entity_ids: entityIds,
+ no_attributes: true,
+ significant_changes_only: false,
+ minimal_response: true,
+ },
+ {
+ type: "history/history_during_period",
+ start_time: startIso,
+ end_time: endIso,
+ entity_ids: entityIds,
+ },
+ ];
+
+ for (const attempt of wsAttempts) {
+ try {
+ return await this.hass.callWS(attempt);
+ } catch (error) {
+ errors.push(error);
+ }
+ }
+ }
+
+ if (errors.length > 0) {
+ throw errors[errors.length - 1];
+ }
+ throw new Error("Recorder API unavailable");
+ }
+
+ private _parseRecorderResponse(
+ response: unknown,
+ keepHours = this._getHistoryHours(),
+ entityIdsOrder: string[] = [],
+ ): Record {
+ const byEntity: Record = {};
+ const parseTimestamp = (value: unknown): number => {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value > 1e11 ? value : value * 1000;
+ }
+
+ if (typeof value === "string" && value.trim().length > 0) {
+ const trimmed = value.trim();
+ const numeric = Number(trimmed);
+ if (Number.isFinite(numeric) && /^\d+(\.\d+)?$/.test(trimmed)) {
+ return numeric > 1e11 ? numeric : numeric * 1000;
+ }
+ const parsedDate = Date.parse(trimmed);
+ if (Number.isFinite(parsedDate)) {
+ return parsedDate;
+ }
+ }
+
+ return Number.NaN;
+ };
+
+ const ingestGroup = (group: unknown, defaultEntityId?: string): void => {
+ if (!Array.isArray(group) || group.length === 0) {
+ return;
+ }
+
+ let currentEntityId: string | undefined = defaultEntityId;
+ for (const stateRow of group) {
+ if (Array.isArray(stateRow)) {
+ if (!currentEntityId || stateRow.length < 2) {
+ continue;
+ }
+ const candidateATs = parseTimestamp(stateRow[0]);
+ const candidateAValue = Number(stateRow[1]);
+ const candidateBTs = parseTimestamp(stateRow[1]);
+ const candidateBValue = Number(stateRow[0]);
+
+ let ts = candidateATs;
+ let value = candidateAValue;
+ if (!Number.isFinite(ts) || !Number.isFinite(value)) {
+ ts = candidateBTs;
+ value = candidateBValue;
+ }
+
+ if (!Number.isFinite(ts) || !Number.isFinite(value)) {
+ continue;
+ }
+
+ if (!byEntity[currentEntityId]) {
+ byEntity[currentEntityId] = [];
+ }
+ byEntity[currentEntityId].push({ ts, value });
+ continue;
+ }
+
+ if (typeof stateRow !== "object" || stateRow === null) {
+ continue;
+ }
+ const row = stateRow as {
+ entity_id?: unknown;
+ e?: unknown;
+ state?: unknown;
+ s?: unknown;
+ last_changed?: unknown;
+ last_updated?: unknown;
+ lc?: unknown;
+ lu?: unknown;
+ last_changed_ts?: unknown;
+ last_updated_ts?: unknown;
+ ts?: unknown;
+ };
+ const entityId =
+ typeof row.entity_id === "string" && row.entity_id.length > 0
+ ? row.entity_id
+ : typeof row.e === "string" && row.e.length > 0
+ ? row.e
+ : currentEntityId;
+ if (!entityId) {
+ continue;
+ }
+ currentEntityId = entityId;
+
+ const value = Number(row.state ?? row.s);
+ if (!Number.isFinite(value)) {
+ continue;
+ }
+ const timestampSource =
+ typeof row.last_changed === "string"
+ ? row.last_changed
+ : typeof row.last_updated === "string"
+ ? row.last_updated
+ : row.lc ?? row.lu ?? row.last_changed_ts ?? row.last_updated_ts ?? row.ts;
+ const ts = parseTimestamp(timestampSource);
+ if (!Number.isFinite(ts)) {
+ continue;
+ }
+ if (!byEntity[entityId]) {
+ byEntity[entityId] = [];
+ }
+ byEntity[entityId].push({ ts, value });
+ }
+ };
+
+ const normalizedResponse =
+ typeof response === "object" &&
+ response !== null &&
+ "result" in (response as Record)
+ ? (response as { result: unknown }).result
+ : response;
+
+ if (Array.isArray(normalizedResponse)) {
+ for (const [index, group] of normalizedResponse.entries()) {
+ ingestGroup(group, entityIdsOrder[index]);
+ }
+ } else if (typeof normalizedResponse === "object" && normalizedResponse !== null) {
+ for (const [entityId, group] of Object.entries(
+ normalizedResponse as Record,
+ )) {
+ ingestGroup(group, entityId);
+ }
+ } else {
+ return byEntity;
+ }
+
+ for (const [entityId, samples] of Object.entries(byEntity)) {
+ byEntity[entityId] = this._pruneSamples(samples, keepHours);
+ }
+ return byEntity;
+ }
+}
+
+export class SolarPanelVisualizerCard extends SolarPanelVisualizerBaseCard {}
+
+if (!customElements.get("solar-panel-visualizer")) {
+ customElements.define("solar-panel-visualizer", SolarPanelVisualizerCard);
+}
+
+if (!customElements.get("solar-panel-visualizer-card")) {
+ class SolarPanelVisualizerCardLegacy extends SolarPanelVisualizerCard {}
+ customElements.define("solar-panel-visualizer-card", SolarPanelVisualizerCardLegacy);
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "solar-panel-visualizer-base": SolarPanelVisualizerBaseCard;
+ "solar-panel-visualizer": SolarPanelVisualizerCard;
+ "solar-panel-visualizer-card": SolarPanelVisualizerCard;
+ }
+}
diff --git a/src/solar-panel-visualizer-editor.ts b/src/solar-panel-visualizer-editor.ts
new file mode 100644
index 0000000..c9fe3a2
--- /dev/null
+++ b/src/solar-panel-visualizer-editor.ts
@@ -0,0 +1,2127 @@
+import { LitElement, css, html, nothing } from "lit";
+import {
+ CARD_TYPE,
+ MAX_LAYOUT_DIMENSION,
+ MAX_PANEL_RATED_POWER_W,
+ normalizeConfig,
+} from "./config";
+import {
+ autoPopulateAdvancedTelemetry,
+ autoPopulateEnergyEntitiesByPowerAnchor,
+ autoPopulateInverterStatusEntities,
+ applyAutoPopulateFriendlyNames,
+ type AutoPopulateSortMode,
+ autoPopulatePanelEntities,
+ shouldAutoRenamePanelName,
+} from "./editor-autofill";
+import { translate, type TranslationKey } from "./i18n";
+import { naturalCompare } from "./natural-sort";
+import type {
+ HassEntity,
+ HomeAssistant,
+ PanelConfig,
+ SolarPanelVisualizerConfig,
+ StringGroupConfig,
+} from "./types";
+
+const CARD_CONFIG_SYNC_EVENT = "spv-card-config-updated";
+
+const fireConfigChanged = (
+ element: HTMLElement,
+ config: SolarPanelVisualizerConfig,
+): void => {
+ element.dispatchEvent(
+ new CustomEvent("config-changed", {
+ detail: { config },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+};
+
+const normalizeAttr = (value: unknown): string =>
+ typeof value === "string" ? value.trim().toLowerCase() : "";
+
+const unitIs = (entity: HassEntity, units: string[]): boolean => {
+ const normalizedUnit = normalizeAttr(entity.attributes?.unit_of_measurement);
+ return normalizedUnit.length > 0 && units.includes(normalizedUnit);
+};
+
+const deviceClassIs = (entity: HassEntity, classes: string[]): boolean => {
+ const normalizedDeviceClass = normalizeAttr(entity.attributes?.device_class);
+ return normalizedDeviceClass.length > 0 && classes.includes(normalizedDeviceClass);
+};
+
+const entityMatchesPanelPower = (normalizedUnit: string, entity: HassEntity): boolean =>
+ ["w", "kw"].includes(normalizedUnit) || deviceClassIs(entity, ["power"]);
+
+const entityMatchesPanelEnergy = (normalizedUnit: string, entity: HassEntity): boolean =>
+ ["wh", "kwh", "mwh"].includes(normalizedUnit) || deviceClassIs(entity, ["energy"]);
+
+const getStateEntityId = (entityId: string, entity: HassEntity): string =>
+ typeof entity.entity_id === "string" && entity.entity_id.trim().length > 0
+ ? entity.entity_id
+ : entityId;
+
+interface AdvancedMetricFieldDef {
+ key: keyof NonNullable;
+ labelKey: TranslationKey;
+ matcher: (entity: HassEntity) => boolean;
+}
+
+const ADVANCED_METRIC_FIELDS = [
+ {
+ key: "inverter_ac_power_entity",
+ labelKey: "editor.field.advanced_inverter_ac_power",
+ matcher: (entity: HassEntity) =>
+ unitIs(entity, ["w", "kw"]) || deviceClassIs(entity, ["power"]),
+ },
+ {
+ key: "inverter_ac_voltage_entity",
+ labelKey: "editor.field.advanced_inverter_ac_voltage",
+ matcher: (entity: HassEntity) =>
+ unitIs(entity, ["v", "kv", "mv"]) || deviceClassIs(entity, ["voltage"]),
+ },
+ {
+ key: "inverter_ac_current_entity",
+ labelKey: "editor.field.advanced_inverter_ac_current",
+ matcher: (entity: HassEntity) =>
+ unitIs(entity, ["a", "ma"]) || deviceClassIs(entity, ["current"]),
+ },
+ {
+ key: "inverter_temp_entity",
+ labelKey: "editor.field.advanced_inverter_temp",
+ matcher: (entity: HassEntity) =>
+ deviceClassIs(entity, ["temperature"]) ||
+ unitIs(entity, ["\u00b0c", "\u00b0f", "\u2103", "\u2109", "c", "f"]),
+ },
+ {
+ key: "panel_current_entity",
+ labelKey: "editor.field.advanced_panel_current",
+ matcher: (entity: HassEntity) =>
+ unitIs(entity, ["a", "ma"]) || deviceClassIs(entity, ["current"]),
+ },
+ {
+ key: "panel_voltage_entity",
+ labelKey: "editor.field.advanced_panel_voltage",
+ matcher: (entity: HassEntity) =>
+ unitIs(entity, ["v", "kv", "mv"]) || deviceClassIs(entity, ["voltage"]),
+ },
+ {
+ key: "panel_power_entity",
+ labelKey: "editor.field.advanced_panel_power",
+ matcher: (entity: HassEntity) =>
+ unitIs(entity, ["w", "kw"]) || deviceClassIs(entity, ["power"]),
+ },
+] as const satisfies readonly AdvancedMetricFieldDef[];
+
+type AdvancedMetricKey = (typeof ADVANCED_METRIC_FIELDS)[number]["key"];
+
+export class SolarPanelVisualizerBaseCardEditor extends LitElement {
+ static properties = {
+ hass: { attribute: false },
+ _config: { state: true },
+ _autoFillPowerPrefix: { state: true },
+ _autoFillEnergyPrefix: { state: true },
+ _autoFillStatusPrefix: { state: true },
+ _autoFillSortMode: { state: true },
+ _autoFillInverterStatus: { state: true },
+ _autoFillAdvancedTelemetry: { state: true },
+ _autoFillResultMessage: { state: true },
+ };
+
+ static styles = css`
+ :host {
+ display: block;
+ }
+
+ .editor {
+ display: grid;
+ gap: 20px;
+ }
+
+ .section {
+ display: grid;
+ gap: 12px;
+ padding: 16px;
+ border-radius: 18px;
+ border: 1px solid var(--divider-color);
+ background: rgba(127, 146, 171, 0.06);
+ }
+
+ .section-header {
+ display: grid;
+ gap: 4px;
+ }
+
+ .section-title {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 700;
+ }
+
+ .section-copy {
+ margin: 0;
+ color: var(--secondary-text-color);
+ font-size: 0.92rem;
+ }
+
+ .grid {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ }
+
+ .field {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+ color: var(--secondary-text-color);
+ font-size: 0.78rem;
+ font-weight: 500;
+ }
+
+ .field-label {
+ line-height: 1.2;
+ }
+
+ .text-input {
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 42px;
+ border-radius: 12px;
+ border: 1px solid var(--divider-color);
+ background: var(--card-background-color);
+ color: var(--primary-text-color);
+ padding: 0 12px;
+ font: inherit;
+ outline: none;
+ }
+
+ .text-input:focus {
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 1px var(--primary-color);
+ }
+
+ .text-input:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+ }
+
+ .panel-list {
+ display: grid;
+ gap: 12px;
+ }
+
+ details {
+ border-radius: 16px;
+ border: 1px solid var(--divider-color);
+ background: var(--card-background-color);
+ overflow: hidden;
+ }
+
+ summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 16px;
+ cursor: pointer;
+ font-weight: 600;
+ list-style: none;
+ }
+
+ summary::-webkit-details-marker {
+ display: none;
+ }
+
+ .panel-form {
+ display: grid;
+ gap: 12px;
+ padding: 0 16px 16px;
+ }
+
+ .panel-advanced {
+ border-radius: 12px;
+ border: 1px solid var(--divider-color);
+ background: rgba(127, 146, 171, 0.08);
+ overflow: hidden;
+ }
+
+ .panel-advanced summary {
+ padding: 10px 12px;
+ font-size: 0.86rem;
+ font-weight: 600;
+ }
+
+ .panel-advanced-grid {
+ display: grid;
+ gap: 10px;
+ padding: 0 12px 12px;
+ }
+
+ .toggle {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .autofill-option {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 32px;
+ color: var(--primary-text-color);
+ font-size: 0.95rem;
+ font-weight: 500;
+ cursor: pointer;
+ }
+
+ .autofill-option input {
+ width: 18px;
+ height: 18px;
+ margin: 0;
+ accent-color: var(--primary-color);
+ cursor: pointer;
+ }
+
+ .button-row {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ }
+
+ .secondary-button {
+ border: 1px solid var(--divider-color);
+ background: rgba(127, 146, 171, 0.08);
+ color: var(--primary-text-color);
+ border-radius: 12px;
+ min-height: 38px;
+ padding: 0 12px;
+ cursor: pointer;
+ font: inherit;
+ }
+
+ .color-grid {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ }
+
+ label.color-field {
+ display: grid;
+ gap: 8px;
+ font-size: 0.88rem;
+ color: var(--secondary-text-color);
+ }
+
+ input[type="color"] {
+ width: 100%;
+ height: 42px;
+ border-radius: 12px;
+ border: 1px solid var(--divider-color);
+ background: transparent;
+ padding: 4px;
+ cursor: pointer;
+ }
+
+ input[type="range"] {
+ width: 100%;
+ }
+
+ select {
+ width: 100%;
+ min-height: 42px;
+ border-radius: 12px;
+ border: 1px solid var(--divider-color);
+ background: var(--card-background-color);
+ color: var(--primary-text-color);
+ padding: 0 12px;
+ font: inherit;
+ }
+
+ .chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ font-size: 0.78rem;
+ background: rgba(127, 146, 171, 0.12);
+ color: var(--secondary-text-color);
+ }
+ `;
+
+ declare hass?: HomeAssistant;
+ protected _config: SolarPanelVisualizerConfig = this._normalizeEditorConfig({
+ type: this._getCardType(),
+ });
+ private _autoFillPowerPrefix = "";
+ private _autoFillEnergyPrefix = "";
+ private _autoFillStatusPrefix = "";
+ private _autoFillSortMode: AutoPopulateSortMode = "auto";
+ private _autoFillInverterStatus = false;
+ private _autoFillAdvancedTelemetry = false;
+ private _autoFillResultMessage = "";
+
+ public connectedCallback(): void {
+ super.connectedCallback();
+ window.addEventListener(CARD_CONFIG_SYNC_EVENT, this._handleCardConfigSync);
+ }
+
+ public disconnectedCallback(): void {
+ window.removeEventListener(CARD_CONFIG_SYNC_EVENT, this._handleCardConfigSync);
+ super.disconnectedCallback();
+ }
+
+ public setConfig(config: Partial): void {
+ this._config = this._normalizeEditorConfig(config ?? {});
+ }
+
+ protected _getCardType(): string {
+ return CARD_TYPE;
+ }
+
+ protected _normalizeEditorConfig(
+ config: Partial,
+ ): SolarPanelVisualizerConfig {
+ return normalizeConfig(config);
+ }
+
+ protected _renderExtraSections() {
+ return nothing;
+ }
+
+ protected _t(key: TranslationKey, params?: Record): string {
+ return translate(this.hass, key, params);
+ }
+
+ protected render() {
+ if (!this.hass) {
+ return nothing;
+ }
+ const hasForecastPowerDefault = Boolean(
+ this.hass.states["sensor.power_production_now"],
+ );
+ const hasForecastEnergyDefault = Boolean(
+ this.hass.states["sensor.energy_production_today"],
+ );
+ const forecastStatus = hasForecastPowerDefault && hasForecastEnergyDefault
+ ? this._t("editor.forecast.detected")
+ : this._t("editor.forecast.missing");
+
+ return html`
+
+
+
+
+
+ ${this._renderTextField("title", this._t("editor.field.title"), this._config.title ?? "")}
+ ${this._renderNumberField("rows", this._t("editor.field.rows"), this._config.rows, 1, MAX_LAYOUT_DIMENSION)}
+ ${this._renderNumberField(
+ "columns",
+ this._t("editor.field.columns"),
+ this._config.columns,
+ 1,
+ MAX_LAYOUT_DIMENSION,
+ )}
+ ${this._renderNumberField(
+ "max_card_width_px",
+ this._t("editor.field.max_card_width"),
+ this._config.max_card_width_px ?? 980,
+ 300,
+ 2400,
+ )}
+ ${this._renderOptionalNumberField(
+ "max_card_height_px",
+ this._t("editor.field.max_card_height"),
+ this._config.max_card_height_px,
+ 300,
+ 2600,
+ )}
+
+
+
+
+
+
+ ${this._renderSelectField(
+ "theme_mode",
+ this._t("editor.field.theme_mode"),
+ this._config.theme_mode ?? "auto",
+ [
+ { value: "auto", label: this._t("editor.select.theme_auto") },
+ { value: "dark", label: this._t("editor.select.theme_dark") },
+ { value: "light", label: this._t("editor.select.theme_light") },
+ ],
+ )}
+
+
+
+
+
+
+ ${this._renderNumberField(
+ "power_decimals",
+ this._t("editor.field.power_decimals"),
+ this._config.power_decimals ?? 0,
+ 0,
+ 4,
+ )}
+ ${this._renderNumberField(
+ "energy_decimals",
+ this._t("editor.field.energy_decimals"),
+ this._config.energy_decimals ?? 2,
+ 0,
+ 4,
+ )}
+ ${this._renderNumberField(
+ "custom_kpi_decimals",
+ this._t("editor.field.custom_kpi_decimals"),
+ this._config.custom_kpi_decimals ?? 0,
+ 0,
+ 4,
+ )}
+ ${this._renderSelectField(
+ "panel_tap_action",
+ this._t("editor.field.panel_tap_action"),
+ this._config.panel_tap_action ?? "details",
+ [
+ { value: "details", label: this._t("editor.select.panel_tap_details") },
+ { value: "none", label: this._t("editor.select.panel_tap_none") },
+ ],
+ )}
+
+
+
+ this._updateRootValue(
+ "use_system_power_entity",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.use_system_power")}
+
+ ${this._renderSelectorWithClear(
+ this._t("editor.field.system_power_sensor"),
+ this._config.system_power_entity,
+ {
+ entity: {
+ domain: "sensor",
+ },
+ },
+ (nextValue) => this._updateRootValue("system_power_entity", nextValue),
+ )}
+
+
+ this._updateRootValue(
+ "invert_system_power",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.invert_system_power")}
+
+
+
+ this._updateRootValue(
+ "use_system_energy_entity",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.use_system_energy")}
+
+ ${this._renderSelectorWithClear(
+ this._t("editor.field.system_energy_sensor"),
+ this._config.system_energy_entity,
+ {
+ entity: {
+ domain: "sensor",
+ },
+ },
+ (nextValue) => this._updateRootValue("system_energy_entity", nextValue),
+ )}
+ ${this._renderSelectorWithClear(
+ this._t("editor.field.custom_kpi_sensor"),
+ this._config.custom_kpi_entity,
+ {
+ entity: {
+ domain: "sensor",
+ },
+ },
+ (nextValue) => this._updateRootValue("custom_kpi_entity", nextValue),
+ )}
+
+
+ this._updateRootValue(
+ "invert_custom_kpi",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.invert_custom_kpi")}
+
+ ${this._renderTextField(
+ "custom_kpi_title",
+ this._t("editor.field.custom_kpi_heading"),
+ this._config.custom_kpi_title ?? "",
+ )}
+
+
+ this._updateRootValue(
+ "show_custom_kpi",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.show_custom_kpi")}
+
+
+
+ this._updateRootValue(
+ "limit_panel_width",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.limit_panel_width")}
+
+ ${this._renderEditorInput(
+ this._t("editor.field.max_panel_tile_width"),
+ String(this._config.panel_max_width_px ?? 220),
+ (value) =>
+ this._updateRootValue(
+ "panel_max_width_px",
+ this._parseNumberWithClamp(
+ value,
+ this._config.panel_max_width_px ?? 220,
+ 120,
+ 320,
+ ),
+ ),
+ {
+ type: "number",
+ min: 120,
+ max: 320,
+ disabled: !(this._config.limit_panel_width ?? false),
+ updateOn: "change",
+ },
+ )}
+
+
+
+
+
+
+ this._updateRootValue(
+ "enable_forecast_overlay",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.enable_forecast_overlay")}
+
+ ${forecastStatus}
+
+
+
+
+
+
+ this._updateRootValue(
+ "enable_array_checks",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.enable_array_health")}
+
+
+ ${this._t("editor.section.array_health_smoothing_help")}
+
+
+ ${this._renderNumberField(
+ "deviation_threshold_percent",
+ this._t("editor.field.deviation_threshold"),
+ this._config.deviation_threshold_percent ?? 15,
+ 1,
+ 100,
+ )}
+ ${this._renderNumberField(
+ "deviation_absolute_w_threshold",
+ this._t("editor.field.deviation_absolute_shortfall"),
+ this._config.deviation_absolute_w_threshold ?? 50,
+ 0,
+ 5000,
+ )}
+ ${this._renderNumberField(
+ "deviation_min_runtime_minutes",
+ this._t("editor.field.deviation_check_time"),
+ this._config.deviation_min_runtime_minutes ?? 15,
+ 0,
+ 1440,
+ )}
+ ${this._renderNumberField(
+ "deviation_min_active_panels",
+ this._t("editor.field.deviation_min_active_panels"),
+ this._config.deviation_min_active_panels ?? 3,
+ 2,
+ 30,
+ )}
+ ${this._renderNumberField(
+ "deviation_min_samples",
+ this._t("editor.field.deviation_min_samples"),
+ this._config.deviation_min_samples ?? 3,
+ 1,
+ 120,
+ )}
+ ${this._renderNumberField(
+ "deviation_smoothing_minutes",
+ this._t("editor.field.deviation_smoothing"),
+ this._config.deviation_smoothing_minutes ?? 0,
+ 0,
+ 1440,
+ )}
+ ${this._renderNumberField(
+ "deviation_dynamic_floor_w",
+ this._t("editor.field.deviation_dynamic_floor"),
+ this._config.deviation_dynamic_floor_w ?? 20,
+ 0,
+ 5000,
+ )}
+ ${this._renderNumberField(
+ "deviation_history_hours",
+ this._t("editor.field.deviation_history_window"),
+ this._config.deviation_history_hours ?? 12,
+ 1,
+ 168,
+ )}
+
+
+
+
+
+
+
+ this._updateRootValue(
+ "enable_inverter_status",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.enable_inverter_status")}
+
+ this._updateRootValue(
+ "show_inverter_status_on_tiles",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.show_inverter_status_tiles")}
+
+ ${this._renderEditorInput(
+ this._t("editor.field.fault_terms"),
+ (this._config.inverter_fault_terms ?? []).join(", "),
+ (value) =>
+ this._updateRootValue(
+ "inverter_fault_terms",
+ value,
+ ),
+ )}
+ ${this._renderEditorInput(
+ this._t("editor.field.working_terms"),
+ (this._config.inverter_working_terms ?? []).join(", "),
+ (value) =>
+ this._updateRootValue(
+ "inverter_working_terms",
+ value,
+ ),
+ )}
+
+ ${this._t("editor.helper.fault_example")}
+
+
+ ${this._t("editor.helper.working_example")}
+
+
+
+
+
+
+ ${this._renderColorField("production_start", this._t("editor.field.production_base"))}
+ ${this._renderColorField("production_mid", this._t("editor.field.production_mid"))}
+ ${this._renderColorField("production_end", this._t("editor.field.production_peak"))}
+ ${this._renderColorField("deviation", this._t("editor.field.deviation_color"))}
+ ${this._renderColorField("error", this._t("editor.field.error_color"))}
+ ${this._renderColorField("unavailable", this._t("editor.field.unavailable_color"))}
+
+
+
+
+
+
+
+
+ this._updateRootValue(
+ "motion_enabled",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.motion_enabled")}
+
+
+
+ this._updateRootValue(
+ "motion_power_flow",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.motion_power_flow")}
+
+
+
+ this._updateRootValue(
+ "motion_update_shimmer",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.motion_update_shimmer")}
+
+
+
+ this._updateRootValue(
+ "motion_alert_ripple",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.motion_alert_ripple")}
+
+
+
+
+
+
+ ${this._renderStringSetupSection()}
+
+
+
+
+ ${this._renderOptionalNumberField(
+ "default_panel_rated_power_w",
+ this._t("editor.field.default_panel_rated_power"),
+ this._config.default_panel_rated_power_w,
+ 1,
+ MAX_PANEL_RATED_POWER_W,
+ )}
+
+
+
+
+
+
+
+
+
+ ${this._renderEditorInput(
+ this._t("editor.field.power_prefix"),
+ this._autoFillPowerPrefix,
+ (value) => {
+ this._autoFillPowerPrefix = value;
+ },
+ )}
+ ${this._renderEditorInput(
+ this._t("editor.field.energy_prefix"),
+ this._autoFillEnergyPrefix,
+ (value) => {
+ this._autoFillEnergyPrefix = value;
+ },
+ )}
+ ${this._renderEditorInput(
+ this._t("editor.field.status_prefix"),
+ this._autoFillStatusPrefix,
+ (value) => {
+ this._autoFillStatusPrefix = value;
+ },
+ )}
+ ${this._renderAutoFillSortModeField()}
+
+
+ ${this._t("editor.helper.autofill_search_help")}
+
+
+
+ ${this._t("editor.helper.autofill_inverter_status_help")}
+
+
+
+ ${this._t("editor.helper.autofill_advanced_help")}
+
+
+
+
+
+ ${this._autoFillResultMessage
+ ? html`
${this._autoFillResultMessage}
`
+ : nothing}
+
+
+
+ ${this._config.panels.map((panel, index) =>
+ this._renderPanelEditor(panel, index),
+ )}
+
+
+
+ ${this._renderExtraSections()}
+
+ `;
+ }
+
+ private _renderStringSetupSection() {
+ const enabled = this._config.enable_string_sensor_sharing ?? false;
+ const groups = this._getStringGroupsForEditor();
+
+ return html`
+
+
+
+
+ this._handleStringSensorSharingToggle(
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.toggle.enable_string_sensor_sharing")}
+
+
+ ${this._t("editor.helper.string_sensor_sharing_help")}
+
+ ${enabled
+ ? html`
+
+ ${this._renderEditorInput(
+ this._t("editor.field.string_group_count"),
+ String(groups.length),
+ (value) => this._updateStringGroupCount(value),
+ {
+ type: "number",
+ min: 1,
+ max: MAX_LAYOUT_DIMENSION,
+ updateOn: "change",
+ },
+ )}
+
+
+ ${groups.map((group, index) =>
+ this._renderStringGroupEditor(group, index),
+ )}
+
+ `
+ : nothing}
+
+ `;
+ }
+
+ private _renderStringGroupEditor(group: StringGroupConfig, index: number) {
+ const powerSensorEntityIds = this._getStringSensorEntityIdsByUnit(
+ group.power_entity,
+ entityMatchesPanelPower,
+ );
+ const energySensorEntityIds = this._getStringSensorEntityIdsByUnit(
+ group.energy_entity,
+ entityMatchesPanelEnergy,
+ );
+ const title = group.name ?? `String ${index + 1}`;
+
+ return html`
+
+
+ ${title}
+
+ ${this._t("editor.string_group.panel_count_chip", {
+ count: group.panel_count,
+ })}
+
+
+
+
+ ${this._renderEditorInput(
+ this._t("editor.field.string_group_name"),
+ group.name ?? "",
+ (value) => this._updateStringGroupValue(index, "name", value),
+ { updateOn: "change" },
+ )}
+ ${this._renderEditorInput(
+ this._t("editor.field.string_group_panel_count"),
+ String(group.panel_count),
+ (value) =>
+ this._updateStringGroupValue(
+ index,
+ "panel_count",
+ this._parseNumberWithClamp(
+ value,
+ group.panel_count,
+ 1,
+ MAX_LAYOUT_DIMENSION,
+ ),
+ ),
+ {
+ type: "number",
+ min: 1,
+ max: MAX_LAYOUT_DIMENSION,
+ updateOn: "change",
+ },
+ )}
+
+ ${this._renderSelectorWithClear(
+ this._t("editor.field.string_group_power_sensor"),
+ group.power_entity,
+ {
+ entity: {
+ domain: "sensor",
+ include_entities: powerSensorEntityIds,
+ },
+ },
+ (value) => this._updateStringGroupValue(index, "power_entity", value),
+ )}
+ ${this._renderSelectorWithClear(
+ this._t("editor.field.string_group_energy_sensor"),
+ group.energy_entity,
+ {
+ entity: {
+ domain: "sensor",
+ include_entities: energySensorEntityIds,
+ },
+ },
+ (value) => this._updateStringGroupValue(index, "energy_entity", value),
+ )}
+
+
+ `;
+ }
+
+ private _renderPanelEditor(panel: PanelConfig, index: number) {
+ const powerSensorEntityIds = this._getAvailableSensorEntityIdsByUnit(
+ index,
+ "power_entity",
+ entityMatchesPanelPower,
+ );
+ const energySensorEntityIds = this._getAvailableSensorEntityIdsByUnit(
+ index,
+ "energy_entity",
+ entityMatchesPanelEnergy,
+ );
+ return html`
+
+
+ ${this._formatPanelSlotTitle(index)}
+ ${panel.name ?? panel.id}
+
+
+ ${this._renderPanelTextField(index, "name", this._t("editor.field.display_name"), panel.name ?? "")}
+ ${this._renderPanelSensorSelector(
+ index,
+ "power_entity",
+ this._t("editor.field.power_sensor"),
+ panel.power_entity,
+ powerSensorEntityIds,
+ )}
+ ${this._renderPanelSensorSelector(
+ index,
+ "energy_entity",
+ this._t("editor.field.energy_sensor"),
+ panel.energy_entity,
+ energySensorEntityIds,
+ )}
+
+
+ this._updatePanelValue(
+ index,
+ "show_energy",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.field.panel_energy_toggle")}
+
+ ${this._renderPanelOptionalNumberField(
+ index,
+ "rated_power_w",
+ this._t("editor.field.panel_rated_power"),
+ panel.rated_power_w,
+ 1,
+ MAX_PANEL_RATED_POWER_W,
+ )}
+ ${this._renderPanelNumberField(
+ index,
+ "deviation_derate_percent",
+ this._t("editor.field.deviation_derate"),
+ panel.deviation_derate_percent ?? 100,
+ 1,
+ 100,
+ )}
+
+ ${this._t("editor.helper.derate_help")}
+
+ ${this._renderPanelEntityPicker(
+ index,
+ "inverter_status_entity",
+ this._t("editor.field.inverter_status_sensor"),
+ panel.inverter_status_entity,
+ ["sensor", "binary_sensor"],
+ )}
+ ${this._renderPanelAdvancedSection(index, panel)}
+
+
+ this._updatePanelValue(
+ index,
+ "enabled",
+ (event.currentTarget as HTMLInputElement).checked,
+ )}
+ >${this._t("editor.field.panel_enabled_toggle")}
+
+
+
+ `;
+ }
+
+ private _renderPanelAdvancedSection(index: number, panel: PanelConfig) {
+ return html`
+
+ ${this._t("editor.section.advanced_title")}
+
+
+ ${this._t("editor.section.advanced_copy")}
+
+ ${ADVANCED_METRIC_FIELDS.map((field) =>
+ this._renderPanelAdvancedSensorSelector(
+ index,
+ field.key,
+ this._t(field.labelKey),
+ panel.advanced_metrics?.[field.key],
+ this._getAdvancedSensorEntityIds(index, field.key, field.matcher),
+ ),
+ )}
+
+
+ `;
+ }
+
+ private _renderPanelAdvancedSensorSelector(
+ index: number,
+ key: AdvancedMetricKey,
+ label: string,
+ value: string | undefined,
+ includeEntities: string[],
+ ) {
+ return this._renderSelectorWithClear(
+ label,
+ value,
+ {
+ entity: {
+ domain: "sensor",
+ include_entities: includeEntities,
+ },
+ },
+ (nextValue) => this._updatePanelAdvancedMetricValue(index, key, nextValue),
+ );
+ }
+
+ private _formatPanelSlotTitle(index: number): string {
+ const row = Math.floor(index / this._config.columns) + 1;
+ const column = (index % this._config.columns) + 1;
+ return this._t("editor.panel_slot_title", { row, column });
+ }
+
+ private _renderPanelEntityPicker(
+ index: number,
+ key: keyof PanelConfig,
+ label: string,
+ value: string | undefined,
+ includeDomains: string[],
+ ) {
+ return this._renderSelectorWithClear(
+ label,
+ value,
+ {
+ entity: {
+ domain: includeDomains,
+ },
+ },
+ (nextValue) => this._updatePanelValue(index, key, nextValue),
+ );
+ }
+
+ private _renderTextField(key: keyof SolarPanelVisualizerConfig, label: string, value: string) {
+ return this._renderEditorInput(
+ label,
+ value,
+ (nextValue) => this._updateRootValue(key, nextValue),
+ );
+ }
+
+ private _renderPanelSensorSelector(
+ index: number,
+ key: "power_entity" | "energy_entity",
+ label: string,
+ value: string | undefined,
+ includeEntities: string[],
+ ) {
+ return this._renderSelectorWithClear(
+ label,
+ value,
+ {
+ entity: {
+ domain: "sensor",
+ include_entities: includeEntities,
+ },
+ },
+ (nextValue) => this._updatePanelValue(index, key, nextValue),
+ );
+ }
+
+ private _renderSelectorWithClear(
+ label: string,
+ value: string | undefined,
+ selector: Record,
+ onValueChanged: (value: string) => void,
+ ) {
+ return html`
+
+ onValueChanged(
+ typeof event.detail?.value === "string" ? event.detail.value : "",
+ )}
+ >
+ `;
+ }
+
+ private _renderNumberField(
+ key: keyof SolarPanelVisualizerConfig,
+ label: string,
+ value: number,
+ min: number,
+ max: number,
+ ) {
+ return this._renderEditorInput(
+ label,
+ String(value),
+ (nextValue) =>
+ this._updateRootValue(
+ key,
+ this._parseNumberWithClamp(nextValue, value, min, max),
+ ),
+ { type: "number", min, max, updateOn: "change" },
+ );
+ }
+
+ private _renderOptionalNumberField(
+ key: keyof SolarPanelVisualizerConfig,
+ label: string,
+ value: number | undefined,
+ min: number,
+ max: number,
+ ) {
+ return this._renderEditorInput(
+ label,
+ value !== undefined ? String(value) : "",
+ (nextValue) =>
+ this._updateRootValue(
+ key,
+ this._parseOptionalNumber(nextValue, min, max),
+ ),
+ { type: "number", min, max, updateOn: "change" },
+ );
+ }
+
+ private _renderPanelTextField(
+ index: number,
+ key: keyof PanelConfig,
+ label: string,
+ value: string,
+ ) {
+ return this._renderEditorInput(
+ label,
+ value,
+ (nextValue) => this._updatePanelValue(index, key, nextValue),
+ );
+ }
+
+ private _renderPanelOptionalNumberField(
+ index: number,
+ key: keyof PanelConfig,
+ label: string,
+ value: number | undefined,
+ min: number,
+ max: number,
+ ) {
+ return this._renderEditorInput(
+ label,
+ value !== undefined ? String(value) : "",
+ (nextValue) =>
+ this._updatePanelValue(
+ index,
+ key,
+ this._parseOptionalNumber(nextValue, min, max),
+ ),
+ { type: "number", min, max, updateOn: "change" },
+ );
+ }
+
+ private _renderPanelNumberField(
+ index: number,
+ key: keyof PanelConfig,
+ label: string,
+ value: number,
+ min: number,
+ max: number,
+ ) {
+ return this._renderEditorInput(
+ label,
+ String(value),
+ (nextValue) =>
+ this._updatePanelValue(
+ index,
+ key,
+ this._parseNumberWithClamp(nextValue, value, min, max),
+ ),
+ { type: "number", min, max, updateOn: "change" },
+ );
+ }
+
+ private _renderEditorInput(
+ label: string,
+ value: string,
+ onValueChanged: (value: string) => void,
+ options: {
+ type?: "text" | "number";
+ min?: number;
+ max?: number;
+ disabled?: boolean;
+ updateOn?: "input" | "change";
+ } = {},
+ ) {
+ const updateOn = options.updateOn ?? "input";
+ const handleInput = (event: Event): void => {
+ if (updateOn === "input") {
+ onValueChanged((event.currentTarget as HTMLInputElement).value);
+ }
+ };
+ const handleChange = (event: Event): void => {
+ if (updateOn === "change") {
+ onValueChanged((event.currentTarget as HTMLInputElement).value);
+ }
+ };
+
+ return html`
+
+ `;
+ }
+
+ private _renderSelectField(
+ key: keyof SolarPanelVisualizerConfig,
+ label: string,
+ value: string,
+ options: Array<{ value: string; label: string }>,
+ ) {
+ return html`
+
+ `;
+ }
+
+ private _renderAutoFillSortModeField() {
+ const options: Array<{ value: AutoPopulateSortMode; label: string }> = [
+ { value: "auto", label: this._t("editor.select.autofill_sort_auto") },
+ { value: "entityId", label: this._t("editor.select.autofill_sort_entity") },
+ { value: "friendlyName", label: this._t("editor.select.autofill_sort_friendly") },
+ { value: "grouped", label: this._t("editor.select.autofill_sort_grouped") },
+ ];
+
+ return html`
+
+ `;
+ }
+
+ private _renderColorField(
+ key: keyof NonNullable,
+ label: string,
+ ) {
+ const current = this._config.colors?.[key] ?? "";
+ return html`
+
+ `;
+ }
+
+ protected _updateRootValue(key: keyof SolarPanelVisualizerConfig, value: unknown): void {
+ const nextConfig = this._normalizeEditorConfig({
+ ...this._config,
+ [key]: value,
+ });
+ this._commit(nextConfig);
+ }
+
+ private _updateColor(
+ key: keyof NonNullable,
+ value: string,
+ ): void {
+ const nextConfig = this._normalizeEditorConfig({
+ ...this._config,
+ colors: {
+ ...(this._config.colors ?? {}),
+ [key]: value,
+ },
+ });
+ this._commit(nextConfig);
+ }
+
+ private _getStringGroupsForEditor(): StringGroupConfig[] {
+ const groups = this._config.string_groups ?? [];
+ return groups.length > 0 ? groups : this._buildStringGroupsFromCurrentPanels();
+ }
+
+ private _buildStringGroupsFromCurrentPanels(): StringGroupConfig[] {
+ return Array.from({ length: Math.max(this._config.rows, 1) }, (_, rowIndex) => {
+ const startIndex = rowIndex * this._config.columns;
+ const rowPanels = this._config.panels.slice(
+ startIndex,
+ startIndex + this._config.columns,
+ );
+ const enabledPanels = rowPanels.filter((panel) => panel.enabled !== false);
+ const firstPowerPanel = enabledPanels.find((panel) => panel.power_entity);
+ const firstEnergyPanel = enabledPanels.find((panel) => panel.energy_entity);
+ return {
+ id: `string-${rowIndex + 1}`,
+ name: `String ${rowIndex + 1}`,
+ panel_count: Math.min(
+ Math.max(enabledPanels.length || this._config.columns || 1, 1),
+ MAX_LAYOUT_DIMENSION,
+ ),
+ power_entity: firstPowerPanel?.power_entity,
+ energy_entity: firstEnergyPanel?.energy_entity,
+ };
+ });
+ }
+
+ private _makeDefaultStringGroup(index: number): StringGroupConfig {
+ return {
+ id: `string-${index + 1}`,
+ name: `String ${index + 1}`,
+ panel_count: Math.min(Math.max(this._config.columns || 3, 1), MAX_LAYOUT_DIMENSION),
+ };
+ }
+
+ private _handleStringSensorSharingToggle(enabled: boolean): void {
+ const stringGroups =
+ enabled && (this._config.string_groups?.length ?? 0) === 0
+ ? this._buildStringGroupsFromCurrentPanels()
+ : this._config.string_groups ?? [];
+ this._commit(
+ this._normalizeEditorConfig({
+ ...this._config,
+ enable_string_sensor_sharing: enabled,
+ string_groups: stringGroups,
+ }),
+ );
+ }
+
+ private _updateStringGroupCount(value: string): void {
+ const nextCount = this._parseNumberWithClamp(
+ value,
+ this._getStringGroupsForEditor().length || 1,
+ 1,
+ MAX_LAYOUT_DIMENSION,
+ );
+ const currentGroups = this._getStringGroupsForEditor();
+ const nextGroups = Array.from({ length: nextCount }, (_, index) =>
+ currentGroups[index] ?? this._makeDefaultStringGroup(index),
+ );
+ this._commitStringGroups(nextGroups);
+ }
+
+ private _updateStringGroupValue(
+ index: number,
+ key: keyof StringGroupConfig,
+ value: unknown,
+ ): void {
+ const nextGroups = this._getStringGroupsForEditor().map((group, groupIndex) => {
+ if (groupIndex !== index) {
+ return group;
+ }
+ return {
+ ...group,
+ [key]: value,
+ } as StringGroupConfig;
+ });
+ this._commitStringGroups(nextGroups);
+ }
+
+ private _commitStringGroups(groups: StringGroupConfig[]): void {
+ this._commit(
+ this._normalizeEditorConfig({
+ ...this._config,
+ enable_string_sensor_sharing: true,
+ string_groups: groups,
+ }),
+ );
+ }
+
+ protected _updatePanelValue(index: number, key: keyof PanelConfig, value: unknown): void {
+ const panels = this._config.panels.map((panel, panelIndex) => {
+ if (panelIndex !== index) {
+ return panel;
+ }
+
+ const nextPanel: PanelConfig = { ...panel, [key]: value };
+ if (key === "power_entity") {
+ const previousEntityId = panel.power_entity;
+ const nextEntityId = typeof value === "string" ? value.trim() : "";
+ if (nextEntityId.length > 0) {
+ const nextFriendlyName = this._getEntityFriendlyName(nextEntityId);
+ if (
+ nextFriendlyName &&
+ this._shouldAutoRenamePanel(panel.name, panel.id, previousEntityId)
+ ) {
+ nextPanel.name = nextFriendlyName;
+ }
+ }
+ }
+ if (key === "enabled" && value === false) {
+ nextPanel.power_entity = undefined;
+ nextPanel.energy_entity = undefined;
+ nextPanel.show_energy = false;
+ nextPanel.inverter_status_entity = undefined;
+ nextPanel.error_entity = undefined;
+ nextPanel.advanced_metrics = undefined;
+ }
+ return nextPanel;
+ });
+
+ const nextConfig = this._normalizeEditorConfig({
+ ...this._config,
+ panels,
+ });
+
+ this._commit(nextConfig);
+ }
+
+ private _updatePanelAdvancedMetricValue(
+ index: number,
+ key: AdvancedMetricKey,
+ value: unknown,
+ ): void {
+ const normalizedValue =
+ typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
+ const panels = this._config.panels.map((panel, panelIndex) => {
+ if (panelIndex !== index) {
+ return panel;
+ }
+
+ const nextAdvanced: NonNullable = {
+ ...(panel.advanced_metrics ?? {}),
+ };
+ nextAdvanced[key] = normalizedValue;
+
+ if (
+ ADVANCED_METRIC_FIELDS.every(
+ (field) => !nextAdvanced[field.key] || nextAdvanced[field.key]?.trim().length === 0,
+ )
+ ) {
+ return {
+ ...panel,
+ advanced_metrics: undefined,
+ };
+ }
+
+ return {
+ ...panel,
+ advanced_metrics: nextAdvanced,
+ };
+ });
+
+ const nextConfig = this._normalizeEditorConfig({
+ ...this._config,
+ panels,
+ });
+ this._commit(nextConfig);
+ }
+
+ private _applyDefaultRatedPowerToAllPanels = (): void => {
+ const ratedPower = this._config.default_panel_rated_power_w;
+ if (!ratedPower) {
+ return;
+ }
+
+ const panels = this._config.panels.map((panel) => ({
+ ...panel,
+ rated_power_w: ratedPower,
+ }));
+
+ this._commit(
+ this._normalizeEditorConfig({
+ ...this._config,
+ panels,
+ }),
+ );
+ };
+
+ private _handleAutoPopulateSensors = (): void => {
+ if (!this.hass) {
+ return;
+ }
+
+ const powerPrefix = this._autoFillPowerPrefix.trim();
+ const energyPrefix = this._autoFillEnergyPrefix.trim();
+ const statusPrefix = this._autoFillStatusPrefix.trim();
+ if (
+ !powerPrefix &&
+ !energyPrefix &&
+ !this._autoFillInverterStatus &&
+ !this._autoFillAdvancedTelemetry
+ ) {
+ this._autoFillResultMessage = this._t("editor.autofill.enter_prefix");
+ return;
+ }
+
+ let panels = [...this._config.panels];
+ const summaries: string[] = [];
+
+ if (powerPrefix) {
+ panels = panels.map((panel) => ({ ...panel, power_entity: undefined }));
+ const powerResult = autoPopulatePanelEntities(
+ panels,
+ this.hass.states,
+ "power_entity",
+ powerPrefix,
+ entityMatchesPanelPower,
+ this._autoFillSortMode,
+ );
+ panels = powerResult.panels;
+ summaries.push(
+ this._t("editor.autofill.power_summary", {
+ matched: powerResult.matched,
+ filled: powerResult.filled,
+ skipped: powerResult.skipped,
+ }),
+ );
+ }
+
+ if (energyPrefix) {
+ panels = panels.map((panel) => ({ ...panel, energy_entity: undefined }));
+ const energyResult = autoPopulateEnergyEntitiesByPowerAnchor(
+ panels,
+ this.hass.states,
+ energyPrefix,
+ entityMatchesPanelEnergy,
+ this._autoFillSortMode,
+ {
+ shareSingleGroupEnergy: this._config.enable_string_sensor_sharing ?? false,
+ },
+ );
+ panels = energyResult.panels;
+ summaries.push(
+ this._t("editor.autofill.energy_summary", {
+ matched: energyResult.matched,
+ filled: energyResult.filled,
+ skipped: energyResult.skipped,
+ }),
+ );
+ }
+
+ panels = applyAutoPopulateFriendlyNames(
+ panels,
+ (entityId) => this._getEntityFriendlyName(entityId),
+ );
+
+ if (this._autoFillInverterStatus) {
+ const statusResult = autoPopulateInverterStatusEntities(
+ panels,
+ this.hass.states,
+ statusPrefix,
+ );
+ panels = statusResult.panels;
+ summaries.push(
+ this._t("editor.autofill.inverter_status_summary", {
+ filled: statusResult.filled,
+ skipped: statusResult.skippedPanels,
+ }),
+ );
+ }
+
+ if (this._autoFillAdvancedTelemetry) {
+ const advancedResult = autoPopulateAdvancedTelemetry(
+ panels,
+ this.hass.states,
+ );
+ panels = advancedResult.panels;
+ summaries.push(
+ this._t("editor.autofill.advanced_summary", {
+ filled: advancedResult.filled,
+ skipped: advancedResult.skippedPanels,
+ }),
+ );
+ }
+
+ this._autoFillResultMessage = summaries.join(" ");
+ this._commit(
+ this._normalizeEditorConfig({
+ ...this._config,
+ panels,
+ }),
+ );
+ };
+
+ private _handleRemoveAllPanelSensors = (): void => {
+ const panels = this._config.panels.map((panel) => ({
+ ...panel,
+ power_entity: undefined,
+ energy_entity: undefined,
+ show_energy: false,
+ inverter_status_entity: undefined,
+ error_entity: undefined,
+ advanced_metrics: undefined,
+ }));
+ this._autoFillResultMessage =
+ this._t("editor.autofill.cleared");
+ this._commit(
+ this._normalizeEditorConfig({
+ ...this._config,
+ panels,
+ }),
+ );
+ };
+
+ private _parseOptionalNumber(
+ value: string,
+ min: number,
+ max: number,
+ ): number | undefined {
+ if (value.trim() === "") {
+ return undefined;
+ }
+
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) {
+ return undefined;
+ }
+ return Math.min(Math.max(numeric, min), max);
+ }
+
+ private _parseNumberWithClamp(
+ value: string,
+ fallback: number,
+ min: number,
+ max: number,
+ ): number {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) {
+ return fallback;
+ }
+ return Math.min(Math.max(numeric, min), max);
+ }
+
+ private _getAvailableSensorEntityIdsByUnit(
+ targetPanelIndex: number,
+ key: "power_entity" | "energy_entity",
+ matcher: (normalizedUnit: string, entity: HassEntity) => boolean,
+ ): string[] {
+ if (!this.hass) {
+ return [];
+ }
+
+ const allowDuplicatePanelSensors = this._config.enable_string_sensor_sharing ?? false;
+ const usedByOthers = allowDuplicatePanelSensors
+ ? new Set()
+ : new Set(
+ this._config.panels
+ .map((panel, index) => (index === targetPanelIndex ? undefined : panel[key]))
+ .filter((entityId): entityId is string => typeof entityId === "string" && entityId.length > 0),
+ );
+ const selectedByCurrent = this._config.panels[targetPanelIndex]?.[key];
+
+ const options: string[] = [];
+ for (const [stateEntityId, entity] of Object.entries(this.hass.states)) {
+ const entityId = getStateEntityId(stateEntityId, entity);
+ if (!entityId.startsWith("sensor.")) {
+ continue;
+ }
+
+ const unitRaw = entity.attributes?.unit_of_measurement;
+ const normalizedUnit = typeof unitRaw === "string" ? unitRaw.trim().toLowerCase() : "";
+ if (!matcher(normalizedUnit, entity)) {
+ continue;
+ }
+
+ if (
+ usedByOthers.has(entityId) &&
+ entityId !== selectedByCurrent
+ ) {
+ continue;
+ }
+
+ options.push(entityId);
+ }
+
+ if (
+ typeof selectedByCurrent === "string" &&
+ selectedByCurrent.length > 0 &&
+ !options.includes(selectedByCurrent)
+ ) {
+ options.push(selectedByCurrent);
+ }
+
+ return options.sort(naturalCompare);
+ }
+
+ private _getStringSensorEntityIdsByUnit(
+ selectedEntityId: string | undefined,
+ matcher: (normalizedUnit: string, entity: HassEntity) => boolean,
+ ): string[] {
+ if (!this.hass) {
+ return [];
+ }
+
+ const options: string[] = [];
+ for (const [stateEntityId, entity] of Object.entries(this.hass.states)) {
+ const entityId = getStateEntityId(stateEntityId, entity);
+ if (!entityId.startsWith("sensor.")) {
+ continue;
+ }
+
+ const unitRaw = entity.attributes?.unit_of_measurement;
+ const normalizedUnit = typeof unitRaw === "string" ? unitRaw.trim().toLowerCase() : "";
+ if (!matcher(normalizedUnit, entity)) {
+ continue;
+ }
+
+ options.push(entityId);
+ }
+
+ if (
+ typeof selectedEntityId === "string" &&
+ selectedEntityId.length > 0 &&
+ !options.includes(selectedEntityId)
+ ) {
+ options.push(selectedEntityId);
+ }
+
+ return options.sort(naturalCompare);
+ }
+
+ private _getAdvancedSensorEntityIds(
+ targetPanelIndex: number,
+ key: AdvancedMetricKey,
+ matcher: (entity: HassEntity) => boolean,
+ ): string[] {
+ if (!this.hass) {
+ return [];
+ }
+
+ const selectedByCurrent = this._config.panels[targetPanelIndex]?.advanced_metrics?.[key];
+ const options: string[] = [];
+ for (const [stateEntityId, entity] of Object.entries(this.hass.states)) {
+ const entityId = getStateEntityId(stateEntityId, entity);
+ if (!entityId.startsWith("sensor.")) {
+ continue;
+ }
+ if (!matcher(entity)) {
+ continue;
+ }
+ options.push(entityId);
+ }
+
+ if (
+ typeof selectedByCurrent === "string" &&
+ selectedByCurrent.length > 0 &&
+ !options.includes(selectedByCurrent)
+ ) {
+ options.push(selectedByCurrent);
+ }
+
+ return options.sort(naturalCompare);
+ }
+
+ private _getEntityFriendlyName(entityId: string | undefined): string | undefined {
+ const normalizedEntityId = entityId?.trim();
+ if (!this.hass || !normalizedEntityId) {
+ return undefined;
+ }
+ const state = this.hass.states[normalizedEntityId];
+ const friendlyName = state?.attributes?.friendly_name;
+ if (typeof friendlyName !== "string") {
+ return undefined;
+ }
+ const normalized = friendlyName.trim();
+ return normalized.length > 0 ? normalized : undefined;
+ }
+
+ private _shouldAutoRenamePanel(
+ currentName: string | undefined,
+ panelId: string,
+ previousPowerEntity: string | undefined,
+ ): boolean {
+ return shouldAutoRenamePanelName(
+ currentName,
+ panelId,
+ previousPowerEntity,
+ (entityId) => this._getEntityFriendlyName(entityId),
+ );
+ }
+
+ protected _commit(config: SolarPanelVisualizerConfig): void {
+ this._config = config;
+ fireConfigChanged(this, config);
+ }
+
+ private _handleCardConfigSync = (event: Event): void => {
+ const customEvent = event as CustomEvent<{ config?: unknown }>;
+ const rawConfig = customEvent.detail?.config;
+ if (!rawConfig || typeof rawConfig !== "object") {
+ return;
+ }
+
+ const incoming = this._normalizeEditorConfig(
+ rawConfig as Partial,
+ );
+ if (!this._isReorderOnlySync(incoming)) {
+ return;
+ }
+
+ this._commit(incoming);
+ };
+
+ protected _isReorderOnlySync(incoming: SolarPanelVisualizerConfig): boolean {
+ if (incoming.type !== this._getCardType()) {
+ return false;
+ }
+
+ if (
+ incoming.rows !== this._config.rows ||
+ incoming.columns !== this._config.columns ||
+ incoming.panels.length !== this._config.panels.length
+ ) {
+ return false;
+ }
+
+ const currentById = this._toPanelSignatureMap(this._config.panels);
+ const incomingById = this._toPanelSignatureMap(incoming.panels);
+ if (currentById.size !== incomingById.size) {
+ return false;
+ }
+
+ for (const [panelId, signature] of currentById.entries()) {
+ if (incomingById.get(panelId) !== signature) {
+ return false;
+ }
+ }
+
+ const currentOrder = this._config.panels.map((panel) => panel.id).join("|");
+ const incomingOrder = incoming.panels.map((panel) => panel.id).join("|");
+ return currentOrder !== incomingOrder;
+ }
+
+ protected _toPanelSignatureMap(panels: PanelConfig[]): Map {
+ const map = new Map();
+ for (const panel of panels) {
+ map.set(
+ panel.id,
+ JSON.stringify({
+ name: panel.name ?? "",
+ power_entity: panel.power_entity ?? "",
+ energy_entity: panel.energy_entity ?? "",
+ inverter_status_entity: panel.inverter_status_entity ?? "",
+ error_entity: panel.error_entity ?? "",
+ show_energy: panel.show_energy ?? false,
+ enabled: panel.enabled ?? true,
+ rated_power_w: panel.rated_power_w ?? null,
+ deviation_derate_percent: panel.deviation_derate_percent ?? 100,
+ advanced_metrics: panel.advanced_metrics ?? {},
+ }),
+ );
+ }
+ return map;
+ }
+}
+
+export class SolarPanelVisualizerCardEditor extends SolarPanelVisualizerBaseCardEditor {}
+
+if (!customElements.get("solar-panel-visualizer-card-editor")) {
+ customElements.define(
+ "solar-panel-visualizer-card-editor",
+ SolarPanelVisualizerCardEditor,
+ );
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "solar-panel-visualizer-base-card-editor": SolarPanelVisualizerBaseCardEditor;
+ "solar-panel-visualizer-card-editor": SolarPanelVisualizerCardEditor;
+ }
+}
diff --git a/src/translations/en.json b/src/translations/en.json
new file mode 100644
index 0000000..26f186e
--- /dev/null
+++ b/src/translations/en.json
@@ -0,0 +1,380 @@
+{
+ "meta": {
+ "card_name": "Solar Panel Visualizer",
+ "card_description": "GUI-first solar array card with panel health, animated power rails, forecasts, history graphs, and light/dark themes."
+ },
+ "common": {
+ "unavailable": "Unavailable",
+ "not_configured": "Not configured",
+ "disabled": "Disabled",
+ "unknown_recorder_error": "Unknown recorder error"
+ },
+ "card": {
+ "default_title": "Solar Array",
+ "eyebrow": "Solar Panel Visualizer",
+ "subtitle": {
+ "loading_history": "Loading shared {hours}h solar panel history...",
+ "warmup": "Deviation checks are warming up.",
+ "deviation_detected": "{count} panel{suffix} below expected output",
+ "tap_diagnostics": "Tap a panel for detailed diagnostics",
+ "drag_hint": "Drag and drop panel tiles to swap positions."
+ },
+ "summary": {
+ "power": "Power",
+ "energy": "Energy",
+ "alerts": "Alerts",
+ "system_sensor": "System sensor",
+ "sum_panel_sensors": "Sum of panel sensors",
+ "custom_sensor": "Custom KPI sensor",
+ "custom_default_title": "Custom KPI"
+ },
+ "panel": {
+ "hidden": "hidden",
+ "hidden_name": "Hidden Panel",
+ "hidden_performance": "Hidden",
+ "info_label": "Info",
+ "info_title": "Open panel details",
+ "inverter_prefix": "Status: {status}",
+ "inverter_short": {
+ "ok": "Status: OK",
+ "deviation": "Status: Deviation",
+ "error": "Status: Error"
+ },
+ "performance_compact": "{percent}%",
+ "performance_medium": "{percent}% of {rated}W",
+ "performance_full": "{percent}% of {rated}W Panel",
+ "string_label": "String {index}",
+ "slot_label": "R{row}C{column}",
+ "status": {
+ "normal": "normal",
+ "deviation": "deviation",
+ "inverter": "inverter",
+ "error": "error",
+ "offline": "offline",
+ "unconfigured": "unconfigured",
+ "disabled": "hidden"
+ }
+ },
+ "popup": {
+ "close_detail": "Close detail",
+ "close_live_power": "Close live power detail",
+ "close_energy": "Close energy detail",
+ "close_custom_kpi": "Close custom KPI detail",
+ "close_system_health": "Close system health",
+ "panel_eyebrow": "Panel Detail {slot}",
+ "power_eyebrow": "Power Detail",
+ "power_title": "Live Power",
+ "energy_eyebrow": "Energy Detail",
+ "energy_title": "Energy",
+ "custom_eyebrow": "Custom KPI Detail",
+ "system_health_eyebrow": "System Health",
+ "system_health_title": "Overview",
+ "detail": {
+ "status": "Status",
+ "power": "Power",
+ "energy": "Energy",
+ "estimated_power_now": "Forecast production",
+ "estimated_energy_now": "Forecast production",
+ "deviation": "Deviation",
+ "rated_performance": "Rated / Performance",
+ "information": "Information",
+ "current": "Current",
+ "source": "Source"
+ },
+ "deviation": {
+ "inverter_mismatch": "Status mismatch",
+ "below_peers": "{percent}% below peers",
+ "within_range": "Within range"
+ },
+ "rated_performance": {
+ "format": "{rated} W / {percent}",
+ "na": "n/a"
+ },
+ "info": {
+ "power_source": "Power source: {value}",
+ "string_share": "{label}: {percent}% of {source}",
+ "current_inverter_status": "Current status: \"{value}\"",
+ "inverter_evaluation": "Status evaluation: {value}",
+ "inverter_source": "Status source: {value}"
+ },
+ "inverter_eval": {
+ "no_status": "No status available",
+ "fault_match": "Fault term matched",
+ "working_match": "Working term matched",
+ "no_match": "No configured term matched"
+ },
+ "telemetry": {
+ "title": "Panel / Inverter Info",
+ "configured_title": "Configured",
+ "unconfigured_title": "Unconfigured",
+ "none_configured": "No advanced telemetry configured for this panel.",
+ "setup_hint": "Configure in Panels > Advanced telemetry.",
+ "label": {
+ "inverter_status": "Inverter / panel status",
+ "inverter_ac_power": "Inverter AC power",
+ "inverter_ac_voltage": "Inverter AC voltage",
+ "inverter_ac_current": "Inverter AC current",
+ "inverter_temp": "Inverter temperature",
+ "panel_current": "Panel current",
+ "panel_voltage": "Panel voltage",
+ "panel_power": "Panel power"
+ }
+ },
+ "history": {
+ "power": "Power History",
+ "system_power": "System Power History",
+ "total_panel_power": "Total Panel Power History",
+ "system_energy": "System Energy History",
+ "total_panel_energy": "Total Panel Energy History",
+ "panel_power_values": "Panel Power Values",
+ "panel_energy_values": "Panel Energy Values",
+ "panel_compare": "Panel Performance Comparison",
+ "panel_compare_power": "Panel Power Comparison",
+ "panel_compare_energy": "Panel Energy Comparison",
+ "overlay_forecast": "Forecast",
+ "graph_not_configured": "No sensor configured for graph.",
+ "custom_not_configured": "No sensor configured for Custom KPI.",
+ "loading": "Loading sensor history...",
+ "no_data": "No history data for selected range.",
+ "unable_load": "Unable to load panel history ({error})",
+ "unable_load_plain": "Unable to load panel history",
+ "max": "Max {value}",
+ "median": "Median {value}",
+ "min": "Min {value}",
+ "time_range": "{start} - {end}"
+ },
+ "forecast": {
+ "enable_button": "Enable forecasts",
+ "disabled_hint": "Forecast overlay is disabled.",
+ "not_configured": "Forecast.Solar not configured.",
+ "default_sensor_not_found": "Default forecast sensor not found ({entity}).",
+ "power_compare_requires_system": "System power sensor is required for power forecast comparison.",
+ "energy_compare_requires_system": "System energy sensor is required for energy forecast comparison."
+ },
+ "panel_compare": {
+ "toggle": "Compare Panel Performance",
+ "toggle_power": "Compare Panel Power",
+ "toggle_energy": "Compare Panel Energy",
+ "loading": "Loading panel comparison history...",
+ "no_panels": "No configured panel sensors available for comparison.",
+ "no_panels_power": "No configured panel power sensors available for comparison.",
+ "no_panels_energy": "No configured panel energy sensors available for comparison.",
+ "no_data": "No comparison data for selected range.",
+ "unable_load": "Unable to load panel comparison history ({error}).",
+ "render_failure": "Comparison data loaded, but traces could not be drawn.",
+ "diagnostics_title": "Compare graph diagnostics (temporary)",
+ "diagnostics_summary": "model hasData={hasData}, drawable={drawable}, series={series}, range={range}h",
+ "diagnostics_reason_render_failure": "Series exist but no drawable traces were produced.",
+ "diagnostics_reason_suspect": "One or more series produced an invalid drawable shape.",
+ "diagnostics_row": "{label}: samples={samples}, points={points}, first={first}, last={last}, min={min}, max={max}"
+ },
+ "system_health": {
+ "everything_ok": "Everything is working well.",
+ "section": {
+ "inverter": "Inverter",
+ "error": "Error",
+ "deviation": "Deviation",
+ "offline": "Unavailable",
+ "setup": "Needs setup"
+ },
+ "item": "Panel on {slot} ({label}): {reason}"
+ }
+ },
+ "quick_setup": {
+ "title": "Quick Setup:",
+ "select_power_sensor": "Select panel power sensor:",
+ "selector_label": "Select panel power sensor",
+ "no_sensors": "No available W sensors found.",
+ "disable_panel": "Disable Panel (hide but keep slot when off)"
+ },
+ "system_health_chip": {
+ "faults": "{count} Fault{suffix}",
+ "unavailable": "{count} Unavailable",
+ "deviation": "{count} Deviation",
+ "needs_setup": "{count} Needs Setup",
+ "ok": "System OK"
+ }
+ },
+ "state": {
+ "reason": {
+ "slot_hidden": "Panel slot is hidden in the card configuration.",
+ "select_power_sensor": "Select a power sensor to activate this panel slot.",
+ "power_entity_missing": "Power entity {entity} was not found.",
+ "power_entity_unavailable": "{entity} is unavailable.",
+ "inverter_fault_match": "Current status: \"{status}\" matches configured fault terms.",
+ "inverter_working_mismatch": "Current status: \"{status}\" does not match configured working terms.",
+ "producing_expected": "Producing within the expected array range.",
+ "producing_adjusted": "Producing within array-adjusted target range.",
+ "rated_not_configured": "Rated power not configured; excluded from deviation checks.",
+ "output_below_target": "Output is {percent}% and {shortfall} W below array target.",
+ "array_check_disabled": "Array Health Check is disabled.",
+ "need_non_derated_panels": "Need at least {count} non-derated active rated panels for deviation checks.",
+ "collecting_samples": "Collecting samples ({current}/{required}).",
+ "warmup_progress": "Warm-up in progress ({current}/{required} min).",
+ "low_light_pause": "Low-light pause: waiting above {floor} W target floor."
+ },
+ "status_display": {
+ "disabled": "Disabled",
+ "not_configured": "Not configured"
+ },
+ "energy": {
+ "default_unit": "kWh"
+ },
+ "power": {
+ "default_unit": "W"
+ }
+ },
+ "editor": {
+ "section": {
+ "layout_title": "Layout",
+ "layout_copy": "Set the array size first. Panel slots expand automatically from the row and column values.",
+ "display_title": "Display",
+ "display_copy": "Tune precision and panel detail behavior.",
+ "appearance_title": "Appearance",
+ "appearance_copy": "Auto follows the active Home Assistant theme. Force Light or Dark if a dashboard theme needs a specific card style.",
+ "forecast_title": "Forecast.Solar",
+ "forecast_copy": "Auto-detects Home Assistant default forecast sensors and overlays estimated production in Power/Energy KPI popups.",
+ "array_health_title": "Array Health Check",
+ "array_health_copy": "Automatically checks panel health by comparing active panels against each other using rated power, shared solar panel history, and configurable guardrails.",
+ "array_health_smoothing_help": "Smoothing window averages recent samples before checks; 0 means no smoothing.",
+ "inverter_title": "Status Checks",
+ "inverter_copy": "Track textual status from each panel’s inverter or panel status sensor. A panel turns red only when status text contains one of the configured fault terms. The current status is shown in the panel popup.",
+ "status_colors_title": "Status Colors",
+ "status_colors_copy": "Production colors blend based on panel output. Alert colors override the production scale.",
+ "motion_title": "Motion",
+ "motion_copy": "Animate live production with left-collector power rails, Power/Energy KPI impact effects, and repeated alert ripples. Motion automatically respects reduced-motion preferences.",
+ "panels_title": "Panels",
+ "panels_copy": "Each generated slot can be configured with its own power, energy, and optional inverter/panel status sensor. Disable a slot to hide that panel while keeping grid spacing.",
+ "panels_drag_hint": "In the card view, drag and drop panel tiles to swap their positions.",
+ "string_setup_title": "String of panels",
+ "string_setup_copy": "Use this when one power or energy sensor represents a full string of physical panels. The tree creates one visual row per string.",
+ "panel_defaults_title": "Panel default rated power",
+ "panel_defaults_copy": "Set a common default panel power and apply it to all panel slots.",
+ "autofill_title": "Auto-populate sensors",
+ "autofill_copy": "Fill panel sensors in slot order. Use sensor. for exact entity ID prefixes, or type friendly-name text to search sensor names.",
+ "advanced_title": "Advanced telemetry",
+ "advanced_copy": "Optional manual telemetry mappings shown in the panel popup when pressing INFO."
+ },
+ "field": {
+ "title": "Title",
+ "rows": "Rows",
+ "columns": "Columns",
+ "max_card_width": "Max card width (px)",
+ "max_card_height": "Max card height (px)",
+ "theme_mode": "Theme mode",
+ "power_decimals": "Power decimals",
+ "energy_decimals": "Energy decimals",
+ "custom_kpi_decimals": "Custom KPI decimals",
+ "panel_tap_action": "Panel tap action",
+ "system_power_sensor": "System power sensor (W)",
+ "system_energy_sensor": "System daily energy sensor",
+ "custom_kpi_sensor": "Custom KPI sensor",
+ "custom_kpi_heading": "Custom KPI heading",
+ "max_panel_tile_width": "Max panel tile width (px)",
+ "deviation_threshold": "Deviation threshold (%)",
+ "deviation_absolute_shortfall": "Absolute shortfall threshold (W)",
+ "deviation_check_time": "Deviation check time (minutes)",
+ "deviation_min_active_panels": "Minimum active panels",
+ "deviation_min_samples": "Minimum samples per panel",
+ "deviation_smoothing": "Smoothing window (minutes)",
+ "deviation_dynamic_floor": "Dynamic floor start (W)",
+ "deviation_history_window": "Shared history window (hours)",
+ "fault_terms": "Fault terms (comma-separated)",
+ "working_terms": "Working terms (comma-separated)",
+ "production_base": "Production base",
+ "production_mid": "Production mid",
+ "production_peak": "Production peak",
+ "deviation_color": "Deviation",
+ "error_color": "Error",
+ "unavailable_color": "Unavailable",
+ "production_intensity": "Production color intensity ({value})",
+ "default_panel_rated_power": "Default panel rated power (W)",
+ "power_prefix": "Power search",
+ "energy_prefix": "Energy search (optional)",
+ "status_prefix": "Status search (optional)",
+ "autofill_sort_mode": "Auto-fill sort order",
+ "string_group_count": "Number of strings",
+ "string_group_name": "String name",
+ "string_group_panel_count": "Number of panels",
+ "string_group_power_sensor": "String power sensor",
+ "string_group_energy_sensor": "String energy sensor (optional)",
+ "display_name": "Display name",
+ "power_sensor": "Power sensor P(W)",
+ "energy_sensor": "Energy sensor (kWh/Wh)",
+ "panel_rated_power": "Panel rated power (W)",
+ "deviation_derate": "Deviation derate (%)",
+ "inverter_status_sensor": "Inverter / panel status sensor (optional)",
+ "advanced_inverter_ac_power": "Inverter AC power (W)",
+ "advanced_inverter_ac_voltage": "Inverter AC voltage (V)",
+ "advanced_inverter_ac_current": "Inverter AC current (A)",
+ "advanced_inverter_temp": "Inverter temperature (\u00B0C/\u00B0F)",
+ "advanced_panel_current": "Panel current (A)",
+ "advanced_panel_voltage": "Panel voltage (V)",
+ "advanced_panel_power": "Panel power (W)",
+ "panel_energy_toggle": "Show panel energy",
+ "panel_enabled_toggle": "Show panel tile (hide but keep slot when off)"
+ },
+ "toggle": {
+ "use_system_power": "Use one system power sensor for top KPI",
+ "invert_system_power": "Invert system power value",
+ "use_system_energy": "Use one system daily energy sensor for top KPI",
+ "invert_custom_kpi": "Invert Custom KPI value",
+ "enable_forecast_overlay": "Enable forecast overlays in popups",
+ "motion_enabled": "Enable motion",
+ "motion_power_flow": "Power-rail flow to Power KPI",
+ "motion_update_shimmer": "Power/Energy KPI update effect",
+ "motion_alert_ripple": "Alert ripple for deviation/inverter/error",
+ "show_custom_kpi": "Show Custom KPI box",
+ "limit_panel_width": "Limit panel tile max width",
+ "enable_array_health": "Enable Array Health Check",
+ "enable_inverter_status": "Enable status checks",
+ "show_inverter_status_tiles": "Show status on panel tiles",
+ "enable_string_sensor_sharing": "String of panels / share duplicate sensors",
+ "autofill_inverter_status": "Fill inverter or panel status",
+ "autofill_advanced_telemetry": "Fill advanced telemetry"
+ },
+ "select": {
+ "theme_auto": "Auto",
+ "theme_dark": "Dark",
+ "theme_light": "Light",
+ "panel_tap_details": "Open detail popover",
+ "panel_tap_none": "No action",
+ "autofill_sort_auto": "Auto detect (recommended)",
+ "autofill_sort_entity": "Literal entity ID order",
+ "autofill_sort_friendly": "Literal friendly name order",
+ "autofill_sort_grouped": "Group by repeated suffix"
+ },
+ "button": {
+ "apply_default_rated_power": "Apply default rated W to all panels",
+ "autofill_sensors": "Auto-fill panel sensors",
+ "remove_all_sensors": "Remove all sensors"
+ },
+ "helper": {
+ "fault_example": "Example: fault, alarm, error, failed, failure, trip",
+ "working_example": "Working example: normal, ok, running, waiting, waiting for operation, producing",
+ "derate_help": "Used only by Array Health Check for naturally shaded panels.",
+ "autofill_search_help": "Search with sensor.panel_ to match entity IDs by prefix, or type words like Roof Panel or Daily Energy to search friendly names. Auto detect is recommended for most setups: it uses natural sorting and can group matching sensors by repeated serial/device parts or serial-like suffixes when that better matches the physical layout. Use the other sort modes only when Auto does not match your array.",
+ "string_sensor_sharing_help": "Turn this on to allow duplicate string sensors. Shared power and energy values are split across the generated panels by rated W, with equal split if rated W is missing.",
+ "autofill_inverter_status_help": "When enabled, status is filled from configured panel power sensors. The card first tries one-to-one panel status matches, then falls back to a shared inverter/string status sensor. Use Status search only when you need to narrow the candidates.",
+ "autofill_advanced_help": "When enabled, advanced telemetry is filled from configured panel power sensors by matching the same repeated serial/device/group. Existing advanced telemetry choices are preserved."
+ },
+ "forecast": {
+ "default_sensors": "Uses: sensor.power_production_now and sensor.energy_production_today.",
+ "line_help": "The forecast reference is a thin dashed line shown only for the selected history range up to the current time, with no future projection.",
+ "detected": "Forecast.Solar default sensors detected.",
+ "missing": "Forecast.Solar default sensors not fully detected. Expected: sensor.power_production_now and sensor.energy_production_today."
+ },
+ "panel_slot_title": "Row {row}, Column {column}",
+ "autofill": {
+ "enter_prefix": "Enter at least one prefix to run auto-fill.",
+ "power_summary": "Power matched {matched}, filled {filled}, skipped {skipped}.",
+ "energy_summary": "Energy matched {matched}, filled {filled}, skipped {skipped}.",
+ "inverter_status_summary": "Status filled {filled} panels, skipped {skipped} panels.",
+ "advanced_summary": "Advanced telemetry filled {filled} fields, skipped {skipped} panels.",
+ "cleared": "Cleared power, energy, inverter, and advanced telemetry sensors on all panels."
+ },
+ "string_group": {
+ "panel_count_chip": "{count} panels"
+ }
+ }
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..44cde16
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,187 @@
+export type PanelTapAction = "details" | "none";
+export type CardThemeMode = "auto" | "dark" | "light";
+
+export interface PanelColors {
+ production_start: string;
+ production_mid: string;
+ production_end: string;
+ deviation: string;
+ error: string;
+ unavailable: string;
+}
+
+export interface PanelAdvancedMetrics {
+ inverter_ac_power_entity?: string;
+ inverter_ac_voltage_entity?: string;
+ inverter_ac_current_entity?: string;
+ inverter_temp_entity?: string;
+ panel_current_entity?: string;
+ panel_voltage_entity?: string;
+ panel_power_entity?: string;
+}
+
+export interface PanelConfig {
+ id: string;
+ name?: string;
+ power_entity?: string;
+ energy_entity?: string;
+ show_energy?: boolean;
+ inverter_status_entity?: string;
+ error_entity?: string;
+ advanced_metrics?: PanelAdvancedMetrics;
+ enabled?: boolean;
+ rated_power_w?: number;
+ deviation_derate_percent?: number;
+}
+
+export interface StringGroupConfig {
+ id: string;
+ name?: string;
+ panel_count: number;
+ power_entity?: string;
+ energy_entity?: string;
+}
+
+export interface SolarPanelVisualizerConfig {
+ type: string;
+ title?: string;
+ theme_mode?: CardThemeMode;
+ rows: number;
+ columns: number;
+ panels: PanelConfig[];
+ enable_inverter_status?: boolean;
+ enable_string_sensor_sharing?: boolean;
+ string_groups?: StringGroupConfig[];
+ inverter_fault_terms?: string[];
+ inverter_working_terms?: string[];
+ show_inverter_status_on_tiles?: boolean;
+ enable_array_checks?: boolean;
+ deviation_threshold_percent?: number;
+ deviation_absolute_w_threshold?: number;
+ deviation_min_active_panels?: number;
+ deviation_min_samples?: number;
+ deviation_min_runtime_minutes?: number;
+ deviation_smoothing_minutes?: number;
+ deviation_dynamic_floor_w?: number;
+ deviation_restart_entity?: string;
+ deviation_history_hours?: number;
+ colors?: Partial;
+ production_color_intensity?: number;
+ show_energy?: boolean;
+ use_system_power_entity?: boolean;
+ system_power_entity?: string;
+ invert_system_power?: boolean;
+ use_system_energy_entity?: boolean;
+ system_energy_entity?: string;
+ enable_forecast_overlay?: boolean;
+ show_custom_kpi?: boolean;
+ custom_kpi_title?: string;
+ custom_kpi_entity?: string;
+ custom_kpi_decimals?: number;
+ invert_custom_kpi?: boolean;
+ panel_tap_action?: PanelTapAction;
+ power_decimals?: number;
+ energy_decimals?: number;
+ default_panel_rated_power_w?: number;
+ limit_panel_width?: boolean;
+ panel_max_width_px?: number;
+ max_card_width_px?: number;
+ max_card_height_px?: number;
+ grid_options?: {
+ columns?: "full" | number;
+ rows?: "auto" | number;
+ };
+ motion_enabled?: boolean;
+ motion_power_flow?: boolean;
+ motion_update_shimmer?: boolean;
+ motion_alert_ripple?: boolean;
+}
+
+export interface HassEntity {
+ entity_id: string;
+ state: string;
+ attributes: Record;
+}
+
+export interface HomeAssistant {
+ states: Record;
+ callApi?: (method: string, path: string, parameters?: Record) => Promise;
+ callWS?: (message: Record) => Promise;
+ locale?: {
+ language?: string;
+ };
+ themes?: {
+ darkMode?: boolean;
+ };
+}
+
+export interface PowerSample {
+ ts: number;
+ value: number;
+}
+
+export type PanelStatus =
+ | "normal"
+ | "deviation"
+ | "inverter"
+ | "error"
+ | "offline"
+ | "unconfigured"
+ | "disabled";
+
+export interface DerivedPanelState {
+ id: string;
+ slotIndex: number;
+ label: string;
+ status: PanelStatus;
+ power: number | null;
+ powerDisplay: string;
+ energy: number | null;
+ energyDisplay?: string;
+ powerEntityName?: string;
+ inverterStatusEntityName?: string;
+ inverterStatusDisplay?: string;
+ inverterFaultMatched: boolean;
+ inverterWorkingMatched: boolean;
+ deviationPercent: number | null;
+ reason: string;
+ accentColor: string;
+ intensity: number;
+ enabled: boolean;
+ hiddenSlot: boolean;
+ ratedPowerW: number | null;
+ performancePercent: number | null;
+ powerShareFactor?: number;
+ energyShareFactor?: number;
+ stringGroupLabel?: string;
+ stringPowerSourceName?: string;
+ stringPowerSharePercent?: number;
+}
+
+export interface DerivedArrayState {
+ panels: DerivedPanelState[];
+ totalPower: number;
+ totalEnergy: number | null;
+ maxPower: number;
+ deviationCount: number;
+ inverterCount: number;
+ errorCount: number;
+ offlineCount: number;
+ normalCount: number;
+ unconfiguredCount: number;
+ disabledCount: number;
+ deviationReady: boolean;
+ deviationSuppressedReason?: string;
+}
+
+declare global {
+ interface Window {
+ customCards?: Array<{
+ type: string;
+ name: string;
+ description: string;
+ icon?: string;
+ preview?: boolean;
+ }>;
+ }
+}
diff --git a/test/card-metadata.test.ts b/test/card-metadata.test.ts
new file mode 100644
index 0000000..e689914
--- /dev/null
+++ b/test/card-metadata.test.ts
@@ -0,0 +1,17 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+describe("card metadata", () => {
+ it("registers the bare custom element type for the Home Assistant card picker", () => {
+ const source = readFileSync("src/index.ts", "utf8");
+
+ expect(source).toContain("type: CARD_ELEMENT_NAME");
+ expect(source).not.toContain("type: CARD_TYPE");
+ });
+
+ it("registers a Home Assistant card picker icon", () => {
+ const source = readFileSync("src/index.ts", "utf8");
+
+ expect(source).toContain('icon: "mdi:solar-panel"');
+ });
+});
diff --git a/test/compare-graph.test.ts b/test/compare-graph.test.ts
new file mode 100644
index 0000000..af6c77b
--- /dev/null
+++ b/test/compare-graph.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+import { buildCompareGraphModel } from "../src/compare-graph";
+import type { PowerSample } from "../src/types";
+
+const HOUR_MS = 60 * 60 * 1000;
+const MINUTE_MS = 60 * 1000;
+const NOW = Date.UTC(2026, 2, 14, 12, 0, 0);
+
+const makeSeries = (values: number[], startTs: number, stepMs: number): PowerSample[] =>
+ values.map((value, index) => ({
+ ts: startTs + index * stepMs,
+ value,
+ }));
+
+describe("buildCompareGraphModel", () => {
+ it("renders normal multi-panel series for the selected range", () => {
+ const panelA = makeSeries([120, 160, 210, 180, 140], NOW - 5 * HOUR_MS, HOUR_MS);
+ const panelB = makeSeries([80, 100, 115, 130, 110, 90], NOW - 6 * HOUR_MS, HOUR_MS);
+
+ const model = buildCompareGraphModel(
+ [
+ { id: "panel-a", samples: panelA },
+ { id: "panel-b", samples: panelB },
+ ],
+ NOW,
+ 6,
+ 150,
+ );
+
+ expect(model.hasData).toBe(true);
+ expect(model.drawableCount).toBe(2);
+ expect(model.series).toHaveLength(2);
+ expect(model.series.every((series) => series.linePath.startsWith("M"))).toBe(true);
+ expect(model.series.every((series) => (series.firstTs ?? 0) >= NOW - 6 * HOUR_MS)).toBe(true);
+ expect(model.series.every((series) => (series.lastTs ?? 0) <= NOW)).toBe(true);
+ });
+
+ it("draws all-zero flat series", () => {
+ const panelA = makeSeries([0, 0, 0, 0], NOW - 3 * HOUR_MS, HOUR_MS);
+ const panelB = makeSeries([0, 0, 0, 0], NOW - 3 * HOUR_MS, HOUR_MS);
+
+ const model = buildCompareGraphModel(
+ [
+ { id: "panel-a", samples: panelA },
+ { id: "panel-b", samples: panelB },
+ ],
+ NOW,
+ 6,
+ 150,
+ );
+
+ expect(model.hasData).toBe(true);
+ expect(model.drawableCount).toBe(2);
+ expect(model.series.every((series) => series.pointsCount >= 2)).toBe(true);
+ expect(model.series.every((series) => series.linePath.length > 0)).toBe(true);
+ });
+
+ it("expands single-point input into a drawable flat line", () => {
+ const model = buildCompareGraphModel(
+ [
+ {
+ id: "panel-a",
+ samples: [{ ts: NOW - 10 * MINUTE_MS, value: 255 }],
+ },
+ {
+ id: "panel-b",
+ samples: [{ ts: NOW - 5 * MINUTE_MS, value: 175 }],
+ },
+ ],
+ NOW,
+ 1,
+ 150,
+ );
+
+ expect(model.hasData).toBe(true);
+ expect(model.drawableCount).toBe(2);
+ expect(model.series.every((series) => series.sampleCount >= 2)).toBe(true);
+ expect(model.series.every((series) => series.pointsCount >= 2)).toBe(true);
+ expect(model.series.every((series) => series.linePath.length > 0)).toBe(true);
+ });
+
+ it("handles mixed sparse and dense series with deterministic bounds", () => {
+ const dense = makeSeries(
+ Array.from({ length: 320 }, (_, index) => 200 + Math.sin(index / 10) * 30),
+ NOW - 24 * HOUR_MS,
+ (24 * HOUR_MS) / 319,
+ );
+ const sparse = makeSeries([95, 110, 88], NOW - 9 * HOUR_MS, 3 * HOUR_MS);
+
+ const model = buildCompareGraphModel(
+ [
+ { id: "dense", samples: dense },
+ { id: "sparse", samples: sparse },
+ ],
+ NOW,
+ 24,
+ 120,
+ );
+
+ const denseSeries = model.series.find((series) => series.id === "dense");
+ const sparseSeries = model.series.find((series) => series.id === "sparse");
+ expect(denseSeries).toBeDefined();
+ expect(sparseSeries).toBeDefined();
+ expect(denseSeries?.sampleCount).toBeLessThanOrEqual(120);
+ expect(denseSeries?.pointsCount).toBeGreaterThan(0);
+ expect(sparseSeries?.sampleCount).toBe(3);
+ expect(sparseSeries?.linePath.length).toBeGreaterThan(0);
+ });
+
+ it("returns non-drawable state for invalid point inputs instead of throwing", () => {
+ const invalidSeries: PowerSample[] = [
+ { ts: Number.NaN, value: 12 },
+ { ts: NOW - HOUR_MS, value: Number.NaN },
+ { ts: Number.POSITIVE_INFINITY, value: 8 },
+ ];
+
+ const model = buildCompareGraphModel(
+ [{ id: "panel-invalid", samples: invalidSeries }],
+ NOW,
+ 6,
+ 150,
+ );
+
+ expect(model.hasData).toBe(false);
+ expect(model.drawableCount).toBe(0);
+ expect(model.series[0].sampleCount).toBe(0);
+ expect(model.series[0].pointsCount).toBe(0);
+ expect(model.series[0].linePath).toBe("");
+ });
+});
diff --git a/test/config.test.ts b/test/config.test.ts
new file mode 100644
index 0000000..b14370a
--- /dev/null
+++ b/test/config.test.ts
@@ -0,0 +1,582 @@
+import { buildStubConfig, normalizeConfig, validateConfig } from "../src/config";
+
+describe("normalizeConfig", () => {
+ it("creates slots from rows and columns", () => {
+ const config = normalizeConfig({
+ rows: 2,
+ columns: 4,
+ panels: [{ id: "a", power_entity: "sensor.a" }],
+ });
+
+ expect(config.panels).toHaveLength(8);
+ expect(config.panels[0].id).toBe("a");
+ expect(config.panels[7].id).toBe("panel-8");
+ });
+
+ it("fills defaults for a stub config", () => {
+ const stub = buildStubConfig(1, 2);
+
+ expect(stub.type).toBe("custom:solar-panel-visualizer");
+ expect(stub.panels).toHaveLength(2);
+ expect(stub.theme_mode).toBe("auto");
+ expect(stub.show_energy).toBe(true);
+ expect(stub.max_card_width_px).toBe(980);
+ expect(stub.enable_inverter_status).toBe(false);
+ expect(stub.enable_string_sensor_sharing).toBe(false);
+ expect(stub.string_groups).toEqual([]);
+ expect(stub.show_inverter_status_on_tiles).toBe(false);
+ expect(stub.inverter_fault_terms).toEqual([
+ "fault",
+ "alarm",
+ "error",
+ "failed",
+ "failure",
+ "trip",
+ ]);
+ expect(stub.inverter_working_terms).toEqual([
+ "normal",
+ "ok",
+ "running",
+ "waiting",
+ "waiting for operation",
+ "producing",
+ ]);
+ expect(stub.enable_array_checks).toBe(false);
+ expect(stub.deviation_min_active_panels).toBe(3);
+ expect(stub.deviation_min_runtime_minutes).toBe(20);
+ expect(stub.deviation_dynamic_floor_w).toBe(30);
+ expect(stub.deviation_history_hours).toBe(12);
+ expect(stub.use_system_power_entity).toBe(false);
+ expect(stub.system_power_entity).toBeUndefined();
+ expect(stub.invert_system_power).toBe(false);
+ expect(stub.use_system_energy_entity).toBe(false);
+ expect(stub.system_energy_entity).toBeUndefined();
+ expect(stub.enable_forecast_overlay).toBe(false);
+ expect(stub.motion_enabled).toBe(true);
+ expect(stub.motion_power_flow).toBe(true);
+ expect(stub.motion_update_shimmer).toBe(true);
+ expect(stub.motion_alert_ripple).toBe(true);
+ expect(stub.show_custom_kpi).toBe(true);
+ expect(stub.custom_kpi_title).toBe("Custom KPI");
+ expect(stub.custom_kpi_entity).toBeUndefined();
+ expect(stub.custom_kpi_decimals).toBe(0);
+ expect(stub.invert_custom_kpi).toBe(false);
+ expect(stub.limit_panel_width).toBe(false);
+ expect(stub.panel_max_width_px).toBe(220);
+ expect(stub.panels[0].deviation_derate_percent).toBe(100);
+ expect(stub.panels[0].show_energy).toBe(false);
+ });
+
+ it("normalizes optional card sizing and panel rated power", () => {
+ const config = normalizeConfig({
+ rows: 2,
+ columns: 3,
+ max_card_width_px: 2600,
+ max_card_height_px: 120,
+ panel_max_width_px: 500,
+ panels: [{ id: "p1", rated_power_w: 0, deviation_derate_percent: 0 }],
+ });
+
+ expect(config.max_card_width_px).toBe(2400);
+ expect(config.max_card_height_px).toBe(300);
+ expect(config.panel_max_width_px).toBe(320);
+ expect(config.panels[0].rated_power_w).toBeUndefined();
+ expect(config.panels[0].deviation_derate_percent).toBe(1);
+ });
+
+ it("allows string-sized panel rated power up to 10 kW", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 2,
+ default_panel_rated_power_w: 12000,
+ panels: [
+ { id: "string-1", rated_power_w: 10000 },
+ { id: "string-2", rated_power_w: 15000 },
+ ],
+ });
+
+ expect(config.default_panel_rated_power_w).toBe(10000);
+ expect(config.panels[0].rated_power_w).toBe(10000);
+ expect(config.panels[1].rated_power_w).toBe(10000);
+ });
+
+ it("normalizes system energy override settings", () => {
+ const config = normalizeConfig({
+ use_system_power_entity: true,
+ system_power_entity: " sensor.house_power ",
+ invert_system_power: true,
+ use_system_energy_entity: true,
+ system_energy_entity: " sensor.daily_energy ",
+ invert_custom_kpi: true,
+ enable_forecast_overlay: true,
+ });
+
+ expect(config.use_system_power_entity).toBe(true);
+ expect(config.system_power_entity).toBe("sensor.house_power");
+ expect(config.invert_system_power).toBe(true);
+ expect(config.use_system_energy_entity).toBe(true);
+ expect(config.system_energy_entity).toBe("sensor.daily_energy");
+ expect(config.invert_custom_kpi).toBe(true);
+ expect(config.enable_forecast_overlay).toBe(true);
+ });
+
+ it("normalizes string sensor sharing as an optional additive setting", () => {
+ const defaults = normalizeConfig({});
+ const enabled = normalizeConfig({ enable_string_sensor_sharing: true });
+
+ expect(defaults.enable_string_sensor_sharing).toBe(false);
+ expect(defaults.string_groups).toEqual([]);
+ expect(enabled.enable_string_sensor_sharing).toBe(true);
+ expect(enabled.string_groups).toEqual([]);
+ });
+
+ it("generates one visual row per configured string group", () => {
+ const config = normalizeConfig({
+ rows: 2,
+ columns: 3,
+ enable_string_sensor_sharing: true,
+ string_groups: [
+ {
+ id: "roof-a",
+ name: "Roof A",
+ panel_count: 3,
+ power_entity: "sensor.string_a_power",
+ energy_entity: "sensor.string_a_energy",
+ },
+ {
+ id: "roof-b",
+ name: "Roof B",
+ panel_count: 2,
+ power_entity: "sensor.string_b_power",
+ energy_entity: "sensor.string_b_energy",
+ },
+ ],
+ panels: [
+ { id: "p1", rated_power_w: 400 },
+ { id: "p2", deviation_derate_percent: 80 },
+ { id: "p3", inverter_status_entity: "sensor.p3_status" },
+ { id: "p4", advanced_metrics: { panel_voltage_entity: "sensor.p4_voltage" } },
+ ],
+ });
+
+ expect(config.rows).toBe(2);
+ expect(config.columns).toBe(3);
+ expect(config.panels).toHaveLength(6);
+ expect(config.panels[0]).toMatchObject({
+ id: "p1",
+ enabled: true,
+ power_entity: "sensor.string_a_power",
+ energy_entity: "sensor.string_a_energy",
+ show_energy: true,
+ rated_power_w: 400,
+ });
+ expect(config.panels[2].inverter_status_entity).toBe("sensor.p3_status");
+ expect(config.panels[3]).toMatchObject({
+ id: "p4",
+ enabled: true,
+ power_entity: "sensor.string_b_power",
+ energy_entity: "sensor.string_b_energy",
+ });
+ expect(config.panels[3].advanced_metrics?.panel_voltage_entity).toBe("sensor.p4_voltage");
+ expect(config.panels[5]).toMatchObject({
+ id: "panel-6",
+ enabled: false,
+ power_entity: undefined,
+ energy_entity: undefined,
+ });
+ });
+
+ it("regenerates stale automatic string panel names when string layout changes", () => {
+ const config = normalizeConfig({
+ enable_string_sensor_sharing: true,
+ string_groups: [
+ {
+ id: "string-1",
+ name: "String 1",
+ panel_count: 2,
+ power_entity: "sensor.string_1_power",
+ },
+ {
+ id: "string-2",
+ name: "String 2",
+ panel_count: 5,
+ power_entity: "sensor.string_2_power",
+ },
+ ],
+ panels: [
+ { id: "p1", name: "String 2 Panel 3" },
+ { id: "p2", name: "String 2 Panel 3" },
+ { id: "p3", name: "String 2 Panel 3" },
+ { id: "p4", name: "String 2 Panel 3" },
+ { id: "p5", name: "String 2 Panel 3" },
+ { id: "p6", name: "String 2 Panel 3" },
+ { id: "p7", name: "String 2 Panel 3" },
+ ],
+ });
+
+ expect(config.rows).toBe(2);
+ expect(config.columns).toBe(5);
+ expect(config.panels.map((panel) => panel.name)).toEqual([
+ "String 1 Panel 1",
+ "String 1 Panel 2",
+ undefined,
+ undefined,
+ undefined,
+ "String 2 Panel 1",
+ "String 2 Panel 2",
+ "String 2 Panel 3",
+ "String 2 Panel 4",
+ "String 2 Panel 5",
+ ]);
+ expect(config.panels[2].enabled).toBe(false);
+ expect(config.panels[3].enabled).toBe(false);
+ expect(config.panels[4].enabled).toBe(false);
+ });
+
+ it("preserves custom panel names in string-generated layouts", () => {
+ const config = normalizeConfig({
+ enable_string_sensor_sharing: true,
+ string_groups: [
+ {
+ id: "string-1",
+ name: "String 1",
+ panel_count: 2,
+ power_entity: "sensor.string_1_power",
+ },
+ ],
+ panels: [
+ { id: "p1", name: "Garage east" },
+ { id: "p2", name: "String 1 Panel custom" },
+ ],
+ });
+
+ expect(config.panels.map((panel) => panel.name)).toEqual([
+ "Garage east",
+ "String 1 Panel custom",
+ ]);
+ });
+
+ it("converts a default 3x3 panel grid into a 2-row string layout cleanly", () => {
+ const config = normalizeConfig({
+ rows: 3,
+ columns: 3,
+ enable_string_sensor_sharing: true,
+ string_groups: [
+ {
+ id: "string-1",
+ name: "String 1",
+ panel_count: 5,
+ power_entity: "sensor.string_1_power",
+ },
+ {
+ id: "string-2",
+ name: "String 2",
+ panel_count: 12,
+ power_entity: "sensor.string_2_power",
+ },
+ ],
+ panels: [
+ { id: "p1", name: "Panel 1", rated_power_w: 410 },
+ { id: "p2", name: "Panel 2", deviation_derate_percent: 90 },
+ { id: "p3", name: "Panel 3", inverter_status_entity: "sensor.p3_status" },
+ { id: "p4", name: "Panel 4", advanced_metrics: { panel_voltage_entity: "sensor.p4_voltage" } },
+ { id: "p5", name: "Panel 5" },
+ { id: "p6", name: "Panel 6" },
+ { id: "p7", name: "Panel 7" },
+ { id: "p8", name: "Panel 8" },
+ { id: "p9", name: "Panel 9" },
+ ],
+ });
+
+ expect(config.rows).toBe(2);
+ expect(config.columns).toBe(12);
+ expect(config.panels).toHaveLength(24);
+ expect(config.panels.map((panel) => panel.name)).toEqual([
+ "String 1 Panel 1",
+ "String 1 Panel 2",
+ "String 1 Panel 3",
+ "String 1 Panel 4",
+ "String 1 Panel 5",
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ "String 2 Panel 1",
+ "String 2 Panel 2",
+ "String 2 Panel 3",
+ "String 2 Panel 4",
+ "String 2 Panel 5",
+ "String 2 Panel 6",
+ "String 2 Panel 7",
+ "String 2 Panel 8",
+ "String 2 Panel 9",
+ "String 2 Panel 10",
+ "String 2 Panel 11",
+ "String 2 Panel 12",
+ ]);
+ expect(config.panels.slice(0, 5).map((panel) => panel.enabled)).toEqual([
+ true,
+ true,
+ true,
+ true,
+ true,
+ ]);
+ expect(config.panels.slice(5, 12).map((panel) => panel.enabled)).toEqual(
+ Array.from({ length: 7 }, () => false),
+ );
+ expect(config.panels.slice(12).map((panel) => panel.enabled)).toEqual(
+ Array.from({ length: 12 }, () => true),
+ );
+ expect(config.panels[0].rated_power_w).toBe(410);
+ expect(config.panels[1].deviation_derate_percent).toBe(90);
+ expect(config.panels[2].inverter_status_entity).toBe("sensor.p3_status");
+ expect(config.panels[12].advanced_metrics?.panel_voltage_entity).toBe("sensor.p4_voltage");
+ });
+
+ it("keeps existing manual panels unchanged when no string groups are saved", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 2,
+ enable_string_sensor_sharing: true,
+ panels: [
+ { id: "p1", power_entity: "sensor.shared" },
+ { id: "p2", power_entity: "sensor.shared" },
+ ],
+ });
+
+ expect(config.rows).toBe(1);
+ expect(config.columns).toBe(2);
+ expect(config.panels.map((panel) => panel.power_entity)).toEqual([
+ "sensor.shared",
+ "sensor.shared",
+ ]);
+ });
+
+ it("allows row and column layouts above the old 12-slot limit", () => {
+ const config = normalizeConfig({
+ rows: 20,
+ columns: 25,
+ });
+ const clamped = normalizeConfig({
+ rows: 40,
+ columns: 50,
+ });
+
+ expect(config.rows).toBe(20);
+ expect(config.columns).toBe(25);
+ expect(clamped.rows).toBe(30);
+ expect(clamped.columns).toBe(30);
+ });
+
+ it("normalizes motion settings on the main card", () => {
+ const defaults = normalizeConfig({});
+ const disabled = normalizeConfig({
+ motion_enabled: false,
+ motion_power_flow: false,
+ motion_update_shimmer: false,
+ motion_alert_ripple: false,
+ });
+
+ expect(defaults.motion_enabled).toBe(true);
+ expect(defaults.motion_power_flow).toBe(true);
+ expect(defaults.motion_update_shimmer).toBe(true);
+ expect(defaults.motion_alert_ripple).toBe(true);
+ expect(disabled.motion_enabled).toBe(false);
+ expect(disabled.motion_power_flow).toBe(false);
+ expect(disabled.motion_update_shimmer).toBe(false);
+ expect(disabled.motion_alert_ripple).toBe(false);
+ });
+
+ it("allows clearing custom KPI heading text", () => {
+ const config = normalizeConfig({
+ custom_kpi_title: " ",
+ });
+
+ expect(config.custom_kpi_title).toBe("");
+ });
+
+ it("normalizes custom KPI decimals with default and clamping", () => {
+ expect(normalizeConfig({}).custom_kpi_decimals).toBe(0);
+ expect(
+ normalizeConfig({
+ custom_kpi_decimals: -2,
+ }).custom_kpi_decimals,
+ ).toBe(0);
+ expect(
+ normalizeConfig({
+ custom_kpi_decimals: 8,
+ }).custom_kpi_decimals,
+ ).toBe(4);
+ });
+
+ it("normalizes theme mode with default and fallback", () => {
+ expect(normalizeConfig({}).theme_mode).toBe("auto");
+ expect(normalizeConfig({ theme_mode: "dark" }).theme_mode).toBe("dark");
+ expect(normalizeConfig({ theme_mode: "light" }).theme_mode).toBe("light");
+ expect(normalizeConfig({ theme_mode: "blue" as never }).theme_mode).toBe("auto");
+ });
+
+ it("accepts minimal config and applies defaults for editor-first setup", () => {
+ const minimal = { type: "custom:solar-panel-visualizer" };
+ const errors = validateConfig(minimal);
+ const normalized = normalizeConfig(minimal);
+
+ expect(errors).toHaveLength(0);
+ expect(normalized.rows).toBe(2);
+ expect(normalized.columns).toBe(3);
+ expect(normalized.panels).toHaveLength(6);
+ });
+
+ it("validates custom KPI decimals range", () => {
+ const errors = validateConfig({
+ custom_kpi_decimals: 7,
+ });
+
+ expect(errors).toContain("`custom_kpi_decimals` must be between 0 and 4.");
+ });
+
+ it("validates panel width cap range", () => {
+ const errors = validateConfig({
+ panel_max_width_px: 400,
+ });
+
+ expect(errors).toContain("`panel_max_width_px` must be between 120 and 320.");
+ });
+
+ it("validates panel deviation derate range", () => {
+ const errors = validateConfig({
+ panels: [
+ {
+ id: "p1",
+ deviation_derate_percent: 101,
+ },
+ ],
+ });
+
+ expect(errors).toContain(
+ "`panels[0].deviation_derate_percent` must be between 1 and 100.",
+ );
+ });
+
+ it("preserves grid_options when editing", () => {
+ const config = normalizeConfig({
+ type: "custom:solar-panel-visualizer",
+ grid_options: {
+ columns: "full",
+ rows: "auto",
+ },
+ });
+
+ expect(config.grid_options).toEqual({
+ columns: "full",
+ rows: "auto",
+ });
+ });
+
+ it("normalizes intelligent deviation guardrails", () => {
+ const config = normalizeConfig({
+ deviation_absolute_w_threshold: -1,
+ deviation_min_active_panels: 1,
+ deviation_min_samples: 0,
+ deviation_min_runtime_minutes: -5,
+ deviation_smoothing_minutes: -2,
+ deviation_dynamic_floor_w: -3,
+ deviation_history_hours: 900,
+ });
+
+ expect(config.deviation_absolute_w_threshold).toBe(0);
+ expect(config.deviation_min_active_panels).toBe(2);
+ expect(config.deviation_min_samples).toBe(1);
+ expect(config.deviation_min_runtime_minutes).toBe(0);
+ expect(config.deviation_smoothing_minutes).toBe(0);
+ expect(config.deviation_dynamic_floor_w).toBe(0);
+ expect(config.deviation_history_hours).toBe(168);
+ });
+
+ it("maps legacy error_entity to inverter_status_entity", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ panels: [
+ {
+ id: "p1",
+ power_entity: "sensor.p1_power",
+ error_entity: "sensor.p1_status",
+ },
+ ],
+ });
+
+ expect(config.panels[0].inverter_status_entity).toBe("sensor.p1_status");
+ });
+
+ it("clears panel sensors when panel is disabled", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ panels: [
+ {
+ id: "p1",
+ enabled: false,
+ power_entity: "sensor.p1_power",
+ energy_entity: "sensor.p1_energy",
+ inverter_status_entity: "sensor.p1_status",
+ error_entity: "sensor.p1_legacy_error",
+ advanced_metrics: {
+ inverter_temp_entity: "sensor.p1_temp",
+ },
+ },
+ ],
+ });
+
+ expect(config.panels[0].enabled).toBe(false);
+ expect(config.panels[0].power_entity).toBeUndefined();
+ expect(config.panels[0].energy_entity).toBeUndefined();
+ expect(config.panels[0].inverter_status_entity).toBeUndefined();
+ expect(config.panels[0].error_entity).toBeUndefined();
+ expect(config.panels[0].advanced_metrics).toBeUndefined();
+ });
+
+ it("normalizes advanced telemetry mappings on panels", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ panels: [
+ {
+ id: "p1",
+ advanced_metrics: {
+ inverter_ac_power_entity: " sensor.p1_ac_power ",
+ panel_voltage_entity: "sensor.p1_voltage",
+ panel_current_entity: "",
+ },
+ },
+ ],
+ });
+
+ expect(config.panels[0].advanced_metrics).toEqual({
+ inverter_ac_power_entity: "sensor.p1_ac_power",
+ inverter_ac_voltage_entity: undefined,
+ inverter_ac_current_entity: undefined,
+ inverter_temp_entity: undefined,
+ panel_current_entity: undefined,
+ panel_voltage_entity: "sensor.p1_voltage",
+ panel_power_entity: undefined,
+ });
+ });
+
+ it("normalizes inverter fault terms from comma-separated text", () => {
+ const config = normalizeConfig({
+ inverter_fault_terms: " fault,ALARM, error , fault ",
+ inverter_working_terms: " normal, ok, waiting for operation ",
+ });
+
+ expect(config.inverter_fault_terms).toEqual(["fault", "alarm", "error"]);
+ expect(config.inverter_working_terms).toEqual([
+ "normal",
+ "ok",
+ "waiting for operation",
+ ]);
+ });
+});
diff --git a/test/custom-kpi.test.ts b/test/custom-kpi.test.ts
new file mode 100644
index 0000000..051d5ce
--- /dev/null
+++ b/test/custom-kpi.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { formatCustomKpiStateValue } from "../src/custom-kpi";
+
+describe("formatCustomKpiStateValue", () => {
+ it("formats numeric custom KPI values with configured decimals and unit", () => {
+ const formatted = formatCustomKpiStateValue("12.3456", "en", 2, "kW");
+
+ expect(formatted).toBe("12.35 kW");
+ });
+
+ it("normalizes negative zero and clamps decimals", () => {
+ const formatted = formatCustomKpiStateValue("-0", "en", 9, "W");
+
+ expect(formatted).toBe("0.0000 W");
+ });
+
+ it("returns null for non-numeric states", () => {
+ const formatted = formatCustomKpiStateValue("unavailable", "en", 0, "W");
+
+ expect(formatted).toBeNull();
+ });
+});
diff --git a/test/editor-autofill.test.ts b/test/editor-autofill.test.ts
new file mode 100644
index 0000000..1839da3
--- /dev/null
+++ b/test/editor-autofill.test.ts
@@ -0,0 +1,2128 @@
+import { describe, expect, it } from "vitest";
+import {
+ applyAutoPopulateFriendlyNames,
+ autoPopulateAdvancedTelemetry,
+ autoPopulateEnergyEntitiesByPowerAnchor,
+ autoPopulateInverterStatusEntities,
+ autoPopulatePanelEntities,
+ shouldAutoRenamePanelName,
+ sortSensorsForAutoPopulate,
+} from "../src/editor-autofill";
+import type { HassEntity, PanelConfig } from "../src/types";
+
+const makeSensor = (
+ entityId: string,
+ unit: string,
+ friendlyName?: string,
+ attributes: Record = {},
+): HassEntity => ({
+ entity_id: entityId,
+ state: "0",
+ attributes: {
+ ...attributes,
+ unit_of_measurement: unit,
+ friendly_name: friendlyName,
+ },
+});
+
+describe("autoPopulateAdvancedTelemetry", () => {
+ it("reuses one inverter temperature sensor for panels with the same serial group", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ },
+ {
+ id: "panel-3",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.inverter_temperature_device_30m3y10": makeSensor(
+ "sensor.inverter_temperature_device_30m3y10",
+ "\u00b0C",
+ "Inverter Temperature",
+ { device_class: "temperature" },
+ ),
+ "sensor.inverter_temperature_device_40m3y20": makeSensor(
+ "sensor.inverter_temperature_device_40m3y20",
+ "\u00b0C",
+ "Inverter Temperature",
+ { device_class: "temperature" },
+ ),
+ };
+
+ const result = autoPopulateAdvancedTelemetry(panels, states);
+
+ expect(result.panels.map((panel) => panel.advanced_metrics?.inverter_temp_entity)).toEqual([
+ "sensor.inverter_temperature_device_30m3y10",
+ "sensor.inverter_temperature_device_30m3y10",
+ "sensor.inverter_temperature_device_40m3y20",
+ ]);
+ });
+
+ it("maps panel current and voltage by local input tokens inside the same serial group", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_current_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_current_device_30m3y10",
+ "A",
+ "MPPT 1 Current",
+ { device_class: "current" },
+ ),
+ "sensor.sds_mppt_map_mppt2_current_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_current_device_30m3y10",
+ "A",
+ "MPPT 2 Current",
+ { device_class: "current" },
+ ),
+ "sensor.sds_mppt_map_mppt1_voltage_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_voltage_device_30m3y10",
+ "V",
+ "MPPT 1 Voltage",
+ { device_class: "voltage" },
+ ),
+ "sensor.sds_mppt_map_mppt2_voltage_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_voltage_device_30m3y10",
+ "V",
+ "MPPT 2 Voltage",
+ { device_class: "voltage" },
+ ),
+ };
+
+ const result = autoPopulateAdvancedTelemetry(panels, states);
+
+ expect(result.panels[0].advanced_metrics).toMatchObject({
+ panel_current_entity: "sensor.sds_mppt_map_mppt1_current_device_30m3y10",
+ panel_voltage_entity: "sensor.sds_mppt_map_mppt1_voltage_device_30m3y10",
+ panel_power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ });
+ expect(result.panels[1].advanced_metrics).toMatchObject({
+ panel_current_entity: "sensor.sds_mppt_map_mppt2_current_device_30m3y10",
+ panel_voltage_entity: "sensor.sds_mppt_map_mppt2_voltage_device_30m3y10",
+ panel_power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ });
+ });
+
+ it("skips disabled slots and preserves existing manual advanced telemetry", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ enabled: false,
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ advanced_metrics: {
+ panel_current_entity: "sensor.manual_current",
+ },
+ },
+ {
+ id: "panel-3",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ ),
+ "sensor.sds_mppt_map_mppt2_current_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_current_device_30m3y10",
+ "A",
+ ),
+ "sensor.sds_mppt_map_mppt2_voltage_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_voltage_device_30m3y10",
+ "V",
+ ),
+ "sensor.sds_mppt_map_mppt1_voltage_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_voltage_device_30m3y10",
+ "V",
+ ),
+ };
+
+ const result = autoPopulateAdvancedTelemetry(panels, states);
+
+ expect(result.panels[0].advanced_metrics).toBeUndefined();
+ expect(result.panels[1].advanced_metrics).toMatchObject({
+ panel_current_entity: "sensor.manual_current",
+ panel_voltage_entity: "sensor.sds_mppt_map_mppt2_voltage_device_30m3y10",
+ panel_power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ });
+ expect(result.skippedPanels).toBe(1);
+ });
+
+ it("leaves uncertain telemetry empty when no serial group can be detected", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", power_entity: "sensor.roof_panel_1_power" },
+ { id: "panel-2", power_entity: "sensor.roof_panel_2_power" },
+ ];
+ const states: Record = {
+ "sensor.roof_panel_1_power": makeSensor("sensor.roof_panel_1_power", "W"),
+ "sensor.roof_panel_2_power": makeSensor("sensor.roof_panel_2_power", "W"),
+ "sensor.roof_panel_current": makeSensor("sensor.roof_panel_current", "A"),
+ "sensor.roof_panel_voltage": makeSensor("sensor.roof_panel_voltage", "V"),
+ };
+
+ const result = autoPopulateAdvancedTelemetry(panels, states);
+
+ expect(result.panels[0].advanced_metrics).toEqual({
+ panel_power_entity: "sensor.roof_panel_1_power",
+ });
+ expect(result.panels[1].advanced_metrics).toEqual({
+ panel_power_entity: "sensor.roof_panel_2_power",
+ });
+ });
+});
+
+describe("autoPopulateInverterStatusEntities", () => {
+ it("maps one-to-one panel status sensors by matching power anchor tokens", () => {
+ const panels: PanelConfig[] = Array.from({ length: 30 }, (_, index) => ({
+ id: `panel-${index + 1}`,
+ power_entity: `sensor.power_1_0_${index + 1}`,
+ }));
+ const states: Record = Object.fromEntries(
+ panels.flatMap((panel, index) => {
+ const panelNumber = index + 1;
+ const powerEntityId = `sensor.power_1_0_${panelNumber}`;
+ const statusEntityId = `sensor.status_1_0_${panelNumber}`;
+ return [
+ [
+ powerEntityId,
+ makeSensor(powerEntityId, "W", `Optimizer 1.0.${panelNumber} Power`),
+ ],
+ [
+ statusEntityId,
+ makeSensor(statusEntityId, "", `Optimizer 1.0.${panelNumber} Status`),
+ ],
+ ];
+ }),
+ );
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual(
+ Array.from({ length: 30 }, (_, index) => `sensor.status_1_0_${index + 1}`),
+ );
+ expect(result.filled).toBe(30);
+ });
+
+ it("uses explicit status search text to narrow one-to-one panel status matches", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", power_entity: "sensor.power_1_0_1" },
+ { id: "panel-2", power_entity: "sensor.power_1_0_2" },
+ { id: "panel-3", power_entity: "sensor.power_1_0_3" },
+ ];
+ const states: Record = {
+ "sensor.power_1_0_1": makeSensor("sensor.power_1_0_1", "W"),
+ "sensor.power_1_0_2": makeSensor("sensor.power_1_0_2", "W"),
+ "sensor.power_1_0_3": makeSensor("sensor.power_1_0_3", "W"),
+ "sensor.status_1_0_1": makeSensor("sensor.status_1_0_1", ""),
+ "sensor.status_1_0_2": makeSensor("sensor.status_1_0_2", ""),
+ "sensor.status_1_0_3": makeSensor("sensor.status_1_0_3", ""),
+ "sensor.inverter_status_1_0": makeSensor(
+ "sensor.inverter_status_1_0",
+ "",
+ "Inverter Status",
+ ),
+ };
+
+ const result = autoPopulateInverterStatusEntities(
+ panels,
+ states,
+ "sensor.status_1_0_",
+ );
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ "sensor.status_1_0_1",
+ "sensor.status_1_0_2",
+ "sensor.status_1_0_3",
+ ]);
+ });
+
+ it("prefers clear one-to-one panel status over a shared status candidate", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", power_entity: "sensor.power_1_0_1" },
+ { id: "panel-2", power_entity: "sensor.power_1_0_2" },
+ { id: "panel-3", power_entity: "sensor.power_1_0_3" },
+ ];
+ const states: Record = {
+ "sensor.power_1_0_1": makeSensor("sensor.power_1_0_1", "W"),
+ "sensor.power_1_0_2": makeSensor("sensor.power_1_0_2", "W"),
+ "sensor.power_1_0_3": makeSensor("sensor.power_1_0_3", "W"),
+ "sensor.status_1_0_1": makeSensor("sensor.status_1_0_1", ""),
+ "sensor.status_1_0_2": makeSensor("sensor.status_1_0_2", ""),
+ "sensor.status_1_0_3": makeSensor("sensor.status_1_0_3", ""),
+ "sensor.inverter_status_1_0": makeSensor(
+ "sensor.inverter_status_1_0",
+ "",
+ "Inverter Status",
+ ),
+ };
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ "sensor.status_1_0_1",
+ "sensor.status_1_0_2",
+ "sensor.status_1_0_3",
+ ]);
+ });
+
+ it("preserves manual one-to-one status values and skips disabled panel slots", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ enabled: false,
+ power_entity: "sensor.power_1_0_1",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.power_1_0_2",
+ inverter_status_entity: "sensor.manual_status",
+ },
+ { id: "panel-3", power_entity: "sensor.power_1_0_3" },
+ ];
+ const states: Record = {
+ "sensor.power_1_0_1": makeSensor("sensor.power_1_0_1", "W"),
+ "sensor.power_1_0_2": makeSensor("sensor.power_1_0_2", "W"),
+ "sensor.power_1_0_3": makeSensor("sensor.power_1_0_3", "W"),
+ "sensor.status_1_0_1": makeSensor("sensor.status_1_0_1", ""),
+ "sensor.status_1_0_2": makeSensor("sensor.status_1_0_2", ""),
+ "sensor.status_1_0_3": makeSensor("sensor.status_1_0_3", ""),
+ };
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ undefined,
+ "sensor.manual_status",
+ "sensor.status_1_0_3",
+ ]);
+ expect(result.skippedPanels).toBe(1);
+ });
+
+ it("leaves one-to-one panel status empty when local matches are ambiguous", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", power_entity: "sensor.power_1_0_1" },
+ { id: "panel-2", power_entity: "sensor.power_1_0_2" },
+ ];
+ const states: Record = {
+ "sensor.power_1_0_1": makeSensor("sensor.power_1_0_1", "W"),
+ "sensor.power_1_0_2": makeSensor("sensor.power_1_0_2", "W"),
+ "sensor.left_status_1_0_1": makeSensor("sensor.left_status_1_0_1", ""),
+ "sensor.right_status_1_0_1": makeSensor("sensor.right_status_1_0_1", ""),
+ "sensor.status_1_0_2": makeSensor("sensor.status_1_0_2", ""),
+ };
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ undefined,
+ "sensor.status_1_0_2",
+ ]);
+ });
+
+ it("reuses one same-serial status sensor for all enabled panels in that group", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ },
+ {
+ id: "panel-3",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.inverter_status_device_30m3y10": makeSensor(
+ "sensor.inverter_status_device_30m3y10",
+ "",
+ "Inverter Status",
+ ),
+ "sensor.inverter_status_device_40m3y20": makeSensor(
+ "sensor.inverter_status_device_40m3y20",
+ "",
+ "Inverter Status",
+ ),
+ };
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ "sensor.inverter_status_device_30m3y10",
+ "sensor.inverter_status_device_30m3y10",
+ "sensor.inverter_status_device_40m3y20",
+ ]);
+ expect(result.filled).toBe(3);
+ });
+
+ it("prefers the actual device status sensor over same-serial work mode sensors", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ enabled: false,
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t",
+ },
+ {
+ id: "panel-3",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10115030t",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10115030t": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10115030t",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_device_status_device_30m3y10115030t": makeSensor(
+ "sensor.sds_device_status_device_30m3y10115030t",
+ "",
+ "Device Status",
+ ),
+ "sensor.sds_work_mode_device_30m3y10115030t": makeSensor(
+ "sensor.sds_work_mode_device_30m3y10115030t",
+ "",
+ "Work Mode",
+ ),
+ };
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ undefined,
+ "sensor.sds_device_status_device_30m3y10115030t",
+ "sensor.sds_device_status_device_30m3y10115030t",
+ ]);
+ });
+
+ it("matches device status by serial token even when the serial is not repeated in enabled panels", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ enabled: false,
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t",
+ },
+ {
+ id: "panel-3",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10115036l",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10115036l": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10115036l",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_device_status_device_30m3y10115030t": makeSensor(
+ "sensor.sds_device_status_device_30m3y10115030t",
+ "",
+ "Device Status",
+ ),
+ "sensor.sds_device_status_device_30m3y10115036l": makeSensor(
+ "sensor.sds_device_status_device_30m3y10115036l",
+ "",
+ "Device Status",
+ ),
+ };
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ undefined,
+ "sensor.sds_device_status_device_30m3y10115030t",
+ "sensor.sds_device_status_device_30m3y10115036l",
+ ]);
+ });
+
+ it("prefers device status when several same-serial status sensors exist", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10115030t",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10115030t": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10115030t",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_battery_status_device_30m3y10115030t": makeSensor(
+ "sensor.sds_battery_status_device_30m3y10115030t",
+ "",
+ "Battery Status",
+ ),
+ "sensor.sds_wifi_status_device_30m3y10115030t": makeSensor(
+ "sensor.sds_wifi_status_device_30m3y10115030t",
+ "",
+ "Wifi Status",
+ ),
+ "sensor.sds_online_status_device_30m3y10115030t": makeSensor(
+ "sensor.sds_online_status_device_30m3y10115030t",
+ "",
+ "Online Status",
+ ),
+ "sensor.sds_device_status_device_30m3y10115030t": makeSensor(
+ "sensor.sds_device_status_device_30m3y10115030t",
+ "",
+ "Device Status",
+ ),
+ };
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ "sensor.sds_device_status_device_30m3y10115030t",
+ "sensor.sds_device_status_device_30m3y10115030t",
+ ]);
+ });
+
+ it("can match status entities from the hass state key when entity_id is missing", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10115030t",
+ },
+ ];
+ const statusWithoutEntityId = makeSensor(
+ "sensor.sds_device_status_device_30m3y10115030t",
+ "",
+ "Device Status",
+ );
+ delete (statusWithoutEntityId as Partial).entity_id;
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10115030t": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10115030t",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_device_status_device_30m3y10115030t": statusWithoutEntityId,
+ };
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ "sensor.sds_device_status_device_30m3y10115030t",
+ "sensor.sds_device_status_device_30m3y10115030t",
+ ]);
+ });
+
+ it("can match binary_sensor inverter status entities", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10115030t",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10115030t",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10115030t": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10115030t",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "binary_sensor.sds_device_status_device_30m3y10115030t": makeSensor(
+ "binary_sensor.sds_device_status_device_30m3y10115030t",
+ "",
+ "Device Status",
+ ),
+ };
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ "binary_sensor.sds_device_status_device_30m3y10115030t",
+ "binary_sensor.sds_device_status_device_30m3y10115030t",
+ ]);
+ });
+
+ it("preserves manual status values and skips disabled panels", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ enabled: false,
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ inverter_status_entity: "sensor.manual_status",
+ },
+ {
+ id: "panel-3",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ ),
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "W",
+ ),
+ "sensor.inverter_status_device_30m3y10": makeSensor(
+ "sensor.inverter_status_device_30m3y10",
+ "",
+ ),
+ "sensor.inverter_status_device_40m3y20": makeSensor(
+ "sensor.inverter_status_device_40m3y20",
+ "",
+ ),
+ };
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ undefined,
+ "sensor.manual_status",
+ "sensor.inverter_status_device_40m3y20",
+ ]);
+ expect(result.skippedPanels).toBe(1);
+ });
+
+ it("leaves status empty when the same-group match is ambiguous", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ ),
+ "sensor.left_status_device_30m3y10": makeSensor(
+ "sensor.left_status_device_30m3y10",
+ "",
+ ),
+ "sensor.right_status_device_30m3y10": makeSensor(
+ "sensor.right_status_device_30m3y10",
+ "",
+ ),
+ };
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ undefined,
+ undefined,
+ ]);
+ });
+
+ it("leaves status empty when there is no same-group status candidate", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", power_entity: "sensor.roof_panel_1_power" },
+ { id: "panel-2", power_entity: "sensor.roof_panel_2_power" },
+ ];
+ const states: Record = {
+ "sensor.roof_panel_1_power": makeSensor("sensor.roof_panel_1_power", "W"),
+ "sensor.roof_panel_2_power": makeSensor("sensor.roof_panel_2_power", "W"),
+ "sensor.inverter_status_device_other": makeSensor(
+ "sensor.inverter_status_device_other",
+ "",
+ ),
+ };
+
+ const result = autoPopulateInverterStatusEntities(panels, states);
+
+ expect(result.panels.map((panel) => panel.inverter_status_entity)).toEqual([
+ undefined,
+ undefined,
+ ]);
+ });
+});
+
+describe("sortSensorsForAutoPopulate", () => {
+ it("sorts entity ids by natural numeric groups", () => {
+ const sorted = sortSensorsForAutoPopulate([
+ { entityId: "sensor.slx_powerdc10" },
+ { entityId: "sensor.slx_powerdc2" },
+ { entityId: "sensor.slx_powerdc1" },
+ ]);
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.slx_powerdc1",
+ "sensor.slx_powerdc2",
+ "sensor.slx_powerdc10",
+ ]);
+ });
+
+ it("sorts dotted entity ids naturally across all numeric groups", () => {
+ const sorted = sortSensorsForAutoPopulate([
+ { entityId: "sensor.power_1_0_10" },
+ { entityId: "sensor.power_1_0_2" },
+ { entityId: "sensor.power_1_0_30" },
+ { entityId: "sensor.power_1_0_1" },
+ ]);
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.power_1_0_1",
+ "sensor.power_1_0_2",
+ "sensor.power_1_0_10",
+ "sensor.power_1_0_30",
+ ]);
+ });
+
+ it("sorts by friendly name when requested", () => {
+ const sorted = sortSensorsForAutoPopulate(
+ [
+ { entityId: "sensor.slx_power_alpha", friendlyName: "Panel 3" },
+ { entityId: "sensor.slx_power_beta", friendlyName: "Panel 1" },
+ { entityId: "sensor.slx_power_gamma", friendlyName: "Panel 2" },
+ ],
+ "friendlyName",
+ );
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.slx_power_beta",
+ "sensor.slx_power_gamma",
+ "sensor.slx_power_alpha",
+ ]);
+ });
+
+ it("falls back to lexical entity_id order when no numbers are present", () => {
+ const sorted = sortSensorsForAutoPopulate([
+ { entityId: "sensor.slx_power_zeta" },
+ { entityId: "sensor.slx_power_alpha" },
+ { entityId: "sensor.slx_power_mu" },
+ ]);
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.slx_power_alpha",
+ "sensor.slx_power_mu",
+ "sensor.slx_power_zeta",
+ ]);
+ });
+
+ it("groups sensors by a repeated suffix token when requested", () => {
+ const sorted = sortSensorsForAutoPopulate(
+ [
+ { entityId: "sensor.roof_input1_power_serial_BBB" },
+ { entityId: "sensor.roof_input3_power_serial_AAA" },
+ { entityId: "sensor.roof_input2_power_serial_BBB" },
+ { entityId: "sensor.roof_input1_power_serial_AAA" },
+ { entityId: "sensor.roof_input3_power_serial_BBB" },
+ { entityId: "sensor.roof_input2_power_serial_AAA" },
+ ],
+ "grouped",
+ );
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.roof_input1_power_serial_AAA",
+ "sensor.roof_input2_power_serial_AAA",
+ "sensor.roof_input3_power_serial_AAA",
+ "sensor.roof_input1_power_serial_BBB",
+ "sensor.roof_input2_power_serial_BBB",
+ "sensor.roof_input3_power_serial_BBB",
+ ]);
+ });
+
+ it("auto-detects complete repeated suffix groups without assuming a sensor name pattern", () => {
+ const sorted = sortSensorsForAutoPopulate([
+ { entityId: "sensor.roof_input1_power_serial_BBB" },
+ { entityId: "sensor.roof_input2_power_serial_AAA" },
+ { entityId: "sensor.roof_input2_power_serial_BBB" },
+ { entityId: "sensor.roof_input1_power_serial_AAA" },
+ ]);
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.roof_input1_power_serial_AAA",
+ "sensor.roof_input2_power_serial_AAA",
+ "sensor.roof_input1_power_serial_BBB",
+ "sensor.roof_input2_power_serial_BBB",
+ ]);
+ });
+
+ it("auto-detects entity id serial groups when friendly names hide the serial", () => {
+ const sorted = sortSensorsForAutoPopulate(
+ [
+ {
+ entityId: "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ friendlyName: "MPPT 1 Power",
+ },
+ {
+ entityId: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ friendlyName: "MPPT 1 Power",
+ },
+ {
+ entityId: "sensor.sds_mppt_map_mppt2_power_device_40m3y20",
+ friendlyName: "MPPT 2 Power",
+ },
+ {
+ entityId: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ friendlyName: "MPPT 2 Power",
+ },
+ ],
+ "auto",
+ "friendlyName",
+ );
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "sensor.sds_mppt_map_mppt2_power_device_40m3y20",
+ ]);
+ });
+
+ it("auto-detects entity id serial groups when some HA entity ids have extra suffix tokens", () => {
+ const sorted = sortSensorsForAutoPopulate(
+ [
+ {
+ entityId: "sensor.sds_mppt_map_mppt1_power_device_40m3y20_extra",
+ friendlyName: "MPPT 1 Power",
+ },
+ {
+ entityId: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ friendlyName: "MPPT 2 Power",
+ },
+ {
+ entityId: "sensor.sds_mppt_map_mppt2_power_device_40m3y20_extra",
+ friendlyName: "MPPT 2 Power",
+ },
+ {
+ entityId: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ friendlyName: "MPPT 1 Power",
+ },
+ ],
+ "auto",
+ "friendlyName",
+ );
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20_extra",
+ "sensor.sds_mppt_map_mppt2_power_device_40m3y20_extra",
+ ]);
+ });
+
+ it("auto-detects trailing serial suffixes after changing input tokens", () => {
+ const sorted = sortSensorsForAutoPopulate(
+ [
+ {
+ entityId: "sensor.slx_powerdc1_3030t",
+ friendlyName: "DC Power String 1",
+ },
+ {
+ entityId: "sensor.slx_powerdc2_3030l",
+ friendlyName: "DC Power String 2",
+ },
+ {
+ entityId: "sensor.slx_powerdc2_3030t",
+ friendlyName: "DC Power String 2",
+ },
+ {
+ entityId: "sensor.slx_powerdc1_3030l",
+ friendlyName: "DC Power String 1",
+ },
+ ],
+ "auto",
+ "friendlyName",
+ );
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.slx_powerdc1_3030l",
+ "sensor.slx_powerdc2_3030l",
+ "sensor.slx_powerdc1_3030t",
+ "sensor.slx_powerdc2_3030t",
+ ]);
+ });
+
+ it("does not mistake short local input suffixes for serial groups", () => {
+ const sorted = sortSensorsForAutoPopulate([
+ { entityId: "sensor.site_b_mppt2" },
+ { entityId: "sensor.site_a_mppt2" },
+ { entityId: "sensor.site_b_mppt1" },
+ { entityId: "sensor.site_a_mppt1" },
+ ]);
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.site_a_mppt1",
+ "sensor.site_a_mppt2",
+ "sensor.site_b_mppt1",
+ "sensor.site_b_mppt2",
+ ]);
+ });
+
+ it("auto-detects entity id serial groups when one group is incomplete", () => {
+ const sorted = sortSensorsForAutoPopulate(
+ [
+ {
+ entityId: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ friendlyName: "MPPT 2 Power",
+ },
+ {
+ entityId: "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ friendlyName: "MPPT 1 Power",
+ },
+ {
+ entityId: "sensor.sds_mppt_map_mppt2_power_device_40m3y20",
+ friendlyName: "MPPT 2 Power",
+ },
+ {
+ entityId: "sensor.sds_mppt_map_mppt1_power_device_50m3y30",
+ friendlyName: "MPPT 1 Power",
+ },
+ {
+ entityId: "sensor.sds_mppt_map_mppt2_power_device_50m3y30",
+ friendlyName: "MPPT 2 Power",
+ },
+ ],
+ "auto",
+ "friendlyName",
+ );
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "sensor.sds_mppt_map_mppt2_power_device_40m3y20",
+ "sensor.sds_mppt_map_mppt1_power_device_50m3y30",
+ "sensor.sds_mppt_map_mppt2_power_device_50m3y30",
+ ]);
+ });
+
+ it("does not auto-detect incomplete repeated suffix groups", () => {
+ const sorted = sortSensorsForAutoPopulate([
+ { entityId: "sensor.roof_input1_power_serial_BBB" },
+ { entityId: "sensor.roof_input2_power_serial_AAA" },
+ { entityId: "sensor.roof_input2_power_serial_BBB" },
+ { entityId: "sensor.roof_input1_power" },
+ ]);
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.roof_input1_power",
+ "sensor.roof_input1_power_serial_BBB",
+ "sensor.roof_input2_power_serial_AAA",
+ "sensor.roof_input2_power_serial_BBB",
+ ]);
+ });
+
+ it("keeps natural order when the repeated group appears before numeric inputs", () => {
+ const sorted = sortSensorsForAutoPopulate([
+ { entityId: "sensor.site_inverter_B_input_2_power" },
+ { entityId: "sensor.site_inverter_A_input_2_power" },
+ { entityId: "sensor.site_inverter_B_input_1_power" },
+ { entityId: "sensor.site_inverter_A_input_1_power" },
+ ]);
+
+ expect(sorted.map((item) => item.entityId)).toEqual([
+ "sensor.site_inverter_A_input_1_power",
+ "sensor.site_inverter_A_input_2_power",
+ "sensor.site_inverter_B_input_1_power",
+ "sensor.site_inverter_B_input_2_power",
+ ]);
+ });
+});
+
+describe("autoPopulatePanelEntities", () => {
+ it("fills empty slots in order and does not overwrite existing values", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", power_entity: undefined },
+ { id: "panel-2", power_entity: "sensor.slx_powerdc2" },
+ { id: "panel-3", power_entity: undefined },
+ ];
+
+ const states: Record = {
+ "sensor.slx_powerdc1": makeSensor("sensor.slx_powerdc1", "W", "Panel 1"),
+ "sensor.slx_powerdc2": makeSensor("sensor.slx_powerdc2", "W", "Panel 2"),
+ "sensor.slx_powerdc3": makeSensor("sensor.slx_powerdc3", "W", "Panel 3"),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "sensor.slx_powerdc",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual([
+ "sensor.slx_powerdc1",
+ "sensor.slx_powerdc2",
+ "sensor.slx_powerdc3",
+ ]);
+ expect(result.matched).toBe(3);
+ expect(result.filled).toBe(2);
+ expect(result.skipped).toBe(1);
+ });
+
+ it("does not assign duplicate sensors when one is already used", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", energy_entity: undefined },
+ { id: "panel-2", energy_entity: "sensor.slx_energy1" },
+ { id: "panel-3", energy_entity: undefined },
+ ];
+
+ const states: Record = {
+ "sensor.slx_energy1": makeSensor("sensor.slx_energy1", "kWh"),
+ "sensor.slx_energy2": makeSensor("sensor.slx_energy2", "kWh"),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "energy_entity",
+ "sensor.slx_energy",
+ (unit) => unit === "kwh" || unit === "wh",
+ );
+
+ expect(result.panels.map((panel) => panel.energy_entity)).toEqual([
+ "sensor.slx_energy2",
+ "sensor.slx_energy1",
+ undefined,
+ ]);
+ expect(result.matched).toBe(2);
+ expect(result.filled).toBe(1);
+ expect(result.skipped).toBe(2);
+ });
+
+ it("can repeat each matched power sensor across multiple enabled string panel slots", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", power_entity: undefined },
+ { id: "panel-2", enabled: false, power_entity: undefined },
+ { id: "panel-3", power_entity: undefined },
+ { id: "panel-4", power_entity: undefined },
+ { id: "panel-5", power_entity: undefined },
+ ];
+
+ const states: Record = {
+ "sensor.string_power_1": makeSensor("sensor.string_power_1", "W", "String Power 1"),
+ "sensor.string_power_2": makeSensor("sensor.string_power_2", "W", "String Power 2"),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "sensor.string_power_",
+ (unit) => unit === "w",
+ "auto",
+ { repeatEachCandidate: 2 },
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual([
+ "sensor.string_power_1",
+ undefined,
+ "sensor.string_power_1",
+ "sensor.string_power_2",
+ "sensor.string_power_2",
+ ]);
+ expect(result.matched).toBe(2);
+ expect(result.filled).toBe(4);
+ expect(result.skipped).toBe(1);
+ });
+
+ it("matches every sensor that starts with the prefix", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", power_entity: undefined },
+ { id: "panel-2", power_entity: undefined },
+ ];
+
+ const states: Record = {
+ "sensor.slx_powerdc1": makeSensor("sensor.slx_powerdc1", "W"),
+ "sensor.slx_powerdcTotal": makeSensor("sensor.slx_powerdcTotal", "W"),
+ "sensor.other_powerdc1": makeSensor("sensor.other_powerdc1", "W"),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "sensor.slx_powerdc",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual([
+ "sensor.slx_powerdc1",
+ "sensor.slx_powerdcTotal",
+ ]);
+ expect(result.matched).toBe(2);
+ });
+
+ it("matches power sensors by friendly name when search is not an entity id", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", power_entity: undefined },
+ { id: "panel-2", power_entity: undefined },
+ ];
+
+ const states: Record = {
+ "sensor.ac_power_1": makeSensor("sensor.ac_power_1", "W", "Roof Panel 1 power"),
+ "sensor.ac_power_2": makeSensor("sensor.ac_power_2", "W", "Roof Panel 2 power"),
+ "sensor.other_power": makeSensor("sensor.other_power", "W", "Garage panel power"),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "roof panel",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual([
+ "sensor.ac_power_1",
+ "sensor.ac_power_2",
+ ]);
+ expect(result.matched).toBe(2);
+ });
+
+ it("fills dotted panel power sensors in natural entity id order", () => {
+ const panels: PanelConfig[] = Array.from({ length: 30 }, (_, index) => ({
+ id: `panel-${index + 1}`,
+ power_entity: undefined,
+ }));
+ const states: Record = Object.fromEntries(
+ Array.from({ length: 30 }, (_, index) => {
+ const number = index + 1;
+ const entityId = `sensor.power_1_0_${number}`;
+ return [
+ entityId,
+ makeSensor(entityId, "W", `Optimizer 1.0.${number} Power`),
+ ];
+ }).reverse(),
+ );
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "sensor.power_1_0_",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual(
+ Array.from({ length: 30 }, (_, index) => `sensor.power_1_0_${index + 1}`),
+ );
+ expect(result.matched).toBe(30);
+ expect(result.filled).toBe(30);
+ });
+
+ it("fills friendly-name searches in natural friendly name order", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", power_entity: undefined },
+ { id: "panel-2", power_entity: undefined },
+ { id: "panel-3", power_entity: undefined },
+ { id: "panel-4", power_entity: undefined },
+ ];
+
+ const states: Record = {
+ "sensor.optimizer_delta": makeSensor(
+ "sensor.optimizer_delta",
+ "W",
+ "Optimizer 1.0.30 Power",
+ ),
+ "sensor.optimizer_beta": makeSensor(
+ "sensor.optimizer_beta",
+ "W",
+ "Optimizer 1.0.2 Power",
+ ),
+ "sensor.optimizer_gamma": makeSensor(
+ "sensor.optimizer_gamma",
+ "W",
+ "Optimizer 1.0.10 Power",
+ ),
+ "sensor.optimizer_alpha": makeSensor(
+ "sensor.optimizer_alpha",
+ "W",
+ "Optimizer 1.0.1 Power",
+ ),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "optimizer",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual([
+ "sensor.optimizer_alpha",
+ "sensor.optimizer_beta",
+ "sensor.optimizer_gamma",
+ "sensor.optimizer_delta",
+ ]);
+ expect(result.matched).toBe(4);
+ expect(result.filled).toBe(4);
+ });
+
+ it("auto-populates repeated suffix sensor groups together", () => {
+ const panels: PanelConfig[] = Array.from({ length: 6 }, (_, index) => ({
+ id: `panel-${index + 1}`,
+ power_entity: undefined,
+ }));
+ const states: Record = {
+ "sensor.roof_input1_power_serial_BBB": makeSensor(
+ "sensor.roof_input1_power_serial_BBB",
+ "W",
+ ),
+ "sensor.roof_input3_power_serial_AAA": makeSensor(
+ "sensor.roof_input3_power_serial_AAA",
+ "W",
+ ),
+ "sensor.roof_input2_power_serial_BBB": makeSensor(
+ "sensor.roof_input2_power_serial_BBB",
+ "W",
+ ),
+ "sensor.roof_input1_power_serial_AAA": makeSensor(
+ "sensor.roof_input1_power_serial_AAA",
+ "W",
+ ),
+ "sensor.roof_input3_power_serial_BBB": makeSensor(
+ "sensor.roof_input3_power_serial_BBB",
+ "W",
+ ),
+ "sensor.roof_input2_power_serial_AAA": makeSensor(
+ "sensor.roof_input2_power_serial_AAA",
+ "W",
+ ),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "sensor.roof_",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual([
+ "sensor.roof_input1_power_serial_AAA",
+ "sensor.roof_input2_power_serial_AAA",
+ "sensor.roof_input3_power_serial_AAA",
+ "sensor.roof_input1_power_serial_BBB",
+ "sensor.roof_input2_power_serial_BBB",
+ "sensor.roof_input3_power_serial_BBB",
+ ]);
+ expect(result.matched).toBe(6);
+ expect(result.filled).toBe(6);
+ });
+
+ it("auto-populates friendly-name searches by entity id serial groups", () => {
+ const panels: PanelConfig[] = Array.from({ length: 4 }, (_, index) => ({
+ id: `panel-${index + 1}`,
+ power_entity: undefined,
+ }));
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_40m3y20": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_40m3y20",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ "MPPT 2 Power",
+ ),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "mppt",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual([
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "sensor.sds_mppt_map_mppt2_power_device_40m3y20",
+ ]);
+ expect(result.matched).toBe(4);
+ expect(result.filled).toBe(4);
+ });
+
+ it("auto-populates friendly-name DC string searches by trailing serial groups", () => {
+ const panels: PanelConfig[] = Array.from({ length: 4 }, (_, index) => ({
+ id: `panel-${index + 1}`,
+ power_entity: undefined,
+ }));
+ const states: Record = {
+ "sensor.slx_powerdc1_3030t": makeSensor(
+ "sensor.slx_powerdc1_3030t",
+ "W",
+ "DC Power String 1",
+ ),
+ "sensor.slx_powerdc1_3030l": makeSensor(
+ "sensor.slx_powerdc1_3030l",
+ "W",
+ "DC Power String 1",
+ ),
+ "sensor.slx_powerdc2_3030t": makeSensor(
+ "sensor.slx_powerdc2_3030t",
+ "W",
+ "DC Power String 2",
+ ),
+ "sensor.slx_powerdc2_3030l": makeSensor(
+ "sensor.slx_powerdc2_3030l",
+ "W",
+ "DC Power String 2",
+ ),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "dc power",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual([
+ "sensor.slx_powerdc1_3030l",
+ "sensor.slx_powerdc2_3030l",
+ "sensor.slx_powerdc1_3030t",
+ "sensor.slx_powerdc2_3030t",
+ ]);
+ expect(result.matched).toBe(4);
+ expect(result.filled).toBe(4);
+ });
+
+ it("keeps serial-group order when the first panel slot is disabled", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", enabled: false, power_entity: undefined },
+ { id: "panel-2", power_entity: undefined },
+ { id: "panel-3", power_entity: undefined },
+ { id: "panel-4", power_entity: undefined },
+ { id: "panel-5", power_entity: undefined },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_40m3y20": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_40m3y20",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ "MPPT 2 Power",
+ ),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "mppt",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual([
+ undefined,
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "sensor.sds_mppt_map_mppt2_power_device_40m3y20",
+ ]);
+ expect(result.filled).toBe(4);
+ expect(result.skipped).toBe(1);
+ });
+
+ it("keeps serial-group order with a disabled first slot and an incomplete first serial group", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", enabled: false, power_entity: undefined },
+ { id: "panel-2", power_entity: undefined },
+ { id: "panel-3", power_entity: undefined },
+ { id: "panel-4", power_entity: undefined },
+ { id: "panel-5", power_entity: undefined },
+ { id: "panel-6", power_entity: undefined },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_40m3y20": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_40m3y20",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_power_device_50m3y30": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_50m3y30",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_50m3y30": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_50m3y30",
+ "W",
+ "MPPT 2 Power",
+ ),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "mppt",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual([
+ undefined,
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "sensor.sds_mppt_map_mppt2_power_device_40m3y20",
+ "sensor.sds_mppt_map_mppt1_power_device_50m3y30",
+ "sensor.sds_mppt_map_mppt2_power_device_50m3y30",
+ ]);
+ expect(result.filled).toBe(5);
+ expect(result.skipped).toBe(1);
+ });
+
+ it("matches energy sensors by friendly name with case-insensitive search", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", energy_entity: undefined },
+ { id: "panel-2", energy_entity: undefined },
+ ];
+
+ const states: Record = {
+ "sensor.energy_a": makeSensor("sensor.energy_a", "kWh", "Panel Daily Energy 1"),
+ "sensor.energy_b": makeSensor("sensor.energy_b", "Wh", "Panel Daily Energy 2"),
+ "sensor.energy_c": makeSensor("sensor.energy_c", "kWh", "Battery Daily Energy"),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "energy_entity",
+ "panel daily energy",
+ (unit) => unit === "kwh" || unit === "wh",
+ );
+
+ expect(result.panels.map((panel) => panel.energy_entity)).toEqual([
+ "sensor.energy_a",
+ "sensor.energy_b",
+ ]);
+ expect(result.matched).toBe(2);
+ });
+
+ it("uses entity id prefix mode when search starts with sensor dot", () => {
+ const panels: PanelConfig[] = [{ id: "panel-1", power_entity: undefined }];
+
+ const states: Record = {
+ "sensor.real_power_1": makeSensor("sensor.real_power_1", "W", "Friendly Power 1"),
+ "sensor.other_power_1": makeSensor("sensor.other_power_1", "W", "sensor.real power 2"),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "sensor.real_power",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels[0].power_entity).toBe("sensor.real_power_1");
+ expect(result.matched).toBe(1);
+ });
+
+ it("supports wildcard suffix in prefix input", () => {
+ const panels: PanelConfig[] = [{ id: "panel-1", power_entity: undefined }];
+
+ const states: Record = {
+ "sensor.slx_powerdc1": makeSensor("sensor.slx_powerdc1", "W"),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "sensor.slx_powerdc*",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels[0].power_entity).toBe("sensor.slx_powerdc1");
+ expect(result.matched).toBe(1);
+ expect(result.filled).toBe(1);
+ });
+
+ it("matches prefix with exact case-sensitive startsWith", () => {
+ const panels: PanelConfig[] = [{ id: "panel-1", power_entity: undefined }];
+ const states: Record = {
+ "sensor.slx_powerdc1": makeSensor("sensor.slx_powerdc1", "W"),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "sensor.slx_powerdc",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels[0].power_entity).toBe("sensor.slx_powerdc1");
+ expect(result.matched).toBe(1);
+ expect(result.filled).toBe(1);
+ });
+
+ it("does not match when case differs", () => {
+ const panels: PanelConfig[] = [{ id: "panel-1", power_entity: undefined }];
+ const states: Record = {
+ "sensor.slx_powerdc1": makeSensor("sensor.slx_powerdc1", "W"),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "sensor.slx_powerDC",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels[0].power_entity).toBeUndefined();
+ expect(result.matched).toBe(0);
+ expect(result.filled).toBe(0);
+ });
+
+ it("repopulates all slots when caller clears existing assignments first", () => {
+ const existingPanels: PanelConfig[] = [
+ { id: "panel-1", power_entity: "sensor.old_1" },
+ { id: "panel-2", power_entity: "sensor.old_2" },
+ { id: "panel-3", power_entity: "sensor.old_3" },
+ ];
+ const cleared = existingPanels.map((panel) => ({ ...panel, power_entity: undefined }));
+ const states: Record = {
+ "sensor.slx_powerdc1": makeSensor("sensor.slx_powerdc1", "W"),
+ "sensor.slx_powerdc2": makeSensor("sensor.slx_powerdc2", "W"),
+ "sensor.slx_powerdc3": makeSensor("sensor.slx_powerdc3", "W"),
+ };
+
+ const result = autoPopulatePanelEntities(
+ cleared,
+ states,
+ "power_entity",
+ "sensor.slx_powerdc",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual([
+ "sensor.slx_powerdc1",
+ "sensor.slx_powerdc2",
+ "sensor.slx_powerdc3",
+ ]);
+ });
+
+ it("skips hidden panel slots during auto-population", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", enabled: true, power_entity: undefined },
+ { id: "panel-2", enabled: false, power_entity: undefined },
+ { id: "panel-3", enabled: true, power_entity: undefined },
+ ];
+ const states: Record = {
+ "sensor.slx_powerdc1": makeSensor("sensor.slx_powerdc1", "W"),
+ "sensor.slx_powerdc2": makeSensor("sensor.slx_powerdc2", "W"),
+ "sensor.slx_powerdc3": makeSensor("sensor.slx_powerdc3", "W"),
+ };
+
+ const result = autoPopulatePanelEntities(
+ panels,
+ states,
+ "power_entity",
+ "sensor.slx_powerdc",
+ (unit) => unit === "w",
+ );
+
+ expect(result.panels.map((panel) => panel.power_entity)).toEqual([
+ "sensor.slx_powerdc1",
+ undefined,
+ "sensor.slx_powerdc2",
+ ]);
+ });
+});
+
+describe("autoPopulateEnergyEntitiesByPowerAnchor", () => {
+ it("assigns one inverter daily energy sensor only once per serial group", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ },
+ {
+ id: "panel-3",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.daily_yield_device_30m3y10": makeSensor(
+ "sensor.daily_yield_device_30m3y10",
+ "kWh",
+ "Daily Yield",
+ ),
+ "sensor.daily_yield_device_40m3y20": makeSensor(
+ "sensor.daily_yield_device_40m3y20",
+ "kWh",
+ "Daily Yield",
+ ),
+ };
+
+ const result = autoPopulateEnergyEntitiesByPowerAnchor(
+ panels,
+ states,
+ "daily",
+ (unit) => unit === "kwh" || unit === "wh",
+ );
+
+ expect(result.panels.map((panel) => panel.energy_entity)).toEqual([
+ "sensor.daily_yield_device_30m3y10",
+ undefined,
+ "sensor.daily_yield_device_40m3y20",
+ ]);
+ expect(result.matched).toBe(2);
+ expect(result.filled).toBe(2);
+ });
+
+ it("can assign one inverter daily energy sensor to every matching string panel when sharing is enabled", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ },
+ {
+ id: "panel-3",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.daily_yield_device_30m3y10": makeSensor(
+ "sensor.daily_yield_device_30m3y10",
+ "kWh",
+ "Daily Yield",
+ ),
+ "sensor.daily_yield_device_40m3y20": makeSensor(
+ "sensor.daily_yield_device_40m3y20",
+ "kWh",
+ "Daily Yield",
+ ),
+ };
+
+ const result = autoPopulateEnergyEntitiesByPowerAnchor(
+ panels,
+ states,
+ "daily",
+ (unit) => unit === "kwh" || unit === "wh",
+ "auto",
+ { shareSingleGroupEnergy: true },
+ );
+
+ expect(result.panels.map((panel) => panel.energy_entity)).toEqual([
+ "sensor.daily_yield_device_30m3y10",
+ "sensor.daily_yield_device_30m3y10",
+ "sensor.daily_yield_device_40m3y20",
+ ]);
+ expect(result.matched).toBe(2);
+ expect(result.filled).toBe(3);
+ });
+
+ it("finds one Yield Today energy sensor per trailing serial group by friendly name and energy device class", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ power_entity: "sensor.slx_powerdc1_3030t",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.slx_powerdc2_3030t",
+ },
+ {
+ id: "panel-3",
+ power_entity: "sensor.slx_powerdc1_3030l",
+ },
+ {
+ id: "panel-4",
+ power_entity: "sensor.slx_powerdc2_3030l",
+ },
+ ];
+ const states: Record = {
+ "sensor.slx_powerdc1_3030t": makeSensor(
+ "sensor.slx_powerdc1_3030t",
+ "W",
+ "DC Power String 1",
+ ),
+ "sensor.slx_powerdc2_3030t": makeSensor(
+ "sensor.slx_powerdc2_3030t",
+ "W",
+ "DC Power String 2",
+ ),
+ "sensor.slx_powerdc1_3030l": makeSensor(
+ "sensor.slx_powerdc1_3030l",
+ "W",
+ "DC Power String 1",
+ ),
+ "sensor.slx_powerdc2_3030l": makeSensor(
+ "sensor.slx_powerdc2_3030l",
+ "W",
+ "DC Power String 2",
+ ),
+ "sensor.slx_yieldtoday_3030t": makeSensor(
+ "sensor.slx_yieldtoday_3030t",
+ "",
+ "Yield Today",
+ { device_class: "energy" },
+ ),
+ "sensor.slx_yieldtoday_3030l": makeSensor(
+ "sensor.slx_yieldtoday_3030l",
+ "",
+ "Yield Today",
+ { device_class: "energy" },
+ ),
+ };
+
+ const result = autoPopulateEnergyEntitiesByPowerAnchor(
+ panels,
+ states,
+ "Yield Today",
+ (unit, entity) => unit === "kwh" || unit === "wh" || entity.attributes?.device_class === "energy",
+ );
+
+ expect(result.panels.map((panel) => panel.energy_entity)).toEqual([
+ "sensor.slx_yieldtoday_3030t",
+ undefined,
+ "sensor.slx_yieldtoday_3030l",
+ undefined,
+ ]);
+ expect(result.matched).toBe(2);
+ expect(result.filled).toBe(2);
+ });
+
+ it("matches per-panel daily energy sensors by local tokens within the same serial", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_daily_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_daily_device_30m3y10",
+ "kWh",
+ "MPPT 1 Daily Yield",
+ ),
+ "sensor.sds_mppt_map_mppt2_daily_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_daily_device_30m3y10",
+ "kWh",
+ "MPPT 2 Daily Yield",
+ ),
+ };
+
+ const result = autoPopulateEnergyEntitiesByPowerAnchor(
+ panels,
+ states,
+ "daily",
+ (unit) => unit === "kwh" || unit === "wh",
+ );
+
+ expect(result.panels.map((panel) => panel.energy_entity)).toEqual([
+ "sensor.sds_mppt_map_mppt1_daily_device_30m3y10",
+ "sensor.sds_mppt_map_mppt2_daily_device_30m3y10",
+ ]);
+ });
+
+ it("does not use another input's daily energy sensor when a local match is missing", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_daily_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_daily_device_30m3y10",
+ "kWh",
+ "MPPT 1 Daily Yield",
+ ),
+ "sensor.daily_yield_device_30m3y10": makeSensor(
+ "sensor.daily_yield_device_30m3y10",
+ "kWh",
+ "Daily Yield",
+ ),
+ };
+
+ const result = autoPopulateEnergyEntitiesByPowerAnchor(
+ panels,
+ states,
+ "daily",
+ (unit) => unit === "kwh" || unit === "wh",
+ );
+
+ expect(result.panels.map((panel) => panel.energy_entity)).toEqual([
+ "sensor.sds_mppt_map_mppt1_daily_device_30m3y10",
+ undefined,
+ ]);
+ });
+
+ it("falls back to normal slot order when power serial groups cannot be detected", () => {
+ const panels: PanelConfig[] = [
+ { id: "panel-1", power_entity: "sensor.panel_1_power" },
+ { id: "panel-2", power_entity: "sensor.panel_2_power" },
+ ];
+ const states: Record = {
+ "sensor.panel_1_power": makeSensor("sensor.panel_1_power", "W"),
+ "sensor.panel_2_power": makeSensor("sensor.panel_2_power", "W"),
+ "sensor.panel_1_daily": makeSensor("sensor.panel_1_daily", "kWh", "Panel 1 Daily"),
+ "sensor.panel_2_daily": makeSensor("sensor.panel_2_daily", "kWh", "Panel 2 Daily"),
+ };
+
+ const result = autoPopulateEnergyEntitiesByPowerAnchor(
+ panels,
+ states,
+ "daily",
+ (unit) => unit === "kwh" || unit === "wh",
+ );
+
+ expect(result.panels.map((panel) => panel.energy_entity)).toEqual([
+ "sensor.panel_1_daily",
+ "sensor.panel_2_daily",
+ ]);
+ });
+
+ it("uses the first enabled panel in a serial group for shared daily energy", () => {
+ const panels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ enabled: false,
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ },
+ {
+ id: "panel-2",
+ power_entity: "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ },
+ {
+ id: "panel-3",
+ power_entity: "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ },
+ ];
+ const states: Record = {
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_30m3y10",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10": makeSensor(
+ "sensor.sds_mppt_map_mppt2_power_device_30m3y10",
+ "W",
+ "MPPT 2 Power",
+ ),
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20": makeSensor(
+ "sensor.sds_mppt_map_mppt1_power_device_40m3y20",
+ "W",
+ "MPPT 1 Power",
+ ),
+ "sensor.daily_yield_device_30m3y10": makeSensor(
+ "sensor.daily_yield_device_30m3y10",
+ "kWh",
+ "Daily Yield",
+ ),
+ "sensor.daily_yield_device_40m3y20": makeSensor(
+ "sensor.daily_yield_device_40m3y20",
+ "kWh",
+ "Daily Yield",
+ ),
+ };
+
+ const result = autoPopulateEnergyEntitiesByPowerAnchor(
+ panels,
+ states,
+ "daily",
+ (unit) => unit === "kwh" || unit === "wh",
+ );
+
+ expect(result.panels.map((panel) => panel.energy_entity)).toEqual([
+ undefined,
+ "sensor.daily_yield_device_30m3y10",
+ "sensor.daily_yield_device_40m3y20",
+ ]);
+ });
+});
+
+describe("shouldAutoRenamePanelName", () => {
+ it("allows names that match the previous power sensor friendly name", () => {
+ expect(
+ shouldAutoRenamePanelName(
+ "Old MPPT Power 1",
+ "panel-1",
+ "sensor.old_mppt_1",
+ (entityId) =>
+ entityId === "sensor.old_mppt_1" ? "Old MPPT Power 1" : undefined,
+ ),
+ ).toBe(true);
+ });
+
+ it("preserves custom panel names", () => {
+ expect(
+ shouldAutoRenamePanelName(
+ "South roof string",
+ "panel-1",
+ "sensor.old_mppt_1",
+ (entityId) =>
+ entityId === "sensor.old_mppt_1" ? "Old MPPT Power 1" : undefined,
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("applyAutoPopulateFriendlyNames", () => {
+ const friendlyNames: Record = {
+ "sensor.old_mppt_1": "Old MPPT Power 1",
+ "sensor.new_mppt_1": "MPPT 1 Power",
+ "sensor.new_mppt_2": "MPPT 2 Power",
+ };
+ const resolveFriendlyName = (entityId: string | undefined): string | undefined =>
+ entityId ? friendlyNames[entityId] : undefined;
+
+ it("renames populated panels to the selected sensor friendly name", () => {
+ const populatedPanels: PanelConfig[] = [
+ {
+ id: "panel-1",
+ name: "AC Power1",
+ power_entity: "sensor.new_mppt_1",
+ },
+ ];
+
+ const result = applyAutoPopulateFriendlyNames(
+ populatedPanels,
+ resolveFriendlyName,
+ );
+
+ expect(result[0].name).toBe("MPPT 1 Power");
+ });
+
+ it("renames default panel names to the populated sensor friendly name", () => {
+ const result = applyAutoPopulateFriendlyNames(
+ [{ id: "panel-2", name: "Panel 2", power_entity: "sensor.new_mppt_2" }],
+ resolveFriendlyName,
+ );
+
+ expect(result[0].name).toBe("MPPT 2 Power");
+ });
+
+ it("leaves the existing name when the populated sensor has no friendly name", () => {
+ const result = applyAutoPopulateFriendlyNames(
+ [
+ {
+ id: "panel-1",
+ name: "South roof string",
+ power_entity: "sensor.unknown_mppt_1",
+ },
+ ],
+ resolveFriendlyName,
+ );
+
+ expect(result[0].name).toBe("South roof string");
+ });
+});
diff --git a/test/editor-markup.test.ts b/test/editor-markup.test.ts
new file mode 100644
index 0000000..62b0735
--- /dev/null
+++ b/test/editor-markup.test.ts
@@ -0,0 +1,67 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+describe("editor markup", () => {
+ it("uses native inputs for text fields so core setup controls cannot disappear", () => {
+ const source = readFileSync("src/solar-panel-visualizer-editor.ts", "utf8");
+
+ expect(source).not.toContain(" {
+ const editorSource = readFileSync("src/solar-panel-visualizer-editor.ts", "utf8");
+ const cardSource = readFileSync("src/solar-panel-visualizer-card.ts", "utf8");
+
+ expect(editorSource).not.toContain("ha-formfield");
+ expect(cardSource).not.toContain("ha-formfield");
+ expect(editorSource.match(/${this._t("editor.toggle.motion_enabled")}');
+ expect(cardSource).toContain(">Disable Panel (hide but keep slot when off)");
+ });
+
+ it("includes an editor-only auto-fill sort mode selector", () => {
+ const source = readFileSync("src/solar-panel-visualizer-editor.ts", "utf8");
+
+ expect(source).toContain("_renderAutoFillSortModeField");
+ expect(source).toContain('value: "grouped"');
+ expect(source).toContain("_autoFillSortMode");
+ expect(source).not.toContain("autofill_sort_mode:");
+ });
+
+ it("includes editor-only status and advanced telemetry auto-fill controls", () => {
+ const source = readFileSync("src/solar-panel-visualizer-editor.ts", "utf8");
+
+ expect(source).toContain("_autoFillInverterStatus");
+ expect(source).toContain("_autoFillStatusPrefix");
+ expect(source).toContain("editor.field.status_prefix");
+ expect(source).toContain("editor.toggle.autofill_inverter_status");
+ expect(source).toContain("autoPopulateInverterStatusEntities");
+ expect(source).toContain('class="autofill-option"');
+ expect(source).toContain('type="checkbox"');
+ expect(source).not.toContain("autofill_inverter_status:");
+ expect(source).not.toContain("autofill_status_prefix:");
+ expect(source).toContain("_autoFillAdvancedTelemetry");
+ expect(source).toContain("editor.toggle.autofill_advanced_telemetry");
+ expect(source).toContain("autoPopulateAdvancedTelemetry");
+ expect(source).not.toContain("autofill_advanced_telemetry:");
+ });
+
+ it("includes saved string setup tree controls and removes the old auto-fill repeat field", () => {
+ const source = readFileSync("src/solar-panel-visualizer-editor.ts", "utf8");
+
+ expect(source).toContain("_renderStringSetupSection");
+ expect(source).toContain("editor.toggle.enable_string_sensor_sharing");
+ expect(source).toContain("editor.field.string_group_count");
+ expect(source).toContain("editor.field.string_group_panel_count");
+ expect(source).toContain("editor.field.string_group_power_sensor");
+ expect(source).toContain("editor.field.string_group_energy_sensor");
+ expect(source).toContain("enable_string_sensor_sharing");
+ expect(source).toContain("allowDuplicatePanelSensors");
+ expect(source).not.toContain("_autoFillPanelsPerString");
+ expect(source).not.toContain("editor.field.autofill_panels_per_string");
+ expect(source).not.toContain("autofill_panels_per_string:");
+ });
+});
diff --git a/test/forecast.test.ts b/test/forecast.test.ts
new file mode 100644
index 0000000..2266f8e
--- /dev/null
+++ b/test/forecast.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import {
+ FORECAST_ENERGY_ENTITY_ID,
+ FORECAST_POWER_ENTITY_ID,
+ getForecastEntityId,
+ resolveForecastEntityId,
+} from "../src/forecast";
+
+describe("forecast helpers", () => {
+ it("returns fixed Forecast.Solar default entity ids", () => {
+ expect(getForecastEntityId("power")).toBe("sensor.power_production_now");
+ expect(getForecastEntityId("energy")).toBe("sensor.energy_production_today");
+ });
+
+ it("resolves power forecast only when exact default entity exists", () => {
+ expect(
+ resolveForecastEntityId(
+ {
+ states: {
+ [FORECAST_POWER_ENTITY_ID]: {
+ entity_id: FORECAST_POWER_ENTITY_ID,
+ state: "123",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.some_other_forecast_power": {
+ entity_id: "sensor.some_other_forecast_power",
+ state: "120",
+ attributes: { unit_of_measurement: "W" },
+ },
+ },
+ },
+ "power",
+ ),
+ ).toBe(FORECAST_POWER_ENTITY_ID);
+ });
+
+ it("resolves energy forecast only when exact default entity exists", () => {
+ expect(
+ resolveForecastEntityId(
+ {
+ states: {
+ [FORECAST_ENERGY_ENTITY_ID]: {
+ entity_id: FORECAST_ENERGY_ENTITY_ID,
+ state: "8.4",
+ attributes: { unit_of_measurement: "kWh" },
+ },
+ "sensor.energy_production_tomorrow": {
+ entity_id: "sensor.energy_production_tomorrow",
+ state: "7.2",
+ attributes: { unit_of_measurement: "kWh" },
+ },
+ },
+ },
+ "energy",
+ ),
+ ).toBe(FORECAST_ENERGY_ENTITY_ID);
+ });
+
+ it("returns null when default forecast entities are missing", () => {
+ expect(resolveForecastEntityId({ states: {} }, "power")).toBeNull();
+ expect(resolveForecastEntityId({ states: {} }, "energy")).toBeNull();
+ });
+});
+
diff --git a/test/i18n.test.ts b/test/i18n.test.ts
new file mode 100644
index 0000000..c6c43aa
--- /dev/null
+++ b/test/i18n.test.ts
@@ -0,0 +1,122 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import {
+ getBuiltInCatalogs,
+ getLocaleCandidates,
+ translateByLocale,
+ translateWithCatalogs,
+ type TranslationCatalog,
+} from "../src/i18n";
+
+const flattenLeafKeys = (
+ value: unknown,
+ prefix = "",
+): Map => {
+ const keys = new Map();
+ if (typeof value !== "object" || value === null) {
+ return keys;
+ }
+
+ for (const [key, child] of Object.entries(value as Record)) {
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
+ if (typeof child === "string") {
+ keys.set(nextPrefix, "string");
+ continue;
+ }
+ if (typeof child === "object" && child !== null && !Array.isArray(child)) {
+ for (const [leafKey, leafType] of flattenLeafKeys(child, nextPrefix)) {
+ keys.set(leafKey, leafType);
+ }
+ }
+ }
+
+ return keys;
+};
+
+describe("i18n helper", () => {
+ it("builds locale candidates in full->base->en order", () => {
+ expect(getLocaleCandidates("sv-SE")).toEqual(["sv-se", "sv", "en"]);
+ expect(getLocaleCandidates("en-US")).toEqual(["en-us", "en"]);
+ expect(getLocaleCandidates(undefined)).toEqual(["en"]);
+ });
+
+ it("falls back from full locale to base locale and then english", () => {
+ const catalogs = {
+ en: {
+ demo: {
+ hello: "Hello {name}",
+ fallback: "Fallback value",
+ },
+ },
+ sv: {
+ demo: {
+ hello: "Hej {name}",
+ fallback: "Reserv",
+ },
+ },
+ } as unknown as Record;
+
+ expect(
+ translateWithCatalogs(catalogs, "sv-SE", "demo.hello", { name: "Martin" }),
+ ).toBe("Hej Martin");
+ expect(
+ translateWithCatalogs(catalogs, "de-DE", "demo.fallback"),
+ ).toBe("Fallback value");
+ });
+
+ it("returns english strings for built-in translations", () => {
+ expect(translateByLocale("en", "card.summary.power")).toBe("Power");
+ expect(translateByLocale("fr-FR", "card.summary.power")).toBe("Power");
+ });
+});
+
+describe("translation catalog integrity", () => {
+ it("ensures all non-english catalogs exactly match english leaf keys and value types", () => {
+ const translationsDir = join(process.cwd(), "src", "translations");
+ const files = readdirSync(translationsDir).filter((file) => file.endsWith(".json"));
+ const englishFile = files.find((file) => file === "en.json");
+ expect(englishFile).toBeDefined();
+
+ const englishCatalog = JSON.parse(
+ readFileSync(join(translationsDir, "en.json"), "utf-8"),
+ ) as unknown;
+ const englishKeys = flattenLeafKeys(englishCatalog);
+ expect(englishKeys.size).toBeGreaterThan(0);
+
+ for (const file of files) {
+ if (file === "en.json") {
+ continue;
+ }
+
+ const localeCatalog = JSON.parse(
+ readFileSync(join(translationsDir, file), "utf-8"),
+ ) as unknown;
+ const localeKeys = flattenLeafKeys(localeCatalog);
+
+ const missing = [...englishKeys.keys()].filter((key) => !localeKeys.has(key));
+ const extras = [...localeKeys.keys()].filter((key) => !englishKeys.has(key));
+ const wrongTypes = [...englishKeys.keys()].filter(
+ (key) => localeKeys.get(key) !== englishKeys.get(key),
+ );
+
+ expect(
+ { file, missing, extras, wrongTypes },
+ `Translation schema mismatch in ${file}`,
+ ).toEqual({
+ file,
+ missing: [],
+ extras: [],
+ wrongTypes: [],
+ });
+ }
+ });
+
+ it("contains runtime-required keys in english catalog", () => {
+ const catalogs = getBuiltInCatalogs();
+ const english = flattenLeafKeys(catalogs.en);
+ expect(english.has("card.summary.power")).toBe(true);
+ expect(english.has("editor.section.layout_title")).toBe(true);
+ expect(english.has("editor.section.motion_title")).toBe(true);
+ expect(english.has("state.reason.array_check_disabled")).toBe(true);
+ });
+});
diff --git a/test/kpi-detail-layout.test.ts b/test/kpi-detail-layout.test.ts
new file mode 100644
index 0000000..cfd5b8e
--- /dev/null
+++ b/test/kpi-detail-layout.test.ts
@@ -0,0 +1,43 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+const getDetailGridBlocks = (): string[] => {
+ const source = readFileSync("src/solar-panel-visualizer-card.ts", "utf8");
+ const blocks = [...source.matchAll(/([\s\S]*?)<\/div>\n\n\s*\$\{this\._renderKpiCompareControls/g)];
+ return blocks.map((match) => match[1]);
+};
+
+describe("KPI detail layout", () => {
+ it("puts current and forecast before source in Power and Energy popups", () => {
+ const [powerBlock, energyBlock] = getDetailGridBlocks();
+
+ for (const block of [powerBlock, energyBlock]) {
+ expect(block.indexOf("card.popup.detail.current")).toBeGreaterThanOrEqual(0);
+ expect(block.indexOf("card.popup.detail.source")).toBeGreaterThan(0);
+ }
+
+ expect(powerBlock.indexOf("card.popup.detail.estimated_power_now")).toBeGreaterThan(
+ powerBlock.indexOf("card.popup.detail.current"),
+ );
+ expect(powerBlock.indexOf("card.popup.detail.source")).toBeGreaterThan(
+ powerBlock.indexOf("card.popup.detail.estimated_power_now"),
+ );
+
+ expect(energyBlock.indexOf("card.popup.detail.estimated_energy_now")).toBeGreaterThan(
+ energyBlock.indexOf("card.popup.detail.current"),
+ );
+ expect(energyBlock.indexOf("card.popup.detail.source")).toBeGreaterThan(
+ energyBlock.indexOf("card.popup.detail.estimated_energy_now"),
+ );
+ });
+
+ it("makes the KPI source card span the full detail row", () => {
+ const source = readFileSync("src/solar-panel-visualizer-card.ts", "utf8");
+ const [powerBlock, energyBlock] = getDetailGridBlocks();
+
+ expect(source).toContain(".detail-card-source");
+ expect(source).toContain("grid-column: 1 / -1");
+ expect(powerBlock).toContain('class="detail-card detail-card-source"');
+ expect(energyBlock).toContain('class="detail-card detail-card-source"');
+ });
+});
diff --git a/test/motion-overlay.test.ts b/test/motion-overlay.test.ts
new file mode 100644
index 0000000..4f92aa2
--- /dev/null
+++ b/test/motion-overlay.test.ts
@@ -0,0 +1,250 @@
+import {
+ assignRenderedRowIndexes,
+ buildLeftCollectorGridRoutes,
+ shouldUseBottomPowerEntry,
+ type LeftCollectorGridPanelInput,
+ type Point,
+} from "../src/motion-overlay";
+import { readFileSync } from "node:fs";
+
+const makePanel = (
+ id: string,
+ row: number,
+ left: number,
+ top: number,
+ producing = true,
+): LeftCollectorGridPanelInput => ({
+ id,
+ row,
+ anchorX: left + 28,
+ anchorY: top + 2,
+ left,
+ right: left + 120,
+ top,
+ bottom: top + 110,
+ accentColor: "#7ed957",
+ intensity: 0.5,
+ producing,
+});
+
+const powerEntry: Point = { x: 72, y: 96 };
+
+describe("left collector motion grid routing", () => {
+ it("groups motion rails by rendered visual rows instead of configured slot rows", () => {
+ const rows = assignRenderedRowIndexes([
+ { top: 220, bottom: 330, left: 120 },
+ { top: 220, bottom: 330, left: 280 },
+ { top: 220, bottom: 330, left: 440 },
+ { top: 380, bottom: 490, left: 120 },
+ { top: 380, bottom: 490, left: 280 },
+ { top: 380, bottom: 490, left: 440 },
+ { top: 540, bottom: 650, left: 120 },
+ ]);
+
+ expect(rows).toEqual([0, 0, 0, 1, 1, 1, 2]);
+ });
+
+ it("keeps left-center Power entry for compact or left-aligned KPI layouts", () => {
+ expect(shouldUseBottomPowerEntry(420, 40, 390, 126)).toBe(false);
+ expect(shouldUseBottomPowerEntry(980, 60, 940, 280)).toBe(false);
+ });
+
+ it("uses bottom Power entry when the KPI is centered over the grid", () => {
+ expect(shouldUseBottomPowerEntry(1200, 80, 1120, 660)).toBe(true);
+ });
+
+ it("routes one row left first and terminates at the Power KPI left edge", () => {
+ const routes = buildLeftCollectorGridRoutes(
+ [
+ makePanel("p1", 0, 120, 220),
+ makePanel("p2", 0, 280, 220),
+ makePanel("p3", 0, 440, 220),
+ ],
+ powerEntry,
+ 24,
+ 760,
+ );
+
+ expect(routes.collectorX).toBeLessThan(120);
+ expect(routes.collectorX).toBeCloseTo(60);
+ expect(routes.rowBuses).toHaveLength(1);
+ expect(routes.rowBuses[0]).toMatchObject({
+ row: 0,
+ startX: routes.collectorX,
+ endX: 468,
+ });
+ expect(routes.topologyPaths.find((path) => path.id === "collector-rail")?.d).toContain(
+ `${routes.collectorX.toFixed(2)}`,
+ );
+ expect(routes.topologyPaths.find((path) => path.id === "row-rail-0")?.d).toBe(
+ `M ${routes.collectorX.toFixed(2)} 208.00 L 468.00 208.00`,
+ );
+ expect(routes.flowPaths).toHaveLength(3);
+ for (const path of routes.flowPaths) {
+ expect(path.d).not.toContain(" Q ");
+ expect(path.d).not.toContain(" C ");
+ expect(path.d.match(/ L /g)?.length ?? 0).toBeGreaterThanOrEqual(3);
+ expect(path.d.trim().endsWith(`${powerEntry.x.toFixed(2)} ${powerEntry.y.toFixed(2)}`)).toBe(
+ true,
+ );
+ expect(path.offsetPx).toBeLessThanOrEqual(-path.travelPx);
+ expect(path.durationMs).toBeGreaterThan(path.travelPx * 8);
+ }
+ });
+
+ it("routes wide first-row panels directly to the Power drop instead of the left collector", () => {
+ const bottomEntry: Point = { x: 360, y: 148 };
+ const routes = buildLeftCollectorGridRoutes(
+ [makePanel("p1", 0, 120, 220), makePanel("p2", 0, 280, 220)],
+ bottomEntry,
+ 24,
+ 760,
+ 120,
+ "direct-first-row",
+ );
+
+ expect(routes.topologyPaths.some((path) => path.id === "power-entry-rail")).toBe(false);
+ expect(routes.topologyPaths.find((path) => path.id === "power-drop-rail")?.d).toBe(
+ `M ${bottomEntry.x.toFixed(2)} ${bottomEntry.y.toFixed(2)} L ${bottomEntry.x.toFixed(2)} 208.00`,
+ );
+ expect(routes.topologyPaths.find((path) => path.id === "collector-rail")).toBeUndefined();
+ expect(routes.topologyPaths.find((path) => path.id === "row-rail-0")?.d).toBe(
+ "M 148.00 208.00 L 360.00 208.00",
+ );
+ for (const path of routes.flowPaths) {
+ expect(path.d.trim().endsWith(`${bottomEntry.x.toFixed(2)} ${bottomEntry.y.toFixed(2)}`)).toBe(
+ true,
+ );
+ expect(path.d).not.toContain(`L ${routes.collectorX.toFixed(2)} 208.00`);
+ expect(path.offsetPx).toBeLessThanOrEqual(-path.travelPx);
+ }
+ });
+
+ it("routes wide lower rows through the collector and then the first-row Power drop", () => {
+ const bottomEntry: Point = { x: 360, y: 148 };
+ const routes = buildLeftCollectorGridRoutes(
+ [makePanel("p1", 0, 280, 220), makePanel("p2", 1, 280, 380)],
+ bottomEntry,
+ 24,
+ 760,
+ 120,
+ "direct-first-row",
+ );
+
+ expect(routes.topologyPaths.find((path) => path.id === "collector-rail")?.d).toBe(
+ `M ${routes.collectorX.toFixed(2)} 208.00 L ${routes.collectorX.toFixed(2)} 368.00`,
+ );
+ expect(routes.topologyPaths.find((path) => path.id === "row-rail-0")?.d).toBe(
+ `M ${routes.collectorX.toFixed(2)} 208.00 L 360.00 208.00`,
+ );
+ const lowerPath = routes.flowPaths.find((path) => path.panelId === "p2")?.d ?? "";
+ expect(lowerPath).toContain(`L ${routes.collectorX.toFixed(2)} 368.00`);
+ expect(lowerPath).toContain(`L ${routes.collectorX.toFixed(2)} 208.00`);
+ expect(lowerPath).toContain("L 360.00 208.00");
+ expect(lowerPath.trim().endsWith("360.00 148.00")).toBe(true);
+ });
+
+ it("uses one shared collector for multiple rows", () => {
+ const routes = buildLeftCollectorGridRoutes(
+ [
+ makePanel("p1", 0, 120, 220),
+ makePanel("p2", 0, 280, 220),
+ makePanel("p3", 1, 120, 380),
+ makePanel("p4", 1, 280, 380),
+ ],
+ powerEntry,
+ 24,
+ 760,
+ );
+
+ expect(routes.rowBuses).toHaveLength(2);
+ expect(routes.rowBuses[1].y).toBeGreaterThan(routes.rowBuses[0].y);
+ expect(routes.rowBuses.every((row) => row.startX === routes.collectorX)).toBe(true);
+ expect(routes.topologyPaths.filter((path) => path.id.startsWith("row-rail-"))).toHaveLength(2);
+ for (const row of routes.rowBuses) {
+ expect(routes.topologyPaths.find((path) => path.id === `row-rail-${row.row}`)?.d).toBe(
+ `M ${routes.collectorX.toFixed(2)} ${row.y.toFixed(2)} L ${row.endX.toFixed(2)} ${row.y.toFixed(2)}`,
+ );
+ }
+ });
+
+ it("positions the collector from the full grid left edge, not the first powered panel", () => {
+ const routes = buildLeftCollectorGridRoutes(
+ [makePanel("p2", 0, 280, 220), makePanel("p5", 1, 280, 380)],
+ powerEntry,
+ 24,
+ 760,
+ 120,
+ );
+
+ expect(routes.collectorX).toBeCloseTo(60);
+ expect(routes.rowBuses.every((row) => row.startX === routes.collectorX)).toBe(true);
+ expect(routes.rowBuses[0].startX).toBeLessThan(120);
+ });
+
+ it("keeps non-consecutive row rails close to their own row instead of floating between configured rows", () => {
+ const routes = buildLeftCollectorGridRoutes(
+ [
+ makePanel("p1", 0, 120, 220),
+ makePanel("p10", 3, 280, 740),
+ ],
+ powerEntry,
+ 24,
+ 760,
+ );
+
+ expect(routes.rowBuses).toHaveLength(2);
+ expect(routes.rowBuses[1]).toMatchObject({
+ row: 3,
+ y: 728,
+ });
+ expect(routes.flowPaths.find((path) => path.panelId === "p10")?.d).toContain(
+ `L ${routes.collectorX.toFixed(2)} 728.00`,
+ );
+ });
+
+ it("keeps the collector inside the card bounds on narrow layouts", () => {
+ const routes = buildLeftCollectorGridRoutes(
+ [makePanel("p1", 0, 24, 180)],
+ { x: 18, y: 84 },
+ 24,
+ 220,
+ );
+
+ expect(routes.collectorX).toBeGreaterThanOrEqual(6);
+ expect(routes.collectorX).toBeLessThanOrEqual(206);
+ });
+
+ it("does not create pulse routes for non-producing panels", () => {
+ const routes = buildLeftCollectorGridRoutes(
+ [makePanel("p1", 0, 120, 220, true), makePanel("p2", 0, 280, 220, false)],
+ powerEntry,
+ 24,
+ 760,
+ );
+
+ expect(routes.flowPaths.map((path) => path.panelId)).toEqual(["p1"]);
+ expect(routes.topologyPaths.some((path) => path.id === "tap-rail-p2")).toBe(true);
+ });
+
+ it("keeps all explicit rail paths orthogonal", () => {
+ const routes = buildLeftCollectorGridRoutes(
+ [makePanel("p1", 0, 120, 220), makePanel("p2", 1, 280, 380)],
+ powerEntry,
+ 24,
+ 760,
+ );
+
+ for (const path of routes.topologyPaths) {
+ expect(path.d).not.toContain(" Q ");
+ expect(path.d).not.toContain(" C ");
+ }
+ });
+
+ it("does not size the overlay from the card border box or scroll position", () => {
+ const source = readFileSync("src/motion-overlay.ts", "utf8");
+
+ expect(source).not.toContain("scrollOffset.y + cardRect.height");
+ expect(source).not.toMatch(/height:\s*Math\.max\([\s\S]*cardRect\.height/);
+ });
+});
diff --git a/test/natural-sort.test.ts b/test/natural-sort.test.ts
new file mode 100644
index 0000000..5014d84
--- /dev/null
+++ b/test/natural-sort.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { naturalCompare } from "../src/natural-sort";
+
+describe("naturalCompare", () => {
+ it("sorts dotted optimizer names numerically", () => {
+ const sorted = [
+ "Optimizer 1.0.10 Power",
+ "Optimizer 1.0.2 Power",
+ "Optimizer 1.0.30 Power",
+ "Optimizer 1.0.1 Power",
+ ].sort(naturalCompare);
+
+ expect(sorted).toEqual([
+ "Optimizer 1.0.1 Power",
+ "Optimizer 1.0.2 Power",
+ "Optimizer 1.0.10 Power",
+ "Optimizer 1.0.30 Power",
+ ]);
+ });
+
+ it("sorts underscored sensor entity ids numerically", () => {
+ const sorted = [
+ "sensor.power_1_0_10",
+ "sensor.power_1_0_2",
+ "sensor.power_1_0_30",
+ "sensor.power_1_0_1",
+ ].sort(naturalCompare);
+
+ expect(sorted).toEqual([
+ "sensor.power_1_0_1",
+ "sensor.power_1_0_2",
+ "sensor.power_1_0_10",
+ "sensor.power_1_0_30",
+ ]);
+ });
+});
diff --git a/test/panel-performance-label.test.ts b/test/panel-performance-label.test.ts
new file mode 100644
index 0000000..dca6213
--- /dev/null
+++ b/test/panel-performance-label.test.ts
@@ -0,0 +1,65 @@
+import { choosePanelPerformanceLabel } from "../src/panel-performance-label";
+
+const candidates = [
+ { variant: "full" as const, text: "45% of 505W Panel" },
+ { variant: "medium" as const, text: "45% of 505W" },
+ { variant: "compact" as const, text: "45%" },
+];
+
+const measureTextWidthPx = (text: string, fontPx: number): number =>
+ text.length * fontPx * 0.5;
+
+describe("choosePanelPerformanceLabel", () => {
+ it("uses the full panel text when width allows it", () => {
+ const result = choosePanelPerformanceLabel({
+ candidates,
+ panelWidthPx: 220,
+ panelHeightPx: 128,
+ fontPx: 16,
+ measureTextWidthPx,
+ });
+
+ expect(result.variant).toBe("full");
+ expect(result.text).toBe("45% of 505W Panel");
+ });
+
+ it("uses the medium text before falling back to compact", () => {
+ const result = choosePanelPerformanceLabel({
+ candidates,
+ panelWidthPx: 166,
+ panelHeightPx: 128,
+ fontPx: 16,
+ measureTextWidthPx,
+ });
+
+ expect(result.variant).toBe("medium");
+ expect(result.text).toBe("45% of 505W");
+ });
+
+ it("uses compact text for genuinely tiny panels", () => {
+ const result = choosePanelPerformanceLabel({
+ candidates,
+ panelWidthPx: 108,
+ panelHeightPx: 128,
+ fontPx: 16,
+ measureTextWidthPx,
+ });
+
+ expect(result.variant).toBe("compact");
+ expect(result.text).toBe("45%");
+ });
+
+ it("reserves the slot label area before choosing long text", () => {
+ const result = choosePanelPerformanceLabel({
+ candidates,
+ panelWidthPx: 220,
+ panelHeightPx: 128,
+ fontPx: 16,
+ reservedRightPx: 64,
+ measureTextWidthPx,
+ });
+
+ expect(result.variant).toBe("medium");
+ expect(result.text).toBe("45% of 505W");
+ });
+});
diff --git a/test/panel-state.test.ts b/test/panel-state.test.ts
new file mode 100644
index 0000000..b00e840
--- /dev/null
+++ b/test/panel-state.test.ts
@@ -0,0 +1,797 @@
+import { normalizeConfig } from "../src/config";
+import { deriveArrayState } from "../src/panel-state";
+import type { HomeAssistant, PowerSample } from "../src/types";
+
+const createHass = (states: HomeAssistant["states"]): HomeAssistant => ({
+ states,
+ locale: { language: "en" },
+});
+
+const historyFromValues = (
+ entityId: string,
+ values: number[],
+ nowMs: number,
+ stepMinutes = 5,
+): Record => ({
+ [entityId]: values.map((value, index) => ({
+ value,
+ ts: nowMs - (values.length - index) * stepMinutes * 60_000,
+ })),
+});
+
+describe("deriveArrayState", () => {
+ it("splits duplicate string power sensors by rated power when sharing is enabled", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 2,
+ enable_string_sensor_sharing: true,
+ panels: [
+ {
+ id: "p1",
+ name: "North panel",
+ power_entity: "sensor.string_power",
+ rated_power_w: 400,
+ },
+ {
+ id: "p2",
+ name: "South panel",
+ power_entity: "sensor.string_power",
+ rated_power_w: 600,
+ },
+ ],
+ });
+ const hass = createHass({
+ "sensor.string_power": {
+ entity_id: "sensor.string_power",
+ state: "1000",
+ attributes: {
+ unit_of_measurement: "W",
+ friendly_name: "DC Power String 1",
+ },
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+
+ expect(derived.totalPower).toBe(1000);
+ expect(derived.panels[0].power).toBe(400);
+ expect(derived.panels[1].power).toBe(600);
+ expect(derived.panels[0].powerShareFactor).toBeCloseTo(0.4);
+ expect(derived.panels[1].powerShareFactor).toBeCloseTo(0.6);
+ expect(derived.panels[0].stringGroupLabel).toBe("String 1");
+ expect(derived.panels[0].stringPowerSourceName).toBe("DC Power String 1");
+ expect(derived.panels[0].stringPowerSharePercent).toBeCloseTo(40);
+ });
+
+ it("falls back to equal split when a shared string group is missing rated power", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 2,
+ enable_string_sensor_sharing: true,
+ panels: [
+ { id: "p1", power_entity: "sensor.string_power", rated_power_w: 500 },
+ { id: "p2", power_entity: "sensor.string_power" },
+ ],
+ });
+ const hass = createHass({
+ "sensor.string_power": {
+ entity_id: "sensor.string_power",
+ state: "900",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+
+ expect(derived.totalPower).toBe(900);
+ expect(derived.panels[0].power).toBe(450);
+ expect(derived.panels[1].power).toBe(450);
+ });
+
+ it("splits duplicate string energy sensors and keeps the total undoubled", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 2,
+ enable_string_sensor_sharing: true,
+ panels: [
+ {
+ id: "p1",
+ power_entity: "sensor.p1",
+ energy_entity: "sensor.string_energy",
+ show_energy: true,
+ rated_power_w: 300,
+ },
+ {
+ id: "p2",
+ power_entity: "sensor.p2",
+ energy_entity: "sensor.string_energy",
+ show_energy: true,
+ rated_power_w: 700,
+ },
+ ],
+ });
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "300",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p2": {
+ entity_id: "sensor.p2",
+ state: "700",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.string_energy": {
+ entity_id: "sensor.string_energy",
+ state: "10",
+ attributes: { unit_of_measurement: "kWh" },
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+
+ expect(derived.totalEnergy).toBe(10);
+ expect(derived.panels[0].energy).toBe(3);
+ expect(derived.panels[1].energy).toBe(7);
+ expect(derived.panels[0].energyShareFactor).toBeCloseTo(0.3);
+ expect(derived.panels[1].energyShareFactor).toBeCloseTo(0.7);
+ });
+
+ it("keeps duplicate sensors counted per panel when string sharing is off", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 2,
+ panels: [
+ { id: "p1", power_entity: "sensor.string_power" },
+ { id: "p2", power_entity: "sensor.string_power" },
+ ],
+ });
+ const hass = createHass({
+ "sensor.string_power": {
+ entity_id: "sensor.string_power",
+ state: "1000",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+
+ expect(derived.totalPower).toBe(2000);
+ expect(derived.panels[0].power).toBe(1000);
+ expect(derived.panels[1].power).toBe(1000);
+ expect(derived.panels[0].stringGroupLabel).toBeUndefined();
+ });
+
+ it("flags a deviating panel with target-based checks", () => {
+ const nowMs = Date.now();
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 3,
+ deviation_threshold_percent: 15,
+ deviation_absolute_w_threshold: 50,
+ deviation_min_active_panels: 3,
+ deviation_min_samples: 3,
+ deviation_min_runtime_minutes: 15,
+ deviation_dynamic_floor_w: 0,
+ default_panel_rated_power_w: 500,
+ panels: [
+ { id: "p1", power_entity: "sensor.p1" },
+ { id: "p2", power_entity: "sensor.p2" },
+ { id: "p3", power_entity: "sensor.p3" },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "420",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p2": {
+ entity_id: "sensor.p2",
+ state: "415",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p3": {
+ entity_id: "sensor.p3",
+ state: "260",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const history = {
+ ...historyFromValues("sensor.p1", [430, 425, 420], nowMs),
+ ...historyFromValues("sensor.p2", [420, 418, 415], nowMs),
+ ...historyFromValues("sensor.p3", [300, 280, 260], nowMs),
+ };
+
+ const derived = deriveArrayState(config, hass, { nowMs, historyByEntityId: history });
+ expect(derived.deviationReady).toBe(true);
+ expect(derived.deviationCount).toBe(1);
+ expect(derived.panels[2].status).toBe("deviation");
+ });
+
+ it("does not flag naturally shaded panels when derate aligns expected output", () => {
+ const nowMs = Date.now();
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 3,
+ deviation_threshold_percent: 15,
+ deviation_absolute_w_threshold: 50,
+ deviation_min_active_panels: 2,
+ deviation_min_samples: 3,
+ deviation_min_runtime_minutes: 15,
+ deviation_dynamic_floor_w: 0,
+ default_panel_rated_power_w: 500,
+ panels: [
+ { id: "p1", power_entity: "sensor.p1" },
+ { id: "p2", power_entity: "sensor.p2" },
+ { id: "p3", power_entity: "sensor.p3", deviation_derate_percent: 60 },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "420",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p2": {
+ entity_id: "sensor.p2",
+ state: "415",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p3": {
+ entity_id: "sensor.p3",
+ state: "260",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const history = {
+ ...historyFromValues("sensor.p1", [430, 425, 420], nowMs),
+ ...historyFromValues("sensor.p2", [420, 418, 415], nowMs),
+ ...historyFromValues("sensor.p3", [275, 268, 260], nowMs),
+ };
+
+ const derived = deriveArrayState(config, hass, { nowMs, historyByEntityId: history });
+ expect(derived.deviationReady).toBe(true);
+ expect(derived.deviationCount).toBe(0);
+ expect(derived.panels[2].status).toBe("normal");
+ });
+
+ it("requires enough non-derated panels to start deviation checks", () => {
+ const nowMs = Date.now();
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 3,
+ deviation_min_active_panels: 3,
+ deviation_min_samples: 1,
+ deviation_min_runtime_minutes: 0,
+ deviation_dynamic_floor_w: 0,
+ default_panel_rated_power_w: 500,
+ panels: [
+ { id: "p1", power_entity: "sensor.p1" },
+ { id: "p2", power_entity: "sensor.p2" },
+ { id: "p3", power_entity: "sensor.p3", deviation_derate_percent: 50 },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "420",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p2": {
+ entity_id: "sensor.p2",
+ state: "415",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p3": {
+ entity_id: "sensor.p3",
+ state: "200",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const history = {
+ ...historyFromValues("sensor.p1", [420], nowMs),
+ ...historyFromValues("sensor.p2", [415], nowMs),
+ ...historyFromValues("sensor.p3", [200], nowMs),
+ };
+
+ const derived = deriveArrayState(config, hass, { nowMs, historyByEntityId: history });
+ expect(derived.deviationReady).toBe(false);
+ expect(derived.deviationSuppressedReason).toContain("non-derated active rated panels");
+ });
+
+ it("keeps performance percent based on actual rated power when derate is used", () => {
+ const nowMs = Date.now();
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ deviation_min_active_panels: 1,
+ deviation_min_samples: 1,
+ deviation_min_runtime_minutes: 0,
+ deviation_dynamic_floor_w: 0,
+ default_panel_rated_power_w: 500,
+ panels: [{ id: "p1", power_entity: "sensor.p1", deviation_derate_percent: 50 }],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "250",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const history = {
+ ...historyFromValues("sensor.p1", [250], nowMs),
+ };
+
+ const derived = deriveArrayState(config, hass, { nowMs, historyByEntityId: history });
+ expect(derived.panels[0].performancePercent).toBe(50);
+ });
+
+ it("can still flag a derated panel when it falls below its derated target", () => {
+ const nowMs = Date.now();
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 3,
+ deviation_threshold_percent: 15,
+ deviation_absolute_w_threshold: 50,
+ deviation_min_active_panels: 2,
+ deviation_min_samples: 3,
+ deviation_min_runtime_minutes: 15,
+ deviation_dynamic_floor_w: 0,
+ default_panel_rated_power_w: 500,
+ panels: [
+ { id: "p1", power_entity: "sensor.p1" },
+ { id: "p2", power_entity: "sensor.p2" },
+ { id: "p3", power_entity: "sensor.p3", deviation_derate_percent: 50 },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "420",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p2": {
+ entity_id: "sensor.p2",
+ state: "410",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p3": {
+ entity_id: "sensor.p3",
+ state: "90",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const history = {
+ ...historyFromValues("sensor.p1", [430, 425, 420], nowMs),
+ ...historyFromValues("sensor.p2", [420, 415, 410], nowMs),
+ ...historyFromValues("sensor.p3", [120, 100, 90], nowMs),
+ };
+
+ const derived = deriveArrayState(config, hass, { nowMs, historyByEntityId: history });
+ expect(derived.deviationReady).toBe(true);
+ expect(derived.panels[2].status).toBe("deviation");
+ });
+
+ it("suppresses deviation when too few active rated panels are available", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 2,
+ deviation_min_active_panels: 3,
+ default_panel_rated_power_w: 500,
+ panels: [
+ { id: "p1", power_entity: "sensor.p1" },
+ { id: "p2", power_entity: "sensor.p2" },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "440",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p2": {
+ entity_id: "sensor.p2",
+ state: "430",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const derived = deriveArrayState(config, hass, { nowMs: Date.now() });
+ expect(derived.deviationReady).toBe(false);
+ expect(derived.deviationSuppressedReason).toContain("at least 3");
+ });
+
+ it("suppresses deviation during low-light dynamic floor periods", () => {
+ const nowMs = Date.now();
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 3,
+ deviation_min_samples: 1,
+ deviation_min_runtime_minutes: 0,
+ deviation_dynamic_floor_w: 50,
+ default_panel_rated_power_w: 500,
+ panels: [
+ { id: "p1", power_entity: "sensor.p1" },
+ { id: "p2", power_entity: "sensor.p2" },
+ { id: "p3", power_entity: "sensor.p3" },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "3",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p2": {
+ entity_id: "sensor.p2",
+ state: "4",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p3": {
+ entity_id: "sensor.p3",
+ state: "2",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const history = {
+ ...historyFromValues("sensor.p1", [3], nowMs),
+ ...historyFromValues("sensor.p2", [4], nowMs),
+ ...historyFromValues("sensor.p3", [2], nowMs),
+ };
+
+ const derived = deriveArrayState(config, hass, { nowMs, historyByEntityId: history });
+ expect(derived.deviationReady).toBe(false);
+ expect(derived.deviationCount).toBe(0);
+ });
+
+ it("marks panels as error when inverter status matches fault terms", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ enable_inverter_status: true,
+ inverter_fault_terms: ["fault", "alarm"],
+ panels: [
+ {
+ id: "p1",
+ power_entity: "sensor.p1",
+ inverter_status_entity: "sensor.p1_status",
+ },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "380",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p1_status": {
+ entity_id: "sensor.p1_status",
+ state: "Waiting - Grid Fault",
+ attributes: {},
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+ expect(derived.errorCount).toBe(1);
+ expect(derived.panels[0].status).toBe("error");
+ expect(derived.panels[0].inverterStatusDisplay).toBe("Waiting - Grid Fault");
+ });
+
+ it("marks panels as error when a one-to-one panel status sensor matches fault terms", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ enable_inverter_status: true,
+ inverter_fault_terms: ["fault", "alarm"],
+ panels: [
+ {
+ id: "p1",
+ power_entity: "sensor.power_1_0_1",
+ inverter_status_entity: "sensor.status_1_0_1",
+ },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.power_1_0_1": {
+ entity_id: "sensor.power_1_0_1",
+ state: "380",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.status_1_0_1": {
+ entity_id: "sensor.status_1_0_1",
+ state: "Panel Fault",
+ attributes: {},
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+ expect(derived.errorCount).toBe(1);
+ expect(derived.panels[0].status).toBe("error");
+ expect(derived.panels[0].inverterStatusDisplay).toBe("Panel Fault");
+ });
+
+ it("keeps panel normal when inverter status does not match fault terms", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ enable_inverter_status: true,
+ inverter_fault_terms: ["fault", "alarm"],
+ panels: [
+ {
+ id: "p1",
+ power_entity: "sensor.p1",
+ inverter_status_entity: "sensor.p1_status",
+ },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "380",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p1_status": {
+ entity_id: "sensor.p1_status",
+ state: "Waiting for operation",
+ attributes: {},
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+ expect(derived.errorCount).toBe(0);
+ expect(derived.panels[0].status).toBe("normal");
+ expect(derived.panels[0].inverterStatusDisplay).toBe("Waiting for operation");
+ });
+
+ it("treats a plain Waiting status as a default working state", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ enable_inverter_status: true,
+ panels: [
+ {
+ id: "p1",
+ power_entity: "sensor.p1",
+ inverter_status_entity: "sensor.p1_status",
+ },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "380",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p1_status": {
+ entity_id: "sensor.p1_status",
+ state: "Waiting",
+ attributes: {},
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+ expect(derived.errorCount).toBe(0);
+ expect(derived.inverterCount).toBe(0);
+ expect(derived.panels[0].status).toBe("normal");
+ expect(derived.panels[0].inverterWorkingMatched).toBe(true);
+ });
+
+ it("marks panel as inverter when inverter status does not match working terms", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ enable_inverter_status: true,
+ inverter_fault_terms: ["fault", "alarm"],
+ inverter_working_terms: ["normal", "running"],
+ panels: [
+ {
+ id: "p1",
+ power_entity: "sensor.p1",
+ inverter_status_entity: "sensor.p1_status",
+ },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "380",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p1_status": {
+ entity_id: "sensor.p1_status",
+ state: "Waiting for operation",
+ attributes: {},
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+ expect(derived.inverterCount).toBe(1);
+ expect(derived.panels[0].status).toBe("inverter");
+ expect(derived.panels[0].reason).toContain("does not match configured working terms");
+ });
+
+ it("uses whole-word inverter matching and ignores partial token matches", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ enable_inverter_status: true,
+ inverter_fault_terms: ["rr"],
+ inverter_working_terms: ["n"],
+ panels: [
+ {
+ id: "p1",
+ power_entity: "sensor.p1",
+ inverter_status_entity: "sensor.p1_status",
+ },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "380",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p1_status": {
+ entity_id: "sensor.p1_status",
+ state: "Normal",
+ attributes: {},
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+ expect(derived.panels[0].inverterFaultMatched).toBe(false);
+ expect(derived.panels[0].inverterWorkingMatched).toBe(false);
+ expect(derived.panels[0].status).toBe("inverter");
+ });
+
+ it("handles missing entities without crashing", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ panels: [{ id: "p1", power_entity: "sensor.missing" }],
+ });
+
+ const derived = deriveArrayState(config, createHass({}));
+ expect(derived.offlineCount).toBe(1);
+ expect(derived.panels[0].status).toBe("offline");
+ });
+
+ it("uses default rated panel power to calculate performance", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ default_panel_rated_power_w: 500,
+ panels: [{ id: "p1", power_entity: "sensor.p1" }],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "450",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+ expect(derived.panels[0].ratedPowerW).toBe(500);
+ expect(derived.panels[0].performancePercent).toBe(90);
+ });
+
+ it("uses a 3-step production gradient scaled by rated power", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ colors: {
+ production_start: "#000000",
+ production_mid: "#808080",
+ production_end: "#ffffff",
+ },
+ panels: [{ id: "p1", power_entity: "sensor.p1", rated_power_w: 100 }],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "25",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+ expect(derived.panels[0].accentColor).toBe("rgb(64, 64, 64)");
+ });
+
+ it("does not show unavailable energy text when no energy sensor is configured", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ panels: [{ id: "p1", power_entity: "sensor.p1" }],
+ });
+
+ const hass = createHass({
+ "sensor.p1": {
+ entity_id: "sensor.p1",
+ state: "50",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+ expect(derived.panels[0].energyDisplay).toBeUndefined();
+ });
+
+ it("hides panel energy text when show_energy is disabled", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 1,
+ panels: [
+ {
+ id: "p1",
+ power_entity: "sensor.p1_power",
+ energy_entity: "sensor.p1_energy",
+ show_energy: false,
+ },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p1_power": {
+ entity_id: "sensor.p1_power",
+ state: "50",
+ attributes: { unit_of_measurement: "W" },
+ },
+ "sensor.p1_energy": {
+ entity_id: "sensor.p1_energy",
+ state: "1.23",
+ attributes: { unit_of_measurement: "kWh" },
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+ expect(derived.panels[0].energyDisplay).toBeUndefined();
+ });
+
+ it("keeps disabled panel slots as hidden placeholders", () => {
+ const config = normalizeConfig({
+ rows: 1,
+ columns: 2,
+ panels: [
+ { id: "p1", power_entity: "sensor.p1", enabled: false },
+ { id: "p2", power_entity: "sensor.p2" },
+ ],
+ });
+
+ const hass = createHass({
+ "sensor.p2": {
+ entity_id: "sensor.p2",
+ state: "300",
+ attributes: { unit_of_measurement: "W" },
+ },
+ });
+
+ const derived = deriveArrayState(config, hass);
+ expect(derived.disabledCount).toBe(1);
+ expect(derived.panels[0].hiddenSlot).toBe(true);
+ expect(derived.panels[1].hiddenSlot).toBe(false);
+ });
+});
diff --git a/test/popup-graph.test.ts b/test/popup-graph.test.ts
new file mode 100644
index 0000000..7a7fb6d
--- /dev/null
+++ b/test/popup-graph.test.ts
@@ -0,0 +1,192 @@
+import { describe, expect, it } from "vitest";
+import {
+ aggregateStepwiseSamples,
+ bridgeZeroToProductionGaps,
+ buildPopupGraphModel,
+ downsampleSamples,
+ filterSamplesByRangeHours,
+} from "../src/popup-graph";
+import type { PowerSample } from "../src/types";
+
+const sampleSeries = (count: number, nowMs: number): PowerSample[] =>
+ Array.from({ length: count }, (_, index) => ({
+ ts: nowMs - (count - 1 - index) * 60_000,
+ value: index,
+ }));
+
+describe("filterSamplesByRangeHours", () => {
+ it("filters samples inside the selected range", () => {
+ const nowMs = Date.now();
+ const samples: PowerSample[] = [
+ { ts: nowMs - 8 * 60 * 60 * 1000, value: 100 },
+ { ts: nowMs - 2 * 60 * 60 * 1000, value: 200 },
+ { ts: nowMs - 30 * 60 * 1000, value: 250 },
+ ];
+
+ expect(filterSamplesByRangeHours(samples, nowMs, 1)).toHaveLength(1);
+ expect(filterSamplesByRangeHours(samples, nowMs, 6)).toHaveLength(2);
+ expect(filterSamplesByRangeHours(samples, nowMs, 24)).toHaveLength(3);
+ });
+});
+
+describe("downsampleSamples", () => {
+ it("preserves first and last samples", () => {
+ const nowMs = Date.now();
+ const samples = sampleSeries(300, nowMs);
+ const downsampled = downsampleSamples(samples, 150);
+
+ expect(downsampled.length).toBeLessThanOrEqual(150);
+ expect(downsampled[0]).toEqual(samples[0]);
+ expect(downsampled[downsampled.length - 1]).toEqual(samples[samples.length - 1]);
+ });
+});
+
+describe("aggregateStepwiseSamples", () => {
+ it("sums panel series by carrying each latest value forward", () => {
+ const result = aggregateStepwiseSamples([
+ [
+ { ts: 1, value: 100 },
+ { ts: 3, value: 140 },
+ ],
+ [
+ { ts: 2, value: 50 },
+ { ts: 4, value: 80 },
+ ],
+ ]);
+
+ expect(result).toEqual([
+ { ts: 1, value: 100 },
+ { ts: 2, value: 150 },
+ { ts: 3, value: 190 },
+ { ts: 4, value: 220 },
+ ]);
+ });
+
+ it("ignores empty panel series", () => {
+ expect(
+ aggregateStepwiseSamples([
+ [],
+ [
+ { ts: 1, value: 25 },
+ { ts: 2, value: 30 },
+ ],
+ ]),
+ ).toEqual([
+ { ts: 1, value: 25 },
+ { ts: 2, value: 30 },
+ ]);
+ });
+
+ it("sums cumulative energy-style series with the same latest-value carry", () => {
+ const result = aggregateStepwiseSamples([
+ [
+ { ts: 1, value: 1.2 },
+ { ts: 3, value: 1.8 },
+ ],
+ [
+ { ts: 1, value: 0.5 },
+ { ts: 2, value: 0.9 },
+ { ts: 4, value: 1.1 },
+ ],
+ ]);
+
+ expect(result.map((sample) => sample.ts)).toEqual([1, 2, 3, 4]);
+ expect(result[0].value).toBeCloseTo(1.7);
+ expect(result[1].value).toBeCloseTo(2.1);
+ expect(result[2].value).toBeCloseTo(2.7);
+ expect(result[3].value).toBeCloseTo(2.9);
+ });
+});
+
+describe("bridgeZeroToProductionGaps", () => {
+ it("inserts a pre-rise zero anchor for 0-to-production transitions with large gaps", () => {
+ const nowMs = Date.now();
+ const samples: PowerSample[] = [
+ { ts: nowMs - 14 * 60 * 60 * 1000, value: 0 },
+ { ts: nowMs - 4 * 60 * 60 * 1000, value: 12 },
+ { ts: nowMs - 3 * 60 * 60 * 1000, value: 20 },
+ ];
+
+ const bridged = bridgeZeroToProductionGaps(samples, { rangeHours: 24 });
+
+ expect(bridged.length).toBeGreaterThan(samples.length);
+ const inserted = bridged.find(
+ (sample) =>
+ sample.ts > samples[0].ts && sample.ts < samples[1].ts && sample.value === 0,
+ );
+ expect(inserted).toBeDefined();
+ });
+
+ it("does not modify short normal intervals", () => {
+ const nowMs = Date.now();
+ const samples: PowerSample[] = [
+ { ts: nowMs - 30 * 60 * 1000, value: 0 },
+ { ts: nowMs - 25 * 60 * 1000, value: 5 },
+ { ts: nowMs - 20 * 60 * 1000, value: 7 },
+ ];
+
+ const bridged = bridgeZeroToProductionGaps(samples, { rangeHours: 6 });
+
+ expect(bridged).toEqual(samples);
+ });
+
+ it("does not insert anchors when previous value is not zero", () => {
+ const nowMs = Date.now();
+ const samples: PowerSample[] = [
+ { ts: nowMs - 6 * 60 * 60 * 1000, value: 150 },
+ { ts: nowMs - 2 * 60 * 60 * 1000, value: 220 },
+ ];
+
+ const bridged = bridgeZeroToProductionGaps(samples, { rangeHours: 24 });
+
+ expect(bridged).toEqual(samples);
+ });
+});
+
+describe("buildPopupGraphModel", () => {
+ it("builds paths for a normal dataset", () => {
+ const nowMs = Date.now();
+ const samples = sampleSeries(10, nowMs);
+ const model = buildPopupGraphModel(samples, 320, 132);
+
+ expect(model.pointCount).toBe(10);
+ expect(model.linePath.startsWith("M")).toBe(true);
+ expect(model.areaPath.endsWith("Z")).toBe(true);
+ expect(model.minValue).toBe(0);
+ expect(model.maxValue).toBe(9);
+ });
+
+ it("handles flat datasets", () => {
+ const nowMs = Date.now();
+ const samples: PowerSample[] = [
+ { ts: nowMs - 120_000, value: 500 },
+ { ts: nowMs - 60_000, value: 500 },
+ { ts: nowMs, value: 500 },
+ ];
+ const model = buildPopupGraphModel(samples, 320, 132);
+
+ expect(model.pointCount).toBe(3);
+ expect(model.linePath.length).toBeGreaterThan(0);
+ expect(model.minValue).toBe(499);
+ expect(model.maxValue).toBe(501);
+ });
+
+ it("handles single-point datasets", () => {
+ const nowMs = Date.now();
+ const samples: PowerSample[] = [{ ts: nowMs, value: 250 }];
+ const model = buildPopupGraphModel(samples, 320, 132);
+
+ expect(model.pointCount).toBe(1);
+ expect(model.linePath.length).toBeGreaterThan(0);
+ expect(model.areaPath.length).toBeGreaterThan(0);
+ });
+
+ it("handles empty datasets", () => {
+ const model = buildPopupGraphModel([], 320, 132);
+
+ expect(model.pointCount).toBe(0);
+ expect(model.linePath).toBe("");
+ expect(model.minValue).toBeNull();
+ expect(model.maxValue).toBeNull();
+ });
+});
diff --git a/test/popup-interaction.test.ts b/test/popup-interaction.test.ts
new file mode 100644
index 0000000..079b6a5
--- /dev/null
+++ b/test/popup-interaction.test.ts
@@ -0,0 +1,31 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+describe("popup interaction stability", () => {
+ const source = readFileSync("src/solar-panel-visualizer-card.ts", "utf8");
+
+ it("anchors the popup backdrop to the viewport instead of the scrollable card body", () => {
+ expect(source).toMatch(/\.spv-popup-backdrop\s*\{[\s\S]*position:\s*fixed/);
+ });
+
+ it("keeps the sticky close hit area scoped to the visible close button", () => {
+ const closeAnchorBlock = source.match(/\.spv-popup-close-anchor\s*\{([\s\S]*?)\n\s*\}/)?.[1] ?? "";
+
+ expect(closeAnchorBlock).toContain("width: 36px");
+ expect(closeAnchorBlock).toContain("margin: 0 0 -36px auto");
+ expect(closeAnchorBlock).not.toContain("pointer-events: none");
+ expect(source).not.toContain(".spv-popup-close-anchor .spv-popup-close");
+ });
+
+ it("clears stale graph scroll restoration when the user interacts inside a popup", () => {
+ const popupCount = [...source.matchAll(/class="spv-popup"/g)].length;
+
+ expect(popupCount).toBeGreaterThan(0);
+ expect([...source.matchAll(/@pointerdown=\$\{this\._handlePopupUserInteraction\}/g)]).toHaveLength(popupCount);
+ expect([...source.matchAll(/@wheel=\$\{this\._handlePopupUserInteraction\}/g)]).toHaveLength(popupCount);
+ expect(source).toContain("private _handlePopupUserInteraction");
+ expect(source).toMatch(
+ /private _handlePopupUserInteraction[\s\S]*this\._clearPopupScrollRestore\(\)/,
+ );
+ });
+});
diff --git a/test/string-sharing-static.test.ts b/test/string-sharing-static.test.ts
new file mode 100644
index 0000000..45a37c0
--- /dev/null
+++ b/test/string-sharing-static.test.ts
@@ -0,0 +1,32 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+describe("string sensor sharing visuals", () => {
+ it("renders shared string labels on panel tiles without replacing row/column labels", () => {
+ const source = readFileSync("src/solar-panel-visualizer-card.ts", "utf8");
+
+ expect(source).not.toContain(".string-slot");
+ expect(source).toContain("panel.stringGroupLabel");
+ expect(source).toContain("_formatPanelSlotLabel(panel)");
+ expect(source).toContain("has-string-label");
+ expect(source).not.toContain(".slot::before");
+ });
+
+ it("scales popup and comparison graph samples for shared panel sensors", () => {
+ const source = readFileSync("src/solar-panel-visualizer-card.ts", "utf8");
+
+ expect(source).toContain("primaryValueScale: panel.powerShareFactor ?? 1");
+ expect(source).toContain("target.scale");
+ expect(source).toContain("panel.energyShareFactor ?? 1");
+ expect(source).toContain("panel.powerShareFactor ?? 1");
+ });
+
+ it("keeps tile detail scrolling visually scrollbar-free", () => {
+ const source = readFileSync("src/solar-panel-visualizer-card.ts", "utf8");
+
+ expect(source).toMatch(/\.panel-detail-scroll\s*\{[\s\S]*scrollbar-width:\s*none/);
+ expect(source).toMatch(/\.panel-detail-scroll\s*\{[\s\S]*-ms-overflow-style:\s*none/);
+ expect(source).toMatch(/\.panel-detail-scroll::-webkit-scrollbar\s*\{[\s\S]*display:\s*none/);
+ expect(source).toMatch(/\.panel\.has-detail-overflow \.panel-detail-scroll::-webkit-scrollbar\s*\{[\s\S]*display:\s*none/);
+ });
+});
diff --git a/test/telemetry-graph.test.ts b/test/telemetry-graph.test.ts
new file mode 100644
index 0000000..153d2ed
--- /dev/null
+++ b/test/telemetry-graph.test.ts
@@ -0,0 +1,28 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+describe("panel telemetry graphs", () => {
+ const source = readFileSync("src/solar-panel-visualizer-card.ts", "utf8");
+
+ it("keeps telemetry as a two-column grid and spans only the open pill", () => {
+ expect(source).toMatch(/\.telemetry-grid\s*\{[\s\S]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
+ expect(source).not.toContain("has-open-telemetry-graph");
+ expect(source).not.toContain("hasOpenTelemetryGraph");
+ expect(source).toMatch(/\.telemetry-card\.is-open\s*\{[\s\S]*grid-column:\s*1 \/ -1/);
+ });
+
+ it("renders configured telemetry as expandable controls and unconfigured telemetry as static pills", () => {
+ expect(source).toContain('class="telemetry-card-toggle"');
+ expect(source).toContain("aria-expanded=");
+ expect(source).toContain("_toggleTelemetryGraph(event, panelId, entityId)");
+ expect(source).toContain('class="detail-card telemetry-card"');
+ expect(source).toContain("common.not_configured");
+ });
+
+ it("uses an independent telemetry graph range with the existing graph body", () => {
+ expect(source).toContain("_telemetryGraphRangeHours");
+ expect(source).toContain("_handleTelemetryGraphRangeChange");
+ expect(source).toContain("_renderPopupGraphBodyFromSamples(");
+ expect(source).toContain("{ fillStartWithFirstSample: true }");
+ });
+});
diff --git a/test/virtual-total-kpi.test.ts b/test/virtual-total-kpi.test.ts
new file mode 100644
index 0000000..6fcca01
--- /dev/null
+++ b/test/virtual-total-kpi.test.ts
@@ -0,0 +1,24 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+describe("virtual total KPI popup wiring", () => {
+ const source = readFileSync("src/solar-panel-visualizer-card.ts", "utf8");
+
+ it("uses the virtual total graph as the no-system Energy primary graph", () => {
+ expect(source).toContain('this._renderAggregatedPanelGraph(derived, "energy"');
+ expect(source).not.toContain("energy_compare_requires_system");
+ });
+
+ it("expands panel comparison when virtual totals already loaded panel history", () => {
+ expect(source).toContain('power: this._buildPanelCompareTargets(derived, "power").length > 0');
+ expect(source).toContain('energy: this._buildPanelCompareTargets(derived, "energy").length > 0');
+ expect(source).toContain('void this._ensurePanelCompareLoaded(derived, "energy", this._kpiCompareRangeHours.energy)');
+ expect(source).toContain('void this._ensurePanelCompareLoaded(derived, "power", this._kpiCompareRangeHours.power)');
+ });
+
+ it("uses the generic virtual total graph for Power and Energy", () => {
+ expect(source).toContain("private _renderAggregatedPanelGraph(");
+ expect(source).toContain('this._renderAggregatedPanelGraph(derived, "power"');
+ expect(source).toContain('`panel-total-${kind}|${signature}|${range}`');
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1c6c6e6
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "noEmit": true,
+ "lib": ["DOM", "ES2020"],
+ "useDefineForClassFields": false,
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "types": ["node", "vitest/globals"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.json", "test/**/*.ts", "vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..b9c4c8f
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ build: {
+ target: "es2020",
+ outDir: "dist",
+ emptyOutDir: true,
+ sourcemap: false,
+ lib: {
+ entry: "src/index.ts",
+ formats: ["es"],
+ fileName: () => "solar-panel-visualizer.js",
+ },
+ rollupOptions: {
+ output: {
+ inlineDynamicImports: true,
+ },
+ },
+ },
+ test: {
+ environment: "node",
+ globals: true,
+ },
+});