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}${r}
@@ -7389,15 +7376,13 @@ const gn = /[._\-\s]+/, us = /^\d+$/, zt = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,}$/i,${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._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 )}
${R}
+${this._t("card.subtitle.drag_hint")}
+${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._t("card.popup.telemetry.setup_hint")}
+${this._t("card.popup.telemetry.setup_hint")}
`} ++ ${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` +${this._t("card.popup.panel_compare.loading")}
` : c ? y`${c}
` : d.hasData ? g ? y`${this._t("card.popup.panel_compare.render_failure")}
` : y` +${this._t("card.popup.panel_compare.no_data")}
`} + + ${f ? 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` +${r.overlayStateMessage}
` : w} + ${r.showEnableForecastButton ? y` +${this._t("card.popup.history.custom_not_configured")}
`} +${n}
` + )} +${this._t("card.popup.system_health.everything_ok")}
`} ++ ${r.label}: ${r.value} +
+ ` + )} +${this._t("card.popup.history.graph_not_configured")}
`} + ${r.overlayStateMessage ? y`${r.overlayStateMessage}
` : w} + ${r.showEnableForecastButton ? 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` +${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:
+No available W sensors found.
` : w} ++ ${this._t("editor.section.layout_copy")} +
++ ${this._t("editor.section.appearance_copy")} +
++ ${this._t("editor.section.display_copy")} +
++ ${this._t("editor.section.forecast_copy")} +
++ ${this._t("editor.forecast.default_sensors")} +
++ ${this._t("editor.forecast.line_help")} +
+${r}
++ ${this._t("editor.section.array_health_copy")} +
++ ${this._t("editor.section.array_health_smoothing_help")} +
++ ${this._t("editor.section.inverter_copy")} +
++ ${this._t("editor.helper.fault_example")} +
++ ${this._t("editor.helper.working_example")} +
++ ${this._t("editor.section.status_colors_copy")} +
++ ${this._t("editor.section.motion_copy")} +
++ ${this._t("editor.section.panels_copy")} +
++ ${this._t("editor.section.panels_drag_hint")} +
++ ${this._t("editor.section.panel_defaults_copy")} +
++ ${this._t("editor.section.autofill_copy")} +
++ ${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._t("editor.section.string_setup_copy")} +
++ ${this._t("editor.helper.string_sensor_sharing_help")} +
+ ${e ? y` ++ ${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._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) + ) + )} +${subtitle}
+${this._t("card.subtitle.drag_hint")}
+${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._t("card.popup.telemetry.setup_hint")}
+${this._t("card.popup.telemetry.setup_hint")}
` + : nothing} ++ ${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` +${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` ++ ${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` +${options.overlayStateMessage}
` + : nothing} + ${options.showEnableForecastButton + ? html` +${this._t("card.popup.history.custom_not_configured")}
`} +${this._t("card.popup.system_health.everything_ok")}
` + : sections.map( + (section) => html` +${item}
`, + )} ++ ${entry.label}: ${entry.value} +
+ `, + )} +${this._t("card.popup.history.graph_not_configured")}
` + : this._renderPopupGraphBody(entityId, options)} + ${options.overlayStateMessage + ? html`${options.overlayStateMessage}
` + : nothing} + ${options.showEnableForecastButton + ? 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` +${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, + ): PromiseQuick Setup:
+No available W sensors found.
` + : nothing} ++ ${this._t("editor.section.layout_copy")} +
++ ${this._t("editor.section.appearance_copy")} +
++ ${this._t("editor.section.display_copy")} +
++ ${this._t("editor.section.forecast_copy")} +
++ ${this._t("editor.forecast.default_sensors")} +
++ ${this._t("editor.forecast.line_help")} +
+${forecastStatus}
++ ${this._t("editor.section.array_health_copy")} +
++ ${this._t("editor.section.array_health_smoothing_help")} +
++ ${this._t("editor.section.inverter_copy")} +
++ ${this._t("editor.helper.fault_example")} +
++ ${this._t("editor.helper.working_example")} +
++ ${this._t("editor.section.status_colors_copy")} +
++ ${this._t("editor.section.motion_copy")} +
++ ${this._t("editor.section.panels_copy")} +
++ ${this._t("editor.section.panels_drag_hint")} +
++ ${this._t("editor.section.panel_defaults_copy")} +
++ ${this._t("editor.section.autofill_copy")} +
++ ${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._t("editor.section.string_setup_copy")} +
++ ${this._t("editor.helper.string_sensor_sharing_help")} +
+ ${enabled + ? html` ++ ${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._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), + ), + )} +