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 ? "" : "", o = Oe; + for (let l = 0; l < t; l++) { + const a = i[l]; + let c, p, d = -1, h = 0; + for (; h < a.length && (o.lastIndex = h, p = o.exec(a), p !== null); ) h = o.lastIndex, o === Oe ? p[1] === "!--" ? o = rr : p[1] !== void 0 ? o = sr : p[2] !== void 0 ? (as.test(p[2]) && (s = RegExp("" ? (o = s ?? Oe, d = -1) : p[1] === void 0 ? d = -2 : (d = o.lastIndex - p[2].length, c = p[1], o = p[3] === void 0 ? de : p[3] === '"' ? nr : ir) : o === nr || o === ir ? o = de : o === rr || o === sr ? o = Oe : (o = de, s = void 0); + const u = o === de && i[l + 1].startsWith("/>") ? " " : ""; + n += o === Oe ? a + ai : d >= 0 ? (r.push(c), a.slice(0, d) + ns + a.slice(d) + ae + u) : a + ae + (d === -2 ? l : u); + } + return [cs(i, n + (i[t] || "") + (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)} +
+
+
+
+ ${this._t("card.eyebrow")} + +
+

${n}

+

${R}

+

${this._t("card.subtitle.drag_hint")}

+
+
+ + +
+ ${this._t("card.summary.alerts")} + + ${E} + +
+ ${d ? y` + + ` : w} +
+
+ +
+ ${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` +
+
+ ${this._renderPopupCloseButton(this._closeDialog, this._t("card.popup.close_detail"))} +
+
+
${this._t("card.popup.panel_eyebrow", { + slot: this._formatSlotLabel(e.slotIndex) + })}
+

${e.label}

+
+
+ ${this._renderInlinePanelConfig(e)} + ${this._renderPanelTelemetrySection(e)} +
+
+ ` : y` +
+
+ ${this._renderPopupCloseButton(this._closeDialog, this._t("card.popup.close_detail"))} +
+
+
${this._t("card.popup.panel_eyebrow", { + slot: this._formatSlotLabel(e.slotIndex) + })}
+

${e.label}

+
+
+ + ${this._renderPopupGraph(e)} + +
+
+ ${this._t("card.popup.detail.status")} + ${this._panelStatusLabel(e.status)} +
+
+ ${this._t("card.popup.detail.power")} + ${e.powerDisplay} +
+
+ ${this._t("card.popup.detail.energy")} + + ${r} + +
+
+ ${this._t("card.popup.detail.deviation")} + + ${e.status === "inverter" ? this._t("card.popup.deviation.inverter_mismatch") : e.deviationPercent !== null ? this._t("card.popup.deviation.below_peers", { + percent: e.deviationPercent.toFixed(0) + }) : this._t("card.popup.deviation.within_range")} + +
+
+ ${this._t("card.popup.detail.rated_performance")} + + ${e.ratedPowerW !== null ? this._t("card.popup.rated_performance.format", { + rated: e.ratedPowerW.toFixed(0), + percent: e.performancePercent !== null ? `${e.performancePercent.toFixed(0)}%` : this._t("card.popup.rated_performance.na") + }) : this._t("common.not_configured")} + +
+
+ +
+ ${this._t("card.popup.detail.information")} +
+

${e.reason}

+ ${e.powerEntityName ? y`

${this._t("card.popup.info.power_source", { + value: e.powerEntityName + })}

` : w} + ${e.stringGroupLabel && e.stringPowerSourceName && e.stringPowerSharePercent !== void 0 ? y`

${this._t("card.popup.info.string_share", { + label: e.stringGroupLabel, + percent: e.stringPowerSharePercent.toFixed(0), + source: e.stringPowerSourceName + })}

` : w} +

+ ${this._t("card.popup.info.current_inverter_status", { + value: e.inverterStatusDisplay ?? this._t("common.not_configured") + })} +

+

+ ${this._t("card.popup.info.inverter_evaluation", { + value: this._renderInverterEvaluation(e) + })} +

+

+ ${this._t("card.popup.info.inverter_source", { + value: e.inverterStatusEntityName ?? this._t("common.not_configured") + })} +

+
+
+ ${this._renderPanelTelemetrySection(e)} + +
+
+ `; + } + _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` + + ` : 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` +
+
+ ${e.label} +
+ ${nt.map( + (c) => 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` +
+
+ ${se} +
+ ${nt.map( + (b) => 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} + + ${A.map( + (b) => U` + + ` + )} + ${p.map((b) => { + const C = h.get(b.panelId); + return !C || C.pointsCount === 0 ? w : C.pointsCount >= 2 ? U` + + ${C.firstPoint && C.lastPoint ? U` + + + ` : w} + ` : C.firstPoint ? U` + + ` : w; + })} + ${L !== null ? U` + + ` : w} + ${R !== null ? U` + + ` : w} + ${m !== null ? U` + + ` : 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` +
+
+ ${u} +
+ ${nt.map( + (g) => 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` +
+
+ ${this._renderPopupCloseButton( + this._closeLivePowerPopup, + this._t("card.popup.close_live_power") + )} +
+
+
${this._t("card.popup.power_eyebrow")}
+

${this._t("card.popup.power_title")}

+
+
+ + ${t ? this._renderPopupGraphForEntity( + t, + this._t("card.popup.history.system_power"), + { + overlayEntityId: n ? s.entityId : null, + overlayStateMessage: o, + showEnableForecastButton: l + } + ) : this._renderAggregatedPanelGraph(e, "power", { + overlayEntityId: n ? s.entityId : null, + overlayStateMessage: o, + showEnableForecastButton: l + })} + +
+
+ ${this._t("card.popup.detail.current")} + + ${He( + r.value, + this._config?.power_decimals ?? 0, + r.unit, + this._t("common.unavailable"), + a + )} + +
+
+ ${this._t("card.popup.detail.estimated_power_now")} + ${s.display} +
+
+ ${this._t("card.popup.detail.source")} + + ${t ? this._t("card.summary.system_sensor") : this._t("card.summary.sum_panel_sensors")} + +
+
+ + ${this._renderKpiCompareControls(e, "power")} +
+
+ `; + } + _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` +
+
+ ${this._renderPopupCloseButton(this._closeEnergyPopup, this._t("card.popup.close_energy"))} +
+
+
${this._t("card.popup.energy_eyebrow")}
+

${this._t("card.popup.energy_title")}

+
+
+ + ${t ? this._renderPopupGraphForEntity( + t, + this._t("card.popup.history.system_energy"), + { + overlayEntityId: n ? s.entityId : null, + overlayStateMessage: o, + showEnableForecastButton: l + } + ) : this._renderAggregatedPanelGraph(e, "energy", { + overlayEntityId: n ? s.entityId : null, + overlayStateMessage: o, + showEnableForecastButton: l + })} + +
+
+ ${this._t("card.popup.detail.current")} + + ${He( + r.value, + this._config?.energy_decimals ?? 2, + r.unit, + this._t("common.unavailable"), + a + )} + +
+
+ ${this._t("card.popup.detail.estimated_energy_now")} + ${s.display} +
+
+ ${this._t("card.popup.detail.source")} + + ${t ? this._t("card.summary.system_sensor") : this._t("card.summary.sum_panel_sensors")} + +
+
+ + ${this._renderKpiCompareControls(e, "energy")} +
+
+ `; + } + _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` +
+
+ ${this._renderPopupCloseButton( + this._closeCustomKpiPopup, + this._t("card.popup.close_custom_kpi") + )} +
+
+
${this._t("card.popup.custom_eyebrow")}
+

${t}

+
+
+ +
+
+ ${this._t("card.popup.detail.current")} + ${e.value} +
+
+ ${this._t("card.popup.detail.source")} + + ${r ? this._t("card.summary.custom_sensor") : this._t("common.not_configured")} + +
+
+ + ${r ? this._renderPopupGraphForEntity( + r, + `${t} ${this._t("card.popup.history.power")}`, + { + fillStartWithFirstSample: !0, + invertPrimaryValues: this._config?.invert_custom_kpi ?? !1 + } + ) : y`

${this._t("card.popup.history.custom_not_configured")}

`} +
+
+ `; + } + _renderSystemHealthDialog(e) { + const t = this._buildSystemHealthSections(e), r = t.length > 0; + return y` +
+
+ ${this._renderPopupCloseButton( + this._closeSystemHealthPopup, + this._t("card.popup.close_system_health") + )} +
+
+
${this._t("card.popup.system_health_eyebrow")}
+

${this._t("card.popup.system_health_title")}

+
+
+ + ${r ? t.map( + (s) => y` +
+
+ ${s.title} +
+ ${s.items.map( + (n) => y`

${n}

` + )} +
+ ` + ) : y`

${this._t("card.popup.system_health.everything_ok")}

`} +
+
+ `; + } + _renderMetricList(e, t) { + return y` +
+
+ ${e} +
+ ${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` +
+
+ ${t} +
+ ${nt.map( + (s) => 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} + + + + + + + + + ${W.map( + (B) => U` + + ` + )} + ${U` + + `} + ${C.length > 0 ? U` + + ` : w} + ${K !== null ? U` + + ` : w} + ${ye !== null ? U` + + ` : w} + ${ve !== null ? U` + + ` : 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._t("editor.section.layout_title")}

+

+ ${this._t("editor.section.layout_copy")} +

+
+ +
+ ${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._t("editor.section.appearance_title")}

+

+ ${this._t("editor.section.appearance_copy")} +

+
+
+ ${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._t("editor.section.display_title")}

+

+ ${this._t("editor.section.display_copy")} +

+
+
+ ${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._t("editor.section.forecast_title")}

+

+ ${this._t("editor.section.forecast_copy")} +

+

+ ${this._t("editor.forecast.default_sensors")} +

+

+ ${this._t("editor.forecast.line_help")} +

+
+
+ this._updateRootValue( + "enable_forecast_overlay", + s.currentTarget.checked + )} + >${this._t("editor.toggle.enable_forecast_overlay")} +
+

${r}

+
+ +
+
+

${this._t("editor.section.array_health_title")}

+

+ ${this._t("editor.section.array_health_copy")} +

+
+
+ 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._t("editor.section.inverter_title")}

+

+ ${this._t("editor.section.inverter_copy")} +

+
+
+ 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._t("editor.section.status_colors_title")}

+

+ ${this._t("editor.section.status_colors_copy")} +

+
+
+ ${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._t("editor.section.motion_title")}

+

+ ${this._t("editor.section.motion_copy")} +

+
+
+ 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._t("editor.section.panels_title")}

+

+ ${this._t("editor.section.panels_copy")} +

+

+ ${this._t("editor.section.panels_drag_hint")} +

+
+ + ${this._renderStringSetupSection()} + +
+
+

${this._t("editor.section.panel_defaults_title")}

+

+ ${this._t("editor.section.panel_defaults_copy")} +

+
+
+ ${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._t("editor.section.autofill_title")}

+

+ ${this._t("editor.section.autofill_copy")} +

+
+
+ ${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._t("editor.section.string_setup_title")}

+

+ ${this._t("editor.section.string_setup_copy")} +

+
+
+ 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 + })} + + +
+
+ ${this._renderEditorInput( + this._t("editor.field.string_group_name"), + e.name ?? "", + (o) => this._updateStringGroupValue(t, "name", o), + { updateOn: "change" } + )} + ${this._renderEditorInput( + this._t("editor.field.string_group_panel_count"), + String(e.panel_count), + (o) => this._updateStringGroupValue( + t, + "panel_count", + this._parseNumberWithClamp( + o, + e.panel_count, + 1, + Y + ) + ), + { + type: "number", + min: 1, + max: Y, + updateOn: "change" + } + )} +
+ ${this._renderSelectorWithClear( + this._t("editor.field.string_group_power_sensor"), + e.power_entity, + { + entity: { + domain: "sensor", + include_entities: r + } + }, + (o) => this._updateStringGroupValue(t, "power_entity", o) + )} + ${this._renderSelectorWithClear( + this._t("editor.field.string_group_energy_sensor"), + e.energy_entity, + { + entity: { + domain: "sensor", + include_entities: s + } + }, + (o) => this._updateStringGroupValue(t, "energy_entity", o) + )} +
+
+ `; + } + _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)} +
+
+
+
+ ${this._t("card.eyebrow")} + +
+

${title}

+

${subtitle}

+

${this._t("card.subtitle.drag_hint")}

+
+
+ + +
+ ${this._t("card.summary.alerts")} + + ${alertCount} + +
+ ${showCustomKpi + ? html` + + ` + : nothing} +
+
+ +
+ ${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` +
+
+ ${this._renderPopupCloseButton(this._closeDialog, this._t("card.popup.close_detail"))} +
+
+
${this._t("card.popup.panel_eyebrow", { + slot: this._formatSlotLabel(panel.slotIndex), + })}
+

${panel.label}

+
+
+ ${this._renderInlinePanelConfig(panel)} + ${this._renderPanelTelemetrySection(panel)} +
+
+ `; + } + + return html` +
+
+ ${this._renderPopupCloseButton(this._closeDialog, this._t("card.popup.close_detail"))} +
+
+
${this._t("card.popup.panel_eyebrow", { + slot: this._formatSlotLabel(panel.slotIndex), + })}
+

${panel.label}

+
+
+ + ${this._renderPopupGraph(panel)} + +
+
+ ${this._t("card.popup.detail.status")} + ${this._panelStatusLabel(panel.status)} +
+
+ ${this._t("card.popup.detail.power")} + ${panel.powerDisplay} +
+
+ ${this._t("card.popup.detail.energy")} + + ${energyValue} + +
+
+ ${this._t("card.popup.detail.deviation")} + + ${panel.status === "inverter" + ? this._t("card.popup.deviation.inverter_mismatch") + : panel.deviationPercent !== null + ? this._t("card.popup.deviation.below_peers", { + percent: panel.deviationPercent.toFixed(0), + }) + : this._t("card.popup.deviation.within_range")} + +
+
+ ${this._t("card.popup.detail.rated_performance")} + + ${panel.ratedPowerW !== null + ? this._t("card.popup.rated_performance.format", { + rated: panel.ratedPowerW.toFixed(0), + percent: + panel.performancePercent !== null + ? `${panel.performancePercent.toFixed(0)}%` + : this._t("card.popup.rated_performance.na"), + }) + : this._t("common.not_configured")} + +
+
+ +
+ ${this._t("card.popup.detail.information")} +
+

${panel.reason}

+ ${panel.powerEntityName + ? html`

${this._t("card.popup.info.power_source", { + value: panel.powerEntityName, + })}

` + : nothing} + ${panel.stringGroupLabel && + panel.stringPowerSourceName && + panel.stringPowerSharePercent !== undefined + ? html`

${this._t("card.popup.info.string_share", { + label: panel.stringGroupLabel, + percent: panel.stringPowerSharePercent.toFixed(0), + source: panel.stringPowerSourceName, + })}

` + : nothing} +

+ ${this._t("card.popup.info.current_inverter_status", { + value: panel.inverterStatusDisplay ?? this._t("common.not_configured"), + })} +

+

+ ${this._t("card.popup.info.inverter_evaluation", { + value: this._renderInverterEvaluation(panel), + })} +

+

+ ${this._t("card.popup.info.inverter_source", { + value: panel.inverterStatusEntityName ?? this._t("common.not_configured"), + })} +

+
+
+ ${this._renderPanelTelemetrySection(panel)} + +
+
+ `; + } + + 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` + + `; + } + + 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` +
+
+ ${metric.label} +
+ ${POPUP_GRAPH_RANGES.map( + (range) => 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` +
+
+ ${compareTitle} +
+ ${POPUP_GRAPH_RANGES.map( + (range) => 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} + + ${hourTicks.map( + (tick) => svg` + + `, + )} + ${series.map((item) => { + const graphSeries = seriesById.get(item.panelId); + if (!graphSeries || graphSeries.pointsCount === 0) { + return nothing; + } + if (graphSeries.pointsCount >= 2) { + return svg` + + ${graphSeries.firstPoint && graphSeries.lastPoint + ? svg` + + + ` + : nothing} + `; + } + if (!graphSeries.firstPoint) { + return nothing; + } + return svg` + + `; + })} + ${compareMaxY !== null + ? svg` + + ` + : nothing} + ${compareMedianY !== null + ? svg` + + ` + : nothing} + ${compareMinY !== null + ? svg` + + ` + : 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` +
+
+ ${title} +
+ ${POPUP_GRAPH_RANGES.map( + (range) => 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` +
+
+ ${this._renderPopupCloseButton( + this._closeLivePowerPopup, + this._t("card.popup.close_live_power"), + )} +
+
+
${this._t("card.popup.power_eyebrow")}
+

${this._t("card.popup.power_title")}

+
+
+ + ${systemPowerEntityId + ? this._renderPopupGraphForEntity( + systemPowerEntityId, + this._t("card.popup.history.system_power"), + { + overlayEntityId: forecastOverlayEnabled ? forecastPower.entityId : null, + overlayStateMessage: forecastOverlayMessage, + showEnableForecastButton: canEnableForecastFromPowerPopup, + }, + ) + : this._renderAggregatedPanelGraph(derived, "power", { + overlayEntityId: forecastOverlayEnabled ? forecastPower.entityId : null, + overlayStateMessage: forecastOverlayMessage, + showEnableForecastButton: canEnableForecastFromPowerPopup, + })} + +
+
+ ${this._t("card.popup.detail.current")} + + ${formatSummaryValue( + summaryPower.value, + this._config?.power_decimals ?? 0, + summaryPower.unit, + this._t("common.unavailable"), + locale, + )} + +
+
+ ${this._t("card.popup.detail.estimated_power_now")} + ${forecastPower.display} +
+
+ ${this._t("card.popup.detail.source")} + + ${systemPowerEntityId + ? this._t("card.summary.system_sensor") + : this._t("card.summary.sum_panel_sensors")} + +
+
+ + ${this._renderKpiCompareControls(derived, "power")} +
+
+ `; + } + + 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` +
+
+ ${this._renderPopupCloseButton(this._closeEnergyPopup, this._t("card.popup.close_energy"))} +
+
+
${this._t("card.popup.energy_eyebrow")}
+

${this._t("card.popup.energy_title")}

+
+
+ + ${systemEnergyEntityId + ? this._renderPopupGraphForEntity( + systemEnergyEntityId, + this._t("card.popup.history.system_energy"), + { + overlayEntityId: forecastOverlayEnabled ? forecastEnergy.entityId : null, + overlayStateMessage: forecastOverlayMessage, + showEnableForecastButton: canEnableForecastFromEnergyPopup, + }, + ) + : this._renderAggregatedPanelGraph(derived, "energy", { + overlayEntityId: forecastOverlayEnabled ? forecastEnergy.entityId : null, + overlayStateMessage: forecastOverlayMessage, + showEnableForecastButton: canEnableForecastFromEnergyPopup, + })} + +
+
+ ${this._t("card.popup.detail.current")} + + ${formatSummaryValue( + summaryEnergy.value, + this._config?.energy_decimals ?? 2, + summaryEnergy.unit, + this._t("common.unavailable"), + locale, + )} + +
+
+ ${this._t("card.popup.detail.estimated_energy_now")} + ${forecastEnergy.display} +
+
+ ${this._t("card.popup.detail.source")} + + ${systemEnergyEntityId + ? this._t("card.summary.system_sensor") + : this._t("card.summary.sum_panel_sensors")} + +
+
+ + ${this._renderKpiCompareControls(derived, "energy")} +
+
+ `; + } + + 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` +
+
+ ${this._renderPopupCloseButton( + this._closeCustomKpiPopup, + this._t("card.popup.close_custom_kpi"), + )} +
+
+
${this._t("card.popup.custom_eyebrow")}
+

${popupTitle}

+
+
+ +
+
+ ${this._t("card.popup.detail.current")} + ${customKpi.value} +
+
+ ${this._t("card.popup.detail.source")} + + ${customKpiEntityId + ? this._t("card.summary.custom_sensor") + : this._t("common.not_configured")} + +
+
+ + ${customKpiEntityId + ? this._renderPopupGraphForEntity( + customKpiEntityId, + `${popupTitle} ${this._t("card.popup.history.power")}`, + { + fillStartWithFirstSample: true, + invertPrimaryValues: this._config?.invert_custom_kpi ?? false, + }, + ) + : html`

${this._t("card.popup.history.custom_not_configured")}

`} +
+
+ `; + } + + private _renderSystemHealthDialog(derived: DerivedArrayState) { + const sections = this._buildSystemHealthSections(derived); + const hasIssues = sections.length > 0; + + return html` +
+
+ ${this._renderPopupCloseButton( + this._closeSystemHealthPopup, + this._t("card.popup.close_system_health"), + )} +
+
+
${this._t("card.popup.system_health_eyebrow")}
+

${this._t("card.popup.system_health_title")}

+
+
+ + ${!hasIssues + ? html`

${this._t("card.popup.system_health.everything_ok")}

` + : sections.map( + (section) => html` +
+
+ ${section.title} +
+ ${section.items.map( + (item) => html`

${item}

`, + )} +
+ `, + )} +
+
+ `; + } + + private _renderMetricList( + title: string, + entries: Array<{ label: string; value: string }>, + ) { + return html` +
+
+ ${title} +
+ ${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` +
+
+ ${title} +
+ ${POPUP_GRAPH_RANGES.map( + (range) => 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} + + + + + + + + + ${hourTicks.map( + (tick) => svg` + + `, + )} + ${svg` + + `} + ${overlayLinePath.length > 0 + ? svg` + + ` + : nothing} + ${maxY !== null + ? svg` + + ` + : nothing} + ${medianY !== null + ? svg` + + ` + : nothing} + ${minY !== null + ? svg` + + ` + : 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._t("editor.section.layout_title")}

+

+ ${this._t("editor.section.layout_copy")} +

+
+ +
+ ${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._t("editor.section.appearance_title")}

+

+ ${this._t("editor.section.appearance_copy")} +

+
+
+ ${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._t("editor.section.display_title")}

+

+ ${this._t("editor.section.display_copy")} +

+
+
+ ${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._t("editor.section.forecast_title")}

+

+ ${this._t("editor.section.forecast_copy")} +

+

+ ${this._t("editor.forecast.default_sensors")} +

+

+ ${this._t("editor.forecast.line_help")} +

+
+
+ + this._updateRootValue( + "enable_forecast_overlay", + (event.currentTarget as HTMLInputElement).checked, + )} + >${this._t("editor.toggle.enable_forecast_overlay")} +
+

${forecastStatus}

+
+ +
+
+

${this._t("editor.section.array_health_title")}

+

+ ${this._t("editor.section.array_health_copy")} +

+
+
+ + 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._t("editor.section.inverter_title")}

+

+ ${this._t("editor.section.inverter_copy")} +

+
+
+ + 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._t("editor.section.status_colors_title")}

+

+ ${this._t("editor.section.status_colors_copy")} +

+
+
+ ${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._t("editor.section.motion_title")}

+

+ ${this._t("editor.section.motion_copy")} +

+
+
+ + 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._t("editor.section.panels_title")}

+

+ ${this._t("editor.section.panels_copy")} +

+

+ ${this._t("editor.section.panels_drag_hint")} +

+
+ + ${this._renderStringSetupSection()} + +
+
+

${this._t("editor.section.panel_defaults_title")}

+

+ ${this._t("editor.section.panel_defaults_copy")} +

+
+
+ ${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._t("editor.section.autofill_title")}

+

+ ${this._t("editor.section.autofill_copy")} +

+
+
+ ${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._t("editor.section.string_setup_title")}

+

+ ${this._t("editor.section.string_setup_copy")} +

+
+
+ + 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, + }, +});