diff --git a/.env.example b/.env.example index 5e916e73..0d8bf8d9 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,8 @@ # Copy this file to .env and adjust paths for your local setup. - -# Path to span-panel-api repo (for editable pip install) -export SPAN_PANEL_API_DIR=../span-panel-api +# +# Consumed by the shell scripts under `scripts/`, which source it. Nothing in the +# test suite reads it: the library the suite resolves is named by +# `[tool.uv.sources]` in `pyproject.toml`, not by an environment variable. # Path to span-card frontend repo (for build-frontend.sh) export SPAN_CARD_DIR=../cards/span-card diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 04057bc9..c37b19ec 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,14 +11,7 @@ updates: allow: - dependency-type: "all" groups: - # Home Assistant core, stubs, and test component must upgrade together. - # A version mismatch between any of these causes resolution failures. - ha-core: - patterns: - - "homeassistant" - - "homeassistant-stubs" - - "pytest-homeassistant-custom-component" - # Group remaining development dependencies together + # Group development dependencies together dev-dependencies: patterns: - "pytest*" @@ -35,10 +28,6 @@ updates: - "types-*" - "voluptuous-stubs" - "pyright" - exclude-patterns: - - "homeassistant" - - "homeassistant-stubs" - - "pytest-homeassistant-custom-component" reviewers: - "cayossarian" assignees: @@ -50,9 +39,17 @@ updates: - "dependencies" - "python" ignore: - # Ignore major version updates for span-panel-api since it's a local dependency + # Bumped by hand with each integration release, together with + # manifest.json, so the manifest and the pip pins never drift. - dependency-name: "span-panel-api" - update-types: ["version-update:semver-major"] + - dependency-name: "span-panel-api-schema-0" + - dependency-name: "span-panel-api-schema-1" + # Bumped by hand as a pair, as in #281: each harness release pins one + # exact Home Assistant, often a beta, so the harness moves only to the + # release that pins the `homeassistant` version chosen. `homeassistant` + # is ignored too, because bumping it alone breaks the same pairing. + - dependency-name: "pytest-homeassistant-custom-component" + - dependency-name: "homeassistant" # GitHub Actions dependencies - package-ecosystem: "github-actions" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebecee83..04b16469 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} allow-prereleases: true diff --git a/.github/workflows/dependabot-auto-approve.yml b/.github/workflows/dependabot-auto-approve.yml index 2a56ecf9..d27c6bc1 100644 --- a/.github/workflows/dependabot-auto-approve.yml +++ b/.github/workflows/dependabot-auto-approve.yml @@ -19,8 +19,9 @@ jobs: with: github-token: "${{ secrets.GITHUB_TOKEN }}" + # Not `span-panel-api`, for the reason given in dependabot-auto-merge.yml. - name: Auto-approve patch and minor updates - if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' }} + if: ${{ (steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor') && !contains(steps.metadata.outputs.dependency-names, 'span-panel-api') }} uses: hmarr/auto-approve-action@v4 with: review-message: "Auto-approved ${{ steps.metadata.outputs.update-type }} update for ${{ steps.metadata.outputs.dependency-names }}" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index febf8f6e..3a7906b2 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -19,8 +19,10 @@ jobs: with: github-token: "${{ secrets.GITHUB_TOKEN }}" + # Not `span-panel-api`: it is bumped by hand with manifest.json (see + # .github/dependabot.yml), so a bump that slips through must not land alone. - name: Auto-merge for patch and minor updates - if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' }} + if: ${{ (steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor') && !contains(steps.metadata.outputs.dependency-names, 'span-panel-api') }} run: | echo "Auto-merging ${{ steps.metadata.outputs.dependency-names }}" gh pr merge --auto --merge "$PR_URL" diff --git a/AGENTS.md b/AGENTS.md index 18441d56..36cc115c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,9 @@ forget action). Services that perform actions without returning data omit `suppo ### 6. Runtime Data Guards Handlers that access `entry.runtime_data` MUST verify the entry is loaded and its runtime data is the expected type before accessing coordinator or other -runtime objects. Use `hasattr` and `isinstance` checks — never assume runtime data is present. +runtime objects. Call `runtime.loaded_runtime_data(entry)`, which answers `SpanPanelRuntimeData | None` — never inline the check and never assume runtime data +is present. It reads the attribute with a default, because core deletes `runtime_data` on unload, and then narrows it with `isinstance`, because what is there +is whatever the owning integration put there. ### 7. Domain-Level Registration diff --git a/CHANGELOG.md b/CHANGELOG.md index a84ef89a..8ffb751d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,196 @@ All notable changes to this project will be documented in this file. +## [2.1.2] - 10/2026 + +### In short + +- **Install 2.1.2 before your panel takes firmware r202639**, or the battery's Meter Power reads with its sign flipped, positive while charging. + +The credential-rotation, GFE Override and DSM Grid State fixes below were reported and fixed by [@dcj](https://github.com/dcj) in +[#277](https://github.com/SpanPanel/span/pull/277), from issue [#276](https://github.com/SpanPanel/span/issues/276). + +### Added + +- **Support for SPAN panel firmware r202639**, whose changes are listed in SPAN's + [API changelog](https://github.com/spanio/SPAN-API-Client-Docs/blob/main/CHANGELOG.md), including its reversed battery meter sign, a circuit for every + inverter, SPAN Drive charge-current limits and a panel that asks setup to wait and try again. +- **Each additional solar inverter gets its own device and card tile**, with its vendor, product, nameplate capacity, readings and panel link, and the power and + chart of the circuit that feeds it. +- **The card editor has a Solar section**, and its Battery section offers Meter Power and Nameplate Capacity. + +### Changed + +- **New installs no longer allow control without a logged-in user by default**, so automations cannot operate circuits until you turn on **Allow control without + a logged-in user**; existing installs keep their current setting ([#274](https://github.com/SpanPanel/span/discussions/274)). +- **The Solar tile's headline and chart show the power of the circuit that feeds its inverter**, with your site's total PV power beneath it. + +### Fixed + +- **Home Assistant 2026.8 and 2026.9 device-lookup deprecation warnings are resolved**, and a card set up before 2026.8 finds its panel again with nothing to + change. +- **Rotate panel credentials returns the new panel passphrase**, reconnects instead of asking to reauthenticate, says what happened when it fails, works on a + panel that is not loaded, and runs overlapping requests one after the other. +- **GFE Override and DSM Grid State follow what the panel is acting on**: the button is available only while the battery's link is not OK, and DSM Grid State + shows an active GFE override. +- **Energy dip compensation keeps Net Energy consistent with Produced and Consumed Energy** for solar circuits and the main meter, applies a change made while + Home Assistant is starting, and is no longer turned on by saving General Options on an install that never enabled it. +- **A circuit the panel no longer lets you control loses its switch and priority select** instead of leaving them unavailable, gets them back with the same + entity IDs if that changes, and the card shows its relay state rather than a dead toggle. +- **The card no longer draws an empty Microgrid Interconnect tile**, its editor lists every EV charger's entities, and a circuit feeding an EV charger keeps its + chart history. +- **A broker reconnect no longer makes a solar circuit read as a load for a moment or reloads the integration.** + +## [2.1.1] - 8/2026 + +### Added + +- **Adopted entities can now be curated from the dashboard's new Adopted tab** — set a device class, a statistics class and whether the entity stays under + Diagnostics, with the change applied on an automatic reload. + +### Fixed + +- **The startup warning that panel traffic is unencrypted is resolved** — a panel with a pinned certificate authority is now read over verified HTTPS, with + repairs and a Reconfigure HTTPS-port option covering the rare setups where the certificate or the port needs attention (#264). + +## [2.1.0] - 8/2026 + +### In short + +- **Install this before SPAN pushes firmware `r202633`** — it replaces the panel's wire model, and 2.0.8 cannot read a panel that has taken it. Requires Home + Assistant 2026.8. +- **The Microgrid Interconnect Device and the solar inverter become devices of their own**, carrying grid health and whether Grid Power is really measuring the + grid. +- **EVSE Charge Current Limit** is the first control here that changes something on a SPAN Drive rather than on the panel. +- **The battery says when things run out** — Time to Priority Shed and Backup Time Remaining, beside its own power and link state. +- **Vendor extensions are adopted on a reload**, so a device type or reading nobody has modeled no longer waits for a release. +- **Your passphrase is no longer stored and the panel's certificate is pinned before credentials cross the network**, with control lockable or admin-only. +- **Nothing you have moves** — entity ids, unique ids and history survive the upgrade. + +### You will need this release before SPAN updates your panel + +SPAN firmware `r202633` retires the wire model every release up to 2.0.8 reads, and serves its replacement from the reboot onwards. **2.0.8 cannot read a panel +on `r202633`.** + +### Requires Home Assistant 2026.8.0 or newer + +This release requires Home Assistant 2026.8 to avoid a deprecated API — the old API stops working entirely in 2027.8. + +**The transition is uneventful** — the integration boots with the right wire library based on which firmware it sees. Your devices and entities are intact. + +### Added (subject to firmware support) + +- **The panel's Microgrid Interconnect Device appears as its own device**, carrying **Grid State** — the health of the utility supply itself, which your panel + never reported before. +- **Grid Power now reports whether it sits at the service entrance**, through an `at_service_entrance` attribute: false where a battery or another panel sits + between the utility and your main lugs, which is when it and **Grid Power Flow** legitimately disagree. +- **The attribute is present only where your panel publishes the topology it is read from.** + +- **The solar inverter gets a device of its own** rather than rendering as diagnostic sensors on the panel's card; the five PV entities keep the entity ids, + unique ids and history they have today. +- **Assign the new Solar device to an area** — those five no longer inherit the panel's area, which quietly drops them from area-scoped dashboards, automations + and voice commands until you do. +- New installations get `sensor.span_panel_solar_pv_vendor` and friends where existing ones keep `sensor.span_panel_pv_vendor`; both are correct. + +- **EVSE Charge Current Limit** — a settable ceiling on each commissioned SPAN Drive. +- The maximum is the one your installer commissioned; the control is unavailable where the rating is not published. +- The control appears only where the panel says the limit can be changed and reports a change still in flight as a `charge_current_limit_target` attribute. + +- **PV Panel Link and EVSE Panel Link** — the panel's own report of the link to your inverter and to each charger, which is the fact **BESS Connected** has + always shown for the battery. +- **EVSE Panel Link is not EV Connected**: one is the panel reaching the charger at all, the other is the charger reporting a plugged-in vehicle, and they can + disagree mid-session. + +- **BESS Meter Power** — what the battery itself reports it is charging or discharging at, beside the panel's arbitrated **Battery Power**. Enabled by default. +- **BESS Communication State** — the battery's own `OK` / `DEGRADED` / `LOST` / `UNKNOWN` view of its link, which can differ from the panel's. Diagnostic, off + by default. +- **Both battery power sensors read positive when discharging**, settled by measurement on a live panel — the same convention as PV Power producing and Grid + Power Flow importing. + +- **Time to Priority Shed and Backup Time Remaining** — how long before the panel starts shedding circuits and how long before the battery is spent. Enabled by + default. Each forecast carries its full-charge equivalent and the panel's own `forecast_confidence` as attributes, rather than as two more near-constant + entities. + +- **Import Limit, Binding Constraint and PCS Active** — the current limit your panel enforces, which rule set it, and whether anything is being throttled right + now. Filed under Diagnostics. Import Limit carries the arbitration as attributes. +- **Every circuit's power sensor gains `pcs_managed` and `pcs_priority`** where the circuit reports them — the shed order when an import limit binds, which is + not the backup tier the existing `shed_priority` names. + +- **Vendor extensions** — new device types and readings are picked up on a reload. They arrive disabled and filed as diagnostics. Nothing adopted enters + long-term statistics, because `state_class` is not published on the wire. + +- **Your panel's own card shows metadata** — manufacturer, model and hardware revision read from the enclosure once your panel publishes them. +- **Part Number** diagnostic sensor on every SPAN Drive, matching the one the battery already has. Off by default. +- **Circuit Priority's shed policy is readable**: `dsm_state` gains `shed_algorithm` and the two state-of-charge thresholds that decide when circuits shed and + when they come back. +- **Grid-forming device name** as an attribute on the Grid Forming Entity sensor. +- **The diagnostics download includes your entity registry** — every entity this integration owns, its unique id, and what disabled it, which Home Assistant + will not otherwise tell you. + +### Changed (subject to firmware support) + +- **`DSM Grid State` is now more trustworthy**, reading the islanding state the Microgrid Interconnect Device actually senses rather than inferring it from the + battery and the dominant power source, and keeping its entity id and its history. +- **`Grid Islandable` keeps working across the upgrade** by reflecting whether a Microgrid Interconnect Device is present, so a panel without one reads `Off` + rather than going unavailable. +- **Battery model may read differently after upgrading**, now showing the human-readable designation rather than the SKU, normalized in the library on both + sides of the upgrade so it lands once at this release. +- **Five panel sensors are switched off for new installations** (#234) because of three SPAN-documented defects predating firmware r202633: **Feedthrough + Produced Energy**, **Feedthrough Consumed Energy**, **Feedthrough Power**, and the two **Downstream** current sensors. +- **New entities are now announced in a notification** that splits what arrived into ready to use and switched off, and names each one. +- **The Wi-Fi network name moved to the Wi-Fi Link sensor** as a `wifi_ssid` attribute, absent rather than blank on a panel that publishes no SSID. It is no + longer an attribute of the Software Version sensor, so a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` should point at the + Wi-Fi Link binary sensor instead. +- **A panel device you renamed yourself is offered ids carrying that name**, since Home Assistant composes from the name you gave the device ahead of the one + this integration generated. +- **New installations provide uniform circuit energy sensor ids — `consumed_energy`, `produced_energy` and `net_energy`**, in line with the panel-level energy + sensors. +- **A newly commissioned SPAN Drive's feed-circuit sensors are named for the charger** — `sensor._power`, matching the charger's other sensors; the + ones you already have keep their ids. + +### Fixed + +- **Energy dip compensation no longer books a dip that never happened** (#259), where a partial reading at startup looked like a counter reset and every restart + added each circuit's whole lifetime counter to its offset. +- **A panel this integration can no longer reach makes its entities unavailable instead of reporting 0 W** — a certificate authority that changes mid-session + used to leave every power sensor reading zero and every energy sensor frozen, indefinitely and indistinguishably from a panel drawing no power. +- **A panel that has moved to another address now says so in Repairs**, naming the addresses its certificate does carry so you can point the entry at one with + Reconfigure, instead of retrying silently with every entity unavailable and one line a minute in the log. +- **An add-on restart no longer overwrites the address you configured** — a panel announced by an add-on keeps the host you gave it for as long as that host + still answers, and only the add-on's own ports are taken as published. +- **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style, leaving unique ids and statistics + untouched. +- **The README described Battery Power's sign backwards**, since the sensor has always reported discharging as positive (#184) and only the documentation was + ever wrong. +- **A breaker your panel refuses to operate now says so**, reporting the panel's own reason where you pressed it instead of a traceback in the log and a toggle + that quietly springs back. +- **A control command that never reached your panel now says so**, reported where you issued it rather than only in the log and stating plainly that nothing was + queued. + +### Security + +- **The panel's certificate authority is pinned before your passphrase is ever sent**, so the registration exchange that carries it — and everything after — no + longer crosses your network in the clear. +- **A changed authority stops the integration and raises a repair** carrying both fingerprints, because a legitimate rotation and an impersonated panel look + identical from here. +- **Four new options decide who may operate the panel**: administrators only, no commands without a logged-in user, an admin-only control lock, and a two-second + relay debounce. +- **`Administrators only` does not cover an automation a non-admin triggered**, because Home Assistant gives an automation a fresh context with no user on it + and there is then nothing to hold to the admin test; switch off **Allow control without a logged-in user** if you want those refused too. +- **Every control command is now recorded** in the logbook, on the event bus as `span_panel_control_command`, and at `INFO`, attributed to the automation that + issued it rather than to nobody. +- **A new [Security](README.md#security) section in the README** covers what rotation costs and the deployment choices that actually bound the panel's exposure. + +### Known limitations + +- **A panel reachable only by a single-label name — one with no dot and no domain — cannot complete a pinned setup by that name**, because nothing registers a + name that is not a fully qualified domain, so the certificate never comes to name it. Set that panel up by its IP address, or by the `.local` name shown in + the mobile app. +- **A discovered address this entry's authority rejects is refused with a log warning and nothing else** — if the panel really has moved, use **Reconfigure** on + the entry, which checks the new address against the standing pin before storing it. A panel whose certificate does not yet name where it now answers is moved + by reconfiguring to a fully qualified domain name, which asks the panel to regenerate its certificate around it, or to the `.local` name its certificate + already covers; a bare new IP address the certificate does not name is refused, because every connection after the flow would reject it too. + ## [2.0.8] - 5/2026 ### Fixed @@ -35,11 +225,6 @@ All notable changes to this project will be documented in this file. ## [2.0.6] - 4/2026 -**Important** 2.0.x cautions still apply — read those carefully if not already on 2.0.x BEFORE proceeding: - -- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) -- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading - ### Added - **By Activity and By Area views** — Two new circuit views available in both the integration panel and the Lovelace card (span-card 0.9.2): @@ -61,11 +246,6 @@ All notable changes to this project will be documented in this file. ## [2.0.5] - 4/2026 -**Important** 2.0.x cautions still apply — read those carefully if not already on 2.0.x BEFORE proceeding: - -- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) -- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading - ### Added - **Current monitoring and dashboard** — Real-time monitoring of circuit and mains current draw, managed from a new sidebar panel with Panel, Monitoring, and @@ -99,11 +279,6 @@ All notable changes to this project will be documented in this file. ## [2.0.4] - 3/2026 -**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding: - -- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) -- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading - ### Added - **Grid Power sensor** — New `Grid Power`. Previously only `Current Power` (upstream lugs measurement) was available; the new sensor surfaces the panel's own @@ -117,9 +292,9 @@ All notable changes to this project will be documented in this file. ### Changed - **Simulation moved to dedicated add-on** — Panel cloning and simulation are no longer part of the integration's options flow. A new `export_circuit_manifest` - service provides panel parameters to the standalone [SPAN Panel Simulator](https://github.com/SpanPanel/simulator) add-on, which now supports upgrade - modelling (evaluate firmware or integration upgrades in a sandbox before applying them to your real panel) and panel clone (replicate your panel's circuit - layout for testing). + service provides panel parameters to the standalone [SPAN Panel Simulator](https://github.com/SpanPanel/simulator) add-on, which now supports upgrade modeling + (evaluate firmware or integration upgrades in a sandbox before applying them to your real panel) and panel clone (replicate your panel's circuit layout for + testing). ### Fixed @@ -133,11 +308,6 @@ All notable changes to this project will be documented in this file. ## [2.0.3] - 3/2026 -**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding: - -- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) -- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading - ### Fixed - **Force dependency re-resolution** — Version bump to ensure HACS re-installs `span-panel-api` for users who had the earlier 2.0.2 release. Users upgrading HA @@ -145,11 +315,6 @@ All notable changes to this project will be documented in this file. ## [2.0.2] - 3/2026 -**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding: - -- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) -- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading - ### Fixed - **Panel size always available** — `panel_size` is now sourced from the Homie schema by the underlying `span-panel-api` Previously some users could see fewer @@ -165,13 +330,13 @@ All notable changes to this project will be documented in this file. ## [2.0.1] - 3/2026 -⚠️ **STOP — If your SPAN panel is not on firmware `spanos2/r202603/05` or later, do not upgrade. Ensure you are on v1.3.0 or later BEFORE upgrading to 2.0. This -upgrade migrates to the SPAN official eBus API. Make a backup first.** ⚠️ +**This is the release that moved the integration to the SPAN official eBus API**, which every release since reads and every panel now runs. The prerequisites +below applied while panels were still being updated to `spanos2/r202603/05`; they are recorded here for history. ### Breaking Changes -- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) -- You _must_ already be on v1.3.0 or later of the SpanPanel/span integration if upgrading +- Required firmware `spanos2/r202603/05` or later (the eBus MQTT API) +- You had to already be on v1.3.0 or later of the SpanPanel/span integration to upgrade - After upgrading, you must re-authenticate using your **panel passphrase** (found in the SPAN mobile app under On-premise settings) or **proof of proximity** (open and close the panel door 3 times). See the [README](README.md) for details. - If you were running a beta or RC, ensure you reload the integration after upgrade diff --git a/README.md b/README.md index de316de8..f0a7e283 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The integration includes a built-in dashboard accessible from the Home Assistant monitoring with configurable alerts, and circuit settings for relays and load shedding. See [Frontend Dashboard](frontend.md) for details. You can optionally use the [span-card](https://github.com/SpanPanel/span-card) Lovelace card for visualization and switch control. -The [SPAN Panel Simulator](https://github.com/SpanPanel/simulator) HA App lets you clone your panel's circuit layout for testing, or model an upgrade to +The [SPAN Panel PanelBench](https://github.com/SpanPanel/panelbench) HA App lets you clone your panel's circuit layout for testing, or model an upgrade to evaluate firmware or integration changes in a sandbox before applying them to your real panel. This integration communicates with the SPAN Panel over your local network using SPAN's official @@ -44,34 +44,11 @@ This integration communicates with the SPAN Panel over your local network using infrastructure. eBus uses the [Homie Convention](https://homieiot.github.io/) for MQTT topics and messages, with the panel's built-in MQTT broker delivering real-time state updates without polling. -## 1.1.x Integration Sunset (v1) - -Users MUST upgrade by the end of 2026 to avoid disruption. Upgrade to the latest 1.1.x version BEFORE upgrading to 2.0.x. - -## 2.0.x Breaking Changes (v2) - -**Do NOT upgrade unless your panel is running firmware `spanos2/r202603/05` or later.** - -**What you need:** - -- SPAN Panel firmware `spanos2/r202603/05` or later -- Panel passphrase (found in the SPAN mobile app, On-premise settings) **or** physical access to the panel door for proof-of-proximity authentication - -**Breaking:** - -- Requires firmware `spanos2/r202603/05` or later — panels on older firmware will not work -- `Cellular` binary sensor removed — replaced by `Vendor Cloud` sensor - -> Running older firmware? See [v1 Legacy Documentation](v1-legacy.md). - -See [CHANGELOG.md](CHANGELOG.md) for all additions or value changes. - ## Prerequisites - [Home Assistant](https://www.home-assistant.io/) installed - [HACS](https://hacs.xyz/) installed - SPAN Panel with firmware `spanos2/r202603/05` or later -- SPAN Panel integration v1.3.0 or later - Panel passphrase (found via the SPAN app) **or** physical access to the panel door ## Installation @@ -83,22 +60,13 @@ See [CHANGELOG.md](CHANGELOG.md) for all additions or value changes. 5. Restart Home Assistant (you will be prompted by a repair notification) 6. Go to `Settings` > `Devices & Services` 7. Click `+ Add Integration` and search for "Span" -8. Enter the IP address of your SPAN Panel +8. Pick your panel from the discovered list, or enter its IP address or `.local` name (both shown in the mobile app) 9. The integration detects the panel as v2 and presents an authentication choice: - **Enter Panel Passphrase** — type the passphrase found in the SPAN mobile app under On-premise settings - **Proof of Proximity** — open and close the panel door 3 times, then click Submit -10. Choose your entity naming pattern +10. Choose your entity ID naming pattern (see naming patterns below) 11. Optionally adjust the snapshot update interval — 0 is real-time, up to 15 seconds based on CPU -### Upgrade Process - -When upgrading through HACS: - -1. **Create a backup** of your Home Assistant configuration and database -2. **Review the changes** in this README and CHANGELOG -3. **Check your automations** — review any references to removed entities -4. **Update during a quiet period** when you can monitor the upgrade - If you encounter issues, restore from your backup or check the [troubleshooting section](#troubleshooting) below. ## Key Terms @@ -132,45 +100,130 @@ The following terms appear throughout this document and in the integration's sen | Feed Through Net Energy | Energy | Wh | Feedthrough net energy | | DSM State | — | — | dsm_on_grid (grid connected), dsm_off_grid (islanded), unknown. Derived from multiple eBus signals | | Current Run Config | — | — | panel_on_grid (grid connected), panel_off_grid (islanded on PV/generator), panel_backup (islanded on battery), unknown | -| Grid Forming Entity | — | — | (v2) GRID, BATTERY, PV, GENERATOR, NONE, UNKNOWN. See [Grid Forming Entity](#grid-forming-entity) | +| Grid Forming Entity | — | — | GRID, BATTERY, PV, GENERATOR, NONE, UNKNOWN. See[Grid Forming Entity](#grid-forming-entity) | | Main Relay State | — | — | closed (power flowing), open (disconnected), unknown | -| Vendor Cloud | — | — | (v2) CONNECTED, UNCONNECTED, UNKNOWN | +| Vendor Cloud | — | — | CONNECTED, UNCONNECTED, UNKNOWN | | Software Version | — | — | Firmware version string | -### Panel Diagnostic Sensors (v2 only) +### Panel Diagnostic Sensors + +| Sensor | Device Class | Unit | Notes | +| --------------------- | ------------ | ---- | ----------------------------------------------------------------- | +| L1 Voltage | Voltage | V | L1 leg actual voltage | +| L2 Voltage | Voltage | V | L2 leg actual voltage | +| Upstream L1 Current | Current | A | Upstream lugs L1 current | +| Upstream L2 Current | Current | A | Upstream lugs L2 current | +| Downstream L1 Current | Current | A | Downstream lugs L1 current. Off by default from 2.1.x — see below | +| Downstream L2 Current | Current | A | Downstream lugs L2 current. Off by default from 2.1.x — see below | +| Main Breaker Rating | Current | A | Main breaker amperage. Off by default | + +L1/L2 Voltage and Main Breaker Rating have always been off by default; enable them from the panel's device page if you want them. + +**The three Feedthrough sensors and the two Downstream current sensors are off by default from 2.1.x.** The eBus specification's maintainer has documented that +the panel's feedthrough (downstream lugs) figures cannot be relied on: the energy registers can decrease or go negative, the power reading is inverted relative +to every other terminal, and the downstream currents report the **upstream** service conductors. The defects predate `r202633`. + +Existing installations keep all five, with their history and entity ids — Home Assistant applies the setting only when an entity is first created. If you use +them somewhere, removing them is worth considering, but that is your call. + +### Shed Forecast Sensors + +Created only when your panel publishes the `shed-forecast` capability, and only for the estimates it actually publishes. -| Sensor | Device Class | Unit | Notes | -| --------------------- | ------------ | ---- | -------------------------- | -| L1 Voltage | Voltage | V | L1 leg actual voltage | -| L2 Voltage | Voltage | V | L2 leg actual voltage | -| Upstream L1 Current | Current | A | Upstream lugs L1 current | -| Upstream L2 Current | Current | A | Upstream lugs L2 current | -| Downstream L1 Current | Current | A | Downstream lugs L1 current | -| Downstream L2 Current | Current | A | Downstream lugs L2 current | -| Main Breaker Rating | Current | A | Main breaker amperage | +| Sensor | Device Class | Unit | Notes | +| --------------------- | ------------ | ---- | ---------------------------------------------------------------- | +| Time to Priority Shed | Duration | min | Estimated time before the next priority tier of circuits is shed | +| Backup Time Remaining | Duration | min | Estimated time before every sheddable circuit is shed (off-grid) | -### Power Flow Sensors (v2 only) +#### Shed Forecast Sensor Attributes -| Sensor | Device Class | Unit | Notes | -| ------------- | ------------ | ---- | --------------------------------------------------------------------------- | -| Grid Power | Power | W | Grid power flow | -| Site Power | Power | W | Total site power (grid + PV + battery) | -| Battery Power | Power | W | Battery charge/discharge (+discharge, -charge). Only when BESS commissioned | -| PV Power | Power | W | PV generation (+producing). Only when PV commissioned | +Present only when the panel publishes them. -### PV Metadata Sensors (v2 only, on main panel device) +| Attribute | Type | On | Notes | +| ----------------------------------- | ------ | --------------------- | ----------------------------------------------------- | +| `full_charge_time_to_priority_shed` | int | Time to Priority Shed | The same estimate assuming the battery starts full | +| `full_charge_total_time_remaining` | int | Backup Time Remaining | The same estimate assuming the battery starts full | +| `forecast_confidence` | string | both | The panel's own assessment:`LOW`, `MEDIUM`, or `HIGH` | -| Sensor | Device Class | Unit | Notes | -| ------------------ | ------------ | ---- | --------------------------------------------- | -| PV Vendor | — | — | PV inverter vendor (e.g., "Enphase", "Other") | -| PV Product | — | — | PV inverter product (e.g., "IQ8+") | -| Nameplate Capacity | Power | kW | Rated inverter capacity | +### Power Control System Sensors + +Created only when your panel publishes the `pcs` capability, and created whether or not the PCS is switched on — a PCS reporting a limit of 0 A is reporting a +state, not an absence. + +| Sensor | Device Class | Unit | Notes | +| ------------------ | ------------ | ---- | -------------------------------------------------------------------------------------------------------------------------- | +| Import Limit | Current | A | The limit actually being enforced: the most restrictive active constraint (diagnostic) | +| Binding Constraint | Enum | — | Which constraint sets that limit: Firm Service Rating, Grid Envelope, Voltage Support, Off-Grid, Requested, Operator, None | + +#### Power Control System Sensor Attributes + +On **Import Limit**, and present only when the panel publishes them. These are the inputs the panel reconciled to produce the enforced limit above. + +| Attribute | Type | Notes | +| ------------------------ | ------ | -------------------------------------------------------------- | +| `pcs_enabled` | bool | Whether the panel's PCS is enabled at all | +| `feed_import_limit` | float | The Firm Service Rating: the commissioned, always-on floor (A) | +| `operator_import_limit` | float | A cap imposed by a fleet or aggregator operator (A) | +| `off_grid_import_limit` | float | The import cap while islanded (A) | +| `requested_import_limit` | float | A voluntary limit requested by the owner or installer (A) | +| `_enablement` | string | Per limit:`UNSPECIFIED`, `UNCONFIGURED`, `DISABLED`, `ENABLED` | +| `_active` | bool | Per limit: whether that constraint is currently enforcing | + +### Power Flow Sensors + +| Sensor | Device Class | Unit | Notes | +| ------------- | ------------ | ---- | ----------------------------------------------------------------------------------- | +| Grid Power | Power | W | Grid power flow | +| Site Power | Power | W | Total site power (grid + PV + battery) | +| Battery Power | Power | W | Battery charge/discharge (**+discharging, −charging**). Only when BESS commissioned | +| PV Power | Power | W | PV generation (+producing). Only when PV commissioned | + +### PV Metadata Sensors (on the Solar sub-device) + +From 2.1.x these live on a **Solar** device of their own rather than on the panel's card, alongside PV Power and PV Panel Link. + +| Sensor | Device Class | Unit | Notes | +| ------------------ | ------------ | ---- | ------------------------------------------------------------------------------------ | +| PV Vendor | — | — | PV inverter vendor (e.g., "Enphase", "Other") | +| PV Product | — | — | PV inverter product (e.g., "IQ8+") | +| Nameplate Capacity | — | W | DC array size recorded at installation; informational, never a limit. Off by default | + +If you upgraded, these keep their entity ids, unique ids and history — but not the panel's area, since an entity takes its area from its device and the Solar +device starts without one. Assign it an area, or anything area-scoped (dashboards, automations, voice targeting a room) stops matching them. New installations +get ids from the new device name — `sensor.span_panel_solar_pv_vendor` rather than `sensor.span_panel_pv_vendor`. Both are correct and neither changes again. + +Each additional solar inverter (published from firmware r202639) gets a **Solar Inverter** device of its own, named after the circuit that feeds it where one +does, with its own PV Vendor, PV Product and Nameplate Capacity, and its PV Panel Link where the panel reports one. The PV entities on the **Solar** device +never move: they keep describing the inverter they always did, beside PV Power, the panel's total. Where the panel cannot say which inverter they described — an +inverter no circuit feeds, for example behind a Tesla Gateway, or several already reported when you upgraded — they describe all of your inverters together. An +inverter device the panel no longer reports stays, unavailable, until you delete it. Each inverter's power is the power of the circuit that feeds it, which the +SPAN Panel card shows on that inverter's tile. An inverter no circuit feeds, such as one upstream of the panel, has no individual reading; its output is +included in PV Power, the panel's total. The card's Solar tile shows its inverter's circuit and the site total together; on a single-inverter site the two are +usually close. **Deprecated:** -| Sensor | Reason | -| -------------- | ------------------------------------------------------------------------------------------------------------------------- | -| DSM Grid State | Deprecated — still available, but users should rely on `DSM State` as `DSM Grid State` may be removed in a future version | +| Sensor | Reason | +| -------------- | ------------------------------------------------------------------------------------------------------------------------ | +| DSM Grid State | Deprecated — still available, but users should rely on`DSM State` as `DSM Grid State` may be removed in a future version | + +### Microgrid Interconnect Device + +Your panel publishes its Microgrid Interconnect Device — the switch that disconnects your home from the utility during an outage — as a device of its own, +linked to the panel. It appears automatically where the panel reports one; nothing existing moves onto it. + +| Sensor | Device Class | Unit | Notes | +| ---------- | ------------ | ---- | ------------------------------------------------------------------------- | +| Grid State | Enum | — | Health of the utility supply itself:`up`, `down`, `degraded` or `unknown` | + +This is new information — the previous firmware did not report the utility supply at all. It is not **DSM Grid State**, which is whether **your home** is +islanded: the grid can be down while your home runs happily off the battery. + +**A panel with no battery has no MID, and that is itself an answer.** The specification makes backup capability structural — having a MID is what says a panel +can island — so **Grid Islandable** reads `Off` and **Grid Forming Entity** reads `Grid` rather than either going unavailable. + +`DSM Grid State` keeps its entity id and history but is no longer inferred from the battery or the dominant power source; it now reads the islanding state the +MID actually senses, or your GFE override while the panel is acting on one. ### Power Sensor Attributes @@ -181,12 +234,29 @@ Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Powe | `voltage` | string | Nominal panel voltage ("240") | | `amperage` | string | Calculated current (power / voltage) | +**Grid Power** carries one more, because its name is only true in some wiring: + +| Attribute | Type | Notes | +| --------------------- | ------- | -------------------------------------------------------------------------- | +| `at_service_entrance` | boolean | Whether this panel's upstream lugs are where the utility actually connects | + +Grid Power reads the upstream lugs. That is grid flow when those lugs are the utility connection point, which is the ordinary case. Put a battery between the +utility and your main lugs, or feed this panel from another panel, and the same reading becomes **this panel's** supply while **Grid Power Flow** stays the +whole-site figure — so the two legitimately disagree. When `at_service_entrance` is `false`, use Grid Power Flow for site-level grid import and export. + ### Software Version Sensor Attributes -| Attribute | Type | Notes | -| ------------ | ------ | ----------------------------------- | -| `panel_size` | int | Total breaker spaces (e.g., 32, 40) | -| `wifi_ssid` | string | Current Wi-Fi network | +| Attribute | Type | Notes | +| ------------ | ---- | ----------------------------------- | +| `panel_size` | int | Total breaker spaces (e.g., 32, 40) | + +`wifi_ssid` used to appear here. It moved to the Wi-Fi Link binary sensor below; a template reading it from this sensor should be pointed there. + +### Wi-Fi Link Binary Sensor Attributes + +| Attribute | Type | Notes | +| ----------- | ------ | ---------------------------------------------------------------- | +| `wifi_ssid` | string | Network this link is to. Absent when the panel publishes no SSID | ### EVSE (EV Charger) Entities @@ -208,6 +278,10 @@ feature. A display suffix differentiates multiple chargers on the same panel: | Circuit numbers | `Main House SPAN Drive (SN-EVSE-001)` | `sensor.main_house_span_drive_sn_evse_001_charger_status` | | No suffix | `Main House SPAN Drive` | `sensor.main_house_span_drive_charger_status` | +The circuit that feeds a charger has its sensors shown on the charger's device. Their entity IDs name the charger alone — +`sensor.main_house_span_drive_garage_power` — matching the charger's other sensors. A feed sensor you already have keeps its current id until you press +**Recreate entity IDs**, which offers the charger-named one. + #### EVSE Sensors (per charger) | Sensor | Device Class | Unit | Notes | @@ -215,13 +289,30 @@ feature. A display suffix differentiates multiple chargers on the same panel: | Charger Status | Enum | — | OCPP-based states: AVAILABLE, PREPARING, CHARGING, SUSPENDED_EV, etc. Translated | | Advertised Current | Current | A | Amps offered to the vehicle | | Lock State | Enum | — | LOCKED, UNLOCKED, UNKNOWN. Translated | +| Part Number | — | — | Charger part number (diagnostic,**off by default**) | #### EVSE Binary Sensors (per charger) -| Sensor | Device Class | Notes | -| ------------ | ---------------- | ------------------------------------------------------------------ | -| Charging | Battery Charging | ON when status is CHARGING | -| EV Connected | Plug | ON when status is PREPARING, CHARGING, SUSPENDED\_\*, or FINISHING | +| Sensor | Device Class | Notes | +| --------------- | ---------------- | ------------------------------------------------------------------------------------------------ | +| Charging | Battery Charging | ON when status is CHARGING | +| EV Connected | Plug | ON when status is PREPARING, CHARGING, SUSPENDED\_\*, or FINISHING — a vehicle is plugged in | +| EVSE Panel Link | Connectivity | Whether the panel can reach the charger. A different fact from EV Connected, and it can disagree | + +**EVSE Panel Link is not EV Connected.** EV Connected is what the charger says about the cable in front of it; EVSE Panel Link is what the panel says about +whether it can reach the charger at all. A charger part-way through a session behind a lost link reports a plugged-in vehicle and a dead link at the same time. +EVSE Panel Link is a diagnostic and appears only where the circuit feeding that charger publishes the link record. + +#### EVSE Controls (per charger) + +| Control | Platform | Unit | Notes | +| ------------------------- | -------- | ---- | --------------------------------------------------------------------------------------- | +| EVSE Charge Current Limit | Number | A | The charge-current ceiling you can lower. Bounded by the installer-commissioned maximum | + +The maximum is read from the panel, never assumed: it is the current the charger was commissioned for, and a value above it is refused rather than clamped. The +control is created only where the panel declares the limit settable, and reports unavailable while the panel has not published the commissioned maximum that +bounds it. A change the panel has acknowledged but not yet applied appears as a `charge_current_limit_target` attribute while the state stays the limit the +charger is still enforcing. #### EVSE Device Info Attributes @@ -232,23 +323,26 @@ feature. A display suffix differentiates multiple chargers on the same panel: | Serial Number | `serial-number` | | Software Version | `software-version` | -### BESS Sub-Device (v2 only, conditional) +### BESS Sub-Device (conditional) When a Battery Energy Storage System (BESS) is commissioned, the integration creates a separate BESS sub-device linked to the panel via `via_device`. The BESS device uses manufacturer, model, serial number, and software version from battery metadata as device info attributes. #### BESS Sensors -| Sensor | Device Class | Unit | Notes | -| ------------------ | -------------- | ---- | ----------------------------------------------------------------- | -| Battery Level | Battery | % | State of energy as percentage | -| Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device | -| BESS Vendor | — | — | Battery system vendor (diagnostic) | -| BESS Model | — | — | Battery system model (diagnostic) | -| BESS Serial Number | — | — | Battery system serial number (diagnostic) | -| BESS Firmware | — | — | Battery system firmware (diagnostic) | -| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic) | -| Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) | +| Sensor | Device Class | Unit | Notes | +| ------------------- | -------------- | ---- | ------------------------------------------------------------------------------- | +| Battery Level | Battery | % | State of energy as percentage | +| Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device | +| Meter Power | Power | W | The BESS's own meter (**+discharging, −charging**), agreeing with Battery Power | +| Communication State | — | — | The BESS's report of its own link health (diagnostic, disabled by default) | +| BESS Vendor | — | — | Battery system vendor (diagnostic) | +| BESS Model | — | — | Battery system model (diagnostic) | +| BESS Part Number | — | — | Battery system part number (diagnostic,**off by default**) | +| BESS Serial Number | — | — | Battery system serial number (diagnostic) | +| BESS Firmware | — | — | Battery system firmware (diagnostic) | +| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic,**off by default**) | +| Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) | #### BESS Binary Sensors @@ -272,20 +366,22 @@ Applies to Main Meter and Feed Through energy sensors. | Produced Energy | Energy | Wh | Cumulative energy produced | | Consumed Energy | Energy | Wh | Cumulative energy consumed | | Net Energy | Energy | Wh | Net energy (sign depends on device type — PV circuits invert) | -| Current | Current | A | (v2) Measured circuit current. Only when panel reports `current_a` | -| Breaker Rating | Current | A | (v2) Circuit breaker amperage (diagnostic). Only when reported | +| Current | Current | A | Measured circuit current. Only when panel reports`current_a` | +| Breaker Rating | Current | A | Circuit breaker amperage (diagnostic). Only when reported | ### Circuit Power Sensor Attributes -| Attribute | Type | Notes | -| ----------------- | ------ | ----------------------------------------------------- | -| `tabs` | string | Breaker slot position(s) | -| `voltage` | string | 120 or 240 (derived from tab count) | -| `always_on` | bool | Whether circuit is always-on | -| `relay_state` | string | OPEN / CLOSED / UNKNOWN | -| `relay_requester` | string | Who requested relay state | -| `shed_priority` | string | API value: NEVER / SOC_THRESHOLD / OFF_GRID / UNKNOWN | -| `is_sheddable` | bool | Whether circuit can be shed | +| Attribute | Type | Notes | +| ----------------- | ------ | --------------------------------------------------------------------------------------------------------------- | +| `tabs` | string | Breaker slot position(s) | +| `voltage` | string | 120 or 240 (derived from tab count) | +| `always_on` | bool | Whether circuit is always-on | +| `relay_state` | string | OPEN / CLOSED / UNKNOWN | +| `relay_requester` | string | Who requested relay state | +| `shed_priority` | string | API value: NEVER / SOC_THRESHOLD / OFF_GRID / UNKNOWN | +| `is_sheddable` | bool | Whether circuit can be shed | +| `pcs_managed` | bool | Whether the panel's Power Control System manages this circuit. Present only when the circuit reports it | +| `pcs_priority` | int | This circuit's shed order under an active import limit — distinct from`shed_priority`, which is the backup tier | ### Circuit Energy Sensor Attributes @@ -296,32 +392,85 @@ Applies to Main Meter and Feed Through energy sensors. ### Binary Sensors -| Sensor | Device Class | Notes | -| --------------- | ------------ | ------------------------------------------------------------------- | -| Door State | Tamper | Panel door open/closed | -| Ethernet Link | Connectivity | Wired network status | -| Wi-Fi Link | Connectivity | Wireless network status | -| Panel Status | Connectivity | Overall panel online/offline | -| Grid Islandable | — | (v2) Whether the panel can island from the grid. Only when reported | +| Sensor | Device Class | Notes | +| --------------- | ------------ | ------------------------------------------------------------------------------------------- | +| Door State | Tamper | Panel door open/closed | +| Ethernet Link | Connectivity | Wired network status | +| Wi-Fi Link | Connectivity | Wireless network status | +| Panel Status | Connectivity | Overall panel online/offline | +| Grid Islandable | — | Whether the panel can island from the grid. Off on a panel with no MID — see below | +| PCS Active | Running | Whether the Power Control System is limiting import right now. Only when the panel runs one | +| PV Panel Link | Connectivity | Whether the panel can reach the solar inverter. Only when the feeding circuit reports it | **Removed from binary sensors:** -| Sensor | Reason | -| --------------- | ------------------------------------------------------ | -| Cellular (wwan) | Replaced by `Vendor Cloud` sensor (cloud connectivity) | +| Sensor | Reason | +| --------------- | ----------------------------------------------------- | +| Cellular (wwan) | Replaced by`Vendor Cloud` sensor (cloud connectivity) | ### Circuit Controls (per user-controllable circuit) -| Entity | Type | Notes | -| --------------------- | ------ | -------------------------------------------------------------------------- | -| Breaker | Switch | On/off relay control | -| Circuit Shed Priority | Select | (v2) Controls when circuit is shed during off-grid (translated, see below) | +| Entity | Type | Notes | +| ---------------- | ------ | ------------------------------------------------------------------------- | +| Breaker | Switch | On/off relay control | +| Circuit Priority | Select | Controls when the circuit is shed during off-grid (translated, see below) | ### Panel Controls -| Entity | Type | Notes | -| ---------------------------- | ------ | ---------------------------------------------------------------------- | -| GFE Override: Grid Connected | Button | (v2) Tell the panel the grid is up when BESS communication interrupted | +| Entity | Type | Notes | +| ---------------------------- | ------ | ----------------------------------------------------------------- | +| GFE Override: Grid Connected | Button | Tell the panel the grid is up when BESS communication interrupted | + +### Adopted Devices and Readings + +The eBus schema is vendor-extensible, so your panel can publish a device type this integration has never modeled. Rather than ignoring it, the integration gives +it a card of its own hanging off the panel, carrying whatever identity it publishes, with its readings as entities beneath it. + +Everything adopted arrives **disabled** and **filed under Diagnostics until you promote it** from the dashboard [Adopted tab](#the-adopted-tab); the new-entity +notification names the device so you can find it. A property the device accepts writes to becomes a control rather than a reading — a `boolean` becomes a +switch, an enumeration becomes a select, a number becomes a number entity — and those arrive switched off too. + +Three things worth knowing before you build on one: + +- **Nothing adopted enters long-term statistics until you say it should.** Adopted entities arrive with no statistics class, because the correct one is not + published on the wire. You are welcome to supply it in the [Adopted tab](#the-adopted-tab). +- **A new property on a device this integration already models is adopted too**, but as a reading on that device's existing card rather than as a device of its + own. +- **What the delete button does**, since it is not quite what you would expect: + + - Delete a reading while your panel is still publishing that property and it comes back — switched off — at the next reload, carrying whatever you curated for + it. Curation is keyed on the wire address rather than on the entity, so deleting the entity does not discard it. There is no setting to suppress the entity, + because leaving it switched off is already that. + - Delete one after your panel has stopped publishing it and it stays gone, because nothing exists to recreate it from. + +So deletion means "hide it until next time" for a live reading and "clear it out" for a dead one, and your panel decides which. + +**These entities are permanent in id, not in identity.** If one of these readings is later modeled properly (delivered as an official part of the integration), +that entity is a new entity with its own id and its own history — the adopted one is not renamed into it. That is why a vendor reading you have come to depend +on is worth raising in an issue: being modeled is what turns it into something with a real name, a proper category and statistics out of the box. + +### The Adopted Tab + +The integration will not guess what an adopted reading means. You are not guessing — it is your device — so the built-in dashboard has an **Adopted** tab where +you can tell the integration about the reading. + +A row that arrived as a control rather than a reading — a switch, a select or a number box — offers prominence alone, because a device class and a statistics +class both describe a value being read. + +You are only offered choices your panel's own declaration allows. A statistics class is offered only on a reading whose declared datatype is numeric; the device +classes listed are the ones that fit both what the panel says the reading is and the unit it publishes, so a text reading is never offered a class that expects +a number, and the unit itself stays whatever the publisher sends. + +**Saving reloads the integration**, and setting or clearing a statistics class asks you to confirm before it does. + +Two consequences are what that confirmation is about: + +- **`total_increasing` tells Home Assistant the reading is a meter that only counts up.** The recorder treats a drop of more than a tenth as the meter being + reset and starts a new cycle, so choosing it for a reading that legitimately falls will manufacture consumption that never happened. Choose it only for a + genuine lifetime total. +- **Removing a statistics class stops statistics, and Home Assistant will say so.** If an entity already has statistics and then loses its statistics class, + Home Assistant raises a repair notice against it — a warning rather than something with a fix button — and stops compiling new statistics for it. The + statistics already collected are not deleted, and the notice clears by itself if you put a statistics class back. ### BESS & Grid Management @@ -347,6 +496,12 @@ When a BESS is installed, the panel relies on the BESS to determine whether the while the panel is islanded, the GFE value becomes stale — it may show Battery when the grid has actually been restored, causing unnecessary shedding to continue. +**On a panel with no battery, GFE is Grid, on both firmware generations.** The newer firmware moves this value onto the Microgrid Interconnect Device, which is +part of a battery system — so a panel without one has nothing publishing it. The answer is still settled, by what cannot be there: Battery needs a BESS (which +brings a MID), PV cannot form a grid on its own (anything that can is a grid-forming inverter, which is a MID), and None describes a panel supplying nothing, +which is a panel that is not reporting at all. What remains is a generator, and SPAN with no generator interface treats one as the grid. So Grid is what a +battery-less panel reports, which is what the older firmware reported too. + #### What the Panel Can Detect **Grid loss** — The panel independently detects grid loss via its own voltage monitoring, even if BESS communication is already lost. The MID is still closed at @@ -371,6 +526,9 @@ restoration in this state. Only an external signal (utility-side sensor) or manu The **GFE Override: Grid Connected** button tells the panel that the grid is back and shedding can stop. When the BESS restores communication, it automatically reclaims control and the override is superseded — no manual undo is needed. +The button is available only while the panel is not already on grid and the battery's **Communication State** is anything other than OK, which is when the panel +accepts the override. On firmware that does not publish that state, it is available unless **BESS Connected** is on. + **Risk asymmetry** — Telling the panel to shed (conservative direction) is low-risk; worst case is unnecessary circuit disruption. Telling the panel the grid is back when it is not means unmanaged battery drain and reduced runtime, which could affect critical equipment. The battery protects itself by disconnecting when depleted, so there is no overload risk, but runtime will be reduced. Use the override button only with confidence that the grid has actually been restored — via @@ -404,17 +562,29 @@ Configure via `Settings` > `Devices & Services` > `SPAN Panel` > `Configure` > ` The integration provides flexible entity naming patterns, configured during initial setup: 1. **Friendly Names** (Recommended for new installations) + - Entity IDs use descriptive circuit names from your SPAN panel - Example: `sensor.span_panel_kitchen_outlets_power` - - Automatically updates when you rename circuits in the SPAN panel + - Renaming a circuit in the SPAN app updates the displayed name automatically — the integration reloads itself to pick the new name up, since it travels as + the entity's original name and only a rebuild refreshes it; the entity ID changes only if you accept the offer from **Recreate entity IDs** - More intuitive for automations and scripts 2. **Circuit Numbers** (Stable entity IDs) + - Entity IDs use generic circuit numbers - Example: `sensor.span_panel_circuit_15_power` - - Entity IDs remain stable even when circuits are renamed + - Entity IDs stay stable when circuits are renamed - Friendly names still sync from SPAN panel for display +The integration supplies only the circuit half of the ID shown above — `Kitchen Outlets Power` or `Circuit 15 Power`. Home Assistant composes the rest from your +own entity ID settings (`entity_id_parts`, Home Assistant 2026.8 and newer), which decide whether the device name and the area are prefixed. + +For an entity that already exists, the suffix wording in that half is read back from the ID the entity has rather than reworded. Circuit energy IDs have shipped +in two spellings — `consumed_energy` and `energy_consumed` — so an installation is offered whichever one it is already using, and never the other; only new +sensors get the `consumed_energy` word order that the panel-level sensors use. Because your entity ID settings decide the rest of the ID, an installation whose +circuits were named before those settings existed — a SPAN Drive feed circuit, a second panel, or an installation predating the device prefix — may be offered +an ID with a different device half. Entities you already have keep the IDs they have until you press **Recreate entity IDs** and accept the offer. + ### Energy Dip Compensation SPAN panels occasionally report lower energy readings for cumulative energy sensors after firmware updates or resets. Home Assistant's statistics engine @@ -428,7 +598,14 @@ increasing value. - **Threshold:** 1.0 Wh minimum to avoid false triggers from float precision noise - **Disabling:** Clears all accumulated offsets (starts fresh if re-enabled) -When a dip is detected, a persistent notification lists the affected sensors and their dip amounts. +A dip is compensated as soon as it is seen, but not believed straight away. A counter reset is permanent — the counter restarts low and counts up from there — +so a reading that drops and then returns to where it was is a transport artifact rather than a reset, and its offset is taken back. The offset stays provisional +until a later reading either disproves it (the counter comes back) or corroborates it (the counter climbs from the new, lower base). + +**The persistent notification therefore lists a dip once it settles — when that window closes — and a dip that is disproved produces no notification at all** — +the sensor was compensated the whole time and nothing needs your attention. Reporting on corroboration alone left a notice standing for an event the next +reading undid, which a persistent notification cannot take back the way the offset can. Seeing no notification after a momentary dip is the feature working, not +failing. **Diagnostic attributes** (visible when compensation is active): @@ -437,6 +614,12 @@ When a dip is detected, a persistent notification lists the affected sensors and | `energy_offset` | Cumulative Wh compensation applied (when > 0) | | `last_dip_delta` | Size of the most recent dip in Wh | +**Before upgrading to 2.1.2,** check the `energy_offset` of your Produced and Consumed Energy sensors as described under **Energy Dashboard spikes after +firmware updates** in [Troubleshooting](#troubleshooting). At the first update after the upgrade, Main Meter Net Energy steps once by its counters' offsets, +which it now applies, and a solar circuit's Net Energy steps by twice the difference of its counters' offsets, which it now applies in its own direction. A load +circuit's Net Energy does not step. An offset far larger than its sensor's reading was never a real reset: turn Energy Dip Compensation off and then back on to +clear it before you upgrade. + Configure via `Settings` > `Devices & Services` > `SPAN Panel` > `Configure` > `General Options`. ### Customizing Entity Precision @@ -447,22 +630,141 @@ precision 2 (e.g., `0.00`), except battery percentage which uses precision 0 (e. You can change the display precision for any entity via `Settings` > `Devices & Services` > `Entities` tab. Find the entity, click on it, click the gear wheel, and select your preferred precision from the "Display Precision" menu. +## Security + +**The integration cannot enforce much on its own.** Anything holding the eBus broker password — including another integration in the same Home Assistant process +— talks to the panel directly, outside Home Assistant's permission model. + +**What pinning the panel's certificate authority implies**: + +- A **listener** cannot read your passphrase or the credentials the panel returns for it. +- A device **actively in the path at that first fetch** can answer with an authority of its own and read both. Pinning alone cannot detect this (see deployment + recommendations). + +Comparing the fingerprint against another source closes the second case. It is in diagnostics under `panel_ca`, and in the log of the setup that pinned it — at +`INFO` from the config flow, at `WARNING` for an entry pinned on upgrade. After the first pin, any change stops the integration and raises a repair — see +Troubleshooting. + +| Situation | What happens | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Setting up by hostname | Verified, never relaxed. A domain joins the certificate's SAN only after you authenticate; everything before that runs against an address the certificate already names. | +| Panel announces a new address, or you re-add it | The entry moves only if the candidate serves a certificate its own anchor validates. Otherwise the move is refused and logged at `WARNING`. | +| The panel really has moved | Use **Reconfigure**. A host that does not chain is refused, one that does not answer is reported unreachable — move it to an **FQDN** (the panel regenerates its certificate around that name) or the panel's **`.local` name** (already covered). A bare new IP the certificate does not name is refused. | +| The entry has no anchor, or a stored one won't load | **Reauthenticate** acquires one before either sign-in method is offered. | + +### Restricting who can operate the panel + +Four options in **Settings → Devices & Services → Span Panel → Configure**. Each is enforced where commands are published rather than only at the entity, so a +refusal holds however the command arrived. + +| Option | Default | What it refuses | What it does not do | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| **Who may operate the panel** | Any Home Assistant user — Home Assistant's own policy, which grants every non-admin control of every entity | `Administrators only` refuses circuit switches, priority selects, the GFE override, EVSE limits and adopted controls from non-admin users. `Nobody` also stops those entities being created. | Neither touches sensors. Neither reaches an automation, which runs with no user at all — pair it with the next option. | +| **Allow control without a logged-in user** | Off for a new install. An install set up before this default changed keeps its setting: on, unless you had turned it off | While off, refuses every command with no user attached: all automations, and any script or integration an automation calls. A script a person runs keeps that person's user and is unaffected. Turn it on if your automations operate circuits. | It cannot tell a well-behaved automation from a runaway one — only that neither has a user. | +| **Control lock auto-relock** | Off, and no switch exists | A switch that, while armed, refuses every control command. Anyone can arm it; only a logged-in administrator can disarm it. `0` holds the disarm until someone re-arms; any other value re-arms after that many minutes. | It is not a password. It defends against misclicks and runaway automations, which is what a local control can actually do. | +| **Relay debounce** | 2 seconds — **the one default that is not what your panel did before this release**; set `0` to restore that | A second command to the same circuit's relay inside the window. Refused, never queued. | It is per circuit, so an automation cycling many circuits still gets through. | + +**What none of this defends against.** These options constrain callers arriving through Home Assistant, and nothing else. Anything already holding the broker +credential — a second Home Assistant instance, a script you wrote, a malicious custom integration reading it out of this process's memory — publishes to the +panel directly; see [Recommended deployment](#recommended-deployment), where network topology and a locked enclosure are the real boundary. + +**Choosing `Nobody` deletes nothing.** The control entities stop being created and read as unavailable, but their registry entries, names, areas and +customizations are kept, so turning the option back on restores exactly the entities you had. For an administrator, the shipped SPAN Panel card and dashboard +offer no breaker switch or priority while `Nobody` is chosen. A non-administrator's card finds its entities without the panel's topology, so it shows those +switches dimmed and inert, as other dashboards show them unavailable. + +### The record of what was commanded + +Every control command fires a `span_panel_control_command` event and appears in the logbook, whether it succeeded, was refused, or never reached the panel. When +there is no user — an automation — the originating automation or script is named instead, so an unattended write is attributed to _what_ rather than left blank. +Every command is also logged at `INFO`. + +Commands report one of four outcomes, or a refusal, and the distinctions matter: + +| Outcome | Meaning | +| ------------- | ---------------------------------------------------------------------------------------- | +| `confirmed` | The panel reported the value you asked for. | +| `accepted` | The broker acknowledged the message and the panel did not report a change. | +| `unconfirmed` | Nothing came back within the deadline. **Not an error** — see the troubleshooting entry. | +| `failed` | The command was never handed to the broker and will not be delivered. | +| `refused:…` | This integration refused it, for the named reason. | + +### Rotating panel credentials + +This asks the panel to replace its passphrase, which is currently also the password for the connection the integration uses, stores the new broker password, and +reloads. Access tokens the panel has already issued, including the integration's, are not revoked. Run it after a contractor visit, a suspected exposure, or +anything else that put someone in front of your panel. + +```yaml +action: span_panel.rotate_credentials +data: {} +``` + +**Run it yourself, from the interface, and save the new passphrase.** Only a Home Assistant administrator can, and an automation or script cannot. The new panel +passphrase is shown in the action's response and nowhere else: the integration does not store it. With more than one panel, set the **Config entry** field to +say which — the action refuses to guess. + +**Anything else talking to your panel stops until you set it up again.** A second Home Assistant, a script, third-party tooling: each has to be given the new +password before it reconnects. This integration handles itself: the broker may not accept the new password as soon as the panel returns it, so the integration +keeps retrying with the new password for about a minute. + +**A refused rotation changes nothing, so fix the cause and run it again.** If the panel rejects the integration's sign-in, or says its access token has reduced +privileges, reauthenticate it with the panel passphrase first. If a repair about the panel's certificate is open, resolve that first — a new password is never +sent over a connection that cannot be verified. + +**If the panel does not report the outcome, assume the passphrase may have changed.** Run the action again: its response gives you a passphrase you know. It +works whether or not the integration is loaded, because the panel does not revoke the integration's access token. If the panel refuses that rotation, +reauthenticate the integration, using proof of proximity if the old passphrase is no longer accepted. + +**If the response says `reconnected: false`, save the passphrase, then rotate again.** The integration has stored the new password, but the broker was still +refusing it after about a minute. Run the action again, even though the integration is not loaded: the integration uses the newly returned password, and the +passphrase in that response is the one to keep. As a last resort, restart the panel. + +### Recommended deployment + +The panel credential is a single all-or-nothing secret, and anyone standing at the panel can mint a fresh one with three presses of the door switch. Network +topology and physical control of the panel are the real boundary; everything above is defense in depth behind it. + +- **Put the panel on a trusted VLAN**, with default-deny between VLANs, and allow only the Home Assistant host to reach it. Open `tcp/443` (REST), `tcp/8883` + (MQTTS) and `tcp/80` (the plaintext certificate-authority fetch, and REST too until the entry pins), and deny `tcp/9001` and `tcp/9002` unless you are + actively using the SPAN Home on-premise UI. +- **Use the IP address or an FQDN, and the `.local` name only** if you have an mDNS repeater like Avahi. The panel's IP is already in its certificate SAN, and + for an FQDN the integration registers it so the panel adds it. +- **Secure the panel enclosure**, because the three-press proximity bypass hands full credentials to anyone who can open the door — the equivalent of a printed + root password. This outranks every software control on this page. +- **Put dashboard-only household members in Home Assistant's read-only group.** Home Assistant's default user policy grants every non-admin user control of + every entity, which includes this integration's circuit switches and priority selects. +- **Encrypt your Home Assistant backups.** An unencrypted backup contains `.storage/core.config_entries`, and the panel credentials in it are in plaintext. +- **Enable multi-factor authentication on Home Assistant** and do not expose its API directly to the Internet. A compromised Home Assistant administrator + account is a compromised panel. Consider the use of Tailscale, a free and secure mesh network service, if you need to reach Home Assistant from your own + devices over the Internet. +- **A proxy may forward traffic, but must not answer for the panel.** One that passes the traffic through untouched as raw TCP works normally, as long as Home + Assistant still reaches the panel by a name the certificate covers: its IP address, its `.local` name, or a domain set through **Reconfigure**. Point that + name at the proxy in DNS rather than entering the proxy's own address, which is refused. + ## WebSocket API The integration provides a `span_panel/panel_topology` WebSocket command that returns the full physical layout of a panel in a single call — circuits with their -breaker slot positions, entity IDs grouped by role, and sub-devices (BESS, EVSE) with their entities. +breaker slot positions, entity IDs grouped by role, and sub-devices (BESS, MID, EVSE, PV) with their entities, and for each solar device the inverter it +describes and the circuit reading that is its power. See [WebSocket API Reference](websocket-api.md) for the full schema, response format, and usage examples. ## Troubleshooting -| Issue | Symptoms | Resolution | -| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Energy Dashboard spikes after firmware updates** | Huge energy-consumption spikes after panel firmware updates; charts showing untracked values that dwarf normal usage; negative energy values in statistics. Caused by the panel reporting decreased values on otherwise `TOTAL_INCREASING` sensors. | **Prevention:** enable [Energy Dip Compensation](#energy-dip-compensation) in General Options (on by default for new installs). **Fix existing spikes:** in **Developer Tools → Statistics**, search for the affected sensor (e.g. `sensor.span_panel_main_meter_consumed_energy`) and use **Adjust sum** to correct the errant entry. The integration also notifies when a decrease in the main meter consumed sensor is detected. | -| **High CPU usage** | Elevated CPU on low-power hardware (e.g. Raspberry Pi). The integration rebuilds a full panel snapshot from MQTT messages at a configurable interval (default 1 s). | Increase **Snapshot Update Interval** in **General Options**. 10–15 s is recommended for resource-constrained systems. Setting it to 0 disables debouncing and rebuilds on every MQTT message — not recommended. | -| **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In **Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. | -| **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. | -| **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. | +| Issue | Symptoms | Resolution | +| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Energy Dashboard spikes after firmware updates** | Huge energy-consumption spikes after panel firmware updates; charts showing untracked values that dwarf normal usage; negative energy values in statistics. Caused by the panel reporting decreased values on otherwise`TOTAL_INCREASING` sensors. | **Prevention:** enable [Energy Dip Compensation](#energy-dip-compensation) in General Options (on by default for new installs). **Fix existing spikes:** in **Developer Tools → Statistics**, search for the affected sensor (e.g. `sensor.span_panel_main_meter_consumed_energy`) and use **Adjust sum** to correct the errant entry. The integration also posts a persistent notification when a compensated dip on any energy sensor settles (see [Energy Dip Compensation](#energy-dip-compensation)). **If you enabled compensation before 2.1.0 and saw spikes at restart:** check `energy_offset` on your energy sensors in **Developer Tools → States** — an offset far larger than the sensor's own reading is compensation for a dip that never happened, and toggling Energy Dip Compensation off discards it. Offsets booked before 2.1.0 were never corroborated, so the integration cannot tell a good one from a bad one retrospectively; the spikes already in statistics still need **Adjust sum** either way. | +| **High CPU usage** | Elevated CPU on low-power hardware (e.g. Raspberry Pi). The integration rebuilds a full panel snapshot from MQTT messages at a configurable interval (default 1 s). | Increase**Snapshot Update Interval** in **General Options**. 10–15 s is recommended for resource-constrained systems. Setting it to 0 disables debouncing and rebuilds on every MQTT message — not recommended. | +| **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In**Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. | +| **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. | +| **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The panel does not let you control the circuit, so no switch is created. That is the case for circuits set in the SPAN App as "Always on Circuits"; for solar and EV charger circuits; and, from panel firmware r202639, for the Commissioned PV System and Commissioned Backup System circuits, whose switch and priority select from earlier firmware are removed. A circuit set to never back up keeps its switch but has no priority select. | +| **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for**30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. | +| **Setup fails after downgrading the integration** | After installing an older release, the SPAN Panel config entry fails to set up and Home Assistant reports an unsupported configuration version. | The release that stopped storing the panel passphrase migrated the config entry to version 7. Home Assistant refuses to load a config entry whose version is newer than the installed integration understands, and there is no automatic downgrade. Reinstall the newer release, or restore a backup taken before the upgrade. Removing and re-adding the integration also works and preserves entity IDs, but needs the panel passphrase or physical access to the door again. | +| **"SPAN Panel certificate authority changed" repair** | The integration has stopped connecting and a repair reports two fingerprints: the one it pinned and the one the panel now advertises. Entities are unavailable. | The panel is presenting a different certificate authority than the one you accepted at setup. Two things look identical from here and only you can tell them apart: a firmware upgrade or a factory reset rotates the authority legitimately, and so does a device on your network standing in for your panel.**If you know why it changed**, open the repair, compare the new fingerprint, and accept it — that re-pins and reconnects. **If nothing should have changed**, do not accept. Check what else is on the panel's network segment first. The integration will not reconnect on its own and will not re-pin on its own, deliberately: retrying would mean waiting to succeed against whatever is answering. | +| **A circuit re-commissioned in the SPAN App has the wrong controls** | A circuit whose configuration changed in the SPAN App — made controllable, locked, or set to never back up — seems to have the wrong controls. | Nothing needs doing. When the panel changes which controls a circuit allows, the integration reloads on its own and creates or removes that circuit's Breaker switch and priority select to match. A removed control returns with its entity ID, name and area when the panel offers it again. If a control still looks wrong, reload the integration (**Settings → Devices & Services → Span Panel → ⋮ → Reload**). | +| **The Adopted tab is missing from the dashboard** | The dashboard offers By Activity, By Area and Monitoring, but neither**Adopted** nor **By Panel**. | The panel dropdown at the top of the dashboard is on**Favorites**, which merges every configured panel into one workspace rather than being a panel itself, so the tabs that only mean something for a single panel are not offered there. Switch the dropdown to a panel and both come back; the selection is remembered per browser. Adopted is also administrator-only. | +| **A control reports `unconfirmed`** | The logbook or the`span_panel_control_command` event says a command was `unconfirmed`. Nothing appears broken. | **This is not an error.** It means the panel took the command and did not report a change within the deadline, and the most common reason by far is that there was no change to report — the relay was already open, the priority was already that value. It is also indistinguishable from a silent rejection by the panel, because SPAN's firmware sends no reason code; the integration reports what it observed rather than guessing. The two outcomes that do mean something went wrong are `failed`, which means the command was never sent, and `refused:…`, which means this integration refused it and names why. | ## Development diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index dba10167..42e6ee45 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -8,26 +8,65 @@ from homeassistant.components.frontend import async_remove_panel as async_remove_panel from homeassistant.components.panel_custom import async_register_panel as async_register_panel -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, ) -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType -from span_panel_api import SpanMqttClient, SpanPanelSnapshot +from homeassistant.util.hass_dict import HassKey +from span_panel_api import ( + LeafNameMismatch, + SpanMqttClient, + SpanPanelSnapshot, + ca_fingerprint, +) from span_panel_api.exceptions import ( + SpanPanelAPIError, SpanPanelAuthError, + SpanPanelCAChangedError, SpanPanelConnectionError, + SpanPanelError, + SpanPanelServerError, SpanPanelTimeoutError, + SpanPanelTLSVerificationError, + SpanPanelValidationError, ) from span_panel_api.mqtt.models import MqttClientConfig # Import config flow to ensure it's registered from . import config_flow # noqa: F401 +from .additions import ( + async_announce_new_entities, + async_forget_announcements, +) +from .adoption import async_register_adopted_devices +from .ca_repairs import ( + async_clear_ca_changed, + async_clear_ca_unusable, + async_clear_rest_tls_untrusted, + async_raise_ca_changed, + async_raise_ca_unusable, + async_raise_rest_tls_untrusted, +) +from .config_flow_validation import ( + LeafVerdict, + PanelCaUnusableError, + as_port, + async_ca_signs_panel_leaf, + async_fetch_panel_ca, + async_leaf_probe, + panel_rest_transport, +) from .const import ( CONF_API_VERSION, CONF_EBUS_BROKER_HOST, @@ -35,12 +74,18 @@ CONF_EBUS_BROKER_PORT, CONF_EBUS_BROKER_USERNAME, CONF_HTTP_PORT, + CONF_PANEL_CA_PEM, + DEFAULT_MQTTS_PORT, DEFAULT_SNAPSHOT_INTERVAL, DOMAIN, ENABLE_CURRENT_MONITORING, + PANEL_CA_PENDING, ) +from .control_gate import ControlGate, ControlLock, ControlPolicy from .coordinator import SpanPanelCoordinator +from .curation import async_forget_curation, async_load_curation from .current_monitor import CurrentMonitor +from .extension import async_notice_declined_extensions from .frontend import ( PANEL_FRONTEND_DIR as PANEL_FRONTEND_DIR, PANEL_URL as PANEL_URL, @@ -50,9 +95,28 @@ async_save_panel_settings as async_save_panel_settings, ) from .graph_horizon import GraphHorizonManager +from .leaf_repairs import async_clear_leaf_name_mismatch, async_raise_leaf_name_mismatch from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401 +from .notices import async_forget, async_restore from .options import SNAPSHOT_UPDATE_INTERVAL +from .pv_binding import async_forget_pv_binding, async_resolve_pv_binding + +# Re-exported: the types themselves live in a leaf module (see `runtime.py`), but +# `custom_components.span_panel.SpanPanelRuntimeData` is the name the tests and +# the rest of the integration have always used, so the package root keeps +# answering to it. +from .runtime import ( + SpanPanelConfigEntry as SpanPanelConfigEntry, + SpanPanelRuntimeData as SpanPanelRuntimeData, + loaded_runtime_data, +) +from .schema_repairs import ( + async_clear_retired_new_entity_notices, + async_clear_retired_upgrade_notice, + async_clear_schema_issues, +) from .services import ( # noqa: F401 + _async_register_credential_services, _async_register_favorites_services, _async_register_graph_horizon_services, _async_register_monitoring_services, @@ -62,6 +126,7 @@ _build_set_circuit_threshold_schema, _build_set_global_monitoring_schema, _build_set_mains_threshold_schema, + rotation_in_progress, ) from .util import snapshot_to_device_info from .websocket import async_register_commands @@ -69,18 +134,14 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -@dataclass -class SpanPanelRuntimeData: - """Runtime data for a Span Panel config entry.""" - - coordinator: SpanPanelCoordinator - - -type SpanPanelConfigEntry = ConfigEntry[SpanPanelRuntimeData] - PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, + # Added with the EVSE charge-current control -- the first number this + # integration has ever had, and forwarded unconditionally like every other + # platform: `number.async_setup_entry` creates nothing on a panel with no + # charger that declares a settable limit. + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, @@ -89,18 +150,230 @@ class SpanPanelRuntimeData: _LOGGER = logging.getLogger(__name__) +@dataclass(slots=True) +class _DeferredReload: + """A reload waiting for Home Assistant to start; see `_defer_reload_until_started`.""" + + cancel: CALLBACK_TYPE | None = None + """Unsubscribes the wait; None until `async_at_started` has answered.""" + + +_DEFERRED_RELOADS: HassKey[dict[str, _DeferredReload]] = HassKey(f"{DOMAIN}_deferred_reloads") +"""Per entry, the reload waiting for Home Assistant to start; see `update_listener`.""" + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Span Panel integration (domain-level, called once).""" _async_register_services(hass) _async_register_monitoring_services(hass) _async_register_graph_horizon_services(hass) _async_register_favorites_services(hass) + _async_register_credential_services(hass) await async_apply_panel_registration(hass) return True +async def _async_pinned_ca( + hass: HomeAssistant, entry: SpanPanelConfigEntry, host: str, http_port: int +) -> str | None: + """Return the CA to anchor this entry's broker connection on. + + An entry provisioned since CA pinning landed already carries one. An entry + that predates it carries `panel_ca_pending` instead, put there by the v7 + migration, and this is where that is settled — at setup, where the panel is + about to be reachable anyway, rather than in the migration, which runs during + startup and would delay boot for an unreachable panel. + + A failed fetch is not a setup failure. The flag survives, the next setup + retries for free, and in the meantime the library falls back to its 3.0.1 + behaviour of refetching the CA on each connect. Refusing to start would trade + a partial control for no integration at all. + + The store is logged at WARNING with the fingerprint on purpose. This is + trust-on-first-use on an upgrade path — nobody confirmed this certificate + against anything — and the user should be able to find the value afterwards + to compare it against another install or the panel's own label. + + Trust on first use is not the same as trust in anything that answers. The + fetch is plaintext and unauthenticated, so what comes back is checked + against the certificate the panel's broker serves before it is stored; a CA + that signs nothing the panel serves is not the panel's. A refusal is treated + exactly as a failed fetch, because it is the same situation from the entry's + point of view: no anchor was acquired, the flag stays, and the next setup + tries again. + """ + pinned = entry.data.get(CONF_PANEL_CA_PEM) + if pinned: + return str(pinned) + + if not entry.data.get(PANEL_CA_PENDING): + return None + + try: + ca_pem = await async_fetch_panel_ca(hass, host, http_port=http_port) + fingerprint = ca_fingerprint(ca_pem) + except ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + SpanPanelValidationError, + ) as err: + _LOGGER.warning( + "Could not acquire the CA for SPAN panel %s (%s). Continuing unpinned; " + "the fetch is retried on the next setup", + entry.title, + err, + ) + return None + + # The broker's port, not the panel's HTTPS port. The anchor has two uses, + # and this checks it against one of them: the MQTTS session the coordinator + # opens below, and -- once it is stored -- the REST transport that + # `panel_rest_transport` builds on the HTTPS port for reauth detection, + # reconfigure, FQDN registration and `rotate_credentials`. The broker's is + # the connection this pin was introduced for, and the only one every v2 + # entry carries a port for: setup refuses an entry without + # `CONF_EBUS_BROKER_PORT` a few lines above, and the same value is handed to + # `MqttClientConfig` below. + # + # `https_port` was the wrong port to verify on for the entries that actually + # reach here. It arrived in the same commit as `panel_ca_pending`, so an + # entry carrying the flag is by definition one migrated from before either + # existed and can never hold a port: every such check ran against 443, and a + # pre-pinning install whose TLS lives elsewhere -- behind NAT, a port + # forward, a proxy -- would have been refused on every setup and left on the + # unauthenticated refetch path for good, which is the one population this + # deferred pin exists for. + # + # Verifying one port and using the anchor on two has a cost, and it is + # stated rather than hidden: a CA that signs the broker's leaf but not + # whatever answers on 443 -- a proxy terminating only the HTTPS port -- + # pins here and then fails every REST path. Since the runtime schema fetch + # moved onto the pin (issue #264), that failure is no longer quiet: setup + # retries under `ConfigEntryNotReady` with the `panel_rest_tls_untrusted` + # Repair naming the port when something answers it badly, and with the + # retry message naming the port and Reconfigure when nothing answers it at + # all -- Reconfigure offers the HTTPS port to a pinned entry for exactly + # this population. Never plaintext either way. README and the changelog + # say so. + # + # One asymmetry to know about: `async_ca_signs_panel_leaf` accepts a leaf + # reached at the address `host` resolves to, while the bridge dials `host` + # itself. A certificate that names only the address would pin here and then + # fail hostname verification at connect. Pre-existing -- the config flow + # bootstraps the same way -- and not made worse by checking this port. + mqtts_port = as_port(entry.data.get(CONF_EBUS_BROKER_PORT), DEFAULT_MQTTS_PORT) + if not await async_ca_signs_panel_leaf(hass, host, mqtts_port, ca_pem): + _LOGGER.warning( + "The CA published by SPAN panel %s (SHA-256 %s) does not sign the " + "certificate served by its broker at %s:%s, so it is not this panel's CA " + "and has not been pinned. Continuing unpinned and retrying on the next " + "setup; check that nothing on the network is answering for the panel", + entry.title, + fingerprint, + host, + mqtts_port, + ) + return None + + data = dict(entry.data) + data[CONF_PANEL_CA_PEM] = ca_pem + data.pop(PANEL_CA_PENDING, None) + hass.config_entries.async_update_entry(entry, data=data) + _LOGGER.warning( + "Pinned the CA advertised by SPAN panel %s: SHA-256 %s. Nothing has confirmed " + "this certificate — compare it against another install or the panel itself if " + "you can, and see the integration's security documentation", + entry.title, + fingerprint, + ) + return ca_pem + + +async def _async_rest_tls_verdict( + hass: HomeAssistant, + entry: SpanPanelConfigEntry, + host: str, + http_port: int, + https_port: int, + pinned_pem: str, + err: SpanPanelTLSVerificationError, +) -> ConfigEntryError | ConfigEntryNotReady: + """Say what a REST certificate-verification failure was, and how setup ends. + + The same question the library asks when the MQTT handshake fails under a + pin, answered the same way, because the failure itself carries no evidence: + re-read the CA the panel advertises — plaintext deliberately, since a fetch + verified against the old pin would fail and tell us nothing — and compare + fingerprints. The fetch is diagnostic only and never re-anchors anything. + + A different fingerprint is the CA-changed Repair, with its guided re-pin — + a firmware reset rotating the CA is the common legitimate cause of this + failure, and it already has a flow. It is also the only terminal outcome, + which mirrors the library's own `_diagnose_verification_failure`: a + matching fingerprint never escalates, because `SSLCertVerificationError` + under an unchanged CA is three different conditions and two of them are + transient. The leaf probe splits them. A leaf that names somewhere else is + a moved panel — the leaf-mismatch Repair, which promises retrying, and + setup keeps that promise. A leaf the pin does not validate at all is either + something terminating TLS in front of the panel or a certificate a clock + reset pushed outside its validity window — one Repair naming both, retried + so the clock case heals itself and the proxy case stands visibly refused, + never downgraded to plaintext. And missing evidence — the CA unreadable, + the TLS port unreachable — retries with no verdict at all, because a panel + mid-reboot must not be convicted of anything. + """ + expected = ca_fingerprint(pinned_pem) + try: + advertised = await async_fetch_panel_ca(hass, host, http_port=http_port) + observed = ca_fingerprint(advertised) + except ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + SpanPanelValidationError, + ) as fetch_err: + _LOGGER.warning( + "TLS verification failed for SPAN panel %s's REST API and the panel's CA " + "could not be re-read to say why (%s). Treating this as transient and retrying", + entry.title, + fetch_err, + ) + return ConfigEntryNotReady(f"SPAN panel REST TLS failure is undiagnosed yet: {err}") + if observed != expected: + async_raise_ca_changed(hass, entry, expected, observed) + return ConfigEntryError( + f"SPAN panel {entry.title} is advertising CA {observed} where {expected} was pinned" + ) + probe = await async_leaf_probe(host, https_port, pinned_pem) + if probe.verdict is LeafVerdict.NAME_MISMATCH: + # The other half of the mutual supersede: `async_raise_rest_tls_untrusted` + # clears the leaf repair itself, but `leaf_repairs` cannot import + # `ca_repairs` back without a cycle, so this direction lives with the + # one caller that can produce both verdicts. A panel cannot be both + # merely moved and untrusted; only the current verdict may stand. + async_clear_rest_tls_untrusted(hass, entry) + async_raise_leaf_name_mismatch( + hass, entry, LeafNameMismatch(host=host, leaf_names=probe.leaf_names) + ) + return ConfigEntryNotReady( + f"SPAN panel {entry.title}'s certificate does not name {host}; " + "see the Repair, and retrying meanwhile" + ) + if probe.verdict is LeafVerdict.UNTRUSTED: + async_raise_rest_tls_untrusted(hass, entry, host, https_port, expected) + return ConfigEntryNotReady( + f"{host}:{https_port} answered with a certificate SPAN panel {entry.title}'s " + "pinned CA does not currently validate; see the Repair, and retrying meanwhile" + ) + return ConfigEntryNotReady( + f"SPAN panel {entry.title}'s REST TLS failure did not reproduce under diagnosis " + f"({probe.verdict}); retrying: {err}" + ) + + async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> bool: """Set up Span Panel from a config entry.""" _LOGGER.debug("Setting up entry %s (version %s)", entry.entry_id, entry.version) @@ -121,6 +394,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> "Please reauthenticate with your panel passphrase or proximity." ) + # Before the coordinator, because the coordinator can raise a notice as soon + # as it starts streaming -- a panel that upgraded its firmware while Home + # Assistant was down announces itself on the first snapshot. Restoring after + # that would overwrite the record with a view that never saw it. + await async_restore(hass, entry) + coordinator: SpanPanelCoordinator | None = None try: @@ -159,15 +438,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> host, ) + # `as_port` rather than `int()`: `entry.data` round-trips through + # JSON and is typed `Any` at this boundary, so a hand-edited + # `.storage` can put anything here, and `int("http")` would raise + # out of setup where the pin site a few lines above quietly falls + # back to the documented default. Same treatment, same answer. + panel_http_port = as_port(config.get(CONF_HTTP_PORT), 80) + + # Before the client is built, because the pin is a constructor + # argument: supplying it is what stops the bridge fetching a CA over + # plaintext HTTP on every connect and trusting whatever answers. + ca_pem = await _async_pinned_ca(hass, entry, host, panel_http_port) + + # From `entry.data` rather than `config`, because the deferred pin + # above may have just written the anchor there. Fail closed on a + # stored PEM that cannot be read: the fallback the other callers get + # would put this entry's schema fetch — the call that runs + # unattended on every boot — back on plaintext, which is the + # downgrade the pin exists to prevent (issue #264). The transport's + # own client is not used here; see the constructor comment below. + try: + transport = panel_rest_transport(hass, entry.data, allow_plaintext_fallback=False) + except PanelCaUnusableError as err: + async_raise_ca_unusable(hass, entry, str(err)) + raise ConfigEntryError( # noqa: TRY301 + f"The stored CA for SPAN panel {entry.title} cannot be read; " + "re-acquire it in Settings > Repairs" + ) from err + broker_config = MqttClientConfig( broker_host=host, username=config[CONF_EBUS_BROKER_USERNAME], password=config[CONF_EBUS_BROKER_PASSWORD], - mqtts_port=int(config[CONF_EBUS_BROKER_PORT]), + mqtts_port=as_port(config[CONF_EBUS_BROKER_PORT], DEFAULT_MQTTS_PORT), + ca_pem=ca_pem, ) - panel_http_port = int(config.get(CONF_HTTP_PORT, 80)) - snapshot_interval = entry.options.get( SNAPSHOT_UPDATE_INTERVAL, DEFAULT_SNAPSHOT_INTERVAL ) @@ -176,16 +482,169 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> serial_number, broker_config, snapshot_interval=snapshot_interval, + # Both ports, because the client runs two transports with + # opposite security properties. The plaintext port carries the + # bridge's CA fetches, which never follow the pin — they read + # the very anchor everything else is checked against. The + # schema fetch rides the pinned transport on the HTTPS port + # whenever the entry holds an anchor, which is every entry the + # config flow has created since pinning landed; passing the + # HTTPS port without a context is refused by the library, hence + # the pairing (issue #264). panel_http_port=panel_http_port, + panel_https_port=transport.port if transport.ssl_context is not None else None, + ssl_context=transport.ssl_context, + # Home Assistant's shared client, which it owns and closes at + # shutdown, and which serves only the unpinned plaintext path -- + # under a context the library builds a dedicated client per + # call, because httpx fixes its trust store at construction. + # `transport.httpx_client` is deliberately not used: it is the + # config flow's `verify_ssl=False` variant, and asking for that + # here would stand up a second connection pool for a flag that + # is inert on plaintext. The default client is the one every + # other integration already shares. + httpx_client=get_async_client(hass), ) + + # Before `connect()`, unlike every other subscription here, because + # the library runs the diagnosis this reports *inside* the first + # connect. Registered afterwards it would see nothing on the path it + # exists for: a mismatch makes `connect()` raise + # `SpanPanelConnectionError`, setup raises `ConfigEntryNotReady`, and + # the retry builds a new client -- so every attempt would fire the + # signal into a client with no subscriber and the user would keep + # getting the log line and nothing else. + @callback + def _on_leaf_name_mismatch(mismatch: LeafNameMismatch) -> None: + async_raise_leaf_name_mismatch(hass, entry, mismatch) + + entry.async_on_unload(client.register_leaf_mismatch_callback(_on_leaf_name_mismatch)) + try: await client.connect() + except SpanPanelCAChangedError as err: + # Terminal on purpose, and `ConfigEntryError` rather than + # `ConfigEntryNotReady` for the same reason the library refuses + # to retry: a client that keeps trying is a client waiting to + # succeed against whatever is answering, which is the outcome + # pinning exists to prevent. The Repair is the only way forward, + # and it requires a person. + await client.close() + async_raise_ca_changed( + hass, entry, err.expected_fingerprint, err.observed_fingerprint + ) + raise ConfigEntryError(str(err)) from err except SpanPanelAuthError as err: await client.close() + if rotation_in_progress(hass, entry.entry_id): + # Just after `rotate_credentials` the broker may not have + # accepted the new password yet; the service retries the reload. + raise ConfigEntryNotReady( + "MQTT broker has not accepted the rotated password yet" + ) from err raise ConfigEntryAuthFailed(f"MQTT authentication failed: {err}") from err - except (SpanPanelConnectionError, SpanPanelTimeoutError) as err: + except SpanPanelValidationError as err: + # The library refusing a stored combination it will not guess + # about — an HTTPS port of 80 under a pin is the one that + # reaches here, written by a config flow that used to accept + # it. A stored value does not fix itself, so no retry; the + # message names the remedy. + await client.close() + raise ConfigEntryError( # noqa: TRY301 + f"SPAN panel {entry.title}'s stored HTTPS port is not usable under " + f"its pinned CA ({err}); correct it with Reconfigure" + ) from err + except SpanPanelTLSVerificationError as err: + # Before its parent `SpanPanelConnectionError`, which would + # retry it with no diagnosis. Something answered the REST HTTPS + # port with a certificate the pin rejects; which Repair that + # raises — and whether it is terminal — is decided by the + # diagnosis below, the only party holding the evidence. await client.close() - raise ConfigEntryNotReady(f"Failed to connect to SPAN panel: {err}") from err + pinned_pem = transport.ca_pem + if pinned_pem is None: + # Not reachable: the library raises this only under an + # ssl_context, and the transport carries a PEM exactly when + # it carries a context. Stated as a guard rather than + # laundered through `str()`, so if the pairing is ever + # broken the failure names itself instead of fingerprinting + # the string "None". + raise + raise await _async_rest_tls_verdict( + hass, + entry, + host, + panel_http_port, + transport.port, + pinned_pem, + err, + ) from err + except ( + SpanPanelConnectionError, + SpanPanelTimeoutError, + # A panel part-way through a reboot answers rather than refusing: + # 5xx from the front end while the application behind it starts, + # or a 200 with nothing usable in it. That is not a broken + # install, it is a panel that is not up yet, and the two arrive + # together more often than they look -- one power event takes out + # the house's panel and the Home Assistant host that watches it, + # and they race each other back. Uncaught it produced a dead entry + # needing a human, for a condition that clears itself in minutes. + SpanPanelServerError, + ) as err: + await client.close() + message = f"SPAN panel is not ready yet: {err}" + if transport.ssl_context is not None: + # For the population the deferred pin serves — TLS behind + # NAT, a port forward, a proxy — nothing may answer the + # default HTTPS port at all, and that failure is a plain + # refused connection indistinguishable from a reboot. The + # retry message is the one channel that reaches them, so it + # carries the remedy instead of blaming the panel. + message += ( + f" (its REST API is expected on HTTPS port {transport.port}; if the " + "panel's TLS is served elsewhere, set the HTTPS port via Reconfigure)" + ) + raise ConfigEntryNotReady(message) from err + + # The connection got as far as a handshake under the current pin, so + # any standing Repair describes a state that no longer holds. That + # covers the name mismatch too: the handshake that just succeeded is + # the one whose failure the mismatch explains, and it is the same + # event the library re-arms its own signal on. + async_clear_ca_changed(hass, entry) + async_clear_leaf_name_mismatch(hass, entry) + # Same reconciliation: the REST half of the handshake succeeded + # under the current pin too — the schema fetch inside connect() is + # what these two describe failing. + async_clear_rest_tls_untrusted(hass, entry) + async_clear_ca_unusable(hass, entry) + + # The other half of the same condition: the reconnect loop runs + # fire-and-forget, so a CA that changes mid-session cannot surface as + # an exception on anybody's call stack. The library gives it a + # channel of its own precisely so a consumer can act on it. + @callback + def _on_fatal_transport_error(error: SpanPanelError) -> None: + if isinstance(error, SpanPanelCAChangedError): + async_raise_ca_changed( + hass, entry, error.expected_fingerprint, error.observed_fingerprint + ) + + entry.async_on_unload(client.register_fatal_error_callback(_on_fatal_transport_error)) + + # One interceptor, installed before the coordinator starts streaming + # so no control command can reach the panel ungated — including one + # issued during the first refresh. + control_policy = ControlPolicy.from_options(entry.options) + # Armed for as long as the feature is on, from the moment the + # interceptor has a lock to consult rather than from the moment the + # switch is added — the first refresh and the whole platform + # forward happen in between. `SpanPanelControlLockSwitch` reopens it + # if that is what the previous run left behind. + control_lock = ControlLock(armed=control_policy.lock_enabled) + client.set_control_interceptor(ControlGate(hass, entry, control_policy, control_lock)) + entry.async_on_unload(lambda: client.set_control_interceptor(None)) coordinator = SpanPanelCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() @@ -211,8 +670,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) - snapshot: SpanPanelSnapshot = coordinator.data serial_number = snapshot.serial_number @@ -236,9 +693,60 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> if entry.title == serial_number: hass.config_entries.async_update_entry(entry, title=smart_device_name) - await ensure_device_registered(hass, entry, snapshot, smart_device_name) + # Before the platforms: which inverter the Solar card's PV entities read, + # recorded once. Nothing is re-keyed. See `pv_binding`. + pv_binding = await async_resolve_pv_binding(hass, entry, snapshot) + + # Populated here rather than earlier because the panel's registry id is + # part of it, and that only exists once the device is registered. Nothing + # between the coordinator's first refresh and this point reads + # runtime_data, and platforms — which all do — are forwarded below. + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + control_policy=control_policy, + control_lock=control_lock, + panel_device_id=await ensure_device_registered( + hass, entry, snapshot, smart_device_name + ), + curation=await async_load_curation(hass, entry), + pv_binding=pv_binding, + setup_snapshot=snapshot, + ) + + # Before the forward, because a sub-device's `via_device_id` has to name a + # device that already exists -- and because an adopted device whose whole + # declaration is an `info` node has no entity to be created by. + async_register_adopted_devices( + hass, entry.entry_id, snapshot, panel_device_id=entry.runtime_data.panel_device_id + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # After the forward, because it reports on what the platforms just built + # -- and once, rather than from each platform's own call to `adoptable`. + await async_notice_declined_extensions( + hass, + entry, + snapshot, + dr.async_get(hass), + er.async_get(hass), + pv_binding=pv_binding, + ) + + # After the platforms, not before: schema validation runs on the first + # refresh, which is awaited above, but the Repairs it raises name the + # entities an unresolved field took down — and those entities only + # register themselves once their platform has added them. + coordinator.async_sync_schema_repairs() + + # Also after the platforms, for the other half of the same reason: a + # newly added entity is only in the registry once its platform has added + # it. Nobody watches their entity count, so an addition that breaks + # nothing reaches the user through this and nothing else -- whether or + # not it arrived switched on. + async_clear_retired_new_entity_notices(hass, entry) + async_clear_retired_upgrade_notice(hass, entry) + await async_announce_new_entities(hass, entry) except Exception: if coordinator is not None: await coordinator.async_shutdown() @@ -261,14 +769,46 @@ async def async_unload_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) - if not unload_ok: return False - if hasattr(entry, "runtime_data") and entry.runtime_data is not None: - if entry.runtime_data.coordinator.current_monitor is not None: - entry.runtime_data.coordinator.current_monitor.async_stop() - await entry.runtime_data.coordinator.async_shutdown() + runtime_data = loaded_runtime_data(entry) + if runtime_data is not None: + if runtime_data.coordinator.current_monitor is not None: + runtime_data.coordinator.current_monitor.async_stop() + await runtime_data.coordinator.async_shutdown() return True +async def async_remove_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> None: + """Clean up what this entry left outside its own runtime data. + + Core deletes neither issues nor their dismissals when a config entry is + removed, so a panel taken out of the system would otherwise leave its schema + notices behind forever. Scoped to this entry: another panel's issues share the + domain and must survive. + + The announcement record goes with them, and for a sharper reason: it outliving + the entry would mean re-adding the same panel announces none of the entities + it recreates, because every one of them is already recorded as announced. + + The curation overlay goes for the same reason. Its keys are wire addresses + rather than registry ids, so a store left behind is one the next entry for + the same panel would load and apply, re-asserting metadata the user is no + longer here to have asked for. + """ + async_clear_schema_issues(hass, entry) + # The CA family too, for the same reason — and the two persistent ones most + # of all, since nothing else can ever clear an issue whose entry is gone: + # the fixable flow aborts with `entry_gone`, and the rest have no flow. + async_clear_ca_changed(hass, entry) + async_clear_ca_unusable(hass, entry) + async_clear_rest_tls_untrusted(hass, entry) + async_clear_leaf_name_mismatch(hass, entry) + await async_forget_announcements(hass, entry) + await async_forget_curation(hass, entry) + await async_forget_pv_binding(hass, entry) + await async_forget(hass, entry) + + async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: SpanPanelConfigEntry, @@ -279,10 +819,11 @@ async def async_remove_config_entry_device( The main panel device cannot be removed — only sub-devices (like EVSE chargers) that are no longer present can be removed by the user. """ - if not hasattr(config_entry, "runtime_data") or config_entry.runtime_data is None: + runtime_data = loaded_runtime_data(config_entry) + if runtime_data is None: return True - coordinator = config_entry.runtime_data.coordinator + coordinator = runtime_data.coordinator snapshot = coordinator.data # Identify the main panel device identifier @@ -297,11 +838,66 @@ async def async_remove_config_entry_device( async def update_listener(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> None: - """Handle options updates.""" + """Reload the entry after an update: now, or once Home Assistant has started. + + While Home Assistant is starting the reload waits for it to finish rather + than being dropped. Dropped, it left the entry running on options it was + never set up with until the next restart -- a change to energy dip + compensation, for one, reached the sensors, which re-read it on every + update, but not the offsets only a reload restores or discards. + """ _LOGGER.debug("Configuration options changed for entry: %s", entry.entry_id) + if hass.state is not CoreState.running: + _defer_reload_until_started(hass, entry) + return + + await _async_reload_after_update(hass, entry) + + +@callback +def _defer_reload_until_started(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> None: + """Reload the entry once Home Assistant has started, once however many updates ask. + + One-shot: the pending reload is forgotten when it runs, and cancelled with + the entry if it unloads first. The record is stored before `async_at_started` + is asked, and only its own job or canceller removes it, because that helper + runs the job at once when Home Assistant has already started: a record + stored afterwards would outlive its job and block every later deferral. + """ + pending = hass.data.setdefault(_DEFERRED_RELOADS, {}) + if entry.entry_id in pending: + return + deferred = _DeferredReload() + pending[entry.entry_id] = deferred + + @callback + def _forget() -> bool: + """Remove this deferral's record if it is still the pending one.""" + if pending.get(entry.entry_id) is not deferred: + return False + del pending[entry.entry_id] + return True + + async def _reload_once_started(hass: HomeAssistant) -> None: + if _forget(): + await _async_reload_after_update(hass, entry) + + deferred.cancel = async_at_started(hass, _reload_once_started) + + @callback + def _cancel() -> None: + if _forget() and deferred.cancel is not None: + deferred.cancel() + + entry.async_on_unload(_cancel) + + +async def _async_reload_after_update(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> None: + """Reload the entry to apply an update, unless a credential rotation is applying it.""" try: - if hass.state is not CoreState.running: + # `rotate_credentials` stored the new password and reloads it itself. + if rotation_in_progress(hass, entry.entry_id): return await hass.config_entries.async_reload(entry.entry_id) @@ -318,22 +914,34 @@ async def ensure_device_registered( entry: SpanPanelConfigEntry, snapshot: SpanPanelSnapshot, device_name: str, -) -> None: +) -> str: """Register or reconcile the HA Device before creating sensors. Ensures the device exists in the device registry with proper naming and - identifiers. + identifiers, and returns its registry id — the value every sub-device links + to. Returned rather than looked up again by each platform because this is + the one place the device is known to exist: either it already did, or this + call is what created it. """ device_registry = dr.async_get(hass) serial_number = snapshot.serial_number host = entry.data.get(CONF_HOST) - existing_device = device_registry.async_get_device(identifiers={(DOMAIN, serial_number)}) + # Scoped to this config entry rather than searching every one. Identifiers + # are unique only within an entry, so the unscoped lookup is ambiguous by + # construction — it could answer with another integration's device that + # happens to share the identifier — which is why it is deprecated and stops + # working in 2027.8. + existing_device = device_registry.async_get_device_by_identifier( + (DOMAIN, serial_number), entry.entry_id + ) - if existing_device: + if existing_device is not None: if existing_device.name == serial_number: device_registry.async_update_device(existing_device.id, name=device_name) - else: - device_info = snapshot_to_device_info(snapshot, device_name, host=host) - device_registry.async_get_or_create(config_entry_id=entry.entry_id, **device_info) + return existing_device.id + + device_info = snapshot_to_device_info(snapshot, device_name, host=host) + created = device_registry.async_get_or_create(config_entry_id=entry.entry_id, **device_info) + return created.id diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py new file mode 100644 index 00000000..4957f7fc --- /dev/null +++ b/custom_components/span_panel/additions.py @@ -0,0 +1,411 @@ +"""Telling the user what this integration just added. + +Split out of `schema_repairs` deliberately. An addition is not a repair: nothing +is broken, nothing needs fixing, and filing it in the Repairs list puts it in a +category whose whole meaning is "something wants your attention because it went +wrong". It is a notification, and it says what was added rather than only how +many. + +Two things this fixes about the previous behaviour. + +**Enabled additions were announced to nobody.** The old notice covered only +entities added `disabled_by=INTEGRATION`, on the reasoning that an enabled entity +is already visible in the entity list and its history. That reasoning does not +survive contact with how anyone actually uses Home Assistant: nobody watches +their entity count, so an addition that breaks nothing is indistinguishable from +no addition at all. + +**The diff was derived from setup timing, so it was unrepeatable.** It compared +the registry before the platforms against the registry after, which answers +correctly exactly once: on the next startup the entity is already registered +beforehand and the diff is empty by construction. Anything not announced in that +one window was never announced. What is announced is now recorded, so the +question becomes "has this been announced" rather than "was this registered in +the last few seconds", and nothing is lost to a restart landing in the wrong +place. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Final, TypedDict + +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.storage import Store + +from .const import DOMAIN +from .extension import is_extension_unique_id +from .notices import async_raise, read_translations +from .util import ADOPTED_IDENTIFIER_TOKEN + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +_STORE_VERSION: Final = 1 +_ANNOUNCED: Final = "announced_unique_ids" + +COLLAPSE_ABOVE: Final = 5 +"""How many vendor readings one curated device may add before they are collapsed. + +Above this, the device gets one line with a count; at or below it, each entity is +named. The flood this guards against is a firmware update adding fifteen +properties at once, which would spend the whole notification on one device and +cost the reader the curated additions in the same message. Two or three is not +that, and naming them is what tells the reader anything at all -- the card is one +they already have, so its name alone says nothing new. + +Adopted *devices* are collapsed regardless of count, because their line names a +device that did not exist before, which is itself the news. +""" + +_SECTION: Final = "new_entities" +"""Names both the translation section and the notice id. They describe the same +thing, and keeping them one symbol means a rename cannot leave a standing notice +orphaned from the strings that render it.""" + +_FALLBACK: Final[dict[str, str]] = { + "title": "SPAN Panel added new entities", + "intro_one": "This update added 1 new entity to your SPAN Panel.", + "intro_many": "This update added {count} new entities to your SPAN Panel.", + "enabled_heading": "Added and ready to use", + "disabled_heading": "Added but switched off", + "how_to_enable": ( + "Switched-off entities record nothing until you turn them on. Open the SPAN Panel " + "device page, show its disabled entities, and enable the ones you want." + ), + "nothing_broken": "Nothing is broken and no action is required.", +} +"""English text, used when a translation file cannot be read. + +A fallback rather than the source of truth: `strings.json` and `translations/` +carry the same keys in five languages, and `_text` prefers those. Kept in code so +a notification is never *lost* to an unreadable file -- the addition still gets +announced, in English. +""" + + +class StoredAnnouncements(TypedDict): + """The unique_ids this entry has already announced. + + Typed rather than `dict[str, Any]` so the one shape this file writes is + stated once. It is a compile-time claim only -- see `_load` for what the disk + is actually allowed to hold. + """ + + announced_unique_ids: list[str] + + +def _store(hass: HomeAssistant, entry: ConfigEntry) -> Store[StoredAnnouncements]: + """Return the record of what has already been announced for this entry. + + Per entry rather than per domain: two panels add entities independently, and + a shared record would let one panel's announcement suppress the other's. + """ + return Store(hass, _STORE_VERSION, f"{DOMAIN}.announced.{entry.entry_id}") + + +def _load(stored: object, entry: ConfigEntry) -> frozenset[str] | None: + """Read the announced set off disk, or `None` when the file is not what we wrote. + + `notices._load` does this and has three tests; this file had neither, and the + consequence here is the worse of the two. This coroutine is awaited from + inside `async_setup_entry`, so a wrong-shaped file raised out of it, the + coordinator was shut down and the entry went to SETUP_ERROR -- which is not + retried. A panel would stay down until somebody found and deleted a file + about *notifications*. + + On-disk data violates the TypedDict freely: a hand edit, a partially restored + backup, a file written by a later version and read after a rollback. Home + Assistant already renames undecodable JSON and raises a core repair; the gap + is valid JSON of the wrong shape, which it hands back intact. + + `None` means "no usable record", which the caller treats exactly as a first + install: re-seed from what is registered now and announce nothing. That + silently swallows one pass of genuine additions, which is the same trade the + first install already makes and is worth strictly less than the entry. + + An `announced_unique_ids` holding something that is not a list of strings is + refused whole rather than filtered down to the strings in it. A partial read + would announce every id the bad row displaced as though it were new, which is + the flood the record exists to prevent -- and unlike a notice, an id here + carries no meaning of its own to salvage. + """ + if stored is None: + return None + announced = stored.get(_ANNOUNCED) if isinstance(stored, dict) else None + if not isinstance(announced, list) or not all( + isinstance(unique_id, str) for unique_id in announced + ): + _LOGGER.warning( + "Ignoring the announcement record for %s: expected an object with an " + "'%s' list of ids, found %s. Entities added by this setup are recorded " + "as known rather than announced; the next addition is announced normally.", + entry.entry_id, + _ANNOUNCED, + _describe(stored), + ) + return None + return frozenset(announced) + + +def _describe(stored: object) -> str: + """Say what was on disk where the announced list should have been. + + A separate function because the honest answer has three cases and only one of + them is a type name. Reporting `type(stored.get(...))` for all three says + "found NoneType" for a record that simply has no such key, which reads as a + null somebody wrote and sends whoever is looking at the wrong part of the + file. + """ + if not isinstance(stored, dict): + return type(stored).__name__ + if _ANNOUNCED not in stored: + return "missing" + announced = stored[_ANNOUNCED] + if isinstance(announced, list): + offending = next((item for item in announced if not isinstance(item, str)), None) + return f"a list holding {type(offending).__name__}" + return type(announced).__name__ + + +async def async_announce_new_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Tell the user which entities this setup added, and which need enabling. + + Silent on a first install. With nothing recorded and nothing registered, every + entity is new, so the notification would name the entire integration and teach + the user that this category is noise -- which would cost them the real + additions later. + + Silent, once, on the first run after this mechanism ships. An install that + predates the record has entities that were never announced but are not new + either, so the first pass adopts them as already-known rather than announcing + a release's worth of history. + + Silent, once, after a record that could not be read -- the same seeding path, + reached from `_load` returning `None`. Nothing about a notification is worth + failing setup for. + """ + store = _store(hass, entry) + announced = _load(await store.async_load(), entry) + registered = _registered(hass, entry) + + if announced is None: + await store.async_save(StoredAnnouncements(announced_unique_ids=sorted(registered))) + _LOGGER.debug( + "Seeded the announcement record for %s with %d entities; nothing announced", + entry.entry_id, + len(registered), + ) + return + + added = [ + registry_entry + for registry_entry in _entries(hass, entry) + if registry_entry.unique_id not in announced + ] + if not added: + return + + text = await hass.async_add_executor_job(read_translations, hass.config.language, _SECTION) + async_raise( + hass, + entry, + _SECTION, + title=_text(text, "title"), + message=_message(hass, added, text), + ) + await store.async_save(StoredAnnouncements(announced_unique_ids=sorted(announced | registered))) + _LOGGER.debug("Announced %d new entities for %s", len(added), entry.entry_id) + + +def _entries(hass: HomeAssistant, entry: ConfigEntry) -> list[er.RegistryEntry]: + return er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) + + +def _registered(hass: HomeAssistant, entry: ConfigEntry) -> frozenset[str]: + return frozenset(registry_entry.unique_id for registry_entry in _entries(hass, entry)) + + +def _text(text: dict[str, str], key: str, **placeholders: object) -> str: + """One string, translated where a file supplied it and English where not.""" + template = text.get(key) or _FALLBACK[key] + return template.format(**placeholders) if placeholders else template + + +def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str, str]) -> str: + """Build the notification body: what was added, split by whether it is switched on. + + The split is the actionable part. An enabled entity is already recording and + needs nothing; a disabled one records nothing at all until the user turns it + on, and saying so is the difference between a notice they can act on and one + they can only acknowledge. + + **Adopted devices belong on the switched-off side of that split**, because + every adopted entity registers disabled -- `AdoptedEntity` sets + `_attr_entity_registry_enabled_default = False` for all of them. Listing them + anywhere else said the opposite of the truth twice over: under the + ready-to-use heading they read as already recording, and being counted apart + from `disabled` meant a release whose only additions were adopted rendered no + heading and, worse, suppressed `how_to_enable` -- the one actionable string in + the message. A vendor device appearing is the case adoption exists for, and it + was the case that told the user least. + """ + devices = dr.async_get(hass) + enabled: list[str] = [] + disabled: list[str] = [] + collapsed: dict[str, int] = {} + extensions: dict[str, list[str]] = {} + + for registry_entry in added: + device_name = _adopted_device_name(devices, registry_entry) + if device_name is not None: + collapsed[device_name] = collapsed.get(device_name, 0) + 1 + continue + # Vendor extensions need their own detector: they live on *curated* + # devices, so the identifier test above cannot see them. Their unique_id + # is what says what they are. Held as labels rather than counted, + # because whether they collapse depends on how many there turn out to be. + extension_device = _extension_device_name(devices, registry_entry) + if extension_device is not None: + extensions.setdefault(extension_device, []).append(_label(devices, registry_entry)) + continue + target = ( + disabled + if registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + else enabled + ) + target.append(_label(devices, registry_entry)) + + # Collapse a vendor's additions only once there are enough of them to crowd + # the message out. Below the threshold the names are the useful part: an + # adopted *device*'s line at least names a new device the user can go find, + # while a curated card is one they already have, so "Span Panel (2 entities)" + # tells them strictly less than naming the two would. Extension entities are + # always disabled -- that is their arrival state -- so the uncollapsed ones + # join that list rather than being sorted again. + for device_name, labels in extensions.items(): + if len(labels) > COLLAPSE_ABOVE: + collapsed[device_name] = collapsed.get(device_name, 0) + len(labels) + else: + disabled.extend(labels) + + # Two keys rather than one with a plural placeholder: a plural rule the caller + # picks a *word* for is an English rule, and the five languages here do not + # share it. + intro = ( + _text(text, "intro_one") if len(added) == 1 else _text(text, "intro_many", count=len(added)) + ) + lines = [intro, ""] + if enabled: + lines += [f"**{_text(text, 'enabled_heading')}**", ""] + lines += [f"- {label}" for label in sorted(enabled)] + lines.append("") + if disabled or collapsed: + lines += [f"**{_text(text, 'disabled_heading')}**", ""] + lines += [f"- {label}" for label in sorted(disabled)] + lines += [f"- {name} ({count} entities)" for name, count in sorted(collapsed.items())] + lines += ["", _text(text, "how_to_enable"), ""] + lines.append(_text(text, "nothing_broken")) + return "\n".join(lines) + + +def _adopted_device_name( + devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry +) -> str | None: + """Return the adopted device this entity belongs to, or None when it belongs to none. + + An adopted device's entities are collapsed to one line with a count. A vendor + device declaring a dozen properties would otherwise spend the whole + notification on itself and teach the user to skip it -- which would cost them + the curated additions in the same message. + """ + if registry_entry.device_id is None: + return None + device = devices.async_get(registry_entry.device_id) + if device is None: + return None + token = f"_{ADOPTED_IDENTIFIER_TOKEN}_" + if not any(token in identifier for _domain, identifier in device.identifiers): + return None + return device.name_by_user or device.name or registry_entry.entity_id + + +def _extension_device_name( + devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry +) -> str | None: + """Return the curated device a vendor extension belongs to, or None for anything else. + + Detected by unique_id rather than by device identifier, because that is the + only thing that distinguishes these: an extension entity sits on a *curated* + card beside curated entities, so the card says nothing about it. + + Collapsed for the same reason adopted devices are, and the arithmetic is + worse here: a firmware update adding fifteen vendor properties to the battery + would spend the entire notification on them and teach the user to skip it, + costing them the curated additions in the same message. + """ + if registry_entry.device_id is None or not registry_entry.unique_id: + return None + if not is_extension_unique_id(registry_entry.unique_id): + return None + device = devices.async_get(registry_entry.device_id) + if device is None: + return None + return device.name_by_user or device.name or registry_entry.entity_id + + +def _label(devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry) -> str: + """Return what to call an entity the user has never seen. + + A disabled entity has no state, so there is no friendly name on the state + machine -- only what the registry recorded when the platform added it. The + entity_id is the last resort rather than the first choice, because it is the + name the user will *not* see in the device's entity list. + + **Sub-device entities carry their device's name.** Every platform here sets + `_attr_has_entity_name`, so the registry stores only the entity half and Home + Assistant prepends the device in its own UI. A flat list does not, and the + result was two chargers contributing "EVSE Charge Current Limit" twice with + nothing to tell them apart, and the battery's meter reading as a bare "Meter + Power". Two identical rows is what teaches somebody to skip the category. + + The panel's own entities are left bare. Prefixing them would put the panel + name on every line of a notification that already says which panel it is + about, which is noise rather than disambiguation -- so the prefix is added + only where the device is a sub-device, which is exactly where the collision + happens. + """ + name = registry_entry.name or registry_entry.original_name + if name is None: + return registry_entry.entity_id + device_name = _sub_device_name(devices, registry_entry) + return f"{device_name} {name}" if device_name else name + + +def _sub_device_name(devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry) -> str | None: + """Return the sub-device this entity sits on, or None for the panel itself. + + A sub-device is one that hangs off another with `via_device_id` -- the + battery, the MID, each charger, the solar inverter. The panel is the one + device with no parent, and its entities need no prefix. + """ + if registry_entry.device_id is None: + return None + device = devices.async_get(registry_entry.device_id) + # A child device (Home Assistant 2026.9+) hangs off nothing by + # `via_device_id`, and SPAN registers none, so it names no sub-device. + if not isinstance(device, dr.DeviceEntry) or device.via_device_id is None: + return None + return device.name_by_user or device.name + + +async def async_forget_announcements(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the announcement record when the entry is removed. + + Without this the record outlives the entry it describes, and re-adding the + same panel would suppress the announcement of every entity it recreates. + """ + await _store(hass, entry).async_remove() diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py new file mode 100644 index 00000000..8f12388c --- /dev/null +++ b/custom_components/span_panel/adoption.py @@ -0,0 +1,1086 @@ +"""Entities for devices the panel publishes and this integration models nothing for. + +The panel is a hub for whatever plugs into it, and the eBus schema is explicitly +vendor-extensible. A device type this integration has never modelled therefore +arrives as an expected event rather than a hypothetical one -- and until now it +arrived as nothing at all: no device, no entity, no sign it was there. + +**This module's unit is a device. A property on a device already modelled is +`extension.py`'s**, and the split is where the two costs differ. On a device type +nobody has modelled, no better identity is coming, so a disabled diagnostic +entity is strictly better than the silence -- and a settable property becomes a +real control, because there is no curated control beside it for a generic write +path to undercut. On a curated device, curation is plausibly coming and its +controls do real work, so `extension.py` mints a terminal identity in plain wire +vocabulary and surfaces even a settable property as a reading. + +**This module never decides that something enters long-term statistics.** It +spells no `state_class` anywhere -- an AST guard in `tests/test_adoption.py` +asserts the token is absent from the syntax, not merely from the paths a test +happens to construct. Three reasons, and the third is the one that shapes the +module: `state_class` is not declared on the wire and is not derivable from one +(`feedthroughEnergyProducedWh` is `TOTAL` beside `mainMeterEnergyProducedWh` as +`TOTAL_INCREASING` -- same unit, same device class); a wrong one writes corrupt +statistics that fixing the producer does not repair; and enrolling a property +nobody asked for into long-term statistics is a permanent write to every +install's recorder database. + +**The owner of the device may still assert one, and that is a different act.** +A user curating a row is not guessing about their own hardware, and their +assertion arrives here as a `CurationRecord` that `curation.sensor_description` +turns into a description -- which is how a curated row gets a state class +without this module naming the thing it must not infer. Nothing is asserted by +default: an uncurated row is exactly the entity it was before curation existed. + +**These entities declare no field paths.** `snapshot.adopted_devices` is outside +the curated field-path vocabulary by construction: it carries no metadata row, so +the producible gate has nothing to check it against and `residual_field_paths()` +must not collect it. That is why this module is absent from that walk's import +list, and why the classes below declare no `_residual_field_paths`. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from math import isfinite +from types import MappingProxyType +from typing import TYPE_CHECKING, Final + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.number import NumberEntity +from homeassistant.components.select import SelectEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import Platform +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from span_panel_api import AdoptedDevice, AdoptedProperty, SpanPanelSnapshot + +from .const import DOMAIN +from .curation import ( + CurationOverlay, + CurationRecord, + RowContext, + binary_sensor_device_class, + entity_category_for, + sensor_description, +) +from .entity import SpanPanelEntity +from .id_builder import get_user_friendly_suffix +from .util import ( + ADOPTED_IDENTIFIER_TOKEN, + BOOLEAN_DATATYPE, + ENUM_DATATYPE, + NUMERIC_DATATYPES, + declares_a_number, +) + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + from homeassistant.helpers.device_registry import DeviceRegistry + + from .coordinator import SpanPanelCoordinator + +_LOGGER = logging.getLogger(__name__) + +MAX_STATE_LENGTH: Final = 255 +"""Home Assistant's hard limit on a state string. + +The same number core spells `MAX_LENGTH_STATE_STATE`, and the same number its own +error message quotes back at you. + +Lives in this module rather than in `extension.py` because both halves of vendor +extensibility need it and this is the half the other imports from. It arrived in +extension only, which is exactly how adoption came to be missing it. +""" + + +def clamp_state(published: str, wire_path: str) -> str: + """Return a published string short enough for Home Assistant to store as a state. + + **Truncated rather than passed through, and truncated rather than refused.** + A vendor string is unbounded on the wire and core refuses one over + `MAX_STATE_LENGTH`: `hass.states.async_set` raises `InvalidStateError`, and + the entity platform's own setter logs an error and substitutes `unknown`. So + passing it through does not deliver a long reading, it delivers *no* reading + plus one error line per update, forever, for a value nobody chose to enable. + The first 255 characters are strictly more than that. + + Applied only where the value reaches the state machine as text. A declared + numeric is parsed to a float first and cannot be long, and this must not + touch it: clamping the digits of a number would silently change its value + rather than shorten a label. + + One function for both halves of vendor extensibility. `9567cb9` added this to + `extension.py` and left `adoption.py` -- the module with the *unbounded* + device vocabulary -- writing raw strings. + """ + if len(published) <= MAX_STATE_LENGTH: + return published + _LOGGER.debug( + "%s published %d characters; truncated to %d", + wire_path, + len(published), + MAX_STATE_LENGTH, + ) + return published[:MAX_STATE_LENGTH] + + +DEVICE_CLASS_BY_UNIT: Mapping[str, SensorDeviceClass] = MappingProxyType( + { + "W": SensorDeviceClass.POWER, + "kW": SensorDeviceClass.POWER, + "Wh": SensorDeviceClass.ENERGY, + "kWh": SensorDeviceClass.ENERGY, + "V": SensorDeviceClass.VOLTAGE, + "A": SensorDeviceClass.CURRENT, + "Hz": SensorDeviceClass.FREQUENCY, + "VA": SensorDeviceClass.APPARENT_POWER, + "var": SensorDeviceClass.REACTIVE_POWER, + "°C": SensorDeviceClass.TEMPERATURE, + "°F": SensorDeviceClass.TEMPERATURE, + "s": SensorDeviceClass.DURATION, + } +) +"""Units this integration is willing to claim a device class for, enumerated. + +Enumerated rather than inferred, and the omissions are the point. `%` is absent +because its uses in this vocabulary are not one class -- a state of charge, a +confidence, a duty cycle -- and guessing `BATTERY` for all of them mislabels the +rest. A unit outside this map yields **no** device class rather than a guess: an +unlabelled reading is honest, a mislabelled one is not. + +Note what is *not* here: a `state_class`. A device class is a display decision +and a wrong one is fixed by a line in the next release. A state class writes +long-term statistics, and a wrong one is not repaired by fixing it afterwards. +""" + + +def classify(declaration: AdoptedProperty) -> Platform: + """Return the platform a declared property surfaces on. + + Driven by the declaration, in the order the declaration constrains it: + + | Declaration | Platform | + | --- | --- | + | `boolean`, settable | `SWITCH` | + | `boolean` | `BINARY_SENSOR` | + | `enum`, settable, with a `format` | `SELECT` | + | numeric, settable, with a `format` **stating a range** | `NUMBER` | + | anything else | `SENSOR` | + + **A settable property with no usable value domain falls back to a reading, + and that is not caution.** A select with no option list and a number with no + bounds are not safer controls; they are broken ones. `format` is where Homie + carries the domain, so its absence is the absence of the thing a control + needs. + + A numeric `format` that *is* present and states no range this integration can + read -- `"0-100"`, `"auto"` -- falls the same way, and for the same reason + rather than for safety: the bounds are what make a number a number, so a + declaration that carries none leaves nothing to build a control out of + except invented bounds. Homie 5 permits no such spelling, so this needs a + non-compliant publisher; what makes it worth a branch is that the parse used + to raise, and it raised inside `async_setup_entry`, where one vendor device + took down every number on the panel including the curated EVSE limits. + + Disabled-by-default is what gates a control, not read-only. Enabling an + entity is a deliberate act and commanding it is a second one, the panel + authorises the write regardless of what is created here, and this + integration already ships switches that open and close breakers. + """ + settable_with_domain = declaration.settable and bool(declaration.format) + if declaration.datatype == BOOLEAN_DATATYPE: + return Platform.SWITCH if declaration.settable else Platform.BINARY_SENSOR + if declaration.datatype == ENUM_DATATYPE and settable_with_domain: + return Platform.SELECT + if declaration.datatype in NUMERIC_DATATYPES and settable_with_domain: + if parse_number_format(declaration.format) is not None: + return Platform.NUMBER + _LOGGER.debug( + "Adopted %s/%s declares format %r, which states no range this integration can " + "read; surfacing the property as a reading rather than as a control", + declaration.node_id, + declaration.property_id, + declaration.format, + ) + return Platform.SENSOR + + +CONTROL_PLATFORMS: Final = frozenset({Platform.SWITCH, Platform.SELECT, Platform.NUMBER}) +"""The platforms `classify` names that write back to the panel. + +Built, since 2026-08-20. The write goes through `set_adopted_property`, whose +authorisation is a snapshot lookup rather than its arguments: it resolves the +property against the current `adopted_devices` and publishes to the topic that +property carries. A device the adapter models produces no adopted record, so it +cannot be addressed that way however the arguments are spelled -- which is what +keeps this from becoming a generic write around `set_circuit_relay`, +`set_circuit_priority` and `set_evse_charge_limit`. + +That mattered: two of those do real work on the way out. The islanding assertion +translates its value, and the charge ceiling refuses one above what the charger +was commissioned for. +""" + + +def adopted_anchor(device: AdoptedDevice) -> str: + """Return the identity this device would be adopted under, before any freezing. + + The serial when the device publishes one, because the specification is + explicit that consumers correlate representations of a physical device by + `info/serial-number` and never by device id -- ids are opaque, and a proxied + id is `{proxier-id}-{proxied-id}`, so the same hardware carries different + ids under different enclosures by design. + + The wire id otherwise, as this panel's local handle. + + This is only the *candidate*. What an install actually uses is frozen at + first sighting; see `resolve_identifier`. + """ + return device.serial_number or device.device_id + + +def adopted_identifier(panel_serial: str, anchor: str) -> str: + """Return a registry identifier for one adopted device.""" + return f"{panel_serial}_{ADOPTED_IDENTIFIER_TOKEN}_{anchor}" + + +def resolve_identifier( + registry: DeviceRegistry, + panel_serial: str, + device: AdoptedDevice, + *, + config_entry_id: str, +) -> str: + """Return the identifier this install already uses for this device, or a new one. + + **An adopted device freezes its identity anchor at first sighting.** Both + candidate spellings are looked up before either is minted, because both + drift in practice and each covers the other's case: + + - a serial arriving *after* adoption would move the device from its wire id + onto the serial, and + - a producer that derives its wire id from a serial moves the id itself when + the serial appears -- which is why this repository holds PV's + `info/serial-number` unvalued, since publishing it moves the PV device + from `-pv-1` to `-`. + + Either move is a device *replacement* to the registry, taking the device's + entities and their history with it. Whatever was seen first is what is kept; + a better anchor arriving later is recorded on the card and changes nothing. + + The registry is the memory, so this needs no new persistence: a device that + exists was adopted before, and one that does not is being adopted now. + + That memory is read within `config_entry_id`, never across every entry. + Identifiers are unique inside a config entry and nowhere else, so the unscoped + question can be answered by a device another entry owns -- and this panel's + device would then be frozen onto a stranger's identifier, permanently, since + the freeze is by design irreversible. Home Assistant deprecated the unscoped + lookup for that ambiguity and stops answering it in 2027.8. + """ + for candidate in (device.device_id, device.serial_number): + if candidate is None: + continue + identifier = adopted_identifier(panel_serial, candidate) + if ( + registry.async_get_device_by_identifier((DOMAIN, identifier), config_entry_id) + is not None + ): + return identifier + return adopted_identifier(panel_serial, adopted_anchor(device)) + + +def adopted_unique_id(identifier: str, declaration: AdoptedProperty) -> str: + """Return the unique id for one adopted property, in this integration's grammar. + + `span_{serial}_adopted_{anchor}_{suffix}` -- the same + `span_{serial}_{scope}_{suffix}` shape every curated id has, built through the + same suffix helper. Uniform on purpose: a reader that parses an id by + position, as `extract_circuit_uuid_from_unique_id` does, must not meet a + second grammar. An earlier version lower-cased and de-hyphenated the whole + string, which mangled the panel serial itself -- `span_sp3_242424_001_...` + where every other id in the integration says `span_sp3-242424-001_...`. + + Only the node and property are de-hyphenated, because they are the segment + that has to end up snake_case like a curated suffix. The serial and the + anchor keep their hyphens, exactly as a curated id keeps the serial's. + + **Uniform grammar, and still not the id curation will mint.** Two of the three + segments change when a device type stops being unmodelled: the scope becomes + that type's sub-device kind rather than `adopted_{anchor}`, and the suffix + becomes a human-chosen description key rather than a wire address. Those are + the change itself, not a formatting difference, which is why promotion needs + to take over the existing id rather than expecting to reproduce it. + + **This grammar is not injective, deliberately and permanently.** Flattening + the hyphens is what makes the suffix read like a curated one, and it is also + what collapses `battery-2` + `cell-temperature` and `battery` + + `2-cell-temperature` onto one id. `extension_unique_id` carries the wire path + verbatim and is injective by construction, and that is the encoding a *new* + identity namespace should use -- but adopting it here would move every + adopted id an install already holds, which strands the entities keyed on them + with nothing to migrate them back. So the collision is handled where the + entities are built (`_create`) rather than removed here: the lexically first + wire path keeps the id and the other is skipped with both paths named. + Changing the encoding is a follow-on for whatever namespace comes next, and + it needs a maintainer's ruling rather than a fix. + """ + wire_path = f"{declaration.node_id}.{declaration.property_id}".replace("-", "_") + return f"span_{identifier.lower()}_{get_user_friendly_suffix(wire_path)}" + + +def adopted_curation_key(identifier: str, declaration: AdoptedProperty) -> str: + """Return the curation-store key for one adopted property. + + The frozen registry identifier plus the `{node}/{property}` path, hyphens + preserved -- injective where `adopted_unique_id` deliberately is not, + because nothing here flattens. Scope-prefixed because `path` alone is + unique only within one device. + """ + return f"{identifier}/{declaration.path}" + + +def adopted_device_info( + identifier: str, + device: AdoptedDevice, + *, + panel_device_id: str, +) -> DeviceInfo: + """Device card for an adopted device, from its `info` node. + + The same reading `bess_device_info` has done since v1.0, applied to a device + nobody modelled. `info` describes the thing rather than reporting a reading, + so it lands here and never as entities -- a panel publishing its own build + metadata should not arrive as a handful of string sensors. + + `name` falls back to the wire vocabulary because there is nothing better: an + adopted device has no translation key until somebody curates it, which is + the main thing curating it is for. + """ + return DeviceInfo( + identifiers={(DOMAIN, identifier)}, + name=adopted_device_label(device), + manufacturer=device.vendor_name or "Unknown", + model=device.model or humanised(device.device_type.rsplit(".", 1)[-1]), + serial_number=device.serial_number, + sw_version=device.software_version, + hw_version=device.hardware_version, + via_device_id=panel_device_id, + ) + + +def adopted_device_label(device: AdoptedDevice) -> str: + """Return the name this device is known by, on its card and in its errors. + + One function because the two have to agree: an error naming a device the + user cannot find in their device list is an error they cannot act on. + """ + return device.name or humanised(device.device_type.rsplit(".", 1)[-1]) + + +def humanised(wire_token: str) -> str: + """`backup-generator` -> `Backup Generator`, for a name with no translation. + + Deliberately plain. An adopted entity renders from wire vocabulary until it + is promoted, and dressing that up would disguise which entities are curated + and which are waiting to be. + """ + return wire_token.replace("-", " ").replace("_", " ").title() + + +def async_register_adopted_devices( + hass: HomeAssistant, + entry_id: str, + snapshot: SpanPanelSnapshot, + *, + panel_device_id: str, +) -> None: + """Create a device-registry entry for every adopted device, before any entity. + + Registered explicitly rather than left to fall out of entity creation, and the + reason is a device that has no entities to fall out of. A vendor device that + publishes only an `info` node -- advertising what it is before it publishes + any reading -- resolves entirely to the device card by the node rule, so it + creates no entity, so nothing ever calls `async_get_or_create` for it. It used + to produce *nothing at all*: no device, no entity, and no notice. Which is the + silence adoption exists to end, reached by a different route. + + It also fixes the identity freeze in one place. `resolve_identifier` reads the + registry to decide which spelling this install already uses, so running it + here -- once, before the platforms -- means every entity created afterwards + resolves against a device that already exists and cannot disagree. + + Called before the platforms are forwarded, for the same reason the panel's own + registration is: a sub-device's `via_device_id` has to name a device that + exists. + """ + registry = dr.async_get(hass) + for device in snapshot.adopted_devices: + identifier = resolve_identifier( + registry, snapshot.serial_number, device, config_entry_id=entry_id + ) + registry.async_get_or_create( + config_entry_id=entry_id, + **adopted_device_info(identifier, device, panel_device_id=panel_device_id), + ) + _LOGGER.debug("Registered adopted device %s as %s", device.device_type, identifier) + + +class AdoptedEntity(SpanPanelEntity): + """Base for an entity built from a declaration rather than from a description. + + Disabled without exception, and diagnostic unless the owner of the device + said otherwise. Adoption's job is to make a device reachable, not to put it + on somebody's dashboard: the user decides what is worth enabling, having + seen the device exists. A curated row is that decision already made, so + `entity_category_for` lets it out of diagnostics -- and leaves it disabled + all the same, because enabling is still their act. + """ + + _attr_entity_registry_enabled_default = False + + def __init__( + self, + coordinator: SpanPanelCoordinator, + identifier: str, + device: AdoptedDevice, + declaration: AdoptedProperty, + *, + panel_device_id: str, + record: CurationRecord | None = None, + ) -> None: + """Bind this entity to one property of one adopted device.""" + super().__init__(coordinator) + self._device_wire_id = device.device_id + self._declaration_path = declaration.path + self._attr_unique_id = adopted_unique_id(identifier, declaration) + self._attr_name = humanised(declaration.property_id) + self._attr_device_info = adopted_device_info( + identifier, device, panel_device_id=panel_device_id + ) + self._attr_entity_category = entity_category_for(record) + + def _published(self) -> str | None: + """Return this property's current value, or None when the panel publishes none. + + Read back out of the snapshot each time rather than captured at + construction: the device is matched by its wire id, so a device that + leaves the tree and returns keeps reporting through the same entity. + """ + snapshot: SpanPanelSnapshot = self.coordinator.data + for device in snapshot.adopted_devices: + if device.device_id != self._device_wire_id: + continue + for declaration in device.properties: + if declaration.path == self._declaration_path: + return declaration.value + return None + + +class AdoptedSensor(AdoptedEntity, SensorEntity): + """A reading from an adopted device, described by its declaration and its record.""" + + def __init__( + self, + coordinator: SpanPanelCoordinator, + identifier: str, + device: AdoptedDevice, + declaration: AdoptedProperty, + *, + panel_device_id: str, + record: CurationRecord | None = None, + ) -> None: + """Take the unit and device class from what the panel declared. + + The description is built in `curation` rather than here, which is what + lets a curated row carry the one piece of metadata this module may not + name and still leaves an uncurated row with exactly what it had. + """ + super().__init__( + coordinator, + identifier, + device, + declaration, + panel_device_id=panel_device_id, + record=record, + ) + self.entity_description = sensor_description( + declaration.path, + declaration.unit, + DEVICE_CLASS_BY_UNIT.get(declaration.unit or ""), + record, + ) + self._numeric = declares_a_number(declaration.datatype, declaration.unit) + + @property + def native_value(self) -> str | float | None: + """Return the published value, parsed to a number where the declaration says it is one. + + The declared `$datatype` is what says so, and a declared unit is taken as + saying so too. The unit alone used to decide it, as a proxy: a property + carrying `W` is a number whatever else it says. But a bare count declares + no unit and is numeric all the same, and the proxy read one as text -- + harmless while an uncurated reading asserted nothing about itself, and + not harmless once the owner of the device could put a `measurement` on + exactly that row and have the recorder handed a string under it. + + The union rather than the datatype alone, because a publisher that omits + a `$datatype` still declares a unit, and nothing that parses today may + stop parsing. + + A declared numeric that arrives unparseable is reported as `None` rather + than as its raw text: putting a string behind a unit and a device class + would be a worse lie than reporting nothing. + + Anything else is text, and text off a vendor device is unbounded, so it + goes through `clamp_state` -- see there for why truncating beats letting + core refuse it. + """ + raw = self._published() + if raw is None: + return None + if not self._numeric: + return clamp_state(raw, f"Adopted {self._declaration_path}") + try: + return float(raw) + except ValueError: + _LOGGER.debug( + "Adopted %s published %r, which is not a number", self._declaration_path, raw + ) + return None + + +class AdoptedBinarySensor(AdoptedEntity, BinarySensorEntity): + """A declared `boolean` from an adopted device that the panel does not accept writes to.""" + + def __init__( + self, + coordinator: SpanPanelCoordinator, + identifier: str, + device: AdoptedDevice, + declaration: AdoptedProperty, + *, + panel_device_id: str, + record: CurationRecord | None = None, + ) -> None: + """Take the device class from the record, because there is nothing to default from. + + A sensor's device class can be read off the declared unit; a boolean + declares no unit, so `door` and `problem` and `running` are + indistinguishable on the wire. An uncurated binary sensor therefore has + no device class at all, and the user's assertion is the only one there + can be. + """ + super().__init__( + coordinator, + identifier, + device, + declaration, + panel_device_id=panel_device_id, + record=record, + ) + self._attr_device_class = binary_sensor_device_class(record) + + @property + def is_on(self) -> bool | None: + """Homie spells a boolean `true`/`false`; anything else is not an answer.""" + return homie_boolean(self._published()) + + +class AdoptedControl(AdoptedEntity): + """Base for an adopted entity that writes back to the panel. + + The write is refused by the library unless the property is still there and + still settable, so nothing here re-checks it: a control for a device that has + left the tree raises rather than publishing into a topic nothing subscribes + to, and that is the correct outcome to surface. + """ + + def __init__( + self, + coordinator: SpanPanelCoordinator, + identifier: str, + device: AdoptedDevice, + declaration: AdoptedProperty, + *, + panel_device_id: str, + record: CurationRecord | None = None, + ) -> None: + """Remember the wire address this control publishes to. + + The record reaches a control carrying prominence and nothing else -- + `sanitise` refuses a state class or a device class on a row that is not + a sensor -- so it is passed straight through rather than filtered again + here. + """ + super().__init__( + coordinator, + identifier, + device, + declaration, + panel_device_id=panel_device_id, + record=record, + ) + self._node_id = declaration.node_id + self._property_id = declaration.property_id + self._device_label = adopted_device_label(device) + + async def _publish(self, value: str) -> None: + """Write one value and refresh, reporting both ways it can fail to happen. + + Through the same `_async_control` every curated control uses, and for the + same reason: a refusal (the device left the tree, the property stopped + being settable) never published anything, and a `FAILED` outcome was + never handed to the broker and will not arrive later. Either one leaves + the panel as it was, so returning normally would tell the person who + operated this control that their device changed when it did not. + + The messages name the property and the device rather than the circuit or + the charger the curated keys name, because that is the whole identity an + adopted device has: this integration models nothing about it beyond its + declaration, and the wire vocabulary is what the user will find in the + panel's own tree. + + No `hasattr` guard, unlike the curated controls. Those ask because a + transport may not implement an optional protocol at all; this entity only + exists because a v1.0 tree reported an adopted device, and that is the + same transport that carries the write. + """ + await self._async_control( + self.coordinator.client.set_adopted_property( + self._device_wire_id, self._node_id, self._property_id, value + ), + command=f"a write to {self._node_id}/{self._property_id} on {self._device_wire_id}", + failed_key="adopted_control_failed", + not_delivered_key="adopted_control_not_delivered", + placeholders={ + "property": f"{self._node_id}/{self._property_id}", + "device": self._device_label, + }, + ) + await self.coordinator.async_request_refresh() + + +class AdoptedSwitch(AdoptedControl, SwitchEntity): + """A declared `boolean` the panel accepts writes to.""" + + @property + def is_on(self) -> bool | None: + """Homie spells a boolean `true`/`false`; anything else is not an answer.""" + return homie_boolean(self._published()) + + async def async_turn_on(self, **kwargs: object) -> None: + """Publish the vocabulary Homie defines for a boolean, not HA's.""" + await self._publish("true") + + async def async_turn_off(self, **kwargs: object) -> None: + """Publish the vocabulary Homie defines for a boolean, not HA's.""" + await self._publish("false") + + +class AdoptedSelect(AdoptedControl, SelectEntity): + """A declared `enum` the panel accepts writes to, with its declared options.""" + + def __init__( + self, + coordinator: SpanPanelCoordinator, + identifier: str, + device: AdoptedDevice, + declaration: AdoptedProperty, + *, + panel_device_id: str, + record: CurationRecord | None = None, + ) -> None: + """Take the option list from the declaration, which is the whole domain.""" + super().__init__( + coordinator, + identifier, + device, + declaration, + panel_device_id=panel_device_id, + record=record, + ) + self._attr_options = parse_enum_format(declaration.format) + + @property + def current_option(self) -> str | None: + """The published value, but only when it is one of the declared options. + + A value outside the declared set is reported as unknown rather than as a + selection. Home Assistant rejects a `current_option` outside `options`, + and quietly widening the list to admit whatever arrived would hide a + panel disagreeing with its own declaration. + """ + published = self._published() + return published if published in self._attr_options else None + + async def async_select_option(self, option: str) -> None: + """Publish the option verbatim -- it came from the panel's own list.""" + await self._publish(option) + + +class AdoptedNumber(AdoptedControl, NumberEntity): + """A declared numeric the panel accepts writes to, with its declared bounds.""" + + def __init__( + self, + coordinator: SpanPanelCoordinator, + identifier: str, + device: AdoptedDevice, + declaration: AdoptedProperty, + *, + panel_device_id: str, + record: CurationRecord | None = None, + ) -> None: + """Take the bounds from the declaration, which is what makes this a number. + + A declaration `parse_number_format` reads no range from never reaches + here -- `classify` routes it to a sensor -- so this raises rather than + substituting anything. There is no defensible substitute: bounds + invented here would offer the user a 0-100 control the panel never + declared, and a caller that got past `classify` has a bug worth hearing + about. `number.async_setup_entry` bounds what hearing about it can cost + by adding the curated controls first. + """ + bounds = parse_number_format(declaration.format) + if bounds is None: + raise ValueError( + f"{declaration.node_id}/{declaration.property_id} declares format " + f"{declaration.format!r}, which states no range; a numeric property " + "whose format cannot be read is classified as a sensor" + ) + super().__init__( + coordinator, + identifier, + device, + declaration, + panel_device_id=panel_device_id, + record=record, + ) + self._attr_native_min_value, self._attr_native_max_value, self._attr_native_step = bounds + self._attr_native_unit_of_measurement = declaration.unit + self._integral = declaration.datatype == "integer" + + @property + def native_value(self) -> float | None: + """The published value as a number, or None when it is not one.""" + published = self._published() + if published is None: + return None + try: + return float(published) + except ValueError: + _LOGGER.debug( + "Adopted %s published %r, which is not a number", self._declaration_path, published + ) + return None + + async def async_set_native_value(self, value: float) -> None: + """Publish the value in the datatype the property declares. + + An `integer` property gets an integer literal. Publishing `5.0` where the + declaration says `integer` is a payload outside the declared datatype, + and this library has no business sending one. + """ + await self._publish(str(int(value)) if self._integral else str(value)) + + +def homie_boolean(published: str | None) -> bool | None: + """Return Homie's `true`/`false`, with anything else meaning no answer.""" + if published is None: + return None + lowered = published.strip().lower() + if lowered in ("true", "false"): + return lowered == "true" + return None + + +def parse_enum_format(declared: str | None) -> list[str]: + """Return the options a Homie `enum` `$format` lists. + + Comma-separated, per Homie 5. An empty result means the declaration carried + no usable domain, which `classify` has already used to route the property to + a sensor -- so this never returns empty for a property that reached a select. + """ + if not declared: + return [] + return [option.strip() for option in declared.split(",") if option.strip()] + + +def parse_number_format(declared: str | None) -> tuple[float, float, float] | None: + """Return the `min:max:step` a Homie numeric `$format` states, or None for no range. + + Step defaults to 1 when the declaration gives only a range, and either bound + may be left empty, both of which Homie permits. + + **None is the answer for anything else, and this function never raises.** + It is the one place that decides whether a declared numeric domain is + readable, which is why `classify` asks it rather than testing the string + itself -- the platform decision and the bounds a `NUMBER` is built from then + cannot disagree. It used to raise `ValueError` straight out of `float()`, + which reached `number.async_setup_entry` and failed the whole platform, so a + single non-compliant publisher on a device nobody modelled cost the user + every number on the panel, curated EVSE charge limits included. + + Non-finite bounds are refused alongside the unparseable ones: `float()` + accepts `"nan"` and `"inf"`, and neither is a bound a value can be clamped + to or a figure the frontend can be handed. + + So are a reversed range and a step of zero or less, for the same reason and + not for a different one: they parse, and they are still not a domain. A + `NumberEntity` with `min > max` admits no value at all, and a step of `0` + divides by zero in the frontend's own slider arithmetic while a negative one + counts the wrong way. The rule is `minimum <= maximum and step > 0`; + `minimum == maximum` is left alone, because a single-valued domain is a + degenerate control rather than a broken one and refusing it would move a + property nobody has complained about. + """ + if not declared: + return None + parts = declared.split(":") + try: + minimum = float(parts[0]) if parts[0] else 0.0 + maximum = float(parts[1]) if len(parts) > 1 and parts[1] else 100.0 + step = float(parts[2]) if len(parts) > 2 and parts[2] else 1.0 + except ValueError: + return None + bounds = (minimum, maximum, step) + if not all(isfinite(bound) for bound in bounds): + return None + return bounds if minimum <= maximum and step > 0 else None + + +def adopted_control_count(snapshot: SpanPanelSnapshot) -> int: + """How many adopted properties this panel exposes as controls rather than readings. + + Reported in diagnostics beside the device list. A control on a device nobody + modelled is the highest-consequence thing adoption creates, so the count is + worth having in the one artefact that reaches a maintainer. + """ + return sum( + 1 + for device in snapshot.adopted_devices + for declaration in device.properties + if classify(declaration) in CONTROL_PLATFORMS + ) + + +def create_adopted_sensors( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + registry: DeviceRegistry, + *, + config_entry_id: str, + panel_device_id: str, + overlay: CurationOverlay, +) -> list[AdoptedSensor]: + """Every adopted property that is not a declared boolean. + + Everything `classify` routes to `SENSOR`: every property that is not a + declared boolean and not a settable one with a usable value domain. + """ + return _create( + AdoptedSensor, + coordinator, + snapshot, + registry, + Platform.SENSOR, + config_entry_id=config_entry_id, + panel_device_id=panel_device_id, + overlay=overlay, + ) + + +def create_adopted_binary_sensors( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + registry: DeviceRegistry, + *, + config_entry_id: str, + panel_device_id: str, + overlay: CurationOverlay, +) -> list[AdoptedBinarySensor]: + """Every adopted property declared `boolean` that the panel accepts no write to.""" + return _create( + AdoptedBinarySensor, + coordinator, + snapshot, + registry, + Platform.BINARY_SENSOR, + config_entry_id=config_entry_id, + panel_device_id=panel_device_id, + overlay=overlay, + ) + + +def create_adopted_switches( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + registry: DeviceRegistry, + *, + config_entry_id: str, + panel_device_id: str, + overlay: CurationOverlay, +) -> list[AdoptedSwitch]: + """Every adopted property declared `boolean` and settable.""" + return _create( + AdoptedSwitch, + coordinator, + snapshot, + registry, + Platform.SWITCH, + config_entry_id=config_entry_id, + panel_device_id=panel_device_id, + overlay=overlay, + ) + + +def create_adopted_selects( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + registry: DeviceRegistry, + *, + config_entry_id: str, + panel_device_id: str, + overlay: CurationOverlay, +) -> list[AdoptedSelect]: + """Every adopted `enum` that is settable and declares its option list.""" + return _create( + AdoptedSelect, + coordinator, + snapshot, + registry, + Platform.SELECT, + config_entry_id=config_entry_id, + panel_device_id=panel_device_id, + overlay=overlay, + ) + + +def create_adopted_numbers( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + registry: DeviceRegistry, + *, + config_entry_id: str, + panel_device_id: str, + overlay: CurationOverlay, +) -> list[AdoptedNumber]: + """Every adopted numeric that is settable and declares its bounds.""" + return _create( + AdoptedNumber, + coordinator, + snapshot, + registry, + Platform.NUMBER, + config_entry_id=config_entry_id, + panel_device_id=panel_device_id, + overlay=overlay, + ) + + +def _create[AdoptedT: AdoptedEntity]( + entity_class: type[AdoptedT], + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + registry: DeviceRegistry, + platform: Platform, + *, + config_entry_id: str, + panel_device_id: str, + overlay: CurationOverlay, +) -> list[AdoptedT]: + """Build one platform's share of the adopted properties, one entity per id. + + One partition function rather than five bodies, so `classify` stays the only + place a property's platform is decided. Five bodies would each restate the + predicate, and a property could then reach two platforms or none. + + **One id is claimed by one property, and which one does not depend on the + wire.** `adopted_unique_id` is not injective -- see its docstring for why that + cannot be fixed by changing the encoding -- so two wire addresses can arrive + at one id. Handing both to Home Assistant registers the first and drops the + second *permanently*, with one core log line naming a `unique_id` the user + cannot map back to anything on the wire. Skipping it here costs the same + entity and names both addresses, so the case is reportable by whoever meets + it. + + Claimed per platform, which is the scope the registry keys on: an entity is + unique by (domain, integration, unique_id), so the same id under `sensor` and + under `binary_sensor` is not a collision and must not be treated as one. + + **The winner is the lexically first wire path, not the first one published.** + Adapter emission order tracks the wire, so a firmware update that declared + the properties in the other order would have moved the id from one property + to the other -- which is a *standing entity changing what it reads* with no + id change to make it visible, and nothing here to migrate it back. Sorting on + `path` makes the survivor a function of the declarations alone. Registry + preference, which is how the extension cap resolves its equivalent problem, + is not available: both properties resolve to the same `unique_id`, so the + registry row cannot say which of them put it there. + + The sort covers a whole device rather than only the colliding pair, because a + rule that only applied on collision would still depend on arrival order to + decide which pair collided first. + + **The curated record is read through `for_row`, never off the overlay.** The + store keeps whatever the user asserted when they asserted it, and the + declaration it was asserted against can have moved since. `for_row` measures + the record against the declaration in hand and drops what no longer fits; + the raw record would reach `SensorDeviceClass(...)` unchecked and raise + inside `async_setup_entry`, which is the whole platform for one stale row. + """ + built: list[AdoptedT] = [] + claimed: dict[str, str] = {} + for device, identifier in _adopted(snapshot, registry, config_entry_id): + for declaration in sorted(device.properties, key=lambda row: row.path): + if classify(declaration) is not platform: + continue + unique_id = adopted_unique_id(identifier, declaration) + first_claim = claimed.get(unique_id) + if first_claim is not None: + _LOGGER.warning( + "Adopted %s on %s resolves to the same entity id as %s, which sorts " + "first; %s is not surfaced. Both wire addresses flatten to %s. Attach " + "this integration's diagnostics to an issue if you need the second one", + declaration.path, + device.device_id, + first_claim, + declaration.path, + unique_id, + ) + continue + claimed[unique_id] = declaration.path + record = overlay.for_row( + adopted_curation_key(identifier, declaration), + RowContext(platform=platform, datatype=declaration.datatype, unit=declaration.unit), + ) + built.append( + entity_class( + coordinator, + identifier, + device, + declaration, + panel_device_id=panel_device_id, + record=record, + ) + ) + return built + + +def _adopted( + snapshot: SpanPanelSnapshot, registry: DeviceRegistry, config_entry_id: str +) -> list[tuple[AdoptedDevice, str]]: + """Each adopted device paired with the identifier this install uses for it.""" + return [ + ( + device, + resolve_identifier( + registry, snapshot.serial_number, device, config_entry_id=config_entry_id + ), + ) + for device in snapshot.adopted_devices + ] diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 4c899e00..e8ebc72f 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -2,9 +2,11 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass import logging +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, ClassVar from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -12,15 +14,17 @@ BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from span_panel_api import SpanEvseSnapshot, SpanPanelSnapshot +from span_panel_api import SpanEvseSnapshot, SpanPanelSnapshot, SpanPVSnapshot -from . import SpanPanelConfigEntry +from .adoption import create_adopted_binary_sensors from .const import ( CONF_DEVICE_NAME, PANEL_STATUS, + PV_PANEL_LINK_KEY, SYSTEM_DOOR_STATE, SYSTEM_DOOR_STATE_CLOSED, SYSTEM_DOOR_STATE_OPEN, @@ -30,13 +34,30 @@ ) from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity +from .extension import create_extension_binary_sensors +from .field_paths import DerivedReason, FieldPathDeclarationMixin from .helpers import ( build_binary_sensor_unique_id_for_entry, build_evse_unique_id_for_entry, + build_pv_inverter_unique_id_for_entry, has_bess, + has_mid, + has_pcs, resolve_evse_display_suffix, + resolve_pv_display_suffixes, ) -from .util import bess_device_info, evse_device_info +from .runtime import SpanPanelConfigEntry +from .util import ( + EMPTY_EVSE, + EMPTY_PV, + bess_device_info, + evse_device_info, + pv_device_info, + pv_inverter_device_info, +) + +if TYPE_CHECKING: + from .pv_binding import PvBinding # pylint: disable=invalid-overridden-method @@ -47,13 +68,13 @@ @dataclass(frozen=True) -class SpanPanelRequiredKeysMixin: +class SpanPanelRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for Span Panel binary sensors.""" value_fn: Callable[[SpanPanelSnapshot], bool | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanPanelBinarySensorEntityDescription( BinarySensorEntityDescription, SpanPanelRequiredKeysMixin ): @@ -71,6 +92,7 @@ class SpanPanelBinarySensorEntityDescription( ] = ( SpanPanelBinarySensorEntityDescription( key=SYSTEM_DOOR_STATE, + field_path="panel.door_state", translation_key="door_state", device_class=BinarySensorDeviceClass.TAMPER, entity_category=EntityCategory.DIAGNOSTIC, @@ -82,6 +104,7 @@ class SpanPanelBinarySensorEntityDescription( ), SpanPanelBinarySensorEntityDescription( key=SYSTEM_ETHERNET_LINK, + field_path="panel.eth0_link", translation_key="ethernet_link", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, @@ -89,6 +112,7 @@ class SpanPanelBinarySensorEntityDescription( ), SpanPanelBinarySensorEntityDescription( key=SYSTEM_WIFI_LINK, + field_path="panel.wlan_link", translation_key="wifi_link", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, @@ -96,6 +120,9 @@ class SpanPanelBinarySensorEntityDescription( ), SpanPanelBinarySensorEntityDescription( key=PANEL_STATUS, + # Reports coordinator reachability, not a snapshot field — the value_fn + # is a placeholder the entity class overrides. + derived=DerivedReason.NO_SOURCE_FIELD, translation_key="panel_status", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, @@ -103,22 +130,128 @@ class SpanPanelBinarySensorEntityDescription( ), ) + +def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None: + """Whether this panel can island, across both schema generations. + + Flat publishes `core/grid-islandable` outright. v1.0 has no such property and + that is deliberate -- `devices/bess.md` says a consumer "distinguishes the + variants without any dedicated type property: a MID `grid` child means + premises-segment backup ... neither means no backup", and "there is no single + 'islanded?' bit to reconcile". + + So under v1.0 the answer is read from the classifier the spec nominates rather + than from a property that no longer exists. That is a consumer deriving a + convenience from what is published, not a producer inventing a claim -- the + panel still says exactly what the spec says it should. + + Keeping the entity alive matters because the alternative is what a firmware + upgrade did in practice: it went `unavailable` with `restored: true`, which + reaches a user as a sensor that broke rather than one whose source moved. The + deprecation is announced separately, as a repair issue. + + DUAL-SCHEMA: when the flat path retires this becomes `has_mid(snapshot)` and the + first branch goes. + """ + if snapshot.grid_islandable is not None: + return snapshot.grid_islandable + return has_mid(snapshot) + + GRID_ISLANDABLE_SENSOR = SpanPanelBinarySensorEntityDescription( key="grid_islandable", + field_path="panel.grid_islandable", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, translation_key="grid_islandable", device_class=BinarySensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda s: s.grid_islandable, + value_fn=_grid_islandable, ) BESS_CONNECTED_SENSOR = SpanPanelBinarySensorEntityDescription( key="bess_connected", + field_path="battery.connected", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, translation_key="bess_connected", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda s: s.battery.connected, ) +PV_PANEL_LINK_SENSOR = SpanPanelBinarySensorEntityDescription( + key=PV_PANEL_LINK_KEY, + field_path="pv.connected", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="pv_panel_link", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: s.pv.connected, +) +"""Can the enclosure talk to the solar inverter? + +`bess_connected`'s counterpart for the other DER the panel feeds through a +circuit. The BESS got one because the upstream lugs' `connection/fed-by-*` +record was already read; the PV's and the charger's live on the *circuit* that +feeds them, as `connection` 0.1 specifies, and nothing read that half — so the +one device class whose link the panel happened to report through the lugs was +the only one a user could see. + +On the inverter's own sub-device, beside `pv_vendor` and `pv_product`, which is +where it moved when the PV got a device of its own -- the same place +`bess_connected` sits relative to the battery. + +`SCHEMA_CONDITIONAL_FIELD` *and* `field_path`: flat firmware publishes +`connected` on the BESS and on nothing else, so the both-adapters gate cannot be +satisfied, while the entity still needs its Repair mention and its +unavailability when the panel stops resolving the property. +""" + + +PCS_ACTIVE_SENSOR = SpanPanelBinarySensorEntityDescription( + key="pcs_active", + field_path="pcs.active", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="pcs_active", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda s: None if s.pcs is None else s.pcs.active, +) +"""Is the Power Control System limiting import right now? + +The one property of this capability that changes on its own, and therefore the +one an automation triggers on: `pcs/enabled` is a commissioning fact and the four +constraint limits move only on reconfiguration, but `active` flips when the panel +starts throttling. A binary sensor rather than a third enum, because the question +is binary and `pcs_binding_constraint` already answers "which limit" for anyone +who needs it. + +Diagnostic, and enabled by default. It reports the panel constraining the user's +supply, which is worth seeing, but it belongs beside the other panel-state +sensors rather than among the power readings. + +`None` when the panel runs no PCS, which is what a flat panel and any v1.0 +firmware without the node report — but the entity is not created there at all, so +the branch is reached only if the node disappears mid-session, where unknown is +the right answer. + +`SCHEMA_CONDITIONAL_FIELD` *and* `field_path`, by the producible rule: flat +declares no `pcs` capability, so the both-adapters gate cannot be satisfied, +while the entity still needs its Repair mention and its unavailability when the +panel stops resolving the property. +""" + + +_HARDWARE_STATUS_SENSORS: frozenset[str] = frozenset( + {SYSTEM_DOOR_STATE, SYSTEM_ETHERNET_LINK, SYSTEM_WIFI_LINK} +) +"""The sensors that stay available while the panel is offline, reading unknown. + +Read by `available` and by `_handle_coordinator_update`, which is why it is named +once here: the two have to agree, and a set restated in each is a set that can be +extended in one and not the other -- leaving a sensor that reports unknown while +offline but drops out of the registry's availability, or the reverse. +""" + class SpanPanelBinarySensor[T: SpanPanelBinarySensorEntityDescription]( SpanPanelEntity, BinarySensorEntity @@ -159,23 +292,35 @@ def available(self) -> bool: - Panel status sensor: always available to show online/offline state - Hardware status sensors: remain available when offline to show Unknown state - Other binary sensors (switches): become unavailable when panel is offline + + The unresolved-field probe runs ahead of all of that, for the same + reason it precedes the grace-period branch in `SpanSensorBase`: the + offline branch below returns True, so a probe after it would leave + `door_state`, `eth0_link` and `wlan_link` reporting a field the adapter + cannot resolve. `panel_status` and the derived sensors declare no + `field_path`, so the probe never fires for them. + + `panel_status` is the one entity a dead transport does not take with + it, and the exception is the same one that already exempts it from the + offline branch: it reports reachability, so it has to survive the + condition it exists to report. It reads `off` rather than holding a + stale `on` -- see `_handle_coordinator_update`. Every other binary + sensor here reads a snapshot field, and holding one from before the + transport died is exactly what the transport probe is for. """ + if self._reads_an_unresolved_field: + return False + # Panel status sensor should always be available to show online/offline state - if hasattr(self.entity_description, "key") and self.entity_description.key == PANEL_STATUS: + if self.entity_description.key == PANEL_STATUS: return True + if not self._transport_available: + return False + # Hardware status sensors should remain available when offline to show Unknown - hardware_status_sensors = { - SYSTEM_DOOR_STATE, - SYSTEM_ETHERNET_LINK, - SYSTEM_WIFI_LINK, - } - - if ( - hasattr(self.entity_description, "key") - and self.entity_description.key in hardware_status_sensors - ): - if getattr(self.coordinator, "panel_offline", False): + if self.entity_description.key in _HARDWARE_STATUS_SENSORS: + if self.coordinator.panel_offline: return True if getattr(self, "_attr_available", True) is False: @@ -186,24 +331,22 @@ def available(self) -> bool: def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" # Special handling for panel_status sensor - if hasattr(self.entity_description, "key") and self.entity_description.key == PANEL_STATUS: - self._attr_is_on = not self.coordinator.panel_offline + if self.entity_description.key == PANEL_STATUS: + # Both flags, because both mean "no panel at the other end". A dead + # transport is deliberately not marked offline -- that flag makes + # the other entities hold their last reading -- and reading it here + # is what stops the one connectivity sensor in the integration from + # reporting connected while the connection is gone for good. + self._attr_is_on = not ( + self.coordinator.panel_offline or self.coordinator.transport_dead + ) self._attr_available = True super()._handle_coordinator_update() return # Check for panel offline status first to prevent accessing None data if self.coordinator.panel_offline or self.coordinator.data is None: - hardware_status_sensors = { - SYSTEM_DOOR_STATE, - SYSTEM_ETHERNET_LINK, - SYSTEM_WIFI_LINK, - } - - if ( - hasattr(self.entity_description, "key") - and self.entity_description.key in hardware_status_sensors - ): + if self.entity_description.key in _HARDWARE_STATUS_SENSORS: self._attr_is_on = None self._attr_available = True _LOGGER.debug( @@ -243,19 +386,93 @@ def _construct_binary_sensor_unique_id( ) +class SpanPanelWifiLinkBinarySensor(SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]): + """The Wi-Fi link, which also reports the network the link is to. + + Its own class, not a branch on `SpanPanelBinarySensor`, because + `_residual_field_paths` is a `ClassVar` and that base class serves every + panel binary sensor — the door, the two links, the panel status, the PCS + activity, the PV link. Declaring the SSID there would claim all of them read + it, and the declaration is not decoration: it is what a Repair consults to + name the entities a dead field took down with it, so an unresolved + `panel.wifi_ssid` would name the door sensor. `SpanPanelStatus` is its own + class in `sensor_panel` for exactly this reason. + """ + + _residual_field_paths: ClassVar[tuple[str, ...]] = ("panel.wifi_ssid",) + """The SSID, read for an attribute rather than by the `value_fn`. + + A plain residual and not an exemption: both adapters map the property + (`core/wifi-ssid` on flat, `status/wifi-ssid` on v1.0), so the producible + gate covers it. + + The only declaration of this path in the integration. It was on + `SpanPanelStatus` while that sensor rendered the SSID, and it moved with the + read rather than being left behind: the declaration is what a Repair + consults to name the entity a dead field took down with it, so a stale copy + would name a sensor that no longer reads the field. Because this is now the + only declaration, `residual_field_paths()` has to import `binary_sensor` for + the subclass walk to see it -- the walk sees only imported modules, and this + path would otherwise leave the producible gate silently. + """ + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """The network this link is up on, when the panel names it. + + The coherent host for the SSID: the entity that reports whether Wi-Fi is + up is the one that should report which network it is up on. Both values + come from the same node on the wire — `status/wifi` and + `status/wifi-ssid` on v1.0, `core/wifi` and `core/wifi-ssid` on flat. + + The Software Version sensor used to publish it, for the historical + reason that `panel_size` was already occupying its attribute block. That + copy is gone -- see `SpanPanelStatus.extra_state_attributes` for why the + compatibility argument for keeping one did not hold up. + + Omitted rather than reported as `None` when the panel publishes no SSID: + an attribute present and empty reads as a reading that failed, which is + a different claim from the panel never having made one. + """ + snapshot = self.coordinator.data + if snapshot is None or snapshot.wifi_ssid is None: + return None + return {"wifi_ssid": snapshot.wifi_ssid} + + +_PANEL_BINARY_SENSOR_CLASSES: Mapping[ + str, type[SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]] +] = MappingProxyType({SYSTEM_WIFI_LINK: SpanPanelWifiLinkBinarySensor}) +"""Panel binary sensors needing a class of their own, by description key. + +Everything absent from this map is a plain `SpanPanelBinarySensor`. A named map +rather than a conditional inside the setup comprehension: the comprehension says +"build one entity per description" and should keep saying only that, and the +next description that needs its own class is then a one-line addition here +rather than a second branch to read past. +""" + + +def _panel_binary_sensor_class( + description: SpanPanelBinarySensorEntityDescription, +) -> type[SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]]: + """Return the entity class that serves one panel binary sensor description.""" + return _PANEL_BINARY_SENSOR_CLASSES.get(description.key, SpanPanelBinarySensor) + + # --------------------------------------------------------------------------- # EVSE (EV Charger) binary sensors # --------------------------------------------------------------------------- @dataclass(frozen=True) -class SpanEvseBinarySensorRequiredKeysMixin: +class SpanEvseBinarySensorRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for EVSE binary sensors.""" value_fn: Callable[[SpanEvseSnapshot], bool | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanEvseBinarySensorEntityDescription( BinarySensorEntityDescription, SpanEvseBinarySensorRequiredKeysMixin ): @@ -272,20 +489,45 @@ class SpanEvseBinarySensorEntityDescription( ] = ( SpanEvseBinarySensorEntityDescription( key="evse_charging", + field_path="evse.status", translation_key="evse_charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, value_fn=lambda e: (e.status or "") == "CHARGING", ), SpanEvseBinarySensorEntityDescription( key="evse_ev_connected", + field_path="evse.status", translation_key="evse_ev_connected", device_class=BinarySensorDeviceClass.PLUG, value_fn=lambda e: (e.status or "") in _EV_CONNECTED_STATUSES, ), ) -# Fallback EVSE snapshot used when the EVSE disappears mid-session -_EMPTY_EVSE = SpanEvseSnapshot(node_id="", feed_circuit_id="") +EVSE_PANEL_LINK_SENSOR = SpanEvseBinarySensorEntityDescription( + key="evse_panel_link", + field_path="evse.connected", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="evse_panel_link", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda e: e.connected, +) +"""Can the enclosure talk to this charger? + +**Not `evse_ev_connected`, which sits two definitions above it.** That one reads +the charger's own `status/status` and answers "is a vehicle plugged in" — a +`PLUG` device class, enabled by default, the fact a user builds a charging +automation on. This reads the *feeding circuit's* `connection/feeds-device-status` +and answers "can the panel reach the charger at all" — a `CONNECTIVITY` device +class, diagnostic. The two disagree exactly when it matters: a charger +mid-session over a lost link reports a plugged-in vehicle and a dead link at the +same time, because the last session state the panel heard is still the last +session state the panel heard. + +Deliberately outside `EVSE_BINARY_SENSORS`, which is the unconditional pair. +This one is created per charger and only where the record exists, following +`bess_connected` and `pcs_active` rather than its two neighbours. +""" class SpanEvseBinarySensor(SpanPanelEntity, BinarySensorEntity): @@ -314,11 +556,15 @@ def __init__( ) panel_identifier = snapshot.serial_number - evse = snapshot.evse.get(evse_id, _EMPTY_EVSE) + evse = snapshot.evse.get(evse_id, EMPTY_EVSE) use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) self._attr_device_info = evse_device_info( - panel_identifier, evse, panel_name, display_suffix + panel_identifier, + evse, + panel_name, + display_suffix, + panel_device_id=data_coordinator.config_entry.runtime_data.panel_device_id, ) device_name = data_coordinator.config_entry.data.get( @@ -336,11 +582,151 @@ def _handle_coordinator_update(self) -> None: return snapshot = self.coordinator.data - evse = snapshot.evse.get(self._evse_id, _EMPTY_EVSE) + evse = snapshot.evse.get(self._evse_id, EMPTY_EVSE) self._attr_is_on = self._value_fn(evse) super()._handle_coordinator_update() +# --------------------------------------------------------------------------- +# PV inverter binary sensors, for each inverter the Solar card does not read +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SpanPVBinarySensorRequiredKeysMixin(FieldPathDeclarationMixin): + """Required keys mixin for per-inverter binary sensors.""" + + value_fn: Callable[[SpanPVSnapshot], bool | None] + + +@dataclass(frozen=True, kw_only=True) +class SpanPVBinarySensorEntityDescription( + BinarySensorEntityDescription, SpanPVBinarySensorRequiredKeysMixin +): + """Describes one inverter's binary sensor entity.""" + + +PV_INVERTER_LINK_SENSOR = SpanPVBinarySensorEntityDescription( + key=PV_PANEL_LINK_SENSOR.key, + field_path="pv.connected", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key=PV_PANEL_LINK_SENSOR.translation_key, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda pv: pv.connected, +) +"""`PV_PANEL_LINK_SENSOR` for an inverter the Solar card does not read. + +The same key, translation and source field, read from that inverter's own +snapshot rather than from `snapshot.pv`. Created per inverter and only where +its feeding circuit publishes the record, as `EVSE_PANEL_LINK_SENSOR` is per +charger. +""" + + +class SpanPVInverterBinarySensor(SpanPanelEntity, BinarySensorEntity): + """One inverter's binary sensor, shaped like `SpanEvseBinarySensor`.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPVBinarySensorEntityDescription, + inverter_key: str, + ) -> None: + """Initialize the inverter binary sensor.""" + super().__init__(data_coordinator, context=description) + snapshot: SpanPanelSnapshot = data_coordinator.data + self._inverter_key = inverter_key + self.entity_description = description + self._attr_device_class = description.device_class + self._value_fn = description.value_fn + + device_name = data_coordinator.config_entry.data.get( + CONF_DEVICE_NAME, data_coordinator.config_entry.title + ) + inverter = snapshot.pv_inverters.get(inverter_key, EMPTY_PV) + use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + self._attr_device_info = pv_inverter_device_info( + snapshot.serial_number, + inverter_key, + inverter, + device_name or "Span Panel", + resolve_pv_display_suffixes(snapshot, use_circuit_numbers).get(inverter_key), + panel_device_id=data_coordinator.config_entry.runtime_data.panel_device_id, + ) + self._attr_unique_id = build_pv_inverter_unique_id_for_entry( + data_coordinator, snapshot, inverter_key, description.key, device_name + ) + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.panel_offline or self.coordinator.data is None: + self._attr_is_on = None + super()._handle_coordinator_update() + return + + inverter = self.coordinator.data.pv_inverters.get(self._inverter_key, EMPTY_PV) + self._attr_is_on = self._value_fn(inverter) + super()._handle_coordinator_update() + + +class SpanPVSolarLinkBinarySensor(SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]): + """`PV_PANEL_LINK_SENSOR` on the Solar card, reading what `pv_binding` binds the card to. + + Unique id, device and description are the panel sensor's, unchanged; only + the reading follows the identity, so the entity keeps describing the + inverter it always did when another is published beside it. + """ + + def __init__( + self, coordinator: SpanPanelCoordinator, identity: PvBinding, device_info: DeviceInfo + ) -> None: + """Initialize the Solar card's link sensor.""" + super().__init__(coordinator, PV_PANEL_LINK_SENSOR, device_info_override=device_info) + self._value_fn = lambda snapshot: identity.source(snapshot).connected + + +def _create_pv_link_sensors( + config_entry: SpanPanelConfigEntry, + snapshot: SpanPanelSnapshot, +) -> list[SpanPVSolarLinkBinarySensor | SpanPVInverterBinarySensor]: + """Create the enclosure's view of the link to each solar inverter. + + The Solar card's link reads through `pv_binding`, like its other PV + entities, and exists where the binding's `solar_link` says: already + registered, or every inverter the card reads publishes a link record -- never + by what a link reads at setup. Every other inverter has its own on its own + card by the same rule, per key (`inverter_links`): already registered, or its + circuit publishes the record, whose absence is the panel saying it does not + know. + """ + coordinator = config_entry.runtime_data.coordinator + identity = config_entry.runtime_data.pv_binding + entities: list[SpanPVSolarLinkBinarySensor | SpanPVInverterBinarySensor] = [ + SpanPVInverterBinarySensor(coordinator, PV_INVERTER_LINK_SENSOR, key) + for key in snapshot.pv_inverters + if key in identity.inverter_links + ] + if identity.solar_link: + source = identity.source(snapshot) + configured_name = coordinator.config_entry.data.get( + CONF_DEVICE_NAME, coordinator.config_entry.title + ) + entities.append( + SpanPVSolarLinkBinarySensor( + coordinator, + identity, + pv_device_info( + snapshot.serial_number, + source, + configured_name or "Span Panel", + panel_device_id=config_entry.runtime_data.panel_device_id, + ), + ) + ) + return entities + + async def async_setup_entry( hass: HomeAssistant, config_entry: SpanPanelConfigEntry, @@ -353,13 +739,40 @@ async def async_setup_entry( coordinator = config_entry.runtime_data.coordinator entities: list[ - SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription] | SpanEvseBinarySensor - ] = [SpanPanelBinarySensor(coordinator, description) for description in BINARY_SENSORS] - - # Add grid islandable binary sensor when v2 data is available - snapshot: SpanPanelSnapshot = coordinator.data - if snapshot.grid_islandable is not None: - entities.append(SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR)) + SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription] + | SpanEvseBinarySensor + | SpanPVInverterBinarySensor + ] = [ + _panel_binary_sensor_class(description)(coordinator, description) + for description in BINARY_SENSORS + ] + + snapshot: SpanPanelSnapshot = config_entry.runtime_data.setup_snapshot + + # Created unconditionally, because on both generations the answer is knowable. + # + # This gate has been narrowed twice for the same symptom. Gating on the flat + # property alone left the entity `unavailable` after a v1.0 upgrade -- the + # panel had not lost the capability, only the property that used to report + # it. Adding `has_mid` rescued panels that have a battery and left every + # battery-less one exactly where it was, because it admitted the entity only + # when the answer was going to be `True`. For a boolean whose `False` is + # informative that is backwards: no MID is not missing information, it is the + # information. `devices/bess.md` makes the signal structural -- "a MID `grid` + # child means premises-segment backup ... neither means no backup" -- so a + # panel without one does not island, and saying so is the answer rather than + # the absence of one. + # + # Nothing about flat changes. Its property is published, so `_grid_islandable` + # returns it exactly as before; and a flat panel that stops publishing it has + # a metadata row that fails to resolve, which is what makes the entity + # unavailable and raises the Repair. That path is untouched, and it is the one + # that must not become a default presented as a reading. + # + # DUAL-SCHEMA: nothing to remove here when the flat path retires -- an + # unconditional append is already the end state. The branch that goes is the + # first one in `_grid_islandable`, which is where the flat property is read. + entities.append(SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR)) # Add BESS connected sensor on the BESS sub-device when battery is commissioned if has_bess(snapshot): @@ -368,19 +781,65 @@ async def async_setup_entry( or "Span Panel" ) - bess_info = bess_device_info(snapshot.serial_number, snapshot.battery, panel_name) + bess_info = bess_device_info( + snapshot.serial_number, + snapshot.battery, + panel_name, + panel_device_id=config_entry.runtime_data.panel_device_id, + ) entities.append( SpanPanelBinarySensor( coordinator, BESS_CONNECTED_SENSOR, device_info_override=bess_info ) ) + # Add the PCS activity sensor where the panel runs a Power Control System. + # Gated on the node, not on a value: every property this capability publishes + # is legally zero or false, so a value gate would delete the entity of every + # panel whose PCS is merely switched off — see `has_pcs`. + if has_pcs(snapshot): + entities.append(SpanPanelBinarySensor(coordinator, PCS_ACTIVE_SENSOR)) + + entities.extend(_create_pv_link_sensors(config_entry, snapshot)) + # Add EVSE binary sensors for each commissioned charger if snapshot.evse: - for evse_id in snapshot.evse: + for evse_id, evse in snapshot.evse.items(): entities.extend( SpanEvseBinarySensor(coordinator, evse_desc, evse_id) for evse_desc in EVSE_BINARY_SENSORS ) - - async_add_entities(entities) + # Per charger, not per panel: two chargers can be fed by two + # circuits of which only one publishes the record. + if evse.connected is not None: + entities.append(SpanEvseBinarySensor(coordinator, EVSE_PANEL_LINK_SENSOR, evse_id)) + + # Declared booleans on devices this integration models nothing for. Disabled + # and diagnostic, so a panel that gains a vendor device gains no dashboard + # clutter -- only something the user can find and enable. + async_add_entities( + [ + *entities, + *create_adopted_binary_sensors( + coordinator, + snapshot, + dr.async_get(hass), + config_entry_id=config_entry.entry_id, + panel_device_id=config_entry.runtime_data.panel_device_id, + overlay=config_entry.runtime_data.curation, + ), + # Vendor extensions on devices this integration *does* model. A + # separate inventory from adoption's for the same reason adoption is + # separate from the curated descriptions: different question, and + # `extension` owns the answer. + *create_extension_binary_sensors( + coordinator, + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=config_entry.entry_id, + overlay=config_entry.runtime_data.curation, + pv_binding=config_entry.runtime_data.pv_binding, + ), + ] + ) diff --git a/custom_components/span_panel/button.py b/custom_components/span_panel/button.py index faf24fd4..d4f244b6 100644 --- a/custom_components/span_panel/button.py +++ b/custom_components/span_panel/button.py @@ -1,28 +1,24 @@ """Button entities for the Span Panel.""" -import logging from typing import Final from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from span_panel_api import SpanMqttClient, SpanPanelSnapshot -from span_panel_api.exceptions import SpanPanelServerError +from span_panel_api import SpanPanelSnapshot -from . import SpanPanelConfigEntry from .const import CONF_DEVICE_NAME +from .control_gate import ControlMode from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity -from .helpers import ( - async_create_span_notification, - construct_panel_unique_id_for_entry, - has_bess, -) - -_LOGGER = logging.getLogger(__name__) +from .helpers import construct_panel_unique_id_for_entry, has_bess +from .runtime import SpanPanelConfigEntry PARALLEL_UPDATES = 1 +_HEALTHY_COMMUNICATION_STATE: Final = "OK" +"""The battery communication state during which the panel ignores the assertion.""" + GFE_OVERRIDE_DESCRIPTION: Final = ButtonEntityDescription( key="gfe_override", @@ -63,47 +59,68 @@ def __init__( ) async def async_press(self) -> None: - """Publish the GFE override to the panel.""" + """Publish the GFE override to the panel. + + A refusal is raised at the caller rather than filed as a persistent + notification, the same way the circuit controls report one: nothing was + published, so there is nothing to correct later, and the person who + pressed the button is the one who needs to hear about it. A `FAILED` + outcome -- handed to nobody, and promised not to arrive later -- is + raised on the same grounds and worded as the different fact it is. Both + live in `_async_control`, which every control here shares. + """ client = self.coordinator.client - if not hasattr(client, "set_dominant_power_source"): - _LOGGER.warning("Client does not support GFE override") - return - - try: - await client.set_dominant_power_source(self._override_value) - await self.coordinator.async_request_refresh() - except SpanPanelServerError: - warning_msg = ( - f"SPAN API returned a server error attempting " - f"to override GFE to {self._override_value}." - ) - _LOGGER.warning(warning_msg) - await async_create_span_notification( - self.hass, - message=warning_msg, - title="SPAN API Error", - notification_id="span_panel_gfe_override_error", - ) + await self._async_control( + client.set_dominant_power_source(self._override_value), + command=f"a GFE override to {self._override_value}", + failed_key="gfe_override_failed", + not_delivered_key="gfe_override_not_delivered", + placeholders={"value": self._override_value}, + ) + + await self.coordinator.async_request_refresh() @property def available(self) -> bool: """Return entity availability. The override is only relevant when BESS communication is lost and the - panel is not already reporting grid-connected. When BESS is online or - GFE is already GRID, firmware is managing correctly and the button + panel is not already reporting grid-connected. When BESS is online or the + panel already reads on-grid, firmware is managing correctly and the button should not be pressable. + + The second check reads `dsm_state`, not the grid-forming entity. It asks + "are we already on the grid", which is what `dsm_state` answers directly and + the GFE answers only by implication -- and, unlike the GFE, it is populated on + both wire schemas, so this stays free of any knowledge of which one is + underneath. It used to compare `dominant_power_source` to `"GRID"`, which is + `None` under v1.0 and so never fired there at all. + + One useful consequence: `dsm_state` folds in the user's own assertion, so once + a press takes effect the button disables itself rather than inviting a second. + + **The battery's own link health decides, when it is published.** The panel + accepts the assertion only while the battery's communication state is not + `OK`, so any other value (`LOST`, `DEGRADED`, `UNKNOWN`) makes the button + available. When no communication state is published, `connected` decides + instead, and an absent connection status counts as eligible because the + panel still accepts the assertion then. """ - if getattr(self.coordinator, "panel_offline", False): + if not self._transport_available: + return False + if self.coordinator.panel_offline: return False if not super().available: return False snapshot: SpanPanelSnapshot = self.coordinator.data - bess_connected = snapshot.battery.connected if snapshot.battery else None - gfe = snapshot.dominant_power_source - if bess_connected is True: + battery = snapshot.battery + communication_state = battery.communication_state if battery else None + if communication_state is not None: + if communication_state.upper() == _HEALTHY_COMMUNICATION_STATE: + return False + elif battery is not None and battery.connected is True: return False - if gfe == "GRID": + if snapshot.dsm_state == "DSM_ON_GRID": return False return True @@ -114,12 +131,17 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button entities for Span Panel.""" + # Under `disabled` no control entity is created and no registry entry is + # removed. See `switch.async_setup_entry` for why the registry entries stay. + if config_entry.runtime_data.control_policy.mode is ControlMode.DISABLED: + return + coordinator = config_entry.runtime_data.coordinator entities: list[SpanPanelGFEOverrideButton] = [] - snapshot: SpanPanelSnapshot = coordinator.data - if isinstance(coordinator.client, SpanMqttClient) and has_bess(snapshot): + snapshot: SpanPanelSnapshot = config_entry.runtime_data.setup_snapshot + if has_bess(snapshot): entities.append(SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID")) async_add_entities(entities) diff --git a/custom_components/span_panel/ca_repairs.py b/custom_components/span_panel/ca_repairs.py new file mode 100644 index 00000000..c2f98055 --- /dev/null +++ b/custom_components/span_panel/ca_repairs.py @@ -0,0 +1,217 @@ +"""The Repair raised when a pinned panel starts advertising a different CA. + +Separate from `schema_repairs` because the two are reconciled on opposite terms. +A schema finding is re-derived from live state on every pass and deleted the +moment it stops being true; this one describes an event that already happened on +a transport that has stopped for good, and there is nothing left running to +re-derive it from. It is raised once, persists across restarts, and is cleared +only by the user accepting the new fingerprint or by a setup that connects +cleanly again. + +It is also the only Repair here that is fixable, and the fix is the point. A +rotated CA and an intercepted connection are indistinguishable from inside the +integration — the library says so and refuses to choose — so the only honest +resolution is a person comparing the new fingerprint against a value they hold +and saying yes. Re-pinning automatically on reconnect would be precisely the +substitution the pin exists to prevent. +""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN +from .leaf_repairs import async_clear_leaf_name_mismatch + +_LOGGER = logging.getLogger(__name__) + +CA_CHANGED_ISSUE_PREFIX = "panel_ca_changed_" +CA_UNUSABLE_ISSUE_PREFIX = "panel_ca_unusable_" +REST_TLS_UNTRUSTED_ISSUE_PREFIX = "panel_rest_tls_untrusted_" + + +def ca_changed_issue_id(entry_id: str) -> str: + """Issue id for one entry's CA change.""" + return f"{CA_CHANGED_ISSUE_PREFIX}{entry_id}" + + +def ca_unusable_issue_id(entry_id: str) -> str: + """Issue id for one entry's unreadable stored CA.""" + return f"{CA_UNUSABLE_ISSUE_PREFIX}{entry_id}" + + +def rest_tls_untrusted_issue_id(entry_id: str) -> str: + """Issue id for one entry's REST TLS port serving something the pin rejects.""" + return f"{REST_TLS_UNTRUSTED_ISSUE_PREFIX}{entry_id}" + + +@callback +def async_raise_ca_changed( + hass: HomeAssistant, + entry: ConfigEntry, + expected_fingerprint: str, + observed_fingerprint: str, +) -> None: + """Raise the Repair, carrying both fingerprints. + + Both, because the two remedies are opposite and only the user can choose + between them: re-pin, if a firmware upgrade or a factory reset rotated the + CA legitimately, or investigate, if nothing should have. + + `is_persistent` because the transport this describes is already dead. A + non-persistent issue reloads as a tombstone, and this one has no live state + to re-assert it from — the client that would have noticed is gone. + + Supersedes a standing name mismatch on the same entry, and does it here + rather than at each call site so no future one can forget. The two findings + come out of the same failed handshake and its diagnosis, so both can stand + against one panel; only this one carries a decision, and telling somebody to + re-point their configuration at an address served by a certificate this + integration has just refused to trust is the wrong instruction. + """ + async_clear_leaf_name_mismatch(hass, entry) + _LOGGER.error( + "SPAN panel %s is advertising CA %s where %s was pinned. Not reconnecting: a " + "rotated CA and an intercepted connection look identical from here. Confirm the " + "new fingerprint out of band, then accept it in Settings > Repairs", + entry.title, + observed_fingerprint, + expected_fingerprint, + ) + ir.async_create_issue( + hass, + DOMAIN, + ca_changed_issue_id(entry.entry_id), + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_ca_changed", + translation_placeholders={ + "panel": entry.title, + "expected_fingerprint": expected_fingerprint, + "observed_fingerprint": observed_fingerprint, + }, + data={ + "entry_id": entry.entry_id, + "observed_fingerprint": observed_fingerprint, + }, + ) + + +@callback +def async_clear_ca_changed(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the Repair, on a setup that reached the panel under the current pin.""" + ir.async_delete_issue(hass, DOMAIN, ca_changed_issue_id(entry.entry_id)) + + +@callback +def async_raise_ca_unusable(hass: HomeAssistant, entry: ConfigEntry, reason: str) -> None: + """Raise the Repair for a stored anchor this system cannot read. + + Setup fails closed on this instead of downgrading to plaintext, because the + downgrade would run unattended on every boot at exactly the call the pin + exists to protect. The other REST callers that refuse this state — a + credential rotation — could point at reauth; setup cannot fix a stored + value, so the fix is the same flow the CA-change uses: fetch what the panel + advertises, verify it signs the panel's own certificate, and ask a person to + accept the fingerprint. + + Fixable and persistent for the CA-changed Repair's reasons: the fix *is* the + resolution, and there is no live transport left to re-derive the finding + from. + """ + _LOGGER.error( + "The stored certificate authority for SPAN panel %s cannot be read (%s), so its " + "REST calls cannot be verified and setup has stopped rather than falling back to " + "plaintext. Re-acquire the panel's certificate authority in Settings > Repairs", + entry.title, + reason, + ) + ir.async_create_issue( + hass, + DOMAIN, + ca_unusable_issue_id(entry.entry_id), + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_ca_unusable", + translation_placeholders={"panel": entry.title}, + data={"entry_id": entry.entry_id}, + ) + + +@callback +def async_clear_ca_unusable(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the Repair, on a setup whose stored anchor built a working transport.""" + ir.async_delete_issue(hass, DOMAIN, ca_unusable_issue_id(entry.entry_id)) + + +@callback +def async_raise_rest_tls_untrusted( + hass: HomeAssistant, entry: ConfigEntry, host: str, https_port: int, fingerprint: str +) -> None: + """Raise the Repair for a REST TLS port answering with a certificate the pin rejects. + + Raised only after the diagnosis has ruled the alternatives out: the panel + still advertises the pinned CA (a rotated CA takes the CA-changed Repair + and its guided re-pin), and the leaf probe found a certificate the pin does + not currently validate rather than one that merely names somewhere else (a + moved panel takes the leaf-mismatch Repair). What is left is two conditions + the probe cannot tell apart, and the text names both: something terminating + TLS in front of the panel with a certificate of its own, or the panel's own + certificate outside its validity window after a clock reset. + + Not fixable, because none of the remedies are this integration's to apply: + the port is corrected by Reconfigure, the middlebox by whoever put it + there, and the clock by the panel getting time. Retried rather than + terminal — the clock case clears itself, plaintext is never fallen back + to either way, and a matching fingerprint never escalates, which is the + same stance the library's own diagnosis takes. `is_persistent=False` for + the leaf-mismatch Repair's reason: re-derived on every retry, so a restart + that resolves it must not resurrect it. + + Supersedes a standing leaf-name mismatch on the same entry, for the + CA-changed Repair's reason: the two verdicts come out of the same probe and + contradict each other — one promises recovery at a returning address, this + one says what answers there is not trusted — so only the current one may + stand. The reverse supersede lives at the verdict call site in `__init__`, + because `leaf_repairs` cannot import this module back without a cycle. + """ + async_clear_leaf_name_mismatch(hass, entry) + _LOGGER.error( + "Something at %s:%s is answering SPAN panel %s's HTTPS port with a certificate " + "its pinned CA (SHA-256 %s) does not currently validate, while the panel still " + "advertises that same CA. Retrying without connecting: check the entry's HTTPS " + "port and whether anything terminates TLS between Home Assistant and the panel; " + "a panel whose clock reset clears this on its own once it has time again", + host, + https_port, + entry.title, + fingerprint, + ) + ir.async_create_issue( + hass, + DOMAIN, + rest_tls_untrusted_issue_id(entry.entry_id), + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_rest_tls_untrusted", + translation_placeholders={ + "panel": entry.title, + "host": host, + "https_port": str(https_port), + "fingerprint": fingerprint, + }, + data={"entry_id": entry.entry_id}, + ) + + +@callback +def async_clear_rest_tls_untrusted(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the Repair, on a setup that reached the panel under the current pin.""" + ir.async_delete_issue(hass, DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 75e149fd..8b06d059 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -4,8 +4,10 @@ import asyncio from collections.abc import Mapping +from dataclasses import replace import enum import logging +import ssl from typing import TYPE_CHECKING, Any from homeassistant import config_entries @@ -21,6 +23,8 @@ from homeassistant.util.network import is_ipv4_address from span_panel_api import ( V2AuthResponse, + build_panel_ssl_context, + ca_fingerprint, delete_fqdn, detect_api_version, register_fqdn, @@ -29,7 +33,10 @@ SpanPanelAPIError, SpanPanelAuthError, SpanPanelConnectionError, + SpanPanelPassphraseUnavailableError, + SpanPanelServerError, SpanPanelTimeoutError, + SpanPanelValidationError, ) import voluptuous as vol @@ -39,8 +46,20 @@ process_general_options_input, ) from .config_flow_validation import ( + LeafVerdict, + PanelCaUnusableError, + PanelRestTransport, + as_port, + async_download_ca_or_none, + async_fetch_panel_ca, + async_leaf_chains_to_ca, + async_leaf_verdict, + async_panel_leaf_host, check_fqdn_tls_ready, is_fqdn, + panel_rest_transport, + pem_fingerprint_or_reason, + port_or_none, validate_host, validate_v2_passphrase, validate_v2_proximity, @@ -53,22 +72,30 @@ CONF_EBUS_BROKER_USERNAME, CONF_HOP_PASSPHRASE, CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, CONF_PANEL_SERIAL, CONF_REGISTERED_FQDN, + DEFAULT_HTTPS_PORT, + DEFAULT_MQTTS_PORT, DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION, ENTITY_NAMING_PATTERN, + NEW_INSTALL_ENERGY_DIP_COMPENSATION, + PANEL_CA_PENDING, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX, EntityNamingPattern, ) +from .control_gate import DEFAULT_ALLOW_CONTEXTLESS_CONTROL from .options import ( + ALLOW_CONTEXTLESS_CONTROL, ENERGY_DISPLAY_PRECISION, POWER_DISPLAY_PRECISION, ) if TYPE_CHECKING: - from . import SpanPanelConfigEntry + from .runtime import SpanPanelConfigEntry _LOGGER = logging.getLogger(__name__) @@ -85,7 +112,9 @@ def get_user_data_schema(default_host: str = "") -> vol.Schema: vol.Optional(CONF_HTTP_PORT, default=80): int, vol.Optional(POWER_DISPLAY_PRECISION, default=0): int, vol.Optional(ENERGY_DISPLAY_PRECISION, default=2): int, - vol.Optional(ENABLE_ENERGY_DIP_COMPENSATION, default=True): bool, + vol.Optional( + ENABLE_ENERGY_DIP_COMPENSATION, default=NEW_INSTALL_ENERGY_DIP_COMPENSATION + ): bool, } ) @@ -99,6 +128,30 @@ def get_user_data_schema(default_host: str = "") -> vol.Schema: ) +def _discovered_port(record: Mapping[str, Any], *names: str) -> int | None: + """Read a port out of a discovery record, or None when it names none. + + Everything in a discovery record is whatever the panel put on the wire: + mDNS TXT values arrive as strings, may be absent, and may be spelled in a + case this integration did not pick. Anything unreadable is treated as + unpublished rather than raising, because a malformed TXT record must not be + the reason a panel cannot be set up. + + None is distinct from a published value that happens to equal the default: + only None leaves the flow free to ask the user. + """ + for name in names: + raw = record.get(name) + if raw is None or raw == "": + continue + try: + return int(raw) + except (TypeError, ValueError): + _LOGGER.debug("Discovery record carried an unreadable %s: %r", name, raw) + return None + return None + + class TriggerFlowType(enum.Enum): """Types of configuration flow triggers.""" @@ -109,8 +162,8 @@ class TriggerFlowType(enum.Enum): class SpanPanelConfigFlow(config_entries.ConfigFlow): """Handle a config flow for Span Panel.""" - VERSION = 6 - MINOR_VERSION = 1 + VERSION = 7 + MINOR_VERSION = 2 domain = DOMAIN def is_matching(self, other_flow: SpanPanelConfigFlow) -> bool: @@ -136,15 +189,104 @@ def __init__(self) -> None: self._v2_broker_port: int | None = None self._v2_broker_username: str | None = None self._v2_broker_password: str | None = None - self._v2_passphrase: str | None = None self._v2_panel_serial: str | None = None self._http_port: int = 80 + self._https_port: int = DEFAULT_HTTPS_PORT + # Reconfigure's own copy, kept apart from `_https_port` because that one + # belongs to the CA-acquisition steps and reauth. None until the user + # submits the reconfigure form of a pinned entry. + self._reconfigure_https_port: int | None = None + # Whether something authoritative already told this flow where the panel + # serves TLS, rather than the value above being a default. Discovery is + # one such source: a panel that publishes the port knows where it + # serves, and asking the user for it after being told is asking them to + # confirm a fact they have no way to check. An existing entry is the + # other: on reauth the port it was pinned against is the answer. + self._https_port_known: bool = False + # The panel CA, once fetched and leaf-confirmed. None means the flow + # could not acquire one and the entry is created with the pending flag. + self._panel_ca_pem: str | None = None + # How this flow's REST calls reach the panel. Set from the existing + # entry on reauth and reconfigure, where a pin may already exist; left + # None on initial setup, which has nothing pinned yet by construction. + self._rest_transport: PanelRestTransport | None = None + # The address this flow dials while `self.host` is a name the panel's + # certificate does not yet name -- an FQDN before `register_fqdn` has + # run, which is every FQDN install up to the moment it authenticates. + # + # It carries an invariant the rest of the flow leans on: **it is set + # only while `self.host` is unverified under the pin.** It is None when + # the host is an IP, when the leaf already names the host, and again as + # soon as registration makes the panel serve the name. So anything about + # to persist `self.host` can read it as "this host is one the anchor + # would reject", and refuse rather than write an entry that cannot + # connect to its own panel. + self._bootstrap_host: str | None = None + # Whether the host this flow is about to store is one the pinned + # certificate names. Only reconfigure sets it false, and only to + # refuse -- see `async_step_reconfigure`. + self._leaf_names_host: bool = True + # Whether the panel accepted the FQDN and now serves it. Recorded rather + # than re-derived from `is_fqdn(host)` at entry creation, because that + # question is "does this look like a domain name" and the one that + # matters is "did the registration this flow performed succeed". + self._fqdn_registered: bool = False # Energy dip compensation default for fresh installs self._enable_dip_compensation: bool = True # FQDN registration task (async_show_progress) self._fqdn_task: asyncio.Task[None] | None = None self._reconfigure_fqdn_task: asyncio.Task[None] | None = None + @property + def _rest_host(self) -> str: + """The address this flow's REST and TLS calls dial.""" + return self._bootstrap_host or self.host or "" + + def _require_transport(self) -> PanelRestTransport: + """Return the transport this flow's credential exchanges run over. + + Raised rather than defaulted. Every path that asks for one has been + through the CA step or adopted an existing entry's pin, so a missing + transport is a routing bug -- and the only value that could stand in for + it is a plaintext one, which would send the panel passphrase in the + clear to paper over the bug. + """ + if self._rest_transport is None: + raise ConfigFlowError("Reached a panel REST call with no transport pinned") + return self._rest_transport + + async def _async_choose_bootstrap_host(self, ca_pem: str, tls_port: int) -> bool: + """Settle which address the panel is dialled by, and whether it can be at all. + + Which addresses are tried, and in what order, is `async_panel_leaf_host` + -- one implementation, shared with the setup-time and repair-time + checks. What is this flow's own is the *consequence* of the answer: a + panel reached at something other than the host the user gave is a panel + whose certificate does not name that host, and `_bootstrap_host` records + exactly that, so every later step about to persist `self.host` can read + it as "the anchor would reject this" and refuse. + + Returns False when nothing answers under the published CA, which is a + leaf mismatch and is fatal to the step that called it. + """ + self._bootstrap_host = None + host = self.host + if not host: + return False + + reached = await async_panel_leaf_host(self.hass, host, tls_port, ca_pem) + if reached is None: + return False + if reached != host: + self._bootstrap_host = reached + _LOGGER.debug( + "The certificate this panel serves does not name %s; reaching it " + "at %s until it does", + host, + reached, + ) + return True + def ensure_flow_is_set_up(self) -> None: """Ensure the flow is set up.""" if self._is_flow_setup is False: @@ -159,7 +301,166 @@ async def ensure_not_already_configured(self, raise_on_progress: bool = True) -> # User-initiated flows pass raise_on_progress=False so they can # proceed when a zeroconf discovery flow is already running. await self.async_set_unique_id(self.serial_number, raise_on_progress=raise_on_progress) - self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) + self._abort_if_unique_id_configured(updates=await self._async_host_update()) + + async def _async_host_update(self) -> dict[str, Any] | None: + """Return the host update the already-configured abort should carry, or None. + + The abort that follows is also how an entry follows its panel: a new + DHCP lease, a re-announcement on the new address, and the entry is + rewritten to point at it. Both routes into this reach it before anything + has been authenticated — an `_ebus._tcp` record is whatever the LAN + chose to broadcast, and the serial that matched came from the candidate + host's own unauthenticated `/api/v2/status` — so on its own, "claims to + be this serial" is a claim anything on the network can make. + + A pinned entry has a better question available: does the candidate serve + a certificate this entry's own anchor validates? Nothing but the panel + holding the matching key can answer it, and it is precisely the check + the entry's own connections will apply afterwards. Moving a pinned entry + onto a host that fails it is not a recoverable mistake either way round: + an impostor gets the entry pointed at itself and the user a CA-changed + repair inviting them to accept its fingerprint, while an honest name the + panel simply does not serve — a single-label host, an FQDN never + registered — leaves the entry unable to reach its own panel with no flow + left to correct it. + + Verified as the host would be *stored*, against the port the entry + already uses. The address fallback that `_async_choose_bootstrap_host` + applies is deliberately not repeated: that one exists to dial a panel + while a name is unverified, and accepting a name here because its + address validates would persist exactly the host that fallback refuses + to write. + + An entry with no anchor keeps the behaviour it shipped with. There is no + pin to protect and no check to make, so refusing the move would cost an + unpinned install its panel and buy nothing. + """ + host = self.host + if not host or self.unique_id is None: + return None + entry = self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, self.unique_id + ) + if entry is None: + return {CONF_HOST: host} + ca_pem = entry.data.get(CONF_PANEL_CA_PEM) + if not ca_pem: + return {CONF_HOST: host} + + tls_port = as_port(entry.data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + try: + chains = await async_leaf_chains_to_ca(host, tls_port, str(ca_pem)) + except Exception: + # `async_leaf_chains_to_ca` answers False for every failure it + # anticipates, so reaching here at all is unexpected. Caught broadly + # rather than left to propagate because the two outcomes are not + # symmetric: an escaped error inside a discovery flow is an + # unhandled traceback, and what it would abandon is the one check + # standing between an unauthenticated claim and a pinned entry's + # host. Anything unrecognised is a refusal. + _LOGGER.exception("Could not verify %s against the pinned authority", host) + chains = False + if chains: + return {CONF_HOST: host} + + _LOGGER.warning( + "Refusing to move the entry for panel %s to %s: the certificate served there on " + "port %s does not chain to the authority this entry is pinned to (SHA-256 %s). " + "If the panel really has moved, use Reconfigure to move this entry", + self.unique_id, + host, + tls_port, + pem_fingerprint_or_reason(ca_pem), + ) + return None + + async def _async_hassio_host_update(self, host: str, port: int, serial: str) -> dict[str, Any]: + """Return the host keys a Supervisor discovery should carry, usually none. + + An add-on restart republishes the container hostname it answers on, + which is not a claim that the panel has moved and is not the address the + user configured. Overwriting the stored host with it broke a working + entry seconds after creation: the panel's certificate names the address + the user typed and does not name the add-on's hostname, so every + connection afterwards failed verification against the entry's own pin. + + The question that decides it is the same one the entry itself asks on + every setup -- does this address still reach this panel -- so it is + asked directly rather than by proxy: probe the configured host on the + *newly discovered* port, because a reallocated port on the same machine + is exactly the case the ports being unguarded exists for. A v2 answer + carrying this serial means the entry is fine where it is and is left + alone. + + Deliberately not `_async_host_update`'s pinned-address check. That one + decides whether an unauthenticated claim may move an entry, and answers + "no" by refusing the move outright; this one decides whether there is + anything to move at all, and its evidence is the panel the entry already + has rather than the candidate. An add-on that has genuinely taken the + panel's place still moves the entry, which is the Supervisor route's + documented trade. + + `CONF_EBUS_BROKER_HOST` moves with `CONF_HOST` or not at all. The two + are the same address for the same panel -- `async_setup_entry` dials the + configured host and only logs the broker's own advertisement -- and + moving one without the other left the entry describing two different + machines. + """ + entry = self.hass.config_entries.async_entry_for_domain_unique_id(self.handler, serial) + if entry is None: + return {CONF_HOST: host} + + configured = str(entry.data.get(CONF_HOST, "")) + if not configured or configured == host: + return {CONF_HOST: host} + + # The identity read below decides whether the entry moves, and a pinned + # entry owes that read its own transport — since 3.4.1 the library's + # plaintext warning is silent for this endpoint, so nothing else would + # say the serial arrived unverified. The TLS port follows the same rule + # as the plaintext one: the add-on's newly published port when it + # published one, because a reallocated port is the very case this probe + # exists for, else the transport's own. 80 is the one value the library + # refuses under a context, so it falls back to the TLS default rather + # than raising out of a background discovery. + transport = panel_rest_transport(self.hass, entry.data) + if transport.ssl_context is not None: + probe_port = self._https_port if self._https_port_known else transport.port + if probe_port == 80: + probe_port = DEFAULT_HTTPS_PORT + detection = await detect_api_version( + configured, port=probe_port, ssl_context=transport.ssl_context + ) + else: + detection = await detect_api_version( + configured, port=port, httpx_client=get_async_client(self.hass, verify_ssl=False) + ) + reached = ( + detection.api_version == "v2" + and detection.status_info is not None + and detection.status_info.serial_number == serial + ) + if reached: + _LOGGER.debug( + "Supervisor discovery published host %s, but panel %s still answers at its " + "configured host %s on port %s. Taking the ports and leaving the host alone", + host, + serial, + configured, + port, + ) + return {} + + _LOGGER.info( + "Moving the entry for panel %s from %s to %s: the configured host no longer " + "answers for this panel on port %s", + serial, + configured, + host, + port, + ) + return {CONF_HOST: host, CONF_EBUS_BROKER_HOST: host} async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> ConfigFlowResult: """Handle a flow initiated by zeroconf discovery.""" @@ -186,13 +487,19 @@ async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> Conf # v2 panels discovered via eBus / secure-mqtt service types # Read optional httpPort from mDNS TXT records (non-standard port) props = discovery_info.properties or {} - http_port_str = props.get("httpPort", props.get("httpport", "")) - try: - http_port = int(http_port_str) if http_port_str else 80 - except (ValueError, TypeError): - http_port = 80 + discovered_http_port = _discovered_port(props, "httpPort", "httpport") + http_port = 80 if discovered_http_port is None else discovered_http_port self._http_port = http_port + # A panel that moved its HTTP port has usually moved its TLS port + # too, and it is the only party that knows where. Taking the + # published value here is what keeps the reverse-proxy question off + # the screen of a user whose panel already answered it. + https_port = _discovered_port(props, "httpsPort", "httpsport") + if https_port is not None: + self._https_port = https_port + self._https_port_known = True + detection = await detect_api_version( discovery_info.host, port=http_port, @@ -225,15 +532,42 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> ConfigFl on different HTTP ports (for example the SPAN Panel Simulator add-on). Deduplicate by panel serial, not by host, so each panel gets its own config entry. + + **The ports are deliberately not held to the pinned-address check** that + `async_step_zeroconf` and the by-hand re-add both go through, in + `_async_host_update`. A Supervisor discovery arrives over the + authenticated Supervisor API from an add-on the user installed, and + add-ons legitimately reallocate their own ports across restarts, so + holding the ports to the stored values would freeze the entry against + its own add-on. They go straight into the update below. + + The *host* is a different question and is not carried by that argument. + An add-on reallocating a port has not moved, and the address the user + configured is the one their panel's certificate names; overwriting it + with the add-on's own container hostname broke a working entry seconds + after it was created, because that hostname is not a name the panel + serves. So the host moves only when the configured one has stopped + answering for this panel -- see `_async_hassio_host_update`. See + developer.md, "The Supervisor discovery path is unguarded on ports". """ config = discovery_info.config host = str(config.get("host", "")) - port = int(config.get("port", 80)) + discovered_port = _discovered_port(config, "port") + port = 80 if discovered_port is None else discovered_port serial = str(config.get("serial", "")) if not host: return self.async_abort(reason="no_host") + # The add-on serves TLS on a port it allocated and publishes here. + # Nobody else can tell the flow what it is, so being told is the + # difference between a silent setup and a prompt for a number the user + # would have to go read out of the add-on log. + https_port = _discovered_port(config, "https_port", "httpsPort") + if https_port is not None: + self._https_port = https_port + self._https_port_known = True + # Validate panel is reachable and v2 self._http_port = port detection = await detect_api_version( @@ -247,9 +581,16 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> ConfigFl if not panel_serial: return self.async_abort(reason="no_serial") - # Dedup by serial — multiple panels may share the same host IP + # Dedup by serial — multiple panels may share the same host IP. The + # ports go into the update because the add-on reallocates them across + # restarts: an entry left pointing at last run's ports is an entry that + # cannot reach its panel. await self.async_set_unique_id(panel_serial) - self._abort_if_unique_id_configured(updates={CONF_HOST: host, CONF_HTTP_PORT: port}) + updates: dict[str, Any] = {CONF_HTTP_PORT: port} + if self._https_port_known: + updates[CONF_HTTPS_PORT] = self._https_port + updates.update(await self._async_hassio_host_update(host, port, panel_serial)) + self._abort_if_unique_id_configured(updates=updates) # Set up flow — same path as v2 zeroconf discovery self.api_version = "v2" @@ -274,7 +615,9 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con # Store precision settings from user input for later flow steps. self.power_display_precision = user_input.get(POWER_DISPLAY_PRECISION, 0) self.energy_display_precision = user_input.get(ENERGY_DISPLAY_PRECISION, 2) - self._enable_dip_compensation = user_input.get(ENABLE_ENERGY_DIP_COMPENSATION, True) + self._enable_dip_compensation = user_input.get( + ENABLE_ENERGY_DIP_COMPENSATION, NEW_INSTALL_ENERGY_DIP_COMPENSATION + ) _LOGGER.debug( "CONFIG_INPUT_DEBUG: User input precision - power: %s, energy: %s, full input: %s", @@ -334,7 +677,9 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con } self._is_flow_setup = True await self.ensure_not_already_configured(raise_on_progress=False) - return await self.async_step_choose_v2_auth() + # The CA first, then authentication over it. Registration is the one + # exchange that carries the passphrase and returns both credentials. + return await self.async_step_panel_ca_start() # Non-v2 panels are not supported return self.async_abort(reason="v1_not_supported") @@ -343,12 +688,24 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowRe """Handle a flow initiated by re-auth.""" host = entry_data[CONF_HOST] self._http_port = int(entry_data.get(CONF_HTTP_PORT, 80)) + # Only a port that could actually be read counts as known. `as_port` + # would substitute the default for an unreadable stored value and this + # flow would then record "somebody authoritative told us" about a number + # nobody supplied, skipping the one question that could correct it. + stored_https_port = port_or_none(entry_data.get(CONF_HTTPS_PORT)) + if stored_https_port is not None: + self._https_port = stored_https_port + self._https_port_known = True + # This entry may already have a pinned CA, and a reauth is precisely + # where fresh credentials cross the wire. Adopted before the first call. + self._rest_transport = panel_rest_transport(self.hass, entry_data) # Detect current API version of the panel detection = await detect_api_version( host, - port=self._http_port, - httpx_client=get_async_client(self.hass, verify_ssl=False), + port=self._rest_transport.port, + httpx_client=self._rest_transport.httpx_client, + ssl_context=self._rest_transport.ssl_context, ) if detection.probe_failed: return self.async_abort(reason="cannot_connect") @@ -363,6 +720,15 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowRe self.trigger_flow_type = TriggerFlowType.UPDATE_ENTRY self._is_flow_setup = True self.context["title_placeholders"] = {"host": host} + if self._rest_transport.ssl_context is None: + # Nothing pinned, and the next exchange carries the passphrase + # and returns the broker password. Entries arrive here unpinned + # by three routes — an entry still recorded as v1, a v2 entry + # missing its broker credentials, and one whose deferred fetch + # failed — and all three fail setup before the deferred fetch at + # setup can settle them, so reauth is where they get an anchor + # or do not proceed. The CA step returns to `reauth_confirm`. + return await self.async_step_panel_ca_start() return await self.async_step_reauth_confirm() # Non-v2 panels are not supported @@ -395,7 +761,7 @@ async def async_step_confirm_discovery( }, ) - return await self.async_step_choose_v2_auth() + return await self.async_step_panel_ca_start() async def async_step_choose_v2_auth( self, user_input: dict[str, Any] | None = None @@ -424,13 +790,18 @@ async def async_step_auth_proximity_confirm( if not self.host: return self.async_abort(reason="host_not_set") + transport = self._require_transport() # Check proximityProven before calling register_v2 (avoids 15-min block). # On older firmware the field is None — fall through to register_v2 directly. + # Over the pin, like the registration it gates: this probe is what + # decides whether the door challenge is believed, and a plaintext answer + # is one anything on the path can write. try: detection = await detect_api_version( - self.host, - port=self._http_port, - httpx_client=get_async_client(self.hass, verify_ssl=False), + self._rest_host, + port=transport.port, + httpx_client=transport.httpx_client, + ssl_context=transport.ssl_context, ) except (SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError) as err: _LOGGER.warning("Failed to detect API version during proximity auth: %s", err) @@ -443,13 +814,32 @@ async def async_step_auth_proximity_confirm( return await self.async_step_auth_proximity() try: - result = await validate_v2_proximity(self.hass, self.host, port=self._http_port) + result = await validate_v2_proximity(self._rest_host, transport) except (SpanPanelAuthError, SpanPanelConnectionError): return await self.async_step_auth_proximity() + except SpanPanelPassphraseUnavailableError: + return self._async_show_proximity_error("passphrase_unavailable") + except (SpanPanelServerError, SpanPanelTimeoutError): + return self._async_show_proximity_error("panel_not_ready") - self._store_v2_auth_result(result, passphrase="") # nosec B106 + self._store_v2_auth_result(result) return await self._async_finalize_v2_auth() + def _async_show_proximity_error(self, reason: str) -> ConfigFlowResult: + """Show why a proven proximity still did not register, with a retry. + + A form rather than the instruction menu, because a menu cannot carry an + error and these two are not the user's doing: returning to "open the + door three times" would suggest the challenge failed. Submitting the + form re-enters `async_step_auth_proximity_confirm`, which checks the + door challenge again before registering. + """ + return self.async_show_form( + step_id="auth_proximity_confirm", + data_schema=vol.Schema({}), + errors={"base": reason}, + ) + async def async_step_auth_passphrase( self, user_input: dict[str, Any] | None = None, @@ -474,7 +864,7 @@ async def async_step_auth_passphrase( try: result = await validate_v2_passphrase( - self.hass, self.host, passphrase, port=self._http_port + self._rest_host, passphrase, self._require_transport() ) except SpanPanelAuthError: return self.async_show_form( @@ -482,6 +872,20 @@ async def async_step_auth_passphrase( data_schema=STEP_AUTH_PASSPHRASE_DATA_SCHEMA, errors={"base": "invalid_auth"}, ) + except SpanPanelPassphraseUnavailableError: + # Not `invalid_auth`: the panel could not read its own passphrase, + # so the one the user typed was never compared and may be right. + return self.async_show_form( + step_id="auth_passphrase", + data_schema=STEP_AUTH_PASSPHRASE_DATA_SCHEMA, + errors={"base": "passphrase_unavailable"}, + ) + except (SpanPanelServerError, SpanPanelTimeoutError): + return self.async_show_form( + step_id="auth_passphrase", + data_schema=STEP_AUTH_PASSPHRASE_DATA_SCHEMA, + errors={"base": "panel_not_ready"}, + ) except SpanPanelConnectionError: return self.async_show_form( step_id="auth_passphrase", @@ -489,28 +893,53 @@ async def async_step_auth_passphrase( errors={"base": "cannot_connect"}, ) - self._store_v2_auth_result(result, passphrase) + self._store_v2_auth_result(result) return await self._async_finalize_v2_auth() - def _store_v2_auth_result(self, result: V2AuthResponse, passphrase: str) -> None: - """Store v2 auth credentials from registration result.""" + def _store_v2_auth_result(self, result: V2AuthResponse) -> None: + """Store v2 auth credentials from registration result. + + The passphrase that produced this result is deliberately not kept. It is + an input to registration and nothing afterwards reads it, so holding it + only widens what a flow in progress has in memory. + """ self.access_token = result.access_token self._v2_broker_host = result.ebus_broker_host self._v2_broker_port = result.ebus_broker_mqtts_port self._v2_broker_username = result.ebus_broker_username self._v2_broker_password = result.ebus_broker_password - self._v2_passphrase = passphrase self._v2_panel_serial = result.serial_number async def _async_finalize_v2_auth(self) -> ConfigFlowResult: - """Route to appropriate next step after successful v2 auth.""" + """Route to appropriate next step after successful v2 auth. + + Registration is the one thing that can make the panel name a host it + does not name yet, so it is the only route allowed to persist a host + that is currently unverified. Every other route here writes `self.host` + into an entry pinned to this anchor, and writing one the anchor rejects + produces an entry that cannot reach its own panel — a single-label name + that a search domain resolves, or an FQDN this flow bootstrapped over + the address for and is not going on to register. + """ + # If host is an FQDN, register it with the panel for TLS cert SAN inclusion + installing = self.trigger_flow_type != TriggerFlowType.UPDATE_ENTRY + if installing and self.host and is_fqdn(self.host): + return await self.async_step_register_fqdn() + + if self._bootstrap_host is not None: + _LOGGER.warning( + "Panel %s does not name %s in the certificate it serves, and nothing in " + "this flow will ask it to; refusing to pin an entry to a host its own " + "certificate authority rejects", + self._bootstrap_host, + self.host, + ) + return self._async_show_ca_error("ca_leaf_mismatch") + if self.trigger_flow_type == TriggerFlowType.UPDATE_ENTRY: if "entry_id" not in self.context: raise ValueError("Entry ID is missing from context") return self._update_v2_entry(self.context["entry_id"]) - # If host is an FQDN, register it with the panel for TLS cert SAN inclusion - if self.host and is_fqdn(self.host): - return await self.async_step_register_fqdn() return await self.async_step_choose_entity_naming_initial() async def async_step_register_fqdn( @@ -541,40 +970,96 @@ async def async_step_register_fqdn( return self.async_show_progress_done(next_step_id="choose_entity_naming_initial") async def _async_register_fqdn_and_wait(self) -> None: - """Register the FQDN and poll until the TLS cert includes it.""" + """Register the FQDN, wait for the panel's new leaf, then verify it. + + Three addresses matter here and they are not the same one. The request + carries the access token, so it goes over the pin -- and it is dialled by + the address the current leaf names, because the FQDN is precisely what + this call is asking the panel to add. Only once the panel reports the + regenerated certificate is the FQDN itself verified, and it is verified + on the port the entry will actually use, before anything is persisted. + """ if not self.host or not self.access_token: raise ConfigFlowError("Host and access token required for FQDN registration") - httpx_client = get_async_client(self.hass, verify_ssl=False) + transport = self._require_transport() await register_fqdn( - self.host, + self._rest_host, self.access_token, self.host, - port=self._http_port, - httpx_client=httpx_client, + port=transport.port, + httpx_client=transport.httpx_client, + ssl_context=transport.ssl_context, ) - mqtts_port = self._v2_broker_port or 8883 + mqtts_port = self._v2_broker_port or DEFAULT_MQTTS_PORT max_attempts = 30 + + # The leaf is what changes during this wait; the anchor is not. An entry + # that holds no CA of its own therefore fetches one once and keeps it, + # rather than making a fresh plaintext trust decision on each of thirty + # polls. Kept inside the loop so a fetch that fails while the panel is + # still coming back is retried rather than ending the wait. + ca_pem = transport.ca_pem for attempt in range(max_attempts): await asyncio.sleep(2) - if await check_fqdn_tls_ready( - self.hass, self.host, mqtts_port, http_port=self._http_port - ): + if ca_pem is None: + ca_pem = await async_download_ca_or_none( + self.hass, self.host, http_port=self._http_port + ) + if ca_pem is None: + continue + if await check_fqdn_tls_ready(self.host, mqtts_port, ca_pem): _LOGGER.debug( "FQDN %s found in TLS cert SAN after %d attempts", self.host, attempt + 1, ) + await self._async_verify_host_over_pin(transport) + # The panel serves the name now, so the address it was reached + # by while it did not is no longer standing in for anything. + self._bootstrap_host = None + self._fqdn_registered = True return raise ConfigFlowError(f"Timed out waiting for TLS certificate to include FQDN {self.host}") + async def _async_verify_host_over_pin(self, transport: PanelRestTransport) -> None: + """Confirm the pinned anchor validates the panel at `self.host` and the REST port. + + The readiness poll watches the broker port, which is the port the MQTT + client uses. The entry's REST calls go somewhere else -- the HTTPS port + this transport holds -- and an entry is about to be written naming the + FQDN for both. Confirming the exact pair that will be stored is what + stops registration reporting success on a combination that then fails on + the first connect after setup. + + Nothing is pinned on a reconfigure of an entry whose CA fetch never + succeeded; there is no anchor to verify against and no pin to protect, + so that entry proceeds exactly as it did before. + """ + if transport.ca_pem is None: + return + if not await async_leaf_chains_to_ca(self.host or "", transport.port, transport.ca_pem): + raise ConfigFlowError( + f"Panel registered {self.host} but does not serve it on port " + f"{transport.port} under the pinned certificate authority" + ) + async def async_step_fqdn_failed( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle FQDN registration failure — user may continue without it.""" + """Handle FQDN registration failure — user may continue over the panel's address. + + Continuing keeps the panel, not the name. The certificate does not name + the domain, because getting it named is exactly what failed, so an entry + recording the domain would be pinned to a host its own anchor rejects + and would fail on its first connect. It is set up over the address the + certificate does name instead, and the domain can be tried again from + Reconfigure once whatever blocked the registration is fixed. + """ if user_input is not None: + self._fall_back_to_the_bootstrap_address() return await self.async_step_choose_entity_naming_initial() return self.async_show_form( step_id="fqdn_failed", @@ -582,6 +1067,197 @@ async def async_step_fqdn_failed( errors={"base": "fqdn_registration_failed"}, ) + def _fall_back_to_the_bootstrap_address(self) -> None: + """Adopt the address the panel was reached by as the host to record. + + Only ever called where registration has already failed. `_bootstrap_host` + is set exactly when `self.host` is a name the pinned leaf does not name, + so this is the point where an unusable name is traded for the address + that got this far — and the invariant is restored, because the host + being recorded is now one the anchor accepts. + + That restoration depends on there being an address to trade for. On the + moved-panel path there is not: the leaf names nothing this flow could + reach the panel by, so `_bootstrap_host` is None and this returns having + changed nothing. A caller must not read that as permission to store + `self.host` — see `async_step_reconfigure_fqdn_failed`, which refuses + rather than record a name the panel does not serve. + """ + if self._bootstrap_host is None: + return + _LOGGER.warning( + "Could not get panel %s to serve %s, so the entry is set up over %s instead; " + "the certificate the panel serves does not name %s", + self._bootstrap_host, + self.host, + self._bootstrap_host, + self.host, + ) + self.host = self._bootstrap_host + self._bootstrap_host = None + + async def async_step_panel_ca_start( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Enter CA acquisition, asking for the HTTPS port only when nothing knows it. + + The port is prompted for on an install that already moved the HTTP port, + because that is the install most likely to be reaching the panel through + something that also moved the TLS one. Asking everybody would put a + question about reverse proxies in front of users who have none. + + Discovery outranks the prompt. A panel that published its TLS port has + already answered the question, and better than the user could: the + add-on allocates that port per panel and reallocates it across restarts, + so the only correct answer is the one it just gave. + """ + if self._http_port == 80 or self._https_port_known: + return await self.async_step_panel_ca() + return await self.async_step_panel_https_port() + + async def async_step_panel_https_port( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Collect the port the panel serves TLS on. + + 80 is refused here, at the form, because the library refuses it at + every call it would later anchor — a stored plaintext port under a pin + fails each setup with nothing the user can do but reconfigure. Refuse + it while the person who typed it is present. + """ + schema = vol.Schema( + { + vol.Required(CONF_HTTPS_PORT, default=DEFAULT_HTTPS_PORT): vol.All( + vol.Coerce(int), vol.Range(min=1, max=65535) + ), + } + ) + if user_input is None: + return self.async_show_form(step_id="panel_https_port", data_schema=schema) + port = int(user_input[CONF_HTTPS_PORT]) + if port == 80: + return self.async_show_form( + step_id="panel_https_port", + data_schema=schema, + errors={"base": "https_port_is_plaintext"}, + ) + self._https_port = port + return await self.async_step_panel_ca() + + async def async_step_panel_ca( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Fetch the panel's CA, prove it signs what the panel serves, and offer it. + + This runs **before** authentication, which is the whole point of its + position in the flow: registration is the exchange that carries the + passphrase and returns both the access token and the broker password, and + it is the message most worth protecting. Everything after this step goes + over the anchor accepted here. + + `user_input` is ignored. The step does its work on entry, and a submitted + error form re-enters it — which is exactly the retry semantics wanted, + since the failures are transient network ones. + """ + if not self.host: + return self.async_abort(reason="host_not_set") + + try: + ca_pem = await async_fetch_panel_ca(self.hass, self.host, http_port=self._http_port) + except ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + ) as err: + _LOGGER.warning("Could not fetch the CA from panel %s: %s", self.host, err) + return self._async_show_ca_error("ca_unavailable") + + # A CA that cannot validate the certificate the panel actually serves is + # not the panel's CA. Checked before the fingerprint is shown, so the + # user is never asked to accept a value that already failed. Hostname + # verification stays on throughout -- what the chooser varies is the + # address the panel is dialled by, never whether the name is checked. + if not await self._async_choose_bootstrap_host(ca_pem, self._https_port): + _LOGGER.warning( + "The certificate served by %s on port %s does not chain to the CA the " + "panel published; refusing to pin it", + self.host, + self._https_port, + ) + return self._async_show_ca_error("ca_leaf_mismatch") + + try: + fingerprint = ca_fingerprint(ca_pem) + except SpanPanelValidationError as err: + _LOGGER.warning("Panel %s served an unreadable CA: %s", self.host, err) + return self._async_show_ca_error("ca_unavailable") + + _LOGGER.info( + "Pinned the certificate authority published by panel %s (SHA-256 %s)", + self.host, + fingerprint, + ) + self._panel_ca_pem = ca_pem + return await self._async_adopt_anchor_and_authenticate() + + def _async_show_ca_error(self, reason: str) -> ConfigFlowResult: + """Re-show the CA step with an actionable error. + + Deliberately not a way past. There is no "carry on unpinned" option here, + because the next thing this flow does is send the panel passphrase: an + opt-out would quietly restore the plaintext credential exchange that + pinning before registration exists to remove, at the moment a user is + least likely to weigh it. + + Submitting the form re-enters `async_step_panel_ca`, so a panel that was + briefly unreachable is one click away from working. + """ + return self.async_show_form( + step_id="panel_ca", + data_schema=vol.Schema({}), + errors={"base": reason}, + ) + + async def _async_adopt_anchor_and_authenticate(self) -> ConfigFlowResult: + """Adopt the pinned anchor, then authenticate over it. + + Not a step, and deliberately not a confirmation. Pinning happens either + way — the protection is that registration runs over the anchor, not that + somebody pressed Submit — and at first contact there is nothing to check + the fingerprint against: SPAN publishes it nowhere, so a screen asking a + user to accept it offers a decision they cannot make and an alarm they + cannot act on. + + The fingerprint is surfaced where it is actionable instead: in + diagnostics, and in the Repair raised if it ever changes, where there is + a prior value to compare against and a real decision to take. It is also + logged here, so the value is recoverable from the moment of the install + that pinned it. + """ + if self._panel_ca_pem is None: + return await self.async_step_panel_ca() + + try: + context = build_panel_ssl_context(self._panel_ca_pem) + except (ssl.SSLError, ValueError) as err: + # Unreachable in practice — the leaf check above already built a + # context from this PEM. Handled rather than assumed, because the + # alternative to raising here is registering in plaintext. + _LOGGER.warning("Accepted CA from %s could not be used: %s", self.host, err) + return self._async_show_ca_error("ca_unavailable") + + self._rest_transport = PanelRestTransport( + port=self._https_port, + ssl_context=context, + httpx_client=None, + ca_pem=self._panel_ca_pem, + ) + if self.trigger_flow_type == TriggerFlowType.UPDATE_ENTRY: + # Same two methods, but the reauth wording: this user is not setting + # a panel up, they are being asked why an entry stopped working. + return await self.async_step_reauth_confirm() + return await self.async_step_choose_v2_auth() + def create_new_entry( self, host: str, serial_number: str, access_token: str ) -> ConfigFlowResult: @@ -610,13 +1286,26 @@ def create_new_entry( CONF_EBUS_BROKER_PORT: self._v2_broker_port, CONF_EBUS_BROKER_USERNAME: self._v2_broker_username, CONF_EBUS_BROKER_PASSWORD: self._v2_broker_password, - CONF_HOP_PASSPHRASE: self._v2_passphrase, CONF_PANEL_SERIAL: self._v2_panel_serial, } if self._http_port != 80: entry_data[CONF_HTTP_PORT] = self._http_port - if is_fqdn(host): + if self._https_port != DEFAULT_HTTPS_PORT: + entry_data[CONF_HTTPS_PORT] = self._https_port + if self._panel_ca_pem is None: + # Unreachable: the flow cannot reach entity naming without passing + # the CA confirmation, and authentication ran over the anchor + # accepted there. Raised rather than defaulted, because the only + # other option is to write an unpinned entry whose credentials were + # nonetheless exchanged over TLS — a state nothing else expects. + raise ConfigFlowError("Reached entry creation with no pinned panel CA") + entry_data[CONF_PANEL_CA_PEM] = self._panel_ca_pem + if self._fqdn_registered: + # Recorded from what the registration did, not from what the host + # looks like. `is_fqdn(host)` was true on the path where + # registration had just failed and the user chose to continue, so + # the entry claimed a name the panel had never accepted. entry_data[CONF_REGISTERED_FQDN] = host return self.async_create_entry( @@ -628,6 +1317,10 @@ def create_new_entry( POWER_DISPLAY_PRECISION: self.power_display_precision, ENERGY_DISPLAY_PRECISION: self.energy_display_precision, ENABLE_ENERGY_DIP_COMPENSATION: self._enable_dip_compensation, + # Stored rather than left to the default, so a release from + # before that default existed reads this entry the same way + # instead of treating the absent key as on. + ALLOW_CONTEXTLESS_CONTROL: DEFAULT_ALLOW_CONTEXTLESS_CONTROL, }, ) @@ -645,15 +1338,54 @@ def _update_v2_entry(self, entry_id: str) -> ConfigFlowResult: updated_data[CONF_EBUS_BROKER_PORT] = self._v2_broker_port updated_data[CONF_EBUS_BROKER_USERNAME] = self._v2_broker_username updated_data[CONF_EBUS_BROKER_PASSWORD] = self._v2_broker_password - updated_data[CONF_HOP_PASSPHRASE] = self._v2_passphrase updated_data[CONF_PANEL_SERIAL] = self._v2_panel_serial + # A reauth on an entry that predates v7 is also the moment to drop the + # passphrase it still carries; the migration only sees entries at setup. + updated_data.pop(CONF_HOP_PASSPHRASE, None) if self._http_port != 80: updated_data[CONF_HTTP_PORT] = self._http_port + if self._panel_ca_pem is not None: + # The entry reached this reauth with nothing usable pinned, so the + # flow acquired an anchor before sending the passphrase. Keeping it + # is what stops the next reauth — and every connect in between — + # from going back to a plaintext CA fetch. `_panel_ca_pem` is None + # only when the entry already carried a usable pin, which is left + # exactly as it was. + self._log_replaced_anchor(entry.data.get(CONF_PANEL_CA_PEM)) + updated_data[CONF_PANEL_CA_PEM] = self._panel_ca_pem + updated_data.pop(PANEL_CA_PENDING, None) + if self._https_port != DEFAULT_HTTPS_PORT: + updated_data[CONF_HTTPS_PORT] = self._https_port self.hass.config_entries.async_update_entry(entry, data=updated_data) self.hass.async_create_task(self.hass.config_entries.async_reload(entry_id)) return self.async_abort(reason="reauth_successful") + def _log_replaced_anchor(self, previous_pem: object) -> None: + """Say so, at WARNING, when a reauth overwrites a stored trust anchor. + + The entry only reaches here unpinned, and one of the ways it does is a + stored PEM this system can no longer load. Replacing that is silently + changing what the entry trusts, which is the one change a user needs a + record of: the fingerprint below is the value to compare against the one + logged at install, and the only trace of it if the old PEM is gone. + + A first pin is not a replacement and says nothing here -- the install + log already named it. + """ + if not previous_pem or previous_pem == self._panel_ca_pem or self._panel_ca_pem is None: + return + try: + fingerprint = ca_fingerprint(self._panel_ca_pem) + except SpanPanelValidationError: # pragma: no cover - already fingerprinted once + fingerprint = "unreadable" + _LOGGER.warning( + "Replaced the stored certificate authority for panel %s during reauthentication; " + "the entry is now pinned to SHA-256 %s", + self.host, + fingerprint, + ) + def get_unique_device_name(self, base_name: str) -> str: """Return a unique device name based on existing config entry titles.""" existing_names = {entry.title for entry in self.hass.config_entries.async_entries(DOMAIN)} @@ -702,6 +1434,40 @@ async def async_step_choose_entity_naming_initial( raise ConfigFlowError("Missing required parameters during entry creation") return self.create_new_entry(self.host, self.serial_number, self.access_token) + def _reconfigure_form( + self, entry: config_entries.ConfigEntry, host: str, error: str | None = None + ) -> ConfigFlowResult: + """Show the reconfigure form, with the HTTPS port on it exactly when the entry is pinned. + + A pinned entry's REST calls ride the HTTPS port, and one population was + never asked for it: TLS behind a port forward with the HTTP port still + on 80, which the CA step's own gating skips. Reconfigure is the remedy + every refusal points at, so this is where the port has to be settable. + An unpinned entry keeps the host-only form — it has no TLS transport + for the answer to configure. + """ + schema: dict[Any, Any] = {vol.Required(CONF_HOST, default=host): str} + if entry.data.get(CONF_PANEL_CA_PEM): + default = ( + self._reconfigure_https_port + if self._reconfigure_https_port is not None + else as_port(entry.data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + ) + schema[vol.Required(CONF_HTTPS_PORT, default=default)] = vol.All( + vol.Coerce(int), vol.Range(min=1, max=65535) + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema(schema), + errors={"base": error} if error else None, + ) + + def _reconfigured_transport(self, transport: PanelRestTransport) -> PanelRestTransport: + """Apply the submitted HTTPS port to a transport built from stored data.""" + if self._reconfigure_https_port is None or transport.ssl_context is None: + return transport + return replace(transport, port=self._reconfigure_https_port) + async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -710,26 +1476,103 @@ async def async_step_reconfigure( if user_input is None: current_host = reconfigure_entry.data.get(CONF_HOST, "") - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=current_host): str}), - ) + return self._reconfigure_form(reconfigure_entry, str(current_host)) host = user_input[CONF_HOST].strip() if not host: - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=""): str}), - errors={"base": "host_required"}, - ) + return self._reconfigure_form(reconfigure_entry, "", "host_required") + + if reconfigure_entry.data.get(CONF_PANEL_CA_PEM): + submitted = port_or_none(user_input.get(CONF_HTTPS_PORT)) + if submitted is None: + submitted = as_port(reconfigure_entry.data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + if submitted == 80: + # The plaintext default. The library refuses this combination at + # every call, so storing it would fail every later setup; + # refused here instead, where the person who typed it is + # present. See `_build_url` in span-panel-api. + return self._reconfigure_form(reconfigure_entry, host, "https_port_is_plaintext") + self._reconfigure_https_port = submitted # Validate the host is reachable and is a v2 panel - http_port = int(reconfigure_entry.data.get(CONF_HTTP_PORT, 80)) + http_port = as_port(reconfigure_entry.data.get(CONF_HTTP_PORT), 80) + # Fail closed rather than downgrade, as `rotate_credentials` does and + # for the same reason: this flow carries the entry's access token to the + # panel -- `delete_fqdn` on one branch, `register_fqdn` on the other -- + # and `_async_verify_host_over_pin` has nothing to check the new name + # against without an anchor. A stored PEM that cannot be read would + # otherwise turn a pinned entry's reconfigure into a plaintext one that + # also skips its own host check, which is the pin undone twice over. The + # CA-changed repair and Reauthenticate are the routes back. + try: + self._rest_transport = panel_rest_transport( + self.hass, reconfigure_entry.data, allow_plaintext_fallback=False + ) + except PanelCaUnusableError as err: + _LOGGER.warning( + "The certificate authority stored for panel %s cannot be read (%s); " + "refusing to reconfigure it over an unverified connection", + reconfigure_entry.title, + err, + ) + return self._reconfigure_form(reconfigure_entry, host, "ca_unusable") + self._rest_transport = self._reconfigured_transport(self._rest_transport) + # The new host may be one the panel's certificate does not name -- a new + # DHCP lease, or an FQDN it has not been told about yet -- and this entry + # is pinned. Ask what is actually true of it before deciding, because + # "not your panel" and "your panel, under a name it has not caught up + # with" have opposite remedies and used to be the same answer. + self.host = host + self._leaf_names_host = True + pinned_pem = self._rest_transport.ca_pem + if pinned_pem is not None: + if await self._async_choose_bootstrap_host(pinned_pem, self._rest_transport.port): + # Something under the pin both validates and names itself. The + # host is storable only when that something was the host: a + # substituted address reaches the panel but says nothing about + # the name the user asked to store. + self._leaf_names_host = self._bootstrap_host is None + else: + # Nothing the anchor both validates and names, which used to end + # the flow here. Three different things produce that, and only + # one of them is the panel being absent. + verdict = await async_leaf_verdict(host, self._rest_transport.port, pinned_pem) + if verdict is LeafVerdict.UNREACHABLE: + return self._reconfigure_form(reconfigure_entry, host, "cannot_connect") + if verdict is not LeafVerdict.NAME_MISMATCH: + _LOGGER.warning( + "Refusing to reconfigure panel %s to %s: the certificate served there on " + "port %s does not chain to the authority this entry is pinned to " + "(SHA-256 %s)", + reconfigure_entry.title, + host, + self._rest_transport.port, + pem_fingerprint_or_reason(pinned_pem), + ) + return self._reconfigure_form(reconfigure_entry, host, "ca_leaf_mismatch") + # The panel is there -- only it holds a key the pinned anchor + # signed -- but its certificate names neither this address nor + # anything this flow could reach it by. Every call over the + # strict transport would fail and report it unreachable, so this + # flow's own calls keep the pin and drop only the name binding. + # That is what lets the FQDN branch below ask the panel to + # regenerate the certificate that fixes this. The relaxed + # transport never leaves this flow and is never stored. + self._leaf_names_host = False + self._rest_transport = self._reconfigured_transport( + panel_rest_transport( + self.hass, + reconfigure_entry.data, + allow_plaintext_fallback=False, + verify_hostname=False, + ) + ) try: detection = await detect_api_version( - host, - port=http_port, - httpx_client=get_async_client(self.hass, verify_ssl=False), + self._rest_host, + port=self._rest_transport.port, + httpx_client=self._rest_transport.httpx_client, + ssl_context=self._rest_transport.ssl_context, ) except ( ValueError, @@ -737,25 +1580,13 @@ async def async_step_reconfigure( SpanPanelTimeoutError, SpanPanelAPIError, ): - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "cannot_connect"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "cannot_connect") if detection.probe_failed: - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "cannot_connect"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "cannot_connect") if detection.api_version != "v2" or detection.status_info is None: - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), - errors={"base": "cannot_connect"}, - ) + return self._reconfigure_form(reconfigure_entry, host, "cannot_connect") # Ensure the serial number matches — prevent switching to a different panel await self.async_set_unique_id(detection.status_info.serial_number) @@ -763,24 +1594,48 @@ async def async_step_reconfigure( if is_fqdn(host): # New host is FQDN — register it (replaces any existing FQDN on the panel) - self.host = host self.access_token = str(reconfigure_entry.data.get(CONF_ACCESS_TOKEN, "")) self._http_port = http_port - self._v2_broker_port = int(reconfigure_entry.data.get(CONF_EBUS_BROKER_PORT, 8883)) + self._v2_broker_port = as_port( + reconfigure_entry.data.get(CONF_EBUS_BROKER_PORT), DEFAULT_MQTTS_PORT + ) return await self.async_step_reconfigure_register_fqdn() - # New host is not an FQDN — simple update + # New host is not an FQDN — simple update. Nothing on this branch will + # ask the panel to start naming it, so a host the pinned certificate + # does not cover is refused here rather than written into the entry. + # + # Refused even though the panel demonstrably answered here, because + # storing it would not help: this flow can relax the name binding for + # its own probe, but the coordinator and the broker cannot, so every + # connection after the flow ends would fail hostname verification + # against the same certificate. The entry would be pointed at an address + # it cannot use. The remedies are real ones and the message names them. + if not self._leaf_names_host: + _LOGGER.warning( + "Panel %s answered at %s but does not name it in the certificate it serves; " + "refusing to store an address every later connection would reject. Reconfigure " + "to an FQDN, which asks the panel to regenerate its certificate, or to the " + "panel's .local name, which its certificate already covers", + reconfigure_entry.title, + host, + ) + return self._reconfigure_form(reconfigure_entry, host, "ca_name_mismatch") + data_updates: dict[str, Any] = {CONF_HOST: host} + if self._reconfigure_https_port is not None: + data_updates[CONF_HTTPS_PORT] = self._reconfigure_https_port old_fqdn = str(reconfigure_entry.data.get(CONF_REGISTERED_FQDN, "")) if old_fqdn: # Switching from FQDN to IP — clean up old registration access_token = str(reconfigure_entry.data.get(CONF_ACCESS_TOKEN, "")) try: await delete_fqdn( - host, + self._rest_host, access_token, - port=http_port, - httpx_client=get_async_client(self.hass, verify_ssl=False), + port=self._rest_transport.port, + httpx_client=self._rest_transport.httpx_client, + ssl_context=self._rest_transport.ssl_context, ) except ( SpanPanelAPIError, @@ -828,24 +1683,56 @@ async def async_step_reconfigure_fqdn_done( ) -> ConfigFlowResult: """Complete reconfiguration after successful FQDN registration.""" reconfigure_entry = self._get_reconfigure_entry() + data_updates: dict[str, Any] = { + CONF_HOST: self.host or "", + CONF_REGISTERED_FQDN: self.host or "", + } + if self._reconfigure_https_port is not None: + data_updates[CONF_HTTPS_PORT] = self._reconfigure_https_port return self.async_update_reload_and_abort( reconfigure_entry, - data_updates={ - CONF_HOST: self.host or "", - CONF_REGISTERED_FQDN: self.host or "", - }, + data_updates=data_updates, ) async def async_step_reconfigure_fqdn_failed( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle FQDN registration failure during reconfigure.""" + """Handle FQDN registration failure during reconfigure. + + Same trade as on install: the panel is kept and the name is not. The + entry's existing `CONF_REGISTERED_FQDN` is deliberately left alone — + whatever the panel had registered before this attempt, it still has, and + that is what the next reconfigure needs in order to clean it up. + """ if user_input is not None: + if not self._leaf_names_host and self._bootstrap_host is None: + # There is nothing to continue to. This is the moved-panel path: + # the certificate names neither the name asked for nor any + # address this flow could reach the panel by, and registration — + # the one thing that would have made the panel serve the name — + # is what just failed. Falling back has no address to fall back + # to, so continuing would store the unserved name and report + # success, stranding every later connection on the hostname + # check this flow relaxed only for itself. + _LOGGER.warning( + "Registration failed and panel %s serves a certificate naming neither %s " + "nor an address this flow reached it by, so there is no host to store. " + "Try the panel's .local name, which its certificate already covers", + self._get_reconfigure_entry().title, + self.host, + ) + return self._reconfigure_form( + self._get_reconfigure_entry(), self.host or "", "ca_name_mismatch" + ) # User chose to continue anyway — update host without FQDN registration + self._fall_back_to_the_bootstrap_address() reconfigure_entry = self._get_reconfigure_entry() + data_updates: dict[str, Any] = {CONF_HOST: self.host or ""} + if self._reconfigure_https_port is not None: + data_updates[CONF_HTTPS_PORT] = self._reconfigure_https_port return self.async_update_reload_and_abort( reconfigure_entry, - data_updates={CONF_HOST: self.host or ""}, + data_updates=data_updates, ) return self.async_show_form( step_id="reconfigure_fqdn_failed", diff --git a/custom_components/span_panel/config_flow_options.py b/custom_components/span_panel/config_flow_options.py index 024af7b3..10de7cff 100644 --- a/custom_components/span_panel/config_flow_options.py +++ b/custom_components/span_panel/config_flow_options.py @@ -5,9 +5,15 @@ from typing import Any from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) import voluptuous as vol from .const import ( + DEFAULT_ENERGY_DIP_COMPENSATION, DEFAULT_SNAPSHOT_INTERVAL, ENABLE_CIRCUIT_NET_ENERGY_SENSORS, ENABLE_ENERGY_DIP_COMPENSATION, @@ -19,8 +25,17 @@ USE_DEVICE_PREFIX, EntityNamingPattern, ) +from .control_gate import ( + DEFAULT_ALLOW_CONTEXTLESS_CONTROL, + DEFAULT_RELAY_DEBOUNCE_SECONDS, + ControlMode, +) from .options import ( + ALLOW_CONTEXTLESS_CONTROL, + CONTROL_LOCK_TIMEOUT, + CONTROL_MODE, ENERGY_REPORTING_GRACE_PERIOD, + RELAY_DEBOUNCE_SECONDS, SNAPSHOT_UPDATE_INTERVAL, ) @@ -36,6 +51,22 @@ vol.Optional(ENABLE_UNMAPPED_CIRCUIT_SENSORS): bool, vol.Optional(ENERGY_REPORTING_GRACE_PERIOD): vol.All(int, vol.Range(min=0, max=60)), vol.Optional(ENABLE_ENERGY_DIP_COMPENSATION): bool, + # A SelectSelector rather than `vol.In`, so the three choices are + # translated from `selector.control_mode` rather than shown as raw + # option values. + vol.Optional(CONTROL_MODE): SelectSelector( + SelectSelectorConfig( + options=[mode.value for mode in ControlMode], + translation_key=CONTROL_MODE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(ALLOW_CONTEXTLESS_CONTROL): bool, + # Minutes. `0` means the lock stays armed until somebody disarms it; a + # negative value turns the feature off, which is how "no lock entity" + # is expressed without a second option. + vol.Optional(CONTROL_LOCK_TIMEOUT): vol.All(vol.Coerce(float), vol.Range(min=-1, max=1440)), + vol.Optional(RELAY_DEBOUNCE_SECONDS): vol.All(vol.Coerce(float), vol.Range(min=0, max=60)), } ) @@ -72,7 +103,20 @@ def get_general_options_defaults( ), ENERGY_REPORTING_GRACE_PERIOD: config_entry.options.get(ENERGY_REPORTING_GRACE_PERIOD, 15), ENABLE_ENERGY_DIP_COMPENSATION: config_entry.options.get( - ENABLE_ENERGY_DIP_COMPENSATION, True + ENABLE_ENERGY_DIP_COMPENSATION, DEFAULT_ENERGY_DIP_COMPENSATION + ), + # Every default below reproduces what an entry already does. A silent + # tightening on upgrade would break a household's automations with an + # error the user cannot diagnose from the entity. Contextless control + # can default to off only because migration 7.2 stored the old `True` + # in every entry that predates it. + CONTROL_MODE: config_entry.options.get(CONTROL_MODE, ControlMode.ALL_USERS.value), + ALLOW_CONTEXTLESS_CONTROL: config_entry.options.get( + ALLOW_CONTEXTLESS_CONTROL, DEFAULT_ALLOW_CONTEXTLESS_CONTROL + ), + CONTROL_LOCK_TIMEOUT: config_entry.options.get(CONTROL_LOCK_TIMEOUT, -1), + RELAY_DEBOUNCE_SECONDS: config_entry.options.get( + RELAY_DEBOUNCE_SECONDS, DEFAULT_RELAY_DEBOUNCE_SECONDS ), } diff --git a/custom_components/span_panel/config_flow_validation.py b/custom_components/span_panel/config_flow_validation.py index c5f79775..06d1001a 100644 --- a/custom_components/span_panel/config_flow_validation.py +++ b/custom_components/span_panel/config_flow_validation.py @@ -3,26 +3,39 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping +from dataclasses import dataclass +from enum import StrEnum import ipaddress import logging -from pathlib import Path import socket import ssl -import tempfile from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.util.network import is_ipv4_address +import httpx from span_panel_api import ( V2AuthResponse, + build_panel_ssl_context, + ca_fingerprint, detect_api_version, download_ca_cert, + leaf_names_host, register_v2, ) from span_panel_api.exceptions import ( SpanPanelAPIError, SpanPanelConnectionError, + SpanPanelPassphraseUnavailableError, SpanPanelTimeoutError, + SpanPanelValidationError, +) + +from .const import ( + CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, + DEFAULT_HTTPS_PORT, ) _LOGGER = logging.getLogger(__name__) @@ -51,18 +64,71 @@ async def validate_host( async def validate_v2_passphrase( - hass: HomeAssistant, host: str, passphrase: str, port: int = 80 + host: str, + passphrase: str, + transport: PanelRestTransport, ) -> V2AuthResponse: """Validate a v2 panel passphrase and return MQTT credentials. + `transport` is required rather than defaulted, because it is what carries + the pinned CA and this is the call that sends the panel passphrase. A + default would be a plaintext transport, and a caller that forgot to pass one + would get the exchange this pinning exists to prevent without anything + saying so. Every path that reaches here has a transport by construction: the + CA step runs before authentication on initial setup, and a reauth of an + entry that arrived unpinned is routed back through that step first. + Raises: - SpanPanelAuthError: on invalid passphrase (401/403). - SpanPanelConnectionError: on network/timeout failures. + SpanPanelAuthError: on invalid passphrase (401/403, or a 422 other than + the one below). + SpanPanelPassphraseUnavailableError: the panel cannot read its own + passphrase, either refusing registration outright or (from firmware + r202639) completing it with no broker password. Not a wrong + passphrase; see `_require_broker_password`. + SpanPanelServerError: any 5xx, including the 503 a panel still starting + up answers from r202639. Retryable. + SpanPanelConnectionError: on network failures. SpanPanelTimeoutError: on request timeout. """ - client = get_async_client(hass, verify_ssl=False) - return await register_v2(host, "Home Assistant", passphrase, port=port, httpx_client=client) + return _require_broker_password( + await register_v2( + host, + "Home Assistant", + passphrase, + port=transport.port, + httpx_client=transport.httpx_client, + ssl_context=transport.ssl_context, + ) + ) + + +def _require_broker_password(result: V2AuthResponse) -> V2AuthResponse: + """Return `result` only when it carries a broker password to store. + + From firmware r202639 a panel that cannot read its passphrase still + completes registration, but with the broker password null. The library + reports that as `None` and leaves the decision to its caller; here it is a + failure, because the only thing this flow does with the result is write the + password into a config entry, and an entry holding a blank one fails every + broker connection with an authentication error that sends the user to + reauthenticate against a passphrase that was never the problem. Raised as + the same class the panel's explicit refusal uses, so one handler covers both. + """ + if not result.ebus_broker_password: + raise SpanPanelPassphraseUnavailableError( + "Panel registered the client but returned no broker password" + ) + return result + + +def is_ip_literal(host: str) -> bool: + """Whether `host` is already an address rather than a name to be resolved.""" + try: + ipaddress.ip_address(host) + except ValueError: + return False + return True def is_fqdn(host: str) -> bool: @@ -71,77 +137,513 @@ def is_fqdn(host: str) -> bool: Returns True for domain names like 'span.home.lan' or 'panel.example.com'. Returns False for IP addresses, mDNS (.local) names, and single-label hostnames. """ - if is_ipv4_address(host): - return False - try: - ipaddress.ip_address(host) - except ValueError: - pass - else: + if is_ip_literal(host): return False if host.endswith((".local", ".local.")): return False return "." in host -async def check_fqdn_tls_ready( - hass: HomeAssistant, fqdn: str, mqtts_port: int, http_port: int = 80 -) -> bool: - """Check if the MQTTS server certificate includes the FQDN in its SAN. +async def async_download_ca_or_none( + hass: HomeAssistant, fqdn: str, http_port: int = 80 +) -> str | None: + """Fetch the panel's CA over plain HTTP, or None when it cannot be had. - Downloads the CA certificate from the panel via HTTP, then attempts - a TLS connection to the MQTTS port using the FQDN as server_hostname. - If the TLS handshake succeeds with hostname verification, the panel - has regenerated its certificate to include the FQDN. + Plaintext, and only for the caller that genuinely holds no anchor -- a + reconfigure of an entry whose CA fetch never succeeded -- where this is the + only channel there is and is no weaker than the unpinned entry it belongs to. + + Separate from the check below because acquiring an anchor and testing a leaf + against one are different jobs on different schedules: the leaf changes while + the panel regenerates it, the anchor does not. Only a caller knows whether it + is polling, so only a caller can decide how often to pay for this. """ client = get_async_client(hass, verify_ssl=False) try: - ca_pem = await download_ca_cert(fqdn, port=http_port, httpx_client=client) + return await download_ca_cert(fqdn, port=http_port, httpx_client=client) except ( OSError, SpanPanelAPIError, SpanPanelConnectionError, SpanPanelTimeoutError, ): - return False + return None - loop = asyncio.get_running_loop() - def _check() -> bool: - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - ctx.check_hostname = True - ctx.verify_mode = ssl.CERT_REQUIRED +async def check_fqdn_tls_ready(fqdn: str, mqtts_port: int, ca_pem: str) -> bool: + """Whether the panel now serves a certificate that names `fqdn`. + + A TLS connection is made to the MQTTS port with the FQDN as + `server_hostname`; a handshake that completes with hostname verification on + means the panel has regenerated its leaf to include the FQDN, which is what + this is polling for. + + `ca_pem` is the anchor the caller already holds, and requiring it is the + point: this used to refetch the CA over plain HTTP whenever it was not + given, which means a fresh trust decision every two seconds against whatever + answered. Anything that can answer that fetch can also serve a leaf signed by + the CA it just handed over, so the poll would have reported the FQDN ready on + a certificate the real anchor does not sign. A caller with no anchor of its + own gets one from `async_download_ca_or_none`, once. + """ + return await async_leaf_chains_to_ca(fqdn, mqtts_port, ca_pem) + + +async def async_resolve_host(hass: HomeAssistant, host: str) -> str | None: + """Resolve `host` to the IPv4 address the panel answers on, or None. + + The panel's certificate names the addresses it knows itself by -- its IP and + its mDNS name -- and an FQDN only joins that list once `register_fqdn` has + run. Everything before that has to be dialled by an address the leaf already + names, and this is where that address comes from. + + IPv4 only, and deliberately: the library builds bootstrap URLs by string + concatenation and does not bracket an IPv6 literal, so returning one would + produce a URL that cannot be parsed. `None` means "no answer this code can + use", and the caller falls back to the name it was given rather than + failing -- that is exactly the behaviour that shipped, so a host this + resolver cannot help with is no worse off than before. + """ + if is_ip_literal(host): + return host - with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as tmp: - tmp.write(ca_pem) - ca_path = Path(tmp.name) + def _resolve() -> str | None: + try: + infos = socket.getaddrinfo(host, None, family=socket.AF_INET, type=socket.SOCK_STREAM) + except (OSError, UnicodeError): + # `UnicodeError` rather than only `OSError`: a label over 63 + # characters fails IDNA encoding before any lookup is attempted, and + # `UnicodeEncodeError` is a `ValueError`. A host the user typed must + # not be able to end a config flow in a traceback. + return None + for *_unused, sockaddr in infos: + return str(sockaddr[0]) + return None + + return await hass.async_add_executor_job(_resolve) + + +async def async_fetch_panel_ca(hass: HomeAssistant, host: str, http_port: int = 80) -> str: + """Fetch the panel's CA over plain HTTP. + + Unauthenticated and unverified by construction — it fetches the very anchor + everything else is checked against, so there is nothing for it to check + itself against. Anything on the path can answer with a CA of its own. The + caller owes the trust decision; see `async_leaf_chains_to_ca` for the one + check that can be made without a value from another channel, and the confirm + step for the fingerprint a user can compare against one. + + Raises whatever `download_ca_cert` raises; the caller decides what a failure + to acquire means, and that differs between a config flow and a deferred fetch + at setup. + """ + return await download_ca_cert( + host, port=http_port, httpx_client=get_async_client(hass, verify_ssl=False) + ) + + +class LeafVerdict(StrEnum): + """What the certificate on a host says about that host. + + One boolean used to answer three different questions, and the answers call + for opposite responses. Splitting them is the whole point: + + - `TRUSTED` — the leaf chains to the anchor *and* names this host. The only + verdict that authorises storing the host or carrying traffic to it. + - `NAME_MISMATCH` — the leaf chains to the anchor but does not name this + host. Only the panel can produce this, because only the panel holds a key + the anchor signed; what it means is that the panel's certificate has not + caught up with the address it is being reached at, which is what a DHCP + move looks like from here. + - `UNTRUSTED` — something answered and its certificate does not chain to the + anchor. The interception case, and the only one worth alarming a user + about. + - `UNREACHABLE` — nothing answered at all. Distinct from `UNTRUSTED` + because it had been collapsed into it, so an unplugged cable was reported + to users as "the certificate this panel serves is not signed by the + authority it published" — an accusation of interception for a network + timeout. + """ + + TRUSTED = "trusted" + NAME_MISMATCH = "name_mismatch" + UNTRUSTED = "untrusted" + UNREACHABLE = "unreachable" + + +@dataclass(frozen=True, slots=True) +class LeafProbeResult: + """One leaf probe's verdict, with the names the certificate actually carries. + + `leaf_names` exists for exactly one verdict: a `NAME_MISMATCH` is only + actionable when the user can be told which names *would* work, and the + probe is the one place that has the validated certificate in hand. SAN + ``DNS`` and ``IP Address`` entries in certificate order — the same reading + the library's `LeafNameMismatch` documents — and empty for every other + verdict, where no validated certificate came back or the names answered + nothing. + """ + + verdict: LeafVerdict + leaf_names: tuple[str, ...] + + +async def async_leaf_verdict(host: str, tls_port: int, ca_pem: str) -> LeafVerdict: + """Classify the certificate served on `tls_port` against `ca_pem`. + + The verdict half of `async_leaf_probe`, kept as its own name for the + callers that act only on the classification. + """ + return (await async_leaf_probe(host, tls_port, ca_pem)).verdict + + +async def async_leaf_probe(host: str, tls_port: int, ca_pem: str) -> LeafProbeResult: + """Classify the certificate served on `tls_port` against `ca_pem`. + + One handshake, with hostname checking off, which establishes the chain; the + name binding is then evaluated separately from the certificate the + handshake returned. Both halves come from the library rather than being + written here, for the reason its `_ssl` module gives about the fingerprint: + a security primitive with two implementations drifts, and a hand-written + hostname matcher is the most error-prone of the three now that + `ssl.match_hostname` no longer exists to defer to. + + Relaxing the hostname check does not relax trust. The chain, the signature + and the expiry are all still verified against the pinned anchor, so a peer + without a key that anchor signed fails here exactly as it did before; what + changes is only that failing the *name* is now reported as a different + thing from failing the *chain*. + """ + loop = asyncio.get_running_loop() + + def _check() -> LeafProbeResult: + try: + # The library's builder, not a hand-rolled context: the panel's CA + # omits the Authority Key Identifier extension, which Python's + # default-on VERIFY_X509_STRICT rejects outright. A context built + # here without clearing that flag fails against a healthy panel. + ctx = build_panel_ssl_context(ca_pem, check_hostname=False) + except (ssl.SSLError, ValueError): + # An anchor that will not load cannot validate anything. Nothing was + # reached, so this is not an accusation against the host. + return LeafProbeResult(LeafVerdict.UNTRUSTED, ()) try: - ctx.load_verify_locations(str(ca_path)) with ( - socket.create_connection((fqdn, mqtts_port), timeout=5) as sock, - ctx.wrap_socket(sock, server_hostname=fqdn), + socket.create_connection((host, tls_port), timeout=5) as sock, + ctx.wrap_socket(sock, server_hostname=host) as tls, ): - return True - except (ssl.SSLCertVerificationError, ssl.SSLError, OSError, TimeoutError): - return False - finally: - ca_path.unlink(missing_ok=True) + peer = tls.getpeercert() + except ssl.SSLCertVerificationError: + return LeafProbeResult(LeafVerdict.UNTRUSTED, ()) + except (OSError, TimeoutError, UnicodeError): + # `UnicodeError` for the same reason as in `async_resolve_host`: the + # connect resolves the name, and an over-long label raises out of + # IDNA encoding rather than as a lookup failure. `ssl.SSLError` is a + # subclass of `OSError` and lands here too -- a handshake that broke + # without a verification failure says nothing about the peer's + # certificate, so it is a failure to reach, not a failure to trust. + return LeafProbeResult(LeafVerdict.UNREACHABLE, ()) + + # The handshake completed under the pinned anchor, so the peer holds a + # key that anchor signed. Only the name is left in question. + if not peer: + # `CERT_REQUIRED` should make this unreachable. It fails to + # UNTRUSTED rather than to NAME_MISMATCH because NAME_MISMATCH is + # the verdict that unlocks the relaxed transport, and "the handshake + # completed but no validated certificate came back" is not evidence + # that anything holds a key the anchor signed. + return LeafProbeResult(LeafVerdict.UNTRUSTED, ()) + if leaf_names_host(peer, host): + return LeafProbeResult(LeafVerdict.TRUSTED, ()) + names: list[str] = [] + for san_entry in peer.get("subjectAltName", ()): + # Typeshed leaves SAN entries loosely typed; only the two-string + # shape carries an address, which is all the repair can point at. + if isinstance(san_entry, tuple) and len(san_entry) == 2: + kind, value = san_entry + if kind in ("DNS", "IP Address") and isinstance(value, str): + names.append(value) + return LeafProbeResult(LeafVerdict.NAME_MISMATCH, tuple(names)) return await loop.run_in_executor(None, _check) -async def validate_v2_proximity(hass: HomeAssistant, host: str, port: int = 80) -> V2AuthResponse: +async def async_leaf_chains_to_ca(host: str, tls_port: int, ca_pem: str) -> bool: + """Whether the panel at `host` serves a certificate that chains *and* names it. + + The question every existing caller was already asking, kept as its own name + so that the answer does not quietly widen underneath them. A caller that + wants to act on *why* the answer is no asks `async_leaf_verdict` instead; + the two must not be confused, because a `NAME_MISMATCH` host is one this + function has always refused and must keep refusing -- storing it would + strand every later connection, all of which verify the hostname. + """ + return await async_leaf_verdict(host, tls_port, ca_pem) is LeafVerdict.TRUSTED + + +async def async_panel_leaf_host( + hass: HomeAssistant, host: str, tls_port: int, ca_pem: str +) -> str | None: + """Return the address that reaches the panel at `host` under `ca_pem`, or None. + + The single implementation of "which address does this panel answer on". + `host` itself when the certificate it serves already names it, the resolved + address when only that works, and None when neither does. + + The name is tried first, always: the panel's leaf names the addresses it + knows itself by, and where it already names the host there is nothing to + work around and no reason to prefer anything else. Only then the resolved + address, which is the case that matters -- a host recorded as a name (an + add-on's container hostname, a search-domain short name, an FQDN the panel + has not been told about) fails hostname verification against a perfectly + good certificate, and an FQDN joins the SAN only once `register_fqdn` has + run, which is after authentication. + + A host that will not resolve simply has one candidate and fails exactly as + it would have without this fallback. + + Callers differ in what they may do with a resolved answer. Dialling one is + always safe; *persisting* one, or persisting the name it stood in for, is + not -- see `SpanPanelConfigFlow._async_choose_bootstrap_host`, which records + the substitution precisely so the rest of the flow can refuse to store a + host the anchor rejects. + """ + if not host: + return None + if await async_leaf_chains_to_ca(host, tls_port, ca_pem): + return host + if is_ip_literal(host): + return None + resolved = await async_resolve_host(hass, host) + if resolved is None: + _LOGGER.debug("Could not resolve %s to an address; only the name is tried", host) + return None + if resolved == host: + return None + if await async_leaf_chains_to_ca(resolved, tls_port, ca_pem): + return resolved + return None + + +async def async_ca_signs_panel_leaf( + hass: HomeAssistant, host: str, tls_port: int, ca_pem: str +) -> bool: + """Whether `ca_pem` signs the certificate the panel at `host` actually serves. + + The one check that can be made on a CA nobody has confirmed. `ca_pem` comes + off an unauthenticated plaintext fetch, so anything on the path can have + answered it -- a reverse proxy in front of the panel answers it with an + authority of its own -- and pinning that is not a recoverable mistake: the + broker connection then fails against a certificate the pin rejects, the + change diagnosis finds the fetched CA and the pinned one identical and has + no change to report, and the entry retries forever with no repair to offer. + + For callers that only need the verdict; `async_panel_leaf_host` carries the + address the panel was actually reached on, and why there is more than one + candidate. + """ + return await async_panel_leaf_host(hass, host, tls_port, ca_pem) is not None + + +async def validate_v2_proximity( + host: str, + transport: PanelRestTransport, +) -> V2AuthResponse: """Validate v2 panel proximity (door bypass) and return MQTT credentials. Calls register_v2 without a passphrase, which triggers door-bypass registration. The panel accepts this when the user opens/closes the door 3 times within the proximity window. + `transport` is required for the same reason as in `validate_v2_passphrase`: + no passphrase crosses the wire here, but the broker password comes back, and + a defaulted plaintext transport would return it in the clear. + Raises: SpanPanelAuthError: if proximity was not proven (door not opened). - SpanPanelConnectionError: on network/timeout failures. + SpanPanelPassphraseUnavailableError: as for `validate_v2_passphrase`. + A registration that sends no passphrase still comes back without a + broker password while the panel cannot read its own. + SpanPanelServerError: any 5xx, including the 503 a panel still starting + up answers from r202639. Retryable. + SpanPanelConnectionError: on network failures. SpanPanelTimeoutError: on request timeout. """ - client = get_async_client(hass, verify_ssl=False) - return await register_v2(host, "Home Assistant", port=port, httpx_client=client) + return _require_broker_password( + await register_v2( + host, + "Home Assistant", + port=transport.port, + httpx_client=transport.httpx_client, + ssl_context=transport.ssl_context, + ) + ) + + +@dataclass(frozen=True, slots=True) +class PanelRestTransport: + """How to reach one panel's bootstrap REST API. + + Two shapes, and the difference is not cosmetic. With a pinned CA the calls + go to the TLS port under an anchor built from it, and Home Assistant's shared + httpx client cannot be used — httpx fixes its trust store at construction, so + a context cannot be applied to a client somebody else built, and the library + builds a dedicated one rather than silently ignoring the pin. Without a pin + they go to the plaintext port on the shared client, exactly as before. + + Mixing the two is refused by the library rather than guessed at: an explicit + `port=80` alongside an `ssl_context` raises, because "HTTPS on port 80" and + "a port stored before the CA was pinned" are both plausible readings. + + `ca_pem` is the anchor `ssl_context` was built from, carried alongside it + because a context cannot be read back into a PEM and two callers need the + text: the FQDN readiness poll, which verifies a different port against the + same anchor, and anything that wants to fingerprint what it is pinned to. + It is None exactly when `ssl_context` is. + """ + + port: int + ssl_context: ssl.SSLContext | None + httpx_client: httpx.AsyncClient | None + ca_pem: str | None + + +def port_or_none(value: object) -> int | None: + """Read a port out of untyped entry data, or None when it does not hold one. + + `entry.data` round-trips through JSON and is typed `Any` at the boundary; a + hand-edited `.storage` can put a string there. `None` is the honest answer + for anything unreadable, and it is a different answer from a default: a + caller deciding whether the panel's TLS port is *known* must not be told yes + because a substitute was available. + """ + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdigit(): + return int(value) + return None + + +def as_port(value: object, default: int) -> int: + """Read a port out of untyped entry data, falling back to `default`. + + A bad port must not be the reason an otherwise healthy entry cannot start, + so this never raises. Use `port_or_none` where the difference between a + stored value and a substituted one matters. + """ + parsed = port_or_none(value) + return default if parsed is None else parsed + + +def pem_fingerprint_or_reason(pem: object) -> str: + """Name a stored anchor for a log line, without letting it raise into one. + + Every caller is a log line about a refusal, and each of them is naming the + anchor *already stored on the entry* — the value the install logged when it + pinned and the value diagnostics reports, so the one a user can compare + something against. Never the certificate the refused host served: reading + that would mean opening a second, deliberately unverified connection to a + host this code has just decided not to trust, for a number with nothing to + compare it to. + + `object` rather than `str` because that is what `entry.data` hands out. The + two ways a stored anchor can fail to name itself are answered rather than + raised, because a log line that cannot be written is worse than an imprecise + one: "none" for an entry that stores no anchor, "unreadable" for one whose + PEM this system cannot parse. + """ + if not isinstance(pem, str) or not pem: + return "none" + try: + return ca_fingerprint(pem) + except SpanPanelValidationError: + return "unreadable" + + +class PanelCaUnusableError(ValueError): + """An entry stores a panel CA that cannot be turned into a trust anchor. + + Raised only for a caller that passed `allow_plaintext_fallback=False`; the + default path logs and downgrades, as it always has. + + A `ValueError`, because that is what a stored value that cannot be parsed + is, and because it is one of the two exceptions `build_panel_ssl_context` + already raises for the same condition -- a caller that catches `ValueError` + around either function keeps catching this. + """ + + +def panel_rest_transport( + hass: HomeAssistant, + entry_data: Mapping[str, object], + *, + allow_plaintext_fallback: bool = True, + verify_hostname: bool = True, +) -> PanelRestTransport: + """Decide how this entry's REST calls should reach the panel. + + `verify_hostname=False` keeps the pin and drops only the name binding, for + the single case that needs it: a reconfigure of a pinned entry whose panel + is answering at an address its certificate does not yet name. Such a panel + has proven it holds a key the pinned anchor signed -- nothing else can + complete the handshake -- but every call to it still fails hostname + verification, so without this the flow cannot probe the panel, cannot ask + it to regenerate its certificate, and cannot repair the situation it + exists to repair. + + It is for a flow deciding *which* host to talk to, and for nothing else. It + must never reach a transport that gets stored on the entry or used at + runtime: the name binding is what stops a validated certificate being + replayed by a host it was not issued to, and a coordinator carrying data + over a relaxed context would give that up permanently rather than for the + length of one flow. + + A malformed stored PEM falls back to plaintext rather than raising. The + alternative is a config entry that cannot make a single REST call until + somebody edits `.storage` by hand, which is a worse outcome than the + transport this entry had before it was pinned — and the CA-changed repair is + the path back to a good pin. + + That trade is right for a caller whose alternative is not working at all. It + is wrong for one carrying a secret: a credential rotation sends the access + token out and a fresh broker password back, and doing that unverified undoes + the pin at the one moment it matters most. Such a caller passes + `allow_plaintext_fallback=False` and gets `PanelCaUnusableError` instead of a + quiet downgrade. An entry with no stored PEM at all is not pinned in the first + place and still gets the plaintext transport either way — the flag refuses a + downgrade, it does not invent a pin. + + Raises: + PanelCaUnusableError: `allow_plaintext_fallback` is False and the stored + PEM is not a certificate this system accepts. + + """ + pem = entry_data.get(CONF_PANEL_CA_PEM) + if pem: + try: + context = build_panel_ssl_context(str(pem), check_hostname=verify_hostname) + except (ssl.SSLError, ValueError) as err: + if not allow_plaintext_fallback: + raise PanelCaUnusableError(str(err)) from err + _LOGGER.warning( + "The stored panel CA is not a certificate this system accepts; " + "falling back to plaintext HTTP for REST calls" + ) + else: + https_port = as_port(entry_data.get(CONF_HTTPS_PORT), DEFAULT_HTTPS_PORT) + return PanelRestTransport( + port=https_port, + ssl_context=context, + httpx_client=None, + ca_pem=str(pem), + ) + + return PanelRestTransport( + port=as_port(entry_data.get(CONF_HTTP_PORT), 80), + ssl_context=None, + httpx_client=get_async_client(hass, verify_ssl=False), + ca_pem=None, + ) diff --git a/custom_components/span_panel/const.py b/custom_components/span_panel/const.py index f18e4e09..ee0dc598 100644 --- a/custom_components/span_panel/const.py +++ b/custom_components/span_panel/const.py @@ -17,9 +17,51 @@ CONF_EBUS_BROKER_PORT = "ebus_broker_mqtts_port" CONF_HOP_PASSPHRASE = "hop_passphrase" # nosec B105 CONF_HTTP_PORT = "http_port" +CONF_HTTPS_PORT = "https_port" +CONF_PANEL_CA_PEM = "panel_ca_pem" CONF_PANEL_SERIAL = "panel_serial" CONF_REGISTERED_FQDN = "registered_fqdn" +DEFAULT_HTTPS_PORT = 443 +"""Where the panel serves TLS. Configurable for the same reason `http_port` is: +a reverse proxy on the panel's VLAN does not have to listen on 443.""" + +DEFAULT_MQTTS_PORT = 8883 +"""Where the panel serves MQTT over TLS. + +A fallback and nothing more: the panel publishes this port during registration +and every v2 entry carries it, so a stored value is what is actually used. It is +here for the reader that has to name a port when `entry.data` holds something +unusable, rather than refuse an otherwise healthy entry over it.""" + +PANEL_CA_PENDING = "panel_ca_pending" +"""Set in `entry.data` for a v2 entry that predates CA pinning. + +The CA cannot be fetched in `async_migrate_entry` — that runs during startup, so +a network call there delays boot whenever the panel is unreachable and leaves a +failed fetch with no recovery path. The flag defers it to the first successful +setup, which retries on every subsequent one for free. Same shape as +`solar_migration_pending`. + +**Only the migration sets this.** A newly configured entry always carries a pin, +because the config flow fetches and confirms the CA before it authenticates and +refuses to continue without one — registration is the exchange that carries the +passphrase. + +An upgraded entry is a different case, but not a risk-free one, and the +difference is not that nothing is at stake. Its MQTTS session authenticates with +the broker password, and with no `ca_pem` the library falls back to fetching the +CA over unauthenticated plaintext HTTP on every connect and trusting whatever +answers — which is the substitution this pin exists to close. So an unpinned +upgraded entry is exposed on each connect until the flag settles. + +Starting anyway is still right, for a different reason: that exposure is exactly +the status quo ante. It is what the entry already had under 3.0.1, and refusing +to start does not remove it — it removes the integration, and an integration +that will not start protects nothing. Retrying converts a pre-existing, bounded +exposure into a closed one at the first successful setup; refusing converts it +into a guaranteed outage with the credential no safer.""" + # Binary sensor / status field keys (used in entity definitions) SYSTEM_DOOR_STATE = "doorState" SYSTEM_DOOR_STATE_CLOSED = "CLOSED" @@ -29,6 +71,13 @@ SYSTEM_CELLULAR_LINK = "wwanLink" SYSTEM_WIFI_LINK = "wlanLink" PANEL_STATUS = "panel_status" +PV_PANEL_LINK_KEY = "pv_panel_link" +"""The solar inverter link sensor's key, on the Solar card and on each inverter's own card. + +Here rather than only on the description in `binary_sensor`, because +`pv_binding` builds the per-inverter link sensors' unique ids from it and must +not import a platform module to learn its key. +""" USE_DEVICE_PREFIX = "use_device_prefix" USE_CIRCUIT_NUMBERS = "use_circuit_numbers" @@ -56,6 +105,13 @@ ENABLE_PANEL_NET_ENERGY_SENSORS = "enable_panel_net_energy_sensors" ENABLE_CIRCUIT_NET_ENERGY_SENSORS = "enable_circuit_net_energy_sensors" ENABLE_ENERGY_DIP_COMPENSATION = "enable_energy_dip_compensation" +DEFAULT_ENERGY_DIP_COMPENSATION = False +"""What an entry that never stored `ENABLE_ENERGY_DIP_COMPENSATION` does: compensate +nothing. Installs from before the option have no value for it; a new install stores +the config flow's choice, which starts on. Read by the sensors and pre-filled by the +General Options form, so saving that form never changes what the sensors do.""" +NEW_INSTALL_ENERGY_DIP_COMPENSATION = True +"""The config flow's starting choice for a new install, which it stores.""" # Unmapped circuit sensor configuration ENABLE_UNMAPPED_CIRCUIT_SENSORS = "enable_unmapped_circuit_sensors" @@ -80,6 +136,11 @@ ) EVENT_CURRENT_ALERT = "span_panel_current_alert" +# Fired alongside a schema Repair so an automation can react without polling +# the issue registry. Same bus-event shape as the current alert above. +EVENT_SCHEMA_ISSUE = "span_panel_schema_issue" +EVENT_CONTROL_COMMAND = "span_panel_control_command" + # Graph time horizon configuration VALID_GRAPH_HORIZONS: Final[tuple[str, ...]] = ("5m", "1h", "1d", "1w", "1M") DEFAULT_GRAPH_HORIZON = "5m" diff --git a/custom_components/span_panel/control_gate.py b/custom_components/span_panel/control_gate.py new file mode 100644 index 00000000..cc1a6d97 --- /dev/null +++ b/custom_components/span_panel/control_gate.py @@ -0,0 +1,550 @@ +"""Who may operate this panel through Home Assistant, and a record of who did. + +**What this is a boundary against.** Callers arriving through Home Assistant, +and nothing else. It does not constrain anything holding the broker credential: +a process with that credential publishes to the panel's broker directly and +never reaches this code, and that includes a malicious or buggy custom +integration running in the same Home Assistant process. Presenting this as a +security boundary around the *panel* would be the most damaging thing it could +do, because a user would stop looking for the real one — which is network +topology and a lock on the enclosure. The README says so too. + +**Why it is split in two.** Authorization needs to know who is asking, and that +is knowable only at the entity: Home Assistant sets `Entity._context` from the +service call immediately before the handler runs. Completeness needs a single +choke point, and that exists only at the publish, inside the library. So +identity is captured at the entity into a `ContextVar` (see +`SpanPanelEntity._async_guarded_control`) and the decision is made in the +library's `ControlInterceptor`, which sees every control command including ones +a future code path forgets to route through the helper. Such a command arrives +with an empty `ContextVar` and is treated as contextless rather than silently +permitted — that is the fail-closed default, and it is the reason the gate lives +here rather than in the helper. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from contextvars import ContextVar, Token +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +import logging +import time +from typing import Any, Self + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.restore_state import ExtraStoredData +from homeassistant.util import dt as dt_util +from span_panel_api import ControlCommand, PublishOutcome, PublishState + +from .const import DOMAIN, EVENT_CONTROL_COMMAND +from .options import ( + ALLOW_CONTEXTLESS_CONTROL, + CONTROL_LOCK_TIMEOUT, + CONTROL_MODE, + RELAY_DEBOUNCE_SECONDS, + option_bool, +) + +_LOGGER = logging.getLogger(__name__) + +RELAY_PROPERTY = "relay" +"""The property id both schema adapters use for a circuit relay. + +Flat spells the address `(serial, circuit_id, "relay")` and v1.0 spells it +`(circuit_id, "switch", "relay")` — the device and node differ, the property does +not. That is what makes it usable here without teaching this module either +schema's topic grammar. +""" + +DEFAULT_RELAY_DEBOUNCE_SECONDS = 2.0 + +DEFAULT_ALLOW_CONTEXTLESS_CONTROL = False +"""Whether a command with no logged-in user is allowed, for an entry that never said. + +Off, because an install that has made no choice should not take commands from +every automation, script and integration in the house. Entries created before +this default existed allowed them; migration 7.2 writes that `True` into each +one's options, so this constant only ever decides for an entry created since. +""" + + +class ControlMode(StrEnum): + """Who may operate the panel's controls.""" + + ALL_USERS = "all_users" + """Home Assistant's own default: any user who can call a service.""" + + ADMIN_ONLY = "admin_only" + """Administrators only. Home Assistant's default user policy grants every + non-admin control of every entity, so this is the only way to distinguish + them.""" + + DISABLED = "disabled" + """No control entities are created at all. Their registry entries are kept — + see the platforms.""" + + +@dataclass(frozen=True, slots=True) +class ControlCaller: + """Who issued the command currently being published, if anyone. + + `entity_id` is carried alongside the Context because the interceptor sees a + wire address and cannot map it back to an entity — and the audit trail is + worth far less without it. + """ + + context: Context | None + entity_id: str | None + + +CONTROL_CALLER: ContextVar[ControlCaller | None] = ContextVar( + "span_panel_control_caller", default=None +) +"""Bound around one awaited control call, and reset in a `finally`. + +A `ContextVar` rather than an attribute on the entity because a mutable +attribute races across concurrent service calls, and rather than an override of +`async_set_context` because that would inherit core's five-second +`CONTEXT_RECENT_TIME_SECONDS` staleness window and would also capture paths that +are not service calls at all. +""" + +_REFUSAL_REASON: ContextVar[str | None] = ContextVar("span_panel_control_refusal", default=None) +"""Why `before_publish` vetoed, read back by `after_publish`. + +The library fires `after_publish` for a veto — so an audit built on it cannot +silently omit the refusals, which would make it worse than no audit — but hands +it only a `FAILED` outcome with `detail="vetoed"`, not the reason. The reason is +left here on the way out. + +This works because the library fires that callback as a task created from the +same context this was set in, and `loop.create_task` copies the current context. +The entity's `finally` runs after the task exists, so the copy keeps the value. +""" + + +def _as_float(value: object, default: float) -> float: + """Read a non-negative float out of untyped option data.""" + if isinstance(value, bool): + return default + if isinstance(value, int | float): + return float(value) if value >= 0 else default + return default + + +@dataclass(frozen=True, slots=True) +class ControlPolicy: + """The four options, resolved once. + + Every default reproduces the behaviour an existing entry already has. A + silent tightening on upgrade that breaks a household's automations is a worse + outcome than the status quo, and the user cannot diagnose it from the + entity's error. `allow_contextless` departs from what an older entry did, and + can only because migration 7.2 stored that older behaviour in every such + entry before this reads it. + """ + + mode: ControlMode + allow_contextless: bool + lock_timeout_minutes: float | None + relay_debounce_seconds: float + + @property + def lock_enabled(self) -> bool: + """Whether the control-lock entity exists at all.""" + return self.lock_timeout_minutes is not None + + @classmethod + def default(cls) -> ControlPolicy: + """Return the policy an entry with no control options configured has. + + Written down once, here, so "the default" is a single object rather than + four constants repeated at each reader. + """ + return cls( + mode=ControlMode.ALL_USERS, + allow_contextless=DEFAULT_ALLOW_CONTEXTLESS_CONTROL, + lock_timeout_minutes=None, + relay_debounce_seconds=DEFAULT_RELAY_DEBOUNCE_SECONDS, + ) + + @classmethod + def from_options(cls, options: Mapping[str, object]) -> ControlPolicy: + """Resolve the policy from one entry's options.""" + raw_mode = options.get(CONTROL_MODE) + try: + mode = ControlMode(raw_mode) if isinstance(raw_mode, str) else ControlMode.ALL_USERS + except ValueError: + _LOGGER.warning("Unknown control_mode %r; falling back to all_users", raw_mode) + mode = ControlMode.ALL_USERS + + raw_timeout = options.get(CONTROL_LOCK_TIMEOUT) + # Absent (or negative) means the lock feature is off and there is no + # switch; 0 means the feature is on with no auto-relock, so a disarm + # lasts until someone arms it again. "Off" and "on with no countdown" + # are different states and a plain float cannot hold both, which is why + # this is nullable. + timeout: float | None = None + if isinstance(raw_timeout, int | float) and not isinstance(raw_timeout, bool): + timeout = float(raw_timeout) if raw_timeout >= 0 else None + + return cls( + mode=mode, + allow_contextless=option_bool( + options.get(ALLOW_CONTEXTLESS_CONTROL), DEFAULT_ALLOW_CONTEXTLESS_CONTROL + ), + lock_timeout_minutes=timeout, + relay_debounce_seconds=_as_float( + options.get(RELAY_DEBOUNCE_SECONDS), DEFAULT_RELAY_DEBOUNCE_SECONDS + ), + ) + + +class ControlLock: + """An armed/disarmed flag that refuses every control while armed. + + Home Assistant has no per-entity admin flag, so "an admin-only switch entity" + is not a thing that exists. This is the honest local substitute: the + alarm-panel "armed" pattern, defending against misclicks and runaway + automations, which is what a local second factor can actually do. + + Arming is permitted for anyone, including a contextless caller. Disarming + requires an administrator, and is refused outright for a contextless caller + regardless of `allow_contextless_control` — an automation that can unlock the + panel defeats the purpose of the lock. Making a household safer should not + require admin; making it less safe should. + """ + + def __init__(self, *, armed: bool = False) -> None: + """Start armed or disarmed, before anything is known about the last run. + + `async_setup_entry` passes the feature's own switch: a lock that exists + as a user-facing thing is a lock that is on. That is what the option + text promises, and it is what fails closed over the window between the + interceptor being installed and the entity being added — the first + refresh and the whole platform forward happen in between. + + `SpanPanelControlLockSwitch.async_added_to_hass` then relaxes it to + whatever the previous run left behind. Relaxing is the only direction a + restore ever moves this, which is why the constructor can decide the + rest by itself. + """ + self._armed = armed + self._relock_at: float | None = None + + @property + def armed(self) -> bool: + """Whether control is currently locked out. + + Auto-relock is settled on read as well as on the entity's timer, so it + cannot be missed by a suspended event loop, and so the lock never stays + open longer than it was asked to even if nothing writes the state. + """ + if self._relock_at is not None and time.monotonic() >= self._relock_at: + self._armed = True + self._relock_at = None + return self._armed + + @property + def relock_in_seconds(self) -> float | None: + """Seconds left of the auto-relock window, or None when none is running. + + None for an armed lock and for a disarm that was asked to last until + someone arms it again — neither has a deadline to report. + """ + if self.armed or self._relock_at is None: + return None + return max(0.0, self._relock_at - time.monotonic()) + + def arm(self) -> None: + """Lock control out.""" + self._armed = True + self._relock_at = None + + def disarm(self, timeout_minutes: float) -> None: + """Allow control, re-arming after `timeout_minutes` unless that is zero.""" + self._armed = False + self._relock_at = None if timeout_minutes <= 0 else time.monotonic() + timeout_minutes * 60 + + def resume_disarm(self, relock_in_seconds: float | None) -> None: + """Re-enter the disarmed state the previous run left this entry in. + + The entity calls this and nothing else: a lock for an enabled entry is + constructed armed, so restoring a run that ended armed means leaving it + alone, and the only thing the previous run has to tell this object is + that it had been *opened*. + + `relock_in_seconds` is what was left of that run's auto-relock window, + recomputed by the entity from a wall-clock deadline because `_relock_at` + is a monotonic reading and a monotonic reading does not survive a + restart. None means there was no window. Zero or less means it closed + while Home Assistant was down, and the lock stays armed rather than + granting a grace period nobody asked for. + + Resuming the remainder rather than restarting the window is the point. + A reload happens on every options save, every credential rotation and + every circuit rename; a restarted window would let routine maintenance + hold the lock open indefinitely. + """ + if relock_in_seconds is not None and relock_in_seconds <= 0: + self.arm() + return + self._armed = False + self._relock_at = ( + None if relock_in_seconds is None else time.monotonic() + relock_in_seconds + ) + + +@dataclass(frozen=True, slots=True) +class ControlLockExtraStoredData(ExtraStoredData): + """A pending auto-relock, written down in terms that outlive the process. + + Lives beside `ControlLock` rather than in the switch module because it is + that class's deadline in serialized form: the two have to change together, + and a reader of one should not have to go looking for the other. + + The switch's own `on`/`off` state is not duplicated here — Home Assistant + already restores it — so this holds exactly the one thing the state cannot + express, and holds it as an absolute instant because `_relock_at` is a + monotonic reading with no meaning after a restart. + """ + + relock_at: datetime | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return {"relock_at": None if self.relock_at is None else self.relock_at.isoformat()} + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Read back what `as_dict` wrote, or None when it cannot be read. + + None means the record holds something that is not a deadline — edited + by hand, or written in a shape this release does not know. An *absent* + key is not that case: a record written before this release, and one + written by a run with nothing pending, both say "no deadline", and both + read back as one. The caller decides what to do with either; failing + setup over a switch's restore data would not be a proportionate answer. + """ + raw = restored.get("relock_at") + if raw is None: + return cls(relock_at=None) + if not isinstance(raw, str): + return None + parsed = dt_util.parse_datetime(raw) + return None if parsed is None else cls(relock_at=parsed) + + +class ControlGate: + """One veto-and-observe point for every control command on one entry.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + policy: ControlPolicy, + lock: ControlLock, + ) -> None: + """Hold what the decision needs.""" + self._hass = hass + self._entry = entry + self._policy = policy + self._lock = lock + # Monotonic timestamp of the last relay command that was handed to the + # broker, per circuit -- not of the last one that was attempted. Keyed on + # the address the adapter produced rather than on an entity id, so it + # holds for a caller that never went through an entity. + self._last_relay: dict[tuple[str, str], float] = {} + + # -- ControlInterceptor ------------------------------------------------- + + async def before_publish(self, command: ControlCommand) -> None: + """Decide whether this command may be published. Raise to veto. + + The exception reaches the service caller unchanged — the library + propagates it rather than translating it — which is what lets a + `ServiceValidationError` carrying a translated message arrive intact. + """ + caller = CONTROL_CALLER.get() + context = caller.context if caller is not None else None + user_id = context.user_id if context is not None else None + + # First, because it is the entry-wide answer and the most useful thing + # to be told. Not creating the entities is what a user sees, but it is + # not a choke point: a command arriving by any other route -- a service + # this integration grows later, a code path that forgets the helper -- + # would otherwise be published by an entry whose owner switched control + # off. That is the promise this module opens with. + if self._policy.mode is ControlMode.DISABLED: + self._refuse( + "control_disabled", + "Control of this SPAN panel is switched off in its options. " + "Nothing was sent to the panel.", + ) + + if self._lock.armed: + self._refuse( + "control_locked", + "The SPAN panel's control lock is armed. Disarm it before operating circuits.", + ) + + if user_id is None and not self._policy.allow_contextless: + self._refuse( + "contextless_control_refused", + "This SPAN panel refuses control commands that do not come from a logged-in user. " + 'To allow them, turn on "Allow control without a logged-in user" in its options.', + parent_id=context.parent_id if context is not None else None, + ) + + if self._policy.mode is ControlMode.ADMIN_ONLY and user_id is not None: + user = await self._hass.auth.async_get_user(user_id) + if user is None or not user.is_admin: + self._refuse( + "admin_only_control", + "Only a Home Assistant administrator may operate this SPAN panel.", + ) + + if command.property_id == RELAY_PROPERTY: + self._refuse_a_debounced_relay(command) + + async def after_publish(self, command: ControlCommand, outcome: PublishOutcome) -> None: + """Record what happened to every command, refusals included.""" + if command.property_id == RELAY_PROPERTY and outcome.state is not PublishState.FAILED: + self._open_relay_debounce_window(command) + + caller = CONTROL_CALLER.get() + context = caller.context if caller is not None else None + reason = _REFUSAL_REASON.get() + + result = f"refused:{reason}" if reason is not None else str(outcome.state) + event = { + "entry_id": self._entry.entry_id, + "user_id": context.user_id if context is not None else None, + "parent_id": context.parent_id if context is not None else None, + "entity_id": caller.entity_id if caller is not None else None, + "command": f"{command.node_id}/{command.property_id}", + "topic": command.topic, + "value": command.value, + "outcome": result, + "no_op": outcome.no_op, + } + self._hass.bus.async_fire(EVENT_CONTROL_COMMAND, event) + + # INFO on every publish, not only on failures. A panel that operates a + # breaker is worth a log line whichever way it went, and `unconfirmed` in + # particular is invisible otherwise. + _LOGGER.info( + "SPAN control %s = %s by %s: %s%s", + command.topic, + command.value, + caller.entity_id if caller is not None and caller.entity_id else "an unknown caller", + result, + f" ({outcome.detail})" if outcome.detail else "", + ) + + # -- internals ---------------------------------------------------------- + + def _refuse_a_debounced_relay(self, command: ControlCommand) -> None: + """Refuse a relay command that follows too closely on the last one. + + Refused, never queued. A queued relay command firing seconds later + against a changed panel state is worse than a refusal the automation + author can see and fix. + + Only the check lives on this side. Opening the window is + `_open_relay_debounce_window`'s job and happens once the outcome is + known. + """ + window = self._policy.relay_debounce_seconds + if window <= 0: + return + + last = self._last_relay.get((command.device_id, command.node_id)) + if last is not None and time.monotonic() - last < window: + self._refuse( + "relay_debounced", + f"That circuit's relay was operated less than {window:g} seconds ago. " + "The command was refused rather than queued.", + ) + + def _open_relay_debounce_window(self, command: ControlCommand) -> None: + """Start the window for a relay command that was actually handed over. + + Stamped from the outcome rather than from the attempt. A command that + was never delivered moved no contactor, and there is nothing to protect + from a second one -- while a bridge that is down is exactly the moment a + person presses the button again, so debouncing that retry with "operated + less than 2 seconds ago" is both false and unactionable. + + `FAILED` is the one state that promises the command will not arrive + later, and it covers every refusal too, this gate's own vetoes included. + `ACCEPTED` and `UNCONFIRMED` were handed to the broker and may already + have moved the relay, so both open the window. + + The library fires `after_publish` as a task rather than awaiting it, so + the stamp lands a tick after the publish returns. That is late only for + a caller issuing two relay commands without yielding to the event loop + in between, which a service call never does. + """ + if self._policy.relay_debounce_seconds > 0: + self._last_relay[(command.device_id, command.node_id)] = time.monotonic() + + def _refuse(self, reason: str, message: str, parent_id: str | None = None) -> None: + """Record the reason for `after_publish`, then veto.""" + _REFUSAL_REASON.set(reason) + if parent_id is not None: + # Names *what* asked when there is no *who* — the automation or + # script the command came from. Without it a contextless refusal is + # a blank line in the log. + _LOGGER.warning( + "Refused a SPAN control command (%s) originating from context %s", + reason, + parent_id, + ) + raise ServiceValidationError( + message, + translation_domain=DOMAIN, + translation_key=reason, + ) + + +@callback +def async_bind_caller( + context: Context | None, entity_id: str | None +) -> Token[ControlCaller | None]: + """Bind the caller for the duration of one control call. + + Returns the reset token; the caller must reset it in a `finally`. Kept here + rather than inlined at the entity so the two `ContextVar`s are set and + cleared in one place. + """ + _REFUSAL_REASON.set(None) + return CONTROL_CALLER.set(ControlCaller(context=context, entity_id=entity_id)) + + +def outcome_failure_reason(outcome: PublishOutcome) -> str: + """Return the library's own account of why a command was not delivered. + + `detail` is the library's own words for why -- "broker not connected; + refused rather than queued" -- and is documented as free text for a human + reading a log or an audit row. So it is displayed and never parsed, the same + way a refusal's message is. The state is the fallback for an outcome + carrying no detail, which is worth saying poorly rather than not at all. + """ + return outcome.detail or str(outcome.state) + + +def outcome_is_failure(outcome: PublishOutcome) -> bool: + """Whether this outcome means the command will never be delivered. + + Only `FAILED` is a promise about the future. `ACCEPTED` and `UNCONFIRMED` + both mean the command was handed over and may already have been acted on — + `UNCONFIRMED` most often means the value was already what was asked for — and + presenting either as a failure would tell a user something untrue about their + panel. + """ + return outcome.state is PublishState.FAILED diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 0c08410d..8b4830f0 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -6,32 +6,47 @@ from datetime import timedelta import logging from time import time as _epoch_time -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Final, Protocol if TYPE_CHECKING: - from . import SpanPanelConfigEntry from .current_monitor import CurrentMonitor from .graph_horizon import GraphHorizonManager + from .runtime import SpanPanelConfigEntry from homeassistant.components.persistent_notification import async_create -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError, ) from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from span_panel_api import SpanMqttClient, SpanPanelSnapshot -from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError +from span_panel_api.exceptions import ( + SpanPanelAuthError, + SpanPanelCAChangedError, + SpanPanelError, + SpanPanelStaleDataError, +) from .const import DOMAIN +from .energy_orientation import EnergyCounter, EnergyMeter +from .helpers import ( + circuit_has_a_breaker_switch, + circuit_has_a_priority_select, + detect_capabilities, +) from .id_builder import build_circuit_unique_id -from .schema_validation import collect_sensor_definitions, validate_field_metadata +from .leaf_repairs import async_clear_leaf_name_mismatch +from .notices import async_raise, read_translations +from .schema_repairs import async_sync_schema_issues +from .schema_validation import SchemaFindings, evaluate_field_metadata +from .sensor_definitions import sensor_descriptions_by_field_path -class SpanCircuitEnergySensorProtocol(Protocol): - """Protocol for circuit energy sensors that expose their dip offset.""" +class EnergyOffsetSource(Protocol): + """An energy counter sensor that exposes its cumulative dip offset.""" @property def energy_offset(self) -> float: @@ -41,6 +56,35 @@ def energy_offset(self) -> float: _LOGGER = logging.getLogger(__name__) +_UPGRADE_NOTICE: Final = "panel_upgraded" +"""Names both the notice id and its translation section. + +One symbol for both because they are the same notice: a rename that moved only +one of them would leave a standing notice pointing at strings that no longer +exist, and the notice cannot be re-derived once the upgrade is over. +""" + +_UPGRADE_FALLBACK: Final[dict[str, str]] = { + "title": "SPAN Panel firmware upgraded", + "body": ( + "Your SPAN Panel reported a new eBus data model (**{previous} \u2192 {current}**), " + "which happens after a firmware upgrade. The integration reloaded so its devices " + "and entities match what the panel now publishes.\n\n" + "Nothing you rely on has gone away, and no automation changes are required.\n\n" + "**DSM Grid State** keeps its entity ID and its history, and now reads the " + "islanding state the Microgrid Interconnect Device (MID) senses rather than " + "inferring it. **Grid Islandable** now reflects whether a MID is present.\n\n" + "Entities that were renamed or replaced by the upgrade may need to be removed " + "manually if they remain unavailable." + ), +} +"""English text, used when no translation file can be read. + +Shorter than the translated body on purpose: this is the copy nobody proofreads, +and the paragraphs it drops are elaboration rather than the facts a user needs. +""" + + # Suppress the noisy "Manually updated span_panel data" DEBUG message that # HA's DataUpdateCoordinator emits on every async_set_updated_data() call. # In push/streaming mode this fires every ~1s and drowns out useful debug logs. @@ -62,6 +106,8 @@ def filter(self, record: logging.LogRecord) -> bool: class SpanPanelCoordinator(DataUpdateCoordinator[SpanPanelSnapshot]): """Coordinator for managing Span Panel data updates.""" + config_entry: SpanPanelConfigEntry + def __init__( self, hass: HomeAssistant, @@ -70,32 +116,57 @@ def __init__( ) -> None: """Initialize the coordinator.""" self._client = client - self.config_entry: SpanPanelConfigEntry = config_entry # Track last tick for visibility into cadence self._last_tick_epoch: float | None = None # Flag to track if a reload was requested self._reload_requested = False # Flag to track if panel is offline/unreachable self._panel_offline = False + # Flag to track a transport that has stopped for good. Distinct from + # offline on purpose; see `transport_dead`. + self._transport_dead = False # Streaming state self._unregister_streaming: Callable[[], None] | None = None self._unregister_connection: Callable[[], None] | None = None + self._unregister_schema_change: Callable[[], None] | None = None + self._unregister_fatal_error: Callable[[], None] | None = None # Hardware capability tracking — detect when BESS/PV are commissioned # and trigger a reload so the factory creates the appropriate sensors. self._known_capabilities: frozenset[str] | None = None - # Schema validation — run once after first successful refresh + # Per-circuit control settability — detect when the panel changes its + # mind about whether a circuit may be operated, in either direction. + # None until the first snapshot, which is the baseline. + self._known_settability: dict[str, tuple[bool, bool]] | None = None + + # Schema validation — runs once SUCCESSFULLY; a pass that finds no + # metadata yet leaves the flag unset so a later one can still answer. self._schema_validated = False + self._findings: SchemaFindings | None = None + # True once `async_setup_entry` has forwarded the platforms and asked for + # the first reconcile. Before that there are no entities for a finding to + # name; after it, a late first success must reconcile itself. + self._platforms_ready = False + + # Which entities read which snapshot field, recorded by the entities + # themselves as they are added to hass. Authoritative rather than + # reverse-engineered: three platforms build unique_ids three different + # ways, so deriving entity ids from entity descriptions gets most of + # them wrong. See `SpanPanelEntity.async_added_to_hass`. + self._entity_ids_by_field_path: dict[str, set[str]] = {} # Energy dip compensation — sensors append events here during updates; # drained and surfaced as a persistent notification after each cycle. self._pending_dip_events: list[tuple[str, float, float]] = [] - # Circuit energy sensor registry — consumed/produced sensors register - # here so net energy sensors can read their dip offsets directly. - self._circuit_energy_sensors: dict[tuple[str, str], SpanCircuitEnergySensorProtocol] = {} + # Every meter's counter sensors, so each meter's Net Energy can read their + # dip offsets. Keyed by `EnergyCounter` -- never by role and never by + # string -- so a net sensor cannot be registered as an offset source. + self._energy_offset_sources: dict[ + tuple[EnergyMeter, EnergyCounter], EnergyOffsetSource + ] = {} # Current monitor — set by async_setup_entry when monitoring is enabled self.current_monitor: CurrentMonitor | None = None @@ -118,9 +189,6 @@ def __init__( update_interval=update_interval, ) - # Ensure config_entry is properly set after super().__init__ - self.config_entry = config_entry - @property def client(self) -> SpanMqttClient: """Return the underlying panel client for entity control.""" @@ -131,6 +199,34 @@ def panel_offline(self) -> bool: """Return True if the panel is currently offline/unreachable.""" return self._panel_offline + @property + def transport_dead(self) -> bool: + """True once the transport has stopped in a way waiting cannot fix. + + Deliberately not `panel_offline`. Offline means "no data right now", + and every consumer of it is written for a gap that closes: sensors + hold their last reading through the grace period, POWER sensors read + 0.0, the panel-status binary sensor says so, and all of them stay + available because that is the right answer for a broker that drops for + thirty seconds. + + None of it is the right answer for a transport that is not coming + back. The last snapshot was read before the failure and is indefinitely + old; a dashboard cannot tell a held reading from a live one, and 0 W is + not a measurement at all. Entities that read this go unavailable, which + is the one honest state -- it says the value is not knowable rather + than substituting a plausible one. + + Set by the library's fatal-error channel and by the CA branch of the + update path -- today the same condition reached two ways, since a + changed CA is the only failure the library declares terminal. Cleared + by a connection edge back to connected, which is the only evidence that + disproves it; the library refuses to reconnect after a CA change, so in + that case the edge arrives only after a person re-pins and the entry + reloads. + """ + return self._transport_dead + def request_reload(self) -> None: """Request a reload of the integration.""" self._reload_requested = True @@ -157,6 +253,35 @@ def _mark_panel_offline(self, reason: Exception | str) -> None: ) self._panel_offline = True + def _mark_transport_dead(self, reason: Exception | str) -> None: + """Record that the transport has failed terminally and tell the entities. + + Logged at WARNING, once. This condition does need a person -- nothing + here retries, and the Repair raised alongside it is the only way back -- + but it is already reported at ERROR by the library that gave up on the + transport and again by `ca_repairs.async_raise_ca_changed`, which logs + at ERROR and raises an `IssueSeverity.ERROR` Repair. A third copy at + that level says nothing the first two did not. This line's job is + narrower: it records the coordinator's own transition, for whoever is + reading the log around those two. + + The fan-out matters on the fatal-callback path. That path is not an + update cycle -- `last_update_success` is still True and no listener + would otherwise be notified -- so without it every entity would keep + rendering its last value until the fallback poll came round a minute + later and raised `UpdateFailed`. Guarded on the edge so the second + route into the same condition does not re-render everything. + """ + if self._transport_dead: + return + _LOGGER.warning( + "%s transport has stopped and will not recover on its own: %s", + self.config_entry.title or "SPAN Panel", + reason, + ) + self._transport_dead = True + self.async_update_listeners() + # --- Energy dip compensation --- def report_energy_dip(self, entity_id: str, delta: float, cumulative_offset: float) -> None: @@ -167,18 +292,20 @@ def report_energy_dip(self, entity_id: str, delta: float, cumulative_offset: flo """ self._pending_dip_events.append((entity_id, delta, cumulative_offset)) - def register_circuit_energy_sensor( - self, circuit_id: str, energy_type: str, sensor: SpanCircuitEnergySensorProtocol + def register_energy_sensor( + self, meter: EnergyMeter, counter: EnergyCounter, sensor: EnergyOffsetSource ) -> None: - """Register a consumed/produced energy sensor so net energy can read its dip offset.""" - self._circuit_energy_sensors[(circuit_id, energy_type)] = sensor + """Register a meter's counter sensor, so that meter's Net Energy can read its dip offset.""" + self._energy_offset_sources[(meter, counter)] = sensor + + def dip_offset(self, meter: EnergyMeter, counter: EnergyCounter) -> float: + """Return the cumulative dip offset of a meter's counter, or 0.0 with none registered. - def get_circuit_dip_offset(self, circuit_id: str, energy_type: str) -> float: - """Return the cumulative dip offset from the registered sensor, or 0.""" - sensor = self._circuit_energy_sensors.get((circuit_id, energy_type)) - if sensor is None: - return 0.0 - return sensor.energy_offset + A user-disabled counter sensor never registers, so its Net adds nothing for + it; a disabled sensor tracks no offset to be consistent with. + """ + sensor = self._energy_offset_sources.get((meter, counter)) + return 0.0 if sensor is None else sensor.energy_offset async def _fire_dip_notification(self) -> None: """Create a persistent notification summarising energy dips this cycle.""" @@ -218,10 +345,114 @@ async def async_setup_streaming(self) -> None: self._unregister_connection = self._client.register_connection_callback( self._on_connection_change ) + self._unregister_schema_change = self._client.register_schema_change_callback( + self._on_schema_generation_change + ) + # Subscribed here rather than only in `async_setup_entry`, which takes + # the same channel for the Repair, because the two consumers want + # different things from it and neither is the other's business: the + # entry raises something a person can act on, and the coordinator marks + # the transport dead so the entities stop answering. The library fans + # out to every subscriber. + self._unregister_fatal_error = self._client.register_fatal_error_callback( + self._on_fatal_transport_error + ) self._unregister_streaming = self._client.register_snapshot_callback(self._on_snapshot_push) await self._client.start_streaming() _LOGGER.info("MQTT push streaming started") + def _on_schema_generation_change(self, previous: str | None, current: str | None) -> None: + """Reload the entry when the panel changes schema generation underneath us. + + The library rebuilds its parser on its own, which restores *reading* — values + resolve again straight away. It cannot restore *topology*: devices and + entities are created in `async_setup_entry` from the tree as it looked then. + v1.0 introduces a MID the flat tree has no equivalent for and re-keys the + EVSEs, so without a reload the panel reads correctly and still shows the old + device set. Observed exactly that on a live upgrade — data flowed, entities + did not appear, and a manual reload was needed. + + Scheduled rather than awaited: this is called from the client's own callback + fan-out, and reloading the entry tears down that client. `async_schedule_reload` + defers to the loop so the teardown does not run inside the object being torn + down. + """ + _LOGGER.warning( + "SPAN panel firmware upgraded its eBus schema generation: data-model-version " + "%s -> %s. Reloading the integration so devices and entities match the new " + "tree; the MID and any re-keyed chargers appear after the reload.", + previous or "absent (flat)", + current or "absent (flat)", + ) + self.hass.async_create_task( + self._explain_the_upgrade(previous, current), "span_panel_upgrade_notice" + ) + self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) + + async def _explain_the_upgrade(self, previous: str | None, current: str | None) -> None: + """Tell the user what the new schema changed for them, once and durably. + + Nothing they depend on goes away, which is worth saying plainly because a + firmware upgrade invites the opposite assumption. + + `sensor.*_dsm_grid_state` keeps its entity id and its history and gets *more* + trustworthy. Under flat, `schema_0` derived it: the battery's `grid-state` when + one was commissioned, otherwise an inference from `dominant-power-source` and + whether any power was crossing the grid connection. Under v1.0 it reads the + islanding state the MID actually senses -- the heuristic v1.0 exists to retire, + retired. + + `binary_sensor.*_grid_islandable` also survives. v1.0 publishes no panel-level + `grid-islandable`, on purpose: `devices/bess.md` reads backup capability from + the capability set, "a MID `grid` child means premises-segment backup", and + "there is no single 'islanded?' bit to reconcile". So it now reflects MID + presence, the classifier the spec nominates. + + What is genuinely new is the MID device itself and its `grid-state`, the health + of the utility supply, which flat did not report at all. + + **One notice, not two.** This used to be a hardcoded English notification + about the reload followed immediately by a translated Repair about the + consequences -- two rows in two different places for one event, which is + the same duplication that makes people stop reading either. They are now + one message, and it is translated. + + **A notification, not a Repair.** The Repairs list is where defects go: it + stamped this with a severity and offered to ignore it, so an upgrade that + took nothing away arrived looking like a fault. The reason it was a Repair + was durability -- a plain notification dies with the process, and somebody + away when their panel upgraded would never have learned a device appeared. + `notices` supplies that durability directly, so the classification no + longer has to be paid for with a lie about severity. + + Scheduled as a task because reading the translations is blocking file I/O + and the caller is the client's own synchronous callback fan-out. It races + the reload scheduled alongside it and is written to lose safely either + way; see `notices.async_restore`. + """ + # Absent means flat, present means parent/child -- the migration guide's own + # detection rule. + # + # Guarding on the direction even though panel firmware does not roll back: + # once a panel is on v1.0 it stays there, so in the field this only ever fires + # one way. The reverse happens solely in the upgrade rehearsal, where the two + # simulators are swapped under a live client, and announcing a retirement + # there would be noise about a transition no user experiences. + if current is None: + return + text = await self.hass.async_add_executor_job( + read_translations, self.hass.config.language, _UPGRADE_NOTICE + ) + async_raise( + self.hass, + self.config_entry, + _UPGRADE_NOTICE, + title=text.get("title") or _UPGRADE_FALLBACK["title"], + message=(text.get("body") or _UPGRADE_FALLBACK["body"]).format( + previous=previous or "flat", current=current + ), + ) + def _on_connection_change(self, connected: bool) -> None: """Handle a broker connection state edge from the MQTT client. @@ -233,19 +464,46 @@ def _on_connection_change(self, connected: bool) -> None: Listener fan-out is guarded by a real state change so a misbehaving or future-version library that re-emits the same edge does not trigger spurious entity re-renders. + + A connect also drops any standing name-mismatch Repair, and this edge is + the right place for it rather than a snapshot arriving: it is the exact + event the library re-arms its own once-per-outage signal on, so the + notice the user sees and the library's idea of whether it has reported + anything cannot drift apart. Raising is deliberately not done here -- + `async_setup_entry` holds the one subscription that raises, so a mismatch + is reported once however many things are listening. """ was_offline = self._panel_offline + was_dead = self._transport_dead if connected: + async_clear_leaf_name_mismatch(self.hass, self.config_entry) self._mark_panel_online() + # The one thing that disproves a dead transport: it connected. Not + # folded into `_mark_panel_online`, which a successful snapshot also + # calls -- a read that came back cannot say the failure the library + # declared terminal has been resolved, only that this read worked. + self._transport_dead = False else: self._mark_panel_offline("MQTT broker disconnected") - if self._panel_offline != was_offline: + if self._panel_offline != was_offline or self._transport_dead != was_dead: self.async_update_listeners() + @callback + def _on_fatal_transport_error(self, error: SpanPanelError) -> None: + """Take the entities down when the library gives up on the transport. + + The reconnect loop runs fire-and-forget, so a mid-session failure has + no call stack to surface on. Waiting for the next fallback poll to + re-raise it would leave a minute of entities reporting values read + before the transport died. + """ + self._mark_transport_dead(error) + async def _on_snapshot_push(self, snapshot: SpanPanelSnapshot) -> None: """Handle a pushed snapshot from MQTT streaming.""" self._mark_panel_online() self._check_capability_change(snapshot) + self._check_settability_change(snapshot) self.async_set_updated_data(snapshot) await self._run_post_update_tasks(snapshot) @@ -255,6 +513,14 @@ async def async_shutdown(self) -> None: self._unregister_connection() self._unregister_connection = None + if self._unregister_schema_change is not None: + self._unregister_schema_change() + self._unregister_schema_change = None + + if self._unregister_fatal_error is not None: + self._unregister_fatal_error() + self._unregister_fatal_error = None + if self._unregister_streaming is not None: self._unregister_streaming() self._unregister_streaming = None @@ -267,47 +533,116 @@ async def async_shutdown(self) -> None: # --- Schema validation --- def _run_schema_validation(self) -> None: - """Run schema field metadata validation once at startup. + """Classify the adapter's field metadata once at startup. - Compares the library's schema-derived field metadata against the - integration's sensor definitions to detect unit mismatches. Also - reports fields the integration doesn't map to any sensor. + Stores the result for the platforms and the Repairs reconciler to read. """ - field_metadata: dict[str, dict[str, object]] | None = None - if isinstance(self._client, SpanMqttClient): - raw = self._client.field_metadata - if raw is not None: - field_metadata = { - k: {"unit": v.unit, "datatype": v.datatype} for k, v in raw.items() - } + field_metadata = self._client.field_metadata if field_metadata is None: - _LOGGER.debug("Schema validation skipped — no field metadata available") + # "Unknown", NOT "nothing is wrong". `field_metadata` is None for the + # whole _on_pre_rebuild -> retained-message window, and that fires on + # an ORDINARY reconnect (after MQTT_FULL_REBUILD_AFTER_FAILURES), not + # only on a generation change. Reconciling against empty findings here + # would delete every schema issue — and with it every dismissal the + # user has made. Keep the previous findings and skip this pass. + _LOGGER.debug("Schema validation skipped: metadata not available yet") return - sensor_defs = collect_sensor_definitions() - validate_field_metadata(field_metadata, sensor_defs=sensor_defs) + self._findings = evaluate_field_metadata( + field_metadata, sensor_descriptions_by_field_path() + ) + # Only now: metadata is static within a session, so one success is + # enough and re-reading identical inputs on every pass would be waste. + self._schema_validated = True + + if self._platforms_ready: + # A late first success. Setup already passed its reconcile point, so + # nothing else will raise these — do it here, where the entities that + # the findings name are guaranteed to exist. + self._sync_repairs() + + @callback + def async_register_field_path_entity(self, field_path: str, entity_id: str) -> None: + """Record that `entity_id` reads `field_path`. + + Called by the entity itself, which is the only thing that knows both + halves for certain. Circuit, panel-data and binary-sensor entities each + build their unique_id from a different suffix rule, so a mapping derived + from entity descriptions would silently miss most of them. + """ + self._entity_ids_by_field_path.setdefault(field_path, set()).add(entity_id) + + @callback + def async_unregister_field_path_entity(self, field_path: str, entity_id: str) -> None: + """Forget an entity that is leaving hass, so it stops inflating counts.""" + entity_ids = self._entity_ids_by_field_path.get(field_path) + if entity_ids is None: + return + entity_ids.discard(entity_id) + if not entity_ids: + del self._entity_ids_by_field_path[field_path] + + @property + def entity_ids_by_field_path(self) -> dict[str, list[str]]: + """Entities currently in hass, by the snapshot field each one reads.""" + return { + field_path: sorted(entity_ids) + for field_path, entity_ids in self._entity_ids_by_field_path.items() + } + + @callback + def async_sync_schema_repairs(self) -> None: + """Reconcile Repairs now that the platforms are up. + + Called by `async_setup_entry` after the platforms are forwarded, which is + the earliest point the entities a finding names exist: validation runs on + the first refresh, and setup awaits that *before* forwarding anything. + + Also records that the reconcile point has passed, so a validation pass + that first succeeds later reconciles itself rather than waiting for the + next reload. + """ + self._platforms_ready = True + self._sync_repairs() + + def _sync_repairs(self) -> None: + """Reconcile Repairs against the findings, if there are any yet. + + Findings of None means "not yet known", never "healthy" — reconciling + against that would delete every issue and every dismissal with it. + """ + if self._findings is None: + return + async_sync_schema_issues( + self.hass, self.config_entry, self._findings, self.entity_ids_by_field_path + ) + + @property + def unresolved_paths(self) -> frozenset[str]: + """Field paths the adapter could not resolve. Empty when healthy.""" + return self._findings.unresolved if self._findings is not None else frozenset() + + @property + def schema_findings(self) -> SchemaFindings | None: + """Findings from the last completed validation pass, if any.""" + return self._findings # --- Hardware capability detection --- @staticmethod def _detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: - """Derive optional hardware capabilities present in the snapshot.""" - caps: set[str] = set() - if snapshot.battery.soe_percentage is not None: - caps.add("bess") - if snapshot.power_flow_pv is not None or any( - c.device_type == "pv" for c in snapshot.circuits.values() - ): - caps.add("pv") - if snapshot.power_flow_site is not None: - caps.add("power_flows") - if ( - any(c.device_type == "evse" for c in snapshot.circuits.values()) - or len(snapshot.evse) > 0 - ): - caps.add("evse") - return frozenset(caps) + """Derive optional hardware capabilities present in the snapshot. + + Delegates to `helpers.detect_capabilities` rather than deriving its + own set. This was a second, hand-rolled copy that never learned about + `mid`, `shed_forecast`, `bess_telemetry`, `pcs` or `der_link_health` -- + so a panel that gained any of them on a firmware upgrade published the + properties, grew no entities, and requested no reload. The platforms + gate creation on the helper; the reload trigger has to read the same + set or the two silently disagree about what the panel can do. + """ + return detect_capabilities(snapshot) def _check_capability_change(self, snapshot: SpanPanelSnapshot) -> None: """Check if hardware capabilities changed and request reload if expanded.""" @@ -326,6 +661,77 @@ def _check_capability_change(self, snapshot: SpanPanelSnapshot) -> None: self._known_capabilities = current self.request_reload() + # --- Per-circuit control settability --- + + @staticmethod + def _read_settability(snapshot: SpanPanelSnapshot) -> dict[str, tuple[bool, bool]]: + """Answer, for every circuit, which control entities it would get. + + The same two predicates `switch.async_setup_entry` and + `select.async_setup_entry` gate creation on, read here over the whole + snapshot rather than restated. A second copy would drift, and the drift + would be silent: the platforms and the reload trigger disagreeing about + what the panel allows. + """ + return { + circuit_id: ( + circuit_has_a_breaker_switch(circuit), + circuit_has_a_priority_select(circuit), + ) + for circuit_id, circuit in snapshot.circuits.items() + } + + def _check_settability_change(self, snapshot: SpanPanelSnapshot) -> None: + """Request a reload when the panel changes its mind about a circuit. + + Both directions, which is why this lives here rather than on the + entities. A circuit that stops being commandable keeps a switch that + refuses every press -- an entity can see that about itself. A circuit + that *becomes* commandable has no switch and no select at all, so there + is nothing on it to notice; only a reader over every circuit can. + + A reload rather than a quiet availability change, because the entity + should not exist -- or should exist and does not -- under the new + answer, and creating and removing entities is what `async_setup_entry` + is for. The baseline is updated on the same pass that asks for the + reload, so a settled panel costs one dict comprehension per push and a + changed one asks exactly once. + + Only circuits present in *both* readings are judged. A circuit can drop + out of a snapshot and come back -- the platforms guard for exactly that + -- and counting an absence as a settability change would turn a flap + into a reload loop. Membership is still carried forward, so a circuit + that leaves and returns with a different answer is caught on its return. + + One known false positive, upstream of here: a library client rebuilt + mid-session replays retained MQTT topics, and a replay that arrives + partial can present a circuit whose `relay-controllable` has not been + redelivered yet. The absent field reads as True for that one dispatch, + which looks like a settability change and costs one spurious reload per + rebuild. It is a library item (3.1.1), not one this reader can settle -- + a partial snapshot is indistinguishable here from a real change -- and + the reload debounce is what keeps the cost to one. + """ + current = self._read_settability(snapshot) + if self._known_settability is None: + # First snapshot — record baseline + self._known_settability = current + return + + known = self._known_settability + changed = sorted( + circuit_id + for circuit_id, answer in current.items() + if circuit_id in known and known[circuit_id] != answer + ) + self._known_settability = current + if changed: + _LOGGER.info( + "Panel changed which controls it allows on circuit(s) %s — requesting reload", + ", ".join(changed), + ) + self.request_reload() + # --- Solar entity migration (v1 → v2) --- _SOLAR_SUFFIX_TO_DESCRIPTION_KEY: dict[str, str] = { @@ -444,9 +850,12 @@ async def _run_post_update_tasks(self, snapshot: SpanPanelSnapshot) -> None: streaming the polling path effectively never fires. This shared method ensures reload requests are processed regardless of transport mode. """ - # One-shot schema validation after first successful refresh + # Schema validation: at most once SUCCESSFULLY, retried only while the + # answer is still unknown. The guard is set inside `_run_schema_validation` + # for that reason — setting it here disabled the feature for the life of + # the entry whenever the very first pass landed in the metadata-not-ready + # window, which an ordinary reconnect opens. if not self._schema_validated: - self._schema_validated = True self._run_schema_validation() # Check for pending solar entity migration (v1 solar → v2 PV circuit) @@ -490,8 +899,29 @@ async def _async_update_data(self) -> SpanPanelSnapshot: # Check for new hardware capabilities (BESS, PV, power-flows) self._check_capability_change(snapshot) + # Check whether the panel changed which circuits it will let a user + # operate. Both transports run it: the fallback poll is the only + # path a panel with no live MQTT stream has. + self._check_settability_change(snapshot) + await self._run_post_update_tasks(snapshot) + except SpanPanelCAChangedError as err: + # Not an outage, and must not be marked as one. The offline branch + # below keeps serving the last snapshot so a brief broker drop does + # not blank the dashboard, which is right for a transport that will + # come back and wrong for one that has stopped for good: it would + # leave every entity showing a plausible value read before the pin + # broke. Marking it offline used to do exactly that -- `available` + # returns True on the offline shortcut, ahead of `UpdateFailed`, so + # the sensors stayed available and the POWER ones read 0 W. + # + # Nothing is retried or torn down here. The library refuses to + # reconnect, the Repair is raised from the fatal-error channel in + # `async_setup_entry`, and re-pinning requires a person. + self._mark_transport_dead(err) + raise UpdateFailed(str(err)) from err + except SpanPanelAuthError as err: raise ConfigEntryAuthFailed from err diff --git a/custom_components/span_panel/curation.py b/custom_components/span_panel/curation.py new file mode 100644 index 00000000..92385072 --- /dev/null +++ b/custom_components/span_panel/curation.py @@ -0,0 +1,425 @@ +"""User-supplied metadata for adopted entities. + +Adopted entities arrive with deliberately minimal metadata because the +integration refuses to guess. A user who owns the vendor device is not +guessing: this module stores their assertions -- `state_class`, +`device_class`, and prominence -- keyed by a scope-prefixed wire address, +validates them against what the wire actually declares, and hands the +platforms fully-formed entity descriptions at construction. + +**This is the one module allowed to spell `state_class`.** `adoption.py` and +`extension.py` carry AST guards asserting the token never appears there; the +description helpers below are how a curated record becomes an entity +description without either module naming the thing it must not set. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import logging +from typing import TYPE_CHECKING, Final, TypedDict + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) + +# `homeassistant.components.sensor` re-exports both at runtime but leaves them +# out of its `__all__`, so the package-level import is an `attr-defined` error +# under mypy. `.const` is where they are actually defined and is the path that +# type-checks. +from homeassistant.components.sensor.const import ( + DEVICE_CLASS_UNITS, + NON_NUMERIC_DEVICE_CLASSES, +) +from homeassistant.const import EntityCategory, Platform +from homeassistant.helpers.storage import Store + +from .const import DOMAIN +from .util import NUMERIC_DATATYPES, declares_a_number + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +PROMOTED: Final = "none" +"""The one storable `entity_category` value: promoted out of diagnostics.""" + + +@dataclass(frozen=True, slots=True) +class RowContext: + """What one adopted row declares, as far as validation needs it.""" + + platform: Platform + datatype: str + unit: str | None + + +@dataclass(frozen=True, slots=True) +class CurationRecord: + """One row's user-asserted metadata. Absence of a field means default.""" + + state_class: SensorStateClass | None = None + device_class: str | None = None + promote: bool = False + + +class CurationError(Exception): + """A record that cannot be stored, with a websocket-ready error code.""" + + def __init__(self, code: str, message: str) -> None: + """Carry a stable code beside the message, because the two have different readers. + + The message says what is wrong in English and is for a log; the code is + what the websocket hands the frontend, so it has to survive rewording. + """ + super().__init__(message) + self.code = code + + +def _validate_state_class(value: object, context: RowContext) -> SensorStateClass: + if context.platform is not Platform.SENSOR or context.datatype not in NUMERIC_DATATYPES: + raise CurationError( + "invalid_state_class", + f"a state class needs a numeric sensor row, not {context.platform.value}" + f"/{context.datatype}", + ) + try: + return SensorStateClass(str(value)) + except ValueError as err: + raise CurationError("invalid_state_class", f"unknown state class {value!r}") from err + + +def _admits_datatype(device_class: SensorDeviceClass, context: RowContext) -> bool: + """Say whether a sensor device class can describe a value of this row's kind. + + Core splits its own sensor device classes in two -- the four in + `NON_NUMERIC_DEVICE_CLASSES` describe a date, an option or a moment, and + every other one describes a number -- and the declaration says which kind of + value the row carries. Crossing the two produces an entity that reads + `unknown` for the life of the install: `power_factor` behind a text reading + has no number to show, and `enum` behind a float has no option list any + publisher could supply. + + Which side a row falls on is `declares_a_number`'s answer, not the datatype + alone, because that is the same question `AdoptedSensor.native_value` asks + when it decides whether to parse. A row read as a float whose only offered + device classes were the non-numeric four would be offered nothing it could + use -- and core refuses to render a state carrying both a unit and a + non-numeric device class at all, so every such offer would raise. + """ + numeric_row = declares_a_number(context.datatype, context.unit) + if device_class in NON_NUMERIC_DEVICE_CLASSES: + return not numeric_row + return numeric_row + + +def _validate_device_class(value: object, context: RowContext) -> str: + if context.platform is Platform.BINARY_SENSOR: + try: + return BinarySensorDeviceClass(str(value)).value + except ValueError as err: + raise CurationError( + "invalid_device_class", f"unknown binary sensor device class {value!r}" + ) from err + if context.platform is not Platform.SENSOR: + raise CurationError( + "invalid_field_for_platform", + "a control row accepts prominence only, not a device class", + ) + try: + device_class = SensorDeviceClass(str(value)) + except ValueError as err: + raise CurationError("invalid_device_class", f"unknown device class {value!r}") from err + if not _admits_datatype(device_class, context): + raise CurationError( + "incompatible_device_class", + f"{device_class.value} does not admit the declared datatype {context.datatype!r}", + ) + constrained = DEVICE_CLASS_UNITS.get(device_class) + if constrained is not None and context.unit not in constrained: + raise CurationError( + "incompatible_device_class", + f"{device_class.value} does not admit the declared unit {context.unit!r}", + ) + return device_class.value + + +def validate_record(raw: Mapping[str, object], context: RowContext) -> CurationRecord: + """Turn a websocket record payload into a `CurationRecord`, or refuse it. + + Refuse rather than warn: a stored record is applied unattended at every + future setup. Cross-field checks only -- field shape and enum membership + are already constrained in the websocket schema, and are re-checked here + because the same validator also re-runs at construction (see `sanitise`). + """ + state_class: SensorStateClass | None = None + device_class: str | None = None + promote = False + if "state_class" in raw: + if context.platform not in (Platform.SENSOR, Platform.BINARY_SENSOR): + raise CurationError( + "invalid_field_for_platform", "a control row accepts prominence only" + ) + state_class = _validate_state_class(raw["state_class"], context) + if "device_class" in raw: + device_class = _validate_device_class(raw["device_class"], context) + if "entity_category" in raw: + if raw["entity_category"] != PROMOTED: + raise CurationError("invalid_entity_category", f"only {PROMOTED!r} is storable") + promote = True + return CurationRecord(state_class=state_class, device_class=device_class, promote=promote) + + +def record_as_dict(record: CurationRecord) -> dict[str, str]: + """Return the storable/wire form of a record: present fields only.""" + raw: dict[str, str] = {} + if record.state_class is not None: + raw["state_class"] = record.state_class.value + if record.device_class is not None: + raw["device_class"] = record.device_class + if record.promote: + raw["entity_category"] = PROMOTED + return raw + + +def parse_record(raw: object) -> CurationRecord | None: + """Read one stored record, or `None` when the disk holds something else. + + Shape-only: staleness against the wire is `sanitise`'s job, because the + wire is not available at load time and may change between loads. + """ + if not isinstance(raw, Mapping): + return None + known = {"state_class", "device_class", "entity_category"} + if not raw or not set(raw) <= known: + return None + state_class: SensorStateClass | None = None + if "state_class" in raw: + try: + state_class = SensorStateClass(str(raw["state_class"])) + except ValueError: + return None + device_class = str(raw["device_class"]) if "device_class" in raw else None + if "entity_category" in raw and raw["entity_category"] != PROMOTED: + return None + return CurationRecord( + state_class=state_class, device_class=device_class, promote="entity_category" in raw + ) + + +def sanitise(record: CurationRecord, context: RowContext) -> tuple[CurationRecord, tuple[str, ...]]: + """Drop the fields of a record the current declaration no longer supports. + + A record can go stale between save and a later setup -- the vendor may + change a row's unit or datatype. Each field is re-validated independently + and a failing one is dropped rather than failing the record: the wire may + revert, and the user's other assertions are still good. + """ + kept: dict[str, object] = {} + dropped: list[str] = [] + for field, value in record_as_dict(record).items(): + try: + validate_record({field: value}, context) + except CurationError: + dropped.append(field) + else: + kept[field] = value + return validate_record(kept, context) if kept else CurationRecord(), tuple(dropped) + + +def allowed_state_classes(context: RowContext) -> list[str]: + """Return the state classes a row of this shape may assert. Empty off numeric sensors.""" + if context.platform is not Platform.SENSOR or context.datatype not in NUMERIC_DATATYPES: + return [] + return [cls.value for cls in SensorStateClass] + + +def allowed_device_classes(context: RowContext) -> list[str]: + """Return the device classes this row's platform, datatype and declared unit admit. + + The same two checks `_validate_device_class` applies, in the same order, so + the editor never offers a class the validator would then refuse. + """ + if context.platform is Platform.BINARY_SENSOR: + return [cls.value for cls in BinarySensorDeviceClass] + if context.platform is not Platform.SENSOR: + return [] + allowed: list[str] = [] + for device_class in SensorDeviceClass: + if not _admits_datatype(device_class, context): + continue + constrained = DEVICE_CLASS_UNITS.get(device_class) + if constrained is None or context.unit in constrained: + allowed.append(device_class.value) + return allowed + + +_STORE_VERSION: Final = 1 + + +class StoredCuration(TypedDict): + """The one shape this module writes to disk.""" + + records: dict[str, dict[str, str]] + + +def _store(hass: HomeAssistant, entry: ConfigEntry) -> Store[StoredCuration]: + """Per entry, exactly as `additions._store` is: two panels curate independently.""" + return Store(hass, _STORE_VERSION, f"{DOMAIN}.curation.{entry.entry_id}") + + +class CurationOverlay: + """Every stored record for one entry, resolved once per setup.""" + + def __init__(self, records: Mapping[str, CurationRecord]) -> None: + """Take a copy: the overlay is read once at setup and never re-reads the disk.""" + self._records = dict(records) + + @classmethod + def empty(cls) -> CurationOverlay: + """Return the overlay for an entry that has curated nothing.""" + return cls({}) + + def record_for(self, key: str) -> CurationRecord | None: + """Return the record as stored, unmeasured against any row -- see `for_row`.""" + return self._records.get(key) + + def for_row(self, key: str, context: RowContext) -> CurationRecord | None: + """Return the record as it applies to the row's *current* declaration. + + Curation must never block setup: a field the wire no longer supports + is dropped with one warning, never raised. + """ + record = self._records.get(key) + if record is None: + return None + sanitised, dropped = sanitise(record, context) + if dropped: + _LOGGER.warning( + "Curation for %s no longer fits the published declaration; ignoring %s", + key, + ", ".join(dropped), + ) + return sanitised + + def stale_fields(self, key: str, context: RowContext) -> tuple[str, ...]: + """Name the fields `for_row` would drop, so the editor can say which are stale.""" + record = self._records.get(key) + if record is None: + return () + return sanitise(record, context)[1] + + def as_dicts(self) -> dict[str, dict[str, str]]: + """Enum values and keys only -- safe for diagnostics and the list command.""" + return {key: record_as_dict(record) for key, record in sorted(self._records.items())} + + +async def async_load_curation(hass: HomeAssistant, entry: ConfigEntry) -> CurationOverlay: + """Read the overlay off disk. A store this module did not write loads as empty. + + Awaited from `async_setup_entry`, so nothing here may raise for bad disk + content -- see `additions._load` for the incident that rule comes from. + """ + stored = await _store(hass, entry).async_load() + records: dict[str, CurationRecord] = {} + raw_records = stored.get("records") if isinstance(stored, dict) else None + if isinstance(raw_records, dict): + for key, raw in raw_records.items(): + record = parse_record(raw) + if isinstance(key, str) and record is not None: + records[key] = record + else: + _LOGGER.warning("Discarding unreadable curation record under %r", key) + elif stored is not None: + _LOGGER.warning("Curation store is not the shape this integration writes; starting empty") + return CurationOverlay(records) + + +async def async_save_record( + hass: HomeAssistant, entry: ConfigEntry, key: str, record: CurationRecord | None +) -> None: + """Write one record (or clear one) and leave every other key untouched. + + A record asserting nothing clears the key rather than being stored, because + its stored form is `{}` and `parse_record` refuses that. Writing it would + leave a record on disk that the next load reports as unreadable -- the + warning meant for a damaged or hand-edited store -- and the save after that + would delete, over a value this signature accepts. Save may not write what + load rejects. + """ + store = _store(hass, entry) + raw_records = _readable_records(await store.async_load()) + fields = record_as_dict(record) if record is not None else {} + if fields: + raw_records[key] = fields + else: + raw_records.pop(key, None) + await store.async_save({"records": raw_records}) + + +def _readable_records(stored: StoredCuration | None) -> dict[str, dict[str, str]]: + """Return the stored records `parse_record` accepts, as the raw dicts a save writes. + + Every writer reads through this, so none can write back what the loader + would reject. + """ + raw_records: dict[str, dict[str, str]] = {} + if isinstance(stored, dict) and isinstance(stored.get("records"), dict): + for existing_key, raw in stored["records"].items(): + if isinstance(existing_key, str) and parse_record(raw) is not None: + raw_records[existing_key] = dict(raw) + return raw_records + + +async def async_forget_curation(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the curated records when the entry is removed. + + The keys are wire addresses, not registry ids, so a store left behind would + be picked back up by whatever entry is next added for the same panel -- and + silently re-assert metadata the user removed the panel to be rid of. + """ + await _store(hass, entry).async_remove() + + +def sensor_description( + path: str, + unit: str | None, + default_device_class: SensorDeviceClass | None, + record: CurationRecord | None, +) -> SensorEntityDescription: + """Return the description a (possibly curated) adopted sensor is built from. + + The one place in the integration where a `state_class` reaches an adopted + entity. `adoption.py` and `extension.py` call this rather than building + their own description, which is what keeps their AST guards true. + """ + device_class = default_device_class + state_class: SensorStateClass | None = None + if record is not None: + if record.device_class is not None: + device_class = SensorDeviceClass(record.device_class) + state_class = record.state_class + return SensorEntityDescription( + key=path, + device_class=device_class, + native_unit_of_measurement=unit, + state_class=state_class, + ) + + +def binary_sensor_device_class(record: CurationRecord | None) -> BinarySensorDeviceClass | None: + """Return a curated binary device class, or none -- there is no unit map to default from.""" + if record is None or record.device_class is None: + return None + return BinarySensorDeviceClass(record.device_class) + + +def entity_category_for(record: CurationRecord | None) -> EntityCategory | None: + """Return DIAGNOSTIC unless the user explicitly promoted this row.""" + return None if record is not None and record.promote else EntityCategory.DIAGNOSTIC diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index 0c46cc6e..f19928ab 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -2,27 +2,339 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any, TypedDict from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from span_panel_api import SpanPanelSnapshot, ca_fingerprint +from span_panel_api.exceptions import SpanPanelValidationError -from . import SpanPanelConfigEntry +from .adoption import adopted_control_count, classify from .const import ( CONF_EBUS_BROKER_PASSWORD, CONF_EBUS_BROKER_USERNAME, CONF_HOP_PASSPHRASE, + CONF_PANEL_CA_PEM, + PANEL_CA_PENDING, ) +from .helpers import identity_digest +from .runtime import SpanPanelConfigEntry +from .schema_validation import SchemaFindings + +if TYPE_CHECKING: + from .pv_binding import PvBinding, PvBindingMode TO_REDACT = { CONF_ACCESS_TOKEN, CONF_EBUS_BROKER_PASSWORD, CONF_EBUS_BROKER_USERNAME, + # No longer persisted as of entry version 7, but an entry that has not yet + # been through the migration still carries it. CONF_HOP_PASSPHRASE, + # Not a secret — it is a public certificate — but multi-KB, and it would + # drown the dump. The diagnostically useful value is its fingerprint, which + # is injected separately below; `async_redact_data` is key-based and cannot + # transform a value. + CONF_PANEL_CA_PEM, "password", "username", } +"""Config-entry keys whose values are replaced before the payload leaves. + +Key-based, and only over `entry.as_dict()`. It knows nothing about wire +property names and cannot be taught them cheaply, so anything added to this +payload from the panel has to be safe by construction rather than by redaction. +That is the constraint `_discovery` is built to — see its docstring. +""" + + +class EntityRow(TypedDict): + """One registry entry, as it appears in the payload.""" + + entity_id: str + unique_id: str + disabled_by: str | None + hidden_by: str | None + device_id: str | None + + +def _entity_registry_rows(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> list[EntityRow]: + """Every registry entry this config entry owns, with the fields that explain it. + + The registry is where an upgrade complaint is actually settled, and none of it + is reachable from the UI: Home Assistant shows "This entity is disabled" and + not *by what*, and a user without shell access to `.storage` has no way to + read it. Diagnosing a sensor that came back disabled after an upgrade meant + guessing between four causes that look identical on screen. + + `disabled_by` names which of them it was -- `integration` is the registration + default having been applied, which only happens to an entity the registry + considers new; `user`, `config_entry` and `device` are three different things + and three different fixes. `unique_id` answers the question underneath it, + because an entity whose id changed is a new entity no matter how familiar its + name looks, and that is the one answer that would make an upgrade a defect + rather than a surprise. + + Safe by construction rather than by redaction, which is the rule this payload + is built to: every field is registry bookkeeping. `unique_id` embeds the panel + serial, which `panel.serial_number` already carries. + """ + registry = er.async_get(hass) + return sorted( + ( + EntityRow( + entity_id=registry_entry.entity_id, + unique_id=registry_entry.unique_id, + disabled_by=registry_entry.disabled_by.value + if registry_entry.disabled_by + else None, + hidden_by=registry_entry.hidden_by.value if registry_entry.hidden_by else None, + device_id=registry_entry.device_id, + ) + for registry_entry in er.async_entries_for_config_entry(registry, entry.entry_id) + ), + key=lambda row: row["entity_id"], + ) + + +class DiscoveredRow(TypedDict): + """One declared-but-unread property, as it appears in the payload.""" + + path: str + datatype: str + unit: str | None + retained: bool | None + + +class DiscoveryBlock(TypedDict): + """The `schema_discovery` section. Typed so the shape is checked, not described. + + Four keys per row and no more: a fifth would be the seam a wire value slips + through, and this is what makes adding one a type error rather than a review + comment. + """ + + available: bool + count: int + properties: list[DiscoveredRow] + + +def _discovery(findings: SchemaFindings | None) -> DiscoveryBlock: + """Report what the panel declares that this integration reads nothing from. + + Maintainer-facing, and the reason it is here rather than anywhere a user + looks: a diagnostics attachment on an issue is where the question "what does + that fleet publish that we ignore" actually gets asked, and aggregating a + few attachments is how the rate gets measured. Nothing is created from it — + no entity, no Repair, no notification. + + **Declarations, never values.** Each row is the property's path, its declared + datatype and unit, and whether the panel has published a value for it. A + diagnostics payload leaves the house into issues and forum posts, and + `TO_REDACT` above is key-based over the config entry — it could not protect a + wire value put here, so no wire value is put here. `test_diagnostics` asserts + that against the adapter's reference capture rather than leaving it to review. + + `available` is False while the adapter has not reported metadata yet, which + is a real state on a reconnect. It is not the same as an empty report: an + empty `properties` list means the panel declares nothing this integration + ignores, which is a finding. + """ + if findings is None: + return {"available": False, "count": 0, "properties": []} + return { + "available": True, + "count": len(findings.discovered), + "properties": [ + { + "path": entry.path, + "datatype": entry.datatype, + "unit": entry.unit, + "retained": entry.retained, + } + for entry in findings.discovered + ], + } + + +class AdoptedDeviceRow(TypedDict): + """One adopted device, as it appears in the payload. + + No `name`, no `serial_number`. Both are on `AdoptedDevice` and both reach the + device card, but neither answers the question this block exists to ask -- and + a vendor-set device name is free text a household chose. The type, the shape + and the counts are what a maintainer needs. + + **`proxied` rather than `parent`.** The parent is a device id, and a device id + can embed a serial: producers derive a DER's id preferring a serial over a + default slug, which is why this repository holds PV's `info/serial-number` + unvalued. Reporting the id verbatim would leak the serial this block + deliberately withholds. The boolean answers the question a maintainer + actually has -- has a *proxied* unmodelled device appeared -- and carries no + identity. + """ + + device_type: str + model: str | None + proxied: bool + property_count: int + properties: list[AdoptedPropertyRow] + + +class AdoptedPropertyRow(TypedDict): + """One adopted property's declaration, with no value. + + The same rule `DiscoveredRow` follows and for the same reason: `TO_REDACT` is + key-based over the config entry and cannot protect a wire value put here. + `platform` is included because it is derived from the other three and is the + decision a maintainer would otherwise recompute by hand. + """ + + path: str + datatype: str + unit: str | None + settable: bool + platform: str + + +class AdoptionBlock(TypedDict): + """The `adopted_devices` section. + + `controls` counts the adopted properties that write back to the panel rather + than only reporting. It is the highest-consequence thing adoption creates, so + it is worth a number in the one artefact that reaches a maintainer. + """ + + count: int + controls: int + devices: list[AdoptedDeviceRow] + + +def _adoption(snapshot: SpanPanelSnapshot) -> AdoptionBlock: + """Report the devices this integration models nothing for. + + Declarations, never values -- see `AdoptedPropertyRow`. Empty on every panel + that publishes only device types this integration reads, which is every panel + seen so far, and a non-empty block is the first evidence that the schema's + vendor extensibility is being used in the field. + """ + return { + "count": len(snapshot.adopted_devices), + "controls": adopted_control_count(snapshot), + "devices": [ + { + "device_type": device.device_type, + "model": device.model, + "proxied": device.proxied, + "property_count": len(device.properties), + "properties": [ + { + "path": declaration.path, + "datatype": declaration.datatype, + "unit": declaration.unit, + "settable": declaration.settable, + "platform": classify(declaration).value, + } + for declaration in device.properties + ], + } + for device in snapshot.adopted_devices + ], + } + + +class PvInverterRow(TypedDict): + """One inverter in the `pv` section: where it is fed from, and whether it has a card and a link of its own.""" + + feed_circuit_id: str | None + relative_position: str | None + connected: bool | None + own_card: bool + own_link: bool + + +class PvBlock(TypedDict): + """The `pv` section. Typed so the shape is checked, not described. + + Every key that names an inverter goes through `_pv`'s `shown`, so a device-id + key cannot reach the payload undigested by a field added beside the others. + """ + + mode: PvBindingMode + bound_circuit_id: str | None + legacy_key: str | None + solar_card_reads: str | None + solar_link: bool + withheld: list[str] + inverters: dict[str, PvInverterRow] + + +def _pv(snapshot: SpanPanelSnapshot, identity: PvBinding) -> PvBlock: + """Which inverter the Solar card reads, and each inverter's place. + + A device-id key can embed the panel serial (`-`), + so it is digested as the capability tokens are; a circuit id is shown as + the `circuits` block already shows it. `legacy_key` is the inverter the Solar + card's PV entities describe at this setup, and `solar_card_reads` adds what + they read when no one inverter is it: the inverters together, or nothing yet. + """ + + def shown(key: str) -> str: + pv = snapshot.pv_inverters.get(key) + if key == identity.bound_key or (pv is not None and pv.feed_circuit_id is not None): + return key + return identity_digest(key) + + legacy = None if identity.legacy_key is None else shown(identity.legacy_key) + # What the Solar card's PV entities read: one inverter, the inverters + # together (unbound with several), or nothing yet. + reads: str | None + if legacy is not None: + reads = legacy + elif identity.bound_key is not None: + reads = identity.bound_key + elif identity.mode == "unbound" and len(snapshot.pv_inverters) > 1: + reads = "together" + else: + reads = None + + return { + "mode": identity.mode, + "bound_circuit_id": identity.bound_key, + "legacy_key": legacy, + "solar_card_reads": reads, + "solar_link": identity.solar_link, + "withheld": sorted(shown(key) for key in identity.withheld), + "inverters": { + shown(key): { + "feed_circuit_id": pv.feed_circuit_id, + "relative_position": pv.relative_position, + "connected": pv.connected, + "own_card": identity.has_own_card(key), + "own_link": key in identity.inverter_links, + } + for key, pv in snapshot.pv_inverters.items() + }, + } + + +def _panel_ca(entry: SpanPanelConfigEntry) -> dict[str, Any]: + """How this entry's broker connection is anchored, without the PEM itself. + + The fingerprint is what a support conversation actually needs: it says + whether two installs see the same panel CA, and whether the anchor moved. + A malformed stored PEM is reported as such rather than raising — diagnostics + that fail to render are worse than diagnostics that name the problem. + """ + pem = entry.data.get(CONF_PANEL_CA_PEM) + if not pem: + return {"pinned": False, "pending": bool(entry.data.get(PANEL_CA_PENDING))} + try: + return {"pinned": True, "pending": False, "fingerprint": ca_fingerprint(str(pem))} + except SpanPanelValidationError as err: + return {"pinned": True, "pending": False, "fingerprint_error": str(err)} async def async_get_config_entry_diagnostics( @@ -37,6 +349,13 @@ async def async_get_config_entry_diagnostics( "serial_number": snapshot.serial_number, "firmware_version": snapshot.firmware_version, "panel_size": snapshot.panel_size, + # Whether the upstream lugs are the utility connection point. First thing + # worth knowing on a "my grid sensor reads wrong" report: where this is + # False, `instant_grid_power_w` is this panel's feed rather than the + # site's grid, and the two grid figures differ legitimately. + "lugs_at_service_entrance": snapshot.lugs_at_service_entrance, + "instant_grid_power_w": snapshot.instant_grid_power_w, + "power_flow_grid": snapshot.power_flow_grid, } if snapshot.wifi_ssid is not None: @@ -58,11 +377,9 @@ async def async_get_config_entry_diagnostics( "instant_power_w": circuit.instant_power_w, "produced_energy_wh": circuit.produced_energy_wh, "consumed_energy_wh": circuit.consumed_energy_wh, + "device_type": circuit.device_type, + "tabs": circuit.tabs, } - if hasattr(circuit, "device_type"): - circuit_data[circuit_id]["device_type"] = circuit.device_type - if hasattr(circuit, "tabs"): - circuit_data[circuit_id]["tabs"] = circuit.tabs evse_data: dict[str, dict[str, Any]] = {} if snapshot.evse: @@ -85,12 +402,21 @@ async def async_get_config_entry_diagnostics( return { "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), + "panel_ca": _panel_ca(entry), + "entities": _entity_registry_rows(hass, entry), "panel": panel_data, "circuits": circuit_data, "evse": evse_data, "battery": battery_data, + "pv": _pv(snapshot, entry.runtime_data.pv_binding), "coordinator": { "panel_offline": coordinator.panel_offline, "last_update_success": coordinator.last_update_success, }, + "schema_discovery": _discovery(coordinator.schema_findings), + "adopted_devices": _adoption(snapshot), + # Keys and enum values only -- no wire values, no user free text (names + # and icons live in Core's registry, not here). Same withholding rules + # as the adoption block above. + "adopted_curation": entry.runtime_data.curation.as_dicts(), } diff --git a/custom_components/span_panel/energy_dip.py b/custom_components/span_panel/energy_dip.py index 515b7b38..69b85d9f 100644 --- a/custom_components/span_panel/energy_dip.py +++ b/custom_components/span_panel/energy_dip.py @@ -3,40 +3,310 @@ Pure functions for detecting energy value dips (typically caused by panel firmware resets) and computing the compensated value to maintain monotonic TOTAL_INCREASING sensor readings. + +A dip is compensated as soon as it is seen, because the sensor must stay correct +in the meantime, but the *conclusion* that a reset happened is held open until a +later reading settles it. That is the difference between this and a plain +"lower reading means reset" rule, and the reason is that the write is +irreversible: the offset lands in Home Assistant's long-term statistics, where +undoing it is a manual statistics adjustment per affected hour. + +Corroboration is evidence rather than proof, so it is not the end of the record +either. A burst of stale samples can deliver a low reading and then a slightly +higher low reading before the true value arrives, which looks exactly like a +counter starting to climb from a fresh base. A corroborated dip is therefore +kept for `CONFIRMED_RETRACTION_WINDOW` further readings, during which a return +to its baseline still retracts it. When the window closes the dip *settles*: +only then is the offset final, and only then is the user told a reset happened. + +The two records are kept apart. A dip that occurs inside another dip's window is +its own dip, with its own baseline — the reading it fell from, not the older +high-water mark — because that is what it has to return to in order to be +undone. Folding them together would leave a small artefact judged against a +baseline it can never reach, and so compensated for good. + +See `process_energy_dip` for the rule and `tests/test_energy_dip_retraction.py` +for the sequences it is written against. """ from __future__ import annotations -from typing import Any +from enum import StrEnum +from typing import Any, NamedTuple + +DIP_THRESHOLD_WH = 1.0 +"""How far a counter must fall before the drop counts as a dip. + +Also how far it must return before the drop counts as undone, so the two +decisions are made against the same tolerance rather than one being exact. +""" + + +CONFIRMED_RETRACTION_WINDOW = 3 +"""How many readings a corroborated dip stays retractable for. + +Three is the shortest window that covers the failure it exists for: a debounced +snapshot burst replaying two or three stale samples before the true reading +lands. What it costs is a genuine reset that, within its next three readings, +climbs the whole way back to the counter's old lifetime total — which would mean +a circuit consuming its entire recorded history in a few polling intervals, so +the exchange is a physically implausible mis-retraction for a plausible +mis-confirmation. Longer would be safer against replays and costlier here; the +bound exists so the offset does become final rather than staying open forever. +""" + + +class PendingDip(NamedTuple): + """A booked dip that is still, in some degree, provisional. + + `baseline` is what the counter read before it — the mark a recovery is + judged against, which does not move if the counter falls again. `delta` is + how much has been added to the offset provisionally, and is what gets + removed if the dip turns out not to have happened. + + `confirmed_ticks_left` is `None` for an unsettled dip, which waits + open-endedly for evidence either way. On a corroborated dip it holds how + many further readings may still retract it; when that runs out the dip + settles and its offset is final. + """ + + baseline: float + delta: float + confirmed_ticks_left: int | None = None + + +def _count_down(record: PendingDip | None) -> PendingDip | None: + """Spend one reading of a corroborated record's retraction window. + + Nothing, and an unsettled record, come back as they are — neither has a + window to spend. A corroborated record whose window runs out here is + dropped, which is what settling is. + """ + if record is None or record.confirmed_ticks_left is None: + return record + + remaining = record.confirmed_ticks_left - 1 + return record._replace(confirmed_ticks_left=remaining) if remaining > 0 else None + + +def _settled_by(before: PendingDip | None, after: PendingDip | None) -> float | None: + """Return what a record made final by running out of window, if it did.""" + return before.delta if before is not None and after is None else None + + +def _has_returned(raw_value: float, record: PendingDip) -> bool: + """Whether a reading is back at a record's own baseline, within tolerance.""" + return raw_value >= record.baseline - DIP_THRESHOLD_WH + + +class DipEvent(StrEnum): + """What a reading did to the provisional state, when it did anything.""" + + BOOKED = "booked" + """A drop was seen and compensated. Not yet believed.""" + + CONFIRMED = "confirmed" + """The counter counted up from the lower base, so the reset is believed. + + Believed, not closed: the dip stays retractable for + `CONFIRMED_RETRACTION_WINDOW` more readings, and is reported when it + settles rather than here. + """ + + RETRACTED = "retracted" + """The counter came back, so no reset happened and the offset is removed.""" + + SETTLED = "settled" + """A corroborated dip ran out of window, so its offset is now final. + + This is the point at which the reset is worth telling the user about, + because it is the first point at which nothing can take it back. + """ + + +class DipOutcome(NamedTuple): + """The result of feeding one raw reading through the rule.""" + + offset: float + pending: PendingDip | None + compensated: float + event: DipEvent | None + delta: float | None + """The amount the event concerns, or None when nothing happened.""" + + recently_confirmed: PendingDip | None = None + """A corroborated dip still inside its retraction window, if there is one.""" + + settled: float | None = None + """What settled on this reading, if anything. + + Carried separately from `event` because a dip can settle on the same + reading that books or retracts another one, and the caller must report it + either way. + """ + + +def _book( + raw_value: float, + last_panel_reading: float, + current_offset: float, + pending: PendingDip | None, + recently_confirmed: PendingDip | None, +) -> DipOutcome: + """Compensate a fall, and age whatever was already provisional. + + A fall cannot also be a return: every reading since either record was booked + has been below that record's baseline, and this one is below the last of + them. So a corroborated dip only ages here, and the fall is a dip in its own + right — booked against the reading it fell from, or accumulated into an + unsettled dip that is still waiting on the same question. + """ + dip = last_panel_reading - raw_value + offset = current_offset + dip + booked = ( + PendingDip(baseline=last_panel_reading, delta=dip) + if pending is None + else PendingDip(baseline=pending.baseline, delta=pending.delta + dip) + ) + aged = _count_down(recently_confirmed) + return DipOutcome( + offset, + booked, + raw_value + offset, + DipEvent.BOOKED, + dip, + aged, + _settled_by(recently_confirmed, aged), + ) def process_energy_dip( raw_value: float, last_panel_reading: float | None, current_offset: float, -) -> tuple[float, float | None, float]: - """Detect an energy dip and return updated offset, optional dip delta, and compensated value. + pending: PendingDip | None, + recently_confirmed: PendingDip | None = None, +) -> DipOutcome: + """Advance dip compensation by one reading. + + A firmware counter reset is permanent: the counter restarts low and counts + up from there. So a reading that drops and then returns to where it was is + not a reset — it is a transport artefact, a stale sample, or a reading the + panel never actually made. Both look identical on the tick they arrive, and + that single sample used to be enough to add a circuit's whole lifetime + counter to its offset for good (`SpanPanel/span#259`). + + The rule keeps the compensation and defers the belief: + + - **A drop of at least `DIP_THRESHOLD_WH`** is compensated immediately and + recorded as pending. The compensated value does not move on this tick, + which is why the spike shows up one sample later. + - **A later reading back at the baseline** (within the same tolerance) + retracts it: the counter never reset, so the offset is removed. + - **A later reading above the dipped one but still below the baseline** + corroborates it: the counter is demonstrably counting up from the lower + base, which only a reset produces. + - **A repeat of the dipped reading** settles nothing and is held. + - **A return to the baseline in the `CONFIRMED_RETRACTION_WINDOW` readings + after that** retracts it after all, because a replayed burst can produce + corroborating progress out of two stale samples. + - **The reading that closes the window** settles the dip. The offset is + final and the reset is reported. - A "dip" occurs when the panel reports a raw energy value that is at least 1.0 - lower than the previous reading (e.g., after a firmware reset). When detected, - the difference is added to the cumulative offset so downstream sensors keep a - monotonically increasing total. + That fourth case is why corroboration needs *progress* rather than just a + second low reading. Snapshots are dispatched on a debounce, so one replay + can deliver the same incomplete state twice; a rule that accepted any low + reading as corroboration would be satisfied by a second copy of the very + sample it exists to reject. The window exists because the same burst can + just as easily deliver two *different* stale samples, the later one higher, + which is corroboration by coincidence. + + A counter that falls again while a dip is unsettled accumulates into it and + keeps that dip's baseline, because a recovery has to be judged against what + the counter read before any of the fall. A fall inside a *corroborated* + dip's window is a separate dip instead, judged against the reading it fell + from: the two are undone by different readings, and merging them would leave + a small artefact needing the counter's whole lifetime total back before it + could ever be retracted. The older dip ages either way — a fall is not a + return, so it does not buy a window more readings — and if an unsettled dip + is corroborated while an older one is still open, the older one settles then + and there rather than merging, so the bound stays three readings from the + most recent corroboration. + + The cost is that a genuine reset on a circuit drawing nothing stays + unsettled until it next moves, deferring only the notification — + compensation is applied throughout, so the sensor reads correctly while the + evidence is outstanding. Args: raw_value: The current raw energy reading from the panel. last_panel_reading: The previous raw reading, or None if this is the first. current_offset: The cumulative compensation offset so far. + pending: An unsettled dip from an earlier reading, if there is one. + recently_confirmed: A corroborated dip still inside its retraction + window, if there is one. Returns: - A 3-tuple of (new_offset, dip_delta_or_none, compensated_value). + A `DipOutcome`. `event` names what happened, if anything; `delta` is the + amount it concerns; `settled` is what became final on this reading; + `pending` and `recently_confirmed` are the state to carry into the next + call. """ - if last_panel_reading is not None and last_panel_reading - raw_value >= 1.0: - dip = last_panel_reading - raw_value - new_offset = current_offset + dip - return (new_offset, dip, raw_value + new_offset) + if last_panel_reading is not None and last_panel_reading - raw_value >= DIP_THRESHOLD_WH: + return _book(raw_value, last_panel_reading, current_offset, pending, recently_confirmed) + + confirmed = recently_confirmed + settled: float | None = None + removed = 0.0 + + if confirmed is not None: + if _has_returned(raw_value, confirmed): + removed = confirmed.delta + confirmed = None + else: + aged = _count_down(confirmed) + settled = _settled_by(confirmed, aged) + confirmed = aged + + if pending is not None and _has_returned(raw_value, pending): + # A corroborated dip's baseline is the higher of the two, so a reading + # that reached it has reached this one as well: both come off together. + removed += pending.delta + pending = None + + if removed: + offset = current_offset - removed + return DipOutcome( + offset, pending, raw_value + offset, DipEvent.RETRACTED, removed, confirmed, settled + ) + + if pending is not None and last_panel_reading is not None and raw_value > last_panel_reading: + return DipOutcome( + current_offset, + None, + raw_value + current_offset, + DipEvent.CONFIRMED, + pending.delta, + PendingDip( + baseline=pending.baseline, + delta=pending.delta, + confirmed_ticks_left=CONFIRMED_RETRACTION_WINDOW, + ), + # An older corroborated dip is superseded rather than merged, so it + # is final as of now. + confirmed.delta if confirmed is not None else settled, + ) - return (current_offset, None, raw_value + current_offset) + return DipOutcome( + current_offset, + pending, + raw_value + current_offset, + DipEvent.SETTLED if settled is not None else None, + settled, + confirmed, + settled, + ) def build_dip_attributes( diff --git a/custom_components/span_panel/energy_orientation.py b/custom_components/span_panel/energy_orientation.py new file mode 100644 index 00000000..07d67d5c --- /dev/null +++ b/custom_components/span_panel/energy_orientation.py @@ -0,0 +1,200 @@ +"""Which way a meter's Net Energy counts, and which energy sensor plays which part. + +A net sensor is the difference of two counters on one meter: a circuit, the +main meter or the feed-through lugs. Each is declared once, as a `NetEnergy` +on its description, and its value and the dip-compensation adjustment it adds +are both read from that one declaration's `NetEnergyOrientation`, so the two +cannot disagree about which counter is credited. They used to be two rules: +the value was generation-oriented for a solar circuit while the adjustment was +always load-oriented, so a solar circuit's Net moved by twice each dip the +wrong way. + +An energy sensor's part in this -- its meter, its role and, for a Net, its +`NetEnergy` -- is its `EnergyBinding`. + +A leaf: it imports only the standard library and `span_panel_api`, so the +descriptions, the coordinator and every sensor module can depend on it +without a cycle. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from typing import Final + +from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot + +_GENERATION_DEVICE_TYPE: Final = "pv" +"""The circuit `device_type` whose energy is oriented as generation.""" + + +class EnergyCounter(Enum): + """One of a meter's two cumulative counters. + + A plain `Enum`, not a `StrEnum`: a member equals only itself, so neither a + role nor a string can answer a lookup keyed by counter. Nothing needs these + to be strings. + """ + + CONSUMED = "consumed" + PRODUCED = "produced" + + +@dataclass(frozen=True, slots=True) +class NetEnergyOrientation: + """Which counter a Net Energy sensor counts up, and which it counts down.""" + + credit: EnergyCounter + """The counter Net Energy counts up.""" + + debit: EnergyCounter + """The counter Net Energy counts down.""" + + def __post_init__(self) -> None: + """Refuse an orientation that is not a difference at all.""" + if self.credit is self.debit: + raise ValueError("A net sensor cannot credit and debit one counter") + + def ordered( + self, *, consumed: float | None, produced: float | None + ) -> tuple[float | None, float | None]: + """Return a meter's two readings as `(credit, debit)`.""" + readings = {EnergyCounter.CONSUMED: consumed, EnergyCounter.PRODUCED: produced} + return readings[self.credit], readings[self.debit] + + def adjustment(self, offset_of: Callable[[EnergyCounter], float]) -> float: + """Return what dip compensation adds to the raw net: the credited offset minus the debited one.""" + return offset_of(self.credit) - offset_of(self.debit) + + +LOAD: Final = NetEnergyOrientation(credit=EnergyCounter.CONSUMED, debit=EnergyCounter.PRODUCED) +"""Consumed minus produced: every load circuit, the main meter and the feed-through lugs.""" + +GENERATION: Final = NetEnergyOrientation( + credit=EnergyCounter.PRODUCED, debit=EnergyCounter.CONSUMED +) +"""Produced minus consumed: a circuit that feeds a generator.""" + + +def circuit_is_generation(circuit: SpanCircuitSnapshot) -> bool: + """Whether a circuit's energy is oriented as generation; its power's sign follows the same answer.""" + return circuit.device_type == _GENERATION_DEVICE_TYPE + + +def circuit_net_orientation(circuit: SpanCircuitSnapshot) -> NetEnergyOrientation: + """Return the orientation of a circuit's Net Energy, by its device type.""" + return GENERATION if circuit_is_generation(circuit) else LOAD + + +def panel_meter_net_orientation(snapshot: SpanPanelSnapshot) -> NetEnergyOrientation: + """Return the orientation of the main meter's and the feed-through lugs' Net Energy: always load.""" + return LOAD + + +class EnergyRole(Enum): + """The part an energy sensor plays on its meter, declared on its description. + + A plain `Enum` for the reason `EnergyCounter` is one. + """ + + PRODUCED = "produced" + CONSUMED = "consumed" + NET = "net" + + @property + def counter(self) -> EnergyCounter | None: + """The counter this role reads, or None for Net, which reads both.""" + if self is EnergyRole.PRODUCED: + return EnergyCounter.PRODUCED + if self is EnergyRole.CONSUMED: + return EnergyCounter.CONSUMED + return None + + +class PanelMeter(Enum): + """The panel's own meters, as opposed to a circuit's.""" + + MAIN_METER = "main_meter" + FEEDTHROUGH = "feedthrough" + + +@dataclass(frozen=True, slots=True) +class CircuitMeter: + """A circuit's meter, named by its circuit id.""" + + circuit_id: str + + +type EnergyMeter = CircuitMeter | PanelMeter +"""Any meter; hashable, and with an `EnergyCounter` the coordinator's offset key.""" + + +def _difference(minuend: float | None, subtrahend: float | None) -> float | None: + """`minuend - subtrahend`, or `None` while either side is unreported. + + Half a difference is not a smaller difference, it is no answer: a circuit + that has published its consumed counter and not its produced one knows + nothing yet about the net of the two. + """ + if minuend is None or subtrahend is None: + return None + return minuend - subtrahend + + +@dataclass(frozen=True, slots=True) +class NetEnergy[D]: + """A Net Energy sensor's definition: its orientation and the two counters it reads. + + Held by the sensor's description. The description's `value_fn` is `value`, + and the sensor's dip adjustment reads `orientation_of`, so the orientation + is declared once and both read the same one. + """ + + orientation_of: Callable[[D], NetEnergyOrientation] + """The orientation for the reading being processed; a circuit's follows its device type.""" + + consumed: Callable[[D], float | None] + """The meter's consumed counter.""" + + produced: Callable[[D], float | None] + """The meter's produced counter.""" + + def value(self, data: D) -> float | None: + """Return the credited reading minus the debited one, or None while either is unreported.""" + return _difference( + *self.orientation_of(data).ordered( + consumed=self.consumed(data), produced=self.produced(data) + ) + ) + + +@dataclass(frozen=True, slots=True) +class EnergyBinding[D]: + """The meter an energy sensor reads, the part it plays there and, for a Net, its definition. + + Every energy sensor binds exactly one, before `SpanEnergySensorBase` is + initialised; see `SpanEnergySensorBase._bind_energy`. + """ + + meter: EnergyMeter | None + """The meter, or None for a sensor on no meter, which plays no part.""" + + role: EnergyRole | None + """Produced, Consumed or Net, as the description declares.""" + + net: NetEnergy[D] | None + """The Net's definition: set exactly when `role` is Net.""" + + def __post_init__(self) -> None: + """Refuse a role on no meter, and a Net without its definition or a definition on a counter.""" + if self.role is not None and self.meter is None: + raise ValueError("An energy role is played on a meter") + if (self.role is EnergyRole.NET) != (self.net is not None): + raise ValueError("A binding carries a Net definition exactly when its role is Net") + + @property + def counter(self) -> EnergyCounter | None: + """The counter this sensor offers its meter's Net, or None for a Net or a sensor with no role.""" + return self.role.counter if self.role is not None else None diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index afc2590f..ed8d9c4d 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -2,21 +2,286 @@ from __future__ import annotations +from collections.abc import Awaitable +import logging +from typing import ClassVar + from homeassistant.const import CONF_HOST +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from span_panel_api import SpanPanelSnapshot +from span_panel_api import PublishOutcome, SpanPanelSnapshot +from span_panel_api.exceptions import SpanPanelServerError -from .const import CONF_DEVICE_NAME +from .const import CONF_DEVICE_NAME, DOMAIN +from .control_gate import ( + CONTROL_CALLER, + async_bind_caller, + outcome_failure_reason, + outcome_is_failure, +) from .coordinator import SpanPanelCoordinator +from .field_paths import FieldPathDeclarationMixin from .util import snapshot_to_device_info +_LOGGER = logging.getLogger(__name__) + class SpanPanelEntity(CoordinatorEntity[SpanPanelCoordinator]): """Base entity for all Span Panel platforms.""" + coordinator: SpanPanelCoordinator + """Restated so that reading it is typed, which the generic base does not give us. + + `CoordinatorEntity[SpanPanelCoordinator]` binds the type parameter correctly + -- `__init__` is checked against `SpanPanelCoordinator` either way -- but the + attribute itself is only ever created by `BaseCoordinatorEntity.__init__`'s + unannotated `self.coordinator = coordinator`. mypy does not run its inference + pass over installed packages, so an attribute whose type is inferred from a + parameter there comes back as `Any` no matter what the type argument says, + and every `self.coordinator.…` read in this integration was unchecked. (An + attribute the same `__init__` assigns a literal to, like + `last_update_success`, is unaffected; the difference is inference, not the + generic.) One line here types every subclass's reads. + """ + _attr_has_entity_name = True + _span_object_id_base: str | None = None + """The base Home Assistant composes this entity's id from, when set. + + Circuit entities set it from `naming.circuit_object_id_base`; every other + entity leaves it None and Core composes from the display name as it + always has. Core reads `suggested_object_id` only for an entity that has + not preset `entity_id`, which after this release is every entity here. + """ + + @property + def suggested_object_id(self) -> str | None: + """Hand Core the base, or the stock answer when there is none.""" + if self._span_object_id_base is not None: + return self._span_object_id_base + return super().suggested_object_id + + _residual_field_paths: ClassVar[tuple[str, ...]] = () + """Snapshot fields this entity reads from entity code, not from a description. + + A handful of reads cannot be expressed as a description `field_path`: the + switch has no entity description at all, the select wraps one, and a circuit + entity's name, tabs and attributes are read outside any `value_fn`. + Declaring them here is what lets a Repair say which entities a dead field + takes with it, instead of "0 affected". + + This is the only place these paths are written down: + `field_paths.residual_field_paths()` collects them from every subclass, so + the producible gate covers exactly what the entities claim. + + Keep the list short. A new entry is a hint that the reader belongs on a + description instead, where the declaration and the reader are one object. + """ + + async def _async_guarded_control(self, action: Awaitable[PublishOutcome]) -> PublishOutcome: + """Run one control call with this entity's caller bound to it. + + `self._context` is set by core immediately before a service handler runs + (`helpers/service.py`), in the same task, so reading it here is reading + the call that is happening right now. It is read explicitly rather than + captured by overriding `async_set_context`, which would inherit core's + five-second `CONTEXT_RECENT_TIME_SECONDS` staleness window and would also + fire for paths that are not service calls. + + The binding is around the awaited call and reset in a `finally`, so it + cannot leak into an unrelated task the way a mutable attribute would. The + decision itself is made in `ControlGate`, at the publish — see that + module for why the two halves are separate. + """ + token = async_bind_caller(self._context, self.entity_id) + try: + return await action + finally: + CONTROL_CALLER.reset(token) + + async def _async_control( + self, + action: Awaitable[PublishOutcome], + *, + command: str, + failed_key: str, + not_delivered_key: str, + placeholders: dict[str, str], + ) -> PublishOutcome: + """Run one control call and raise both ways it can fail to happen. + + Every control this integration offers -- relay, priority, charge-current + limit, GFE override, and any settable property on an adopted device -- + reaches the panel through here, because the two ways a command does not + happen are the same two facts whatever was being commanded, and stating + them once is what keeps a new control from silently reporting success. + + They are reported separately and worded differently, because they are + different facts. A refusal (`SpanPanelServerError`) never resolved an + address: the panel declares the thing non-commandable, or the value has + no representation, and nothing was published. A `FAILED` outcome + resolved one and was never handed over, because the transport was closed + or the broker was disconnected; the library refuses such a write rather + than letting paho queue it, so it is also a promise that nothing fires + later against a panel nobody is watching. Both raise: a control that + silently does nothing is the defect. + + `command` names what was asked in the caller's own words -- "a relay + command for switch.kitchen_outlets" -- and appears only in the log, + where a phrase reading as English is worth more than a key. What the + user sees is `failed_key` / `not_delivered_key` with `placeholders`, + which is why the reason is added here rather than at each call site: it + is the one placeholder every one of these messages carries, and the one + a caller cannot know before the call. + + Returns the outcome for a caller that has something to say about a + delivered command -- `no_op`, say, or the topic it went to. + """ + try: + outcome = await self._async_guarded_control(action) + except SpanPanelServerError as err: + _LOGGER.warning("SPAN panel did not accept %s: %s", command, err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=failed_key, + translation_placeholders={**placeholders, "reason": str(err)}, + ) from err + + if outcome_is_failure(outcome): + reason = outcome_failure_reason(outcome) + _LOGGER.warning("SPAN panel was never handed %s: %s", command, reason) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=not_delivered_key, + translation_placeholders={**placeholders, "reason": reason}, + ) + + return outcome + + async def async_added_to_hass(self) -> None: + """Tell the coordinator which snapshot fields this entity reads. + + The entity is the only place both halves are known for certain: it holds + its own declarations and its own `entity_id`. Reconstructing the pair + from entity descriptions instead would mean reimplementing three + different unique_id builders — circuit sensors, panel-data sensors and + binary sensors each use a different suffix rule — and would rot the next + time one of them changed. + + Consumed by the schema Repairs, which name the entities a field the panel + stopped producing has taken down with it. + """ + await super().async_added_to_hass() + for field_path in self._declared_field_paths(): + self.coordinator.async_register_field_path_entity(field_path, self.entity_id) + + async def async_will_remove_from_hass(self) -> None: + """Stop counting this entity against its fields.""" + for field_path in self._declared_field_paths(): + self.coordinator.async_unregister_field_path_entity(field_path, self.entity_id) + await super().async_will_remove_from_hass() + + def _source_field_path(self) -> str | None: + """Return the snapshot field this entity's value comes from, if any. + + The description's `field_path`, regardless of `derived`. A + `SCHEMA_CONDITIONAL_FIELD` description is exempt from the *producible* + gate, which is a statement about the other adapter, not about this + entity: the adapter that does produce the field publishes a metadata + row for it, that row can come back unresolved, and when it does this + entity's reading is a default rather than a measurement — exactly as + for a plain declaration. + + `None` for `MULTIPLE_FIELDS` and `NO_SOURCE_FIELD` descriptions, which + have no single field to blame, and for the circuit switch, which has no + entity description at all. + """ + description: object = getattr(self, "entity_description", None) + if isinstance(description, FieldPathDeclarationMixin): + return description.field_path + return None + + def _declared_field_paths(self) -> tuple[str, ...]: + """Return every snapshot field this entity reads. + + Its source field when it has one, plus any residual reads. A platform + with no entity description at all — the circuit switch — contributes + only its residual reads. + """ + source = self._source_field_path() + if source is not None: + return (source, *self._residual_field_paths) + return self._residual_field_paths + + @property + def _reads_an_unresolved_field(self) -> bool: + """True when this entity's own source field could not be resolved. + + An O(1) membership test against a set that is empty on a healthy panel, + so the normal case costs one hash lookup per availability read. + + Only the description's `field_path` counts -- the entity's value source. + Residual paths are deliberately excluded: `circuit.name` and + `circuit.tabs` feed naming and attributes, and a circuit's power reading + is still true when they are gone. The switch and select read their state + through residual paths and so are not covered here; the Repair still + names them. + + `derived` is deliberately not consulted. It says why a path is outside + the both-adapters producible gate, which is a fact about the *other* + adapter; the adapter running here still resolves the field or fails to. + Excluding schema-conditional entities from this probe left them with a + default they present as a reading -- the `evse_ev_connected` failure + mode, reached by a different route. + """ + source = self._source_field_path() + return source is not None and source in self.coordinator.unresolved_paths + + @property + def _transport_available(self) -> bool: + """False once the panel's transport has stopped for good. + + The single copy of this check. Every platform's `available` consults it + *ahead of* whatever that platform does about `panel_offline`, because + each of those offline branches answers a question this one has already + settled: the sensors and the hardware-status binary sensors return True + to keep showing a held reading, and the controls return False for a + reason -- unreachable -- that understates a transport nobody can reach + again. Both are answers about a gap that closes. A dead transport is + not one, so it is decided first and decided once. + + See `SpanPanelCoordinator.transport_dead` for what makes it dead and + what brings it back. + """ + return not self.coordinator.transport_dead + + @property + def available(self) -> bool: + """False when this entity's snapshot field could not be resolved. + + Any value we would report for an unresolvable field is a default rather + than a reading, and a default is indistinguishable from a real one at + the dashboard. Reporting unavailable is reporting, not correcting: the + entity keeps its shape and comes back when the field does. + + This covers the resolution-failure case only. A field the adapter + resolves but the device stops publishing still reaches HA as a parsed + default; that needs the snapshot model to admit None. + + Also the availability of every entity that does not override this -- + the adopted and extension entities and the EVSE binary sensors -- which + is where the transport check earns its place on the base class. The + control-lock switch is the one control that does override it, because + arming is local and stays answerable with no panel at the other end. + """ + if self._reads_an_unresolved_field: + return False + if not self._transport_available: + return False + return super().available + @staticmethod def _build_device_info( coordinator: SpanPanelCoordinator, diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py index 7cd2032d..b6605008 100644 --- a/custom_components/span_panel/entity_resolver.py +++ b/custom_components/span_panel/entity_resolver.py @@ -1,36 +1,49 @@ """Entity resolver functions for Span Panel integration. -This module contains functions that depend on the coordinator, entity registry, -or config entry options to resolve entity IDs and unique IDs. These are the -"entry-aware" wrappers around the pure ID builders in id_builder.py. +This module contains functions that depend on the coordinator or the config +entry to resolve unique IDs. These are the "entry-aware" wrappers around the +pure ID builders in id_builder.py. Entity ids are not built here: an entity's +id is composed by Home Assistant from the base `naming.py` supplies. """ from __future__ import annotations -import logging from typing import TYPE_CHECKING -from homeassistant.helpers import entity_registry as er -from homeassistant.util import slugify -from span_panel_api import SpanCircuitSnapshot, SpanEvseSnapshot, SpanPanelSnapshot +from span_panel_api import SpanEvseSnapshot, SpanPanelSnapshot -from .const import DOMAIN, USE_CIRCUIT_NUMBERS, USE_DEVICE_PREFIX +from .const import DOMAIN from .id_builder import ( build_bess_unique_id, build_binary_sensor_unique_id, build_circuit_unique_id, build_evse_unique_id, + build_mid_unique_id, build_panel_unique_id, + build_pv_inverter_unique_id, build_select_unique_id, build_switch_unique_id, construct_synthetic_unique_id, ) -from .util import snapshot_to_device_info if TYPE_CHECKING: + from homeassistant.helpers import entity_registry as er + from .coordinator import SpanPanelCoordinator -_LOGGER = logging.getLogger(__name__) + +def entity_id_in_entry( + registry: er.EntityRegistry, entry_id: str, domain: str, unique_id: str +) -> str | None: + """Return the entity id `unique_id` is registered under in this entry, or None. + + The registry keys an entity on its platform and unique id, not on its entry, + so another config entry of this integration can hold the same unique id. What + that entry holds says nothing about this one. + """ + entity_id = registry.async_get_entity_id(domain, DOMAIN, unique_id) + entry = registry.async_get(entity_id) if entity_id is not None else None + return entity_id if entry is not None and entry.config_entry_id == entry_id else None def resolve_evse_display_suffix( @@ -143,302 +156,47 @@ def build_evse_unique_id_for_entry( return build_evse_unique_id(identifier, evse_id, description_key) -def build_bess_unique_id_for_entry( +def build_pv_inverter_unique_id_for_entry( coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot, + inverter_key: str, description_key: str, device_name: str | None = None, ) -> str: - """Build BESS unique_id using the panel serial from the snapshot.""" + """Build one inverter's unique_id using the panel serial from the snapshot.""" identifier = _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) - return build_bess_unique_id(identifier, description_key) + return build_pv_inverter_unique_id(identifier, inverter_key, description_key) -def get_device_identifier_for_entry( +def build_bess_unique_id_for_entry( coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot, + description_key: str, device_name: str | None = None, ) -> str: - """Public helper to get the per-entry device identifier used in unique_ids and storage.""" - return _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) - - -def construct_multi_circuit_entity_id( - coordinator: SpanPanelCoordinator, - snapshot: SpanPanelSnapshot, - platform: str, - suffix: str, - circuit_numbers: list[int], - friendly_name: str | None = None, - unique_id: str | None = None, -) -> str | None: - """Construct entity ID for multi-circuit sensors (like solar inverters). - - Args: - coordinator: The coordinator instance - snapshot: The panel snapshot data - platform: Platform name ("sensor", "switch", "select") - suffix: Entity-specific suffix ("power", "energy_produced", etc.) - circuit_numbers: List of circuit numbers this sensor combines - friendly_name: Descriptive name for this sensor (required if unique_id is None) - unique_id: The unique ID for this entity (None to skip registry lookup) - - Returns: - Constructed entity ID string or None if device info unavailable + """Build BESS unique_id using the panel serial from the snapshot.""" + identifier = _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) + return build_bess_unique_id(identifier, description_key) - """ - # Check registry first only if unique_id is provided - if unique_id is not None: - entity_registry = er.async_get(coordinator.hass) - existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) - - _LOGGER.debug( - "Multi-circuit helper registry lookup (switches/selects) - unique_id=%s, found_entity_id=%s", - unique_id, - existing_entity_id, - ) - - if existing_entity_id: - return existing_entity_id - # During migration, unique_id lookup should always succeed - raise ValueError( - f"Registry lookup failed for unique_id '{unique_id}' during migration. Entity should exist in registry." - ) - _LOGGER.debug( - "Multi-circuit helper (switches/selects) - no unique_id provided, skipping registry lookup" - ) - - # Get device name from config entry data - device_name = coordinator.config_entry.data.get("device_name", coordinator.config_entry.title) - if not device_name: - return None - - use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - # If no unique_id provided, friendly_name is required when not using circuit numbers - if unique_id is None and not use_circuit_numbers and not friendly_name: - _LOGGER.error( - "Friendly_name is required when unique_id is None and not using circuit numbers for multi-circuit entity" - ) - return None - if use_circuit_numbers: - # Use circuit number pattern: sensor.span_panel_circuit_30_32_power - if circuit_numbers: - sorted_circuits = sorted([num for num in circuit_numbers if num > 0]) - else: - sorted_circuits = [] - if sorted_circuits: - if len(sorted_circuits) == 1: - circuit_part = f"circuit_{sorted_circuits[0]}" - else: - circuit_list = "_".join(str(num) for num in sorted_circuits) - circuit_part = f"circuit_{circuit_list}" - else: - raise ValueError( - f"Circuit-based naming is enabled but no valid circuit numbers provided. " - f"Got circuit_numbers={circuit_numbers}. Multi-circuit entities require valid circuit numbers when USE_CIRCUIT_NUMBERS is True." - ) - else: - # Use friendly name pattern: sensor.span_panel_solar_inverter_power - circuit_part = slugify(friendly_name) - - # Build the entity ID. - # `False` default preserves legacy installs (no device prefix). Single-circuit - # entities default to True — see construct_single_circuit_entity_id below — - # because they never had a prefix-less form historically; multi-circuit - # synthetics did. - use_device_prefix = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, False) - parts = [] - - if use_device_prefix: - if device_name: - # Sanitize device name for entity ID use - sanitized_device_name = slugify(device_name) - parts.append(sanitized_device_name) - - parts.append(circuit_part) - - # Add suffix if not already in circuit_part - if suffix and not circuit_part.endswith(f"_{suffix}"): - parts.append(suffix) - - return f"{platform}.{'_'.join(parts)}" - - -def construct_single_circuit_entity_id( +def build_mid_unique_id_for_entry( coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot, - platform: str, - suffix: str, - circuit_data: SpanCircuitSnapshot, - unique_id: str | None = None, - device_name: str | None = None, -) -> str | None: - """Construct entity ID for single-circuit sensors. - - Args: - coordinator: The coordinator instance - snapshot: The panel snapshot data - platform: Platform name ("sensor", "switch", "select") - suffix: Entity-specific suffix ("power", "energy_produced", etc.) - circuit_data: Circuit data object - unique_id: The unique ID for this entity (None to skip registry lookup) - device_name: Device name for entity ID construction (None to use from config entry) - - Returns: - Constructed entity ID string or None if device info unavailable - - """ - # Check registry first only if unique_id is provided - if unique_id is not None: - entity_registry = er.async_get(coordinator.hass) - existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) - - _LOGGER.debug( - "Circuit helper registry lookup - unique_id=%s, found_entity_id=%s", - unique_id, - existing_entity_id, - ) - - if existing_entity_id: - return existing_entity_id - # FATAL ERROR: Expected unique_id not found in registry - raise ValueError( - f"REGISTRY LOOKUP ERROR: Expected unique_id '{unique_id}' not found in registry. " - f"This indicates a migration or configuration mismatch." - ) - _LOGGER.debug("Circuit helper - no unique_id provided, skipping registry lookup") - - # Get device info - device_info = snapshot_to_device_info(snapshot, device_name) - if not device_info or not device_info.get("name"): - return None - - use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - if use_circuit_numbers: - # Check if this is a 240V circuit (2 tabs) or 120V circuit (1 tab) - if circuit_data.tabs and len(circuit_data.tabs) == 2: - # 240V circuit - use both tab numbers - sorted_tabs = sorted(circuit_data.tabs) - circuit_part = f"circuit_{sorted_tabs[0]}_{sorted_tabs[1]}" - elif circuit_data.tabs and len(circuit_data.tabs) == 1: - # 120V circuit - use single tab number - circuit_part = f"circuit_{circuit_data.tabs[0]}" - else: - # No tabs available — use the API circuit_id as fallback - circuit_part = ( - f"circuit_{circuit_data.circuit_id}" - if circuit_data.circuit_id - else "circuit_unknown" - ) - # Use friendly name pattern: sensor.span_panel_solar_east_power - elif circuit_data.name: - circuit_part = slugify(circuit_data.name) - else: - circuit_part = "single_circuit" - - # Build the entity ID (only for non-voltage-specific cases) - use_device_prefix = coordinator.config_entry.options.get(USE_DEVICE_PREFIX, True) - parts = [] - - if use_device_prefix: - device_name = device_info.get("name") - if device_name: - # Sanitize device name for entity ID use - sanitized_device_name = slugify(device_name) - parts.append(sanitized_device_name) - - parts.append(circuit_part) - - # Add suffix if not already in circuit_part - if suffix and not circuit_part.endswith(f"_{suffix}"): - parts.append(suffix) - - return f"{platform}.{'_'.join(parts)}" - - -def construct_unmapped_entity_id( - snapshot: SpanPanelSnapshot, - circuit_id: str, - suffix: str, + description_key: str, device_name: str | None = None, ) -> str: - """Construct entity ID for unmapped tab with consistent modern naming. - - Args: - snapshot: The panel snapshot data - circuit_id: Circuit ID (e.g., "unmapped_tab_32") - suffix: Sensor suffix (e.g., "power", "energy_produced") - device_name: The device name to use for entity ID construction + """Build MID unique_id using the panel serial from the snapshot.""" + identifier = _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) + return build_mid_unique_id(identifier, description_key) - Returns: - Entity ID string like "sensor.span_panel_unmapped_tab_32_power" - """ - # Always use device prefix for unmapped entities - # circuit_id is "unmapped_tab_32", add device prefix and suffix to create - # "sensor.span_panel_unmapped_tab_32_power" - device_info = snapshot_to_device_info(snapshot, device_name) - device_name_raw = device_info.get("name") - _LOGGER.debug( - "construct_unmapped_entity_id: circuit_id=%s, suffix=%s, device_name_raw=%s", - circuit_id, - suffix, - device_name_raw, - ) - if device_name_raw: - # Sanitize device name for entity ID use - sanitized_device_name = slugify(device_name_raw) - result = f"sensor.{sanitized_device_name}_{circuit_id}_{suffix}" - _LOGGER.debug("construct_unmapped_entity_id result with device: %s", result) - return result - result = f"sensor.{circuit_id}_{suffix}" - _LOGGER.debug("construct_unmapped_entity_id result without device: %s", result) - return result - - -def get_unmapped_circuit_entity_id( +def get_device_identifier_for_entry( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot, - tab_number: int, - suffix: str, device_name: str | None = None, -) -> str | None: - """Get entity ID for an unmapped circuit based on tab number. - - This helper function constructs the entity ID for native unmapped circuit sensors - that should already exist in Home Assistant. It's useful for synthetic sensors - that need to reference these native entities in formulas. - - Args: - snapshot: The panel snapshot data - tab_number: The tab number (e.g., 30, 32) - suffix: The sensor suffix (e.g., "power", "energy_produced", "energy_consumed") - device_name: The device name to use for entity ID construction - - Returns: - Entity ID string like "sensor.span_panel_unmapped_tab_30_power" - or None if the circuit doesn't exist - - Examples: - get_unmapped_circuit_entity_id(snapshot, 30, "power") - # Returns: "sensor.span_panel_unmapped_tab_30_power" - - get_unmapped_circuit_entity_id(snapshot, 32, "energy_produced") - # Returns: "sensor.span_panel_unmapped_tab_32_energy_produced" - - """ - circuit_id = f"unmapped_tab_{tab_number}" - - # Verify the circuit exists in the panel data - if circuit_id not in snapshot.circuits: - _LOGGER.debug("Unmapped circuit %s not found in circuits list", circuit_id) - return None - - result_entity_id = construct_unmapped_entity_id(snapshot, circuit_id, suffix, device_name) - _LOGGER.debug("Generated unmapped entity ID: %s", result_entity_id) - return result_entity_id +) -> str: + """Public helper to get the per-entry device identifier used in unique_ids and storage.""" + return _get_device_identifier_for_unique_ids(coordinator, snapshot, device_name) def construct_unmapped_friendly_name( diff --git a/custom_components/span_panel/extension.py b/custom_components/span_panel/extension.py new file mode 100644 index 00000000..29241c93 --- /dev/null +++ b/custom_components/span_panel/extension.py @@ -0,0 +1,913 @@ +"""Identity for vendor extension properties on devices this integration models. + +The other half of vendor extensibility from `adoption.py`. That module adopts a +whole *device* nobody modelled; this one carries a new *property* on a device +this integration already models -- a battery vendor hanging `battery-2/ +cell-temperature` off the BESS -- which until now reached the user nowhere. + +**An adopted extension is a terminal identity.** It is a vendor reading on the +correct existing device card, disabled and diagnostic, in plain wire vocabulary, +and it stays that until an external trigger changes it: the publisher stops +publishing the property, or better metadata arrives. Nothing here promotes, +re-homes or migrates one on its own schedule, and curation is never blocked by +one existing -- an entity's `unique_id` and `entity_id` are permanent, its +*identity* carries no expectation of permanence beyond that. + +**Terminal is not unimprovable, and the owner of the device is not guessing.** +A user who curates a row asserts what this module refuses to infer, and their +record arrives as metadata `curation.py` composes into the description built +here -- which is how a curated reading carries a state class without this +module naming the thing an AST guard forbids it to spell. It changes what an +entity *says*, never what it *is*: the id, the card and the platform are +untouched, and an uncurated row is exactly the entity it was before curation +existed. + +**Nothing is ever removed by this integration.** A row the user deletes is +recreated -- disabled, as it arrives -- for as long as the property is still +published, so deletion is not suppression and no suppression feature is needed. +Deletion sticks exactly when publishing has stopped, because then nothing exists +to recreate it from. The delete button therefore already means "hide until next +setup" for a live reading and "clear this out" for a dead one, decided by the +wire rather than by a feature. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import re +from typing import TYPE_CHECKING, Final + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo, DeviceRegistry +from homeassistant.helpers.entity_registry import EntityRegistry +from span_panel_api import ExtensionProperty, ExtensionSubject, SpanPanelSnapshot + +from .adoption import ( + DEVICE_CLASS_BY_UNIT, + clamp_state, + homie_boolean, + humanised, +) +from .const import DOMAIN +from .coordinator import SpanPanelCoordinator +from .curation import ( + CurationOverlay, + CurationRecord, + RowContext, + binary_sensor_device_class, + entity_category_for, + sensor_description, +) +from .entity import SpanPanelEntity +from .notices import async_raise_on_change, read_translations +from .util import ( + ADOPTED_IDENTIFIER_TOKEN, + BOOLEAN_DATATYPE, + SUB_DEVICE_BESS, + SUB_DEVICE_EVSE, + SUB_DEVICE_MID, + SUB_DEVICE_PV, + declares_a_number, +) + +if TYPE_CHECKING: + from .pv_binding import PvBinding + +_LOGGER = logging.getLogger(__name__) + +SCOPE_PANEL: Final = "panel" + +HINT_READING: Final = "reading" +HINT_DETAIL: Final = "detail" + +IDENTITY_FAMILY: Final = frozenset( + {"vendor", "model", "serial", "part-number", "firmware", "hardware", "build", "revision"} +) +"""Property-name tokens that mark a declaration as device description. + +Matched outside the `info` node, which never reaches adoption at all -- so a hit +here is catching a vendor that put identity on a capability node rather than +re-checking what `info` already resolved. +""" + +HOMIE_ID: Final = re.compile(r"^[a-z0-9-]+$") +"""The Homie id charset: lowercase alphanumerics and hyphens, nothing else. + +Load-bearing rather than decorative. The grammar below joins the node and the +property with `/`, and that separator is only unambiguous because an id cannot +contain one -- which is the same reason upstream's own capability-catalog paths +and `discovery_path()` are spelled with slashes. An id outside this charset is +refused adoption rather than sanitised, because sanitising is what would make +the split ambiguous again. +""" + +_SCOPE_BY_KIND: Final[dict[str, str]] = { + "panel": SCOPE_PANEL, + "battery": SUB_DEVICE_BESS, + "mid": SUB_DEVICE_MID, + "pv": SUB_DEVICE_PV, +} +"""Library subject kind → the scope segment this integration's ids already use. + +The singletons only. The kinds below carry an instance key, so their segment is +`{prefix}_{key}` rather than a constant. +""" + +_SCOPE_PREFIX_BY_KIND: Final[dict[str, str]] = { + "evse": SUB_DEVICE_EVSE, + "circuit": "circuit", + "lugs": "lugs", +} +"""Multi-instance subject kinds, and the prefix their scope segment takes. + +`lugs` is here rather than folded into `panel` even though a lugs device's +curated fields land on the panel: the two lugs devices run identical firmware, +so a vendor extension on one is the expected case of the same extension on +both, and one scope for both would mint one id for two readings. They still +*render* on the panel's card -- see `extension_device_identifier`, where the +divergence between identity and placement is deliberate. +""" + + +def extension_scope(subject: ExtensionSubject) -> str | None: + """Return the id scope segment for a subject, or `None` if it names no device. + + `None` rather than a fallback string: a subject kind this integration does + not place on a device card has no card to hang an entity on, and inventing a + scope would mint a permanent id for an entity with nowhere to live. + """ + if subject.kind == "pv" and subject.instance_key is not None: + # One inverter of several; a lone inverter's subject has no key. + return f"{SUB_DEVICE_PV}_{subject.instance_key}" + if subject.kind in _SCOPE_BY_KIND: + return _SCOPE_BY_KIND[subject.kind] + if subject.instance_key is None: + return None + if subject.kind in _SCOPE_PREFIX_BY_KIND: + return f"{_SCOPE_PREFIX_BY_KIND[subject.kind]}_{subject.instance_key}" + return None + + +def solar_subject( + subject: ExtensionSubject, binding: PvBinding, snapshot: SpanPanelSnapshot +) -> ExtensionSubject | None: + """Return the subject a row's id, curation key and card are built from, or `None` to defer it. + + A PV row is placed by the rule that places the PV metadata (see + `pv_binding`), so an inverter's vendor readings sit wherever its metadata + does. The inverter the Solar card reads -- the bound one, or the one it + describes at this setup -- keeps the keyless subject, scope `pv`, the Solar + card's unique ids and its curation keys, and no `{serial}_pv_{key}` device + is ever created for it. An inverter with a card of its own takes the keyed + subject. One with neither is withheld while the bound circuit's record is + pending, and its rows wait for a later setup rather than borrow a card. + + The library leaves a lone inverter's subject keyless and keys every + inverter's once there are several, so a keyless row is resolved to the lone + `pv_inverters` key first. Without that, a lone inverter the Solar card does + not read -- one beside a departed bound inverter, or one holding a card of + its own -- would take the Solar card's ids. The row itself keeps the wire + subject, which is what `ExtensionEntity._row` matches each cycle. + """ + if subject.kind != "pv": + return subject + key = subject.instance_key + if key is None: + if len(snapshot.pv_inverters) != 1: + return subject + key = next(iter(snapshot.pv_inverters)) + if key in (binding.bound_key, binding.legacy_key): + return ExtensionSubject(kind="pv") + if binding.has_own_card(key): + return ExtensionSubject(kind="pv", instance_key=key) + return None + + +@dataclass(frozen=True, slots=True) +class AdoptableExtension: + """One extension property that can become an entity, placed once. + + `subject` is the placed subject `solar_subject` decided -- the one the id, + the card and the curation key are built from -- so no consumer derives the + placement a second time. `row` keeps the wire subject. + """ + + row: ExtensionProperty + unique_id: str + device_identifier: str + subject: ExtensionSubject + + +def extension_unique_id( + serial: str, subject: ExtensionSubject, node_id: str, property_id: str +) -> str | None: + """Return the unique id for one extension property, or `None` if it is unadoptable. + + span_{serial}_adopted_{scope}/{node}/{property} + + Anchored on what is stable and ours -- the panel serial and the curated scope + -- and addressed by the wire path **verbatim**, which is upstream's own + capability-catalog spelling (`AdoptedProperty.path`, `discovery_path()`). + + **Verbatim, and never through `get_user_friendly_suffix`.** That helper + de-*dots* rather than de-hyphens and substitutes a curated suffix on a + mapping hit, so routing a wire address through it would both mangle the + address and let a vendor string collide with a curated spelling. Carrying the + path as published is also what makes this injective: the id *is* the wire + address, so two distinct addresses cannot collapse into one. Normalising + hyphens to underscores would collapse `battery-2` + `cell-temperature` and + `battery` + `2-cell-temperature` into the same id. + + **Not the eBus proxy composition.** `{proxier-id}-{proxied-id}` is upstream's + device-handle spelling, and upstream is explicit that those handles are not + identities: they differ across enclosures by design and are unstable across + the proxy-to-native transition. A permanent id anchored on one would rename + itself when a device stopped being proxied, stranding every entity keyed on + it -- and nothing migrates, so there would be no recovery. + + The `adopted` token sits immediately after the serial, so the id is + namespaced by prefix. Device-level adoption's ids contain no `/`, so the + presence of a slash is what tells the two adoption grammars apart -- no + reader has to count token positions. + """ + scope = extension_scope(subject) + if scope is None: + _LOGGER.debug( + "Extension property %s/%s names no device card (subject kind %s); not adopted", + node_id, + property_id, + subject.kind, + ) + return None + if not HOMIE_ID.match(node_id) or not HOMIE_ID.match(property_id): + # Refused rather than sanitised: see `HOMIE_ID`. The property is still + # reported in diagnostics, so the case is visible rather than silent. + _LOGGER.warning( + "Extension property %s/%s is outside the Homie id charset and is not adopted; " + "it remains visible in diagnostics", + node_id, + property_id, + ) + return None + return f"span_{serial}_{ADOPTED_IDENTIFIER_TOKEN}_{scope}/{node_id}/{property_id}" + + +def is_extension_unique_id(unique_id: str) -> bool: + """Whether an id was minted by the grammar above. + + The slash is the discriminator, for the reason `extension_unique_id` + documents: a curated id has no `adopted` token, and a device-level adopted id + has the token but no slash. + """ + return f"_{ADOPTED_IDENTIFIER_TOKEN}_" in unique_id and "/" in unique_id + + +def extension_curation_key(subject: ExtensionSubject, path: str) -> str | None: + """Return the curation-store key for one extension property: `{scope}/{node}/{property}`. + + Scope-prefixed because `path` is unique only within one wire device; the + scope segment is exactly the one the unique_id carries, so the key is + injective for the same reason the id is. `None` mirrors + `extension_unique_id`: no scope, nothing to curate. + """ + scope = extension_scope(subject) + return None if scope is None else f"{scope}/{path}" + + +def extension_device_identifier(panel_identifier: str, subject: ExtensionSubject) -> str | None: + """Return the registry identifier of the curated device this property belongs on. + + The identifier only, never a rebuilt `DeviceInfo`. An extension entity joins + a card that already exists; restating that card's name, manufacturer or model + here would be a second implementation of `util`'s builders, free to drift and + able to rename a user's device by disagreeing with them. + + A circuit's entities live on the panel's own card in this integration, so a + circuit subject resolves there too -- the entity's *name* is what says which + circuit it came from. Lugs are the same: two devices for identity, one card + for rendering, which is why this mapping and `extension_scope` are separate + functions rather than one. Identity must distinguish what placement merges. + """ + if subject.kind in ("panel", "circuit", "lugs"): + return panel_identifier + if subject.kind == "battery": + return f"{panel_identifier}_{SUB_DEVICE_BESS}" + if subject.kind == "mid": + return f"{panel_identifier}_{SUB_DEVICE_MID}" + if subject.kind == "pv" and subject.instance_key is not None: + return f"{panel_identifier}_{SUB_DEVICE_PV}_{subject.instance_key}" + if subject.kind == "pv": + return f"{panel_identifier}_{SUB_DEVICE_PV}" + if subject.kind == "evse" and subject.instance_key is not None: + return f"{panel_identifier}_{SUB_DEVICE_EVSE}_{subject.instance_key}" + return None + + +def classify_extension(datatype: str) -> Platform: + """Return the platform a declared datatype surfaces on. + + Two platforms, because `adoption.classify`'s three control rows are + deliberately absent: an extension property lives on a device whose curated + controls do real safety work -- the EVSE limit refuses a value above the + commissioned ceiling, the islanding assertion translates `GRID` into + `ON_GRID` -- and a generic write path would sit beside them on the same wire + with neither. A settable property therefore surfaces as a reading. + + **Derived once, at first sighting, and never re-derived.** See + `resolve_platform`: the domain is baked into `entity_id`, so a later + datatype change must not move an existing row. + """ + return Platform.BINARY_SENSOR if datatype == BOOLEAN_DATATYPE else Platform.SENSOR + + +def resolve_platform(registry: EntityRegistry, unique_id: str, datatype: str) -> Platform: + """Return the platform this id already uses, or the one its datatype implies. + + **The platform is a one-way door**, and this function is the door. An entity + domain is part of `entity_id`, and the registry refuses a cross-domain rename + outright -- `async_update_entity` raises `ValueError("New entity ID should be + same domain")`. So a row born a `sensor` can never become a `binary_sensor`: + re-deriving the platform from a changed declaration would not move it, it + would strand it and mint a second entity beside it. + + Metadata may reshape everything else about a standing entity -- category, + device class, unit, name, icon. For an uncurated entity that is free, + because it writes no long-term statistics and so has nothing for a unit + change to corrupt. A curated one carries what its owner asserted, and + `curation.sanitise` re-measures that assertion against each new + declaration, so a reshaping the assertion no longer fits drops it rather + than applying it over a wire that has moved. The platform is the exception + either way, and the exception is enforced here rather than remembered: + whatever domain the id is already registered under wins. + """ + for platform in (Platform.SENSOR, Platform.BINARY_SENSOR): + if registry.async_get_entity_id(platform.value, DOMAIN, unique_id) is not None: + return platform + return classify_extension(datatype) + + +def prominence_hint(row: ExtensionProperty) -> str: + """Return an advisory ranking for one extension property. + + Advisory, and only advisory: every extension entity arrives DIAGNOSTIC + whatever this says, until its owner curates it out -- which is the act this + hint exists to rank rather than to perform. `entity_category` is the one + attribute that is free to revise later -- no id change, no statistics + consequence -- so the conservative default costs a line in a future release, + while the mistakes that are *not* free are simply never made here. + + Ranked by confidence, and each signal's failure mode is why it sits where it + does: + + 1. **Identity-family naming → `detail`.** Highest confidence because it is a + purely *negative* signal: a property named for a vendor, model, serial, + part number or firmware build is device description, not a reading. Fails + only on a vendor using an identity word for a live value. + 2. **A unit with a device class → `reading`.** Moderate: a physical + measurement is more likely a headline than a knob. Fails *systematically* + in one direction -- the most headline-worthy number a battery publishes is + a `%` state of charge, and `%` is deliberately absent from + `DEVICE_CLASS_BY_UNIT` because it is equally a confidence or a duty cycle. + So this signal may promote and never demote. + 3. **Everything else → `detail`**, by fall-through rather than by a third + signal. The node a property hangs off is the obvious candidate for one and + is deliberately not consulted: Homie nodes are organisational rather than + editorial, and vendors hang configuration knobs off `meter` because that + is where the code was. + + The upstream `role` declaration proposed alongside this design would retire + all three for compliant publishers, which is why it is worth asking for. + """ + if any(token in row.property_id for token in IDENTITY_FAMILY): + return HINT_DETAIL + if row.unit and row.unit in DEVICE_CLASS_BY_UNIT: + return HINT_READING + return HINT_DETAIL + + +MAX_PER_DEVICE: Final = 60 +"""How many extension entities one device may mint before the rest are declined. + +A registry row is permanent in a way a reading is not: nothing here removes one, +and a row a user deletes returns at the next setup while the property is still +published. So a vendor node declaring hundreds of properties would put hundreds +of rows in every entity picker on every install that met it, and no later +release could take them back. + +The cap is deliberately generous -- far above any real device, and the sixteen +`pcs` properties are the largest curated example -- so it is a backstop against a +misbehaving publisher rather than a policy on normal ones. Declining is reported +through a notice naming the device, because a silent truncation would read as +"that is everything the vendor publishes" when it is not. +""" + + +class ExtensionEntity(SpanPanelEntity): + """Base for an entity built from a vendor extension on a curated device. + + Disabled without exception, and diagnostic unless the owner of the device + said otherwise, exactly as `AdoptedEntity` is: the integration's job here is + to make the reading reachable, not to put it on somebody's dashboard. A + curated row is that decision already made, so `entity_category_for` lets it + out of diagnostics -- and leaves it disabled all the same, because enabling + is still their act. + """ + + _attr_entity_registry_enabled_default = False + + def __init__( + self, + coordinator: SpanPanelCoordinator, + unique_id: str, + row: ExtensionProperty, + *, + device_identifier: str, + record: CurationRecord | None = None, + ) -> None: + """Bind this entity to one extension property of one curated device.""" + super().__init__(coordinator) + self._subject_kind = row.subject.kind + self._instance_key = row.subject.instance_key + self._declaration_path = row.path + self._attr_unique_id = unique_id + # Node-prefixed on purpose. Curated names on these cards carry no wire + # vocabulary, so a collision with a curated "Power" or "Status" is + # avoided by construction rather than by a registration-order-dependent + # dedup -- and two vendor nodes on one device disambiguate without a + # special case. Plain, clunky, and honestly so: it marks the entity as + # uncurated, which is what it is. + self._attr_name = f"{humanised(row.node_id)} {humanised(row.property_id)}" + # Identifiers only: the card already exists and belongs to `util`'s + # builders. Restating its name or model here could rename a user's device + # by disagreeing with them. + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_identifier)}) + self._attr_extra_state_attributes = { + "prominence_hint": prominence_hint(row), + "wire_path": row.path, + } + self._attr_entity_category = entity_category_for(record) + + def _row(self) -> ExtensionProperty | None: + """Return this property's current row, or None when it has left the tree. + + Matched out of the snapshot each cycle rather than captured at + construction, so a device that leaves and returns keeps reporting through + the same entity. A property the publisher stops publishing simply stops + being found: the entity reads unknown and is never removed, because + absence on the wire is ambiguous between "gone" and "not yet arrived". + """ + snapshot: SpanPanelSnapshot = self.coordinator.data + for row in snapshot.extension_properties: + if ( + row.subject.kind == self._subject_kind + and row.subject.instance_key == self._instance_key + and row.path == self._declaration_path + ): + return row + return None + + def _published(self) -> str | None: + """Return the published value, or None when nothing has arrived.""" + row = self._row() + return None if row is None else row.value + + +class ExtensionSensor(ExtensionEntity, SensorEntity): + """A vendor reading on a curated device, described by the wire and by its record. + + This module infers nothing that would enrol a reading in long-term + statistics, and spells no such attribute anywhere -- an AST guard in + `tests/test_extension_entities.py` asserts the token is absent from the + syntax rather than from the paths a test happens to construct. So an + uncurated reading has nothing for a later unit or device-class change to + corrupt, and metadata may reshape it freely. The owner of the vendor device + may assert one, and their assertion reaches the entity through + `curation.sensor_description` rather than from anything guessed here. + """ + + def __init__( + self, + coordinator: SpanPanelCoordinator, + unique_id: str, + row: ExtensionProperty, + *, + device_identifier: str, + record: CurationRecord | None = None, + ) -> None: + """Take the unit and device class from what the publisher declared. + + The description is built in `curation` rather than here, which is what + lets a curated row carry the one piece of metadata this module may not + name and still leaves an uncurated row with exactly what it had. + """ + super().__init__( + coordinator, unique_id, row, device_identifier=device_identifier, record=record + ) + self.entity_description = sensor_description( + row.path, row.unit, DEVICE_CLASS_BY_UNIT.get(row.unit or ""), record + ) + self._numeric = declares_a_number(row.datatype, row.unit) + + @property + def native_value(self) -> str | float | None: + """Return the published value, parsed to a number where the declaration says it is one. + + The declared `$datatype` is what says so, and a declared unit is taken as + saying so too. The unit alone used to decide it, as a proxy: a property + carrying `V` is a number whatever else it says. But a vendor count + declares no unit and is numeric all the same, and the proxy read one as + text -- harmless while an uncurated reading asserted nothing about + itself, and not harmless once the owner of the device could put a + `measurement` on exactly that row and have the recorder handed a string + under it. + + The union rather than the datatype alone, because a publisher that omits + a `$datatype` still declares a unit, and nothing that parses today may + stop parsing. + + Anything else is text, and a vendor string is unbounded on the wire, so + it goes through the clamp `adoption` holds for both halves of vendor + extensibility. + """ + raw = self._published() + if raw is None: + return None + if not self._numeric: + return clamp_state(raw, f"Extension {self._declaration_path}") + try: + return float(raw) + except ValueError: + _LOGGER.debug( + "Extension %s published %r, which is not a number", self._declaration_path, raw + ) + return None + + +class ExtensionBinarySensor(ExtensionEntity, BinarySensorEntity): + """A declared `boolean` vendor extension on a curated device.""" + + def __init__( + self, + coordinator: SpanPanelCoordinator, + unique_id: str, + row: ExtensionProperty, + *, + device_identifier: str, + record: CurationRecord | None = None, + ) -> None: + """Take the device class from the record, because there is nothing to default from. + + A sensor's device class can be read off the declared unit; a boolean + declares no unit, so `door` and `problem` and `running` are + indistinguishable on the wire. An uncurated binary sensor therefore has + no device class at all, and the user's assertion is the only one there + can be. + """ + super().__init__( + coordinator, unique_id, row, device_identifier=device_identifier, record=record + ) + self._attr_device_class = binary_sensor_device_class(record) + + @property + def is_on(self) -> bool | None: + """Homie spells a boolean `true`/`false`; anything else is not an answer.""" + return homie_boolean(self._published()) + + +def create_extension_sensors( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, + *, + config_entry_id: str, + overlay: CurationOverlay, + pv_binding: PvBinding, +) -> list[ExtensionSensor]: + """Every extension property that is not a declared boolean.""" + return _create( + ExtensionSensor, + coordinator, + snapshot, + device_registry, + entity_registry, + Platform.SENSOR, + config_entry_id=config_entry_id, + overlay=overlay, + pv_binding=pv_binding, + ) + + +def create_extension_binary_sensors( + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, + *, + config_entry_id: str, + overlay: CurationOverlay, + pv_binding: PvBinding, +) -> list[ExtensionBinarySensor]: + """Every extension property declared `boolean`.""" + return _create( + ExtensionBinarySensor, + coordinator, + snapshot, + device_registry, + entity_registry, + Platform.BINARY_SENSOR, + config_entry_id=config_entry_id, + overlay=overlay, + pv_binding=pv_binding, + ) + + +def _create[ExtensionT: ExtensionEntity]( + entity_class: type[ExtensionT], + coordinator: SpanPanelCoordinator, + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, + platform: Platform, + *, + config_entry_id: str, + overlay: CurationOverlay, + pv_binding: PvBinding, +) -> list[ExtensionT]: + """Build one platform's share of the extension properties. + + One partition function rather than two bodies, so `resolve_platform` stays + the only place a property's platform is decided -- two bodies would each + restate the predicate, and a property could then reach both platforms or + neither. + + **The curated record is read through `for_row`, never off the overlay.** The + store keeps whatever the user asserted when they asserted it, and the + declaration it was asserted against can have moved since. `for_row` measures + the record against the declaration in hand and drops what no longer fits; + the raw record would reach the device-class constructor unchecked and raise + inside `async_setup_entry`, which is the whole platform for one stale row. + The context is built from `platform` rather than from the datatype, because + `resolve_platform` has already ruled on which platform this row is on and a + second derivation could disagree with the registry. + + `extension_curation_key` declines exactly the subjects `extension_unique_id` + declines, so every row reaching here has a key. The `None` branch is the + type system holding the two to one contract, not a case that occurs. + """ + built: list[ExtensionT] = [] + for adopted in adoptable( + snapshot, + device_registry, + entity_registry, + config_entry_id=config_entry_id, + pv_binding=pv_binding, + ): + row = adopted.row + if resolve_platform(entity_registry, adopted.unique_id, row.datatype) is not platform: + continue + key = extension_curation_key(adopted.subject, row.path) + context = RowContext(platform=platform, datatype=row.datatype, unit=row.unit) + record = None if key is None else overlay.for_row(key, context) + built.append( + entity_class( + coordinator, + adopted.unique_id, + row, + device_identifier=adopted.device_identifier, + record=record, + ) + ) + return built + + +def subject_key(subject: ExtensionSubject) -> str: + """Return the wire device a subject names, as one string. + + What the cap counts. Not the device *card*: `panel`, every circuit and both + lugs render on the panel's card, so counting per card would pool thirty-five + wire devices against one allowance -- two vendor properties on each circuit + of a 32-circuit panel would truncate with no misbehaving publisher anywhere. + """ + return ( + subject.kind if subject.instance_key is None else f"{subject.kind}:{subject.instance_key}" + ) + + +def adoptable( + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, + *, + config_entry_id: str, + pv_binding: PvBinding, +) -> list[AdoptableExtension]: + """Every extension property that can become an entity, with its id, card and placed subject. + + Three reasons a declared property is declined here, all of them stated rather + than silent: its subject resolves to no device card, its card is not in this + entry's registry yet, or its address is outside the Homie charset. The first + two are ordinary states on a setup that raced a capability -- the entity + appears on the next reload, as capability-gated platforms already do. + + **An id the registry already holds is never displaced by the cap.** The cap + admits rows in the order the adapter emitted them, and that order tracks the + wire: a firmware update declaring a new property earlier in a description + shifts everything after it. Capping on arrival order alone would therefore + let a *new* property evict a standing entity -- whose registry row is + permanent, and for which nothing here would ever build an entity again, so + it would read unavailable forever while a stranger took its slot. Nothing + migrates in this design, so there would be no recovery. Two passes instead: + everything already registered is admitted first, and the cap applies only to + what is new. + """ + return _partition( + snapshot, device_registry, entity_registry, config_entry_id, pv_binding=pv_binding + )[0] + + +def declined_extensions( + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, + *, + config_entry_id: str, + pv_binding: PvBinding, +) -> dict[str, int]: + """How many properties each wire device declared beyond the cap. + + Separate from `adoptable` so the overflow is reported once at setup rather + than once per platform: both platforms build their share from the same + partition, and a warning per platform would double-count in the log while + saying nothing new. + """ + return _partition( + snapshot, device_registry, entity_registry, config_entry_id, pv_binding=pv_binding + )[1] + + +def _partition( + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, + config_entry_id: str, + *, + pv_binding: PvBinding, +) -> tuple[list[AdoptableExtension], dict[str, int]]: + """Split the declared properties into what is adopted and what the cap declined. + + The card is looked up within `config_entry_id` rather than across every entry, + for the reason `util.py` gives at its own head: identifiers are unique inside a + config entry and nowhere else. The unscoped question could be answered by a + device another entry owns, and the row would then be admitted as though its + card existed. It would not land on that device -- the registry files an + entity's device within the entity's own entry -- but on a card minted for it + here, carrying nothing but the identifier: the card of its own an extension + entity must never mint, reached around the deferral that exists to prevent it. + + A second SPAN entry cannot hold this panel's identifiers today, because the + config flow keys entries on the serial every identifier embeds. That is a + property of the flow rather than of the registry, and the lookup does not + rest on it. Home Assistant reports the unscoped lookup as deprecated for this + ambiguity and says it stops working in 2027.8. + """ + known: list[AdoptableExtension] = [] + fresh: list[AdoptableExtension] = [] + for row in snapshot.extension_properties: + subject = solar_subject(row.subject, pv_binding, snapshot) + if subject is None: + _LOGGER.debug( + "Extension property %s belongs to an inverter with no card at this setup; deferred", + row.path, + ) + continue + identifier = extension_device_identifier(snapshot.serial_number, subject) + if identifier is None: + continue + if ( + device_registry.async_get_device_by_identifier((DOMAIN, identifier), config_entry_id) + is None + ): + _LOGGER.debug( + "Extension property %s has no registered device for %s yet; deferred to the next reload", + row.path, + identifier, + ) + continue + unique_id = extension_unique_id( + snapshot.serial_number, subject, row.node_id, row.property_id + ) + if unique_id is None: + continue + registered = any( + entity_registry.async_get_entity_id(platform.value, DOMAIN, unique_id) is not None + for platform in (Platform.SENSOR, Platform.BINARY_SENSOR) + ) + (known if registered else fresh).append( + AdoptableExtension(row, unique_id, identifier, subject) + ) + + per_subject: dict[str, int] = {} + for adopted in known: + key = subject_key(adopted.row.subject) + per_subject[key] = per_subject.get(key, 0) + 1 + + adoptable_rows = list(known) + declined: dict[str, int] = {} + for adopted in fresh: + key = subject_key(adopted.row.subject) + if per_subject.get(key, 0) >= MAX_PER_DEVICE: + declined[key] = declined.get(key, 0) + 1 + continue + per_subject[key] = per_subject.get(key, 0) + 1 + adoptable_rows.append(adopted) + return adoptable_rows, declined + + +_OVERFLOW_NOTICE: Final = "extension_overflow" + +_OVERFLOW_FALLBACK: Final[dict[str, str]] = { + "title": "SPAN Panel: some vendor readings were not added", + "body": ( + "A device on your panel declares more vendor readings than this integration will add " + "for one device ({limit}). The rest were left out: {devices}.\n\n" + "Nothing you already have is affected, and nothing is broken. The readings that were " + "left out are still listed in this integration's diagnostics download, which is what " + "to attach if you want them surfaced." + ), +} +"""English text for the overflow notice, used when a translation cannot be read. + +Carried here rather than only in `strings.json` for the reason `additions` +documents: an unreadable file should cost the translation, never the notice -- +and a silently truncated surface is exactly the thing that must not be silent. +""" + + +async def async_notice_declined_extensions( + hass: HomeAssistant, + entry: ConfigEntry, + snapshot: SpanPanelSnapshot, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, + *, + pv_binding: PvBinding, +) -> None: + """Tell the user once when the cap left vendor readings out, or say nothing. + + A durable notice rather than a log line, because the alternative is a + truncation the user cannot see: an entity list showing sixty of a device's + eighty readings looks exactly like a device with sixty readings. Raised once + at setup rather than from `adoptable`, which both platforms call. + + **Once, not once per setup.** The overflow is re-derived from the same wire + every time this runs, so raising it through `async_raise` put the notice back + on screen after every restart and reload -- for as long as the publisher kept + publishing, which is forever. `notices.py` says in its own opening paragraphs + that a notice stands until it is dismissed; one that ignores the dismissal is + not a notice, and this integration would have taught its users to ignore the + category. + + The remembered fingerprint is the rendered device/count list rather than the + message, so a *worse* truncation later -- another device overflowing, or more + readings declined on the same one -- is news the user is told about, while a + translation change is not. + """ + declined = declined_extensions( + snapshot, + device_registry, + entity_registry, + config_entry_id=entry.entry_id, + pv_binding=pv_binding, + ) + if not declined: + return + rendered = ", ".join(f"{key} ({count})" for key, count in sorted(declined.items())) + text = await hass.async_add_executor_job( + read_translations, hass.config.language, _OVERFLOW_NOTICE + ) + raised = async_raise_on_change( + hass, + entry, + _OVERFLOW_NOTICE, + title=text.get("title") or _OVERFLOW_FALLBACK["title"], + message=(text.get("body") or _OVERFLOW_FALLBACK["body"]).format( + limit=MAX_PER_DEVICE, devices=rendered + ), + fingerprint=rendered, + ) + # Logged on the same decision as the notice, for the same reason. A WARNING + # per setup for a condition the user has already answered is the log's + # version of an un-dismissable notice, and it is the line a maintainer would + # read as "this just happened". + _LOGGER.log( + logging.WARNING if raised else logging.DEBUG, + "Vendor readings beyond the per-device limit of %d were not adopted: %s", + MAX_PER_DEVICE, + rendered, + ) diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py new file mode 100644 index 00000000..37c9cc48 --- /dev/null +++ b/custom_components/span_panel/field_paths.py @@ -0,0 +1,575 @@ +"""Snapshot field paths this integration reads. + +Most declarations live on the entity descriptions that read them, so they +cannot drift from the reader. A few readers are in entity code rather than on a +description; those are listed here. + +This module replaced `schema_expectations.SENSOR_FIELD_MAP` (since deleted), a +hand-maintained parallel dict that had already drifted once (it pointed at +`battery.product_name` and `pv.product_name` after the library renamed those +fields to `battery.model` / `pv.model`). + +Field path convention: ``{snapshot_type}.{field_name}`` — ``panel``, +``circuit``, ``battery``, ``pv``, ``evse``, ``mid`` and ``pcs``. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator, Mapping +from dataclasses import dataclass +from enum import Enum +from types import MappingProxyType + +from homeassistant.helpers.entity import EntityDescription + + +class DerivedReason(Enum): + """Why an entity description declares no single source field. + + A description is derived for exactly one of these reasons, and which one is + mechanical — count the snapshot fields its `value_fn` reads. + `test_derived_reasons_match_what_value_fns_read` runs every derived + description against the recorder and asserts the reason it claims, so a + wrong reason fails the build rather than misinforming a reader. + + Reading exactly one producible field is **always** a declaration, however + much arithmetic, mapping or membership-testing is applied on top. "Computed + from `status`" is not derivation: `field_path="evse.status"` with a + `value_fn` of `status in {...}` declares its source correctly and still + computes whatever it likes. `evse_ev_connected` was misclassified as derived + this way, which cost it both a Repair mention and its unavailability, while + its sibling `evse_charging` — same field, same shape — got both. That is the + conflation this enum exists to break: as a bare `bool`, all three reasons + below and that mistake looked identical. + """ + + NO_SOURCE_FIELD = "no_source_field" + """Reads no field either adapter publishes a metadata row for. + + Either it reads nothing off the snapshot at all (`panel_status` reports + coordinator reachability) or the field it reads is one no adapter produces + (`dsm_state`, every `mid.*` read). Deliberately one member rather than two: + the recorder cannot tell those apart — both leave an empty intersection with + what the adapters emit — and a variant nothing can verify is exactly what + this enum replaces. + """ + + MULTIPLE_FIELDS = "multiple_fields" + """Combines two or more producible fields, so no one of them is the source. + + The net-energy sensors subtract produced from consumed; blaming either field + alone for the entity would be wrong. + """ + + SCHEMA_CONDITIONAL_FIELD = "schema_conditional_field" + """Reads exactly one field, which only one adapter produces. + + Keeps the producible gate satisfiable: the gate requires a path both + adapters emit, so a schema-conditional field cannot satisfy it. If the + other adapter ever grows the field, this stops being true and the + verification fails, demanding promotion to a plain `field_path` declaration. + + Alone among the reasons, this one still names its source: the description + sets `field_path` **as well as** `derived`, and + `test_schema_conditional_descriptions_name_their_field` holds it to that. + The two attributes answer different questions -- "what does this entity's + value come from" and "why is that path outside the both-adapters gate" -- + and only the first is what a Repair and the availability probe need. Leaving + it unset excused schema-conditional entities from both, which is the + `evse_ev_connected` failure mode one level along: an entity whose field the + panel stopped resolving would keep publishing a default, and the Repair + naming that field would say "0 entities affected". + """ + + +class Producibility(Enum): + """Which adapters publish a metadata row for an exempt residual path. + + A *path's* producibility, not a *description's* classification: kept beside + `DerivedReason` because the two are verified from the same pair of adapter + metadata sets, and deliberately separate because they describe different + subjects. `bess_connected` is `SCHEMA_CONDITIONAL_FIELD` while the + `battery.connected` path it reads is `SCHEMA_0_ONLY` — two facts about two + things. + + There is deliberately no `BOTH` member. A path both adapters produce + satisfies the producible gate, so it belongs in `declared_field_paths()` + rather than in an exemption; `test_no_exempt_path_is_producible_by_both` + turns that missing member into a failure naming the path to promote. + """ + + NEITHER = "neither" + """No metadata row on either adapter.""" + + SCHEMA_0_ONLY = "schema_0_only" + """Produced by the schema_0 adapter, absent from schema_1.""" + + SCHEMA_1_ONLY = "schema_1_only" + """Produced by the schema_1 adapter, absent from schema_0.""" + + +@dataclass(frozen=True, kw_only=True) +class FieldPathDeclarationMixin: + """Declares which snapshot field an entity description reads. + + Mixed into every required-keys mixin so the declaration and the reader are + the same object. Defined once here rather than repeated on each mixin, so + the two fields cannot themselves drift apart across platforms. + + The fields are keyword-only: an entity description flattens this mixin's + fields ahead of ``EntityDescription.key``, which has no default, so a + positional pair here would make every description unconstructable. + """ + + field_path: str | None = None + """Snapshot field this entity's value comes from, e.g. "circuit.instant_power_w". + + Declared here rather than in a parallel map so the declaration and the + reader are the same object. Verified against `value_fn` by the proxy test + in tests/test_field_path_introspection.py. + + Set whenever the entity *has* a single source field — including when that + field is schema-conditional and so cannot enter the producible gate. What + keeps a schema-conditional path out of the gate is `derived`, not the + absence of this. Consumers that ask "which field is this entity's value" + (`SpanPanelEntity._declared_field_paths`, `_reads_an_unresolved_field`) + therefore read this alone; the gate additionally consults `derived`. + """ + + derived: DerivedReason | None = None + """Why this entity's source field is outside the producible gate, or `None`. + + Which reason applies is not a matter of opinion — see `DerivedReason`, whose + members are each asserted against what the `value_fn` actually reads. + + Its relationship to `field_path` is per reason, pinned by + `test_every_description_declares_exactly_one`: `NO_SOURCE_FIELD` and + `MULTIPLE_FIELDS` have no single field to name, so `field_path` stays + `None`; `SCHEMA_CONDITIONAL_FIELD` has exactly one and must name it. + + A reason rather than a flag because `bool` conflated four situations, and + that conflation is how `evse_ev_connected` — one producible field, marked + derived — stayed invisible to both the Repair count and the availability + probe. Every consumer tests this by truthiness, which an enum member and + `None` answer exactly as `True` and `False` did. + """ + + +RESIDUAL_EXEMPT_PATHS: Mapping[str, Producibility] = MappingProxyType( + { + # Homie `$target` values — a pending-command echo, not a schema field. + "circuit.relay_state_target": Producibility.NEITHER, + "circuit.priority_target": Producibility.NEITHER, + # Assembled by the library from panel topology rather than read from a + # schema property. + "circuit.device_type": Producibility.NEITHER, + "circuit.relative_position": Producibility.NEITHER, + # The panel reports it outside the typed field surface. + "panel.panel_size": Producibility.NEITHER, + # The enclosure's build identity, read by `snapshot_to_device_info` for + # the panel's device card. No row on either adapter, and deliberately: + # flat declares none of the three, and schema_1 rows exist to carry a + # unit and a datatype for a *reading*, which an identity string is not. + # Same shape as the `mid.*` device-card reads below. + "panel.vendor_name": Producibility.NEITHER, + "panel.model": Producibility.NEITHER, + "panel.hardware_version": Producibility.NEITHER, + # `shed/policy` parsed, read for the attributes on `dsm_state` + # (`SpanPanelPanelStatus.extra_state_attributes`). The raw document is + # kept beside the parsed members because the policy schema is versioned + # in its own `$id`: an algorithm this library does not recognise still + # reaches a user as the string the panel published. No row on either + # adapter -- flat has no `shed` node, and a JSON document has no unit + # surface for a schema_1 row to describe. + "panel.shed_policy": Producibility.NEITHER, + "panel.shed_policy_algorithm": Producibility.NEITHER, + "panel.shed_soc_threshold_shed_percent": Producibility.NEITHER, + "panel.shed_soc_threshold_release_percent": Producibility.NEITHER, + # The panel identity key behind every unique_id and the panel DeviceInfo + # (~30 read sites). + "panel.serial_number": Producibility.NEITHER, + # Gates button availability at button.py:115 — the same reason the + # `dsm_state` sensor is `derived=True`. + "panel.dsm_state": Producibility.NEITHER, + # The circuit's own identity key, used for lookups and id construction + # (helpers.py, coordinator.py, entity_resolver.py). + "circuit.circuit_id": Producibility.NEITHER, + # util.py reads these off the MID snapshot for device_info, and + # sensor_panel.py reads the grid-forming name for an attribute. + "mid.hardware_version": Producibility.NEITHER, + "mid.software_version": Producibility.NEITHER, + "mid.vendor_name": Producibility.NEITHER, + "mid.model": Producibility.NEITHER, + "mid.serial_number": Producibility.NEITHER, + "mid.grid_forming_device_name": Producibility.NEITHER, + # `pv_device_info` reads the inverter's firmware version for its device + # card. `pv.vendor_name` and `pv.model` are not here beside it because + # they are `field_path` declarations on the three PV metadata sensors + # already, and the card reads the same two fields those sensors do. + # + # `SCHEMA_0_ONLY`, and it says so because the annotation is checked + # rather than asserted: this read `NEITHER` on the claim that "flat's + # `pv` device class declares no firmware version at all", which was + # wrong. Flat declares `software-version` on `energy.ebus.device.pv`, + # and the library grew the mapping row for it once a producer valued the + # v1.0 half. schema_1 still carries no row, for the reason the `mid.*` + # and `panel.*` card reads above carry none: a row states a *reading's* + # unit and datatype, and a version string is not a reading. + # + # `pv.serial_number` is deliberately absent -- from this table, from the + # card and from the snapshot. See `pv_device_info`. + "pv.software_version": Producibility.SCHEMA_0_ONLY, + # The `mid_grid_state` sensor's source field — utility-supply health, + # the one non-metadata entity the MID brings. Neither adapter maps the + # MID at all, which is why the description is `NO_SOURCE_FIELD`. + "mid.grid_state": Producibility.NEITHER, + # The EVSE's Homie node id — an addressing handle used to build the + # sub-device identifier, not a published field. + "evse.node_id": Producibility.NEITHER, + # The charge-current control's two non-readings, read by + # `SpanEvseNumber`: `$settable` on the limit's declaration, which is the + # entity-creation gate, and the Homie `$target` echo of a write the + # panel has accepted but not yet applied, rendered as an attribute. Both + # are facts about a command rather than readings, so no adapter carries + # a metadata row for either — the same shape as the `circuit.*_target` + # pair at the top of this map, and the same reason `panel.grid_islandable` + # sits here as a creation gate. + "evse.charge_current_limit_settable": Producibility.NEITHER, + "evse.charge_current_limit_target_a": Producibility.NEITHER, + # The shed-forecast refinements, read for attributes on the two forecast + # sensors (`SpanShedForecastSensor.extra_state_attributes`). schema_1 + # reads all three into the snapshot but carries a `_PROPERTY_FIELD_MAP` + # row for neither: they qualify the two live estimates rather than being + # readings of their own, so there is no unit surface for a row to + # describe. Same shape as the `mid.*` attribute reads above. + "panel.shed_full_charge_time_to_priority_shed_min": Producibility.NEITHER, + "panel.shed_full_charge_total_time_remaining_min": Producibility.NEITHER, + "panel.shed_forecast_confidence": Producibility.NEITHER, + # The PCS arbitration's *inputs*, read for the twelve attributes on + # `pcs_import_limit` plus its `pcs_enabled` (`pcs_arbitration_attributes` + # in sensor_definitions). schema_1 reads all thirteen into the snapshot + # and carries a `_PROPERTY_FIELD_MAP` row for none of them, deliberately: + # the capability calls `import-limit` and `binding-constraint` "the + # result", and these explain that result rather than being readings of + # their own, so there is no unit surface for a row to describe. Same + # shape as the shed-forecast refinements above. + "pcs.enabled": Producibility.NEITHER, + "pcs.feed_import_limit_a": Producibility.NEITHER, + "pcs.feed_import_limit_enablement": Producibility.NEITHER, + "pcs.feed_import_limit_active": Producibility.NEITHER, + "pcs.operator_import_limit_a": Producibility.NEITHER, + "pcs.operator_import_limit_enablement": Producibility.NEITHER, + "pcs.operator_import_limit_active": Producibility.NEITHER, + "pcs.off_grid_import_limit_a": Producibility.NEITHER, + "pcs.off_grid_import_limit_enablement": Producibility.NEITHER, + "pcs.off_grid_import_limit_active": Producibility.NEITHER, + "pcs.requested_import_limit_a": Producibility.NEITHER, + "pcs.requested_import_limit_enablement": Producibility.NEITHER, + "pcs.requested_import_limit_active": Producibility.NEITHER, + # A circuit's *participation* in that PCS — `managed` and `priority`, + # read as attributes on its power sensor (`sensor_circuit.py`). Not + # `_residual_field_paths` on the entity: that feeds + # `declared_field_paths()`, and no flat circuit declares a `pcs` node at + # all, so the producible gate would reject both. schema_1 maps neither + # for the reason above — they qualify a circuit's reading rather than + # being one. + "circuit.pcs_managed": Producibility.NEITHER, + "circuit.pcs_priority": Producibility.NEITHER, + "circuit.is_user_controllable": Producibility.SCHEMA_1_ONLY, + # The two backup-planning estimates behind `time_to_priority_shed` and + # `shed_total_time_remaining`, whose descriptions are + # `SCHEMA_CONDITIONAL_FIELD` for exactly this reason: no flat panel + # publishes `energy.ebus.capability.shed-forecast` at all, so the + # producible gate — which demands both adapters — cannot be satisfied. + # schema_1 does carry a metadata row for each, which is what makes the + # annotation SCHEMA_1_ONLY rather than NEITHER and buys the pair unit + # and datatype validation against the panel's own `$description`. + "panel.shed_time_to_priority_shed_min": Producibility.SCHEMA_1_ONLY, + "panel.shed_total_time_remaining_min": Producibility.SCHEMA_1_ONLY, + # The BESS's own meter and its own link health, behind `bess_meter_power` + # and `bess_communication_state`, whose descriptions are + # `SCHEMA_CONDITIONAL_FIELD` for the usual reason: flat's BESS device + # class declares neither property, so the both-adapters gate cannot be + # satisfied. schema_1 carries a `_PROPERTY_FIELD_MAP` row for each, which + # is what makes these SCHEMA_1_ONLY rather than NEITHER and buys them unit + # and datatype validation against the BESS's own `$description`. + # + # `battery.power_w` carries `bess_meter_power`, and `has_bess` reads it to + # decide whether a battery is commissioned. It agrees with `battery_power` + # by construction: the two wire properties behind them carry the same sign + # as each other on both the panel and the emitter, and each path negates + # once. See `BESS_TELEMETRY_SENSORS` for which direction that shared + # convention runs, which a live capture has not settled. + "battery.power_w": Producibility.SCHEMA_1_ONLY, + "battery.communication_state": Producibility.SCHEMA_1_ONLY, + # The Power Control System's result, behind `pcs_import_limit`, + # `pcs_binding_constraint` and the `pcs_active` binary sensor. Their + # descriptions are `SCHEMA_CONDITIONAL_FIELD` for the usual reason: no + # flat panel declares `energy.ebus.capability.pcs`, so the both-adapters + # gate cannot be satisfied. schema_1 carries a `_PROPERTY_FIELD_MAP` row + # for each of these three, which is what makes them SCHEMA_1_ONLY rather + # than NEITHER and buys `pcs.import_limit_a` unit validation against the + # panel's own `$description`. + "pcs.import_limit_a": Producibility.SCHEMA_1_ONLY, + "pcs.binding_constraint": Producibility.SCHEMA_1_ONLY, + "pcs.active": Producibility.SCHEMA_1_ONLY, + # The enclosure's view of the link to each circuit-fed DER, behind the + # `pv_panel_link` and `evse_panel_link` binary sensors. Both descriptions + # are `SCHEMA_CONDITIONAL_FIELD` for the usual reason: flat firmware + # publishes `connected` on the BESS and on no other device class, so the + # both-adapters gate cannot be satisfied. schema_1 maps both — from one + # property, the feeding circuit's `connection/feeds-device-status` — + # which is what makes these SCHEMA_1_ONLY rather than NEITHER. + # + # `battery.connected` is the same fact for the third DER and sits below + # as SCHEMA_0_ONLY, which is the whole shape of this gap: the link the + # enclosure reported through the lugs was read, and the one it reports + # through a circuit was not. + "pv.connected": Producibility.SCHEMA_1_ONLY, + "evse.connected": Producibility.SCHEMA_1_ONLY, + # The EVSE charge-current pair behind the `evse_charge_current_limit` + # number: the settable limit the entity's value comes from (its + # description is `SCHEMA_CONDITIONAL_FIELD` and names it; the value is + # the library's effective limit, which falls back to the ceiling), and + # the commissioned ceiling the entity reads for `native_max_value`. Flat + # firmware's `evse` device type publishes `advertised-current` and no + # settable ceiling at all, so neither can ever satisfy the both-adapters + # gate. schema_1 carries a metadata row for each, resolved from the + # charger's own `$description` rather than from a table, which is what + # makes these SCHEMA_1_ONLY rather than NEITHER and buys the entity unit + # validation against the property the panel actually declares. + # + # The ceiling is deliberately not on `SpanEvseNumber._residual_field_paths`: + # that feeds `declared_field_paths()`, which is the both-adapters gate, + # and flat produces neither path. + "evse.charge_current_limit_a": Producibility.SCHEMA_1_ONLY, + "evse.charge_current_ceiling_a": Producibility.SCHEMA_1_ONLY, + "circuit.always_on": Producibility.SCHEMA_0_ONLY, + "circuit.is_sheddable": Producibility.SCHEMA_0_ONLY, + # The `grid_forming_entity` sensor's source field. schema_1 answers the + # same question through `resolve_dominant_power_source` over the MID's + # `grid/grid-forming-entity` instead of publishing a row of its own, + # which is what makes the sensor `SCHEMA_CONDITIONAL_FIELD`. + "panel.dominant_power_source": Producibility.SCHEMA_0_ONLY, + # util.py builds the EVSE DeviceInfo from these; entity_resolver.py and + # sensor.py resolve the fed circuit through `feed_circuit_id`. + "evse.vendor_name": Producibility.SCHEMA_0_ONLY, + "evse.model": Producibility.SCHEMA_0_ONLY, + "evse.serial_number": Producibility.SCHEMA_0_ONLY, + "evse.software_version": Producibility.SCHEMA_0_ONLY, + "evse.feed_circuit_id": Producibility.SCHEMA_0_ONLY, + # schema_1 derives islanding via `resolve_grid_islandable(inverters)` + # instead. Read at binary_sensor.py:408 as an entity-creation gate, + # outside any description. + "panel.grid_islandable": Producibility.SCHEMA_0_ONLY, + # schema_1's `_PROPERTY_FIELD_MAP` has no `connected` row — the same gap + # that makes the `bess_connected` binary sensor `derived=True`. + "battery.connected": Producibility.SCHEMA_0_ONLY, + } +) +"""Residual readers exempt from the producible check, and why each is exempt. + +The gate requires a path to be producible by *both* adapters, so a read is +exempt for one of two reasons, and the annotation says which: +`Producibility.NEITHER` for values no adapter publishes a metadata row for — +Homie `$target` echoes, values the library assembles from panel topology, every +`mid.*` field; `SCHEMA_0_ONLY` / `SCHEMA_1_ONLY` for schema-conditional fields +present on one schema and absent from the other. + +Exempt is not the same as derived: these are read straight off a snapshot field, +that field just is not on both schemas. + +The annotations are not documentation. `tests/test_field_path_conformance.py` +builds both adapters' metadata from their reference payloads and asserts every +entry's annotation against what those adapters actually produce, so a stale +reason fails the build instead of misleading a reader. A path that becomes +producible by both fails there too, demanding promotion to a declaration. + +Deliberately **not** returned by `declared_field_paths()`. Recorded here so the +reads are still enumerated somewhere rather than being invisible. +""" + + +def _iter_declared[DescriptionT: EntityDescription]( + descriptions: Iterable[DescriptionT], +) -> Iterator[tuple[str, FieldPathDeclarationMixin, DescriptionT]]: + """Yield ``(field_path, declaration, description)`` for each named source field. + + The single copy of the traversal rule — which descriptions carry a + declaration at all — so no caller can drift from another on it. + + Raises `TypeError` for a description that carries no + `FieldPathDeclarationMixin`: such a description would be dropped silently, + which is the drift this module exists to prevent. A description that carries + the mixin and names no field is skipped, which is the declared-derived case + rather than drift. + + `declaration` and `description` are the same object; they are yielded twice + because a type variable bounded on `EntityDescription` cannot also be known + to carry the mixin, and the alternative is a `cast` at every call site. + """ + for description in descriptions: + if not isinstance(description, FieldPathDeclarationMixin): + raise TypeError( + f"entity description '{description.key}' carries no field-path declaration" + ) + if description.field_path is None: + continue + yield description.field_path, description, description + + +def iter_source_field_declarations[DescriptionT: EntityDescription]( + descriptions: Iterable[DescriptionT], +) -> Iterator[tuple[str, DescriptionT]]: + """Yield ``(field_path, description)`` for each description that names one. + + Every entity that has a single source field, whatever `derived` says about + which adapters produce it. + """ + for field_path, _, description in _iter_declared(descriptions): + yield field_path, description + + +def iter_field_path_declarations[DescriptionT: EntityDescription]( + descriptions: Iterable[DescriptionT], +) -> Iterator[tuple[str, DescriptionT]]: + """Yield only the declarations the producible gate covers. + + `derived` descriptions are skipped whether or not they name a field. That + is the whole content of the exemption: a `SCHEMA_CONDITIONAL_FIELD` + description does name its source field, and naming it is what gives the + entity its Repair mention and its unavailability — but the path is still + one adapter short of the both-adapters gate this function feeds, and + `RESIDUAL_EXEMPT_PATHS` is where it is enumerated instead. + """ + for field_path, declaration, description in _iter_declared(descriptions): + if declaration.derived: + continue + yield field_path, description + + +def _walk_subclasses[EntityT](root: type[EntityT]) -> Iterator[type[EntityT]]: + """Yield every subclass of `root`, transitively. + + `__subclasses__()` is one level deep; platform entities sit two or three + levels below the base (`SpanSensorBase` -> `SpanCircuitPowerSensor`), so a + single level would miss exactly the classes that carry residual reads. + """ + for subclass in root.__subclasses__(): + yield subclass + yield from _walk_subclasses(subclass) + + +def residual_field_paths() -> frozenset[str]: + """Field paths read from entity code rather than from a description. + + A handful of reads cannot be expressed as a description `field_path`: the + switch has no entity description at all, the select wraps one rather than + being a frozen dataclass, and a circuit entity's name, tabs and attributes + are read outside any `value_fn`. Each such read is declared on the entity + that makes it -- `SpanPanelEntity._residual_field_paths` -- because the + entity is what a Repair has to name when the field dies. + + Collected from those declarations rather than restated as a constant here: + a second copy would need a test to hold it against the first, and the copy + that the Repair actually consumes is the entity's. + + The walk sees only classes Python has imported, so the platform modules + that declare residuals are imported here explicitly -- exactly those, no + more: a module listed here that declares nothing is a stale import, and one + that declares something and is missing would go missing silently. Both + directions are pinned by + `test_the_residual_walk_imports_exactly_the_modules_that_declare_one`, which + reads this list out of the source because under pytest the platform modules + are already imported for other reasons and an omission here would still walk. + """ + # Deferred for the same cycle-avoidance reason as `declared_field_paths()` + # below: every platform module imports this one for the declaration mixin. + from . import ( # noqa: F401 pylint: disable=import-outside-toplevel,unused-import + binary_sensor, + select, + sensor_circuit, + switch, + ) + from .entity import SpanPanelEntity # pylint: disable=import-outside-toplevel + + return frozenset( + path + for entity_class in _walk_subclasses(SpanPanelEntity) + for path in entity_class._residual_field_paths # pylint: disable=protected-access + ) + + +def platform_descriptions() -> tuple[EntityDescription, ...]: + """Every entity description this integration builds entities from. + + One copy of the collection list. `declared_field_paths` and + `conditional_field_paths` ask different questions of the same descriptions, + and a second copy of the list is how a platform ends up answering one and + not the other. + """ + # Deferred: the platform modules import `FieldPathDeclarationMixin` from + # here, so importing them at module scope would close that loop. (They no + # longer reach the package root for the config-entry type -- that lives in + # the leaf `runtime` module -- but this loop is the module's own and stays.) + from .binary_sensor import ( # pylint: disable=import-outside-toplevel + BESS_CONNECTED_SENSOR, + BINARY_SENSORS, + EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, + PCS_ACTIVE_SENSOR, + ) + from .number import EVSE_NUMBERS # pylint: disable=import-outside-toplevel + from .sensor_definitions import ( # pylint: disable=import-outside-toplevel + all_sensor_descriptions, + ) + + return ( + *all_sensor_descriptions(), + *BINARY_SENSORS, + *EVSE_BINARY_SENSORS, + *EVSE_NUMBERS, + GRID_ISLANDABLE_SENSOR, + BESS_CONNECTED_SENSOR, + PCS_ACTIVE_SENSOR, + ) + + +def declared_field_paths() -> frozenset[str]: + """Field paths the integration reads that must be producible by an adapter. + + Derived entities are excluded: they have no single source field, or the one + they have is not on both schemas, so there is nothing for both adapters to + produce. Residual readers that no adapter (or only one) produces are + excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`. + """ + paths: set[str] = set(residual_field_paths()) + paths.update( + field_path for field_path, _ in iter_field_path_declarations(platform_descriptions()) + ) + return frozenset(paths) + + +def conditional_field_paths() -> frozenset[str]: + """Source fields of entities only one adapter produces the field for. + + The producible gate cannot cover these -- that is what + `DerivedReason.SCHEMA_CONDITIONAL_FIELD` says -- but the adapter that *does* + produce the field still publishes a metadata row for it, and that row can + come back `resolved=False` when the panel drops the property. So the + degradation half of the apparatus applies to them exactly as it does to a + plain declaration, and `schema_validation.evaluate_field_metadata` asks + about these paths alongside `declared_field_paths()`. + + Read off the descriptions rather than filtered out of + `RESIDUAL_EXEMPT_PATHS`: most of that map is decoration -- device_info + fields, circuit attributes, entity-creation gates -- whose loss does not + make an entity's *reading* wrong, and which is deliberately excluded from + the availability probe. What belongs here is the narrower thing the + description states: the field this entity's value comes from. + """ + return frozenset( + field_path + for field_path, declaration, _ in _iter_declared(platform_descriptions()) + if declaration.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD + ) diff --git a/custom_components/span_panel/frontend/dist/span-panel-card.js b/custom_components/span_panel/frontend/dist/span-panel-card.js index b5d260dc..8f4714c1 100644 --- a/custom_components/span_panel/frontend/dist/span-panel-card.js +++ b/custom_components/span_panel/frontend/dist/span-panel-card.js @@ -1,42 +1,42 @@ -let t="en";const e={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){t=n&&e[n]?n:"en"}function i(n){return e[t]?.[n]??e.en?.[n]??n}function r(n,i){return(e[t]?.[n]??e.en?.[n]??n).replace(/\{(\w+)\}/g,(t,e)=>Object.prototype.hasOwnProperty.call(i,e)?i[e]:`{${e}}`)}const o="power",a="5m",s={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},l="span_panel",c="CLOSED",u="pv",h="bess",d="evse",p="sub_",f=500,g={power:{entityRole:"power",label:()=>i("metric.power"),unit:t=>Math.abs(t)>=1e3?"kW":"W",format:t=>{const e=Math.abs(t);return e>=1e3?(e/1e3).toFixed(1):e<10&&e>0?e.toFixed(1):String(Math.round(e))}},current:{entityRole:"current",label:()=>i("metric.current"),unit:()=>"A",format:t=>Math.abs(t).toFixed(1)}},v={soc:{entityRole:"soc",label:()=>i("metric.soc"),unit:()=>"%",format:t=>String(Math.round(t)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>i("metric.soe"),unit:()=>"kWh",format:t=>t.toFixed(1)},power:g.power},m={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>i("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>i("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>i("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>i("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>i("shedding.unknown")}};var y=function(t,e){return y=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},y(t,e)};function _(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}y(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}function x(t,e,n,i){var r,o=arguments.length,a=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,n,i);else for(var s=t.length-1;s>=0;s--)(r=t[s])&&(a=(o<3?r(a):o>3?r(e,n,a):r(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}"function"==typeof SuppressedError&&SuppressedError; +let t="en";const e={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","tab.adopted":"Adopted","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","adopted.heading":"Adopted entities","adopted.description":"Vendor readings and adopted devices arrive with minimal metadata. Curate one to set its device class, statistics class, and prominence — saved changes reload the integration and apply from the next startup on.","adopted.filter_placeholder":"Filter by entity or device name","adopted.no_results":"No adopted entities match this filter","adopted.none":"This panel publishes nothing to curate.","adopted.load_failed":"Unable to load adopted entities","adopted.count":"{count} adopted","adopted.vendor_readings":"VENDOR READINGS","adopted.adopted_device":"ADOPTED DEVICE","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURATED","adopted.stale":"STALE","adopted.stale_note":"The panel no longer supports what was saved for: {fields}","adopted.enable_entity":"Enable entity","adopted.enabled":"ENABLED","adopted.disabled":"DISABLED","adopted.enable_note":"Enabled when saved — curating never enables on its own","adopted.registry_unavailable":"Enable and name become available once this entity exists in the registry.","adopted.name":"Name","adopted.device_class":"Device class","adopted.device_class_note":"choices limited by the panel's unit ({unit})","adopted.device_class_note_unitless":"choices limited by what the panel publishes","adopted.no_device_class":"No device class","adopted.statistics_class":"Statistics class","adopted.statistics_note":"long-term statistics begin after the next reload","adopted.no_statistics":"No statistics","adopted.prominence":"Prominence","adopted.diagnostic":"Diagnostic","adopted.standard":"Standard","adopted.display_unit":"Display unit","adopted.unit_as_published":"As published","adopted.precision":"Precision","adopted.precision_default":"Default","adopted.read_only":"read-only","adopted.settable":"settable","adopted.save":"Save","adopted.clear":"Clear curation","adopted.reload_note":"Saving reloads the SPAN Panel integration","adopted.saved":"Saved — the integration is reloading","adopted.confirm_heading":"Confirm statistics class","adopted.confirm_setting":"You are setting {name} to {value}.","adopted.confirm_clearing_subject":"You are clearing the statistics class on {name}.","adopted.confirm_total_increasing":"Total increasing treats every drop in the value as a meter reset. If this reading can decrease for any other reason, long-term statistics will be permanently corrupted — fixing the class later does not repair history already written.","adopted.confirm_clearing":"Long-term statistics stop being compiled for this entity, and Home Assistant raises a repair against the statistics already collected under the class you are removing.","adopted.save_anyway":"Save anyway","adopted.cancel":"Cancel","adopted.warn_total_increasing":"Saved as total increasing — every drop in the reading now counts as a meter reset.","adopted.warn_statistics_removed":"This entity has no statistics class any more; Home Assistant will raise a repair against the statistics already collected.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","subdevice.solar":"Solar","subdevice.solar_inverter":"Solar inverter","subdevice.site_total":"Site total","subdevice.in_site_total":"Included in the site total","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.solar_pv":"Solar (PV)","editor.no_entities":"No entities to show","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","tab.adopted":"Adoptadas","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","adopted.heading":"Entidades adoptadas","adopted.description":"Las lecturas del proveedor y los dispositivos adoptados llegan con metadatos mínimos. Cura una para definir su clase de dispositivo, clase de estadísticas y prominencia — los cambios guardados recargan la integración y se aplican desde el siguiente arranque.","adopted.filter_placeholder":"Filtrar por nombre de entidad o dispositivo","adopted.no_results":"Ninguna entidad adoptada coincide con este filtro","adopted.none":"Este panel no publica nada que curar.","adopted.load_failed":"No se pudieron cargar las entidades adoptadas","adopted.count":"{count} adoptadas","adopted.vendor_readings":"LECTURAS DEL PROVEEDOR","adopted.adopted_device":"DISPOSITIVO ADOPTADO","adopted.via_panel":"vía SPAN Panel","adopted.curated":"CURADA","adopted.stale":"OBSOLETA","adopted.stale_note":"El panel ya no admite lo guardado para: {fields}","adopted.enable_entity":"Activar entidad","adopted.enabled":"ACTIVADA","adopted.disabled":"DESACTIVADA","adopted.enable_note":"Se activa al guardar — curar nunca activa por sí solo","adopted.registry_unavailable":"La activación y el nombre estarán disponibles cuando esta entidad exista en el registro.","adopted.name":"Nombre","adopted.device_class":"Clase de dispositivo","adopted.device_class_note":"opciones limitadas por la unidad del panel ({unit})","adopted.device_class_note_unitless":"opciones limitadas por lo que publica el panel","adopted.no_device_class":"Sin clase de dispositivo","adopted.statistics_class":"Clase de estadísticas","adopted.statistics_note":"las estadísticas a largo plazo comienzan tras la próxima recarga","adopted.no_statistics":"Sin estadísticas","adopted.prominence":"Prominencia","adopted.diagnostic":"Diagnóstico","adopted.standard":"Estándar","adopted.display_unit":"Unidad mostrada","adopted.unit_as_published":"Tal como se publica","adopted.precision":"Precisión","adopted.precision_default":"Predeterminada","adopted.read_only":"solo lectura","adopted.settable":"editable","adopted.save":"Guardar","adopted.clear":"Borrar curación","adopted.reload_note":"Guardar recarga la integración SPAN Panel","adopted.saved":"Guardado — la integración se está recargando","adopted.confirm_heading":"Confirmar clase de estadísticas","adopted.confirm_setting":"Vas a definir {name} como {value}.","adopted.confirm_clearing_subject":"Vas a borrar la clase de estadísticas de {name}.","adopted.confirm_total_increasing":"Total creciente interpreta cada caída del valor como un reinicio del contador. Si esta lectura puede disminuir por cualquier otro motivo, las estadísticas a largo plazo quedarán corrompidas de forma permanente — corregir la clase más tarde no repara el historial ya escrito.","adopted.confirm_clearing":"Dejarán de compilarse estadísticas a largo plazo para esta entidad, y Home Assistant abrirá una reparación sobre las estadísticas ya recogidas bajo la clase que estás quitando.","adopted.save_anyway":"Guardar de todos modos","adopted.cancel":"Cancelar","adopted.warn_total_increasing":"Guardado como total creciente — cada caída de la lectura cuenta ahora como un reinicio del contador.","adopted.warn_statistics_removed":"Esta entidad ya no tiene clase de estadísticas; Home Assistant abrirá una reparación sobre las estadísticas ya recogidas.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","subdevice.solar":"Solar","subdevice.solar_inverter":"Inversor solar","subdevice.site_total":"Total del sitio","subdevice.in_site_total":"Incluido en el total del sitio","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.solar_pv":"Solar (FV)","editor.no_entities":"No hay entidades para mostrar","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","tab.adopted":"Adoptées","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","adopted.heading":"Entités adoptées","adopted.description":"Les relevés du fournisseur et les appareils adoptés arrivent avec des métadonnées minimales. Curez-en un pour définir sa classe d'appareil, sa classe de statistiques et sa proéminence — les modifications enregistrées rechargent l'intégration et s'appliquent dès le prochain démarrage.","adopted.filter_placeholder":"Filtrer par nom d'entité ou d'appareil","adopted.no_results":"Aucune entité adoptée ne correspond à ce filtre","adopted.none":"Ce panneau ne publie rien à curer.","adopted.load_failed":"Impossible de charger les entités adoptées","adopted.count":"{count} adoptées","adopted.vendor_readings":"RELEVÉS DU FOURNISSEUR","adopted.adopted_device":"APPAREIL ADOPTÉ","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURÉE","adopted.stale":"OBSOLÈTE","adopted.stale_note":"Le panneau ne prend plus en charge ce qui a été enregistré pour : {fields}","adopted.enable_entity":"Activer l'entité","adopted.enabled":"ACTIVÉE","adopted.disabled":"DÉSACTIVÉE","adopted.enable_note":"Activée à l'enregistrement — la curation n'active jamais d'elle-même","adopted.registry_unavailable":"L'activation et le nom seront disponibles dès que cette entité existera dans le registre.","adopted.name":"Nom","adopted.device_class":"Classe d'appareil","adopted.device_class_note":"choix limités par l'unité du panneau ({unit})","adopted.device_class_note_unitless":"choix limités par ce que le panneau publie","adopted.no_device_class":"Aucune classe d'appareil","adopted.statistics_class":"Classe de statistiques","adopted.statistics_note":"les statistiques à long terme commencent après le prochain rechargement","adopted.no_statistics":"Aucune statistique","adopted.prominence":"Proéminence","adopted.diagnostic":"Diagnostic","adopted.standard":"Standard","adopted.display_unit":"Unité affichée","adopted.unit_as_published":"Telle que publiée","adopted.precision":"Précision","adopted.precision_default":"Par défaut","adopted.read_only":"lecture seule","adopted.settable":"modifiable","adopted.save":"Enregistrer","adopted.clear":"Effacer la curation","adopted.reload_note":"L'enregistrement recharge l'intégration SPAN Panel","adopted.saved":"Enregistré — l'intégration se recharge","adopted.confirm_heading":"Confirmer la classe de statistiques","adopted.confirm_setting":"Vous définissez {name} sur {value}.","adopted.confirm_clearing_subject":"Vous effacez la classe de statistiques de {name}.","adopted.confirm_total_increasing":"Total croissant interprète chaque baisse de la valeur comme une remise à zéro du compteur. Si ce relevé peut diminuer pour une autre raison, les statistiques à long terme seront corrompues de façon permanente — corriger la classe plus tard ne répare pas l'historique déjà écrit.","adopted.confirm_clearing":"Les statistiques à long terme cesseront d'être compilées pour cette entité, et Home Assistant ouvrira une réparation sur les statistiques déjà collectées sous la classe que vous retirez.","adopted.save_anyway":"Enregistrer quand même","adopted.cancel":"Annuler","adopted.warn_total_increasing":"Enregistré en total croissant — chaque baisse du relevé compte désormais comme une remise à zéro du compteur.","adopted.warn_statistics_removed":"Cette entité n'a plus de classe de statistiques ; Home Assistant ouvrira une réparation sur les statistiques déjà collectées.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","subdevice.solar":"Solaire","subdevice.solar_inverter":"Onduleur solaire","subdevice.site_total":"Total du site","subdevice.in_site_total":"Inclus dans le total du site","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.solar_pv":"Solaire (PV)","editor.no_entities":"Aucune entité à afficher","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","tab.adopted":"採用済み","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","adopted.heading":"採用済みエンティティ","adopted.description":"ベンダーの測定値と採用済みデバイスは最小限のメタデータで登録されます。キュレーションでデバイスクラス、統計クラス、表示区分を設定できます。保存すると統合が再読み込みされ、次回の起動から適用されます。","adopted.filter_placeholder":"エンティティ名またはデバイス名で絞り込み","adopted.no_results":"この条件に一致する採用済みエンティティはありません","adopted.none":"このパネルにキュレーション対象はありません。","adopted.load_failed":"採用済みエンティティを読み込めません","adopted.count":"{count} 件","adopted.vendor_readings":"ベンダー測定値","adopted.adopted_device":"採用済みデバイス","adopted.via_panel":"SPAN Panel 経由","adopted.curated":"キュレーション済み","adopted.stale":"無効","adopted.stale_note":"パネルは保存された次の項目をサポートしなくなりました: {fields}","adopted.enable_entity":"エンティティを有効化","adopted.enabled":"有効","adopted.disabled":"無効","adopted.enable_note":"保存時に有効化されます — キュレーション自体が有効化することはありません","adopted.registry_unavailable":"有効化・名前は、このエンティティがレジストリに登録された後に利用できます。","adopted.name":"名前","adopted.device_class":"デバイスクラス","adopted.device_class_note":"パネルの単位({unit})により選択肢が制限されます","adopted.device_class_note_unitless":"パネルが公開する内容により選択肢が制限されます","adopted.no_device_class":"デバイスクラスなし","adopted.statistics_class":"統計クラス","adopted.statistics_note":"長期統計は次回の再読み込み後に開始されます","adopted.no_statistics":"統計なし","adopted.prominence":"表示区分","adopted.diagnostic":"診断","adopted.standard":"標準","adopted.display_unit":"表示単位","adopted.unit_as_published":"公開されたまま","adopted.precision":"小数点以下桁数","adopted.precision_default":"既定","adopted.read_only":"読み取り専用","adopted.settable":"書き込み可","adopted.save":"保存","adopted.clear":"キュレーションを消去","adopted.reload_note":"保存すると SPAN Panel 統合が再読み込みされます","adopted.saved":"保存しました — 統合を再読み込みしています","adopted.confirm_heading":"統計クラスの確認","adopted.confirm_setting":"{name} を {value} に設定しようとしています。","adopted.confirm_clearing_subject":"{name} の統計クラスを消去しようとしています。","adopted.confirm_total_increasing":"積算増加は値の低下をすべてメーターのリセットとして扱います。この測定値が他の理由でも下がる場合、長期統計は恒久的に破損します。後からクラスを直しても、既に書き込まれた履歴は修復されません。","adopted.confirm_clearing":"このエンティティの長期統計は収集されなくなり、削除するクラスの下で既に収集された統計について Home Assistant が修復項目を作成します。","adopted.save_anyway":"それでも保存","adopted.cancel":"キャンセル","adopted.warn_total_increasing":"積算増加として保存しました — 測定値の低下はすべてメーターのリセットとして数えられます。","adopted.warn_statistics_removed":"このエンティティに統計クラスはなくなりました。既に収集された統計について Home Assistant が修復項目を作成します。","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","subdevice.solar":"ソーラー","subdevice.solar_inverter":"ソーラーインバーター","subdevice.site_total":"サイト合計","subdevice.in_site_total":"サイト合計に含まれます","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.solar_pv":"ソーラー (PV)","editor.no_entities":"表示するエンティティがありません","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","tab.adopted":"Adotadas","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","adopted.heading":"Entidades adotadas","adopted.description":"As leituras do fornecedor e os dispositivos adotados chegam com metadados mínimos. Faça a curadoria de uma para definir sua classe de dispositivo, classe de estatísticas e proeminência — alterações salvas recarregam a integração e valem a partir da próxima inicialização.","adopted.filter_placeholder":"Filtrar por nome de entidade ou dispositivo","adopted.no_results":"Nenhuma entidade adotada corresponde a este filtro","adopted.none":"Este painel não publica nada para curadoria.","adopted.load_failed":"Não foi possível carregar as entidades adotadas","adopted.count":"{count} adotadas","adopted.vendor_readings":"LEITURAS DO FORNECEDOR","adopted.adopted_device":"DISPOSITIVO ADOTADO","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURADA","adopted.stale":"OBSOLETA","adopted.stale_note":"O painel não suporta mais o que foi salvo para: {fields}","adopted.enable_entity":"Ativar entidade","adopted.enabled":"ATIVADA","adopted.disabled":"DESATIVADA","adopted.enable_note":"Ativada ao salvar — a curadoria nunca ativa por conta própria","adopted.registry_unavailable":"Ativação e nome ficam disponíveis assim que esta entidade existir no registro.","adopted.name":"Nome","adopted.device_class":"Classe de dispositivo","adopted.device_class_note":"opções limitadas pela unidade do painel ({unit})","adopted.device_class_note_unitless":"opções limitadas pelo que o painel publica","adopted.no_device_class":"Sem classe de dispositivo","adopted.statistics_class":"Classe de estatísticas","adopted.statistics_note":"as estatísticas de longo prazo começam após a próxima recarga","adopted.no_statistics":"Sem estatísticas","adopted.prominence":"Proeminência","adopted.diagnostic":"Diagnóstico","adopted.standard":"Padrão","adopted.display_unit":"Unidade exibida","adopted.unit_as_published":"Como publicada","adopted.precision":"Precisão","adopted.precision_default":"Padrão","adopted.read_only":"somente leitura","adopted.settable":"editável","adopted.save":"Salvar","adopted.clear":"Limpar curadoria","adopted.reload_note":"Salvar recarrega a integração SPAN Panel","adopted.saved":"Salvo — a integração está recarregando","adopted.confirm_heading":"Confirmar classe de estatísticas","adopted.confirm_setting":"Você está definindo {name} como {value}.","adopted.confirm_clearing_subject":"Você está limpando a classe de estatísticas de {name}.","adopted.confirm_total_increasing":"Total crescente trata toda queda do valor como uma reinicialização do medidor. Se esta leitura puder diminuir por qualquer outro motivo, as estatísticas de longo prazo ficarão permanentemente corrompidas — corrigir a classe depois não repara o histórico já gravado.","adopted.confirm_clearing":"As estatísticas de longo prazo deixarão de ser compiladas para esta entidade, e o Home Assistant abrirá um reparo sobre as estatísticas já coletadas na classe que você está removendo.","adopted.save_anyway":"Salvar mesmo assim","adopted.cancel":"Cancelar","adopted.warn_total_increasing":"Salvo como total crescente — cada queda da leitura agora conta como uma reinicialização do medidor.","adopted.warn_statistics_removed":"Esta entidade não tem mais classe de estatísticas; o Home Assistant abrirá um reparo sobre as estatísticas já coletadas.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","subdevice.solar":"Solar","subdevice.solar_inverter":"Inversor solar","subdevice.site_total":"Total do local","subdevice.in_site_total":"Incluído no total do local","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.solar_pv":"Solar (FV)","editor.no_entities":"Nenhuma entidade para mostrar","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){t=n&&e[n]?n:"en"}function i(n){return e[t]?.[n]??e.en?.[n]??n}function r(n,i){return(e[t]?.[n]??e.en?.[n]??n).replace(/\{(\w+)\}/g,(t,e)=>Object.prototype.hasOwnProperty.call(i,e)?i[e]:`{${e}}`)}const o="power",a="5m",s={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},l="span_panel",c="pv",u="bess",d="evse",h="pv",p="sub_",f=500,g={power:{entityRole:"power",label:()=>i("metric.power"),unit:t=>Math.abs(t)>=1e3?"kW":"W",format:t=>{const e=Math.abs(t);return e>=1e3?(e/1e3).toFixed(1):e<10&&e>0?e.toFixed(1):String(Math.round(e))}},current:{entityRole:"current",label:()=>i("metric.current"),unit:()=>"A",format:t=>Math.abs(t).toFixed(1)}},v={soc:{entityRole:"soc",label:()=>i("metric.soc"),unit:()=>"%",format:t=>String(Math.round(t)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>i("metric.soe"),unit:()=>"kWh",format:t=>t.toFixed(1)},power:g.power},m={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>i("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>i("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>i("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>i("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>i("shedding.unknown")}},y=Object.keys(m).filter(t=>"unknown"!==t&&"always_on"!==t);var _=function(t,e){return _=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},_(t,e)};function b(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}_(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}function x(t,e,n,i){var r,o=arguments.length,a=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,n,i);else for(var s=t.length-1;s>=0;s--)(r=t[s])&&(a=(o<3?r(a):o>3?r(e,n,a):r(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}"function"==typeof SuppressedError&&SuppressedError; /** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const b=globalThis,w=b.ShadowRoot&&(void 0===b.ShadyCSS||b.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,S=Symbol(),C=new WeakMap;let M=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==S)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(w&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=C.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&C.set(e,t))}return t}toString(){return this.cssText}};const k=t=>new M("string"==typeof t?t:t+"",void 0,S),T=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new M(n,t,S)},D=w?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return k(e)})(t):t,{is:I,defineProperty:A,getOwnPropertyDescriptor:P,getOwnPropertyNames:L,getOwnPropertySymbols:E,getPrototypeOf:z}=Object,O=globalThis,N=O.trustedTypes,R=N?N.emptyScript:"",H=O.reactiveElementPolyfillSupport,B=(t,e)=>t,F={toAttribute(t,e){switch(e){case Boolean:t=t?R:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},$=(t,e)=>!I(t,e),V={attribute:!0,type:String,converter:F,reflect:!1,useDefault:!1,hasChanged:$}; +const w=globalThis,S=w.ShadowRoot&&(void 0===w.ShadyCSS||w.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,C=Symbol(),M=new WeakMap;let k=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==C)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(S&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=M.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&M.set(e,t))}return t}toString(){return this.cssText}};const T=t=>new k("string"==typeof t?t:t+"",void 0,C),A=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new k(n,t,C)},I=S?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return T(e)})(t):t,{is:D,defineProperty:P,getOwnPropertyDescriptor:L,getOwnPropertyNames:E,getOwnPropertySymbols:O,getPrototypeOf:z}=Object,N=globalThis,R=N.trustedTypes,H=R?R.emptyScript:"",B=N.reactiveElementPolyfillSupport,F=(t,e)=>t,$={toAttribute(t,e){switch(e){case Boolean:t=t?H:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},V=(t,e)=>!D(t,e),W={attribute:!0,type:String,converter:$,reflect:!1,useDefault:!1,hasChanged:V}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */Symbol.metadata??=Symbol("metadata"),O.litPropertyMetadata??=new WeakMap;let W=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=V){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&A(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=P(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??V}static _$Ei(){if(this.hasOwnProperty(B("elementProperties")))return;const t=z(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(B("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(B("properties"))){const t=this.properties,e=[...L(t),...E(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(D(t))}else void 0!==t&&e.push(D(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.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(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,e)=>{if(w)t.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of e){const e=document.createElement("style"),i=b.litNonce;void 0!==i&&e.setAttribute("nonce",i),e.textContent=n.cssText,t.appendChild(e)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:F).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:F;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??$)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};W.elementStyles=[],W.shadowRootOptions={mode:"open"},W[B("elementProperties")]=new Map,W[B("finalized")]=new Map,H?.({ReactiveElement:W}),(O.reactiveElementVersions??=[]).push("2.1.2"); + */Symbol.metadata??=Symbol("metadata"),N.litPropertyMetadata??=new WeakMap;let U=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=W){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&P(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=L(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??W}static _$Ei(){if(this.hasOwnProperty(F("elementProperties")))return;const t=z(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(F("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(F("properties"))){const t=this.properties,e=[...E(t),...O(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(I(t))}else void 0!==t&&e.push(I(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.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(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,e)=>{if(S)t.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of e){const e=document.createElement("style"),i=w.litNonce;void 0!==i&&e.setAttribute("nonce",i),e.textContent=n.cssText,t.appendChild(e)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:$).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:$;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??V)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};U.elementStyles=[],U.shadowRootOptions={mode:"open"},U[F("elementProperties")]=new Map,U[F("finalized")]=new Map,B?.({ReactiveElement:U}),(N.reactiveElementVersions??=[]).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const U=globalThis,G=t=>t,q=U.trustedTypes,j=q?q.createPolicy("lit-html",{createHTML:t=>t}):void 0,X="$lit$",Y=`lit$${Math.random().toFixed(9).slice(2)}$`,Z="?"+Y,K=`<${Z}>`,Q=document,J=()=>Q.createComment(""),tt=t=>null===t||"object"!=typeof t&&"function"!=typeof t,et=Array.isArray,nt="[ \t\n\f\r]",it=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,rt=/-->/g,ot=/>/g,at=RegExp(`>|${nt}(?:([^\\s"'>=/]+)(${nt}*=${nt}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),st=/'/g,lt=/"/g,ct=/^(?:script|style|textarea|title)$/i,ut=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),ht=ut(1),dt=ut(2),pt=Symbol.for("lit-noChange"),ft=Symbol.for("lit-nothing"),gt=new WeakMap,vt=Q.createTreeWalker(Q,129);function mt(t,e){if(!et(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==j?j.createHTML(e):e}const yt=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=it;for(let e=0;e"===l[0]?(a=r??it,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,s=l[1],a=void 0===l[3]?at:'"'===l[3]?lt:st):a===lt||a===st?a=at:a===rt||a===ot?a=it:(a=at,r=void 0);const h=a===at&&t[e+1].startsWith("/>")?" ":"";o+=a===it?n+K:c>=0?(i.push(s),n.slice(0,c)+X+n.slice(c)+Y+h):n+Y+(-2===c?e:h)}return[mt(t,o+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class _t{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[l,c]=yt(t,e);if(this.el=_t.createElement(l,n),vt.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=vt.nextNode())&&s.length0){i.textContent=q?q.emptyScript:"";for(let n=0;net(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==ft&&tt(this._$AH)?this._$AA.nextSibling.data=t:this.T(Q.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,i="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=_t.createElement(mt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(e);else{const t=new bt(i,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=gt.get(t.strings);return void 0===e&>.set(t.strings,e=new _t(t)),e}k(t){et(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,i=0;for(const r of t)i===e.length?e.push(n=new wt(this.O(J()),this.O(J()),this,this.options)):n=e[i],n._$AI(r),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=ft}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=xt(this,t,e,0),o=!tt(t)||t!==this._$AH&&t!==pt,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;at,j=G.trustedTypes,X=j?j.createPolicy("lit-html",{createHTML:t=>t}):void 0,Y="$lit$",Z=`lit$${Math.random().toFixed(9).slice(2)}$`,K="?"+Z,Q=`<${K}>`,J=document,tt=()=>J.createComment(""),et=t=>null===t||"object"!=typeof t&&"function"!=typeof t,nt=Array.isArray,it="[ \t\n\f\r]",rt=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,ot=/-->/g,at=/>/g,st=RegExp(`>|${it}(?:([^\\s"'>=/]+)(${it}*=${it}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),lt=/'/g,ct=/"/g,ut=/^(?:script|style|textarea|title)$/i,dt=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),ht=dt(1),pt=dt(2),ft=Symbol.for("lit-noChange"),gt=Symbol.for("lit-nothing"),vt=new WeakMap,mt=J.createTreeWalker(J,129);function yt(t,e){if(!nt(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==X?X.createHTML(e):e}const _t=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=rt;for(let e=0;e"===l[0]?(a=r??rt,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,s=l[1],a=void 0===l[3]?st:'"'===l[3]?ct:lt):a===ct||a===lt?a=st:a===ot||a===at?a=rt:(a=st,r=void 0);const d=a===st&&t[e+1].startsWith("/>")?" ":"";o+=a===rt?n+Q:c>=0?(i.push(s),n.slice(0,c)+Y+n.slice(c)+Z+d):n+Z+(-2===c?e:d)}return[yt(t,o+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class bt{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[l,c]=_t(t,e);if(this.el=bt.createElement(l,n),mt.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=mt.nextNode())&&s.length0){i.textContent=j?j.emptyScript:"";for(let n=0;nnt(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==gt&&et(this._$AH)?this._$AA.nextSibling.data=t:this.T(J.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,i="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=bt.createElement(yt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(e);else{const t=new wt(i,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=vt.get(t.strings);return void 0===e&&vt.set(t.strings,e=new bt(t)),e}k(t){nt(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,i=0;for(const r of t)i===e.length?e.push(n=new St(this.O(tt()),this.O(tt()),this,this.options)):n=e[i],n._$AI(r),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=gt}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=xt(this,t,e,0),o=!et(t)||t!==this._$AH&&t!==ft,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;a{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new wt(e.insertBefore(J(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return pt}}At._$litElement$=!0,At.finalized=!0,It.litElementHydrateSupport?.({LitElement:At});const Pt=It.litElementPolyfillSupport;Pt?.({LitElement:At}),(It.litElementVersions??=[]).push("4.2.2"); + */class Pt extends U{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=((t,e,n)=>{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new St(e.insertBefore(tt(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return ft}}Pt._$litElement$=!0,Pt.finalized=!0,Dt.litElementHydrateSupport?.({LitElement:Pt});const Lt=Dt.litElementPolyfillSupport;Lt?.({LitElement:Pt}),(Dt.litElementVersions??=[]).push("4.2.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const Lt={attribute:!0,type:String,converter:F,reflect:!1,hasChanged:$},Et=(t=Lt,e,n)=>{const{kind:i,metadata:r}=n;let o=globalThis.litPropertyMetadata.get(r);if(void 0===o&&globalThis.litPropertyMetadata.set(r,o=new Map),"setter"===i&&((t=Object.create(t)).wrapped=!0),o.set(n.name,t),"accessor"===i){const{name:i}=n;return{set(n){const r=e.get.call(this);e.set.call(this,n),this.requestUpdate(i,r,t,!0,n)},init(e){return void 0!==e&&this.C(i,void 0,t,e),e}}}if("setter"===i){const{name:i}=n;return function(n){const r=this[i];e.call(this,n),this.requestUpdate(i,r,t,!0,n)}}throw Error("Unsupported decorator location: "+i)}; +const Et={attribute:!0,type:String,converter:$,reflect:!1,hasChanged:V},Ot=(t=Et,e,n)=>{const{kind:i,metadata:r}=n;let o=globalThis.litPropertyMetadata.get(r);if(void 0===o&&globalThis.litPropertyMetadata.set(r,o=new Map),"setter"===i&&((t=Object.create(t)).wrapped=!0),o.set(n.name,t),"accessor"===i){const{name:i}=n;return{set(n){const r=e.get.call(this);e.set.call(this,n),this.requestUpdate(i,r,t,!0,n)},init(e){return void 0!==e&&this.C(i,void 0,t,e),e}}}if("setter"===i){const{name:i}=n;return function(n){const r=this[i];e.call(this,n),this.requestUpdate(i,r,t,!0,n)}}throw Error("Unsupported decorator location: "+i)}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function zt(t){return(e,n)=>"object"==typeof n?Et(t,e,n):((t,e,n)=>{const i=e.hasOwnProperty(n);return e.constructor.createProperty(n,t),i?Object.getOwnPropertyDescriptor(e,n):void 0})(t,e,n)} + */function zt(t){return(e,n)=>"object"==typeof n?Ot(t,e,n):((t,e,n)=>{const i=e.hasOwnProperty(n);return e.constructor.createProperty(n,t),i?Object.getOwnPropertyDescriptor(e,n):void 0})(t,e,n)} /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function Ot(t){return zt({...t,state:!0,attribute:!1})}const Nt={"&":"&","<":"<",">":">",'"':""","'":"'"};function Rt(t){return String(t).replace(/[&<>"']/g,t=>Nt[t]??t)}const Ht="span_panel_list_columns";function Bt(){try{const t=localStorage.getItem(Ht);if(!t)return 1;const e=parseInt(t,10);return 1===e||2===e||3===e?e:1}catch{return 1}}function Ft(t){try{localStorage.setItem(Ht,String(t))}catch{}}function $t(t,e,n={}){const r=Rt(t.device_name||i("header.default_name")),o=Rt(t.serial||""),a=Rt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==n.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Rt(i("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${function(t,e){const n="current"===(e.chart_metric||"power"),r=!!t.panel_entities?.site_power,o=!!t.panel_entities?.dsm_state,a=!!t.panel_entities?.current_power,s=!!t.panel_entities?.feedthrough_power,l=!!t.panel_entities?.pv_power,c=!!t.panel_entities?.battery_level;return`\n
\n ${r?`\n
\n ${i("header.site")}\n
\n 0\n ${n?"A":"kW"}\n
\n
`:""}\n ${o?`\n
\n ${i("header.grid")}\n
\n --\n
\n
`:""}\n ${a?`\n
\n ${i("header.upstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${s?`\n
\n ${i("header.downstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${i("header.solar")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${i("header.battery")}\n
\n —\n %\n
\n
`:""}\n
\n `}(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n
\n ${Object.entries(m).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Rt(t.icon),n=Rt(t.color),i=Rt(t.label());let r;return r=t.icon2?``:t.textLabel?`${Rt(t.textLabel)}`:``,`
${r}${i}
`}).join("")}\n
\n
\n
\n `}const Vt=g.power;function Wt(t){return Vt.unit(t)}function Ut(t){return(t<0?"-":"")+Vt.format(t)}function Gt(t){return(Math.abs(t)/1e3).toFixed(1)}function qt(t){return Math.ceil(t/2)}function jt(t){return t%2==0?1:0}function Xt(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return qt(e)===qt(n)?"row-span":jt(e)===jt(n)?"col-span":"row-span"}function Yt(t){const e=t.chart_metric??o;return g[e]??g[o]}function Zt(t,e){const n=function(t){return Yt(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}function Kt(t){return new Promise(e=>setTimeout(e,t))}class Qt{constructor(t){this._store=t}async callWS(t,e,n){const i=n?.retries??3,r=n?.errorId??`ws:${String(e.type??"unknown")}`;return this._withRetry(()=>t.callWS(e),i,r,n?.errorMessage)}async callService(t,e,n,i,r,o){const a=o?.retries??3,s=o?.errorId??`svc:${e}.${n}`;return this._withRetry(()=>t.callService(e,n,i,r),a,s,o?.errorMessage)}async _withRetry(t,e,n,r){if(this._store.hasAnyPanelOffline())try{const e=await t();return this._store.remove(n),e}catch(t){const e=t instanceof Error?t:new Error(String(t));throw this._store.add({key:n,level:"error",message:r??i("error.panel_offline"),persistent:!1}),e}let o;for(let i=0;i<=e;i++)try{const e=await t();return this._store.remove(n),e}catch(t){if(o=t instanceof Error?t:new Error(String(t)),i{try{const n={};e&&(n.config_entry_id=e);const o={type:"call_service",domain:l,service:"get_monitoring_status",service_data:n,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await t.callWS(o),s=a?.response??null;return r===this._generation&&(this._status=s,this._lastFetch=Date.now()),s}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:i("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class te{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new Jt,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function ee(t,e){return t?.circuits?t.circuits[e]??null:null}function ne(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}function ie(t,e,n,r,o,a,s,l,h,d=!1){const p=e.entities?.power,f=p?a.states[p]:null,g=f&&parseFloat(f.state)||0,v=e.device_type===u||g<0,y=e.entities?.switch,_=y?a.states[y]:null,x=_?"on"===_.state:(f?.attributes?.relay_state||e.relay_state)===c,b=e.breaker_rating_a,w=b?`${Math.round(b)}A`:"",S=Rt(e.name||i("grid.unknown")),C=Yt(s);let M;if("current"===C.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;M=`${C.format(i)}A`}else M=`${Ut(g)}${Wt(g)}`;const k=h||"unknown";let T="";if("unknown"!==k){const t=m[k]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},e=Rt(t.label()),n=Rt(t.icon),i=Rt(t.color);if(t.icon2){T=`\n \n \n `}else if(t.textLabel){T=`\n \n ${Rt(t.textLabel)}\n `}else T=``}const D=``;let I="",A=l?.utilization_pct??null;if(null==A&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;A=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=A){I=`=80?"utilization-warning":"utilization-normal"}">${Math.round(A)}%`}return`\n
\n
\n
\n ${w?`${w}`:""}\n ${I}\n ${S}\n
\n
\n \n ${M}\n \n ${!1!==e.is_user_controllable&&e.entities?.switch?`\n
\n ${i(x?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${T}\n ${D}\n
\n
\n
\n `}function re(t,e){return`\n
\n —\n
\n `}const oe={names:["power","battery power"],suffixes:["_power"]},ae={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},se={names:["state of energy"],suffixes:["_soe_kwh"]},le={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function ce(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function ue(t){return ce(t,oe)}function he(t){return ce(t,ae)}function de(t){return ce(t,se)}function pe(t){return ce(t,le)}function fe(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let u;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)u=e.formatEntityState(s);else{u=s.state;const t=s.attributes.unit_of_measurement||"";t&&(u+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(u=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Rt(l)}:\n ${Rt(u)}\n
\n `}return o}function ge(t,e,n,r,o,a){if(n){const e=[{key:`${p}${t}_soc`,title:i("subdevice.soc"),available:!!o},{key:`${p}${t}_soe`,title:i("subdevice.soe"),available:!!a},{key:`${p}${t}_power`,title:i("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Rt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function ve(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function me(t){const e=s[t];return e?e.ms:s[a].ms}function ye(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function _e(t){return Math.max(500,Math.floor(t/5e3))}function xe(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function be(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}async function we(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];e.sort((t,e)=>t.time-e.time),r.set(i,e)}}}async function Se(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=ye(i),l=_e(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];r.set(i,be(e,s,l))}}}function Ce(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices)){const t={power:ue(i)};i.type===h&&(t.soc=he(i),t.soe=de(i));for(const[i,r]of Object.entries(t))r&&e.push({entityId:r,key:`${p}${n}_${i}`,devId:n})}return e}async function Me(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=Zt(i,n);if(!e)continue;let o;o=r&&r.has(t)?me(r.get(t)):ve(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const s=a.get(o);s.entityIds.push(e),s.uuidByEntity.set(e,t)}for(const{entityId:t,key:i,devId:r}of Ce(e)){let e;e=o&&o.has(r)?me(o.get(r)):ve(n),a.has(e)||a.set(e,{entityIds:[],uuidByEntity:new Map});const s=a.get(e);s.entityIds.push(t),s.uuidByEntity.set(t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(we(t,n.entityIds,n.uuidByEntity,e,i)):s.push(Se(t,n.entityIds,n.uuidByEntity,e,i))}await Promise.all(s)}var ke=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Te=new function(){this.browser=new ke,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Te.wxa=!0,Te.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Te.worker=!0:!Te.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Te.node=!0,Te.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Te);var De="sans-serif",Ie="12px "+De;var Ae,Pe,Le=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){en(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var u=t[c].getBoundingClientRect(),h=2*c,d=u.left,p=u.top;a.push(d,p),l=l&&o&&d===o[h]&&p===o[h+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?ei(s,a):ei(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function oi(t){return"CANVAS"===t.nodeName.toUpperCase()}var ai=/([&<>"'])/g,si={"&":"&","<":"<",">":">",'"':""","'":"'"};function li(t){return null==t?"":(t+"").replace(ai,function(t,e){return si[e]})}var ci=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ui=[],hi=Te.browser.firefox&&+Te.browser.version.split(".")[0]<39;function di(t,e,n,i){return n=n||{},i?pi(t,e,n):hi&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):pi(t,e,n),n}function pi(t,e,n){if(Te.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(oi(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(ri(ui,t,i,r))return n.zrX=ui[0],void(n.zrY=ui[1])}n.zrX=n.zrY=0}function fi(t){return t||window.event}function gi(t,e,n){if(null!=(e=fi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&di(t,r,e,n)}else{di(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&ci.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function vi(t,e,n,i){t.removeEventListener(e,n,i)}var mi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},yi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=_i(r)/_i(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function bi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function wi(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Si(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function Ci(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function Mi(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),h=Math.cos(n);return t[0]=r*h+s*u,t[1]=-r*u+s*h,t[2]=o*h+l*u,t[3]=-o*u+h*l,t[4]=h*(a-i[0])+u*(c-i[1])+i[0],t[5]=h*(c-i[1])-u*(a-i[0])+i[1],t}function ki(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Ti=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Di=Math.min,Ii=Math.max,Ai=Math.abs,Pi=["x","y"],Li=["width","height"],Ei=new Ti,zi=new Ti,Oi=new Ti,Ni=new Ti,Ri=Gi(),Hi=Ri.minTv,Bi=Ri.maxTv,Fi=[0,0],$i=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Di(t.x,this.x),n=Di(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Ii(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Ii(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return Ci(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),Ci(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Ti.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(Vi,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(Wi,n.x,n.y,n.width,n.height));var s=!!i;Ri.reset(r,s);var l=Ri.touchThreshold,c=e.x+l,u=e.x+e.width-l,h=e.y+l,d=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||h>d||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}Ei.x=Oi.x=n.x,Ei.y=Ni.y=n.y,zi.x=Ni.x=n.x+n.width,zi.y=Oi.y=n.y+n.height,Ei.transform(i),Ni.transform(i),zi.transform(i),Oi.transform(i),e.x=Di(Ei.x,zi.x,Oi.x,Ni.x),e.y=Di(Ei.y,zi.y,Oi.y,Ni.y);var l=Ii(Ei.x,zi.x,Oi.x,Ni.x),c=Ii(Ei.y,zi.y,Oi.y,Ni.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),Vi=new $i(0,0,0,0),Wi=new $i(0,0,0,0);function Ui(t,e,n,i,r,o,a,s){var l=Ai(e-n),c=Ai(i-t),u=Di(l,c),h=Pi[r],d=Pi[1-r],p=Li[r];e=c||!Ri.bidirectional)&&(Hi[h]=-c,Hi[d]=0,Ri.useDir&&Ri.calcDirMTV())))}function Gi(){var t=0,e=new Ti,n=new Ti,i={minTv:new Ti,maxTv:new Ti,useDir:!1,dirMinTv:new Ti,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Ii(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(Ki.copy(u.getBoundingRect()),u.transform&&Ki.applyTransform(u.transform),Ki.intersect(l)&&o.push(u))}if(o.length)for(var h=Math.PI/12,d=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=Ji(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==qi)){e.target=a;break}}}function er(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}en(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){Qi.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=er(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||Gn(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function nr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function ir(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function rr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function or(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function ar(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],h=i[s+1];i[s]=c+h,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var d=or(t[u],t,l,c,0,e);l+=d,0!==(c-=d)&&0!==(h=rr(t[l+c-1],t,u,h,h-1,e))&&(c<=h?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[d+l];return void(t[h]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[h--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[h--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[d+l];if(0===i){m=!0;break}}if(t[h--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-rr(t[c],a,0,s,s-1,e))){for(s-=v,p=(h-=v)+1,d=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(h-=i)+1,d=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[d+l];t[h]=a[u]}else{if(0===s)throw new Error;for(d=h-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=nr(t,n,i,e))s&&(l=s),ir(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var lr=!1;function cr(){lr||(lr=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function ur(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var hr,dr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=ur}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();hr=Te.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var pr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-pr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*pr.bounceIn(2*t):.5*pr.bounceOut(2*t-1)+.5}},fr=Math.pow,gr=Math.sqrt,vr=1e-8,mr=1e-4,yr=gr(3),_r=1/3,xr=Hn(),br=Hn(),wr=Hn();function Sr(t){return t>-1e-8&&tvr||t<-1e-8}function Mr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function kr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Tr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,h=s*l-9*a*c,d=l*l-3*s*c,p=0;if(Sr(u)&&Sr(h)){if(Sr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=h*h-4*u*d;if(Sr(f)){var g=h/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=gr(f),y=u*s+1.5*a*(-h+m),_=u*s+1.5*a*(-h-m);(C=(-s-((y=y<0?-fr(-y,_r):fr(y,_r))+(_=_<0?-fr(-_,_r):fr(_,_r))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var x=(2*u*s-3*a*h)/(2*gr(u*u*u)),b=Math.acos(x)/3,w=gr(u),S=Math.cos(b),C=(-s-2*w*S)/(3*a),M=(v=(-s+w*(S+yr*Math.sin(b)))/(3*a),(-s+w*(S-yr*Math.sin(b)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),M>=0&&M<=1&&(o[p++]=M)}}return p}function Dr(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Sr(a)){if(Cr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Sr(c))r[0]=-o/(2*a);else if(c>0){var u,h=gr(c),d=(-o-h)/(2*a);(u=(-o+h)/(2*a))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}function Ir(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,h=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=h,o[4]=h,o[5]=u,o[6]=l,o[7]=i}function Ar(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,h=0,d=1/l,p=1;p<=l;p++){var f=p*d,g=Mr(t,n,r,a,f),v=Mr(e,i,o,s,f),m=g-c,y=v-u;h+=Math.sqrt(m*m+y*y),c=g,u=v}return h}function Pr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function Lr(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function Er(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function zr(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function Or(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,h=1;h<=a;h++){var d=h*u,p=Pr(t,n,r,d),f=Pr(e,i,o,d),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var Nr=/cubic-bezier\(([0-9,\.e ]+)\)/;function Rr(t){var e=t&&Nr.exec(t);if(e){var n=e[1].split(","),i=+kn(n[0]),r=+kn(n[1]),o=+kn(n[2]),a=+kn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Tr(0,i,o,1,t,s)&&Mr(0,r,a,1,s[0])}}}var Hr=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||Nn,this.ondestroy=t.ondestroy||Nn,this.onrestart=t.onrestart||Nn,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=un(t)?t:pr[t]||Rr(t)},t}(),Br=function(t){this.value=t},Fr=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new Br(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),$r=function(){function t(t){this._list=new Fr,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new Br(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),Vr={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function Wr(t){return(t=Math.round(t))<0?0:t>255?255:t}function Ur(t){return t<0?0:t>1?1:t}function Gr(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Wr(parseFloat(e)/100*255):Wr(parseInt(e,10))}function qr(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Ur(parseFloat(e)/100):Ur(parseFloat(e))}function jr(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function Xr(t,e,n){return t+(e-t)*n}function Yr(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function Zr(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var Kr=new $r(20),Qr=null;function Jr(t,e){Qr&&Zr(Qr,e),Qr=Kr.put(t,Qr||e.slice())}function to(t,e){if(t){e=e||[];var n=Kr.get(t);if(n)return Zr(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in Vr)return Zr(e,Vr[i]),Jr(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(Yr(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),Jr(t,e),e):void Yr(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(Yr(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),Jr(t,e),e):void Yr(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?Yr(e,+c[0],+c[1],+c[2],1):Yr(e,0,0,0,1);u=qr(c.pop());case"rgb":return c.length>=3?(Yr(e,Gr(c[0]),Gr(c[1]),Gr(c[2]),3===c.length?u:qr(c[3])),Jr(t,e),e):void Yr(e,0,0,0,1);case"hsla":return 4!==c.length?void Yr(e,0,0,0,1):(c[3]=qr(c[3]),eo(c,e),Jr(t,e),e);case"hsl":return 3!==c.length?void Yr(e,0,0,0,1):(eo(c,e),Jr(t,e),e);default:return}}Yr(e,0,0,0,1)}}function eo(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=qr(t[1]),r=qr(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return Yr(e=e||[],Wr(255*jr(a,o,n+1/3)),Wr(255*jr(a,o,n)),Wr(255*jr(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function no(t,e){var n=to(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return ro(n,4===n.length?"rgba":"rgb")}}function io(t,e,n,i){var r=to(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,h=((s-r)/6+l/2)/l,d=((s-o)/6+l/2)/l;i===s?e=d-h:r===s?e=1/3+u-d:o===s&&(e=2/3+h-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=qr(un(n)?n(r[1]):n)),null!=i&&(r[2]=qr(un(i)?i(r[2]):i)),ro(eo(r),"rgba")}function ro(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function oo(t,e){var n=to(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var ao=new $r(100);function so(t){if(hn(t)){var e=ao.get(t);return e||(e=no(t,-.1),ao.put(t,e)),e}if(yn(t)){var n=Ze({},t);return n.colorStops=nn(t.colorStops,function(t){return{offset:t.offset,color:no(t.color,-.1)}}),n}return t}var lo=Math.round;function co(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=to(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var uo=1e-4;function ho(t){return t-1e-4}function po(t){return lo(1e3*t)/1e3}function fo(t){return lo(1e4*t)/1e4}var go={left:"start",right:"end",center:"middle",middle:"middle"};function vo(t){return t&&!!t.image}function mo(t){return vo(t)||function(t){return t&&!!t.svgElement}(t)}function yo(t){return"linear"===t.type}function _o(t){return"radial"===t.type}function xo(t){return t&&("linear"===t.type||"radial"===t.type)}function bo(t){return"url(#"+t+")"}function wo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function So(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*Rn,r=bn(t.scaleX,1),o=bn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+lo(a*Rn)+"deg, "+lo(s*Rn)+"deg)"),l.join(" ")}var Co=Te.hasGlobalWindow&&un(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},Mo=Array.prototype.slice;function ko(t,e,n){return(e-t)*n+t}function To(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(tn(e)){var l=function(t){return tn(t&&t[0])?2:1}(e);a=l,(1===l&&!pn(e[0])||2===l&&!pn(e[0][0]))&&(o=!0)}else if(pn(e)&&!_n(e))a=0;else if(hn(e))if(isNaN(+e)){var c=to(e);c&&(s=c,a=3)}else a=0;else if(yn(e)){var u=Ze({},s);u.colorStops=nn(e.colorStops,function(t){return{offset:t.offset,color:to(t.color)}}),yo(e)?a=4:_o(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var h={time:t,value:s,rawValue:e,percent:0};return n&&(h.easing=n,h.easingFunc=un(n)?n:pr[n]||Rr(n)),i.push(h),h},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=Oo(i),l=zo(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=d;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:h?No:t[u];if(!Oo(s)&&!h||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(Oo(s))1===s?To(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,Lo(l),i),this._trackKeys.push(a)}s.addKeyframe(t,Lo(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function Bo(){return(new Date).getTime()}var Fo,$o,Vo=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return _(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=Bo()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,hr(function e(){t._running&&(hr(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=Bo(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=Bo(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=Bo()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new Ho(t,e.loop);return this.addAnimator(n),n},e}(Qn),Wo=Te.domSupported,Uo=($o={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:Fo=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:nn(Fo,function(t){var e=t.replace("mouse","pointer");return $o.hasOwnProperty(e)?e:t})}),Go=["mousemove","mouseup"],qo=["pointermove","pointerup"],jo=!1;function Xo(t){var e=t.pointerType;return"pen"===e||"touch"===e}function Yo(t){t&&(t.zrByTouch=!0)}function Zo(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var Ko=function(t,e){this.stopPropagation=Nn,this.stopImmediatePropagation=Nn,this.preventDefault=Nn,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},Qo={mousedown:function(t){t=gi(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=gi(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=gi(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){Zo(this,(t=gi(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){jo=!0,t=gi(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){jo||(t=gi(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){Yo(t=gi(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),Qo.mousemove.call(this,t),Qo.mousedown.call(this,t)},touchmove:function(t){Yo(t=gi(this.dom,t)),this.handler.processGesture(t,"change"),Qo.mousemove.call(this,t)},touchend:function(t){Yo(t=gi(this.dom,t)),this.handler.processGesture(t,"end"),Qo.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&Qo.click.call(this,t)},pointerdown:function(t){Qo.mousedown.call(this,t)},pointermove:function(t){Xo(t)||Qo.mousemove.call(this,t)},pointerup:function(t){Qo.mouseup.call(this,t)},pointerout:function(t){Xo(t)||Qo.mouseout.call(this,t)}};en(["click","dblclick","contextmenu"],function(t){Qo[t]=function(e){e=gi(this.dom,e),this.trigger(t,e)}});var Jo={pointermove:function(t){Xo(t)||Jo.mousemove.call(this,t)},pointerup:function(t){Jo.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function ta(t,e){var n=e.domHandlers;Te.pointerEventsSupported?en(Uo.pointer,function(i){na(e,i,function(e){n[i].call(t,e)})}):(Te.touchEventsSupported&&en(Uo.touch,function(i){na(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),en(Uo.mouse,function(i){na(e,i,function(r){r=fi(r),e.touching||n[i].call(t,r)})}))}function ea(t,e){function n(n){na(e,n,function(i){i=fi(i),Zo(t,i.target)||(i=function(t,e){return gi(t.dom,new Ko(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Te.pointerEventsSupported?en(qo,n):Te.touchEventsSupported||en(Go,n)}function na(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ia(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&vi(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var ra=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},oa=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new ra(e,Qo),Wo&&(i._globalHandlerScope=new ra(document,Jo)),ta(i,i._localHandlerScope),i}return _(e,t),e.prototype.dispose=function(){ia(this._localHandlerScope),Wo&&ia(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,Wo&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?ea(this,e):ia(e)}},e}(Qn),aa=1;Te.hasGlobalWindow&&(aa=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var sa=aa,la="#333",ca="#ccc",ua=bi,ha=5e-5;function da(t){return t>ha||t<-5e-5}var pa,fa=[],ga=[],va=[1,0,0,1,0,0],ma=Math.abs,ya=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return da(this.rotation)||da(this.x)||da(this.y)||da(this.scaleX-1)||da(this.scaleY-1)||da(this.skewX)||da(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):ua(n),t&&(e?Si(n,t,n):wi(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(ua(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(fa);var n=fa[0]<0?-1:1,i=fa[1]<0?-1:1,r=((fa[0]-n)*e+n)/fa[0]||0,o=((fa[1]-i)*e+i)/fa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],ki(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Si(ga,t.invTransform,e),e=ga);var n=this.originX,i=this.originY;(n||i)&&(va[4]=n,va[5]=i,Si(ga,e,va),ga[4]-=n,ga[5]-=i,e=ga),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&jn(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&jn(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&ma(t[0]-1)>1e-10&&ma(t[3]-1)>1e-10?Math.sqrt(ma(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){xa(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,h=t.skewX?Math.tan(t.skewX):0,d=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-h*f*o,e[5]=-f*o-d*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=d*r,e[2]=h*o,l&&Mi(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),_a=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function xa(t,e){for(var n=0;n<_a.length;n++){var i=_a[n];t[i]=e[i]}}function ba(t){pa||(pa=new $r(100)),t=t||Ie;var e=pa.get(t);return e||(e={font:t,strWidthCache:new $r(500),asciiWidthMap:null,asciiWidthMapTried:!1,stWideCharWidth:Ee.measureText("国",t).width,asciiCharWidth:Ee.measureText("a",t).width},pa.put(t,e)),e}var wa=0,Sa=5;function Ca(t,e){return t.asciiWidthMapTried||(t.asciiWidthMap=function(t){if(!(wa>=Sa)){t=t||Ie;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=Ee.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?wa=Sa:r>2&&wa++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function Ma(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=Ee.measureText(e,t.font).width,n.put(e,i)),i}function ka(t,e,n,i){var r=Ma(ba(e),t),o=Aa(e),a=Da(0,r,n),s=Ia(0,o,i);return new $i(a,s,r,o)}function Ta(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return ka(r[0],e,n,i);for(var o=new $i(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function La(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",h="top";if(i instanceof Array)l+=Pa(i[0],n.width),c+=Pa(i[1],n.height),u=null,h=null;else switch(i){case"left":l-=r,c+=s,u="right",h="middle";break;case"right":l+=r+a,c+=s,h="middle";break;case"top":l+=a/2,c-=r,u="center",h="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",h="middle";break;case"insideLeft":l+=r,c+=s,h="middle";break;case"insideRight":l+=a-r,c+=s,u="right",h="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",h="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,h="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",h="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=h,t}var Ea="__zr_normal__",za=_a.concat(["ignore"]),Oa=rn(_a,function(t,e){return t[e]=!0,t},{ignore:!1}),Na={},Ra=new $i(0,0,0,0),Ha=[],Ba=function(){function t(t){this.id=qe(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,h=void 0;if((u||c)&&(h=Ra,n.layoutRect?h.copy(n.layoutRect):h.copy(this.getBoundingRect()),i||h.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(Na,n,h):La(Na,n,h),r.x=Na.x,r.y=Na.y,o=Na.align,a=Na.verticalAlign;var d=n.origin;if(d&&null!=n.rotation){var p=void 0,f=void 0;"center"===d?(p=.5*h.width,f=.5*h.height):(p=Pa(d[0],h.width),f=Pa(d[1],h.height)),l=!0,r.originX=-r.x+p+(i?0:h.x),r.originY=-r.y+f+(i?0:h.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new $i(0,0,0,0);r.getLocalTransform(Ha),ki(Ha,Ha),$i.copy(m,h),m.applyTransform(Ha)}else v.overflowRect=null;var y=void 0,_=void 0,x=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),x=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),x=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&x===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=x,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?ca:la},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&to(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,ro(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},Ze(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(fn(t))for(var n=an(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(Ea,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===Ea;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(Qe(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}je("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,h),g&&g.useStates(t,e,h),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!h&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=Qe(i,t),o=Qe(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var d=0;d0||r.force&&!a.length){var w,S=void 0,C=void 0,M=void 0;if(s){C={},d&&(S={});for(x=0;x<_;x++){C[m=g[x]]=n[m],d?S[m]=i[m]:n[m]=i[m]}}else if(d){M={};for(x=0;x<_;x++){M[m=g[x]]=Lo(n[m]),Va(n,i,m)}}(w=new Ho(n,!1,!1,h?on(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),d&&S&&w.whenWithKeys(0,S,g),M&&w.whenWithKeys(0,M,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}Je(Ba,Qn),Je(Ba,ya);var Ua=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=Qe(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=Qe(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n":">",'"':""","'":"'"};function Ht(t){return String(t).replace(/[&<>"']/g,t=>Rt[t]??t)}const Bt="span_panel_list_columns";function Ft(){try{const t=localStorage.getItem(Bt);if(!t)return 1;const e=parseInt(t,10);return 1===e||2===e||3===e?e:1}catch{return 1}}function $t(t){try{localStorage.setItem(Bt,String(t))}catch{}}function Vt(t,e,n={}){const r=Ht(t.device_name||i("header.default_name")),o=Ht(t.serial||""),a=Ht(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==n.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Ht(i("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${function(t,e){const n="current"===(e.chart_metric||"power"),r=!!t.panel_entities?.site_power,o=!!t.panel_entities?.dsm_state,a=!!t.panel_entities?.current_power,s=!!t.panel_entities?.feedthrough_power,l=!!t.panel_entities?.pv_power,c=!!t.panel_entities?.battery_level;return`\n
\n ${r?`\n
\n ${i("header.site")}\n
\n 0\n ${n?"A":"kW"}\n
\n
`:""}\n ${o?`\n
\n ${i("header.grid")}\n
\n --\n
\n
`:""}\n ${a?`\n
\n ${i("header.upstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${s?`\n
\n ${i("header.downstream")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${i("header.solar")}\n
\n --\n ${n?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${i("header.battery")}\n
\n —\n %\n
\n
`:""}\n
\n `}(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n
\n ${Object.entries(m).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Ht(t.icon),n=Ht(t.color),i=Ht(t.label());let r;return r=t.icon2?``:t.textLabel?`${Ht(t.textLabel)}`:``,`
${r}${i}
`}).join("")}\n
\n
\n
\n `}const Wt=g.power;function Ut(t){return Wt.unit(t)}function Gt(t){return(t<0?"-":"")+Wt.format(t)}function qt(t){return`${Gt(t)} ${Ut(t)}`}function jt(t){return(Math.abs(t)/1e3).toFixed(1)}function Xt(t){return Math.ceil(t/2)}function Yt(t){return t%2==0?1:0}function Zt(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return Xt(e)===Xt(n)?"row-span":Yt(e)===Yt(n)?"col-span":"row-span"}function Kt(t){const e=t.chart_metric??o;return g[e]??g[o]}function Qt(t,e){const n=function(t){return Kt(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}function Jt(t){return t instanceof Error||"object"==typeof t&&null!==t&&"message"in t&&"string"==typeof t.message?t.message:String(t)}function te(t){return new Promise(e=>setTimeout(e,t))}class ee{constructor(t){this._store=t}async callWS(t,e,n){const i=n?.retries??3,r=n?.errorId??`ws:${String(e.type??"unknown")}`;return this._withRetry(()=>t.callWS(e),i,r,n?.errorMessage)}async callService(t,e,n,i,r,o){const a=o?.retries??3,s=o?.errorId??`svc:${e}.${n}`;return this._withRetry(()=>t.callService(e,n,i,r),a,s,o?.errorMessage)}async _withRetry(t,e,n,r){if(this._store.hasAnyPanelOffline())try{const e=await t();return this._store.remove(n),e}catch(t){const e=t instanceof Error?t:new Error(Jt(t));throw this._store.add({key:n,level:"error",message:r??i("error.panel_offline"),persistent:!1}),e}let o;for(let i=0;i<=e;i++)try{const e=await t();return this._store.remove(n),e}catch(t){if(o=t instanceof Error?t:new Error(Jt(t)),i{try{const n={};e&&(n.config_entry_id=e);const o={type:"call_service",domain:l,service:"get_monitoring_status",service_data:n,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await t.callWS(o),s=a?.response??null;return r===this._generation&&(this._status=s,this._lastFetch=Date.now()),s}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:i("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class ie{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new ne,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function re(t,e){return t?.circuits?t.circuits[e]??null:null}function oe(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}const ae=new Set(y),se={NEVER:"never",SOC_THRESHOLD:"soc_threshold",OFF_GRID:"off_grid"};function le(t,e){const n=t.entities?.switch;return n?e.states[n]?.state:void 0}function ce(t,e,n){const i=t.entities?.power,r=i?e.states[i]?.attributes?.[n]:void 0;return"string"==typeof r&&r?r:void 0}function ue(t,e){const n=le(t,e);return"on"===n||"off"===n?"on"===n:"CLOSED"===(ce(t,e,"relay_state")??t.relay_state)}function de(t,e){if(!t.entities?.switch||!1===t.is_user_controllable)return"none";const n=le(t,e);return void 0===n?"none":"on"===n||"off"===n?"operable":"inert"}function he(t){return void 0===t?void 0:se[t]}function pe(t,e){const n=t.entities?.select;return n?e.states[n]?.state:void 0}function fe(t,e){if(t.always_on)return"always_on";if(!t.entities?.select)return"unknown";const n=pe(t,e);return void 0!==n&&ae.has(n)?n:he(ce(t,e,"shed_priority"))??he(t.priority)??"unknown"}function ge(t,e){if("none"===e)return"";const n=["toggle-pill",t?"toggle-on":"toggle-off"];return"inert"===e&&n.push("toggle-unavailable"),`
\n ${i(t?"grid.on":"grid.off")}\n \n
`}function ve(t,e,n){const r=t.querySelector(".toggle-pill");if(!r)return;r.classList.toggle("toggle-on",e),r.classList.toggle("toggle-off",!e),r.classList.toggle("toggle-unavailable","operable"!==n);const o=r.querySelector(".toggle-label");o&&(o.textContent=i(e?"grid.on":"grid.off"))}function me(t){return"unknown"===t?void 0:m[t]}const ye="display:none;";function _e(t){const e=me(t),n=e?Ht(e.color):"",i=e?` title="${Ht(e.label())}"`:"",r=e?Ht(e.icon):"",o=e?.icon2?Ht(e.icon2):"",a=e?.textLabel?Ht(e.textLabel):"";return`\n \n \n ${a}\n `}function be(t,e){const n=t.querySelector(".shedding-composite");if(!n)return;const i=me(e);n.style.display=i?"":"none",n.title=i?i.label():"";const r=i?.color??"",o=n.querySelector(".shedding-icon");o&&(o.setAttribute("icon",i?.icon??""),o.style.color=r);const a=n.querySelector(".shedding-icon-secondary");a&&(a.setAttribute("icon",i?.icon2??""),a.style.color=r,a.style.display=i?.icon2?"":"none");const s=n.querySelector(".shedding-label");s&&(s.textContent=i?.textLabel??"",s.style.color=r,s.style.display=i?.textLabel?"":"none")}function xe(t,e,n,r,o,a,s,l,u,d=!1){const h=e.entities?.power,p=h?a.states[h]:null,f=p&&parseFloat(p.state)||0,g=e.device_type===c||f<0,v=ue(e,a),m=de(e,a),y=e.breaker_rating_a,_=y?`${Math.round(y)}A`:"",b=Ht(e.name||i("grid.unknown")),x=Kt(s);let w;if("current"===x.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;w=`${x.format(i)}A`}else w=`${Gt(f)}${Ut(f)}`;const S=_e(u||"unknown"),C=``;let M="",k=l?.utilization_pct??null;if(null==k&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;k=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=k){M=`=80?"utilization-warning":"utilization-normal"}">${Math.round(k)}%`}return`\n
\n
\n
\n ${_?`${_}`:""}\n ${M}\n ${b}\n
\n
\n \n ${w}\n \n ${ge(v,m)}\n
\n
\n
\n ${S}\n ${C}\n
\n
\n
\n `}function we(t,e){return`\n
\n —\n
\n `}const Se={names:["power","battery power"],suffixes:["_power"]},Ce={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},Me={names:["state of energy"],suffixes:["_soe_kwh"]};function ke(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function Te(t){return ke(t,Ce)}function Ae(t){return ke(t,Me)}function Ie(t){const e=t.solar;return e?"inverter"===e.role?{headlineEid:e.power_entity_id,headlineIsSiteTotal:!1,siteTotalEid:null}:e.power_entity_id?{headlineEid:e.power_entity_id,headlineIsSiteTotal:!1,siteTotalEid:e.site_power_entity_id}:{headlineEid:e.site_power_entity_id,headlineIsSiteTotal:null!==e.site_power_entity_id,siteTotalEid:null}:{headlineEid:(n=t,ke(n,Se)),headlineIsSiteTotal:!1,siteTotalEid:null};var n}function De(t){const{headlineEid:e,siteTotalEid:n}=Ie(t),i=[e,n];return t.type===u&&i.push(Te(t),Ae(t)),new Set(i.filter(t=>null!==t))}function Pe(t){const e=[{role:"power",entityId:Ie(t).headlineEid}];return t.type===u&&e.push({role:"soc",entityId:Te(t)},{role:"soe",entityId:Ae(t)}),e.filter(t=>null!==t.entityId)}function Le(t,e){const n=t.states[e];return n&&parseFloat(n.state)||0}function Ee(t){if(!t)return null;let e=null;for(const[n,i]of Object.entries(t)){if("sensor"===i.domain)return n;e||(e=n)}return e}function Oe(t,e){return Pe(t).length>0||e&&null!==Ee(t.entities)}function ze(t,e=()=>""){const n=t=>"site"===t.solar?.role?0:1,i=new Map;for(const n of t){if(n[1].type!==h)continue;const t=e(n[0]),r=i.get(t);r?r.push(n):i.set(t,[n])}for(const t of i.values())t.sort(([,t],[,e])=>n(t)-n(e)||(t.name??"").localeCompare(e.name??""));const r=[],o=new Set;for(const n of t){if(n[1].type!==h){r.push(n);continue}const t=e(n[0]);o.has(t)||(o.add(t),r.push(...i.get(t)??[]))}return r}function Ne(t){return t.type===h?"inverter"===t.solar?.role?i("subdevice.solar_inverter"):i("subdevice.solar"):t.type===d?i("subdevice.ev_charger"):t.type===u?i("subdevice.battery"):i("subdevice.fallback")}function Re(t,e,n){let r="";const o=[t.vendor,t.model].filter(t=>!!t).join(" · ");return o&&(r+=`
${Ht(o)}
`),e.siteTotalEid&&(r+=`\n
\n ${Ht(i("subdevice.site_total"))}:\n ${qt(Le(n,e.siteTotalEid))}\n
`),"inverter"!==t.role||e.headlineEid||(r+=`
${Ht(i("subdevice.in_site_total"))}
`),r}function He(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let u;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)u=e.formatEntityState(s);else{u=s.state;const t=s.attributes.unit_of_measurement||"";t&&(u+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(u=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Ht(l)}:\n ${Ht(u)}\n
\n `}return o}function Be(t,e,n,r,o,a){if(n){const e=[{key:`${p}${t}_soc`,title:i("subdevice.soc"),available:!!o},{key:`${p}${t}_soe`,title:i("subdevice.soe"),available:!!a},{key:`${p}${t}_power`,title:i("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Ht(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function Fe(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function $e(t){const e=s[t];return e?e.ms:s[a].ms}function Ve(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function We(t){return Math.max(500,Math.floor(t/5e3))}function Ue(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function Ge(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}function qe(t,e,n,i){let r=t.get(e);r||(r={entityIds:[],keysByEntity:new Map},t.set(e,r));const o=r.keysByEntity.get(n);o?o.push(i):(r.entityIds.push(n),r.keysByEntity.set(n,[i]))}async function je(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(0!==o.length)for(const t of i){const e=r.get(t)||[],n=[...o,...e];n.sort((t,e)=>t.time-e.time),r.set(t,n)}}}async function Xe(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=Ve(i),l=We(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(0!==o.length)for(const t of i){const e=r.get(t)||[],n=[...o,...e];r.set(t,Ge(n,s,l))}}}function Ye(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices))for(const{role:t,entityId:r}of Pe(i))e.push({entityId:r,key:`${p}${n}_${t}`,devId:n});return e}async function Ze(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=Qt(i,n);if(!e)continue;let o;o=r&&r.has(t)?$e(r.get(t)):Fe(n),qe(a,o,e,t)}for(const{entityId:t,key:i,devId:r}of Ye(e)){let e;e=o&&o.has(r)?$e(o.get(r)):Fe(n),qe(a,e,t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(je(t,n.entityIds,n.keysByEntity,e,i)):s.push(Xe(t,n.entityIds,n.keysByEntity,e,i))}await Promise.all(s)}var Ke=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Qe=new function(){this.browser=new Ke,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Qe.wxa=!0,Qe.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Qe.worker=!0:!Qe.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Qe.node=!0,Qe.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Qe);var Je="sans-serif",tn="12px "+Je;var en,nn,rn=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){Tn(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var u=t[c].getBoundingClientRect(),d=2*c,h=u.left,p=u.top;a.push(h,p),l=l&&o&&h===o[d]&&p===o[d+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?ki(s,a):ki(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function Di(t){return"CANVAS"===t.nodeName.toUpperCase()}var Pi=/([&<>"'])/g,Li={"&":"&","<":"<",">":">",'"':""","'":"'"};function Ei(t){return null==t?"":(t+"").replace(Pi,function(t,e){return Li[e]})}var Oi=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,zi=[],Ni=Qe.browser.firefox&&+Qe.browser.version.split(".")[0]<39;function Ri(t,e,n,i){return n=n||{},i?Hi(t,e,n):Ni&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):Hi(t,e,n),n}function Hi(t,e,n){if(Qe.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(Di(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(Ii(zi,t,i,r))return n.zrX=zi[0],void(n.zrY=zi[1])}n.zrX=n.zrY=0}function Bi(t){return t||window.event}function Fi(t,e,n){if(null!=(e=Bi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&Ri(t,r,e,n)}else{Ri(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&Oi.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function $i(t,e,n,i){t.removeEventListener(e,n,i)}var Vi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},Wi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=Ui(r)/Ui(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function qi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function ji(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Xi(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function Yi(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function Zi(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),d=Math.cos(n);return t[0]=r*d+s*u,t[1]=-r*u+s*d,t[2]=o*d+l*u,t[3]=-o*u+d*l,t[4]=d*(a-i[0])+u*(c-i[1])+i[0],t[5]=d*(c-i[1])-u*(a-i[0])+i[1],t}function Ki(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Qi=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Ji=Math.min,tr=Math.max,er=Math.abs,nr=["x","y"],ir=["width","height"],rr=new Qi,or=new Qi,ar=new Qi,sr=new Qi,lr=vr(),cr=lr.minTv,ur=lr.maxTv,dr=[0,0],hr=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Ji(t.x,this.x),n=Ji(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=tr(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=tr(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return Yi(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),Yi(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Qi.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(pr,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(fr,n.x,n.y,n.width,n.height));var s=!!i;lr.reset(r,s);var l=lr.touchThreshold,c=e.x+l,u=e.x+e.width-l,d=e.y+l,h=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||d>h||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}rr.x=ar.x=n.x,rr.y=sr.y=n.y,or.x=sr.x=n.x+n.width,or.y=ar.y=n.y+n.height,rr.transform(i),sr.transform(i),or.transform(i),ar.transform(i),e.x=Ji(rr.x,or.x,ar.x,sr.x),e.y=Ji(rr.y,or.y,ar.y,sr.y);var l=tr(rr.x,or.x,ar.x,sr.x),c=tr(rr.y,or.y,ar.y,sr.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),pr=new hr(0,0,0,0),fr=new hr(0,0,0,0);function gr(t,e,n,i,r,o,a,s){var l=er(e-n),c=er(i-t),u=Ji(l,c),d=nr[r],h=nr[1-r],p=ir[r];e=c||!lr.bidirectional)&&(cr[d]=-c,cr[h]=0,lr.useDir&&lr.calcDirMTV())))}function vr(){var t=0,e=new Qi,n=new Qi,i={minTv:new Qi,maxTv:new Qi,useDir:!1,dirMinTv:new Qi,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=tr(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(wr.copy(u.getBoundingRect()),u.transform&&wr.applyTransform(u.transform),wr.intersect(l)&&o.push(u))}if(o.length)for(var d=Math.PI/12,h=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=Cr(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==mr)){e.target=a;break}}}function kr(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}Tn(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){Sr.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=kr(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||vi(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function Tr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function Ar(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function Ir(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function Dr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function Pr(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],d=i[s+1];i[s]=c+d,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var h=Dr(t[u],t,l,c,0,e);l+=h,0!==(c-=h)&&0!==(d=Ir(t[l+c-1],t,u,d,d-1,e))&&(c<=d?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[h+l];return void(t[d]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[d--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[d--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[h+l];if(0===i){m=!0;break}}if(t[d--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-Ir(t[c],a,0,s,s-1,e))){for(s-=v,p=(d-=v)+1,h=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(d-=i)+1,h=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[h+l];t[d]=a[u]}else{if(0===s)throw new Error;for(h=d-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=Tr(t,n,i,e))s&&(l=s),Ar(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var Er=!1;function Or(){Er||(Er=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function zr(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var Nr,Rr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=zr}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();Nr=Qe.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var Hr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-Hr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*Hr.bounceIn(2*t):.5*Hr.bounceOut(2*t-1)+.5}},Br=Math.pow,Fr=Math.sqrt,$r=1e-8,Vr=1e-4,Wr=Fr(3),Ur=1/3,Gr=ci(),qr=ci(),jr=ci();function Xr(t){return t>-1e-8&&t<$r}function Yr(t){return t>$r||t<-1e-8}function Zr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function Kr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Qr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,d=s*l-9*a*c,h=l*l-3*s*c,p=0;if(Xr(u)&&Xr(d)){if(Xr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=d*d-4*u*h;if(Xr(f)){var g=d/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=Fr(f),y=u*s+1.5*a*(-d+m),_=u*s+1.5*a*(-d-m);(C=(-s-((y=y<0?-Br(-y,Ur):Br(y,Ur))+(_=_<0?-Br(-_,Ur):Br(_,Ur))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var b=(2*u*s-3*a*d)/(2*Fr(u*u*u)),x=Math.acos(b)/3,w=Fr(u),S=Math.cos(x),C=(-s-2*w*S)/(3*a),M=(v=(-s+w*(S+Wr*Math.sin(x)))/(3*a),(-s+w*(S-Wr*Math.sin(x)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),M>=0&&M<=1&&(o[p++]=M)}}return p}function Jr(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Xr(a)){if(Yr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Xr(c))r[0]=-o/(2*a);else if(c>0){var u,d=Fr(c),h=(-o-d)/(2*a);(u=(-o+d)/(2*a))>=0&&u<=1&&(r[l++]=u),h>=0&&h<=1&&(r[l++]=h)}}return l}function to(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,d=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=d,o[4]=d,o[5]=u,o[6]=l,o[7]=i}function eo(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,d=0,h=1/l,p=1;p<=l;p++){var f=p*h,g=Zr(t,n,r,a,f),v=Zr(e,i,o,s,f),m=g-c,y=v-u;d+=Math.sqrt(m*m+y*y),c=g,u=v}return d}function no(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function io(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function ro(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function oo(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function ao(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,d=1;d<=a;d++){var h=d*u,p=no(t,n,r,h),f=no(e,i,o,h),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var so=/cubic-bezier\(([0-9,\.e ]+)\)/;function lo(t){var e=t&&so.exec(t);if(e){var n=e[1].split(","),i=+Kn(n[0]),r=+Kn(n[1]),o=+Kn(n[2]),a=+Kn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Qr(0,i,o,1,t,s)&&Zr(0,r,a,1,s[0])}}}var co=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||si,this.ondestroy=t.ondestroy||si,this.onrestart=t.onrestart||si,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=zn(t)?t:Hr[t]||lo(t)},t}(),uo=function(t){this.value=t},ho=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new uo(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),po=function(){function t(t){this._list=new ho,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new uo(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),fo={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function go(t){return(t=Math.round(t))<0?0:t>255?255:t}function vo(t){return t<0?0:t>1?1:t}function mo(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?go(parseFloat(e)/100*255):go(parseInt(e,10))}function yo(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?vo(parseFloat(e)/100):vo(parseFloat(e))}function _o(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function bo(t,e,n){return t+(e-t)*n}function xo(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function wo(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var So=new po(20),Co=null;function Mo(t,e){Co&&wo(Co,e),Co=So.put(t,Co||e.slice())}function ko(t,e){if(t){e=e||[];var n=So.get(t);if(n)return wo(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in fo)return wo(e,fo[i]),Mo(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(xo(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),Mo(t,e),e):void xo(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(xo(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),Mo(t,e),e):void xo(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?xo(e,+c[0],+c[1],+c[2],1):xo(e,0,0,0,1);u=yo(c.pop());case"rgb":return c.length>=3?(xo(e,mo(c[0]),mo(c[1]),mo(c[2]),3===c.length?u:yo(c[3])),Mo(t,e),e):void xo(e,0,0,0,1);case"hsla":return 4!==c.length?void xo(e,0,0,0,1):(c[3]=yo(c[3]),To(c,e),Mo(t,e),e);case"hsl":return 3!==c.length?void xo(e,0,0,0,1):(To(c,e),Mo(t,e),e);default:return}}xo(e,0,0,0,1)}}function To(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=yo(t[1]),r=yo(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return xo(e=e||[],go(255*_o(a,o,n+1/3)),go(255*_o(a,o,n)),go(255*_o(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function Ao(t,e){var n=ko(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return Do(n,4===n.length?"rgba":"rgb")}}function Io(t,e,n,i){var r=ko(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,d=((s-r)/6+l/2)/l,h=((s-o)/6+l/2)/l;i===s?e=h-d:r===s?e=1/3+u-h:o===s&&(e=2/3+d-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=yo(zn(n)?n(r[1]):n)),null!=i&&(r[2]=yo(zn(i)?i(r[2]):i)),Do(To(r),"rgba")}function Do(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function Po(t,e){var n=ko(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var Lo=new po(100);function Eo(t){if(Nn(t)){var e=Lo.get(t);return e||(e=Ao(t,-.1),Lo.put(t,e)),e}if(Wn(t)){var n=wn({},t);return n.colorStops=An(t.colorStops,function(t){return{offset:t.offset,color:Ao(t.color,-.1)}}),n}return t}var Oo=Math.round;function zo(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=ko(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var No=1e-4;function Ro(t){return t-1e-4}function Ho(t){return Oo(1e3*t)/1e3}function Bo(t){return Oo(1e4*t)/1e4}var Fo={left:"start",right:"end",center:"middle",middle:"middle"};function $o(t){return t&&!!t.image}function Vo(t){return $o(t)||function(t){return t&&!!t.svgElement}(t)}function Wo(t){return"linear"===t.type}function Uo(t){return"radial"===t.type}function Go(t){return t&&("linear"===t.type||"radial"===t.type)}function qo(t){return"url(#"+t+")"}function jo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function Xo(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*li,r=qn(t.scaleX,1),o=qn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+Oo(a*li)+"deg, "+Oo(s*li)+"deg)"),l.join(" ")}var Yo=Qe.hasGlobalWindow&&zn(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},Zo=Array.prototype.slice;function Ko(t,e,n){return(e-t)*n+t}function Qo(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(kn(e)){var l=function(t){return kn(t&&t[0])?2:1}(e);a=l,(1===l&&!Hn(e[0])||2===l&&!Hn(e[0][0]))&&(o=!0)}else if(Hn(e)&&!Un(e))a=0;else if(Nn(e))if(isNaN(+e)){var c=ko(e);c&&(s=c,a=3)}else a=0;else if(Wn(e)){var u=wn({},s);u.colorStops=An(e.colorStops,function(t){return{offset:t.offset,color:ko(t.color)}}),Wo(e)?a=4:Uo(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var d={time:t,value:s,rawValue:e,percent:0};return n&&(d.easing=n,d.easingFunc=zn(n)?n:Hr[n]||lo(n)),i.push(d),d},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=aa(i),l=oa(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=h;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:d?sa:t[u];if(!aa(s)&&!d||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(aa(s))1===s?Qo(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,ia(l),i),this._trackKeys.push(a)}s.addKeyframe(t,ia(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function ua(){return(new Date).getTime()}var da,ha,pa=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return b(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=ua()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,Nr(function e(){t._running&&(Nr(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=ua(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=ua(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=ua()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new ca(t,e.loop);return this.addAnimator(n),n},e}(Si),fa=Qe.domSupported,ga=(ha={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:da=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:An(da,function(t){var e=t.replace("mouse","pointer");return ha.hasOwnProperty(e)?e:t})}),va=["mousemove","mouseup"],ma=["pointermove","pointerup"],ya=!1;function _a(t){var e=t.pointerType;return"pen"===e||"touch"===e}function ba(t){t&&(t.zrByTouch=!0)}function xa(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var wa=function(t,e){this.stopPropagation=si,this.stopImmediatePropagation=si,this.preventDefault=si,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},Sa={mousedown:function(t){t=Fi(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=Fi(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=Fi(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){xa(this,(t=Fi(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){ya=!0,t=Fi(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){ya||(t=Fi(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){ba(t=Fi(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),Sa.mousemove.call(this,t),Sa.mousedown.call(this,t)},touchmove:function(t){ba(t=Fi(this.dom,t)),this.handler.processGesture(t,"change"),Sa.mousemove.call(this,t)},touchend:function(t){ba(t=Fi(this.dom,t)),this.handler.processGesture(t,"end"),Sa.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&Sa.click.call(this,t)},pointerdown:function(t){Sa.mousedown.call(this,t)},pointermove:function(t){_a(t)||Sa.mousemove.call(this,t)},pointerup:function(t){Sa.mouseup.call(this,t)},pointerout:function(t){_a(t)||Sa.mouseout.call(this,t)}};Tn(["click","dblclick","contextmenu"],function(t){Sa[t]=function(e){e=Fi(this.dom,e),this.trigger(t,e)}});var Ca={pointermove:function(t){_a(t)||Ca.mousemove.call(this,t)},pointerup:function(t){Ca.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function Ma(t,e){var n=e.domHandlers;Qe.pointerEventsSupported?Tn(ga.pointer,function(i){Ta(e,i,function(e){n[i].call(t,e)})}):(Qe.touchEventsSupported&&Tn(ga.touch,function(i){Ta(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),Tn(ga.mouse,function(i){Ta(e,i,function(r){r=Bi(r),e.touching||n[i].call(t,r)})}))}function ka(t,e){function n(n){Ta(e,n,function(i){i=Bi(i),xa(t,i.target)||(i=function(t,e){return Fi(t.dom,new wa(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Qe.pointerEventsSupported?Tn(ma,n):Qe.touchEventsSupported||Tn(va,n)}function Ta(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function Aa(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&$i(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var Ia=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},Da=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new Ia(e,Sa),fa&&(i._globalHandlerScope=new Ia(document,Ca)),Ma(i,i._localHandlerScope),i}return b(e,t),e.prototype.dispose=function(){Aa(this._localHandlerScope),fa&&Aa(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,fa&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?ka(this,e):Aa(e)}},e}(Si),Pa=1;Qe.hasGlobalWindow&&(Pa=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var La=Pa,Ea="#333",Oa="#ccc",za=qi,Na=5e-5;function Ra(t){return t>Na||t<-5e-5}var Ha,Ba=[],Fa=[],$a=[1,0,0,1,0,0],Va=Math.abs,Wa=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return Ra(this.rotation)||Ra(this.x)||Ra(this.y)||Ra(this.scaleX-1)||Ra(this.scaleY-1)||Ra(this.skewX)||Ra(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):za(n),t&&(e?Xi(n,t,n):ji(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(za(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(Ba);var n=Ba[0]<0?-1:1,i=Ba[1]<0?-1:1,r=((Ba[0]-n)*e+n)/Ba[0]||0,o=((Ba[1]-i)*e+i)/Ba[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],Ki(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Xi(Fa,t.invTransform,e),e=Fa);var n=this.originX,i=this.originY;(n||i)&&($a[4]=n,$a[5]=i,Xi(Fa,e,$a),Fa[4]-=n,Fa[5]-=i,e=Fa),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&yi(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&yi(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&Va(t[0]-1)>1e-10&&Va(t[3]-1)>1e-10?Math.sqrt(Va(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){Ga(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,d=t.skewX?Math.tan(t.skewX):0,h=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-d*f*o,e[5]=-f*o-h*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=h*r,e[2]=d*o,l&&Zi(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),Ua=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function Ga(t,e){for(var n=0;n=Xa)){t=t||tn;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=on.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?ja=Xa:r>2&&ja++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function Za(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=on.measureText(e,t.font).width,n.put(e,i)),i}function Ka(t,e,n,i){var r=Za(qa(e),t),o=es(e),a=Ja(0,r,n),s=ts(0,o,i);return new hr(a,s,r,o)}function Qa(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return Ka(r[0],e,n,i);for(var o=new hr(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function is(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",d="top";if(i instanceof Array)l+=ns(i[0],n.width),c+=ns(i[1],n.height),u=null,d=null;else switch(i){case"left":l-=r,c+=s,u="right",d="middle";break;case"right":l+=r+a,c+=s,d="middle";break;case"top":l+=a/2,c-=r,u="center",d="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",d="middle";break;case"insideLeft":l+=r,c+=s,d="middle";break;case"insideRight":l+=a-r,c+=s,u="right",d="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",d="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,d="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",d="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=d,t}var rs="__zr_normal__",os=Ua.concat(["ignore"]),as=In(Ua,function(t,e){return t[e]=!0,t},{ignore:!1}),ss={},ls=new hr(0,0,0,0),cs=[],us=function(){function t(t){this.id=yn(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,d=void 0;if((u||c)&&(d=ls,n.layoutRect?d.copy(n.layoutRect):d.copy(this.getBoundingRect()),i||d.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(ss,n,d):is(ss,n,d),r.x=ss.x,r.y=ss.y,o=ss.align,a=ss.verticalAlign;var h=n.origin;if(h&&null!=n.rotation){var p=void 0,f=void 0;"center"===h?(p=.5*d.width,f=.5*d.height):(p=ns(h[0],d.width),f=ns(h[1],d.height)),l=!0,r.originX=-r.x+p+(i?0:d.x),r.originY=-r.y+f+(i?0:d.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new hr(0,0,0,0);r.getLocalTransform(cs),Ki(cs,cs),hr.copy(m,d),m.applyTransform(cs)}else v.overflowRect=null;var y=void 0,_=void 0,b=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),b=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),b=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&b===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=b,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?Oa:Ea},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&ko(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,Do(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},wn(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(Bn(t))for(var n=Pn(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(rs,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===rs;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(Cn(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}_n("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,d),g&&g.useStates(t,e,d),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!d&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=Cn(i,t),o=Cn(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var h=0;h0||r.force&&!a.length){var w,S=void 0,C=void 0,M=void 0;if(s){C={},h&&(S={});for(b=0;b<_;b++){C[m=g[b]]=n[m],h?S[m]=i[m]:n[m]=i[m]}}else if(h){M={};for(b=0;b<_;b++){M[m=g[b]]=ia(n[m]),ps(n,i,m)}}(w=new ca(n,!1,!1,d?Dn(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),h&&S&&w.whenWithKeys(0,S,g),M&&w.whenWithKeys(0,M,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}Mn(us,Si),Mn(us,Wa);var gs=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return b(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=Cn(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=Cn(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*c+a}var es=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return ns(t,e,n)};function ns(t,e,n){return hn(t)?(i=t,i.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t;var i}function is(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function rs(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return function(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}(t)}function os(t,e){var n=Math.max(rs(t),rs(e)),i=t+e;return n>20?i:is(i,n)}function as(t){var e=2*Math.PI;return(t%e+e)%e}function ss(t){return t>-1e-4&&t=10&&e++,e}function hs(t,e){var n=us(t),i=Math.pow(10,n),r=t/i;return t=(r<1.5?1:r<2.5?2:r<4?3:r<7?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function ds(t){var e=parseFloat(t);return e==t&&(0!==e||!hn(t)||t.indexOf("x")<=0)?e:NaN}function ps(){return Math.round(9*Math.random())}function fs(t,e){return 0===e?t:fs(e,t%e)}function gs(t,e){return null==t?e:null==e?t:t*e/fs(t,e)}var vs="undefined"!=typeof console&&console.warn&&console.log;function ms(t,e){!function(t,e){vs&&console[t]("[ECharts] "+e)}("error",t)}function ys(t){throw new Error(t)}function _s(t,e,n){return(e-t)*n+t}var xs="series\0";function bs(t){return t instanceof Array?t:null==t?[]:[t]}function ws(t,e,n){if(t){t[e]=t[e]||{},t.emphasis=t.emphasis||{},t.emphasis[e]=t.emphasis[e]||{};for(var i=0,r=n.length;i=0||r&&Qe(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var Zs=Ys([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),Ks=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return Zs(this,t,e)},t}(),Qs=new $r(50);function Js(t){if("string"==typeof t){var e=Qs.get(t);return e&&e.image}return t}function tl(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=Qs.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!nl(e=o.image)&&o.pending.push(a):((e=Ee.loadImage(t,el,el)).__zrImageSrc=t,Qs.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function el(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;c++)l-=s;var u=Ma(a,n);return u>l&&(n="",u=0),l=t-u,r.ellipsis=n,r.ellipsisWidth=u,r.contentWidth=l,r.containerWidth=t,r}function al(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=Ma(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?sl(e,r,o):a>0?Math.floor(e.length*r/a):0;a=Ma(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function sl(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),h=!0),i.accumWidth=f}else{var g=fl(e,u,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=ba(u),m=0;m=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!dl[t]}function fl(t,e,n,i,r){for(var o=[],a=[],s="",l="",c=0,u=0,h=ba(e),d=0;dn:r+u+f>n)?u?(s||l)&&(g?(s||(s=l,l="",u=c=0),o.push(s),a.push(u-c),l+=p,s="",u=c+=f):(l&&(s+=l,l="",c=0),o.push(s),a.push(u),s=p,u=f)):g?(o.push(l),a.push(c),l=p,c=f):(o.push(p),a.push(f)):(u+=f,g?(l+=p,c+=f):(l&&(s+=l,l="",c=0),s+=p))}else l&&(s+=l,u+=c),o.push(s),a.push(u),s="",l="",c=0,u=0}return l&&(s+=l),s&&(o.push(s),a.push(u)),1===o.length&&(u+=r),{accumWidth:u,lines:o,linesWidths:a}}function gl(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;$i.set(vl,Da(n,a,r),Ia(i,s,o),a,s),$i.intersect(e,vl,null,ml);var l=ml.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=Da(l.x,l.width,r,!0),t.baseY=Ia(l.y,l.height,o,!0)}}var vl=new $i(0,0,0,0),ml={outIntersectRect:{},clamp:!0};function yl(t){return null!=t?t+="":t=""}function _l(t,e,n,i){var r=new $i(Da(t.x||0,e,t.textAlign),Ia(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:xl(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function xl(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var bl="__zr_style_"+Math.round(10*Math.random()),wl={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Sl={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};wl[bl]=!0;var Cl=["z","z2","invisible"],Ml=["invisible"],kl=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype._init=function(e){for(var n=an(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(zl[0]=Ll(r)*n+t,zl[1]=Pl(r)*i+e,Ol[0]=Ll(o)*n+t,Ol[1]=Pl(o)*i+e,c(s,zl,Ol),u(l,zl,Ol),(r%=El)<0&&(r+=El),(o%=El)<0&&(o+=El),r>o&&!a?o+=El:rr&&(Nl[0]=Ll(p)*n+t,Nl[1]=Pl(p)*i+e,c(s,Nl,s),u(l,Nl,l))}var Wl={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},Ul=[],Gl=[],ql=[],jl=[],Xl=[],Yl=[],Zl=Math.min,Kl=Math.max,Ql=Math.cos,Jl=Math.sin,tc=Math.abs,ec=Math.PI,nc=2*ec,ic="undefined"!=typeof Float32Array,rc=[];function oc(t){return Math.round(t/ec*1e8)/1e8%2*ec}var ac=function(){function t(t){this.dpr=1,this._xi=0,this._yi=0,this._x0=0,this._y0=0,this._len=0,t&&(this._saveData=!1),this._saveData&&(this.data=[])}var e;return t.prototype.increaseVersion=function(){this._version++},t.prototype.getVersion=function(){return this._version},t.prototype.setScale=function(t,e,n){(n=n||0)>0&&(this._ux=tc(n/sa/t)||0,this._uy=tc(n/sa/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(Wl.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=tc(t-this._xi),i=tc(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(Wl.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(Wl.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(Wl.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),rc[0]=i,rc[1]=r,function(t,e){var n=oc(t[0]);n<0&&(n+=nc);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=nc?r=n+nc:e&&n-r>=nc?r=n-nc:!e&&n>r?r=n+(nc-oc(n-r)):e&&n0&&o))for(var a=0;ac.length&&(this._expandData(),c=this.data);for(var u=0;u0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){ql[0]=ql[1]=Xl[0]=Xl[1]=Number.MAX_VALUE,jl[0]=jl[1]=Yl[0]=Yl[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||tc(v)>i||h===e-1)&&(f=Math.sqrt(I*I+v*v),r=g,o=_);break;case Wl.C:var m=t[h++],y=t[h++],_=(g=t[h++],t[h++]),x=t[h++],b=t[h++];f=Ar(r,o,m,y,g,_,x,b,10),r=x,o=b;break;case Wl.Q:f=Or(r,o,m=t[h++],y=t[h++],g=t[h++],_=t[h++],10),r=g,o=_;break;case Wl.A:var w=t[h++],S=t[h++],C=t[h++],M=t[h++],k=t[h++],T=t[h++],D=T+k;h+=1,p&&(a=Ql(k)*C+w,s=Jl(k)*M+S),f=Kl(C,M)*Zl(nc,Math.abs(T)),r=Ql(D)*C+w,o=Jl(D)*M+S;break;case Wl.R:a=r=t[h++],s=o=t[h++],f=2*t[h++]+2*t[h++];break;case Wl.Z:var I=a-r;v=s-o;f=Math.sqrt(I*I+v*v),r=a,o=s}f>=0&&(l[u++]=f,c+=f)}return this._pathLen=c,c},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,c,u,h,d=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,m=0,y=0,_=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,c=e*this._pathLen))t:for(var x=0;x0&&(t.lineTo(u,h),_=0),b){case Wl.M:n=r=d[x++],i=o=d[x++],t.moveTo(r,o);break;case Wl.L:a=d[x++],s=d[x++];var S=tc(a-r),C=tc(s-o);if(S>p||C>f){if(v){if(m+(X=l[y++])>c){var M=(c-m)/X;t.lineTo(r*(1-M)+a*M,o*(1-M)+s*M);break t}m+=X}t.lineTo(a,s),r=a,o=s,_=0}else{var k=S*S+C*C;k>_&&(u=a,h=s,_=k)}break;case Wl.C:var T=d[x++],D=d[x++],I=d[x++],A=d[x++],P=d[x++],L=d[x++];if(v){if(m+(X=l[y++])>c){Ir(r,T,I,P,M=(c-m)/X,Ul),Ir(o,D,A,L,M,Gl),t.bezierCurveTo(Ul[1],Gl[1],Ul[2],Gl[2],Ul[3],Gl[3]);break t}m+=X}t.bezierCurveTo(T,D,I,A,P,L),r=P,o=L;break;case Wl.Q:T=d[x++],D=d[x++],I=d[x++],A=d[x++];if(v){if(m+(X=l[y++])>c){zr(r,T,I,M=(c-m)/X,Ul),zr(o,D,A,M,Gl),t.quadraticCurveTo(Ul[1],Gl[1],Ul[2],Gl[2]);break t}m+=X}t.quadraticCurveTo(T,D,I,A),r=I,o=A;break;case Wl.A:var E=d[x++],z=d[x++],O=d[x++],N=d[x++],R=d[x++],H=d[x++],B=d[x++],F=!d[x++],$=O>N?O:N,V=tc(O-N)>.001,W=R+H,U=!1;if(v)m+(X=l[y++])>c&&(W=R+H*(c-m)/X,U=!0),m+=X;if(V&&t.ellipse?t.ellipse(E,z,O,N,B,R,W,F):t.arc(E,z,$,R,W,F),U)break t;w&&(n=Ql(R)*O+E,i=Jl(R)*N+z),r=Ql(W)*O+E,o=Jl(W)*N+z;break;case Wl.R:n=r=d[x],i=o=d[x+1],a=d[x++],s=d[x++];var G=d[x++],q=d[x++];if(v){if(m+(X=l[y++])>c){var j=c-m;t.moveTo(a,s),t.lineTo(a+Zl(j,G),s),(j-=G)>0&&t.lineTo(a+G,s+Zl(j,q)),(j-=q)>0&&t.lineTo(a+Kl(G-j,0),s+q),(j-=G)>0&&t.lineTo(a,s+Kl(q-j,0));break t}m+=X}t.rect(a,s,G,q);break;case Wl.Z:if(v){var X;if(m+(X=l[y++])>c){M=(c-m)/X;t.lineTo(r*(1-M)+n*M,o*(1-M)+i*M);break t}m+=X}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=Wl,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function sc(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+h&&u>i+h&&u>o+h&&u>s+h||ut+h&&c>n+h&&c>r+h&&c>a+h||c=0&&pe+c&&l>i+c&&l>o+c||lt+c&&s>n+c&&s>r+c||s=0&&gn||u+cr&&(r+=dc);var d=Math.atan2(l,s);return d<0&&(d+=dc),d>=i&&d<=r||d+dc>=i&&d+dc<=r}function fc(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var gc=ac.CMD,vc=2*Math.PI;var mc=[-1,-1,-1],yc=[-1,-1];function _c(){var t=yc[0];yc[0]=yc[1],yc[1]=t}function xc(t,e,n,i,r,o,a,s,l,c){if(c>e&&c>i&&c>o&&c>s||c1&&_c(),p=Mr(e,i,o,s,yc[0]),d>1&&(f=Mr(e,i,o,s,yc[1]))),2===d?ve&&s>i&&s>o||s=0&&u<=1&&(r[l++]=u);else{var c=a*a-4*o*s;if(Sr(c))(u=-a/(2*o))>=0&&u<=1&&(r[l++]=u);else if(c>0){var u,h=gr(c),d=(-a-h)/(2*o);(u=(-a+h)/(2*o))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}(e,i,o,s,mc);if(0===l)return 0;var c=Er(e,i,o);if(c>=0&&c<=1){for(var u=0,h=Pr(e,i,o,c),d=0;dn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);mc[0]=-l,mc[1]=l;var c=Math.abs(i-r);if(c<1e-4)return 0;if(c>=vc-1e-4){i=0,r=vc;var u=o?1:-1;return a>=mc[0]+t&&a<=mc[1]+t?u:0}if(i>r){var h=i;i=r,r=h}i<0&&(i+=vc,r+=vc);for(var d=0,p=0;p<2;p++){var f=mc[p];if(f+t>a){var g=Math.atan2(s,f);u=o?1:-1;g<0&&(g=vc+g),(g>=i&&g<=r||g+vc>=i&&g+vc<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(u=-u),d+=u)}}return d}function Sc(t,e,n,i,r){for(var o,a,s=t.data,l=t.len(),c=0,u=0,h=0,d=0,p=0,f=0;f1&&(n||(c+=fc(u,h,d,p,i,r))),v&&(d=u=s[f],p=h=s[f+1]),g){case gc.M:u=d=s[f++],h=p=s[f++];break;case gc.L:if(n){if(sc(u,h,s[f],s[f+1],e,i,r))return!0}else c+=fc(u,h,s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case gc.C:if(n){if(lc(u,h,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=xc(u,h,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case gc.Q:if(n){if(cc(u,h,s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=bc(u,h,s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case gc.A:var m=s[f++],y=s[f++],_=s[f++],x=s[f++],b=s[f++],w=s[f++];f+=1;var S=!!(1-s[f++]);o=Math.cos(b)*_+m,a=Math.sin(b)*x+y,v?(d=o,p=a):c+=fc(u,h,o,a,i,r);var C=(i-m)*x/_+m;if(n){if(pc(m,y,x,b,b+w,S,e,C,r))return!0}else c+=wc(m,y,x,b,b+w,S,C,r);u=Math.cos(b+w)*_+m,h=Math.sin(b+w)*x+y;break;case gc.R:if(d=u=s[f++],p=h=s[f++],o=d+s[f++],a=p+s[f++],n){if(sc(d,p,o,p,e,i,r)||sc(o,p,o,a,e,i,r)||sc(o,a,d,a,e,i,r)||sc(d,a,d,p,e,i,r))return!0}else c+=fc(o,p,o,a,i,r),c+=fc(d,a,d,p,i,r);break;case gc.Z:if(n){if(sc(u,h,d,p,e,i,r))return!0}else c+=fc(u,h,d,p,i,r);u=d,h=p}}return n||function(t,e){return Math.abs(t-e)<1e-4}(h,p)||(c+=fc(u,h,d,p,i,r)||0),0!==c}var Cc=Ke({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},wl),Mc={style:Ke({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Sl.style)},kc=_a.concat(["invisible","culling","z","z2","zlevel","parent"]),Tc=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?la:e>.2?"#eee":ca}if(t)return ca}return la},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(hn(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===oo(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new ac(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Sc(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Sc(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:Ze(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return zn(Cc,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=Ze({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=Ze({},i.shape),Ze(s,n.shape)):(s=Ze({},r?this.shape:i.shape),Ze(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=Ze({},this.shape);for(var c={},u=an(s),h=0;hc&&(n*=c/(a=n+i),i*=c/a),r+o>c&&(r*=c/(a=r+o),o*=c/a),i+r>u&&(i*=u/(a=i+r),r*=u/a),n+o>u&&(n*=u/(a=n+o),o*=u/a),t.moveTo(s+n,l),t.lineTo(s+c-i,l),0!==i&&t.arc(s+c-i,l+i,i,-Math.PI/2,0),t.lineTo(s+c,l+u-r),0!==r&&t.arc(s+c-r,l+u-r,r,0,Math.PI/2),t.lineTo(s+o,l+u),0!==o&&t.arc(s+o,l+u-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(Tc);Bc.prototype.type="rect";var Fc={fill:"#000"},$c={},Vc={style:Ke({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Sl.style)},Wc=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=Fc,n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;em&&p){var _=Math.floor(m/d);f=f||v.length>_,y=(v=v.slice(0,_)).length*d}if(r&&u&&null!=g)for(var x=ol(g,c,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),b={},w=0;w0,M=0;Mg&&hl(o,a.substring(g,v),e,f),hl(o,d[2],e,f,d[1]),g=il.lastIndex}gh){var z=o.lines.length;D>0?(M.tokens=M.tokens.slice(0,D),S(M,T,k),o.lines=o.lines.slice(0,C+1)):o.lines=o.lines.slice(0,C),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(T=_[k]).align;)this._placeToken(T,t,b,f,M,"right",v),w-=T.width,M-=T.width,k--;for(C+=(s-(C-p)-(g-M)-w)/2;S<=k;)T=_[S],this._placeToken(T,t,b,f,C+T.width/2,"center",v),C+=T.width,S++;f+=b}},e.prototype._placeToken=function(t,e,n,i,r,o,a){var s=e.rich[t.styleName]||{};s.text=t.text;var l=t.verticalAlign,c=i+n/2;"top"===l?c=i+t.height/2:"bottom"===l&&(c=i+n-t.height/2),!t.isLineHolder&&eu(s)&&this._renderBackground(s,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var u=!!s.backgroundColor,h=t.textPadding;h&&(r=Jc(r,o,h),c-=t.height/2-h[0]-t.innerHeight/2);var d=this._getOrCreateChild(Ic),p=d.createStyle();d.useStyle(p);var f=this._defaultStyle,g=!1,v=0,m=!1,y=Qc("fill"in s?s.fill:"fill"in e?e.fill:(g=!0,f.fill)),_=Kc("stroke"in s?s.stroke:"stroke"in e?e.stroke:u||a||f.autoStroke&&!g?null:(v=2,m=!0,f.stroke)),x=s.textShadowBlur>0||e.textShadowBlur>0;p.text=t.text,p.x=r,p.y=c,x&&(p.shadowBlur=s.textShadowBlur||e.textShadowBlur||0,p.shadowColor=s.textShadowColor||e.textShadowColor||"transparent",p.shadowOffsetX=s.textShadowOffsetX||e.textShadowOffsetX||0,p.shadowOffsetY=s.textShadowOffsetY||e.textShadowOffsetY||0),p.textAlign=o,p.textBaseline="middle",p.font=t.font||Ie,p.opacity=wn(s.opacity,e.opacity,1),Xc(p,s),_&&(p.lineWidth=wn(s.lineWidth,e.lineWidth,v),p.lineDash=bn(s.lineDash,e.lineDash),p.lineDashOffset=e.lineDashOffset||0,p.stroke=_),y&&(p.fill=y),d.setBoundingRect(_l(p,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,c=t.backgroundColor,u=t.borderWidth,h=t.borderColor,d=c&&c.image,p=c&&!d,f=t.borderRadius,g=this;if(p||t.lineHeight||u&&h){(a=this._getOrCreateChild(Bc)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=c||null,l.fillOpacity=bn(t.fillOpacity,1);else if(d){(s=this._getOrCreateChild(Lc)).onload=function(){g.dirtyStyle()};var m=s.style;m.image=c.image,m.x=n,m.y=i,m.width=r,m.height=o}u&&h&&((l=a.style).lineWidth=u,l.stroke=h,l.strokeOpacity=bn(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var y=(a||s).style;y.shadowBlur=t.shadowBlur||0,y.shadowColor=t.shadowColor||"transparent",y.shadowOffsetX=t.shadowOffsetX||0,y.shadowOffsetY=t.shadowOffsetY||0,y.opacity=wn(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return Yc(t)&&(e=[t.fontStyle,t.fontWeight,jc(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&kn(e)||t.textFont||t.font},e}(kl),Uc={left:!0,right:1,center:1},Gc={top:1,bottom:1,middle:1},qc=["fontStyle","fontWeight","fontSize","fontFamily"];function jc(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function Xc(t,e){for(var n=0;n=0,o=!1;if(t instanceof Tc){var a=ou(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(gu(s)||gu(l)){var c=(i=i||{}).style||{};"inherit"===c.fill?(o=!0,i=Ze({},i),(c=Ze({},c)).fill=s):!gu(c.fill)&&gu(s)?(o=!0,i=Ze({},i),(c=Ze({},c)).fill=so(s)):!gu(c.stroke)&&gu(l)&&(o||(i=Ze({},i),c=Ze({},c)),c.stroke=so(l)),i.style=c}}if(i&&null==i.z2){o||(i=Ze({},i));var u=t.z2EmphasisLift;i.z2=t.z2+(null!=u?u:10)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=Qe(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function Vu(t,e,n){ju(t,!0),Cu(t,Tu),function(t,e,n){var i=nu(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}(t,e,n)}function Wu(t,e,n,i){i?function(t){ju(t,!1)}(t):Vu(t,e,n)}var Uu=["emphasis","blur","select"],Gu={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function qu(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=eh(f),s*=eh(f));var g=(r===o?-1:1)*eh((a*a*(s*s)-a*a*(p*p)-s*s*(d*d))/(a*a*(p*p)+s*s*(d*d)))||0,v=g*a*p/s,m=g*-s*d/a,y=(t+n)/2+ih(h)*v-nh(h)*m,_=(e+i)/2+nh(h)*v+ih(h)*m,x=sh([1,0],[(d-v)/a,(p-m)/s]),b=[(d-v)/a,(p-m)/s],w=[(-1*d-v)/a,(-1*p-m)/s],S=sh(b,w);if(ah(b,w)<=-1&&(S=rh),ah(b,w)>=1&&(S=0),S<0){var C=Math.round(S/rh*1e6)/1e6;S=2*rh+C%2*rh}u.addData(c,y,_,a,s,x,S,h,o)}var ch=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,uh=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var hh=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.applyTransform=function(t){},e}(Tc);function dh(t){return null!=t.setData}function ph(t,e){var n=function(t){var e=new ac;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=ac.CMD,l=t.match(ch);if(!l)return e;for(var c=0;cA*A+P*P&&(C=k,M=T),{cx:C,cy:M,x0:-u,y0:-h,x1:C*(r/b-1),y1:M*(r/b-1)}}function Ah(t,e){var n,i=kh(e.r,0),r=kh(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var c=e.cx,u=e.cy,h=!!e.clockwise,d=Ch(l-s),p=d>_h&&d%_h;if(p>Dh&&(d=p),i>Dh)if(d>_h-Dh)t.moveTo(c+i*bh(s),u+i*xh(s)),t.arc(c,u,i,s,l,!h),r>Dh&&(t.moveTo(c+r*bh(l),u+r*xh(l)),t.arc(c,u,r,l,s,h));else{var f=void 0,g=void 0,v=void 0,m=void 0,y=void 0,_=void 0,x=void 0,b=void 0,w=void 0,S=void 0,C=void 0,M=void 0,k=void 0,T=void 0,D=void 0,I=void 0,A=i*bh(s),P=i*xh(s),L=r*bh(l),E=r*xh(l),z=d>Dh;if(z){var O=e.cornerRadius;O&&(n=function(t){var e;if(cn(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(O),f=n[0],g=n[1],v=n[2],m=n[3]);var N=Ch(i-r)/2;if(y=Th(N,v),_=Th(N,m),x=Th(N,f),b=Th(N,g),C=w=kh(y,_),M=S=kh(x,b),(w>Dh||S>Dh)&&(k=i*bh(l),T=i*xh(l),D=r*bh(s),I=r*xh(s),dDh){var U=Th(v,C),G=Th(m,C),q=Ih(D,I,A,P,i,U,h),j=Ih(k,T,L,E,i,G,h);t.moveTo(c+q.cx+q.x0,u+q.cy+q.y0),C0&&t.arc(c+q.cx,u+q.cy,U,Sh(q.y0,q.x0),Sh(q.y1,q.x1),!h),t.arc(c,u,i,Sh(q.cy+q.y1,q.cx+q.x1),Sh(j.cy+j.y1,j.cx+j.x1),!h),G>0&&t.arc(c+j.cx,u+j.cy,G,Sh(j.y1,j.x1),Sh(j.y0,j.x0),!h))}else t.moveTo(c+A,u+P),t.arc(c,u,i,s,l,!h);else t.moveTo(c+A,u+P);if(r>Dh&&z)if(M>Dh){U=Th(f,M),q=Ih(L,E,k,T,r,-(G=Th(g,M)),h),j=Ih(A,P,D,I,r,-U,h);t.lineTo(c+q.cx+q.x0,u+q.cy+q.y0),M0&&t.arc(c+q.cx,u+q.cy,G,Sh(q.y0,q.x0),Sh(q.y1,q.x1),!h),t.arc(c,u,r,Sh(q.cy+q.y1,q.cx+q.x1),Sh(j.cy+j.y1,j.cx+j.x1),h),U>0&&t.arc(c+j.cx,u+j.cy,U,Sh(j.y1,j.x1),Sh(j.y0,j.x0),!h))}else t.lineTo(c+L,u+E),t.arc(c,u,r,l,s,h);else t.lineTo(c+L,u+E)}else t.moveTo(c,u);t.closePath()}}}var Ph=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},Lh=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Ph},e.prototype.buildPath=function(t,e){Ah(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(Tc);Lh.prototype.type="sector";var Eh=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},zh=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Eh},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(Tc);function Oh(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],c=[],u=[],h=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var d=0,p=t.length;did[1]){if(r=!1,rd.negativeSize||n)return r;var s=ed(id[0]-nd[1]),l=ed(nd[0]-id[1]);Jh(s,l)>ad.len()&&(s=l||!rd.bidirectional)&&(Ti.scale(od,a,-l*i),rd.useDir&&rd.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var h={duration:u.duration,delay:u.delay||0,easing:u.easing,done:o,force:!!o||!!a,setToFinal:!c,scope:t,during:a};l?e.animateFrom(n,h):e.animateTo(n,h)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function dd(t,e,n,i,r,o){hd("update",t,e,n,i,r,o)}function pd(t,e,n,i,r,o){hd("enter",t,e,n,i,r,o)}function fd(t){if(!t.__zr)return!0;for(var e=0;e=-1e-6)return!1;var f=t-r,g=e-o,v=zd(f,g,c,u)/p;if(v<0||v>1)return!1;var m=zd(f,g,h,d)/p;return!(m<0||m>1)}function zd(t,e,n,i){return t*i-n*e}function Od(t,e,n,i,r){return null==e||(pn(e)?Nd[0]=Nd[1]=Nd[2]=Nd[3]=e:(Nd[0]=e[0],Nd[1]=e[1],Nd[2]=e[2],Nd[3]=e[3]),i&&(Nd[0]=Qa(0,Nd[0]),Nd[1]=Qa(0,Nd[1]),Nd[2]=Qa(0,Nd[2]),Nd[3]=Qa(0,Nd[3])),n&&(Nd[0]=-Nd[0],Nd[1]=-Nd[1],Nd[2]=-Nd[2],Nd[3]=-Nd[3]),Rd(t,Nd,"x","width",3,1,r&&r[0]||0),Rd(t,Nd,"y","height",0,2,r&&r[1]||0)),t}var Nd=[0,0,0,0];function Rd(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=Qa(0,Ka(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:Ja(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function Hd(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=hn(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&en(an(l),function(t){On(s,t)||(s[t]=l[t],s.$vars.push(t))});var c=nu(t.el);c.componentMainType=o,c.componentIndex=a,c.tooltipConfig={name:i,option:Ke({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function Bd(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function Fd(t,e){if(t)if(cn(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}},clipPointsByRect:function(t,e){return nn(t,function(t){var n=t[0];n=Qa(n,e.x),n=Ka(n,e.x+e.width);var i=t[1];return i=Qa(i,e.y),[n,i=Ka(i,e.y+e.height)]})},clipRectByRect:function(t,e){var n=Qa(t.x,e.x),i=Ka(t.x+t.width,e.x+e.width),r=Qa(t.y,e.y),o=Ka(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}},createIcon:Ld,ensureCopyRect:Wd,ensureCopyTransform:Ud,expandOrShrinkRect:Od,extendPath:function(t,e){return xd(t,e)},extendShape:function(t){return Tc.extend(t)},getShapeClass:function(t){if(md.hasOwnProperty(t))return md[t]},getTransform:function(t,e){for(var n=bi([]);t&&t!==e;)Si(n,t.getLocalTransform(),n),t=t.parent;return n},groupTransition:Pd,initProps:pd,isBoundingRectAxisAligned:$d,isElementRemoved:fd,lineLineIntersect:Ed,linePolygonIntersect:function(t,e,n,i,r){for(var o=0,a=r[r.length-1];oJa(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"},traverseElements:Fd,traverseUpdateZ:qd,updateProps:dd}),Yd={};function Zd(t,e,n){var i,r=t.labelFetcher,o=t.labelDataIndex,a=t.labelDimIndex,s=e.normal;r&&(i=r.getFormattedLabel(o,"normal",null,a,s&&s.get("formatter"),null!=n?{interpolatedValue:n}:null)),null==i&&(i=un(t.defaultText)?t.defaultText(o,t,n):t.defaultText);for(var l={normal:i},c=0;c-1?wp:Cp;function Dp(t,e){t=t.toUpperCase(),kp[t]=new _p(e),Mp[t]=e}Dp(Sp,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),Dp(wp,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});function Ip(){return null}var Ap=1e3,Pp=6e4,Lp=36e5,Ep=864e5,zp=31536e6,Op={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},Np={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},Rp="{yyyy}-{MM}-{dd}",Hp={year:"{yyyy}",month:"{yyyy}-{MM}",day:Rp,hour:Rp+" "+Np.hour,minute:Rp+" "+Np.minute,second:Rp+" "+Np.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},Bp=["year","month","day","hour","minute","second","millisecond"],Fp=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function $p(t){return hn(t)||un(t)?t:function(t){t=t||{};var e={},n=!0;return en(Bp,function(e){n&&(n=null==t[e])}),en(Bp,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=Bp[s],c=fn(o)&&!cn(o)?o[l]:o,u=void 0;cn(c)?a=(u=c.slice())[0]||"":hn(c)?u=[a=c]:(null==a?a=Np[i]:Op[l].test(a)||(a=e[l][l][0]+" "+a),u=[a],n&&(u[1]="{primary|"+a+"}")),e[i][l]=u}}),e}(t)}function Vp(t,e){return"0000".substr(0,e-(t+="").length)+t}function Wp(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function Up(t){return t===Wp(t)}function Gp(t,e,n,i){var r=cs(t),o=r[Xp(n)](),a=r[Yp(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[Zp(n)](),c=r["get"+(n?"UTC":"")+"Day"](),u=r[Kp(n)](),h=(u-1)%12+1,d=r[Qp(n)](),p=r[Jp(n)](),f=r[tf(n)](),g=u>=12?"pm":"am",v=g.toUpperCase(),m=i instanceof _p?i:function(t){return kp[t]}(i||Tp)||kp[Cp],y=m.getModel("time"),_=y.get("month"),x=y.get("monthAbbr"),b=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,Vp(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[a-1]).replace(/{MMM}/g,x[a-1]).replace(/{MM}/g,Vp(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,Vp(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,b[c]).replace(/{ee}/g,w[c]).replace(/{e}/g,c+"").replace(/{HH}/g,Vp(u,2)).replace(/{H}/g,u+"").replace(/{hh}/g,Vp(h+"",2)).replace(/{h}/g,h+"").replace(/{mm}/g,Vp(d,2)).replace(/{m}/g,d+"").replace(/{ss}/g,Vp(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,Vp(f,3)).replace(/{S}/g,f+"")}function qp(t,e){var n=cs(t),i=n[Yp(e)]()+1,r=n[Zp(e)](),o=n[Kp(e)](),a=n[Qp(e)](),s=n[Jp(e)](),l=0===n[tf(e)](),c=l&&0===s,u=c&&0===a,h=u&&0===o,d=h&&1===r;return d&&1===i?"year":d?"month":h?"day":u?"hour":c?"minute":l?"second":"millisecond"}function jp(t,e,n){switch(e){case"year":t[nf(n)](0);case"month":t[rf(n)](1);case"day":t[of(n)](0);case"hour":t[af(n)](0);case"minute":t[sf(n)](0);case"second":t[lf(n)](0)}return t}function Xp(t){return t?"getUTCFullYear":"getFullYear"}function Yp(t){return t?"getUTCMonth":"getMonth"}function Zp(t){return t?"getUTCDate":"getDate"}function Kp(t){return t?"getUTCHours":"getHours"}function Qp(t){return t?"getUTCMinutes":"getMinutes"}function Jp(t){return t?"getUTCSeconds":"getSeconds"}function tf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function ef(t){return t?"setUTCFullYear":"setFullYear"}function nf(t){return t?"setUTCMonth":"setMonth"}function rf(t){return t?"setUTCDate":"setDate"}function of(t){return t?"setUTCHours":"setHours"}function af(t){return t?"setUTCMinutes":"setMinutes"}function sf(t){return t?"setUTCSeconds":"setSeconds"}function lf(t){return t?"setUTCMilliseconds":"setMilliseconds"}function cf(t){if(isNaN(ds(t)))return hn(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function uf(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var hf=Cn;function df(t,e,n){function i(t){return t&&kn(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?cs(t):t;if(!isNaN(+s))return Gp(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return dn(t)?i(t):pn(t)&&r(t)?t+"":"-";var l=ds(t);return r(l)?cf(l):dn(t)?i(t):"boolean"==typeof t?t+"":"-"}var pf=["a","b","c","d","e","f","g"],ff=function(t,e){return"{"+t+(null==e?"":e)+"}"};function gf(t,e,n){cn(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;oi||l.newline?(o=0,u=g,a+=s+n,s=d.height):s=Math.max(s,d.height)}else{var v=d.height+(f?-f.y+d.y:0);(h=a+v)>r||l.newline?(o+=s+n,a=0,h=v,s=d.width):s=Math.max(s,d.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=u+n:a=h+n)})}function Pf(t,e,n){n=hf(n||0);var i=e.width,r=e.height,o=es(t.left,i),a=es(t.top,r),s=es(t.right,i),l=es(t.bottom,r),c=es(t.width,i),u=es(t.height,r),h=n[2]+n[0],d=n[1]+n[3],p=t.aspect;switch(isNaN(c)&&(c=i-s-d-o),isNaN(u)&&(u=r-l-h-a),null!=p&&(isNaN(c)&&isNaN(u)&&(p>i/r?c=.8*i:u=.8*r),isNaN(c)&&(c=p*u),isNaN(u)&&(u=c/p)),isNaN(o)&&(o=i-s-c-d),isNaN(a)&&(a=r-l-u-h),t.left||t.right){case"center":o=i/2-c/2-n[3];break;case"right":o=i-c-d}switch(t.top||t.bottom){case"middle":case"center":a=r/2-u/2-n[0];break;case"bottom":a=r-u-h}o=o||0,a=a||0,isNaN(c)&&(c=i-d-o-(s||0)),isNaN(u)&&(u=r-h-a-(l||0));var f=new $i((e.x||0)+o+n[3],(e.y||0)+a+n[0],c,u);return f.margin=n,f}ln(Af,"vertical"),ln(Af,"horizontal");var Lf=1;function Ef(t,e,n){var i,r,o,a,s=t.boxCoordinateSystem;if(s){var l=function(t){var e=t.getShallow("coord",!0),n=xf;if(null==e){var i=wf.get(t.type);i&&i.getCoord2&&(n=bf,e=i.getCoord2(t))}return{coord:e,from:n}}(t),c=l.coord,u=l.from;if(s.dataToLayout){o=Lf,a=u;var h=s.dataToLayout(c);i=h.contentRect||h.rect}}return null==o&&(o=Lf),o===Lf&&(i||(i={x:0,y:0,width:e.getWidth(),height:e.getHeight()}),r=[i.x+i.width/2,i.y+i.height/2]),{type:o,refContainer:i,refPoint:r,boxCoordFrom:a}}function zf(t){var e=t.layoutMode||t.constructor.layoutMode;return fn(e)?e:e?{type:e}:null}function Of(t,e,n){var i=n&&n.ignoreSize;!cn(i)&&(i=[i,i]);var r=a(If[0],0),o=a(If[1],1);function a(n,r){var o={},a=0,l={},c=0;if(Tf(n,function(e){l[e]=t[e]}),Tf(n,function(t){On(e,t)&&(o[t]=l[t]=e[t]),s(o,t)&&a++,s(l,t)&&c++}),i[r])return s(e,n[1])?l[n[2]]=null:s(e,n[2])&&(l[n[1]]=null),l;if(2!==c&&a){if(a>=2)return o;for(var u=0;u=0;a--)o=Ye(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return Hs(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return e=!1,{left:(t=this).getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)};var t,e},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((n=e.prototype).type="component",n.id="",n.name="",n.mainType="",n.subType="",void(n.componentIndex=0)),e}(_p);Us(Hf,_p),Xs(Hf),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=Vs(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=Vs(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(Hf),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return en(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return en(t,function(t){Qe(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),en(s,function(t){Qe(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);Qe(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,c={};for(en(t,function(t){c[t]=!0});l.length;){var u=l.pop(),h=s[u],d=!!c[u];d&&(r.call(o,u,h.originalDeps.slice()),delete c[u]),en(h.successor,d?f:p)}en(c,function(){throw new Error("")})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){c[t]=!0,p(t)}}}(Hf,function(t){var e=[];en(Hf.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=nn(e,function(t){return Vs(t).main}),"dataset"!==t&&Qe(e,"dataset")<=0&&e.unshift("dataset");return e});var Bf={color:{},darkColor:{},size:{}},Ff=Bf.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var $f in Ze(Ff,{primary:Ff.neutral80,secondary:Ff.neutral70,tertiary:Ff.neutral60,quaternary:Ff.neutral50,disabled:Ff.neutral20,border:Ff.neutral30,borderTint:Ff.neutral20,borderShade:Ff.neutral40,background:Ff.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:Ff.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:Ff.neutral70,axisLineTint:Ff.neutral40,axisTick:Ff.neutral70,axisTickMinor:Ff.neutral60,axisLabel:Ff.neutral70,axisSplitLine:Ff.neutral15,axisMinorSplitLine:Ff.neutral05}),Ff)if(Ff.hasOwnProperty($f)){var Vf=Ff[$f];"theme"===$f?Bf.darkColor.theme=Ff.theme.slice():"highlight"===$f?Bf.darkColor.highlight="rgba(255,231,130,0.4)":0===$f.indexOf("accent")?Bf.darkColor[$f]=io(Vf,0,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):Bf.darkColor[$f]=io(Vf,0,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}Bf.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var Wf="";"undefined"!=typeof navigator&&(Wf=navigator.platform||"");var Uf="rgba(0, 0, 0, 0.2)",Gf=Bf.color.theme[0],qf=io(Gf,0,null,.9),jf={darkMode:"auto",colorBy:"series",color:Bf.color.theme,gradientColor:[qf,Gf],aria:{decal:{decals:[{color:Uf,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:Uf,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:Uf,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:Uf,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:Uf,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:Uf,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:Wf.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},Xf=En(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),Yf="original",Zf="arrayRows",Kf="objectRows",Qf="keyedColumns",Jf="typedArray",tg="unknown",eg="column",ng="row",ig=1,rg=2,og=3,ag=Es();function sg(t,e,n){var i={},r=lg(e);if(!r||!t)return i;var o,a,s=[],l=[],c=e.ecModel,u=ag(c).datasetMap,h=r.uid+"_"+n.seriesLayoutBy;en(t=t.slice(),function(e,n){var r=fn(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var d=u.get(h)||u.set(h,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if(u=u||n,!u||!u.length)return;var h=u[l];r&&(c[r]=h);return s.paletteIdx=(l+1)%u.length,h}(this,hg,i,r,t,e,n)},t.prototype.clearColorPalette=function(){!function(t,e){e(t).paletteIdx=0,e(t).paletteNameMap={}}(this,hg)},t}();var vg="\0_ec_inner",mg=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new _p(i),this._locale=new _p(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=xg(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,xg(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):fg(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&en(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=En(),s=e&&e.replaceMergeMainTypeMap;ag(this).datasetMap=En(),en(t,function(t,e){null!=t&&(Hf.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?Xe(t):Ye(n[e],t,!0))}),s&&s.each(function(t,e){Hf.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),Hf.topologicalTravel(o,Hf.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=ug.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,bs(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",c=ks(a,o,l);(function(t,e,n){en(t,function(t){var i=t.newOption;fn(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(c,e,Hf),n[e]=null,i.set(e,null),r.set(e,0);var u,h=[],d=[],p=0;en(c,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=Hf.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(u)return;u=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=Ze({componentIndex:n},t.keyInfo);Ze(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(h.push(i.option),d.push(i),p++):(h.push(void 0),d.push(void 0))},this),n[e]=h,i.set(e,d),r.set(e,p),"series"===e&&dg(this)},this),this._seriesIndices||dg(this)},e.prototype.getOption=function(){var t=Xe(this.option);return en(t,function(e,n){if(Hf.hasClass(n)){for(var i=bs(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!Ps(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[vg],t},e.prototype.setTheme=function(t){this._theme=new _p(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var kg=en,Tg=fn,Dg=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Ig(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Dg.length;nc&&(c=p)}s[0]=l,s[1]=c}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return yv(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function bv(t){var e,n;return fn(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function wv(t){return new Sv(t)}var Sv=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=u(this._modBy),s=this._modDataCount||0,l=u(t&&t.modBy),c=t&&t.modDataCount||0;function u(t){return!(t>=1)&&(t=1),t}a===l&&s===c||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=c;var h=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var d=this._dueIndex,p=Math.min(null!=h?this._dueIndex+h:1/0,this._dueEnd);if(!i&&(o||d1&&i>0?s:a}};return o;function a(){return e=t?null:oi?-this._resultLT:0},t}(),Tv=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return Mv(t,e)},t}();function Dv(t){if(!zv(t.sourceFormat)){ys("")}return t.data}function Iv(t){var e=t.sourceFormat,n=t.data;if(!zv(e)){ys("")}if(e===Zf){for(var i=[],r=0,o=n.length;r65535?Rv:Hv}function Wv(){return[1/0,-1/0]}function Uv(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function Gv(t,e,n,i,r){var o=$v[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),c=0;cg[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=nn(o,function(t){return t.property}),c=0;cv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=c&&_<=u||isNaN(_))&&(a[s++]=p),p++}d=!0}else if(2===r){f=h[i[0]];var v=h[i[1]],m=t[i[1]][0],y=t[i[1]][1];for(g=0;g=c&&_<=u||isNaN(_))&&(x>=m&&x<=y||isNaN(x))&&(a[s++]=p),p++}d=!0}}if(!d)if(1===r)for(g=0;g=c&&_<=u||isNaN(_))&&(a[s++]=b)}else for(g=0;gt[C][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,c=Math.floor(1/e),u=this.getRawIndex(0),h=new(Vv(this._rawCount))(Math.min(2*(Math.ceil(s/c)+2),s));h[l++]=u;for(var d=1;dn&&(n=i,r=M)}C>0&&Ca&&(f=a-c);for(var g=0;gp&&(p=v,d=c+g)}var m=this.getRawIndex(u),y=this.getRawIndex(d);uc-p&&(s=c-p,a.length=s);for(var f=0;fu[1]&&(u[1]=v),h[d++]=m}return r._count=d,r._indices=h,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return Mv(t[i],this._dimensions[i])}Ov={arrayRows:t,objectRows:function(t,e,n,i){return Mv(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return Mv(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),jv=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(Xv(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var c=i[0];c.prepareSource(),a=(l=c.getSource()).data,s=l.sourceFormat,e=[c._getVersionSign()]}else s=vn(a=o.get("data",!0))?Jf:Yf,e=[];var u=this._getSourceMetaRawOption()||{},h=l&&l.metaRawOption||{},d=bn(u.seriesLayoutBy,h.seriesLayoutBy)||null,p=bn(u.sourceHeader,h.sourceHeader),f=bn(u.dimensions,h.dimensions);t=d!==h.seriesLayoutBy||!!p!=!!h.sourceHeader||f?[tv(a,{seriesLayoutBy:d,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[tv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){1!==t.length&&Yv("")}var o,a=[],s=[];return en(t,function(t){t.prepareSource();var e=t.getSource(r||0);null==r||e||Yv(""),a.push(e),s.push(t._getVersionSign())}),i?e=function(t,e){var n=bs(t),i=n.length;i||ys("");for(var r=0,o=i;r1||n>0&&!t.noHeader;return en(t.blocks,function(t){var n=im(t);n>=e&&(e=n+ +(i&&(!n||em(t)&&!t.noHeader)))}),e}return 0}function rm(t,e,n,i){var r,o=e.noHeader,a=(r=im(e),{html:Qv[r],richText:Jv[r]}),s=[],l=e.blocks||[];Mn(!l||cn(l)),l=l||[];var c=t.orderMode;if(e.sortBlocks&&c){l=l.slice();var u={valueAsc:"asc",valueDesc:"desc"};if(On(u,c)){var h=new kv(u[c],null);l.sort(function(t,e){return h.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===c&&l.reverse()}en(l,function(n,r){var o=e.valueFormatter,l=nm(n)(o?Ze(Ze({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var d="richText"===t.renderMode?s.join(a.richText):sm(i,s.join(""),o?n:a.html);if(o)return d;var p=df(e.header,"ordinal",t.useUTC),f=Kv(i,t.renderMode).nameStyle,g=Zv(i);return"richText"===t.renderMode?lm(t,p,f)+a.richText+d:sm(i,'
'+li(p)+"
"+d,n)}function om(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,c=t.useUTC,u=e.valueFormatter||t.valueFormatter||function(t){return nn(t=cn(t)?t:[t],function(t,e){return df(t,cn(p)?p[e]:p,c)})};if(!o||!a){var h=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||Bf.color.secondary,r),d=o?"":df(l,"ordinal",c),p=e.valueType,f=a?[]:u(e.value,e.dataIndex),g=!s||!o,v=!s&&o,m=Kv(i,r),y=m.nameStyle,_=m.valueStyle;return"richText"===r?(s?"":h)+(o?"":lm(t,d,y))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(cn(e)?e.join(" "):e,o)}(t,f,g,v,_)):sm(i,(s?"":h)+(o?"":function(t,e,n){return''+li(t)+""}(d,!s,y))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=cn(t)?t:[t],''+nn(t,function(t){return li(t)}).join("  ")+""}(f,g,v,_)),n)}}function am(t,e,n,i,r,o){if(t)return nm(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function sm(t,e,n){return'
'+e+'
'}function lm(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function cm(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var um=function(){function t(){this.richTextStyles={},this._nextStyleNameId=ps()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=function(t,e){var n=hn(t)?{color:t,extraCssText:e}:t||{},i=n.color,r=n.type;e=n.extraCssText;var o=n.renderMode||"html";return i?"html"===o?"subItem"===r?'':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}({color:e,type:t,renderMode:n,markerId:i});return hn(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};cn(e)?en(e,function(t){return Ze(n,t)}):Ze(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function hm(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),c=l.mapDimensionsAll("defaultedTooltip"),u=c.length,h=o.getRawValue(a),d=cn(h),p=function(t,e){return vf(t.getData().getItemVisual(e,"style")[t.visualDrawType])}(o,a);if(u>1||d&&!u){var f=function(t,e,n,i,r){var o=e.getData(),a=rn(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],c=[];function u(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?c.push(tm("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?en(i,function(t){u(yv(o,n,t),t)}):en(t,u),{inlineValues:s,inlineValueTypes:l,blocks:c}}(h,o,a,c,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(u){var g=l.getDimensionInfo(c[0]);r=e=yv(l,a,c[0]),n=g.type}else r=e=d?h[0]:h;var v=As(o),m=v&&o.name||"",y=l.getName(a),_=s?m:y;return tm("section",{header:m,noHeader:s||!v,sortParam:r,blocks:[tm("nameValue",{markerType:"item",markerColor:p,name:_,noName:!kn(_),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var dm=Es();function pm(t,e){return t.getName(e)||t.getId(e)}var fm=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var n;return _(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=wv({count:vm,reset:mm}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(dm(this).sourceManager=new jv(this)).prepareSource();var i=this.getInitialData(t,n);_m(i,this),this.dataTask.context.data=i,dm(this).dataBeforeProcessed=i,gm(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=zf(this),i=n?Nf(t):{},r=this.subType;Hf.hasClass(r)&&(r+="Series"),Ye(t,e.getTheme().get(this.subType)),Ye(t,this.getDefaultOption()),ws(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&Of(t,i,n)},e.prototype.mergeOption=function(t,e){t=Ye(this.option,t,!0),this.fillDataTextStyle(t.data);var n=zf(this);n&&Of(this.option,t,n);var i=dm(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);_m(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,dm(this).dataBeforeProcessed=r,gm(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!vn(t))for(var e=["show"],n=0;n=0&&u<0)&&(c=o,u=r,h=0),r===u&&(l[h++]=e))}),l.length=h,l},e.prototype.formatTooltip=function(t,e,n){return hm({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(Te.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=gg.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[pm(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this.__universalTransitionEnabled)return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){fn(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return Hf.registerClass(t)},e.protoInitialize=((n=e.prototype).type="series.__base__",n.seriesIndex=0,n.ignoreStyleOnData=!1,n.hasSymbolVisual=!1,n.defaultSymbol="circle",n.visualStyleAccessPath="itemStyle",void(n.visualDrawType="fill")),e}(Hf);function gm(t){var e=t.name;As(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return en(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function vm(t){return t.model.getRawData().count()}function mm(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),ym}function ym(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function _m(t,e){en(function(t,e){for(var n=new t.constructor(t.length+e.length),i=0;i=0?h():u=setTimeout(h,-r),l=i};return d.clear=function(){u&&(clearTimeout(u),u=null)},d.debounceNextCall=function(t){s=t},d}function Nm(t,e,n,i){var r=t[e];if(r){var o=r[Lm]||r,a=r[zm];if(r[Em]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=Om(o,n,"debounce"===i))[Lm]=o,r[zm]=i,r[Em]=n}return r}}function Rm(t,e){var n=t[e];n&&n[Lm]&&(n.clear&&n.clear(),t[e]=n[Lm])}var Hm=Es(),Bm={itemStyle:Ys(vp,!0),lineStyle:Ys(pp,!0)},Fm={lineStyle:"stroke",itemStyle:"fill"};function $m(t,e){var n=t.visualStyleMapper||Bm[e];return n||(console.warn("Unknown style type '"+e+"'."),Bm.itemStyle)}function Vm(t,e){var n=t.visualDrawType||Fm[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var Wm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=$m(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=Vm(t,i),l=o[s],c=un(l)?l:null,u="auto"===o.fill||"auto"===o.stroke;if(!o[s]||c||u){var h=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=h,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||un(o.fill)?h:o.fill,o.stroke="auto"===o.stroke||un(o.stroke)?h:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&c)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=Ze({},o);r[s]=c(i),e.setItemVisual(n,"style",r)}}}},Um=new _p,Gm={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=$m(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){Um.option=n[i];var a=r(Um);Ze(t.ensureUniqueItemVisual(e,"style"),a),Um.option.decal&&(t.setItemVisual(e,"decal",Um.option.decal),Um.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},qm={performRawSeries:!0,overallReset:function(t){var e=En();t.eachSeries(function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),Hm(t).scope=r}}),t.eachSeries(function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=Hm(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=Vm(e,a);r.each(function(t){var e=r.getRawIndex(t);i[e]=t}),n.each(function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),c=n.getName(t)||t+"",u=n.count();l[s]=e.getColorFromPalette(c,o,u)}})}})}},jm=Math.PI;var Xm=function(){function t(t,e,n,i){this._stageTaskMap=En(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=En();t.eachSeries(function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;en(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{});Mn(!(i.reset&&i.overallReset),""),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}en(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),c=l.seriesTaskMap,u=l.overallTask;if(u){var h,d=u.agentStubMap;d.each(function(t){a(i,t)&&(t.dirty(),h=!0)}),h&&u.dirty(),o.updatePayload(u,n);var p=o.getPerformArgs(u,i.block);d.each(function(t){t.perform(p)}),u.perform(p)&&(r=!0)}else c&&c.each(function(s,l){a(i,s)&&s.dirty();var c=o.getPerformArgs(s,i.block);c.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(c)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=En(),s=t.seriesType,l=t.getTargetSeries;function c(e){var s=e.uid,l=a.set(s,o&&o.get(s)||wv({plan:Jm,reset:ty,count:iy}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(c):s?n.eachRawSeriesByType(s,c):l&&l(n,i).each(c)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||wv({reset:Ym});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=En(),l=t.seriesType,c=t.getTargetSeries,u=!0,h=!1;function d(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(h=!0,wv({reset:Zm,onDirty:Qm})));n.context={model:t,overallProgress:u},n.agent=o,n.__block=u,r._pipe(t,n)}Mn(!t.createOnAllSeries,""),l?n.eachRawSeriesByType(l,d):c?c(n,i).each(d):(u=!1,en(n.getSeries(),d)),h&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return un(t)&&(t={overallReset:t,seriesType:ry(t)}),t.uid=bp("stageHandler"),e&&(t.visualType=e),t},t}();function Ym(t){t.overallReset(t.ecModel,t.api,t.payload)}function Zm(t){return t.overallProgress&&Km}function Km(){this.agent.dirty(),this.getDownstream().dirty()}function Qm(){this.agent&&this.agent.dirty()}function Jm(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function ty(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=bs(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?nn(e,function(t,e){return ny(e)}):ey}var ey=ny(0);function ny(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&u===r.length-c.length){var h=r.slice(0,u);"data"!==h&&(e.mainType=h,e[c.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return c(s,o,"mainType")&&c(s,o,"subType")&&c(s,o,"index","componentIndex")&&c(s,o,"name")&&c(s,o,"id")&&c(l,r,"name")&&c(l,r,"dataIndex")&&c(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function c(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),vy=["symbol","symbolSize","symbolRotate","symbolOffset"],my=vy.concat(["symbolKeepAspect"]),yy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a=0&&Oy(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=Oy(i)?i:0,r=Oy(r)?r:1,o=Oy(o)?o:0,a=Oy(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:pn(e)?[e]:cn(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=nn(r,function(t){return t/a}),o/=a)}return[r,o]}var Fy=new ac(!0);function $y(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function Vy(t){return"string"==typeof t&&"none"!==t}function Wy(t){var e=t.fill;return null!=e&&"none"!==e}function Uy(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function Gy(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function qy(t,e,n){var i=tl(e.image,e.__image,n);if(nl(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*Rn),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var jy=["shadowBlur","shadowOffsetX","shadowOffsetY"],Xy=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function Yy(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){Qy(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?wl.opacity:a}(i||e.blend!==n.blend)&&(o||(Qy(t,r),o=!0),t.globalCompositeOperation=e.blend||wl.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[p_])if(this._disposed)this.id;else{var i,r,o;if(fn(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[p_]=!0,F_(this),!this._model||e){var a=new Cg(this._api),s=this._theme,l=this._model=new mg;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},Z_);var c={seriesTransition:o,optionChanged:!0};if(n)this[g_]={silent:i,updateParams:c},this[p_]=!1,this.getZr().wakeUp();else{try{w_(this),M_.update.call(this,null,c)}catch(t){throw this[g_]=null,this[p_]=!1,t}this._ssr||this._zr.flush(),this[g_]=null,this[p_]=!1,I_.call(this,i),A_.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[p_])if(this._disposed)this.id;else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[g_]&&(null==i&&(i=this[g_].silent),r=this[g_].updateParams,this[g_]=null),this[p_]=!0,F_(this);try{this._updateTheme(t),n.setTheme(this._theme),w_(this),M_.update.call(this,{type:"setTheme"},r)}catch(t){throw this[p_]=!1,t}this[p_]=!1,I_.call(this,i),A_.call(this,i)}}},e.prototype._updateTheme=function(t){hn(t)&&(t=Q_[t]),t&&((t=Xe(t))&&Gg(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||Te.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){return t=t||{},this._zr.painter.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){return t=t||{},this._zr.painter.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return en(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;en(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return en(i,function(t){t.group.ignore=!1}),o}this.id},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(ex[n]){var a=o,s=o,l=-1/0,c=-1/0,u=[],h=t&&t.pixelRatio||this.getDevicePixelRatio();en(tx,function(o,h){if(o.group===n){var d=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(Xe(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),c=r(p.bottom,c),u.push({dom:d,left:p.left,top:p.top})}});var d=(l*=h)-(a*=h),p=(c*=h)-(s*=h),f=Ee.createCanvas(),g=Ya(f,{renderer:e?"svg":"canvas"});if(g.resize({width:d,height:p}),e){var v="";return en(u,function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""}),g.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&g.painter.setBackgroundColor(t.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}return t.connectedBackgroundColor&&g.add(new Bc({shape:{x:0,y:0,width:d,height:p},style:{fill:t.connectedBackgroundColor}})),en(u,function(t){var e=new Lc({style:{x:t.left*h-a,y:t.top*h-s,image:t.dom}});g.add(e)}),g.refreshImmediately(),f.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}this.id},e.prototype.convertToPixel=function(t,e,n){return k_(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return k_(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return k_(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return en(Os(this._model,t),function(t,i){i.indexOf("Models")>=0&&en(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}},this)},this),!!n;this.id},e.prototype.getVisual=function(t,e){var n=Os(this._model,t,{defaultMainType:"series"}),i=n.seriesModel.getData(),r=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?i.indexOfRawIndex(n.dataIndex):null;return null!=r?function(t,e,n){switch(n){case"color":return t.getItemVisual(e,"style")[t.getVisual("drawType")];case"opacity":return t.getItemVisual(e,"style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getItemVisual(e,n)}}(i,r,e):function(t,e){switch(e){case"color":return t.getVisual("style")[t.getVisual("drawType")];case"opacity":return t.getVisual("style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getVisual(e)}}(i,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;en(G_,function(e){var n=function(n){var i,r=t.getModel(),o=n.target;if("globalout"===e?i={}:o&&by(o,function(t){var e=nu(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=Ze({},e.eventData),!0},!0),i){var a=i.componentType,s=i.componentIndex;"markLine"!==a&&"markPoint"!==a&&"markArea"!==a||(a="series",s=i.seriesIndex);var l=a&&null!=s&&r.getComponent(a,s),c=l&&t["series"===l.mainType?"_chartsMap":"_componentsMap"][l.__viewId];i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:l,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;en(X_,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(xy("map","selectchanged",e,i,t),xy("pie","selectchanged",e,i,t)):"select"===t.fromAction?(xy("map","selected",e,i,t),xy("pie","selected",e,i,t)):"unselect"===t.fromAction&&(xy("map","unselected",e,i,t),xy("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?this.id:this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)this.id;else{this._disposed=!0,this.getDom()&&Bs(this.getDom(),ix,"");var t=this,e=t._api,n=t._model;en(t._componentsViews,function(t){t.dispose(n,e)}),en(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete tx[t.id]}},e.prototype.resize=function(t){if(!this[p_])if(this._disposed)this.id;else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[g_]&&(null==i&&(i=this[g_].silent),n=!0,this[g_]=null),this[p_]=!0,F_(this);try{n&&w_(this),M_.update.call(this,{type:"resize",animation:Ze({duration:0},t&&t.animation)})}catch(t){throw this[p_]=!1,t}this[p_]=!1,I_.call(this,i),A_.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)this.id;else if(fn(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),J_[t]){var n=J_[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?this.id:(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=Ze({},t);return e.type=j_[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)this.id;else if(fn(e)||(e={silent:!!e}),q_[t.type]&&this._model)if(this[p_])this._pendingActions.push(t);else{var n=e.silent;D_.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&Te.browser.weChat&&this._throttledZrFlush(),I_.call(this,n),A_.call(this,n)}},e.prototype.updateLabelLayout=function(){l_.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)this.id;else{var e=t.seriesIndex;this.getModel().getSeriesByIndex(e).appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(fd(t))return;if(t instanceof Tc&&function(t){var e=ou(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}w_=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),S_(t,!0),S_(t,!1),e.plan()},S_=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!Te.node&&!Te.worker&&e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)})}})}(t,e),l_.trigger("series:afterupdate",e,o,s)},H_=function(t){t[v_]=!0,t.getZr().wakeUp()},F_=function(t){t[f_]=(t[f_]+1)%1e3},B_=function(t){t[v_]&&(t.getZr().storage.traverse(function(t){fd(t)||e(t)}),t[v_]=!1)},N_=function(t){return new(function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return _(n,e),n.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},n.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},n.prototype.enterEmphasis=function(e,n){Au(e,n),H_(t)},n.prototype.leaveEmphasis=function(e,n){Pu(e,n),H_(t)},n.prototype.enterBlur=function(e){!function(t){Cu(t,_u)}(e),H_(t)},n.prototype.leaveBlur=function(e){Lu(e),H_(t)},n.prototype.enterSelect=function(e){Eu(e),H_(t)},n.prototype.leaveSelect=function(e){zu(e),H_(t)},n.prototype.getModel=function(){return t.getModel()},n.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},n.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},n.prototype.getMainProcessVersion=function(){return t[f_]},n}(wg))(t)},R_=function(t){function e(t,e){for(var n=0;n=0)){hx.push(n);var o=Xm.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function px(t,e){J_[t]=e}var fx=function(t){var e=(t=Xe(t)).type;e||ys("");var n=e.split(":");2!==n.length&&ys("");var i=!1;"echarts"===n[0]&&(e=n[1],i=!0),t.__isBuiltIn=i,Lv.set(e,t)};function gx(t,e,n,i){return{eventContent:{selected:$u(n),isFromClick:e.isFromClick||!1}}}function vx(t){return null==t?0:t.length||1}function mx(t){return t}ux(u_,Wm),ux(h_,Gm),ux(h_,qm),ux(u_,yy),ux(h_,_y),ux(7e3,function(t,e){t.eachRawSeries(function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each(function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=r_(n,e))});var r=i.getVisual("decal");if(r)i.getVisual("style").decal=r_(r,e)}})}),ax(Gg),sx(900,function(t){var e=En();t.eachSeries(function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.push(o)}}),e.each(function(t){0!==t.length&&("seriesDesc"===(t[0].seriesModel.get("stackOrder")||"seriesAsc")&&t.reverse(),en(t,function(e,n){e.data.setCalculationInfo("stackedOnSeries",n>0?t[n-1].seriesModel:null)}),function(t){en(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,c,u){var h,d,p=a.get(e.stackedDimension,u);if(isNaN(p))return r;s?d=a.getRawIndex(u):h=a.get(e.stackedByDimension,u);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(d=v.data.rawIndexOf(v.stackedByDimension,h)),d>=0){var m=v.data.getByRawIndex(v.stackResultDimension,d);if("all"===l||"positive"===l&&m>0||"negative"===l&&m<0||"samesign"===l&&p>=0&&m>0||"samesign"===l&&p<=0&&m<0){p=os(p,m),f=m;break}}}return i[0]=p,i[1]=f,i})})}(t))})}),px("default",function(t,e){Ke(e=e||{},{text:"loading",textColor:Bf.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:Bf.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new Ua,i=new Bc({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new Wc({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new Bc({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new Xh({shape:{startAngle:-jm/2,endAngle:-jm/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*jm/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*jm/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),c=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:c}),a.setShape({x:l-s,y:c-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),cx({type:cu,event:cu,update:cu},Nn),cx({type:uu,event:uu,update:uu},Nn),cx({type:hu,event:fu,update:hu,action:Nn,refineEvent:gx,publishNonRefinedEvent:!0}),cx({type:du,event:fu,update:du,action:Nn,refineEvent:gx,publishNonRefinedEvent:!0}),cx({type:pu,event:fu,update:pu,action:Nn,refineEvent:gx,publishNonRefinedEvent:!0}),ox("default",{}),ox("dark",fy);var yx=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||mx,this._newKeyGetter=i||mx,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var c=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(c,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===h)this._updateManyToOne&&this._updateManyToOne(c,l),i[s]=null;else if(1===u&&h>1)this._updateOneToMany&&this._updateOneToMany(c,l),i[s]=null;else if(1===u&&1===h)this._update&&this._update(c,l),i[s]=null;else if(u>1&&h>1)this._updateManyToMany&&this._updateManyToMany(c,l),i[s]=null;else if(u>1)for(var d=0;d1)for(var a=0;a30}var Ax,Px,Lx,Ex,zx,Ox,Nx,Rx=fn,Hx=nn,Bx="undefined"==typeof Int32Array?Array:Int32Array,Fx=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],$x=["_approximateExtent"],Vx=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;kx(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},c=0;c=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===Yf&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(cn(r=this.getVisual(e))?r=r.slice():Rx(r)&&(r=Ze({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,Rx(e)?Ze(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){Rx(t)?Ze(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?Ze(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){!function(t,e,n,i){if(i){var r=nu(i);r.dataIndex=n,r.dataType=e,r.seriesIndex=t,r.ssrType="chart","group"===i.type&&i.traverse(function(i){var r=nu(i);r.seriesIndex=t,r.dataIndex=n,r.dataType=e,r.ssrType="chart"})}}(this.hostModel&&this.hostModel.seriesIndex,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){en(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:Hx(this.dimensions,this._getDimInfo,this),this.hostModel)),zx(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];un(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(Sn(arguments)))})},t.internalField=(Ax=function(t){var e=t._invertedIndicesMap;en(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new Bx(o.categories.length);for(var s=0;s1&&(s+="__ec__"+c),i[e]=s}})),t}();function Wx(t,e){Jg(t)||(t=ev(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=En(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return en(e,function(t){var e;fn(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Ix(a),l=i===t.dimensionsDefine,c=l?Dx(t):Tx(i),u=e.encodeDefine;!u&&e.encodeDefaulter&&(u=e.encodeDefaulter(t,a));for(var h=En(u),d=new Bv(a),p=0;p0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new Mx({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function Ux(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var Gx=function(t){this.coordSysDims=[],this.axisMap=En(),this.categoryAxisMap=En(),this.coordSysName=t};var qx={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",Rs).models[0],o=t.getReferringComponents("yAxis",Rs).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),jx(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),jx(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",Rs).models[0];e.coordSysDims=["single"],n.set("single",r),jx(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",Rs).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),jx(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),jx(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();en(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),jx(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",Rs).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function jx(t){return"category"===t.get("type")}function Xx(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!kx(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,c,u,h,d=!(!t||!t.get("stack"));if(en(i,function(t,e){hn(t)&&(i[e]=t={name:t}),d&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),c||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(c=t))}),!c||a||l||(a=!0),c){u="__\0ecstackresult_"+t.id,h="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var p=c.coordDim,f=c.type,g=0;en(i,function(t){t.coordDim===p&&g++});var v={name:u,coordDim:p,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},m={name:h,coordDim:h,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(v.storeDimIndex=o.ensureCalculationDimension(h,f),m.storeDimIndex=o.ensureCalculationDimension(u,f)),r.appendCalculationDimension(v),r.appendCalculationDimension(m)):(i.push(v),i.push(m))}return{stackedDimension:c&&c.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:h,stackResultDimension:u}}function Yx(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function Zx(t,e,n){n=n||{};var i,r,o=e.getSourceManager();r=(i=o.getSource()).sourceFormat===Yf;var a=function(t){var e=t.get("coordinateSystem"),n=new Gx(e),i=qx[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=_f.get(i);return e&&e.coordSysDims&&(n=nn(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=function(t){return"category"===t?"ordinal":"time"===t?"time":"float"}(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,c=un(l)?l:l?ln(sg,s,e):null,u=Wx(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:c,canOmitUnusedDimensions:!r}),h=function(t,e,n){var i,r;return n&&en(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(u.dimensions,n.createInvertedIndices,a),d=r?null:o.getSharedDataStore(u),p=Xx(e,{schema:u,store:d}),f=new Vx(u,e);f.setCalculationInfo(p);var g=null!=h&&function(t){if(t.sourceFormat===Yf){var e=function(t){var e=0;for(;er&&(a=o.interval=r);var s=o.intervalPrecision=tb(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),eb(t,0,e),eb(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(o.niceTickExtent=[is(Math.ceil(t[0]/a)*a,s),is(Math.floor(t[1]/a)*a,s)],t),o}function Jx(t){var e=Math.pow(10,us(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,is(n*e)}function tb(t){return rs(t)+2}function eb(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function nb(t,e){return t>=e[0]&&t<=e[1]}var ib=function(){function t(){this.normalize=rb,this.scale=ob}return t.prototype.updateMethods=function(t){t.hasBreaks()?(this.normalize=sn(t.normalize,t),this.scale=sn(t.scale,t)):(this.normalize=rb,this.scale=ob)},t}();function rb(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function ob(t,e){return t*(e[1]-e[0])+e[0]}function ab(t,e,n){var i=Math.log(t);return[Math.log(n?e[0]:Math.max(0,e[0]))/i,Math.log(n?e[1]:Math.max(0,e[1]))/i]}var sb=function(){function t(t){this._calculator=new ib,this._setting=t||{},this._extent=[1/0,-1/0]}return t.prototype.getSetting=function(t){return this._setting[t]},t.prototype._innerUnionExtent=function(t){var e=this._extent;this._innerSetExtent(t[0]e[1]?t[1]:e[1])},t.prototype.unionExtentFromData=function(t,e){this._innerUnionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){this._innerSetExtent(t,e)},t.prototype._innerSetExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e),this._brkCtx&&this._brkCtx.update(n)},t.prototype.setBreaksFromOption=function(t){},t.prototype._innerSetBreak=function(t){this._brkCtx&&(this._brkCtx.setBreaks(t),this._calculator.updateMethods(this._brkCtx),this._brkCtx.update(this._extent))},t.prototype._innerGetBreaks=function(){return this._brkCtx?this._brkCtx.breaks:[]},t.prototype.hasBreaks=function(){return!!this._brkCtx&&this._brkCtx.hasBreaks()},t.prototype._getExtentSpanWithBreaks=function(){return this._brkCtx&&this._brkCtx.hasBreaks()?this._brkCtx.getExtentSpan():this._extent[1]-this._extent[0]},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();Xs(sb);var lb=0,cb=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++lb,this._onCollect=t.onCollect}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&nn(i,ub);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!hn(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,this._onCollect&&this._onCollect(t,e),e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e),this._onCollect&&this._onCollect(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=En(this.categories))},t}();function ub(t){return fn(t)&&null!=t.value?t.value:t+""}var hb=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new cb({})),cn(i)&&(i=new cb({categories:nn(i,function(t){return fn(t)?t.value:t})})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return _(e,t),e.prototype.parse=function(t){return null==t?NaN:hn(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return nb(t,this._extent)&&t>=0&&t=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(sb);sb.registerClass(hb);var db=is,pb=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return _(e,t),e.prototype.parse=function(t){return null==t||""===t?NaN:Number(t)},e.prototype.contain=function(t){return nb(t,this._extent)},e.prototype.normalize=function(t){return this._calculator.normalize(t,this._extent)},e.prototype.scale=function(t){return this._calculator.scale(t,this._extent)},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=tb(t)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;t.breakTicks;n[0]=0&&(s=db(s+l*e,r))}if(o.length>0&&s===o[o.length-1].value)break;if(o.length>1e4)return[]}var c=o.length?o[o.length-1].value:i[1];return n[1]>c&&(t.expandToNicedExtent?o.push({value:db(c+e,r)}):o.push({value:n[1]})),t.breakTicks,o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks({expandToNicedExtent:!0}),n=[],i=this.getExtent(),r=1;ri[0]&&h0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return en(t,function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),c=r.scale.getExtent(),u=Math.abs(c[1]-c[0]);i=s?l/u*s:l}else{var h=t.getData();i=Math.abs(o[1]-o[0])/h.count()}var d=es(t.get("barWidth"),i),p=es(t.get("barMaxWidth"),i),f=es(t.get("barMinWidth")||(function(t){return t.pipelineContext&&t.pipelineContext.large}(t)?.5:1),i),g=t.get("barGap"),v=t.get("barCategoryGap"),m=t.get("defaultBarGap");n.push({bandWidth:i,barWidth:d,barMaxWidth:p,barMinWidth:f,barGap:g,barCategoryGap:v,defaultBarGap:m,axisKey:yb(r),stackId:mb(t)})}),function(t){var e={};en(t,function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:t.defaultBarGap||0,stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var c=t.barMaxWidth;c&&(a[s].maxWidth=c);var u=t.barMinWidth;u&&(a[s].minWidth=u);var h=t.barGap;null!=h&&(o.gap=h);var d=t.barCategoryGap;null!=d&&(o.categoryGap=d)});var n={};return en(e,function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=an(i).length;o=Math.max(35-4*a,15)+"%"}var s=es(o,r),l=es(t.gap,1),c=t.remainedWidth,u=t.autoWidthCount,h=(c-s)/(u+(u-1)*l);h=Math.max(h,0),en(i,function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,c-=i+l*i,u--}else{var i=h;e&&ei&&(i=n),i!==h&&(t.width=i,c-=i+l*i,u--)}}),h=(c-s)/(u+(u-1)*l),h=Math.max(h,0);var d,p=0;en(i,function(t,e){t.width||(t.width=h),d=t,p+=t.width*(1+l)}),d&&(p-=d.width*l);var f=-p/2;en(i,function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)})}),n}(n)}var xb=function(t){function e(e){var n=t.call(this,e)||this;return n.type="time",n}return _(e,t),e.prototype.getLabel=function(t){var e=this.getSetting("useUTC");return Gp(t.value,Hp[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(Wp(this._minLevelUnit))]||Hp.second,e,this.getSetting("locale"))},e.prototype.getFormattedLabel=function(t,e,n){var i=this.getSetting("useUTC");return function(t,e,n,i,r){var o=null;if(hn(n))o=n;else if(un(n)){var a={time:t.time,level:t.time.level},s=null;s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var c=n[l.lowerTimeUnit][l.upperTimeUnit];o=c[Math.min(l.level,c.length-1)]||""}else{var u=qp(t.value,r);o=n[u][u][0]}}return Gp(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=[];if(!e)return i;var r=this.getSetting("useUTC"),o=qp(n[1],r);i.push({value:n[0],time:{level:0,upperTimeUnit:o,lowerTimeUnit:o}});var a=function(t,e,n,i,r,o){var a=1e4,s=Fp,l=0;function c(t,e,n,r,s,c,u){for(var h=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),d=e,p=new Date(d);da));)if(p[s](p[r]()+t),d=p.getTime(),o){var f=o.calcNiceTickMultiple(d,h);f>0&&(p[s](p[r]()+f*t),d=p.getTime())}u.push({value:d,notAdd:!0})}function u(t,r,o){var a=[],s=!r.length;if(!function(t,e,n,i){return jp(new Date(e),t,i).getTime()===jp(new Date(n),t,i).getTime()}(Wp(t),i[0],i[1],n)){s&&(r=[{value:Tb(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&u<=i[1]&&c(d,u,h,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-d})}}for(l=0;l=i[0]&&_<=i[1]&&p++)}var x=r/e;if(p>1.5*x&&f>x/1.5)break;if(h.push(m),p>x||t===s[g])break}d=[]}}var b=on(nn(h,function(t){return on(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=[],S=b.length-1;for(g=0;gn&&(this._approxInterval=n);var r=bb.length,o=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Sb(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function Cb(t){return(t/=Lp)>12?12:t>6?6:t>3.5?4:t>2?2:1}function Mb(t,e){return(t/=e?Pp:Ap)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function kb(t){return hs(t)}function Tb(t,e,n){var i=Math.max(0,Qe(Bp,e)-1);return jp(new Date(t),Bp[i],n).getTime()}sb.registerClass(xb);var Db=is,Ib=Math.floor,Ab=Math.ceil,Pb=Math.pow,Lb=Math.log,Eb=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new pb,e}return _(e,t),e.prototype.getTicks=function(e){e=e||{};var n=this._extent.slice(),i=this._originalScale.getExtent(),r=t.prototype.getTicks.call(this,e),o=this.base;return this._originalScale._innerGetBreaks(),nn(r,function(t){var e=t.value,r=null,a=Pb(o,e);return e===n[0]&&this._fixMin?r=i[0]:e===n[1]&&this._fixMax&&(r=i[1]),null!=r&&(a=zb(a,r)),{value:a,break:void 0}},this)},e.prototype._getNonTransBreaks=function(){return this._originalScale._innerGetBreaks()},e.prototype.setExtent=function(e,n){this._originalScale.setExtent(e,n);var i=ab(this.base,[e,n]);t.prototype.setExtent.call(this,i[0],i[1])},e.prototype.getExtent=function(){var e=this.base,n=t.prototype.getExtent.call(this);n[0]=Pb(e,n[0]),n[1]=Pb(e,n[1]);var i=this._originalScale.getExtent();return this._fixMin&&(n[0]=zb(n[0],i[0])),this._fixMax&&(n[1]=zb(n[1],i[1])),n},e.prototype.unionExtentFromData=function(t,e){this._originalScale.unionExtentFromData(t,e);var n=ab(this.base,t.getApproximateExtent(e),!0);this._innerUnionExtent(n)},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent.slice(),n=this._getExtentSpanWithBreaks();if(isFinite(n)&&!(n<=0)){var i,r=(i=n,Math.pow(10,us(i)));for(t/n*r<=.5&&(r*=10);!isNaN(r)&&Math.abs(r)<1&&Math.abs(r)>0;)r*=10;var o=[Db(Ab(e[0]/r)*r),Db(Ib(e[1]/r)*r)];this._interval=r,this._intervalPrecision=tb(r),this._niceExtent=o}},e.prototype.calcNiceExtent=function(e){t.prototype.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},e.prototype.contain=function(e){return e=Lb(e)/Lb(this.base),t.prototype.contain.call(this,e)},e.prototype.normalize=function(e){return e=Lb(e)/Lb(this.base),t.prototype.normalize.call(this,e)},e.prototype.scale=function(e){return e=t.prototype.scale.call(this,e),Pb(this.base,e)},e.prototype.setBreaksFromOption=function(t){},e.type="log",e}(pb);function zb(t,e){return Db(t,rs(e))}sb.registerClass(Eb);var Ob=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!c&&(s=0));var h=this._determinedMin,d=this._determinedMax;return null!=h&&(a=h,l=!0),null!=d&&(s=d,c=!0),{min:a,max:s,minFixed:l,maxFixed:c,isBlank:u}},t.prototype.modifyDataMinMax=function(t,e){this[Rb[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){this[Nb[t]]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),Nb={min:"_determinedMin",max:"_determinedMax"},Rb={min:"_dataMin",max:"_dataMax"};function Hb(t,e){return null==e?null:_n(e)?NaN:t.parse(e)}function Bb(t,e){var n=t.type,i=function(t,e,n){var i=t.rawExtentInfo;return i||(i=new Ob(t,e,n),t.rawExtentInfo=i,i)}(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=function(t,e){var n=[];return e.eachSeriesByType(t,function(t){(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type})(t)&&n.push(t)}),n}("bar",a),l=!1;if(en(s,function(t){l=l||t.getBaseAxis()===e.axis}),l){var c=_b(s),u=function(t,e,n,i){var r=n.axis.getExtent(),o=Math.abs(r[1]-r[0]),a=function(t,e){if(t&&e)return t[yb(e)]}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;en(a,function(t){s=Math.min(t.offset,s)});var l=-1/0;en(a,function(t){l=Math.max(t.offset+t.width,l)}),s=Math.abs(s),l=Math.abs(l);var c=s+l,u=e-t,h=u/(1-(s+l)/o)-u;return e+=h*(l/c),t-=h*(s/c),{min:t,max:e}}(r,o,e,c);r=u.min,o=u.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function Fb(t,e){var n=e,i=Bb(t,n),r=i.extent,o=n.get("splitNumber");t instanceof Eb&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setBreaksFromOption(Xb(n)),t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function $b(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=$p(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(hn(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(un(e)){if("category"===t.type)return function(n,i){return e(Vb(t,n),n.value-t.scale.getExtent()[0],null)};var i=null;return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(Vb(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function Vb(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function Wb(t){var e=t.get("interval");return null==e?"auto":e}function Ub(t){return"category"===t.type&&0===Wb(t.getLabelModel())}function Gb(t,e){var n={};return en(t.mapDimensionsAll(e),function(e){n[function(t,e){return Yx(t,e)?t.getCalculationInfo("stackResultDimension"):e}(t,e)]=!0}),an(n)}function qb(t){return"middle"===t||"center"===t}function jb(t){return t.getShallow("show")}function Xb(t){t.get("breaks",!0)}var Yb=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),Zb=[],Kb={registerPreprocessor:ax,registerProcessor:sx,registerPostInit:function(t){lx("afterinit",t)},registerPostUpdate:function(t){lx("afterupdate",t)},registerUpdateLifecycle:lx,registerAction:cx,registerCoordinateSystem:function(t,e){_f.register(t,e)},registerLayout:function(t,e){dx(K_,t,e,1e3,"layout")},registerVisual:ux,registerTransform:fx,registerLoading:px,registerMap:function(t,e,n){var i=c_["registerMap"];i&&i(t,e,n)},registerImpl:function(t,e){c_[t]=e},PRIORITY:d_,ComponentModel:Hf,ComponentView:wm,SeriesModel:fm,ChartView:km,registerComponentModel:function(t){Hf.registerClass(t)},registerComponentView:function(t){wm.registerClass(t)},registerSeriesModel:function(t){fm.registerClass(t)},registerChartView:function(t){km.registerClass(t)},registerCustomSeries:function(t,e){},registerSubTypeDefaulter:function(t,e){Hf.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){var n;n=e,Ga[t]=n}};function Qb(t){cn(t)?en(t,function(t){Qb(t)}):Qe(Zb,t)>=0||(Zb.push(t),un(t)&&(t={install:t}),t.install(Kb))}var Jb=Es(),tw=Es(),ew=1,nw=2;function iw(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function rw(t,e){var n=nn(e,function(e){return t.scale.parse(e)});return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function ow(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=$b(t),r=t.scale.getExtent();return{labels:nn(on(rw(t,n),function(t){return t>=r[0]&&t<=r[1]}),function(e){var n={value:e};return{formattedLabel:i(n),rawLabel:t.scale.getLabel(n),tickValue:e,time:void 0,break:void 0}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=sw(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=$b(t);return{labels:nn(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value,time:e.time,break:e.break}})}}(t)}function aw(t,e,n){var i=t.getTickModel().get("customValues");if(i){var r=t.scale.getExtent();return{ticks:on(rw(t,i),function(t){return t>=r[0]&&t<=r[1]})}}return"category"===t.type?function(t,e){var n,i,r=lw(t),o=Wb(e),a=hw(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(un(o))n=vw(t,o,!0);else if("auto"===o){var s=sw(t,t.getLabelModel(),iw(nw));i=s.labelCategoryInterval,n=nn(s.labels,function(t){return t.tickValue})}else n=gw(t,i=o,!0);return dw(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:nn(t.scale.getTicks(n),function(t){return t.value})}}function sw(t,e,n){var i,r,o=cw(t),a=Wb(e),s=n.kind===ew;if(!s){var l=hw(o,a);if(l)return l}un(a)?i=vw(t,a):(r="auto"===a?function(t,e){if(e.kind===ew){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return tw(t).autoInterval=n,!0}),n}var i=tw(t).autoInterval;return null!=i?i:tw(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=gw(t,r));var c={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return dw(o,a,c),!0}):dw(o,a,c),c}var lw=uw("axisTick"),cw=uw("axisLabel");function uw(t){return function(e){return tw(e)[t]||(tw(e)[t]={list:[]})}}function hw(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function gw(t,e,n){var i=$b(t),r=t.scale,o=r.getExtent(),a=t.getLabelModel(),s=[],l=Math.max((e||0)+1,1),c=o[0],u=r.count();0!==c&&l>1&&u/l>2&&(c=Math.round(Math.ceil(c/l)*l));var h=Ub(t),d=a.get("showMinLabel")||h,p=a.get("showMaxLabel")||h;d&&c!==o[0]&&g(o[0]);for(var f=c;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t,time:void 0,break:void 0})}return p&&f-l!==o[1]&&g(o[1]),s}function vw(t,e,n){var i=t.scale,r=$b(t),o=[];return en(i.getTicks(),function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s,time:void 0,break:void 0})}),o}var mw=[0,1],yw=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return function(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(Ja(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(i.parse(t)),this.onBand&&"ordinal"===i.type&&_w(n=n.slice(),i.count()),ts(t,mw,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&_w(n=n.slice(),i.count());var r=ts(t,n,mw,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=nn(aw(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}},this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],e[0].onBand=!0,o=e[1]={coord:s[1],tickValue:e[0].tickValue,onBand:!0};else{var l=e[r-1].tickValue-e[0].tickValue,c=(e[r-1].coord-e[0].coord)/l;en(e,function(t){t.coord-=c/2,t.onBand=!0});var u=t.scale.getExtent();a=1+u[1]-e[r-1].tickValue,o={coord:e[r-1].coord+c*a,tickValue:u[1]+1,onBand:!0},e.push(o)}var h=s[0]>s[1];d(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&d(s[0],e[0].coord)&&e.unshift({coord:s[0],onBand:!0});d(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&d(o.coord,s[1])&&e.push({coord:s[1],onBand:!0});function d(t,e){return t=is(t),e=is(e),h?t>e:t0&&t<100||(t=5),nn(this.scale.getMinorTicks(t),function(t){return nn(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return ow(this,t=t||iw(nw)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=$b(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var c=1;l>40&&(c=Math.max(1,Math.floor(l/40)));for(var u=s[0],h=t.dataToCoord(u+1)-t.dataToCoord(u),d=Math.abs(h*Math.cos(o)),p=Math.abs(h*Math.sin(o)),f=0,g=0;u<=s[1];u+=c){var v,m,y=Ta(r({value:u}),i.font,"center","top");v=1.3*y.width,m=1.3*y.height,f=Math.max(f,v,7),g=Math.max(g,m,7)}var _=f/d,x=g/p;isNaN(_)&&(_=1/0),isNaN(x)&&(x=1/0);var b=Math.max(0,Math.floor(Math.min(_,x)));if(n===ew)return e.out.noPxChangeTryDetermine.push(sn(pw,null,t,b,l)),b;var w=fw(t,b,l);return null!=w?w:b}(this,t=t||iw(nw))},t}();function _w(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var xw=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"];function bw(t,e,n){n=n||3,e?t.dirty|=n:t.dirty&=~n}function ww(t,e){return e=e||3,null==t.dirty||!!(t.dirty&e)}function Sw(t){if(t)return ww(t)&&function(t,e,n){var i=e.getComputedTransform();t.transform=Ud(t.transform,i);var r=t.localRect=Wd(t.localRect,e.getBoundingRect()),o=e.style,a=o.margin,s=n&&n.marginForce,l=n&&n.minMarginForce,c=n&&n.marginDefault,u=o.__marginType;null==u&&c&&(a=c,u=lp.textMargin);for(var h=0;h<4;h++)Cw[h]=u===lp.minMargin&&l&&null!=l[h]?l[h]:s&&null!=s[h]?s[h]:a?a[h]:0;u===lp.textMargin&&Od(r,Cw,!1,!1);var d=t.rect=Wd(t.rect,r);i&&d.applyTransform(i);u===lp.minMargin&&Od(d,Cw,!1,!1);t.axisAligned=$d(i),(t.label=t.label||{}).ignore=e.ignore,bw(t,!1),bw(t,!0,2)}(t,t.label,t),t}var Cw=[0,0,0,0];function Mw(t,e){for(var n=0;n-1&&(s.style.stroke=s.style.fill,s.style.fill=Bf.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(fm);function Iw(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=yv(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a0?+d:1;k.scaleX=this._sizeX*T,k.scaleY=this._sizeY*T,this.setSymbolScale(1),Wu(this,l,c,u)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=nu(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&gd(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();gd(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return cn(n=t.getItemVisual(e,"symbolSize"))||(n=[+n,+n]),[n[0]||0,n[1]||0];var n},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(Ua);function Pw(t,e){this.parent.drift(t,e)}function Lw(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function Ew(t){return null==t||fn(t)||(t={isIgnore:t}),t||{}}function zw(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:Qd(e),cursorStyle:e.get("cursor")}}var Ow=function(){function t(t){this.group=new Ua,this._SymbolCtor=t||Aw}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=Ew(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=zw(t),l={disableAnimation:a},c=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=c(i);if(Lw(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}}).update(function(u,h){var d=r.getItemGraphicEl(h),p=c(u);if(Lw(t,p,u,e)){var f=t.getItemVisual(u,"symbol")||"circle",g=d&&d.getSymbolType&&d.getSymbolType();if(!d||g&&g!==f)n.remove(d),(d=new o(t,u,s,l)).setPosition(p);else{d.updateData(t,u,s,l);var v={x:p[0],y:p[1]};a?d.attr(v):dd(d,v,i)}n.add(d),t.setItemGraphicEl(u,d)}else n.remove(d)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=c,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()})},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=zw(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=Ew(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),c=e.mapDimension(a),u="x"===s||"radius"===s?1:0,h=nn(t.dimensions,function(t){return e.mapDimension(t)}),d=!1,p=e.getCalculationInfo("stackResultDimension");return Yx(e,h[0])&&(d=!0,h[0]=p),Yx(e,h[1])&&(d=!0,h[1]=p),{dataDimsForPoint:h,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!d,valueDim:l,baseDim:c,baseDataOffset:u,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function Rw(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var Hw=Math.min,Bw=Math.max;function Fw(t,e){return isNaN(t)||isNaN(e)}function $w(t,e,n,i,r,o,a,s,l){for(var c,u,h,d,p,f,g=n,v=0;v=r||g<0)break;if(Fw(m,y)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](m,y),h=m,d=y;else{var _=m-c,x=y-u;if(_*_+x*x<.5){g+=o;continue}if(a>0){for(var b=g+o,w=e[2*b],S=e[2*b+1];w===m&&S===y&&v=i||Fw(w,S))p=m,f=y;else{k=w-c,T=S-u;var A=m-c,P=w-m,L=y-u,E=S-y,z=void 0,O=void 0;if("x"===s){var N=k>0?1:-1;p=m-N*(z=Math.abs(A))*a,f=y,D=m+N*(O=Math.abs(P))*a,I=y}else if("y"===s){var R=T>0?1:-1;p=m,f=y-R*(z=Math.abs(L))*a,D=m,I=y+R*(O=Math.abs(E))*a}else z=Math.sqrt(A*A+L*L),p=m-k*a*(1-(M=(O=Math.sqrt(P*P+E*E))/(O+z))),f=y-T*a*(1-M),I=y+T*a*M,D=Hw(D=m+k*a*M,Bw(w,m)),I=Hw(I,Bw(S,y)),D=Bw(D,Hw(w,m)),f=y-(T=(I=Bw(I,Hw(S,y)))-y)*z/O,p=Hw(p=m-(k=D-m)*z/O,Bw(c,m)),f=Hw(f,Bw(u,y)),D=m+(k=m-(p=Bw(p,Hw(c,m))))*O/z,I=y+(T=y-(f=Bw(f,Hw(u,y))))*O/z}t.bezierCurveTo(h,d,p,f,m,y),h=D,d=I}else t.lineTo(m,y)}c=m,u=y,g+=o}return v}var Vw=function(){this.smooth=0,this.smoothConstraint=!0},Ww=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return _(e,t),e.prototype.getDefaultStyle=function(){return{stroke:Bf.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new Vw},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&Fw(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(u-i)*g+i:(c-n)*g+n;return a?[t,v]:[v,t]}n=c,i=u;break;case o.C:c=r[l++],u=r[l++],h=r[l++],d=r[l++],p=r[l++],f=r[l++];var m=a?Tr(n,c,h,p,t,s):Tr(i,u,d,f,t,s);if(m>0)for(var y=0;y=0){v=a?Mr(i,u,d,f,_):Mr(n,c,h,p,_);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(Tc),Uw=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e}(Vw),Gw=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return _(e,t),e.prototype.getDefaultShape=function(){return new Uw},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&Fw(n[2*o-2],n[2*o-1]);o--);for(;r=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=to(e[r]),s=to(e[o]),l=i-r,c=ro([Wr(Xr(a[0],s[0],l)),Wr(Xr(a[1],s[1],l)),Wr(Xr(a[2],s[2],l)),Ur(Xr(a[3],s[3],l))],"rgba");return n?{color:c,leftIndex:r,rightIndex:o,value:i}:c}}((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}function Qw(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return en(o.getViewLabels(),function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function Jw(t,e){return isNaN(t)||isNaN(e)}function tS(t,e){return[t[2*e],t[2*e+1]]}function eS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),c=nn(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),u=c.length,h=o.outerColors.slice();u&&c[0].coord>c[u-1].coord&&(c.reverse(),h.reverse());var d=Kw(c,"x"===r?n.getWidth():n.getHeight()),p=d.length;if(!p&&u)return c[0].coord<0?h[1]?h[1]:c[u-1].color:h[0]?h[0]:c[0].color;var f=d[0].coord-10,g=d[p-1].coord+10,v=g-f;if(v<.001)return"transparent";en(d,function(t){t.offset=(t.coord-f)/v}),d.push({offset:p?d[p-1].offset:.5,color:h[1]||"transparent"}),d.unshift({offset:p?d[0].offset:.5,color:h[0]||"transparent"});var m=new Kh(0,0,0,0,d,!0);return m[r]=f,m[r+"2"]=g,m}}}(o,i,n)||o.getVisual("style")[o.getVisual("drawType")];if(d&&u.type===i.type&&M===this._step){v&&!p?p=this._newPolygon(l,_):p&&!v&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,vf(k));var T=f.getClipPath();if(T)pd(T,{shape:nS(this,i,!1,t).shape},t);else f.setClipPath(nS(this,i,!0,t));x&&h.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),qw(this._stackedOnPoints,_)&&qw(this._points,l)||(g?this._doUpdateAnimation(o,_,i,n,M,m,b):(M&&(_&&(_=Zw(_,l,i,M,b)),l=Zw(l,null,i,M,b)),d.setShape({points:l}),p&&p.setShape({points:l,stackedOnPoints:_})))}else x&&h.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),g&&this._initSymbolLabelAnimation(o,i,C),M&&(_&&(_=Zw(_,l,i,M,b)),l=Zw(l,null,i,M,b)),d=this._newPolyline(l),v?p=this._newPolygon(l,_):p&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,vf(k)),f.setClipPath(nS(this,i,!0,t));var D=t.getModel("emphasis"),I=D.get("focus"),A=D.get("blurScope"),P=D.get("disabled");(d.useStyle(Ke(a.getLineStyle(),{fill:"none",stroke:k,lineJoin:"bevel"})),qu(d,t,"lineStyle"),d.style.lineWidth>0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(d.getState("emphasis").style.lineWidth=+d.style.lineWidth+1);nu(d).seriesIndex=t.seriesIndex,Wu(d,I,A,P);var L=Yw(t.get("smooth")),E=t.get("smoothMonotone");if(d.setShape({smooth:L,smoothMonotone:E,connectNulls:b}),p){var z=o.getCalculationInfo("stackedOnSeries"),O=0;p.useStyle(Ke(s.getAreaStyle(),{fill:k,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),z&&(O=Yw(z.get("smooth"))),p.setShape({smooth:L,stackedOnSmooth:O,smoothMonotone:E,connectNulls:b}),qu(p,t,"areaStyle"),nu(p).seriesIndex=t.seriesIndex,Wu(p,I,A,P)}var N=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=N)}),this._polyline.onHoverStateChange=N,this._data=o,this._coordSys=i,this._stackedOnPoints=_,this._points=l,this._step=M,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,d),p&&this.packEventData(t,p))},e.prototype.packEventData=function(t,e){nu(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=Ls(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],c=a[2*o+1];if(isNaN(l)||isNaN(c))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,c))return;var u=t.get("zlevel")||0,h=t.get("z")||0;(s=new Aw(r,o)).x=l,s.y=c,s.setZ(u,h);var d=s.getSymbolPath().getTextContent();d&&(d.zlevel=u,d.z=h,d.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else km.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=Ls(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else km.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;Mu(this._polyline,t),e&&Mu(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new Ww({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new Gw({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");un(l)&&(l=l(null));var c=s.get("animationDelay")||0,u=un(c)?c(null):c;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var h=[t.x,t.y],d=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(h);i?(d=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(d=g.r0,p=g.r,f=v[0])}else{var m=n;i?(d=m.x,p=m.x+m.width,f=t.x):(d=m.y+m.height,p=m.y,f=t.y)}var y=p===d?0:(f-d)/(p-d);a&&(y=1-y);var _=un(c)?c(o):l*y+u,x=s.getSymbolPath(),b=x.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:_}),b&&b.animateFrom({style:{opacity:0}},{duration:300,delay:_}),x.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(eS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new Wc({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&Jw(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(Kd(o,Qd(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?function(t,e){var n=t.mapDimensionsAll("defaultedLabel");if(!cn(e))return e+"";for(var i=[],r=0;r=0&&i.push(e[o])}return i.join(" ")}(r,n):Iw(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var c=n.getLayout("points"),u=n.hostModel,h=u.get("connectNulls"),d=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,m=e.shape,y=v?g?m.x:m.y+m.height:g?m.x+m.width:m.y,_=(g?p:0)*(v?-1:1),x=(g?0:-p)*(v?-1:1),b=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,c=0;c=e||i>=e&&r<=e){l=c;break}s=c,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(c,y,b),S=w.range,C=S[1]-S[0],M=void 0;if(C>=1){if(C>1&&!h){var k=tS(c,S[0]);s.attr({x:k[0]+_,y:k[1]+x}),r&&(M=u.getRawValue(S[0]))}else{(k=l.getPointOn(y,b))&&s.attr({x:k[0]+_,y:k[1]+x});var T=u.getRawValue(S[0]),D=u.getRawValue(S[1]);r&&(M=function(t,e,n,i,r){var o=null==e||"auto"===e;if(null==i)return i;if(pn(i))return is(f=_s(n||0,i,r),o?Math.max(rs(n||0),rs(i)):e);if(hn(i))return r<1?n:i;for(var a=[],s=n,l=i,c=Math.max(s?s.length:0,l.length),u=0;u0?S[0]:0;k=tS(c,I);r&&(M=u.getRawValue(I)),s.attr({x:k[0]+_,y:k[1]+x})}if(r){var A=sp(s);"function"==typeof A.setLabelText&&A.setLabelText(M)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,c=t.hostModel,u=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],c=[],u=[],h=[],d=[],p=[],f=[],g=Nw(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],y=0;y3e3||l&&Xw(d,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=u.current,s.shape.points=h;var g={shape:{points:p}};u.current!==h&&(g.shape.__points=u.next),s.stopAnimation(),dd(s,g,c),l&&(l.setShape({points:h,stackedOnPoints:d}),l.stopAnimation(),dd(l,{shape:{stackedOnPoints:f}},c),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],m=u.status,y=0;ye&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(yw),wS="expandAxisBreak",SS=Math.PI,CS=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],MS=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],kS=Es(),TS=Es(),DS=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var IS=[1,0,0,1,0,0],AS=new $i(0,0,0,0),PS=function(t,e,n,i,r,o){if(qb(t.nameLocation)){var a=o.stOccupiedRect;a&&LS(function(t,e,n){return t.transform=Ud(t.transform,n),t.localRect=Wd(t.localRect,e),t.rect=Wd(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=$d(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else ES(o.labelInfoList,o.dirVec,i,r)};function LS(t,e,n){var i=new Ti;Tw(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&function(t,e){if(t){t.label.x+=e.x,t.label.y+=e.y,t.label.markRedraw();var n=t.transform;n&&(n[4]+=e.x,n[5]+=e.y);var i=t.rect;i&&(i.x+=e.x,i.y+=e.y);var r=t.obb;r&&r.fromBoundingRect(t.localRect,n)}}(e,i)}function ES(t,e,n,i){for(var r=Ti.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):ss(o-SS)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),OS=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],NS={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),c=o.transform,u=[l[0],0],h=[l[1],0],d=u[0]>h[0];c&&(jn(u,u,c),jn(h,h,c));var p=Ze({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&i.axis.scale.hasBreaks())null.buildAxisBreakLine(i,r,o,f);else{var g=new Vh(Ze({shape:{x1:u[0],y1:u[1],x2:h[0],y2:h[1]}},f));Td(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var m=i.get(["axisLine","symbolSize"]);hn(v)&&(v=[v,v]),(hn(m)||pn(m))&&(m=[m,m]);var y=zy(i.get(["axisLine","symbolOffset"])||0,m),_=m[0],x=m[1];en([{rotate:t.rotation+Math.PI/2,offset:y[0],r:0},{rotate:t.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((u[0]-h[0])*(u[0]-h[0])+(u[1]-h[1])*(u[1]-h[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=Ey(v[n],-_/2,-x/2,_,x,p.stroke,!0),o=e.r+e.offset,a=d?h:u;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){FS(e,r,s)&&RS(t,e,n,i,r,o,a,ew)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){FS(e,r,s)&&RS(t,e,n,i,r,o,a,nw);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),c=BS(r.getTicksCoords(),n.transform,l,Ke(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),u=0;ui[1],l="start"===e&&!s||"start"!==e&&s;ss(a-SS/2)?(o=l?"bottom":"top",r="center"):ss(a-1.5*SS)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*SS&&a>SS/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,u,b||0,f),null!=(x=t.raw.axisNameAvailableWidth)&&(x=Math.abs(x/Math.sin(_.rotation)),!isFinite(x)&&(x=null)));var w=d.getFont(),S=i.get("nameTruncate",!0)||{},C=S.ellipsis,M=xn(t.raw.nameTruncateMaxWidth,S.maxWidth,x),k=s.nameMarginLevel||0,T=new Wc({x:v.x,y:v.y,rotation:_.rotation,silent:zS.isLabelSilent(i),style:Jd(d,{text:c,font:w,overflow:"truncate",width:M,ellipsis:C,fill:d.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:d.get("align")||_.textAlign,verticalAlign:d.get("verticalAlign")||_.textVerticalAlign}),z2:1});if(Hd({el:T,componentModel:i,itemName:c}),T.__fullText=c,T.anid="name",i.get("triggerEvent")){var D=zS.makeAxisEventDataBase(i);D.targetType="axisName",D.name=c,nu(T).eventData=D}o.add(T),T.updateTransform(),e.nameEl=T;var I=l.nameLayout=Sw({label:T,priority:T.z2,defaultAttr:{ignore:T.ignore},marginDefault:qb(u)?CS[k]:MS[k]});if(l.nameLocation=u,r.add(T),T.decomposeTransform(),t.shouldNameMoveOverlap&&I){var A=n.ensureRecord(i);n.resolveAxisNameOverlap(t,n,i,I,m,A)}}}};function RS(t,e,n,i,r,o,a,s){$S(e)||function(t,e,n,i,r,o){var a=r.axis,s=xn(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new Ua;n.add(l);var c=iw(i);if(!s||a.scale.isBlank())return void VS(e,[],l,c);var u=r.getModel("axisLabel"),h=a.getViewLabels(c),d=(xn(t.raw.labelRotate,u.get("rotate"))||0)*SS/180,p=zS.innerTextLayout(t.rotation,d,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),m=1/0,y=-1/0;en(h,function(t,e){var n,i="ordinal"===a.scale.type?a.scale.getRawOrdinalNumber(t.tickValue):t.tickValue,s=t.formattedLabel,c=t.rawLabel,d=u;if(f&&f[i]){var _=f[i];fn(_)&&_.textStyle&&(d=new _p(_.textStyle,u,r.ecModel))}var x=d.getTextColor()||r.get(["axisLine","lineStyle","color"]),b=d.getShallow("align",!0)||p.textAlign,w=bn(d.getShallow("alignMinLabel",!0),b),S=bn(d.getShallow("alignMaxLabel",!0),b),C=d.getShallow("verticalAlign",!0)||d.getShallow("baseline",!0)||p.textVerticalAlign,M=bn(d.getShallow("verticalAlignMinLabel",!0),C),k=bn(d.getShallow("verticalAlignMaxLabel",!0),C),T=10+((null===(n=t.time)||void 0===n?void 0:n.level)||0);m=Math.min(m,T),y=Math.max(y,T);var D=new Wc({x:0,y:0,rotation:0,silent:zS.isLabelSilent(r),z2:T,style:Jd(d,{text:s,align:0===e?w:e===h.length-1?S:b,verticalAlign:0===e?M:e===h.length-1?k:C,fill:un(x)?x("category"===a.type?c:"value"===a.type?i+"":i,e):x})});D.anid="label_"+i;var I=kS(D);if(I.break=t.break,I.tickValue=i,I.layoutRotation=p.rotation,Hd({el:D,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return D.isTruncated},value:c,tickIndex:e}}),v){var A=zS.makeAxisEventDataBase(r);A.targetType="axisLabel",A.value=c,A.tickIndex=e,t.break&&(A.break={start:t.break.parsedBreak.vmin,end:t.break.parsedBreak.vmax}),"category"===a.type&&(A.dataIndex=i),nu(D).eventData=A,t.break&&function(t,e,n,i){n.on("click",function(n){var r={type:wS,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,D,t.break)}g.push(D),l.add(D)});var _=nn(g,function(t){return{label:t,priority:kS(t).break?t.z2+(y-m+1):t.z2,defaultAttr:{ignore:t.ignore}}});VS(e,_,l,c)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);en(n,function(n,o){var a=Sw(n);if(a){var s=a.label,l=kS(s);a.suggestIgnore=s.ignore,s.ignore=!1,xa(WS,US),WS.x=e.axis.dataToCoord(l.tickValue),WS.y=t.labelOffset+t.labelDirection*r,WS.rotation=l.layoutRotation,i.add(WS),WS.updateTransform(),i.remove(WS),WS.decomposeTransform(),xa(s,WS),s.markRedraw(),bw(a,!0),Sw(a)}})}(t,i,l,o),t.rotation;var c=t.optionHideOverlap;!function(t,e,n){if(Ub(t.axis))return;function i(t,i,r){var o=Sw(e[i]),a=Sw(e[r]);if(o&&a)if(!1===t||o.suggestIgnore)HS(o.label);else if(a.suggestIgnore)HS(a.label);else{var s=.1;if(!n){var l=[0,0,0,0];o=Mw({marginForce:l},o),a=Mw({marginForce:l},a)}Tw(o,a,null,{touchThreshold:s})&&HS(t?a.label:o.label)}}var r=t.get(["axisLabel","showMinLabel"]),o=t.get(["axisLabel","showMaxLabel"]),a=e.length;i(r,0,1),i(o,a-1,a-2)}(i,l,c),c&&function(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i.1?"x":"y",u=a.transGroup[c];if(s.sort(function(t,e){return Math.abs(t.label[c]-u)-Math.abs(e.label[c]-u)}),l&&r){var h=o.getExtent(),d=Math.min(h[0],h[1]),p=Math.max(h[0],h[1])-d;r.union(new $i(d,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function HS(t){t&&(t.ignore=!0)}function BS(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;Kx(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(Fb(l,s),Kx(l)&&(e=a))}r.length&&(e||Fb((e=r.pop()).scale,e.model),en(r,function(t){!function(t,e,n){var i=pb.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,{expandToNicedExtent:!0}),a=r.length-1,s=i.getInterval.call(n),l=Bb(t,e),c=l.extent,u=l.fixMin,h=l.fixMax;"log"===t.type&&(c=ab(t.base,c,!0)),t.setBreaksFromOption(Xb(e)),t.setExtent(c[0],c[1]),t.calcNiceExtent({splitNumber:a,fixMin:u,fixMax:h});var d=i.getExtent.call(t);u&&(c[0]=d[0]),h&&(c[1]=d[1]);var p=i.getInterval.call(t),f=c[0],g=c[1];if(u&&h)p=(g-f)/a;else if(u)for(g=c[0]+p*a;gc[0]&&isFinite(f)&&isFinite(c[0]);)p=Jx(p),f=c[1]-p*a;else{t.getTicks().length-1>a&&(p=Jx(p));var v=p*a;(f=is((g=Math.ceil(c[1]/p)*p)-v))<0&&c[0]>=0?(f=0,g=is(v)):g>0&&c[1]<=0&&(g=0,f=-is(v))}var m=(r[0].value-o[0].value)/s,y=(r[a].value-o[a].value)/s;i.setExtent.call(t,f+p*m,g+p*y),i.setInterval.call(t,p),(m||y)&&i.setNiceExtent.call(t,f+p,g-p)}(t.scale,t.model,e.scale)}))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};en(n.x,function(t){KS(n,"y",t,r)}),en(n.y,function(t){KS(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=Ef(t,e),r=this._rect=Pf(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(JS(o,r),!n){var l=function(t,e,n,i,r){var o=new DS(iC);return en(n,function(n){return en(n,function(n){if(jb(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=qS(t,n),s=!1,l=!1,c=0;c0&&i>0||n<0&&i<0)}(t)}function JS(t,e){en(t.x,function(t){return tC(t,e.x,e.width)}),en(t.y,function(t){return tC(t,e.y,e.height)})}function tC(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function eC(t,e,n,i,r,o,a){nC(i,r,ew,e,!1,a);var s=[0,0,0,0];c(0),c(1),u(i,0,NaN),u(i,1,NaN);var l=null==function(t,e,n){if(t&&e)for(var i=0,r=t.length;i0});return Od(i,s,!0,!0,n),JS(r,i),l;function c(t){en(r[yd[t]],function(e){if(jb(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!_n(e)&&e>1e-4&&(t/=e),t}}function nC(t,e,n,i,r,o){var a=n===nw;en(e,function(e){return en(e,function(e){jb(e.model)&&(!function(t,e,n){var i=qS(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[yd[1-e]]=t[_d[e]]<=.5*o.refContainer[_d[e]]?0:1-e==1?2:1}l(0),l(1),en(e,function(t,e){return en(t,function(t){jb(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var iC=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";PS(t,0,0,i,r,o),qb(t.nameLocation)||en(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&ES(t.labelInfoList,t.dirVec,i,r)})};function rC(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];en(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=lC(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var c=n.model.getModel("tooltip",i);if(en(n.getAxes(),ln(p,!1,null)),n.getTooltipAxes&&i&&c.get("show")){var u="axis"===c.get("trigger"),h="cross"===c.get(["axisPointer","type"]),d=n.getTooltipAxes(c.get(["axisPointer","axis"]));(u||h)&&en(d.baseAxes,ln(p,!h||"cross",u)),h&&en(d.otherAxes,ln(p,"cross",!1))}}function p(i,s,u){var h=u.model.getModel("axisPointer",r),d=h.get("show");if(d&&("auto"!==d||i||sC(h))){null==s&&(s=h.get("triggerTooltip")),h=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};en(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=Xe(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var c=a.get(["label","show"]);if(l.show=null==c||c,!o){var u=s.lineStyle=a.get("crossStyle");u&&Ke(l,u.textStyle)}}return t.model.getModel("axisPointer",new _p(s,n,i))}(u,c,r,e,i,s):h;var p=h.get("snap"),f=h.get("triggerEmphasis"),g=lC(u.model),v=s||p||"category"===u.type,m=t.axesInfo[g]={key:g,axis:u,coordSys:n,axisPointerModel:h,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:sC(h),seriesModels:[],linkGroup:null};l[g]=m,t.seriesInvolved=t.seriesInvolved||v;var y=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function aC(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[lC(t)]}function sC(t){return!!t.get(["handle","show"])}function lC(t){return t.type+"||"+t.id}var cC={},uC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=aC(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=sC(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),aa)return!0;if(o){var s=aC(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=xC(t).pointerEl=new Xd[r.type](bC(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=xC(t).labelEl=new Wc(bC(e.label));t.add(r),kC(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=xC(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=xC(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),kC(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=Ld(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){mi(t.event)},onmousedown:wC(this._onHandleDragMove,this,0,0),drift:wC(this._onHandleDragMove,this),ondragend:wC(this._onHandleDragEnd,this)}),i.add(r)),DC(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");cn(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,Nm(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){CC(this._axisPointerModel,!e&&this._moveAnimation,this._handle,TC(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(TC(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(TC(i)),xC(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),Rm(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function CC(t,e,n,i){MC(xC(n).lastProp,i)||(xC(n).lastProp=i,e?dd(n,i,t):(n.stopAnimation(),n.attr(i)))}function MC(t,e){if(fn(t)&&fn(e)){var n=!0;return en(e,function(e,i){n=n&&MC(t[i],e)}),!!n}return t===e}function kC(t,e){t[e.get(["label","show"])?"show":"hide"]()}function TC(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function DC(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function IC(t,e,n,i,r){var o=AC(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=hf(a.get("padding")||0),l=a.getFont(),c=Ta(o,l),u=r.position,h=c.width+s[1]+s[3],d=c.height+s[0]+s[2],p=r.align;"right"===p&&(u[0]-=h),"center"===p&&(u[0]-=h/2);var f=r.verticalAlign;"bottom"===f&&(u[1]-=d),"middle"===f&&(u[1]-=d/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(u,h,d,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:u[0],y:u[1],style:Jd(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function AC(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:Vb(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};en(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),hn(a)?o=a.replace("{value}",o):un(a)&&(o=a(s))}return o}function PC(t,e,n){var i=[1,0,0,1,0,0];return Mi(i,i,n.rotation),Ci(i,i,n.position),Id([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}var LC=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=EC(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var u=function(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}(i),h=zC[s](o,c,l);h.style=u,t.graphicKey=h.type,t.pointer=h}!function(t,e,n,i,r,o){var a=zS.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),IC(e,i,r,o,{position:PC(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}(e,t,qS(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=qS(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=PC(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=EC(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,c=[t.x,t.y];c[l]+=e[l],c[l]=Math.min(a[1],c[l]),c[l]=Math.max(a[0],c[l]);var u=(s[1]+s[0])/2,h=[u,u];h[l]=c[l];return{x:c[0],y:c[1],rotation:t.rotation,cursorPoint:h,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(SC);function EC(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var zC={line:function(t,e,n){var i,r,o;return{type:"Line",subPixelOptimize:!0,shape:(i=[e,n[0]],r=[e,n[1]],o=OC(t),{x1:i[o=o||0],y1:i[1-o],x2:r[o],y2:r[1-o]})}},shadow:function(t,e,n){var i,r,o,a=Math.max(1,t.getBandWidth()),s=n[1]-n[0];return{type:"Rect",shape:(i=[e-a/2,n[0]],r=[a,s],o=OC(t),{x:i[o=o||0],y:i[1-o],width:r[o],height:r[1-o]})}}};function OC(t){return"x"===t.dim?0:1}var NC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:Bf.color.border,width:1,type:"dashed"},shadowStyle:{color:Bf.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:Bf.color.neutral00,padding:[5,7,5,7],backgroundColor:Bf.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:Bf.color.accent40,throttle:40}},e}(Hf),RC=Es(),HC=en;function BC(t,e,n){if(!Te.node){var i=e.getZr();RC(i).records||(RC(i).records={}),function(t,e){if(RC(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);HC(RC(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}RC(t).initialized=!0,n("click",ln($C,"click")),n("mousemove",ln($C,"mousemove")),n("globalout",FC)}(i,e),(RC(i).records[t]||(RC(i).records[t]={})).handler=n}}function FC(t,e,n){t.handler("leave",null,n)}function $C(t,e,n,i){e.handler(t,n,i)}function VC(t,e){if(!Te.node){var n=e.getZr();(RC(n).records||{})[t]&&(RC(n).records[t]=null)}}var WC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";BC("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){VC("axisPointer",e)},e.prototype.dispose=function(t,e){VC("axisPointer",e)},e.type="axisPointer",e}(wm);function UC(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=Ls(o,t);if(null==a||a<0||cn(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var c=l.getBaseAxis(),u=l.getOtherAxis(c).dim,h=c.dim,d="x"===u||"radius"===u?1:0,p=o.mapDimension(h),f=[];f[d]=o.get(p,a),f[1-d]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(nn(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var GC=Es();function qC(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||sn(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){KC(r)&&(r=UC({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=KC(r),c=o.axesInfo,u=s.axesInfo,h="leave"===i||KC(r),d={},p={},f={list:[],map:{}},g={showPointer:ln(XC,p),showTooltip:ln(YC,f)};en(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);en(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(c,t);if(!h&&n&&(!c||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&jC(t,a,g,!1,d)}})});var v={};return en(u,function(t,e){var n=t.linkGroup;n&&!p[e]&&en(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,ZC(e),ZC(t)))),v[t.key]=o}})}),en(v,function(t,e){jC(u[e],t,g,!0,d)}),function(t,e,n){var i=n.axesInfo=[];en(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,u,d),function(t,e,n,i){if(KC(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=GC(i)[r]||{},a=GC(i)[r]={};en(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&en(n.seriesDataIndices,function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t})});var s=[],l=[];en(o,function(t,e){!a[e]&&l.push(t)}),en(a,function(t,e){!o[e]&&s.push(t)}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(u,0,n),d}}function jC(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return en(e.seriesModels,function(e,l){var c,u,h=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var d=e.getAxisTooltipData(h,t,n);u=d.dataIndices,c=d.nestestValue}else{if(!(u=e.indicesOfNearest(i,h[0],t,"category"===n.type?.5:null)).length)return;c=e.getData().get(h[0],u[0])}if(null!=c&&isFinite(c)){var p=t-c,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=c,o.length=0),en(u,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&Ze(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function XC(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function YC(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,c=lC(l),u=t.map[c];u||(u=t.map[c]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(u)),u.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function ZC(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function KC(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function QC(t){uC.registerAxisPointerClass("CartesianAxisPointer",LC),t.registerComponentModel(NC),t.registerComponentView(WC),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!cn(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=rC(t,e)}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},qC)}var JC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:Bf.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:Bf.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:Bf.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:Bf.color.tertiary,fontSize:14}},e}(Hf);function tM(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function eM(t){if(Te.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&r.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",r="",o="";return n&&(o="opacity"+(r=" "+t/2+"s "+i)+",visibility"+r),e||(r=" "+t+"s "+i,o+=(o.length?",":"")+(Te.transformSupported?""+oM+r:",left"+r+",top"+r)),rM+":"+o}(o,n,i)),a&&r.push("background-color:"+a),en(["width","color","radius"],function(e){var n="border-"+e,i=uf(n),o=t.get(i);null!=o&&r.push(n+":"+o+("color"===e?"":"px"))}),r.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=bn(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),en(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(h)),null!=d&&r.push("padding:"+hf(d).join("px ")+"px"),r.join(";")+";"}function cM(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){ri(ii,e,i,r,!0)&&ri(t,n,ii[0],ii[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var uM=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,Te.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),r=e.appendTo,o=r&&(hn(r)?document.querySelector(r):mn(r)?r:un(r)&&r(t.getDom()));cM(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(n),this._api=t,this._container=o;var a=this;n.onmouseenter=function(){a._enterable&&(clearTimeout(a._hideTimeout),a._show=!0),a._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!a._enterable){var e=i.handler;gi(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){a._inContent=!1,a._enterable&&a._show&&a.hideLater(a._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?a[o]:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=aM+lM(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+sM(r[0],r[1],!0)+"border-color:"+vf(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(hn(r)&&"item"===n.get("trigger")&&!tM(n)&&(a=function(t,e,n){if(!hn(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=vf(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),c="",u=oM+":";Qe(["left","right"],s)>-1?(c+="top:50%",u+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(c+="left:50%",u+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var h=a*Math.PI/180,d=l+r,p=d*Math.abs(Math.cos(h))+d*Math.abs(Math.sin(h)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),hn(t))o.innerHTML=t+a;else if(t){o.innerHTML="",cn(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!Te.node&&n.getDom()){var r=yM(i,n);this._ticket="";var o=i.dataByCoordSys,a=function(t,e,n){var i=Ns(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=Hs(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=nu(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(a){var s=a.el.getBoundingRect().clone();s.applyTransform(a.el.transform),this._tryShow({offsetX:s.x+s.width/2,offsetY:s.y+s.height/2,target:a.el,position:i.position,positionDefault:"bottom"},r)}else if(i.tooltip&&null!=i.x&&null!=i.y){var l=gM;l.x=i.x,l.y=i.y,l.update(),nu(l).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:l},r)}else if(o)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:o,tooltipOption:i.tooltipOption},r);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=UC(i,e),u=c.point[0],h=c.point[1];null!=u&&null!=h&&this._tryShow({offsetX:u,offsetY:h,target:c.el,position:i.position,positionDefault:"bottom"},r)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},r))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(yM(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===mM([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===nu(n).ssrType)return;this._lastDataByCoordSys=null,by(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=nu(t).dataIndex?r=t:null!=nu(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=sn(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=mM([e.tooltipOption],i),a=this._renderMode,s=[],l=tm("section",{blocks:[],noHeader:!0}),c=[],u=new um;en(t,function(t){en(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=AC(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),h=tm("section",{header:o,noHeader:!kn(o),sortBlocks:!0,blocks:[]});l.blocks.push(h),en(t.seriesDataIndices,function(l){var d=n.getSeriesByIndex(l.seriesIndex),p=l.dataIndexInside,f=d.getDataParams(p);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=Vb(e.axis,{value:r}),f.axisValueLabel=o,f.marker=u.makeTooltipMarker("item",vf(f.color),a);var g=bv(d.formatTooltip(p,!0,null)),v=g.frag;if(v){var m=mM([d],i).get("valueFormatter");h.blocks.push(m?Ze({valueFormatter:m},v):v)}g.text&&c.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),c.reverse();var h=e.position,d=o.get("order"),p=am(l,u,a,d,n.get("useUTC"),o.get("textStyle"));p&&c.unshift(p);var f="richText"===a?"\n\n":"
",g=c.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,h,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],h,null,u)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=nu(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,c=r.dataType,u=s.getData(c),h=this._renderMode,d=t.positionDefault,p=mM([u.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,d?{position:d}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,c),v=new um;g.marker=v.makeTooltipMarker("item",vf(g.color),h);var m=bv(s.formatTooltip(l,!1,c)),y=p.get("order"),_=p.get("valueFormatter"),x=m.frag,b=x?am(_?Ze({valueFormatter:_},x):x,v,h,y,i.get("useUTC"),p.get("textStyle")):m.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,b,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:u.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=nu(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(hn(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=Xe(o)).content=li(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var c=t.positionDefault,u=mM(s,this._tooltipModel,c?{position:c}:null),h=u.get("content"),d=Math.random()+"",p=new um;this._showOrMove(u,function(){var n=Xe(u.get("formatterParams")||{});this._showTooltipContent(u,h,n,d,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var c=this._tooltipContent;c.setEnterable(t.get("enterable"));var u=t.get("formatter");a=a||t.get("position");var h=e,d=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(u)if(hn(u)){var p=t.ecModel.get("useUTC"),f=cn(n)?n[0]:n;h=u,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(h=Gp(f.axisValue,h,p)),h=gf(h,n,!0)}else if(un(u)){var g=sn(function(e,i){e===this._ticket&&(c.setContent(i,l,t,d,a),this._updatePosition(t,a,r,o,c,n,s))},this);this._ticket=i,h=u(n,i,g)}else h=u;c.setContent(h,l,t,d,a),c.show(t,d),this._updatePosition(t,a,r,o,c,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||cn(e)?{color:i||r}:cn(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var c=r.getSize(),u=t.get("align"),h=t.get("verticalAlign"),d=a&&a.getBoundingRect().clone();if(a&&d.applyTransform(a.transform),un(e)&&(e=e([n,i],o,r.el,d,{viewSize:[s,l],contentSize:c.slice()})),cn(e))n=es(e[0],s),i=es(e[1],l);else if(fn(e)){var p=e;p.width=c[0],p.height=c[1];var f=Pf(p,{width:s,height:l});n=f.x,i=f.y,u=null,h=null}else if(hn(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,c=e.width,u=e.height;switch(t){case"inside":s=e.x+c/2-r/2,l=e.y+u/2-o/2;break;case"top":s=e.x+c/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+c/2-r/2,l=e.y+u+a;break;case"left":s=e.x-r-a,l=e.y+u/2-o/2;break;case"right":s=e.x+c+a,l=e.y+u/2-o/2}return[s,l]}(e,d,c,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],c=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+c+a>r?e-=c+a:e+=a);return[t,e]}(n,i,r,s,l,u?null:20,h?null:20);n=g[0],i=g[1]}if(u&&(n-=_M(u)?c[0]/2:"right"===u?c[0]:0),h&&(i-=_M(h)?c[1]/2:"bottom"===h?c[1]:0),tM(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&en(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&en(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&en(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&en(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!Te.node&&e.getDom()&&(Rm(this,"_updatePosition"),this._tooltipContent.dispose(),VC("itemTooltip",e))},e.type="tooltip",e}(wm);function mM(t,e,n){var i,r=e.ecModel;n?(i=new _p(n,r,r),i=new _p(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof _p&&(a=a.get("tooltip",!0)),hn(a)&&(a={formatter:a}),a&&(i=new _p(a,i,r)))}return i}function yM(t,e){return t.dispatchAction||sn(e.dispatchAction,e)}function _M(t){return"center"===t||"middle"===t}var xM=Math.sin,bM=Math.cos,wM=Math.PI,SM=2*Math.PI,CM=180/wM,MM=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,c=!s,u=Math.abs(l),h=ho(u-SM)||(c?l>=SM:-l>=SM),d=l>0?l%SM:l%SM+SM,p=!1;p=!!h||!ho(u)&&d>=wM==!!c;var f=t+n*bM(o),g=e+i*xM(o);this._start&&this._add("M",f,g);var v=Math.round(r*CM);if(h){var m=1/this._p,y=(c?1:-1)*(SM-m);this._add("A",n,i,v,1,+c,t+n*bM(o+y),e+i*xM(o+y)),m>.01&&this._add("A",n,i,v,0,+c,f,g)}else{var _=t+n*bM(a),x=e+i*xM(a);this._add("A",n,i,v,+p,+c,_,x)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var c=[],u=this._p,h=1;h"}(r,o)+("style"!==r?li(a):a||"")+(i?""+n+nn(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function RM(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function HM(t,e,n,i){return OM("svg","root",{width:t,height:e,xmlns:PM,"xmlns:xlink":LM,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var BM=0;function FM(){return BM++}var $M={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},VM="transform-origin";function WM(t,e,n){var i=Ze({},t.shape);Ze(i,e),t.buildPath(n,i);var r=new MM;return r.reset(wo(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function UM(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[VM]=n+"px "+i+"px")}var GM={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function qM(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function jM(t){return hn(t)?$M[t]?"cubic-bezier("+$M[t]+")":Rr(t)?t:"":""}function XM(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof Yh){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(en(o,function(t){var e=RM(n.zrId);e.animation=!0,XM(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=an(o),c=l.length;if(c){var u=o[r=l[c-1]];for(var h in u){var d=u[h];a[h]=a[h]||{d:""},a[h].d+=d.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=qM(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},c=0;c0}).length)return qM(u,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var m=n.zrId+"-cls-"+FM();n.cssNodes["."+m]={animation:a.join(",")},e.class=m}}function YM(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+FM(),n.cssStyleCache[r]=o,n.cssNodes["."+o+":hover"]=t),e.class=e.class?e.class+" "+o:o}var ZM=Math.round;function KM(t){return t&&hn(t.src)}function QM(t){return t&&un(t.toDataURL)}function JM(t,e,n,i){AM(function(r,o){var a="fill"===r||"stroke"===r;a&&xo(o)?uk(e,t,r,i):a&&mo(o)?hk(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],c=s[1];if(!l||!c)return;var u=i.shadowOffsetX||0,h=i.shadowOffsetY||0,d=i.shadowBlur,p=co(i.shadowColor),f=p.opacity,g=p.color,v=d/2/l+" "+d/2/c;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=OM("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[OM("feDropShadow","",{dx:u/l,dy:h/c,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=bo(a)}}(n,t,i)}function tk(t,e){var n=function(t){if("function"==typeof ja)return ja(t)}(e);n&&(n.each(function(e,n){null!=e&&(t[(EM+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[EM+"silent"]="true"))}function ek(t){return ho(t[0]-1)&&ho(t[1])&&ho(t[2])&&ho(t[3]-1)}function nk(t,e,n){if(e&&(!function(t){return ho(t[4])&&ho(t[5])}(e)||!ek(e))){var i=1e4;t.transform=ek(e)?"translate("+ZM(e[4]*i)/i+" "+ZM(e[5]*i)/i+")":function(t){return"matrix("+po(t[0])+","+po(t[1])+","+po(t[2])+","+po(t[3])+","+fo(t[4])+","+fo(t[5])+")"}(e)}}function ik(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=so(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var c={cursor:"pointer"};r&&(c.fill=r),i.stroke&&(c.stroke=i.stroke),l&&(c["stroke-width"]=l),YM(c,e,n)}}(t,o,e),OM(s,t.id+"",o)}function ck(t,e){return t instanceof Tc?lk(t,e):t instanceof Lc?function(t,e){var n=t.style,i=n.image;if(i&&!hn(i)&&(KM(i)?i=i.src:QM(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),nk(a,t.transform),JM(a,n,t,e),tk(a,t),e.animation&&XM(t,a,e),OM("image",t.id+"",a)}}(t,e):t instanceof Ic?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||Ie,o=n.x||0,a=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,Aa(r),n.textBaseline),s={"dominant-baseline":"central","text-anchor":go[n.textAlign]||n.textAlign};if(Yc(n)){var l="",c=n.fontStyle,u=jc(n.fontSize);if(!parseFloat(u))return;var h=n.fontFamily||De,d=n.fontWeight;l+="font-size:"+u+";font-family:"+h+";",c&&"normal"!==c&&(l+="font-style:"+c+";"),d&&"normal"!==d&&(l+="font-weight:"+d+";"),s.style=l}else s.style="font: "+r;return i.match(/\s/)&&(s["xml:space"]="preserve"),o&&(s.x=o),a&&(s.y=a),nk(s,t.transform),JM(s,n,t,e),tk(s,t),e.animation&&XM(t,s,e),OM("text",t.id+"",s,void 0,i)}}(t,e):void 0}function uk(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(yo(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!_o(o))return;r="radialGradient",a.cx=bn(o.x,.5),a.cy=bn(o.y,.5),a.r=bn(o.r,.5)}for(var s=o.colorStops,l=[],c=0,u=s.length;cl?kk(t,null==n[h+1]?null:n[h+1].elm,n,s,h):Tk(t,e,a,l))}(n,i,r):wk(r)?(wk(t.text)&&_k(n,""),kk(n,null,r,0,r.length-1)):wk(i)?Tk(n,i,0,i.length-1):wk(t.text)&&_k(n,""):t.text!==e.text&&(wk(i)&&Tk(n,i,0,i.length-1),_k(n,e.text)))}var Ak=0,Pk=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=function(){},this.configLayer=function(){},this.storage=e,this._opts=n=Ze({},n),this.root=t,this._id="zr"+Ak++,this._oldVNode=HM(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=zM("svg");Dk(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(Ck(t,e))Ik(t,e);else{var n=t.elm,i=mk(n);Mk(e),null!==i&&(fk(i,e.elm,yk(n)),Tk(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return ck(t,RM(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=RM(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=OM("rect","bg",{width:t,height:e,x:"0",y:"0"}),xo(n))uk({fill:n},r.attrs,"fill",i);else if(mo(n))hk({style:{fill:n},dirty:Nn,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=co(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=OM("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=nn(an(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(OM("defs","defs",{},l)),t.animation){var c=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=nn(an(t),function(e){return e+r+nn(an(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=nn(an(e),function(t){return"@keyframes "+t+r+nn(an(e[t]),function(n){return n+r+nn(an(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(c){var u=OM("style","stl",{},[],c);o.push(u)}}return HM(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},NM(this.renderToVNode({animation:bn(t.cssAnimation,!0),emphasis:bn(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:bn(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,c=0;c=0&&(!h||!r||h[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),c=s.getExtent(),u=n.getDevicePixelRatio(),h=Math.abs(c[1]-c[0])*(u||1),d=Math.round(a/h);if(isFinite(d)&&d>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/d)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/d));var p=void 0;hn(r)?p=rS[r]:un(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/d,p,oS))}}}}}("line"))},function(t){Qb(_C),Qb(QC)},function(t){Qb(QC),t.registerComponentModel(JC),t.registerComponentView(vM),t.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},Nn),t.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},Nn)},function(t){t.registerPainter("svg",Pk)}]);class Lk extends At{constructor(){super(...arguments),this.options=null,this.data=[],this.height="120px",this._chart=null,this._resizeObserver=null}render(){return ht`
`}connectedCallback(){super.connectedCallback(),this.hasUpdated&&!this._chart&&this.options&&(this._mount(),this._observeResize())}firstUpdated(){this._mount(),this._observeResize()}updated(t){(t.has("options")||t.has("data"))&&(this._chart&&this.options?this._chart.setOption(this._mergedOptions(),!0):!this._chart&&this.options&&this._mount()),t.has("height")&&this._chart&&this._chart.resize()}disconnectedCallback(){this._destroy(),super.disconnectedCallback()}_mount(){if(!this.options)return;const t=this.shadowRoot?.querySelector(".chart-host");t&&(this._chart=rx(t,void 0,{renderer:"svg"}),this._chart.setOption(this._mergedOptions(),!0))}_mergedOptions(){if(!this.options)throw new Error("span-chart: options not set");return{...this.options,series:this.data}}_observeResize(){if("undefined"==typeof ResizeObserver)return;this._resizeObserver=new ResizeObserver(()=>{this._chart&&this._chart.resize()});const t=this.shadowRoot?.querySelector(".chart-host");t&&this._resizeObserver.observe(t)}_destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._chart&&(this._chart.dispose(),this._chart=null)}}Lk.styles=T` +var vs={},ms={};var ys,_s=function(){function t(t,e,n){var i=this;this._sleepAfterStill=10,this._stillFrameAccum=0,this._needsRefresh=!0,this._needsRefreshHover=!0,this._darkMode=!1,n=n||{},this.dom=e,this.id=t;var r=new Rr,o=n.renderer||"canvas";vs[o]||(o=Pn(vs)[0]),n.useDirtyRect=null!=n.useDirtyRect&&n.useDirtyRect;var a=new vs[o](e,r,n,t),s=n.ssr||a.ssrOnly;this.storage=r,this.painter=a;var l,c=Qe.node||Qe.worker||s?null:new Da(a.getViewportRoot(),a.root),u=n.useCoarsePointer;(null==u||"auto"===u?Qe.touchEventsSupported:!!u)&&(l=qn(n.pointerSize,44)),this.handler=new Sr(r,a,c,a.root,l),this.animation=new pa({stage:{update:s?null:function(){return i._flush(!0)}}}),s||this.animation.start()}return t.prototype.add=function(t){!this._disposed&&t&&(this.storage.addRoot(t),t.addSelfToZr(this),this.refresh())},t.prototype.remove=function(t){!this._disposed&&t&&(this.storage.delRoot(t),t.removeSelfFromZr(this),this.refresh())},t.prototype.configLayer=function(t,e){this._disposed||(this.painter.configLayer&&this.painter.configLayer(t,e),this.refresh())},t.prototype.setBackgroundColor=function(t){this._disposed||(this.painter.setBackgroundColor&&this.painter.setBackgroundColor(t),this.refresh(),this._backgroundColor=t,this._darkMode=function(t){if(!t)return!1;if("string"==typeof t)return Po(t,1)<.4;if(t.colorStops){for(var e=t.colorStops,n=0,i=e.length,r=0;r0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*c+a}var ks=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return Ts(t,e,n)};function Ts(t,e,n){return Nn(t)?(i=t,i.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t;var i}function As(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function Is(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return function(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}(t)}function Ds(t,e){var n=Math.max(Is(t),Is(e)),i=t+e;return n>20?i:As(i,n)}function Ps(t){var e=2*Math.PI;return(t%e+e)%e}function Ls(t){return t>-1e-4&&t=10&&e++,e}function Ns(t,e){var n=zs(t),i=Math.pow(10,n),r=t/i;return t=(r<1.5?1:r<2.5?2:r<4?3:r<7?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function Rs(t){var e=parseFloat(t);return e==t&&(0!==e||!Nn(t)||t.indexOf("x")<=0)?e:NaN}function Hs(){return Math.round(9*Math.random())}function Bs(t,e){return 0===e?t:Bs(e,t%e)}function Fs(t,e){return null==t?e:null==e?t:t*e/Bs(t,e)}var $s="undefined"!=typeof console&&console.warn&&console.log;function Vs(t,e){!function(t,e){$s&&console[t]("[ECharts] "+e)}("error",t)}function Ws(t){throw new Error(t)}function Us(t,e,n){return(e-t)*n+t}var Gs="series\0";function qs(t){return t instanceof Array?t:null==t?[]:[t]}function js(t,e,n){if(t){t[e]=t[e]||{},t.emphasis=t.emphasis||{},t.emphasis[e]=t.emphasis[e]||{};for(var i=0,r=n.length;i=0||r&&Cn(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var xl=bl([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),wl=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return xl(this,t,e)},t}(),Sl=new po(50);function Cl(t){if("string"==typeof t){var e=Sl.get(t);return e&&e.image}return t}function Ml(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=Sl.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!Tl(e=o.image)&&o.pending.push(a):((e=on.loadImage(t,kl,kl)).__zrImageSrc=t,Sl.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function kl(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;c++)l-=s;var u=Za(a,n);return u>l&&(n="",u=0),l=t-u,r.ellipsis=n,r.ellipsisWidth=u,r.contentWidth=l,r.containerWidth=t,r}function Pl(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=Za(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?Ll(e,r,o):a>0?Math.floor(e.length*r/a):0;a=Za(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function Ll(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),d=!0),i.accumWidth=f}else{var g=Bl(e,u,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=qa(u),m=0;m=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!Rl[t]}function Bl(t,e,n,i,r){for(var o=[],a=[],s="",l="",c=0,u=0,d=qa(e),h=0;hn:r+u+f>n)?u?(s||l)&&(g?(s||(s=l,l="",u=c=0),o.push(s),a.push(u-c),l+=p,s="",u=c+=f):(l&&(s+=l,l="",c=0),o.push(s),a.push(u),s=p,u=f)):g?(o.push(l),a.push(c),l=p,c=f):(o.push(p),a.push(f)):(u+=f,g?(l+=p,c+=f):(l&&(s+=l,l="",c=0),s+=p))}else l&&(s+=l,u+=c),o.push(s),a.push(u),s="",l="",c=0,u=0}return l&&(s+=l),s&&(o.push(s),a.push(u)),1===o.length&&(u+=r),{accumWidth:u,lines:o,linesWidths:a}}function Fl(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;hr.set($l,Ja(n,a,r),ts(i,s,o),a,s),hr.intersect(e,$l,null,Vl);var l=Vl.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=Ja(l.x,l.width,r,!0),t.baseY=ts(l.y,l.height,o,!0)}}var $l=new hr(0,0,0,0),Vl={outIntersectRect:{},clamp:!0};function Wl(t){return null!=t?t+="":t=""}function Ul(t,e,n,i){var r=new hr(Ja(t.x||0,e,t.textAlign),ts(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:Gl(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function Gl(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var ql="__zr_style_"+Math.round(10*Math.random()),jl={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Xl={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};jl[ql]=!0;var Yl=["z","z2","invisible"],Zl=["invisible"],Kl=function(t){function e(e){return t.call(this,e)||this}var n;return b(e,t),e.prototype._init=function(e){for(var n=Pn(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(oc[0]=ic(r)*n+t,oc[1]=nc(r)*i+e,ac[0]=ic(o)*n+t,ac[1]=nc(o)*i+e,c(s,oc,ac),u(l,oc,ac),(r%=rc)<0&&(r+=rc),(o%=rc)<0&&(o+=rc),r>o&&!a?o+=rc:rr&&(sc[0]=ic(p)*n+t,sc[1]=nc(p)*i+e,c(s,sc,s),u(l,sc,l))}var fc={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},gc=[],vc=[],mc=[],yc=[],_c=[],bc=[],xc=Math.min,wc=Math.max,Sc=Math.cos,Cc=Math.sin,Mc=Math.abs,kc=Math.PI,Tc=2*kc,Ac="undefined"!=typeof Float32Array,Ic=[];function Dc(t){return Math.round(t/kc*1e8)/1e8%2*kc}var Pc=function(){function t(t){this.dpr=1,this._xi=0,this._yi=0,this._x0=0,this._y0=0,this._len=0,t&&(this._saveData=!1),this._saveData&&(this.data=[])}var e;return t.prototype.increaseVersion=function(){this._version++},t.prototype.getVersion=function(){return this._version},t.prototype.setScale=function(t,e,n){(n=n||0)>0&&(this._ux=Mc(n/La/t)||0,this._uy=Mc(n/La/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(fc.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=Mc(t-this._xi),i=Mc(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(fc.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(fc.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(fc.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),Ic[0]=i,Ic[1]=r,function(t,e){var n=Dc(t[0]);n<0&&(n+=Tc);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=Tc?r=n+Tc:e&&n-r>=Tc?r=n-Tc:!e&&n>r?r=n+(Tc-Dc(n-r)):e&&n0&&o))for(var a=0;ac.length&&(this._expandData(),c=this.data);for(var u=0;u0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){mc[0]=mc[1]=_c[0]=_c[1]=Number.MAX_VALUE,yc[0]=yc[1]=bc[0]=bc[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||Mc(v)>i||d===e-1)&&(f=Math.sqrt(I*I+v*v),r=g,o=_);break;case fc.C:var m=t[d++],y=t[d++],_=(g=t[d++],t[d++]),b=t[d++],x=t[d++];f=eo(r,o,m,y,g,_,b,x,10),r=b,o=x;break;case fc.Q:f=ao(r,o,m=t[d++],y=t[d++],g=t[d++],_=t[d++],10),r=g,o=_;break;case fc.A:var w=t[d++],S=t[d++],C=t[d++],M=t[d++],k=t[d++],T=t[d++],A=T+k;d+=1,p&&(a=Sc(k)*C+w,s=Cc(k)*M+S),f=wc(C,M)*xc(Tc,Math.abs(T)),r=Sc(A)*C+w,o=Cc(A)*M+S;break;case fc.R:a=r=t[d++],s=o=t[d++],f=2*t[d++]+2*t[d++];break;case fc.Z:var I=a-r;v=s-o;f=Math.sqrt(I*I+v*v),r=a,o=s}f>=0&&(l[u++]=f,c+=f)}return this._pathLen=c,c},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,c,u,d,h=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,m=0,y=0,_=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,c=e*this._pathLen))t:for(var b=0;b0&&(t.lineTo(u,d),_=0),x){case fc.M:n=r=h[b++],i=o=h[b++],t.moveTo(r,o);break;case fc.L:a=h[b++],s=h[b++];var S=Mc(a-r),C=Mc(s-o);if(S>p||C>f){if(v){if(m+(X=l[y++])>c){var M=(c-m)/X;t.lineTo(r*(1-M)+a*M,o*(1-M)+s*M);break t}m+=X}t.lineTo(a,s),r=a,o=s,_=0}else{var k=S*S+C*C;k>_&&(u=a,d=s,_=k)}break;case fc.C:var T=h[b++],A=h[b++],I=h[b++],D=h[b++],P=h[b++],L=h[b++];if(v){if(m+(X=l[y++])>c){to(r,T,I,P,M=(c-m)/X,gc),to(o,A,D,L,M,vc),t.bezierCurveTo(gc[1],vc[1],gc[2],vc[2],gc[3],vc[3]);break t}m+=X}t.bezierCurveTo(T,A,I,D,P,L),r=P,o=L;break;case fc.Q:T=h[b++],A=h[b++],I=h[b++],D=h[b++];if(v){if(m+(X=l[y++])>c){oo(r,T,I,M=(c-m)/X,gc),oo(o,A,D,M,vc),t.quadraticCurveTo(gc[1],vc[1],gc[2],vc[2]);break t}m+=X}t.quadraticCurveTo(T,A,I,D),r=I,o=D;break;case fc.A:var E=h[b++],O=h[b++],z=h[b++],N=h[b++],R=h[b++],H=h[b++],B=h[b++],F=!h[b++],$=z>N?z:N,V=Mc(z-N)>.001,W=R+H,U=!1;if(v)m+(X=l[y++])>c&&(W=R+H*(c-m)/X,U=!0),m+=X;if(V&&t.ellipse?t.ellipse(E,O,z,N,B,R,W,F):t.arc(E,O,$,R,W,F),U)break t;w&&(n=Sc(R)*z+E,i=Cc(R)*N+O),r=Sc(W)*z+E,o=Cc(W)*N+O;break;case fc.R:n=r=h[b],i=o=h[b+1],a=h[b++],s=h[b++];var G=h[b++],q=h[b++];if(v){if(m+(X=l[y++])>c){var j=c-m;t.moveTo(a,s),t.lineTo(a+xc(j,G),s),(j-=G)>0&&t.lineTo(a+G,s+xc(j,q)),(j-=q)>0&&t.lineTo(a+wc(G-j,0),s+q),(j-=G)>0&&t.lineTo(a,s+wc(q-j,0));break t}m+=X}t.rect(a,s,G,q);break;case fc.Z:if(v){var X;if(m+(X=l[y++])>c){M=(c-m)/X;t.lineTo(r*(1-M)+n*M,o*(1-M)+i*M);break t}m+=X}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=fc,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function Lc(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+d&&u>i+d&&u>o+d&&u>s+d||ut+d&&c>n+d&&c>r+d&&c>a+d||c=0&&pe+c&&l>i+c&&l>o+c||lt+c&&s>n+c&&s>r+c||s=0&&gn||u+cr&&(r+=Rc);var h=Math.atan2(l,s);return h<0&&(h+=Rc),h>=i&&h<=r||h+Rc>=i&&h+Rc<=r}function Bc(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var Fc=Pc.CMD,$c=2*Math.PI;var Vc=[-1,-1,-1],Wc=[-1,-1];function Uc(){var t=Wc[0];Wc[0]=Wc[1],Wc[1]=t}function Gc(t,e,n,i,r,o,a,s,l,c){if(c>e&&c>i&&c>o&&c>s||c1&&Uc(),p=Zr(e,i,o,s,Wc[0]),h>1&&(f=Zr(e,i,o,s,Wc[1]))),2===h?ve&&s>i&&s>o||s=0&&u<=1&&(r[l++]=u);else{var c=a*a-4*o*s;if(Xr(c))(u=-a/(2*o))>=0&&u<=1&&(r[l++]=u);else if(c>0){var u,d=Fr(c),h=(-a-d)/(2*o);(u=(-a+d)/(2*o))>=0&&u<=1&&(r[l++]=u),h>=0&&h<=1&&(r[l++]=h)}}return l}(e,i,o,s,Vc);if(0===l)return 0;var c=ro(e,i,o);if(c>=0&&c<=1){for(var u=0,d=no(e,i,o,c),h=0;hn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);Vc[0]=-l,Vc[1]=l;var c=Math.abs(i-r);if(c<1e-4)return 0;if(c>=$c-1e-4){i=0,r=$c;var u=o?1:-1;return a>=Vc[0]+t&&a<=Vc[1]+t?u:0}if(i>r){var d=i;i=r,r=d}i<0&&(i+=$c,r+=$c);for(var h=0,p=0;p<2;p++){var f=Vc[p];if(f+t>a){var g=Math.atan2(s,f);u=o?1:-1;g<0&&(g=$c+g),(g>=i&&g<=r||g+$c>=i&&g+$c<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(u=-u),h+=u)}}return h}function Xc(t,e,n,i,r){for(var o,a,s=t.data,l=t.len(),c=0,u=0,d=0,h=0,p=0,f=0;f1&&(n||(c+=Bc(u,d,h,p,i,r))),v&&(h=u=s[f],p=d=s[f+1]),g){case Fc.M:u=h=s[f++],d=p=s[f++];break;case Fc.L:if(n){if(Lc(u,d,s[f],s[f+1],e,i,r))return!0}else c+=Bc(u,d,s[f],s[f+1],i,r)||0;u=s[f++],d=s[f++];break;case Fc.C:if(n){if(Ec(u,d,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=Gc(u,d,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],d=s[f++];break;case Fc.Q:if(n){if(Oc(u,d,s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=qc(u,d,s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],d=s[f++];break;case Fc.A:var m=s[f++],y=s[f++],_=s[f++],b=s[f++],x=s[f++],w=s[f++];f+=1;var S=!!(1-s[f++]);o=Math.cos(x)*_+m,a=Math.sin(x)*b+y,v?(h=o,p=a):c+=Bc(u,d,o,a,i,r);var C=(i-m)*b/_+m;if(n){if(Hc(m,y,b,x,x+w,S,e,C,r))return!0}else c+=jc(m,y,b,x,x+w,S,C,r);u=Math.cos(x+w)*_+m,d=Math.sin(x+w)*b+y;break;case Fc.R:if(h=u=s[f++],p=d=s[f++],o=h+s[f++],a=p+s[f++],n){if(Lc(h,p,o,p,e,i,r)||Lc(o,p,o,a,e,i,r)||Lc(o,a,h,a,e,i,r)||Lc(h,a,h,p,e,i,r))return!0}else c+=Bc(o,p,o,a,i,r),c+=Bc(h,a,h,p,i,r);break;case Fc.Z:if(n){if(Lc(u,d,h,p,e,i,r))return!0}else c+=Bc(u,d,h,p,i,r);u=h,d=p}}return n||function(t,e){return Math.abs(t-e)<1e-4}(d,p)||(c+=Bc(u,d,h,p,i,r)||0),0!==c}var Yc=Sn({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},jl),Zc={style:Sn({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Xl.style)},Kc=Ua.concat(["invisible","culling","z","z2","zlevel","parent"]),Qc=function(t){function e(e){return t.call(this,e)||this}var n;return b(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?Ea:e>.2?"#eee":Oa}if(t)return Oa}return Ea},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(Nn(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===Po(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new Pc(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Xc(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Xc(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:wn(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return oi(Yc,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=wn({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=wn({},i.shape),wn(s,n.shape)):(s=wn({},r?this.shape:i.shape),wn(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=wn({},this.shape);for(var c={},u=Pn(s),d=0;dc&&(n*=c/(a=n+i),i*=c/a),r+o>c&&(r*=c/(a=r+o),o*=c/a),i+r>u&&(i*=u/(a=i+r),r*=u/a),n+o>u&&(n*=u/(a=n+o),o*=u/a),t.moveTo(s+n,l),t.lineTo(s+c-i,l),0!==i&&t.arc(s+c-i,l+i,i,-Math.PI/2,0),t.lineTo(s+c,l+u-r),0!==r&&t.arc(s+c-r,l+u-r,r,0,Math.PI/2),t.lineTo(s+o,l+u),0!==o&&t.arc(s+o,l+u-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(Qc);uu.prototype.type="rect";var du={fill:"#000"},hu={},pu={style:Sn({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Xl.style)},fu=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=du,n.attr(e),n}return b(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;em&&p){var _=Math.floor(m/h);f=f||v.length>_,y=(v=v.slice(0,_)).length*h}if(r&&u&&null!=g)for(var b=Dl(g,c,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),x={},w=0;w0,M=0;Mg&&Nl(o,a.substring(g,v),e,f),Nl(o,h[2],e,f,h[1]),g=Al.lastIndex}gd){var O=o.lines.length;A>0?(M.tokens=M.tokens.slice(0,A),S(M,T,k),o.lines=o.lines.slice(0,C+1)):o.lines=o.lines.slice(0,C),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(T=_[k]).align;)this._placeToken(T,t,x,f,M,"right",v),w-=T.width,M-=T.width,k--;for(C+=(s-(C-p)-(g-M)-w)/2;S<=k;)T=_[S],this._placeToken(T,t,x,f,C+T.width/2,"center",v),C+=T.width,S++;f+=x}},e.prototype._placeToken=function(t,e,n,i,r,o,a){var s=e.rich[t.styleName]||{};s.text=t.text;var l=t.verticalAlign,c=i+n/2;"top"===l?c=i+t.height/2:"bottom"===l&&(c=i+n-t.height/2),!t.isLineHolder&&ku(s)&&this._renderBackground(s,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var u=!!s.backgroundColor,d=t.textPadding;d&&(r=Cu(r,o,d),c-=t.height/2-d[0]-t.innerHeight/2);var h=this._getOrCreateChild(tu),p=h.createStyle();h.useStyle(p);var f=this._defaultStyle,g=!1,v=0,m=!1,y=Su("fill"in s?s.fill:"fill"in e?e.fill:(g=!0,f.fill)),_=wu("stroke"in s?s.stroke:"stroke"in e?e.stroke:u||a||f.autoStroke&&!g?null:(v=2,m=!0,f.stroke)),b=s.textShadowBlur>0||e.textShadowBlur>0;p.text=t.text,p.x=r,p.y=c,b&&(p.shadowBlur=s.textShadowBlur||e.textShadowBlur||0,p.shadowColor=s.textShadowColor||e.textShadowColor||"transparent",p.shadowOffsetX=s.textShadowOffsetX||e.textShadowOffsetX||0,p.shadowOffsetY=s.textShadowOffsetY||e.textShadowOffsetY||0),p.textAlign=o,p.textBaseline="middle",p.font=t.font||tn,p.opacity=jn(s.opacity,e.opacity,1),_u(p,s),_&&(p.lineWidth=jn(s.lineWidth,e.lineWidth,v),p.lineDash=qn(s.lineDash,e.lineDash),p.lineDashOffset=e.lineDashOffset||0,p.stroke=_),y&&(p.fill=y),h.setBoundingRect(Ul(p,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,c=t.backgroundColor,u=t.borderWidth,d=t.borderColor,h=c&&c.image,p=c&&!h,f=t.borderRadius,g=this;if(p||t.lineHeight||u&&d){(a=this._getOrCreateChild(uu)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=c||null,l.fillOpacity=qn(t.fillOpacity,1);else if(h){(s=this._getOrCreateChild(iu)).onload=function(){g.dirtyStyle()};var m=s.style;m.image=c.image,m.x=n,m.y=i,m.width=r,m.height=o}u&&d&&((l=a.style).lineWidth=u,l.stroke=d,l.strokeOpacity=qn(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var y=(a||s).style;y.shadowBlur=t.shadowBlur||0,y.shadowColor=t.shadowColor||"transparent",y.shadowOffsetX=t.shadowOffsetX||0,y.shadowOffsetY=t.shadowOffsetY||0,y.opacity=jn(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return bu(t)&&(e=[t.fontStyle,t.fontWeight,yu(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&Kn(e)||t.textFont||t.font},e}(Kl),gu={left:!0,right:1,center:1},vu={top:1,bottom:1,middle:1},mu=["fontStyle","fontWeight","fontSize","fontFamily"];function yu(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function _u(t,e){for(var n=0;n=0,o=!1;if(t instanceof Qc){var a=Du(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(Fu(s)||Fu(l)){var c=(i=i||{}).style||{};"inherit"===c.fill?(o=!0,i=wn({},i),(c=wn({},c)).fill=s):!Fu(c.fill)&&Fu(s)?(o=!0,i=wn({},i),(c=wn({},c)).fill=Eo(s)):!Fu(c.stroke)&&Fu(l)&&(o||(i=wn({},i),c=wn({},c)),c.stroke=Eo(l)),i.style=c}}if(i&&null==i.z2){o||(i=wn({},i));var u=t.z2EmphasisLift;i.z2=t.z2+(null!=u?u:10)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=Cn(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function pd(t,e,n){yd(t,!0),Yu(t,Qu),function(t,e,n){var i=Tu(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}(t,e,n)}function fd(t,e,n,i){i?function(t){yd(t,!1)}(t):pd(t,e,n)}var gd=["emphasis","blur","select"],vd={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function md(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=kd(f),s*=kd(f));var g=(r===o?-1:1)*kd((a*a*(s*s)-a*a*(p*p)-s*s*(h*h))/(a*a*(p*p)+s*s*(h*h)))||0,v=g*a*p/s,m=g*-s*h/a,y=(t+n)/2+Ad(d)*v-Td(d)*m,_=(e+i)/2+Td(d)*v+Ad(d)*m,b=Ld([1,0],[(h-v)/a,(p-m)/s]),x=[(h-v)/a,(p-m)/s],w=[(-1*h-v)/a,(-1*p-m)/s],S=Ld(x,w);if(Pd(x,w)<=-1&&(S=Id),Pd(x,w)>=1&&(S=0),S<0){var C=Math.round(S/Id*1e6)/1e6;S=2*Id+C%2*Id}u.addData(c,y,_,a,s,b,S,d,o)}var Od=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,zd=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var Nd=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return b(e,t),e.prototype.applyTransform=function(t){},e}(Qc);function Rd(t){return null!=t.setData}function Hd(t,e){var n=function(t){var e=new Pc;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=Pc.CMD,l=t.match(Od);if(!l)return e;for(var c=0;cD*D+P*P&&(C=k,M=T),{cx:C,cy:M,x0:-u,y0:-d,x1:C*(r/x-1),y1:M*(r/x-1)}}function eh(t,e){var n,i=Kd(e.r,0),r=Kd(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var c=e.cx,u=e.cy,d=!!e.clockwise,h=Yd(l-s),p=h>Ud&&h%Ud;if(p>Jd&&(h=p),i>Jd)if(h>Ud-Jd)t.moveTo(c+i*qd(s),u+i*Gd(s)),t.arc(c,u,i,s,l,!d),r>Jd&&(t.moveTo(c+r*qd(l),u+r*Gd(l)),t.arc(c,u,r,l,s,d));else{var f=void 0,g=void 0,v=void 0,m=void 0,y=void 0,_=void 0,b=void 0,x=void 0,w=void 0,S=void 0,C=void 0,M=void 0,k=void 0,T=void 0,A=void 0,I=void 0,D=i*qd(s),P=i*Gd(s),L=r*qd(l),E=r*Gd(l),O=h>Jd;if(O){var z=e.cornerRadius;z&&(n=function(t){var e;if(On(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(z),f=n[0],g=n[1],v=n[2],m=n[3]);var N=Yd(i-r)/2;if(y=Qd(N,v),_=Qd(N,m),b=Qd(N,f),x=Qd(N,g),C=w=Kd(y,_),M=S=Kd(b,x),(w>Jd||S>Jd)&&(k=i*qd(l),T=i*Gd(l),A=r*qd(s),I=r*Gd(s),hJd){var U=Qd(v,C),G=Qd(m,C),q=th(A,I,D,P,i,U,d),j=th(k,T,L,E,i,G,d);t.moveTo(c+q.cx+q.x0,u+q.cy+q.y0),C0&&t.arc(c+q.cx,u+q.cy,U,Xd(q.y0,q.x0),Xd(q.y1,q.x1),!d),t.arc(c,u,i,Xd(q.cy+q.y1,q.cx+q.x1),Xd(j.cy+j.y1,j.cx+j.x1),!d),G>0&&t.arc(c+j.cx,u+j.cy,G,Xd(j.y1,j.x1),Xd(j.y0,j.x0),!d))}else t.moveTo(c+D,u+P),t.arc(c,u,i,s,l,!d);else t.moveTo(c+D,u+P);if(r>Jd&&O)if(M>Jd){U=Qd(f,M),q=th(L,E,k,T,r,-(G=Qd(g,M)),d),j=th(D,P,A,I,r,-U,d);t.lineTo(c+q.cx+q.x0,u+q.cy+q.y0),M0&&t.arc(c+q.cx,u+q.cy,G,Xd(q.y0,q.x0),Xd(q.y1,q.x1),!d),t.arc(c,u,r,Xd(q.cy+q.y1,q.cx+q.x1),Xd(j.cy+j.y1,j.cx+j.x1),d),U>0&&t.arc(c+j.cx,u+j.cy,U,Xd(j.y1,j.x1),Xd(j.y0,j.x0),!d))}else t.lineTo(c+L,u+E),t.arc(c,u,r,l,s,d);else t.lineTo(c+L,u+E)}else t.moveTo(c,u);t.closePath()}}}var nh=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},ih=function(t){function e(e){return t.call(this,e)||this}return b(e,t),e.prototype.getDefaultShape=function(){return new nh},e.prototype.buildPath=function(t,e){eh(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(Qc);ih.prototype.type="sector";var rh=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},oh=function(t){function e(e){return t.call(this,e)||this}return b(e,t),e.prototype.getDefaultShape=function(){return new rh},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(Qc);function ah(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],c=[],u=[],d=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var h=0,p=t.length;hAh[1]){if(r=!1,Ih.negativeSize||n)return r;var s=kh(Ah[0]-Th[1]),l=kh(Th[0]-Ah[1]);Ch(s,l)>Ph.len()&&(s=l||!Ih.bidirectional)&&(Qi.scale(Dh,a,-l*i),Ih.useDir&&Ih.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var d={duration:u.duration,delay:u.delay||0,easing:u.easing,done:o,force:!!o||!!a,setToFinal:!c,scope:t,during:a};l?e.animateFrom(n,d):e.animateTo(n,d)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function Rh(t,e,n,i,r,o){Nh("update",t,e,n,i,r,o)}function Hh(t,e,n,i,r,o){Nh("enter",t,e,n,i,r,o)}function Bh(t){if(!t.__zr)return!0;for(var e=0;e=-1e-6)return!1;var f=t-r,g=e-o,v=op(f,g,c,u)/p;if(v<0||v>1)return!1;var m=op(f,g,d,h)/p;return!(m<0||m>1)}function op(t,e,n,i){return t*i-n*e}function ap(t,e,n,i,r){return null==e||(Hn(e)?sp[0]=sp[1]=sp[2]=sp[3]=e:(sp[0]=e[0],sp[1]=e[1],sp[2]=e[2],sp[3]=e[3]),i&&(sp[0]=Ss(0,sp[0]),sp[1]=Ss(0,sp[1]),sp[2]=Ss(0,sp[2]),sp[3]=Ss(0,sp[3])),n&&(sp[0]=-sp[0],sp[1]=-sp[1],sp[2]=-sp[2],sp[3]=-sp[3]),lp(t,sp,"x","width",3,1,r&&r[0]||0),lp(t,sp,"y","height",0,2,r&&r[1]||0)),t}var sp=[0,0,0,0];function lp(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=Ss(0,ws(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:Cs(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function cp(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=Nn(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&Tn(Pn(l),function(t){ai(s,t)||(s[t]=l[t],s.$vars.push(t))});var c=Tu(t.el);c.componentMainType=o,c.componentIndex=a,c.tooltipConfig={name:i,option:Sn({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function up(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function dp(t,e){if(t)if(On(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}},clipPointsByRect:function(t,e){return An(t,function(t){var n=t[0];n=Ss(n,e.x),n=ws(n,e.x+e.width);var i=t[1];return i=Ss(i,e.y),[n,i=ws(i,e.y+e.height)]})},clipRectByRect:function(t,e){var n=Ss(t.x,e.x),i=ws(t.x+t.width,e.x+e.width),r=Ss(t.y,e.y),o=ws(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}},createIcon:ip,ensureCopyRect:fp,ensureCopyTransform:gp,expandOrShrinkRect:ap,extendPath:function(t,e){return Gh(t,e)},extendShape:function(t){return Qc.extend(t)},getShapeClass:function(t){if(Vh.hasOwnProperty(t))return Vh[t]},getTransform:function(t,e){for(var n=qi([]);t&&t!==e;)Xi(n,t.getLocalTransform(),n),t=t.parent;return n},groupTransition:np,initProps:Hh,isBoundingRectAxisAligned:hp,isElementRemoved:Bh,lineLineIntersect:rp,linePolygonIntersect:function(t,e,n,i,r){for(var o=0,a=r[r.length-1];oCs(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"},traverseElements:dp,traverseUpdateZ:mp,updateProps:Rh}),bp={};function xp(t,e,n){var i,r=t.labelFetcher,o=t.labelDataIndex,a=t.labelDimIndex,s=e.normal;r&&(i=r.getFormattedLabel(o,"normal",null,a,s&&s.get("formatter"),null!=n?{interpolatedValue:n}:null)),null==i&&(i=zn(t.defaultText)?t.defaultText(o,t,n):t.defaultText);for(var l={normal:i},c=0;c-1?jp:Yp;function Jp(t,e){t=t.toUpperCase(),Kp[t]=new Up(e),Zp[t]=e}Jp(Xp,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),Jp(jp,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});function tf(){return null}var ef=1e3,nf=6e4,rf=36e5,of=864e5,af=31536e6,sf={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},lf={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},cf="{yyyy}-{MM}-{dd}",uf={year:"{yyyy}",month:"{yyyy}-{MM}",day:cf,hour:cf+" "+lf.hour,minute:cf+" "+lf.minute,second:cf+" "+lf.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},df=["year","month","day","hour","minute","second","millisecond"],hf=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function pf(t){return Nn(t)||zn(t)?t:function(t){t=t||{};var e={},n=!0;return Tn(df,function(e){n&&(n=null==t[e])}),Tn(df,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=df[s],c=Bn(o)&&!On(o)?o[l]:o,u=void 0;On(c)?a=(u=c.slice())[0]||"":Nn(c)?u=[a=c]:(null==a?a=lf[i]:sf[l].test(a)||(a=e[l][l][0]+" "+a),u=[a],n&&(u[1]="{primary|"+a+"}")),e[i][l]=u}}),e}(t)}function ff(t,e){return"0000".substr(0,e-(t+="").length)+t}function gf(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function vf(t){return t===gf(t)}function mf(t,e,n,i){var r=Os(t),o=r[bf(n)](),a=r[xf(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[wf(n)](),c=r["get"+(n?"UTC":"")+"Day"](),u=r[Sf(n)](),d=(u-1)%12+1,h=r[Cf(n)](),p=r[Mf(n)](),f=r[kf(n)](),g=u>=12?"pm":"am",v=g.toUpperCase(),m=i instanceof Up?i:function(t){return Kp[t]}(i||Qp)||Kp[Yp],y=m.getModel("time"),_=y.get("month"),b=y.get("monthAbbr"),x=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,ff(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[a-1]).replace(/{MMM}/g,b[a-1]).replace(/{MM}/g,ff(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,ff(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,x[c]).replace(/{ee}/g,w[c]).replace(/{e}/g,c+"").replace(/{HH}/g,ff(u,2)).replace(/{H}/g,u+"").replace(/{hh}/g,ff(d+"",2)).replace(/{h}/g,d+"").replace(/{mm}/g,ff(h,2)).replace(/{m}/g,h+"").replace(/{ss}/g,ff(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,ff(f,3)).replace(/{S}/g,f+"")}function yf(t,e){var n=Os(t),i=n[xf(e)]()+1,r=n[wf(e)](),o=n[Sf(e)](),a=n[Cf(e)](),s=n[Mf(e)](),l=0===n[kf(e)](),c=l&&0===s,u=c&&0===a,d=u&&0===o,h=d&&1===r;return h&&1===i?"year":h?"month":d?"day":u?"hour":c?"minute":l?"second":"millisecond"}function _f(t,e,n){switch(e){case"year":t[Af(n)](0);case"month":t[If(n)](1);case"day":t[Df(n)](0);case"hour":t[Pf(n)](0);case"minute":t[Lf(n)](0);case"second":t[Ef(n)](0)}return t}function bf(t){return t?"getUTCFullYear":"getFullYear"}function xf(t){return t?"getUTCMonth":"getMonth"}function wf(t){return t?"getUTCDate":"getDate"}function Sf(t){return t?"getUTCHours":"getHours"}function Cf(t){return t?"getUTCMinutes":"getMinutes"}function Mf(t){return t?"getUTCSeconds":"getSeconds"}function kf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function Tf(t){return t?"setUTCFullYear":"setFullYear"}function Af(t){return t?"setUTCMonth":"setMonth"}function If(t){return t?"setUTCDate":"setDate"}function Df(t){return t?"setUTCHours":"setHours"}function Pf(t){return t?"setUTCMinutes":"setMinutes"}function Lf(t){return t?"setUTCSeconds":"setSeconds"}function Ef(t){return t?"setUTCMilliseconds":"setMilliseconds"}function Of(t){if(isNaN(Rs(t)))return Nn(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function zf(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var Nf=Yn;function Rf(t,e,n){function i(t){return t&&Kn(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?Os(t):t;if(!isNaN(+s))return mf(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return Rn(t)?i(t):Hn(t)&&r(t)?t+"":"-";var l=Rs(t);return r(l)?Of(l):Rn(t)?i(t):"boolean"==typeof t?t+"":"-"}var Hf=["a","b","c","d","e","f","g"],Bf=function(t,e){return"{"+t+(null==e?"":e)+"}"};function Ff(t,e,n){On(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;oi||l.newline?(o=0,u=g,a+=s+n,s=h.height):s=Math.max(s,h.height)}else{var v=h.height+(f?-f.y+h.y:0);(d=a+v)>r||l.newline?(o+=s+n,a=0,d=v,s=h.width):s=Math.max(s,h.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=u+n:a=d+n)})}function ng(t,e,n){n=Nf(n||0);var i=e.width,r=e.height,o=ks(t.left,i),a=ks(t.top,r),s=ks(t.right,i),l=ks(t.bottom,r),c=ks(t.width,i),u=ks(t.height,r),d=n[2]+n[0],h=n[1]+n[3],p=t.aspect;switch(isNaN(c)&&(c=i-s-h-o),isNaN(u)&&(u=r-l-d-a),null!=p&&(isNaN(c)&&isNaN(u)&&(p>i/r?c=.8*i:u=.8*r),isNaN(c)&&(c=p*u),isNaN(u)&&(u=c/p)),isNaN(o)&&(o=i-s-c-h),isNaN(a)&&(a=r-l-u-d),t.left||t.right){case"center":o=i/2-c/2-n[3];break;case"right":o=i-c-h}switch(t.top||t.bottom){case"middle":case"center":a=r/2-u/2-n[0];break;case"bottom":a=r-u-d}o=o||0,a=a||0,isNaN(c)&&(c=i-h-o-(s||0)),isNaN(u)&&(u=r-d-a-(l||0));var f=new hr((e.x||0)+o+n[3],(e.y||0)+a+n[0],c,u);return f.margin=n,f}En(eg,"vertical"),En(eg,"horizontal");var ig=1;function rg(t,e,n){var i,r,o,a,s=t.boxCoordinateSystem;if(s){var l=function(t){var e=t.getShallow("coord",!0),n=Gf;if(null==e){var i=jf.get(t.type);i&&i.getCoord2&&(n=qf,e=i.getCoord2(t))}return{coord:e,from:n}}(t),c=l.coord,u=l.from;if(s.dataToLayout){o=ig,a=u;var d=s.dataToLayout(c);i=d.contentRect||d.rect}}return null==o&&(o=ig),o===ig&&(i||(i={x:0,y:0,width:e.getWidth(),height:e.getHeight()}),r=[i.x+i.width/2,i.y+i.height/2]),{type:o,refContainer:i,refPoint:r,boxCoordFrom:a}}function og(t){var e=t.layoutMode||t.constructor.layoutMode;return Bn(e)?e:e?{type:e}:null}function ag(t,e,n){var i=n&&n.ignoreSize;!On(i)&&(i=[i,i]);var r=a(tg[0],0),o=a(tg[1],1);function a(n,r){var o={},a=0,l={},c=0;if(Qf(n,function(e){l[e]=t[e]}),Qf(n,function(t){ai(e,t)&&(o[t]=l[t]=e[t]),s(o,t)&&a++,s(l,t)&&c++}),i[r])return s(e,n[1])?l[n[2]]=null:s(e,n[2])&&(l[n[1]]=null),l;if(2!==c&&a){if(a>=2)return o;for(var u=0;u=0;a--)o=xn(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return cl(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return e=!1,{left:(t=this).getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)};var t,e},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((n=e.prototype).type="component",n.id="",n.name="",n.mainType="",n.subType="",void(n.componentIndex=0)),e}(Up);gl(cg,Up),_l(cg),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=pl(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=pl(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(cg),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return Tn(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return Tn(t,function(t){Cn(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),Tn(s,function(t){Cn(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);Cn(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,c={};for(Tn(t,function(t){c[t]=!0});l.length;){var u=l.pop(),d=s[u],h=!!c[u];h&&(r.call(o,u,d.originalDeps.slice()),delete c[u]),Tn(d.successor,h?f:p)}Tn(c,function(){throw new Error("")})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){c[t]=!0,p(t)}}}(cg,function(t){var e=[];Tn(cg.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=An(e,function(t){return pl(t).main}),"dataset"!==t&&Cn(e,"dataset")<=0&&e.unshift("dataset");return e});var ug={color:{},darkColor:{},size:{}},dg=ug.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var hg in wn(dg,{primary:dg.neutral80,secondary:dg.neutral70,tertiary:dg.neutral60,quaternary:dg.neutral50,disabled:dg.neutral20,border:dg.neutral30,borderTint:dg.neutral20,borderShade:dg.neutral40,background:dg.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:dg.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:dg.neutral70,axisLineTint:dg.neutral40,axisTick:dg.neutral70,axisTickMinor:dg.neutral60,axisLabel:dg.neutral70,axisSplitLine:dg.neutral15,axisMinorSplitLine:dg.neutral05}),dg)if(dg.hasOwnProperty(hg)){var pg=dg[hg];"theme"===hg?ug.darkColor.theme=dg.theme.slice():"highlight"===hg?ug.darkColor.highlight="rgba(255,231,130,0.4)":0===hg.indexOf("accent")?ug.darkColor[hg]=Io(pg,0,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):ug.darkColor[hg]=Io(pg,0,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}ug.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var fg="";"undefined"!=typeof navigator&&(fg=navigator.platform||"");var gg="rgba(0, 0, 0, 0.2)",vg=ug.color.theme[0],mg=Io(vg,0,null,.9),yg={darkMode:"auto",colorBy:"series",color:ug.color.theme,gradientColor:[mg,vg],aria:{decal:{decals:[{color:gg,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:gg,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:gg,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:gg,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:gg,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:gg,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:fg.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},_g=ri(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),bg="original",xg="arrayRows",wg="objectRows",Sg="keyedColumns",Cg="typedArray",Mg="unknown",kg="column",Tg="row",Ag=1,Ig=2,Dg=3,Pg=rl();function Lg(t,e,n){var i={},r=Eg(e);if(!r||!t)return i;var o,a,s=[],l=[],c=e.ecModel,u=Pg(c).datasetMap,d=r.uid+"_"+n.seriesLayoutBy;Tn(t=t.slice(),function(e,n){var r=Bn(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var h=u.get(d)||u.set(d,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if(u=u||n,!u||!u.length)return;var d=u[l];r&&(c[r]=d);return s.paletteIdx=(l+1)%u.length,d}(this,Ng,i,r,t,e,n)},t.prototype.clearColorPalette=function(){!function(t,e){e(t).paletteIdx=0,e(t).paletteNameMap={}}(this,Ng)},t}();var $g="\0_ec_inner",Vg=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return b(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new Up(i),this._locale=new Up(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=Gg(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,Gg(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):Bg(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&Tn(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=ri(),s=e&&e.replaceMergeMainTypeMap;Pg(this).datasetMap=ri(),Tn(t,function(t,e){null!=t&&(cg.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?bn(t):xn(n[e],t,!0))}),s&&s.each(function(t,e){cg.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),cg.topologicalTravel(o,cg.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=zg.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,qs(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",c=Ks(a,o,l);(function(t,e,n){Tn(t,function(t){var i=t.newOption;Bn(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(c,e,cg),n[e]=null,i.set(e,null),r.set(e,0);var u,d=[],h=[],p=0;Tn(c,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=cg.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(u)return;u=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=wn({componentIndex:n},t.keyInfo);wn(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(d.push(i.option),h.push(i),p++):(d.push(void 0),h.push(void 0))},this),n[e]=d,i.set(e,h),r.set(e,p),"series"===e&&Rg(this)},this),this._seriesIndices||Rg(this)},e.prototype.getOption=function(){var t=bn(this.option);return Tn(t,function(e,n){if(cg.hasClass(n)){for(var i=qs(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!nl(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[$g],t},e.prototype.setTheme=function(t){this._theme=new Up(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var Kg=Tn,Qg=Bn,Jg=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function tv(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Jg.length;nc&&(c=p)}s[0]=l,s[1]=c}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return Wv(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function qv(t){var e,n;return Bn(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function jv(t){return new Xv(t)}var Xv=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=u(this._modBy),s=this._modDataCount||0,l=u(t&&t.modBy),c=t&&t.modDataCount||0;function u(t){return!(t>=1)&&(t=1),t}a===l&&s===c||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=c;var d=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var h=this._dueIndex,p=Math.min(null!=d?this._dueIndex+d:1/0,this._dueEnd);if(!i&&(o||h1&&i>0?s:a}};return o;function a(){return e=t?null:oi?-this._resultLT:0},t}(),Qv=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return Zv(t,e)},t}();function Jv(t){if(!om(t.sourceFormat)){Ws("")}return t.data}function tm(t){var e=t.sourceFormat,n=t.data;if(!om(e)){Ws("")}if(e===xg){for(var i=[],r=0,o=n.length;r65535?lm:cm}function fm(){return[1/0,-1/0]}function gm(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function vm(t,e,n,i,r){var o=hm[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),c=0;cg[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=An(o,function(t){return t.property}),c=0;cv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=c&&_<=u||isNaN(_))&&(a[s++]=p),p++}h=!0}else if(2===r){f=d[i[0]];var v=d[i[1]],m=t[i[1]][0],y=t[i[1]][1];for(g=0;g=c&&_<=u||isNaN(_))&&(b>=m&&b<=y||isNaN(b))&&(a[s++]=p),p++}h=!0}}if(!h)if(1===r)for(g=0;g=c&&_<=u||isNaN(_))&&(a[s++]=x)}else for(g=0;gt[C][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,c=Math.floor(1/e),u=this.getRawIndex(0),d=new(pm(this._rawCount))(Math.min(2*(Math.ceil(s/c)+2),s));d[l++]=u;for(var h=1;hn&&(n=i,r=M)}C>0&&Ca&&(f=a-c);for(var g=0;gp&&(p=v,h=c+g)}var m=this.getRawIndex(u),y=this.getRawIndex(h);uc-p&&(s=c-p,a.length=s);for(var f=0;fu[1]&&(u[1]=v),d[h++]=m}return r._count=h,r._indices=d,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return Zv(t[i],this._dimensions[i])}am={arrayRows:t,objectRows:function(t,e,n,i){return Zv(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return Zv(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),ym=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(_m(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var c=i[0];c.prepareSource(),a=(l=c.getSource()).data,s=l.sourceFormat,e=[c._getVersionSign()]}else s=$n(a=o.get("data",!0))?Cg:bg,e=[];var u=this._getSourceMetaRawOption()||{},d=l&&l.metaRawOption||{},h=qn(u.seriesLayoutBy,d.seriesLayoutBy)||null,p=qn(u.sourceHeader,d.sourceHeader),f=qn(u.dimensions,d.dimensions);t=h!==d.seriesLayoutBy||!!p!=!!d.sourceHeader||f?[Mv(a,{seriesLayoutBy:h,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[Mv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){1!==t.length&&bm("")}var o,a=[],s=[];return Tn(t,function(t){t.prepareSource();var e=t.getSource(r||0);null==r||e||bm(""),a.push(e),s.push(t._getVersionSign())}),i?e=function(t,e){var n=qs(t),i=n.length;i||Ws("");for(var r=0,o=i;r1||n>0&&!t.noHeader;return Tn(t.blocks,function(t){var n=Am(t);n>=e&&(e=n+ +(i&&(!n||km(t)&&!t.noHeader)))}),e}return 0}function Im(t,e,n,i){var r,o=e.noHeader,a=(r=Am(e),{html:Sm[r],richText:Cm[r]}),s=[],l=e.blocks||[];Zn(!l||On(l)),l=l||[];var c=t.orderMode;if(e.sortBlocks&&c){l=l.slice();var u={valueAsc:"asc",valueDesc:"desc"};if(ai(u,c)){var d=new Kv(u[c],null);l.sort(function(t,e){return d.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===c&&l.reverse()}Tn(l,function(n,r){var o=e.valueFormatter,l=Tm(n)(o?wn(wn({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var h="richText"===t.renderMode?s.join(a.richText):Lm(i,s.join(""),o?n:a.html);if(o)return h;var p=Rf(e.header,"ordinal",t.useUTC),f=wm(i,t.renderMode).nameStyle,g=xm(i);return"richText"===t.renderMode?Em(t,p,f)+a.richText+h:Lm(i,'
'+Ei(p)+"
"+h,n)}function Dm(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,c=t.useUTC,u=e.valueFormatter||t.valueFormatter||function(t){return An(t=On(t)?t:[t],function(t,e){return Rf(t,On(p)?p[e]:p,c)})};if(!o||!a){var d=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||ug.color.secondary,r),h=o?"":Rf(l,"ordinal",c),p=e.valueType,f=a?[]:u(e.value,e.dataIndex),g=!s||!o,v=!s&&o,m=wm(i,r),y=m.nameStyle,_=m.valueStyle;return"richText"===r?(s?"":d)+(o?"":Em(t,h,y))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(On(e)?e.join(" "):e,o)}(t,f,g,v,_)):Lm(i,(s?"":d)+(o?"":function(t,e,n){return''+Ei(t)+""}(h,!s,y))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=On(t)?t:[t],''+An(t,function(t){return Ei(t)}).join("  ")+""}(f,g,v,_)),n)}}function Pm(t,e,n,i,r,o){if(t)return Tm(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function Lm(t,e,n){return'
'+e+'
'}function Em(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function Om(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var zm=function(){function t(){this.richTextStyles={},this._nextStyleNameId=Hs()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=function(t,e){var n=Nn(t)?{color:t,extraCssText:e}:t||{},i=n.color,r=n.type;e=n.extraCssText;var o=n.renderMode||"html";return i?"html"===o?"subItem"===r?'':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}({color:e,type:t,renderMode:n,markerId:i});return Nn(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};On(e)?Tn(e,function(t){return wn(n,t)}):wn(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function Nm(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),c=l.mapDimensionsAll("defaultedTooltip"),u=c.length,d=o.getRawValue(a),h=On(d),p=function(t,e){return $f(t.getData().getItemVisual(e,"style")[t.visualDrawType])}(o,a);if(u>1||h&&!u){var f=function(t,e,n,i,r){var o=e.getData(),a=In(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],c=[];function u(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?c.push(Mm("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?Tn(i,function(t){u(Wv(o,n,t),t)}):Tn(t,u),{inlineValues:s,inlineValueTypes:l,blocks:c}}(d,o,a,c,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(u){var g=l.getDimensionInfo(c[0]);r=e=Wv(l,a,c[0]),n=g.type}else r=e=h?d[0]:d;var v=el(o),m=v&&o.name||"",y=l.getName(a),_=s?m:y;return Mm("section",{header:m,noHeader:s||!v,sortParam:r,blocks:[Mm("nameValue",{markerType:"item",markerColor:p,name:_,noName:!Kn(_),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var Rm=rl();function Hm(t,e){return t.getName(e)||t.getId(e)}var Bm=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var n;return b(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=jv({count:$m,reset:Vm}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(Rm(this).sourceManager=new ym(this)).prepareSource();var i=this.getInitialData(t,n);Um(i,this),this.dataTask.context.data=i,Rm(this).dataBeforeProcessed=i,Fm(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=og(this),i=n?sg(t):{},r=this.subType;cg.hasClass(r)&&(r+="Series"),xn(t,e.getTheme().get(this.subType)),xn(t,this.getDefaultOption()),js(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&ag(t,i,n)},e.prototype.mergeOption=function(t,e){t=xn(this.option,t,!0),this.fillDataTextStyle(t.data);var n=og(this);n&&ag(this.option,t,n);var i=Rm(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);Um(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,Rm(this).dataBeforeProcessed=r,Fm(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!$n(t))for(var e=["show"],n=0;n=0&&u<0)&&(c=o,u=r,d=0),r===u&&(l[d++]=e))}),l.length=d,l},e.prototype.formatTooltip=function(t,e,n){return Nm({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(Qe.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=Fg.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[Hm(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this.__universalTransitionEnabled)return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){Bn(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return cg.registerClass(t)},e.protoInitialize=((n=e.prototype).type="series.__base__",n.seriesIndex=0,n.ignoreStyleOnData=!1,n.hasSymbolVisual=!1,n.defaultSymbol="circle",n.visualStyleAccessPath="itemStyle",void(n.visualDrawType="fill")),e}(cg);function Fm(t){var e=t.name;el(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return Tn(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function $m(t){return t.model.getRawData().count()}function Vm(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),Wm}function Wm(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function Um(t,e){Tn(function(t,e){for(var n=new t.constructor(t.length+e.length),i=0;i=0?d():u=setTimeout(d,-r),l=i};return h.clear=function(){u&&(clearTimeout(u),u=null)},h.debounceNextCall=function(t){s=t},h}function sy(t,e,n,i){var r=t[e];if(r){var o=r[iy]||r,a=r[oy];if(r[ry]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=ay(o,n,"debounce"===i))[iy]=o,r[oy]=i,r[ry]=n}return r}}function ly(t,e){var n=t[e];n&&n[iy]&&(n.clear&&n.clear(),t[e]=n[iy])}var cy=rl(),uy={itemStyle:bl($p,!0),lineStyle:bl(Hp,!0)},dy={lineStyle:"stroke",itemStyle:"fill"};function hy(t,e){var n=t.visualStyleMapper||uy[e];return n||(console.warn("Unknown style type '"+e+"'."),uy.itemStyle)}function py(t,e){var n=t.visualDrawType||dy[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var fy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=hy(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=py(t,i),l=o[s],c=zn(l)?l:null,u="auto"===o.fill||"auto"===o.stroke;if(!o[s]||c||u){var d=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=d,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||zn(o.fill)?d:o.fill,o.stroke="auto"===o.stroke||zn(o.stroke)?d:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&c)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=wn({},o);r[s]=c(i),e.setItemVisual(n,"style",r)}}}},gy=new Up,vy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=hy(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){gy.option=n[i];var a=r(gy);wn(t.ensureUniqueItemVisual(e,"style"),a),gy.option.decal&&(t.setItemVisual(e,"decal",gy.option.decal),gy.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},my={performRawSeries:!0,overallReset:function(t){var e=ri();t.eachSeries(function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),cy(t).scope=r}}),t.eachSeries(function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=cy(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=py(e,a);r.each(function(t){var e=r.getRawIndex(t);i[e]=t}),n.each(function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),c=n.getName(t)||t+"",u=n.count();l[s]=e.getColorFromPalette(c,o,u)}})}})}},yy=Math.PI;var _y=function(){function t(t,e,n,i){this._stageTaskMap=ri(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=ri();t.eachSeries(function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;Tn(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{});Zn(!(i.reset&&i.overallReset),""),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}Tn(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),c=l.seriesTaskMap,u=l.overallTask;if(u){var d,h=u.agentStubMap;h.each(function(t){a(i,t)&&(t.dirty(),d=!0)}),d&&u.dirty(),o.updatePayload(u,n);var p=o.getPerformArgs(u,i.block);h.each(function(t){t.perform(p)}),u.perform(p)&&(r=!0)}else c&&c.each(function(s,l){a(i,s)&&s.dirty();var c=o.getPerformArgs(s,i.block);c.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(c)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=ri(),s=t.seriesType,l=t.getTargetSeries;function c(e){var s=e.uid,l=a.set(s,o&&o.get(s)||jv({plan:Cy,reset:My,count:Ay}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(c):s?n.eachRawSeriesByType(s,c):l&&l(n,i).each(c)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||jv({reset:by});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=ri(),l=t.seriesType,c=t.getTargetSeries,u=!0,d=!1;function h(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(d=!0,jv({reset:xy,onDirty:Sy})));n.context={model:t,overallProgress:u},n.agent=o,n.__block=u,r._pipe(t,n)}Zn(!t.createOnAllSeries,""),l?n.eachRawSeriesByType(l,h):c?c(n,i).each(h):(u=!1,Tn(n.getSeries(),h)),d&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return zn(t)&&(t={overallReset:t,seriesType:Iy(t)}),t.uid=qp("stageHandler"),e&&(t.visualType=e),t},t}();function by(t){t.overallReset(t.ecModel,t.api,t.payload)}function xy(t){return t.overallProgress&&wy}function wy(){this.agent.dirty(),this.getDownstream().dirty()}function Sy(){this.agent&&this.agent.dirty()}function Cy(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function My(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=qs(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?An(e,function(t,e){return Ty(e)}):ky}var ky=Ty(0);function Ty(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&u===r.length-c.length){var d=r.slice(0,u);"data"!==d&&(e.mainType=d,e[c.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return c(s,o,"mainType")&&c(s,o,"subType")&&c(s,o,"index","componentIndex")&&c(s,o,"name")&&c(s,o,"id")&&c(l,r,"name")&&c(l,r,"dataIndex")&&c(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function c(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),$y=["symbol","symbolSize","symbolRotate","symbolOffset"],Vy=$y.concat(["symbolKeepAspect"]),Wy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a<$y.length;a++){var s=$y[a],l=t.get(s);zn(l)?(o=!0,r[s]=l):i[s]=l}if(i.symbol=i.symbol||t.defaultSymbol,n.setVisual(wn({legendIcon:t.legendIcon||i.symbol,symbolKeepAspect:t.get("symbolKeepAspect")},i)),!e.isSeriesFiltered(t)){var c=Pn(r);return{dataEach:o?function(e,n){for(var i=t.getRawValue(n),o=t.getDataParams(n),a=0;a=0&&a_(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=a_(i)?i:0,r=a_(r)?r:1,o=a_(o)?o:0,a=a_(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:Hn(e)?[e]:On(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=An(r,function(t){return t/a}),o/=a)}return[r,o]}var d_=new Pc(!0);function h_(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function p_(t){return"string"==typeof t&&"none"!==t}function f_(t){var e=t.fill;return null!=e&&"none"!==e}function g_(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function v_(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function m_(t,e,n){var i=Ml(e.image,e.__image,n);if(Tl(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*li),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var y_=["shadowBlur","shadowOffsetX","shadowOffsetY"],__=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function b_(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){S_(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?jl.opacity:a}(i||e.blend!==n.blend)&&(o||(S_(t,r),o=!0),t.globalCompositeOperation=e.blend||jl.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[H_])if(this._disposed)this.id;else{var i,r,o;if(Bn(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[H_]=!0,db(this),!this._model||e){var a=new Yg(this._api),s=this._theme,l=this._model=new Vg;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},xb);var c={seriesTransition:o,optionChanged:!0};if(n)this[F_]={silent:i,updateParams:c},this[H_]=!1,this.getZr().wakeUp();else{try{j_(this),Z_.update.call(this,null,c)}catch(t){throw this[F_]=null,this[H_]=!1,t}this._ssr||this._zr.flush(),this[F_]=null,this[H_]=!1,tb.call(this,i),eb.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[H_])if(this._disposed)this.id;else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[F_]&&(null==i&&(i=this[F_].silent),r=this[F_].updateParams,this[F_]=null),this[H_]=!0,db(this);try{this._updateTheme(t),n.setTheme(this._theme),j_(this),Z_.update.call(this,{type:"setTheme"},r)}catch(t){throw this[H_]=!1,t}this[H_]=!1,tb.call(this,i),eb.call(this,i)}}},e.prototype._updateTheme=function(t){Nn(t)&&(t=Sb[t]),t&&((t=bn(t))&&vv(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||Qe.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){return t=t||{},this._zr.painter.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){return t=t||{},this._zr.painter.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return Tn(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;Tn(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return Tn(i,function(t){t.group.ignore=!1}),o}this.id},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(kb[n]){var a=o,s=o,l=-1/0,c=-1/0,u=[],d=t&&t.pixelRatio||this.getDevicePixelRatio();Tn(Mb,function(o,d){if(o.group===n){var h=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(bn(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),c=r(p.bottom,c),u.push({dom:h,left:p.left,top:p.top})}});var h=(l*=d)-(a*=d),p=(c*=d)-(s*=d),f=on.createCanvas(),g=bs(f,{renderer:e?"svg":"canvas"});if(g.resize({width:h,height:p}),e){var v="";return Tn(u,function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""}),g.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&g.painter.setBackgroundColor(t.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}return t.connectedBackgroundColor&&g.add(new uu({shape:{x:0,y:0,width:h,height:p},style:{fill:t.connectedBackgroundColor}})),Tn(u,function(t){var e=new iu({style:{x:t.left*d-a,y:t.top*d-s,image:t.dom}});g.add(e)}),g.refreshImmediately(),f.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}this.id},e.prototype.convertToPixel=function(t,e,n){return K_(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return K_(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return K_(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return Tn(al(this._model,t),function(t,i){i.indexOf("Models")>=0&&Tn(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}},this)},this),!!n;this.id},e.prototype.getVisual=function(t,e){var n=al(this._model,t,{defaultMainType:"series"}),i=n.seriesModel.getData(),r=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?i.indexOfRawIndex(n.dataIndex):null;return null!=r?function(t,e,n){switch(n){case"color":return t.getItemVisual(e,"style")[t.getVisual("drawType")];case"opacity":return t.getItemVisual(e,"style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getItemVisual(e,n)}}(i,r,e):function(t,e){switch(e){case"color":return t.getVisual("style")[t.getVisual("drawType")];case"opacity":return t.getVisual("style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getVisual(e)}}(i,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;Tn(vb,function(e){var n=function(n){var i,r=t.getModel(),o=n.target;if("globalout"===e?i={}:o&&qy(o,function(t){var e=Tu(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=wn({},e.eventData),!0},!0),i){var a=i.componentType,s=i.componentIndex;"markLine"!==a&&"markPoint"!==a&&"markArea"!==a||(a="series",s=i.seriesIndex);var l=a&&null!=s&&r.getComponent(a,s),c=l&&t["series"===l.mainType?"_chartsMap":"_componentsMap"][l.__viewId];i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:l,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;Tn(_b,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(Gy("map","selectchanged",e,i,t),Gy("pie","selectchanged",e,i,t)):"select"===t.fromAction?(Gy("map","selected",e,i,t),Gy("pie","selected",e,i,t)):"unselect"===t.fromAction&&(Gy("map","unselected",e,i,t),Gy("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?this.id:this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)this.id;else{this._disposed=!0,this.getDom()&&ul(this.getDom(),Ab,"");var t=this,e=t._api,n=t._model;Tn(t._componentsViews,function(t){t.dispose(n,e)}),Tn(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete Mb[t.id]}},e.prototype.resize=function(t){if(!this[H_])if(this._disposed)this.id;else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[F_]&&(null==i&&(i=this[F_].silent),n=!0,this[F_]=null),this[H_]=!0,db(this);try{n&&j_(this),Z_.update.call(this,{type:"resize",animation:wn({duration:0},t&&t.animation)})}catch(t){throw this[H_]=!1,t}this[H_]=!1,tb.call(this,i),eb.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)this.id;else if(Bn(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),Cb[t]){var n=Cb[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?this.id:(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=wn({},t);return e.type=yb[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)this.id;else if(Bn(e)||(e={silent:!!e}),mb[t.type]&&this._model)if(this[H_])this._pendingActions.push(t);else{var n=e.silent;J_.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&Qe.browser.weChat&&this._throttledZrFlush(),tb.call(this,n),eb.call(this,n)}},e.prototype.updateLabelLayout=function(){E_.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)this.id;else{var e=t.seriesIndex;this.getModel().getSeriesByIndex(e).appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(Bh(t))return;if(t instanceof Qc&&function(t){var e=Du(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}j_=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),X_(t,!0),X_(t,!1),e.plan()},X_=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!Qe.node&&!Qe.worker&&e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)})}})}(t,e),E_.trigger("series:afterupdate",e,o,s)},cb=function(t){t[$_]=!0,t.getZr().wakeUp()},db=function(t){t[B_]=(t[B_]+1)%1e3},ub=function(t){t[$_]&&(t.getZr().storage.traverse(function(t){Bh(t)||e(t)}),t[$_]=!1)},sb=function(t){return new(function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return b(n,e),n.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},n.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},n.prototype.enterEmphasis=function(e,n){ed(e,n),cb(t)},n.prototype.leaveEmphasis=function(e,n){nd(e,n),cb(t)},n.prototype.enterBlur=function(e){!function(t){Yu(t,Uu)}(e),cb(t)},n.prototype.leaveBlur=function(e){id(e),cb(t)},n.prototype.enterSelect=function(e){rd(e),cb(t)},n.prototype.leaveSelect=function(e){od(e),cb(t)},n.prototype.getModel=function(){return t.getModel()},n.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},n.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},n.prototype.getMainProcessVersion=function(){return t[B_]},n}(jg))(t)},lb=function(t){function e(t,e){for(var n=0;n=0)){Nb.push(n);var o=_y.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function Hb(t,e){Cb[t]=e}var Bb=function(t){var e=(t=bn(t)).type;e||Ws("");var n=e.split(":");2!==n.length&&Ws("");var i=!1;"echarts"===n[0]&&(e=n[1],i=!0),t.__isBuiltIn=i,im.set(e,t)};function Fb(t,e,n,i){return{eventContent:{selected:hd(n),isFromClick:e.isFromClick||!1}}}function $b(t){return null==t?0:t.length||1}function Vb(t){return t}zb(z_,fy),zb(N_,vy),zb(N_,my),zb(z_,Wy),zb(N_,Uy),zb(7e3,function(t,e){t.eachRawSeries(function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each(function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=I_(n,e))});var r=i.getVisual("decal");if(r)i.getVisual("style").decal=I_(r,e)}})}),Pb(vv),Lb(900,function(t){var e=ri();t.eachSeries(function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.push(o)}}),e.each(function(t){0!==t.length&&("seriesDesc"===(t[0].seriesModel.get("stackOrder")||"seriesAsc")&&t.reverse(),Tn(t,function(e,n){e.data.setCalculationInfo("stackedOnSeries",n>0?t[n-1].seriesModel:null)}),function(t){Tn(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,c,u){var d,h,p=a.get(e.stackedDimension,u);if(isNaN(p))return r;s?h=a.getRawIndex(u):d=a.get(e.stackedByDimension,u);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(h=v.data.rawIndexOf(v.stackedByDimension,d)),h>=0){var m=v.data.getByRawIndex(v.stackResultDimension,h);if("all"===l||"positive"===l&&m>0||"negative"===l&&m<0||"samesign"===l&&p>=0&&m>0||"samesign"===l&&p<=0&&m<0){p=Ds(p,m),f=m;break}}}return i[0]=p,i[1]=f,i})})}(t))})}),Hb("default",function(t,e){Sn(e=e||{},{text:"loading",textColor:ug.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:ug.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new gs,i=new uu({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new fu({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new uu({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new _h({shape:{startAngle:-yy/2,endAngle:-yy/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*yy/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*yy/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),c=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:c}),a.setShape({x:l-s,y:c-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),Ob({type:Ou,event:Ou,update:Ou},si),Ob({type:zu,event:zu,update:zu},si),Ob({type:Nu,event:Bu,update:Nu,action:si,refineEvent:Fb,publishNonRefinedEvent:!0}),Ob({type:Ru,event:Bu,update:Ru,action:si,refineEvent:Fb,publishNonRefinedEvent:!0}),Ob({type:Hu,event:Bu,update:Hu,action:si,refineEvent:Fb,publishNonRefinedEvent:!0}),Db("default",{}),Db("dark",By);var Wb=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||Vb,this._newKeyGetter=i||Vb,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var c=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(c,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===d)this._updateManyToOne&&this._updateManyToOne(c,l),i[s]=null;else if(1===u&&d>1)this._updateOneToMany&&this._updateOneToMany(c,l),i[s]=null;else if(1===u&&1===d)this._update&&this._update(c,l),i[s]=null;else if(u>1&&d>1)this._updateManyToMany&&this._updateManyToMany(c,l),i[s]=null;else if(u>1)for(var h=0;h1)for(var a=0;a30}var tx,ex,nx,ix,rx,ox,ax,sx=Bn,lx=An,cx="undefined"==typeof Int32Array?Array:Int32Array,ux=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],dx=["_approximateExtent"],hx=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;Zb(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},c=0;c=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===bg&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(On(r=this.getVisual(e))?r=r.slice():sx(r)&&(r=wn({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,sx(e)?wn(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){sx(t)?wn(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?wn(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){!function(t,e,n,i){if(i){var r=Tu(i);r.dataIndex=n,r.dataType=e,r.seriesIndex=t,r.ssrType="chart","group"===i.type&&i.traverse(function(i){var r=Tu(i);r.seriesIndex=t,r.dataIndex=n,r.dataType=e,r.ssrType="chart"})}}(this.hostModel&&this.hostModel.seriesIndex,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){Tn(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:lx(this.dimensions,this._getDimInfo,this),this.hostModel)),rx(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];zn(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(Xn(arguments)))})},t.internalField=(tx=function(t){var e=t._invertedIndicesMap;Tn(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new cx(o.categories.length);for(var s=0;s1&&(s+="__ec__"+c),i[e]=s}})),t}();function px(t,e){Cv(t)||(t=kv(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=ri(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return Tn(e,function(t){var e;Bn(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Jb(a),l=i===t.dimensionsDefine,c=l?Qb(t):Kb(i),u=e.encodeDefine;!u&&e.encodeDefaulter&&(u=e.encodeDefaulter(t,a));for(var d=ri(u),h=new um(a),p=0;p0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new Yb({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function fx(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var gx=function(t){this.coordSysDims=[],this.axisMap=ri(),this.categoryAxisMap=ri(),this.coordSysName=t};var vx={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",ll).models[0],o=t.getReferringComponents("yAxis",ll).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),mx(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),mx(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",ll).models[0];e.coordSysDims=["single"],n.set("single",r),mx(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",ll).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),mx(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),mx(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();Tn(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),mx(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",ll).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function mx(t){return"category"===t.get("type")}function yx(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!Zb(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,c,u,d,h=!(!t||!t.get("stack"));if(Tn(i,function(t,e){Nn(t)&&(i[e]=t={name:t}),h&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),c||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(c=t))}),!c||a||l||(a=!0),c){u="__\0ecstackresult_"+t.id,d="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var p=c.coordDim,f=c.type,g=0;Tn(i,function(t){t.coordDim===p&&g++});var v={name:u,coordDim:p,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},m={name:d,coordDim:d,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(v.storeDimIndex=o.ensureCalculationDimension(d,f),m.storeDimIndex=o.ensureCalculationDimension(u,f)),r.appendCalculationDimension(v),r.appendCalculationDimension(m)):(i.push(v),i.push(m))}return{stackedDimension:c&&c.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:d,stackResultDimension:u}}function _x(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function bx(t,e,n){n=n||{};var i,r,o=e.getSourceManager();r=(i=o.getSource()).sourceFormat===bg;var a=function(t){var e=t.get("coordinateSystem"),n=new gx(e),i=vx[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=Uf.get(i);return e&&e.coordSysDims&&(n=An(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=function(t){return"category"===t?"ordinal":"time"===t?"time":"float"}(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,c=zn(l)?l:l?En(Lg,s,e):null,u=px(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:c,canOmitUnusedDimensions:!r}),d=function(t,e,n){var i,r;return n&&Tn(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(u.dimensions,n.createInvertedIndices,a),h=r?null:o.getSharedDataStore(u),p=yx(e,{schema:u,store:h}),f=new hx(u,e);f.setCalculationInfo(p);var g=null!=d&&function(t){if(t.sourceFormat===bg){var e=function(t){var e=0;for(;er&&(a=o.interval=r);var s=o.intervalPrecision=Mx(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),kx(t,0,e),kx(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(o.niceTickExtent=[As(Math.ceil(t[0]/a)*a,s),As(Math.floor(t[1]/a)*a,s)],t),o}function Cx(t){var e=Math.pow(10,zs(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,As(n*e)}function Mx(t){return Is(t)+2}function kx(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function Tx(t,e){return t>=e[0]&&t<=e[1]}var Ax=function(){function t(){this.normalize=Ix,this.scale=Dx}return t.prototype.updateMethods=function(t){t.hasBreaks()?(this.normalize=Ln(t.normalize,t),this.scale=Ln(t.scale,t)):(this.normalize=Ix,this.scale=Dx)},t}();function Ix(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function Dx(t,e){return t*(e[1]-e[0])+e[0]}function Px(t,e,n){var i=Math.log(t);return[Math.log(n?e[0]:Math.max(0,e[0]))/i,Math.log(n?e[1]:Math.max(0,e[1]))/i]}var Lx=function(){function t(t){this._calculator=new Ax,this._setting=t||{},this._extent=[1/0,-1/0]}return t.prototype.getSetting=function(t){return this._setting[t]},t.prototype._innerUnionExtent=function(t){var e=this._extent;this._innerSetExtent(t[0]e[1]?t[1]:e[1])},t.prototype.unionExtentFromData=function(t,e){this._innerUnionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){this._innerSetExtent(t,e)},t.prototype._innerSetExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e),this._brkCtx&&this._brkCtx.update(n)},t.prototype.setBreaksFromOption=function(t){},t.prototype._innerSetBreak=function(t){this._brkCtx&&(this._brkCtx.setBreaks(t),this._calculator.updateMethods(this._brkCtx),this._brkCtx.update(this._extent))},t.prototype._innerGetBreaks=function(){return this._brkCtx?this._brkCtx.breaks:[]},t.prototype.hasBreaks=function(){return!!this._brkCtx&&this._brkCtx.hasBreaks()},t.prototype._getExtentSpanWithBreaks=function(){return this._brkCtx&&this._brkCtx.hasBreaks()?this._brkCtx.getExtentSpan():this._extent[1]-this._extent[0]},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();_l(Lx);var Ex=0,Ox=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++Ex,this._onCollect=t.onCollect}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&An(i,zx);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!Nn(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,this._onCollect&&this._onCollect(t,e),e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e),this._onCollect&&this._onCollect(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=ri(this.categories))},t}();function zx(t){return Bn(t)&&null!=t.value?t.value:t+""}var Nx=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new Ox({})),On(i)&&(i=new Ox({categories:An(i,function(t){return Bn(t)?t.value:t})})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return b(e,t),e.prototype.parse=function(t){return null==t?NaN:Nn(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return Tx(t,this._extent)&&t>=0&&t=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(Lx);Lx.registerClass(Nx);var Rx=As,Hx=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return b(e,t),e.prototype.parse=function(t){return null==t||""===t?NaN:Number(t)},e.prototype.contain=function(t){return Tx(t,this._extent)},e.prototype.normalize=function(t){return this._calculator.normalize(t,this._extent)},e.prototype.scale=function(t){return this._calculator.scale(t,this._extent)},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=Mx(t)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;t.breakTicks;n[0]=0&&(s=Rx(s+l*e,r))}if(o.length>0&&s===o[o.length-1].value)break;if(o.length>1e4)return[]}var c=o.length?o[o.length-1].value:i[1];return n[1]>c&&(t.expandToNicedExtent?o.push({value:Rx(c+e,r)}):o.push({value:n[1]})),t.breakTicks,o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks({expandToNicedExtent:!0}),n=[],i=this.getExtent(),r=1;ri[0]&&d0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return Tn(t,function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),c=r.scale.getExtent(),u=Math.abs(c[1]-c[0]);i=s?l/u*s:l}else{var d=t.getData();i=Math.abs(o[1]-o[0])/d.count()}var h=ks(t.get("barWidth"),i),p=ks(t.get("barMaxWidth"),i),f=ks(t.get("barMinWidth")||(function(t){return t.pipelineContext&&t.pipelineContext.large}(t)?.5:1),i),g=t.get("barGap"),v=t.get("barCategoryGap"),m=t.get("defaultBarGap");n.push({bandWidth:i,barWidth:h,barMaxWidth:p,barMinWidth:f,barGap:g,barCategoryGap:v,defaultBarGap:m,axisKey:Wx(r),stackId:Vx(t)})}),function(t){var e={};Tn(t,function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:t.defaultBarGap||0,stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var c=t.barMaxWidth;c&&(a[s].maxWidth=c);var u=t.barMinWidth;u&&(a[s].minWidth=u);var d=t.barGap;null!=d&&(o.gap=d);var h=t.barCategoryGap;null!=h&&(o.categoryGap=h)});var n={};return Tn(e,function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=Pn(i).length;o=Math.max(35-4*a,15)+"%"}var s=ks(o,r),l=ks(t.gap,1),c=t.remainedWidth,u=t.autoWidthCount,d=(c-s)/(u+(u-1)*l);d=Math.max(d,0),Tn(i,function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,c-=i+l*i,u--}else{var i=d;e&&ei&&(i=n),i!==d&&(t.width=i,c-=i+l*i,u--)}}),d=(c-s)/(u+(u-1)*l),d=Math.max(d,0);var h,p=0;Tn(i,function(t,e){t.width||(t.width=d),h=t,p+=t.width*(1+l)}),h&&(p-=h.width*l);var f=-p/2;Tn(i,function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)})}),n}(n)}var Gx=function(t){function e(e){var n=t.call(this,e)||this;return n.type="time",n}return b(e,t),e.prototype.getLabel=function(t){var e=this.getSetting("useUTC");return mf(t.value,uf[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(gf(this._minLevelUnit))]||uf.second,e,this.getSetting("locale"))},e.prototype.getFormattedLabel=function(t,e,n){var i=this.getSetting("useUTC");return function(t,e,n,i,r){var o=null;if(Nn(n))o=n;else if(zn(n)){var a={time:t.time,level:t.time.level},s=null;s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var c=n[l.lowerTimeUnit][l.upperTimeUnit];o=c[Math.min(l.level,c.length-1)]||""}else{var u=yf(t.value,r);o=n[u][u][0]}}return mf(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=[];if(!e)return i;var r=this.getSetting("useUTC"),o=yf(n[1],r);i.push({value:n[0],time:{level:0,upperTimeUnit:o,lowerTimeUnit:o}});var a=function(t,e,n,i,r,o){var a=1e4,s=hf,l=0;function c(t,e,n,r,s,c,u){for(var d=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),h=e,p=new Date(h);ha));)if(p[s](p[r]()+t),h=p.getTime(),o){var f=o.calcNiceTickMultiple(h,d);f>0&&(p[s](p[r]()+f*t),h=p.getTime())}u.push({value:h,notAdd:!0})}function u(t,r,o){var a=[],s=!r.length;if(!function(t,e,n,i){return _f(new Date(e),t,i).getTime()===_f(new Date(n),t,i).getTime()}(gf(t),i[0],i[1],n)){s&&(r=[{value:Qx(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&u<=i[1]&&c(h,u,d,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-h})}}for(l=0;l=i[0]&&_<=i[1]&&p++)}var b=r/e;if(p>1.5*b&&f>b/1.5)break;if(d.push(m),p>b||t===s[g])break}h=[]}}var x=Dn(An(d,function(t){return Dn(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=[],S=x.length-1;for(g=0;gn&&(this._approxInterval=n);var r=qx.length,o=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Xx(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function Yx(t){return(t/=rf)>12?12:t>6?6:t>3.5?4:t>2?2:1}function Zx(t,e){return(t/=e?nf:ef)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function Kx(t){return Ns(t)}function Qx(t,e,n){var i=Math.max(0,Cn(df,e)-1);return _f(new Date(t),df[i],n).getTime()}Lx.registerClass(Gx);var Jx=As,tw=Math.floor,ew=Math.ceil,nw=Math.pow,iw=Math.log,rw=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new Hx,e}return b(e,t),e.prototype.getTicks=function(e){e=e||{};var n=this._extent.slice(),i=this._originalScale.getExtent(),r=t.prototype.getTicks.call(this,e),o=this.base;return this._originalScale._innerGetBreaks(),An(r,function(t){var e=t.value,r=null,a=nw(o,e);return e===n[0]&&this._fixMin?r=i[0]:e===n[1]&&this._fixMax&&(r=i[1]),null!=r&&(a=ow(a,r)),{value:a,break:void 0}},this)},e.prototype._getNonTransBreaks=function(){return this._originalScale._innerGetBreaks()},e.prototype.setExtent=function(e,n){this._originalScale.setExtent(e,n);var i=Px(this.base,[e,n]);t.prototype.setExtent.call(this,i[0],i[1])},e.prototype.getExtent=function(){var e=this.base,n=t.prototype.getExtent.call(this);n[0]=nw(e,n[0]),n[1]=nw(e,n[1]);var i=this._originalScale.getExtent();return this._fixMin&&(n[0]=ow(n[0],i[0])),this._fixMax&&(n[1]=ow(n[1],i[1])),n},e.prototype.unionExtentFromData=function(t,e){this._originalScale.unionExtentFromData(t,e);var n=Px(this.base,t.getApproximateExtent(e),!0);this._innerUnionExtent(n)},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent.slice(),n=this._getExtentSpanWithBreaks();if(isFinite(n)&&!(n<=0)){var i,r=(i=n,Math.pow(10,zs(i)));for(t/n*r<=.5&&(r*=10);!isNaN(r)&&Math.abs(r)<1&&Math.abs(r)>0;)r*=10;var o=[Jx(ew(e[0]/r)*r),Jx(tw(e[1]/r)*r)];this._interval=r,this._intervalPrecision=Mx(r),this._niceExtent=o}},e.prototype.calcNiceExtent=function(e){t.prototype.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},e.prototype.contain=function(e){return e=iw(e)/iw(this.base),t.prototype.contain.call(this,e)},e.prototype.normalize=function(e){return e=iw(e)/iw(this.base),t.prototype.normalize.call(this,e)},e.prototype.scale=function(e){return e=t.prototype.scale.call(this,e),nw(this.base,e)},e.prototype.setBreaksFromOption=function(t){},e.type="log",e}(Hx);function ow(t,e){return Jx(t,Is(e))}Lx.registerClass(rw);var aw=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!c&&(s=0));var d=this._determinedMin,h=this._determinedMax;return null!=d&&(a=d,l=!0),null!=h&&(s=h,c=!0),{min:a,max:s,minFixed:l,maxFixed:c,isBlank:u}},t.prototype.modifyDataMinMax=function(t,e){this[lw[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){this[sw[t]]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),sw={min:"_determinedMin",max:"_determinedMax"},lw={min:"_dataMin",max:"_dataMax"};function cw(t,e){return null==e?null:Un(e)?NaN:t.parse(e)}function uw(t,e){var n=t.type,i=function(t,e,n){var i=t.rawExtentInfo;return i||(i=new aw(t,e,n),t.rawExtentInfo=i,i)}(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=function(t,e){var n=[];return e.eachSeriesByType(t,function(t){(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type})(t)&&n.push(t)}),n}("bar",a),l=!1;if(Tn(s,function(t){l=l||t.getBaseAxis()===e.axis}),l){var c=Ux(s),u=function(t,e,n,i){var r=n.axis.getExtent(),o=Math.abs(r[1]-r[0]),a=function(t,e){if(t&&e)return t[Wx(e)]}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;Tn(a,function(t){s=Math.min(t.offset,s)});var l=-1/0;Tn(a,function(t){l=Math.max(t.offset+t.width,l)}),s=Math.abs(s),l=Math.abs(l);var c=s+l,u=e-t,d=u/(1-(s+l)/o)-u;return e+=d*(l/c),t-=d*(s/c),{min:t,max:e}}(r,o,e,c);r=u.min,o=u.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function dw(t,e){var n=e,i=uw(t,n),r=i.extent,o=n.get("splitNumber");t instanceof rw&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setBreaksFromOption(_w(n)),t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function hw(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=pf(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(Nn(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(zn(e)){if("category"===t.type)return function(n,i){return e(pw(t,n),n.value-t.scale.getExtent()[0],null)};var i=null;return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(pw(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function pw(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function fw(t){var e=t.get("interval");return null==e?"auto":e}function gw(t){return"category"===t.type&&0===fw(t.getLabelModel())}function vw(t,e){var n={};return Tn(t.mapDimensionsAll(e),function(e){n[function(t,e){return _x(t,e)?t.getCalculationInfo("stackResultDimension"):e}(t,e)]=!0}),Pn(n)}function mw(t){return"middle"===t||"center"===t}function yw(t){return t.getShallow("show")}function _w(t){t.get("breaks",!0)}var bw=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),xw=[],ww={registerPreprocessor:Pb,registerProcessor:Lb,registerPostInit:function(t){Eb("afterinit",t)},registerPostUpdate:function(t){Eb("afterupdate",t)},registerUpdateLifecycle:Eb,registerAction:Ob,registerCoordinateSystem:function(t,e){Uf.register(t,e)},registerLayout:function(t,e){Rb(wb,t,e,1e3,"layout")},registerVisual:zb,registerTransform:Bb,registerLoading:Hb,registerMap:function(t,e,n){var i=O_["registerMap"];i&&i(t,e,n)},registerImpl:function(t,e){O_[t]=e},PRIORITY:R_,ComponentModel:cg,ComponentView:jm,SeriesModel:Bm,ChartView:Km,registerComponentModel:function(t){cg.registerClass(t)},registerComponentView:function(t){jm.registerClass(t)},registerSeriesModel:function(t){Bm.registerClass(t)},registerChartView:function(t){Km.registerClass(t)},registerCustomSeries:function(t,e){},registerSubTypeDefaulter:function(t,e){cg.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){var n;n=e,vs[t]=n}};function Sw(t){On(t)?Tn(t,function(t){Sw(t)}):Cn(xw,t)>=0||(xw.push(t),zn(t)&&(t={install:t}),t.install(ww))}var Cw=rl(),Mw=rl(),kw=1,Tw=2;function Aw(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function Iw(t,e){var n=An(e,function(e){return t.scale.parse(e)});return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function Dw(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=hw(t),r=t.scale.getExtent();return{labels:An(Dn(Iw(t,n),function(t){return t>=r[0]&&t<=r[1]}),function(e){var n={value:e};return{formattedLabel:i(n),rawLabel:t.scale.getLabel(n),tickValue:e,time:void 0,break:void 0}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=Lw(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=hw(t);return{labels:An(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value,time:e.time,break:e.break}})}}(t)}function Pw(t,e,n){var i=t.getTickModel().get("customValues");if(i){var r=t.scale.getExtent();return{ticks:Dn(Iw(t,i),function(t){return t>=r[0]&&t<=r[1]})}}return"category"===t.type?function(t,e){var n,i,r=Ew(t),o=fw(e),a=Nw(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(zn(o))n=$w(t,o,!0);else if("auto"===o){var s=Lw(t,t.getLabelModel(),Aw(Tw));i=s.labelCategoryInterval,n=An(s.labels,function(t){return t.tickValue})}else n=Fw(t,i=o,!0);return Rw(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:An(t.scale.getTicks(n),function(t){return t.value})}}function Lw(t,e,n){var i,r,o=Ow(t),a=fw(e),s=n.kind===kw;if(!s){var l=Nw(o,a);if(l)return l}zn(a)?i=$w(t,a):(r="auto"===a?function(t,e){if(e.kind===kw){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return Mw(t).autoInterval=n,!0}),n}var i=Mw(t).autoInterval;return null!=i?i:Mw(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=Fw(t,r));var c={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return Rw(o,a,c),!0}):Rw(o,a,c),c}var Ew=zw("axisTick"),Ow=zw("axisLabel");function zw(t){return function(e){return Mw(e)[t]||(Mw(e)[t]={list:[]})}}function Nw(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function Fw(t,e,n){var i=hw(t),r=t.scale,o=r.getExtent(),a=t.getLabelModel(),s=[],l=Math.max((e||0)+1,1),c=o[0],u=r.count();0!==c&&l>1&&u/l>2&&(c=Math.round(Math.ceil(c/l)*l));var d=gw(t),h=a.get("showMinLabel")||d,p=a.get("showMaxLabel")||d;h&&c!==o[0]&&g(o[0]);for(var f=c;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t,time:void 0,break:void 0})}return p&&f-l!==o[1]&&g(o[1]),s}function $w(t,e,n){var i=t.scale,r=hw(t),o=[];return Tn(i.getTicks(),function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s,time:void 0,break:void 0})}),o}var Vw=[0,1],Ww=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return function(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(Cs(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(i.parse(t)),this.onBand&&"ordinal"===i.type&&Uw(n=n.slice(),i.count()),Ms(t,Vw,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&Uw(n=n.slice(),i.count());var r=Ms(t,n,Vw,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=An(Pw(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}},this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],e[0].onBand=!0,o=e[1]={coord:s[1],tickValue:e[0].tickValue,onBand:!0};else{var l=e[r-1].tickValue-e[0].tickValue,c=(e[r-1].coord-e[0].coord)/l;Tn(e,function(t){t.coord-=c/2,t.onBand=!0});var u=t.scale.getExtent();a=1+u[1]-e[r-1].tickValue,o={coord:e[r-1].coord+c*a,tickValue:u[1]+1,onBand:!0},e.push(o)}var d=s[0]>s[1];h(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&h(s[0],e[0].coord)&&e.unshift({coord:s[0],onBand:!0});h(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&h(o.coord,s[1])&&e.push({coord:s[1],onBand:!0});function h(t,e){return t=As(t),e=As(e),d?t>e:t0&&t<100||(t=5),An(this.scale.getMinorTicks(t),function(t){return An(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return Dw(this,t=t||Aw(Tw)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=hw(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var c=1;l>40&&(c=Math.max(1,Math.floor(l/40)));for(var u=s[0],d=t.dataToCoord(u+1)-t.dataToCoord(u),h=Math.abs(d*Math.cos(o)),p=Math.abs(d*Math.sin(o)),f=0,g=0;u<=s[1];u+=c){var v,m,y=Qa(r({value:u}),i.font,"center","top");v=1.3*y.width,m=1.3*y.height,f=Math.max(f,v,7),g=Math.max(g,m,7)}var _=f/h,b=g/p;isNaN(_)&&(_=1/0),isNaN(b)&&(b=1/0);var x=Math.max(0,Math.floor(Math.min(_,b)));if(n===kw)return e.out.noPxChangeTryDetermine.push(Ln(Hw,null,t,x,l)),x;var w=Bw(t,x,l);return null!=w?w:x}(this,t=t||Aw(Tw))},t}();function Uw(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var Gw=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"];function qw(t,e,n){n=n||3,e?t.dirty|=n:t.dirty&=~n}function jw(t,e){return e=e||3,null==t.dirty||!!(t.dirty&e)}function Xw(t){if(t)return jw(t)&&function(t,e,n){var i=e.getComputedTransform();t.transform=gp(t.transform,i);var r=t.localRect=fp(t.localRect,e.getBoundingRect()),o=e.style,a=o.margin,s=n&&n.marginForce,l=n&&n.minMarginForce,c=n&&n.marginDefault,u=o.__marginType;null==u&&c&&(a=c,u=Ep.textMargin);for(var d=0;d<4;d++)Yw[d]=u===Ep.minMargin&&l&&null!=l[d]?l[d]:s&&null!=s[d]?s[d]:a?a[d]:0;u===Ep.textMargin&&ap(r,Yw,!1,!1);var h=t.rect=fp(t.rect,r);i&&h.applyTransform(i);u===Ep.minMargin&&ap(h,Yw,!1,!1);t.axisAligned=hp(i),(t.label=t.label||{}).ignore=e.ignore,qw(t,!1),qw(t,!0,2)}(t,t.label,t),t}var Yw=[0,0,0,0];function Zw(t,e){for(var n=0;n-1&&(s.style.stroke=s.style.fill,s.style.fill=ug.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(Bm);function tS(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=Wv(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a0?+h:1;k.scaleX=this._sizeX*T,k.scaleY=this._sizeY*T,this.setSymbolScale(1),fd(this,l,c,u)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=Tu(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&Fh(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();Fh(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return On(n=t.getItemVisual(e,"symbolSize"))||(n=[+n,+n]),[n[0]||0,n[1]||0];var n},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(gs);function nS(t,e){this.parent.drift(t,e)}function iS(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function rS(t){return null==t||Bn(t)||(t={isIgnore:t}),t||{}}function oS(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:Sp(e),cursorStyle:e.get("cursor")}}var aS=function(){function t(t){this.group=new gs,this._SymbolCtor=t||eS}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=rS(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=oS(t),l={disableAnimation:a},c=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=c(i);if(iS(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}}).update(function(u,d){var h=r.getItemGraphicEl(d),p=c(u);if(iS(t,p,u,e)){var f=t.getItemVisual(u,"symbol")||"circle",g=h&&h.getSymbolType&&h.getSymbolType();if(!h||g&&g!==f)n.remove(h),(h=new o(t,u,s,l)).setPosition(p);else{h.updateData(t,u,s,l);var v={x:p[0],y:p[1]};a?h.attr(v):Rh(h,v,i)}n.add(h),t.setItemGraphicEl(u,h)}else n.remove(h)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=c,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()})},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=oS(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=rS(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),c=e.mapDimension(a),u="x"===s||"radius"===s?1:0,d=An(t.dimensions,function(t){return e.mapDimension(t)}),h=!1,p=e.getCalculationInfo("stackResultDimension");return _x(e,d[0])&&(h=!0,d[0]=p),_x(e,d[1])&&(h=!0,d[1]=p),{dataDimsForPoint:d,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!h,valueDim:l,baseDim:c,baseDataOffset:u,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function lS(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var cS=Math.min,uS=Math.max;function dS(t,e){return isNaN(t)||isNaN(e)}function hS(t,e,n,i,r,o,a,s,l){for(var c,u,d,h,p,f,g=n,v=0;v=r||g<0)break;if(dS(m,y)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](m,y),d=m,h=y;else{var _=m-c,b=y-u;if(_*_+b*b<.5){g+=o;continue}if(a>0){for(var x=g+o,w=e[2*x],S=e[2*x+1];w===m&&S===y&&v=i||dS(w,S))p=m,f=y;else{k=w-c,T=S-u;var D=m-c,P=w-m,L=y-u,E=S-y,O=void 0,z=void 0;if("x"===s){var N=k>0?1:-1;p=m-N*(O=Math.abs(D))*a,f=y,A=m+N*(z=Math.abs(P))*a,I=y}else if("y"===s){var R=T>0?1:-1;p=m,f=y-R*(O=Math.abs(L))*a,A=m,I=y+R*(z=Math.abs(E))*a}else O=Math.sqrt(D*D+L*L),p=m-k*a*(1-(M=(z=Math.sqrt(P*P+E*E))/(z+O))),f=y-T*a*(1-M),I=y+T*a*M,A=cS(A=m+k*a*M,uS(w,m)),I=cS(I,uS(S,y)),A=uS(A,cS(w,m)),f=y-(T=(I=uS(I,cS(S,y)))-y)*O/z,p=cS(p=m-(k=A-m)*O/z,uS(c,m)),f=cS(f,uS(u,y)),A=m+(k=m-(p=uS(p,cS(c,m))))*z/O,I=y+(T=y-(f=uS(f,cS(u,y))))*z/O}t.bezierCurveTo(d,h,p,f,m,y),d=A,h=I}else t.lineTo(m,y)}c=m,u=y,g+=o}return v}var pS=function(){this.smooth=0,this.smoothConstraint=!0},fS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return b(e,t),e.prototype.getDefaultStyle=function(){return{stroke:ug.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new pS},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&dS(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(u-i)*g+i:(c-n)*g+n;return a?[t,v]:[v,t]}n=c,i=u;break;case o.C:c=r[l++],u=r[l++],d=r[l++],h=r[l++],p=r[l++],f=r[l++];var m=a?Qr(n,c,d,p,t,s):Qr(i,u,h,f,t,s);if(m>0)for(var y=0;y=0){v=a?Zr(i,u,h,f,_):Zr(n,c,d,p,_);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(Qc),gS=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return b(e,t),e}(pS),vS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return b(e,t),e.prototype.getDefaultShape=function(){return new gS},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&dS(n[2*o-2],n[2*o-1]);o--);for(;r=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=ko(e[r]),s=ko(e[o]),l=i-r,c=Do([go(bo(a[0],s[0],l)),go(bo(a[1],s[1],l)),go(bo(a[2],s[2],l)),vo(bo(a[3],s[3],l))],"rgba");return n?{color:c,leftIndex:r,rightIndex:o,value:i}:c}}((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}function SS(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return Tn(o.getViewLabels(),function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function CS(t,e){return isNaN(t)||isNaN(e)}function MS(t,e){return[t[2*e],t[2*e+1]]}function kS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),c=An(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),u=c.length,d=o.outerColors.slice();u&&c[0].coord>c[u-1].coord&&(c.reverse(),d.reverse());var h=wS(c,"x"===r?n.getWidth():n.getHeight()),p=h.length;if(!p&&u)return c[0].coord<0?d[1]?d[1]:c[u-1].color:d[0]?d[0]:c[0].color;var f=h[0].coord-10,g=h[p-1].coord+10,v=g-f;if(v<.001)return"transparent";Tn(h,function(t){t.offset=(t.coord-f)/v}),h.push({offset:p?h[p-1].offset:.5,color:d[1]||"transparent"}),h.unshift({offset:p?h[0].offset:.5,color:d[0]||"transparent"});var m=new wh(0,0,0,0,h,!0);return m[r]=f,m[r+"2"]=g,m}}}(o,i,n)||o.getVisual("style")[o.getVisual("drawType")];if(h&&u.type===i.type&&M===this._step){v&&!p?p=this._newPolygon(l,_):p&&!v&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,$f(k));var T=f.getClipPath();if(T)Hh(T,{shape:TS(this,i,!1,t).shape},t);else f.setClipPath(TS(this,i,!0,t));b&&d.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),mS(this._stackedOnPoints,_)&&mS(this._points,l)||(g?this._doUpdateAnimation(o,_,i,n,M,m,x):(M&&(_&&(_=xS(_,l,i,M,x)),l=xS(l,null,i,M,x)),h.setShape({points:l}),p&&p.setShape({points:l,stackedOnPoints:_})))}else b&&d.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),g&&this._initSymbolLabelAnimation(o,i,C),M&&(_&&(_=xS(_,l,i,M,x)),l=xS(l,null,i,M,x)),h=this._newPolyline(l),v?p=this._newPolygon(l,_):p&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,$f(k)),f.setClipPath(TS(this,i,!0,t));var A=t.getModel("emphasis"),I=A.get("focus"),D=A.get("blurScope"),P=A.get("disabled");(h.useStyle(Sn(a.getLineStyle(),{fill:"none",stroke:k,lineJoin:"bevel"})),md(h,t,"lineStyle"),h.style.lineWidth>0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(h.getState("emphasis").style.lineWidth=+h.style.lineWidth+1);Tu(h).seriesIndex=t.seriesIndex,fd(h,I,D,P);var L=bS(t.get("smooth")),E=t.get("smoothMonotone");if(h.setShape({smooth:L,smoothMonotone:E,connectNulls:x}),p){var O=o.getCalculationInfo("stackedOnSeries"),z=0;p.useStyle(Sn(s.getAreaStyle(),{fill:k,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),O&&(z=bS(O.get("smooth"))),p.setShape({smooth:L,stackedOnSmooth:z,smoothMonotone:E,connectNulls:x}),md(p,t,"areaStyle"),Tu(p).seriesIndex=t.seriesIndex,fd(p,I,D,P)}var N=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=N)}),this._polyline.onHoverStateChange=N,this._data=o,this._coordSys=i,this._stackedOnPoints=_,this._points=l,this._step=M,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,h),p&&this.packEventData(t,p))},e.prototype.packEventData=function(t,e){Tu(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=il(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],c=a[2*o+1];if(isNaN(l)||isNaN(c))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,c))return;var u=t.get("zlevel")||0,d=t.get("z")||0;(s=new eS(r,o)).x=l,s.y=c,s.setZ(u,d);var h=s.getSymbolPath().getTextContent();h&&(h.zlevel=u,h.z=d,h.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else Km.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=il(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else Km.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;Zu(this._polyline,t),e&&Zu(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new fS({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new vS({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");zn(l)&&(l=l(null));var c=s.get("animationDelay")||0,u=zn(c)?c(null):c;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var d=[t.x,t.y],h=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(d);i?(h=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(h=g.r0,p=g.r,f=v[0])}else{var m=n;i?(h=m.x,p=m.x+m.width,f=t.x):(h=m.y+m.height,p=m.y,f=t.y)}var y=p===h?0:(f-h)/(p-h);a&&(y=1-y);var _=zn(c)?c(o):l*y+u,b=s.getSymbolPath(),x=b.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:_}),x&&x.animateFrom({style:{opacity:0}},{duration:300,delay:_}),b.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(kS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new fu({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&CS(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(wp(o,Sp(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?function(t,e){var n=t.mapDimensionsAll("defaultedLabel");if(!On(e))return e+"";for(var i=[],r=0;r=0&&i.push(e[o])}return i.join(" ")}(r,n):tS(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var c=n.getLayout("points"),u=n.hostModel,d=u.get("connectNulls"),h=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,m=e.shape,y=v?g?m.x:m.y+m.height:g?m.x+m.width:m.y,_=(g?p:0)*(v?-1:1),b=(g?0:-p)*(v?-1:1),x=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,c=0;c=e||i>=e&&r<=e){l=c;break}s=c,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(c,y,x),S=w.range,C=S[1]-S[0],M=void 0;if(C>=1){if(C>1&&!d){var k=MS(c,S[0]);s.attr({x:k[0]+_,y:k[1]+b}),r&&(M=u.getRawValue(S[0]))}else{(k=l.getPointOn(y,x))&&s.attr({x:k[0]+_,y:k[1]+b});var T=u.getRawValue(S[0]),A=u.getRawValue(S[1]);r&&(M=function(t,e,n,i,r){var o=null==e||"auto"===e;if(null==i)return i;if(Hn(i))return As(f=Us(n||0,i,r),o?Math.max(Is(n||0),Is(i)):e);if(Nn(i))return r<1?n:i;for(var a=[],s=n,l=i,c=Math.max(s?s.length:0,l.length),u=0;u0?S[0]:0;k=MS(c,I);r&&(M=u.getRawValue(I)),s.attr({x:k[0]+_,y:k[1]+b})}if(r){var D=Lp(s);"function"==typeof D.setLabelText&&D.setLabelText(M)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,c=t.hostModel,u=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],c=[],u=[],d=[],h=[],p=[],f=[],g=sS(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],y=0;y3e3||l&&_S(h,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=u.current,s.shape.points=d;var g={shape:{points:p}};u.current!==d&&(g.shape.__points=u.next),s.stopAnimation(),Rh(s,g,c),l&&(l.setShape({points:d,stackedOnPoints:h}),l.stopAnimation(),Rh(l,{shape:{stackedOnPoints:f}},c),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],m=u.status,y=0;ye&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(Ww),jS="expandAxisBreak",XS=Math.PI,YS=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],ZS=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],KS=rl(),QS=rl(),JS=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var tC=[1,0,0,1,0,0],eC=new hr(0,0,0,0),nC=function(t,e,n,i,r,o){if(mw(t.nameLocation)){var a=o.stOccupiedRect;a&&iC(function(t,e,n){return t.transform=gp(t.transform,n),t.localRect=fp(t.localRect,e),t.rect=fp(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=hp(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else rC(o.labelInfoList,o.dirVec,i,r)};function iC(t,e,n){var i=new Qi;Qw(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&function(t,e){if(t){t.label.x+=e.x,t.label.y+=e.y,t.label.markRedraw();var n=t.transform;n&&(n[4]+=e.x,n[5]+=e.y);var i=t.rect;i&&(i.x+=e.x,i.y+=e.y);var r=t.obb;r&&r.fromBoundingRect(t.localRect,n)}}(e,i)}function rC(t,e,n,i){for(var r=Qi.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):Ls(o-XS)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),aC=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],sC={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),c=o.transform,u=[l[0],0],d=[l[1],0],h=u[0]>d[0];c&&(yi(u,u,c),yi(d,d,c));var p=wn({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&i.axis.scale.hasBreaks())null.buildAxisBreakLine(i,r,o,f);else{var g=new ph(wn({shape:{x1:u[0],y1:u[1],x2:d[0],y2:d[1]}},f));Qh(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var m=i.get(["axisLine","symbolSize"]);Nn(v)&&(v=[v,v]),(Nn(m)||Hn(m))&&(m=[m,m]);var y=o_(i.get(["axisLine","symbolOffset"])||0,m),_=m[0],b=m[1];Tn([{rotate:t.rotation+Math.PI/2,offset:y[0],r:0},{rotate:t.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((u[0]-d[0])*(u[0]-d[0])+(u[1]-d[1])*(u[1]-d[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=r_(v[n],-_/2,-b/2,_,b,p.stroke,!0),o=e.r+e.offset,a=h?d:u;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){dC(e,r,s)&&lC(t,e,n,i,r,o,a,kw)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){dC(e,r,s)&&lC(t,e,n,i,r,o,a,Tw);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),c=uC(r.getTicksCoords(),n.transform,l,Sn(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),u=0;ui[1],l="start"===e&&!s||"start"!==e&&s;Ls(a-XS/2)?(o=l?"bottom":"top",r="center"):Ls(a-1.5*XS)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*XS&&a>XS/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,u,x||0,f),null!=(b=t.raw.axisNameAvailableWidth)&&(b=Math.abs(b/Math.sin(_.rotation)),!isFinite(b)&&(b=null)));var w=h.getFont(),S=i.get("nameTruncate",!0)||{},C=S.ellipsis,M=Gn(t.raw.nameTruncateMaxWidth,S.maxWidth,b),k=s.nameMarginLevel||0,T=new fu({x:v.x,y:v.y,rotation:_.rotation,silent:oC.isLabelSilent(i),style:Cp(h,{text:c,font:w,overflow:"truncate",width:M,ellipsis:C,fill:h.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:h.get("align")||_.textAlign,verticalAlign:h.get("verticalAlign")||_.textVerticalAlign}),z2:1});if(cp({el:T,componentModel:i,itemName:c}),T.__fullText=c,T.anid="name",i.get("triggerEvent")){var A=oC.makeAxisEventDataBase(i);A.targetType="axisName",A.name=c,Tu(T).eventData=A}o.add(T),T.updateTransform(),e.nameEl=T;var I=l.nameLayout=Xw({label:T,priority:T.z2,defaultAttr:{ignore:T.ignore},marginDefault:mw(u)?YS[k]:ZS[k]});if(l.nameLocation=u,r.add(T),T.decomposeTransform(),t.shouldNameMoveOverlap&&I){var D=n.ensureRecord(i);n.resolveAxisNameOverlap(t,n,i,I,m,D)}}}};function lC(t,e,n,i,r,o,a,s){hC(e)||function(t,e,n,i,r,o){var a=r.axis,s=Gn(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new gs;n.add(l);var c=Aw(i);if(!s||a.scale.isBlank())return void pC(e,[],l,c);var u=r.getModel("axisLabel"),d=a.getViewLabels(c),h=(Gn(t.raw.labelRotate,u.get("rotate"))||0)*XS/180,p=oC.innerTextLayout(t.rotation,h,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),m=1/0,y=-1/0;Tn(d,function(t,e){var n,i="ordinal"===a.scale.type?a.scale.getRawOrdinalNumber(t.tickValue):t.tickValue,s=t.formattedLabel,c=t.rawLabel,h=u;if(f&&f[i]){var _=f[i];Bn(_)&&_.textStyle&&(h=new Up(_.textStyle,u,r.ecModel))}var b=h.getTextColor()||r.get(["axisLine","lineStyle","color"]),x=h.getShallow("align",!0)||p.textAlign,w=qn(h.getShallow("alignMinLabel",!0),x),S=qn(h.getShallow("alignMaxLabel",!0),x),C=h.getShallow("verticalAlign",!0)||h.getShallow("baseline",!0)||p.textVerticalAlign,M=qn(h.getShallow("verticalAlignMinLabel",!0),C),k=qn(h.getShallow("verticalAlignMaxLabel",!0),C),T=10+((null===(n=t.time)||void 0===n?void 0:n.level)||0);m=Math.min(m,T),y=Math.max(y,T);var A=new fu({x:0,y:0,rotation:0,silent:oC.isLabelSilent(r),z2:T,style:Cp(h,{text:s,align:0===e?w:e===d.length-1?S:x,verticalAlign:0===e?M:e===d.length-1?k:C,fill:zn(b)?b("category"===a.type?c:"value"===a.type?i+"":i,e):b})});A.anid="label_"+i;var I=KS(A);if(I.break=t.break,I.tickValue=i,I.layoutRotation=p.rotation,cp({el:A,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return A.isTruncated},value:c,tickIndex:e}}),v){var D=oC.makeAxisEventDataBase(r);D.targetType="axisLabel",D.value=c,D.tickIndex=e,t.break&&(D.break={start:t.break.parsedBreak.vmin,end:t.break.parsedBreak.vmax}),"category"===a.type&&(D.dataIndex=i),Tu(A).eventData=D,t.break&&function(t,e,n,i){n.on("click",function(n){var r={type:jS,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,A,t.break)}g.push(A),l.add(A)});var _=An(g,function(t){return{label:t,priority:KS(t).break?t.z2+(y-m+1):t.z2,defaultAttr:{ignore:t.ignore}}});pC(e,_,l,c)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);Tn(n,function(n,o){var a=Xw(n);if(a){var s=a.label,l=KS(s);a.suggestIgnore=s.ignore,s.ignore=!1,Ga(fC,gC),fC.x=e.axis.dataToCoord(l.tickValue),fC.y=t.labelOffset+t.labelDirection*r,fC.rotation=l.layoutRotation,i.add(fC),fC.updateTransform(),i.remove(fC),fC.decomposeTransform(),Ga(s,fC),s.markRedraw(),qw(a,!0),Xw(a)}})}(t,i,l,o),t.rotation;var c=t.optionHideOverlap;!function(t,e,n){if(gw(t.axis))return;function i(t,i,r){var o=Xw(e[i]),a=Xw(e[r]);if(o&&a)if(!1===t||o.suggestIgnore)cC(o.label);else if(a.suggestIgnore)cC(a.label);else{var s=.1;if(!n){var l=[0,0,0,0];o=Zw({marginForce:l},o),a=Zw({marginForce:l},a)}Qw(o,a,null,{touchThreshold:s})&&cC(t?a.label:o.label)}}var r=t.get(["axisLabel","showMinLabel"]),o=t.get(["axisLabel","showMaxLabel"]),a=e.length;i(r,0,1),i(o,a-1,a-2)}(i,l,c),c&&function(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i.1?"x":"y",u=a.transGroup[c];if(s.sort(function(t,e){return Math.abs(t.label[c]-u)-Math.abs(e.label[c]-u)}),l&&r){var d=o.getExtent(),h=Math.min(d[0],d[1]),p=Math.max(d[0],d[1])-h;r.union(new hr(h,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function cC(t){t&&(t.ignore=!0)}function uC(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;xx(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(dw(l,s),xx(l)&&(e=a))}r.length&&(e||dw((e=r.pop()).scale,e.model),Tn(r,function(t){!function(t,e,n){var i=Hx.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,{expandToNicedExtent:!0}),a=r.length-1,s=i.getInterval.call(n),l=uw(t,e),c=l.extent,u=l.fixMin,d=l.fixMax;"log"===t.type&&(c=Px(t.base,c,!0)),t.setBreaksFromOption(_w(e)),t.setExtent(c[0],c[1]),t.calcNiceExtent({splitNumber:a,fixMin:u,fixMax:d});var h=i.getExtent.call(t);u&&(c[0]=h[0]),d&&(c[1]=h[1]);var p=i.getInterval.call(t),f=c[0],g=c[1];if(u&&d)p=(g-f)/a;else if(u)for(g=c[0]+p*a;gc[0]&&isFinite(f)&&isFinite(c[0]);)p=Cx(p),f=c[1]-p*a;else{t.getTicks().length-1>a&&(p=Cx(p));var v=p*a;(f=As((g=Math.ceil(c[1]/p)*p)-v))<0&&c[0]>=0?(f=0,g=As(v)):g>0&&c[1]<=0&&(g=0,f=-As(v))}var m=(r[0].value-o[0].value)/s,y=(r[a].value-o[a].value)/s;i.setExtent.call(t,f+p*m,g+p*y),i.setInterval.call(t,p),(m||y)&&i.setNiceExtent.call(t,f+p,g-p)}(t.scale,t.model,e.scale)}))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};Tn(n.x,function(t){wC(n,"y",t,r)}),Tn(n.y,function(t){wC(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=rg(t,e),r=this._rect=ng(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(CC(o,r),!n){var l=function(t,e,n,i,r){var o=new JS(AC);return Tn(n,function(n){return Tn(n,function(n){if(yw(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=mC(t,n),s=!1,l=!1,c=0;c0&&i>0||n<0&&i<0)}(t)}function CC(t,e){Tn(t.x,function(t){return MC(t,e.x,e.width)}),Tn(t.y,function(t){return MC(t,e.y,e.height)})}function MC(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function kC(t,e,n,i,r,o,a){TC(i,r,kw,e,!1,a);var s=[0,0,0,0];c(0),c(1),u(i,0,NaN),u(i,1,NaN);var l=null==function(t,e,n){if(t&&e)for(var i=0,r=t.length;i0});return ap(i,s,!0,!0,n),CC(r,i),l;function c(t){Tn(r[Wh[t]],function(e){if(yw(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!Un(e)&&e>1e-4&&(t/=e),t}}function TC(t,e,n,i,r,o){var a=n===Tw;Tn(e,function(e){return Tn(e,function(e){yw(e.model)&&(!function(t,e,n){var i=mC(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[Wh[1-e]]=t[Uh[e]]<=.5*o.refContainer[Uh[e]]?0:1-e==1?2:1}l(0),l(1),Tn(e,function(t,e){return Tn(t,function(t){yw(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var AC=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";nC(t,0,0,i,r,o),mw(t.nameLocation)||Tn(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&rC(t.labelInfoList,t.dirVec,i,r)})};function IC(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];Tn(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=EC(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var c=n.model.getModel("tooltip",i);if(Tn(n.getAxes(),En(p,!1,null)),n.getTooltipAxes&&i&&c.get("show")){var u="axis"===c.get("trigger"),d="cross"===c.get(["axisPointer","type"]),h=n.getTooltipAxes(c.get(["axisPointer","axis"]));(u||d)&&Tn(h.baseAxes,En(p,!d||"cross",u)),d&&Tn(h.otherAxes,En(p,"cross",!1))}}function p(i,s,u){var d=u.model.getModel("axisPointer",r),h=d.get("show");if(h&&("auto"!==h||i||LC(d))){null==s&&(s=d.get("triggerTooltip")),d=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};Tn(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=bn(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var c=a.get(["label","show"]);if(l.show=null==c||c,!o){var u=s.lineStyle=a.get("crossStyle");u&&Sn(l,u.textStyle)}}return t.model.getModel("axisPointer",new Up(s,n,i))}(u,c,r,e,i,s):d;var p=d.get("snap"),f=d.get("triggerEmphasis"),g=EC(u.model),v=s||p||"category"===u.type,m=t.axesInfo[g]={key:g,axis:u,coordSys:n,axisPointerModel:d,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:LC(d),seriesModels:[],linkGroup:null};l[g]=m,t.seriesInvolved=t.seriesInvolved||v;var y=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function PC(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[EC(t)]}function LC(t){return!!t.get(["handle","show"])}function EC(t){return t.type+"||"+t.id}var OC={},zC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return b(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=PC(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=LC(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),aa)return!0;if(o){var s=PC(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=GC(t).pointerEl=new _p[r.type](qC(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=GC(t).labelEl=new fu(qC(e.label));t.add(r),KC(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=GC(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=GC(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),KC(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=ip(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){Vi(t.event)},onmousedown:jC(this._onHandleDragMove,this,0,0),drift:jC(this._onHandleDragMove,this),ondragend:jC(this._onHandleDragEnd,this)}),i.add(r)),JC(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");On(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,sy(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){YC(this._axisPointerModel,!e&&this._moveAnimation,this._handle,QC(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(QC(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(QC(i)),GC(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),ly(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function YC(t,e,n,i){ZC(GC(n).lastProp,i)||(GC(n).lastProp=i,e?Rh(n,i,t):(n.stopAnimation(),n.attr(i)))}function ZC(t,e){if(Bn(t)&&Bn(e)){var n=!0;return Tn(e,function(e,i){n=n&&ZC(t[i],e)}),!!n}return t===e}function KC(t,e){t[e.get(["label","show"])?"show":"hide"]()}function QC(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function JC(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function tM(t,e,n,i,r){var o=eM(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=Nf(a.get("padding")||0),l=a.getFont(),c=Qa(o,l),u=r.position,d=c.width+s[1]+s[3],h=c.height+s[0]+s[2],p=r.align;"right"===p&&(u[0]-=d),"center"===p&&(u[0]-=d/2);var f=r.verticalAlign;"bottom"===f&&(u[1]-=h),"middle"===f&&(u[1]-=h/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(u,d,h,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:u[0],y:u[1],style:Cp(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function eM(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:pw(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};Tn(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),Nn(a)?o=a.replace("{value}",o):zn(a)&&(o=a(s))}return o}function nM(t,e,n){var i=[1,0,0,1,0,0];return Zi(i,i,n.rotation),Yi(i,i,n.position),tp([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}var iM=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return b(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=rM(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var u=function(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}(i),d=oM[s](o,c,l);d.style=u,t.graphicKey=d.type,t.pointer=d}!function(t,e,n,i,r,o){var a=oC.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),tM(e,i,r,o,{position:nM(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}(e,t,mC(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=mC(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=nM(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=rM(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,c=[t.x,t.y];c[l]+=e[l],c[l]=Math.min(a[1],c[l]),c[l]=Math.max(a[0],c[l]);var u=(s[1]+s[0])/2,d=[u,u];d[l]=c[l];return{x:c[0],y:c[1],rotation:t.rotation,cursorPoint:d,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(XC);function rM(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var oM={line:function(t,e,n){var i,r,o;return{type:"Line",subPixelOptimize:!0,shape:(i=[e,n[0]],r=[e,n[1]],o=aM(t),{x1:i[o=o||0],y1:i[1-o],x2:r[o],y2:r[1-o]})}},shadow:function(t,e,n){var i,r,o,a=Math.max(1,t.getBandWidth()),s=n[1]-n[0];return{type:"Rect",shape:(i=[e-a/2,n[0]],r=[a,s],o=aM(t),{x:i[o=o||0],y:i[1-o],width:r[o],height:r[1-o]})}}};function aM(t){return"x"===t.dim?0:1}var sM=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return b(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:ug.color.border,width:1,type:"dashed"},shadowStyle:{color:ug.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:ug.color.neutral00,padding:[5,7,5,7],backgroundColor:ug.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:ug.color.accent40,throttle:40}},e}(cg),lM=rl(),cM=Tn;function uM(t,e,n){if(!Qe.node){var i=e.getZr();lM(i).records||(lM(i).records={}),function(t,e){if(lM(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);cM(lM(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}lM(t).initialized=!0,n("click",En(hM,"click")),n("mousemove",En(hM,"mousemove")),n("globalout",dM)}(i,e),(lM(i).records[t]||(lM(i).records[t]={})).handler=n}}function dM(t,e,n){t.handler("leave",null,n)}function hM(t,e,n,i){e.handler(t,n,i)}function pM(t,e){if(!Qe.node){var n=e.getZr();(lM(n).records||{})[t]&&(lM(n).records[t]=null)}}var fM=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return b(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";uM("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){pM("axisPointer",e)},e.prototype.dispose=function(t,e){pM("axisPointer",e)},e.type="axisPointer",e}(jm);function gM(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=il(o,t);if(null==a||a<0||On(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var c=l.getBaseAxis(),u=l.getOtherAxis(c).dim,d=c.dim,h="x"===u||"radius"===u?1:0,p=o.mapDimension(d),f=[];f[h]=o.get(p,a),f[1-h]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(An(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var vM=rl();function mM(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||Ln(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){wM(r)&&(r=gM({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=wM(r),c=o.axesInfo,u=s.axesInfo,d="leave"===i||wM(r),h={},p={},f={list:[],map:{}},g={showPointer:En(_M,p),showTooltip:En(bM,f)};Tn(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);Tn(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(c,t);if(!d&&n&&(!c||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&yM(t,a,g,!1,h)}})});var v={};return Tn(u,function(t,e){var n=t.linkGroup;n&&!p[e]&&Tn(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,xM(e),xM(t)))),v[t.key]=o}})}),Tn(v,function(t,e){yM(u[e],t,g,!0,h)}),function(t,e,n){var i=n.axesInfo=[];Tn(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,u,h),function(t,e,n,i){if(wM(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=vM(i)[r]||{},a=vM(i)[r]={};Tn(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&Tn(n.seriesDataIndices,function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t})});var s=[],l=[];Tn(o,function(t,e){!a[e]&&l.push(t)}),Tn(a,function(t,e){!o[e]&&s.push(t)}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(u,0,n),h}}function yM(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return Tn(e.seriesModels,function(e,l){var c,u,d=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var h=e.getAxisTooltipData(d,t,n);u=h.dataIndices,c=h.nestestValue}else{if(!(u=e.indicesOfNearest(i,d[0],t,"category"===n.type?.5:null)).length)return;c=e.getData().get(d[0],u[0])}if(null!=c&&isFinite(c)){var p=t-c,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=c,o.length=0),Tn(u,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&wn(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function _M(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function bM(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,c=EC(l),u=t.map[c];u||(u=t.map[c]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(u)),u.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function xM(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function wM(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function SM(t){zC.registerAxisPointerClass("CartesianAxisPointer",iM),t.registerComponentModel(sM),t.registerComponentView(fM),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!On(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=IC(t,e)}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},mM)}var CM=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return b(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:ug.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:ug.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:ug.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:ug.color.tertiary,fontSize:14}},e}(cg);function MM(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function kM(t){if(Qe.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&r.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",r="",o="";return n&&(o="opacity"+(r=" "+t/2+"s "+i)+",visibility"+r),e||(r=" "+t+"s "+i,o+=(o.length?",":"")+(Qe.transformSupported?""+DM+r:",left"+r+",top"+r)),IM+":"+o}(o,n,i)),a&&r.push("background-color:"+a),Tn(["width","color","radius"],function(e){var n="border-"+e,i=zf(n),o=t.get(i);null!=o&&r.push(n+":"+o+("color"===e?"":"px"))}),r.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=qn(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),Tn(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(d)),null!=h&&r.push("padding:"+Nf(h).join("px ")+"px"),r.join(";")+";"}function OM(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){Ii(Ai,e,i,r,!0)&&Ii(t,n,Ai[0],Ai[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var zM=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,Qe.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),r=e.appendTo,o=r&&(Nn(r)?document.querySelector(r):Vn(r)?r:zn(r)&&r(t.getDom()));OM(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(n),this._api=t,this._container=o;var a=this;n.onmouseenter=function(){a._enterable&&(clearTimeout(a._hideTimeout),a._show=!0),a._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!a._enterable){var e=i.handler;Fi(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){a._inContent=!1,a._enterable&&a._show&&a.hideLater(a._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?a[o]:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=PM+EM(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+LM(r[0],r[1],!0)+"border-color:"+$f(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(Nn(r)&&"item"===n.get("trigger")&&!MM(n)&&(a=function(t,e,n){if(!Nn(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=$f(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),c="",u=DM+":";Cn(["left","right"],s)>-1?(c+="top:50%",u+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(c+="left:50%",u+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var d=a*Math.PI/180,h=l+r,p=h*Math.abs(Math.cos(d))+h*Math.abs(Math.sin(d)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),Nn(t))o.innerHTML=t+a;else if(t){o.innerHTML="",On(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!Qe.node&&n.getDom()){var r=WM(i,n);this._ticket="";var o=i.dataByCoordSys,a=function(t,e,n){var i=sl(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=cl(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=Tu(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(a){var s=a.el.getBoundingRect().clone();s.applyTransform(a.el.transform),this._tryShow({offsetX:s.x+s.width/2,offsetY:s.y+s.height/2,target:a.el,position:i.position,positionDefault:"bottom"},r)}else if(i.tooltip&&null!=i.x&&null!=i.y){var l=FM;l.x=i.x,l.y=i.y,l.update(),Tu(l).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:l},r)}else if(o)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:o,tooltipOption:i.tooltipOption},r);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=gM(i,e),u=c.point[0],d=c.point[1];null!=u&&null!=d&&this._tryShow({offsetX:u,offsetY:d,target:c.el,position:i.position,positionDefault:"bottom"},r)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},r))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(WM(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===VM([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===Tu(n).ssrType)return;this._lastDataByCoordSys=null,qy(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=Tu(t).dataIndex?r=t:null!=Tu(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=Ln(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=VM([e.tooltipOption],i),a=this._renderMode,s=[],l=Mm("section",{blocks:[],noHeader:!0}),c=[],u=new zm;Tn(t,function(t){Tn(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=eM(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),d=Mm("section",{header:o,noHeader:!Kn(o),sortBlocks:!0,blocks:[]});l.blocks.push(d),Tn(t.seriesDataIndices,function(l){var h=n.getSeriesByIndex(l.seriesIndex),p=l.dataIndexInside,f=h.getDataParams(p);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=pw(e.axis,{value:r}),f.axisValueLabel=o,f.marker=u.makeTooltipMarker("item",$f(f.color),a);var g=qv(h.formatTooltip(p,!0,null)),v=g.frag;if(v){var m=VM([h],i).get("valueFormatter");d.blocks.push(m?wn({valueFormatter:m},v):v)}g.text&&c.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),c.reverse();var d=e.position,h=o.get("order"),p=Pm(l,u,a,h,n.get("useUTC"),o.get("textStyle"));p&&c.unshift(p);var f="richText"===a?"\n\n":"
",g=c.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,d,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],d,null,u)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=Tu(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,c=r.dataType,u=s.getData(c),d=this._renderMode,h=t.positionDefault,p=VM([u.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,h?{position:h}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,c),v=new zm;g.marker=v.makeTooltipMarker("item",$f(g.color),d);var m=qv(s.formatTooltip(l,!1,c)),y=p.get("order"),_=p.get("valueFormatter"),b=m.frag,x=b?Pm(_?wn({valueFormatter:_},b):b,v,d,y,i.get("useUTC"),p.get("textStyle")):m.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,x,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:u.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=Tu(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(Nn(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=bn(o)).content=Ei(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var c=t.positionDefault,u=VM(s,this._tooltipModel,c?{position:c}:null),d=u.get("content"),h=Math.random()+"",p=new zm;this._showOrMove(u,function(){var n=bn(u.get("formatterParams")||{});this._showTooltipContent(u,d,n,h,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var c=this._tooltipContent;c.setEnterable(t.get("enterable"));var u=t.get("formatter");a=a||t.get("position");var d=e,h=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(u)if(Nn(u)){var p=t.ecModel.get("useUTC"),f=On(n)?n[0]:n;d=u,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(d=mf(f.axisValue,d,p)),d=Ff(d,n,!0)}else if(zn(u)){var g=Ln(function(e,i){e===this._ticket&&(c.setContent(i,l,t,h,a),this._updatePosition(t,a,r,o,c,n,s))},this);this._ticket=i,d=u(n,i,g)}else d=u;c.setContent(d,l,t,h,a),c.show(t,h),this._updatePosition(t,a,r,o,c,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||On(e)?{color:i||r}:On(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var c=r.getSize(),u=t.get("align"),d=t.get("verticalAlign"),h=a&&a.getBoundingRect().clone();if(a&&h.applyTransform(a.transform),zn(e)&&(e=e([n,i],o,r.el,h,{viewSize:[s,l],contentSize:c.slice()})),On(e))n=ks(e[0],s),i=ks(e[1],l);else if(Bn(e)){var p=e;p.width=c[0],p.height=c[1];var f=ng(p,{width:s,height:l});n=f.x,i=f.y,u=null,d=null}else if(Nn(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,c=e.width,u=e.height;switch(t){case"inside":s=e.x+c/2-r/2,l=e.y+u/2-o/2;break;case"top":s=e.x+c/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+c/2-r/2,l=e.y+u+a;break;case"left":s=e.x-r-a,l=e.y+u/2-o/2;break;case"right":s=e.x+c+a,l=e.y+u/2-o/2}return[s,l]}(e,h,c,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],c=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+c+a>r?e-=c+a:e+=a);return[t,e]}(n,i,r,s,l,u?null:20,d?null:20);n=g[0],i=g[1]}if(u&&(n-=UM(u)?c[0]/2:"right"===u?c[0]:0),d&&(i-=UM(d)?c[1]/2:"bottom"===d?c[1]:0),MM(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&Tn(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&Tn(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&Tn(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&Tn(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!Qe.node&&e.getDom()&&(ly(this,"_updatePosition"),this._tooltipContent.dispose(),pM("itemTooltip",e))},e.type="tooltip",e}(jm);function VM(t,e,n){var i,r=e.ecModel;n?(i=new Up(n,r,r),i=new Up(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof Up&&(a=a.get("tooltip",!0)),Nn(a)&&(a={formatter:a}),a&&(i=new Up(a,i,r)))}return i}function WM(t,e){return t.dispatchAction||Ln(e.dispatchAction,e)}function UM(t){return"center"===t||"middle"===t}var GM=Math.sin,qM=Math.cos,jM=Math.PI,XM=2*Math.PI,YM=180/jM,ZM=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,c=!s,u=Math.abs(l),d=Ro(u-XM)||(c?l>=XM:-l>=XM),h=l>0?l%XM:l%XM+XM,p=!1;p=!!d||!Ro(u)&&h>=jM==!!c;var f=t+n*qM(o),g=e+i*GM(o);this._start&&this._add("M",f,g);var v=Math.round(r*YM);if(d){var m=1/this._p,y=(c?1:-1)*(XM-m);this._add("A",n,i,v,1,+c,t+n*qM(o+y),e+i*GM(o+y)),m>.01&&this._add("A",n,i,v,0,+c,f,g)}else{var _=t+n*qM(a),b=e+i*GM(a);this._add("A",n,i,v,+p,+c,_,b)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var c=[],u=this._p,d=1;d"}(r,o)+("style"!==r?Ei(a):a||"")+(i?""+n+An(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function lk(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function ck(t,e,n,i){return ak("svg","root",{width:t,height:e,xmlns:nk,"xmlns:xlink":ik,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var uk=0;function dk(){return uk++}var hk={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},pk="transform-origin";function fk(t,e,n){var i=wn({},t.shape);wn(i,e),t.buildPath(n,i);var r=new ZM;return r.reset(jo(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function gk(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[pk]=n+"px "+i+"px")}var vk={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function mk(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function yk(t){return Nn(t)?hk[t]?"cubic-bezier("+hk[t]+")":lo(t)?t:"":""}function _k(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof bh){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(Tn(o,function(t){var e=lk(n.zrId);e.animation=!0,_k(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=Pn(o),c=l.length;if(c){var u=o[r=l[c-1]];for(var d in u){var h=u[d];a[d]=a[d]||{d:""},a[d].d+=h.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=mk(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},c=0;c0}).length)return mk(u,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var m=n.zrId+"-cls-"+dk();n.cssNodes["."+m]={animation:a.join(",")},e.class=m}}function bk(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+dk(),n.cssStyleCache[r]=o,n.cssNodes["."+o+":hover"]=t),e.class=e.class?e.class+" "+o:o}var xk=Math.round;function wk(t){return t&&Nn(t.src)}function Sk(t){return t&&zn(t.toDataURL)}function Ck(t,e,n,i){ek(function(r,o){var a="fill"===r||"stroke"===r;a&&Go(o)?zk(e,t,r,i):a&&Vo(o)?Nk(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],c=s[1];if(!l||!c)return;var u=i.shadowOffsetX||0,d=i.shadowOffsetY||0,h=i.shadowBlur,p=zo(i.shadowColor),f=p.opacity,g=p.color,v=h/2/l+" "+h/2/c;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=ak("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[ak("feDropShadow","",{dx:u/l,dy:d/c,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=qo(a)}}(n,t,i)}function Mk(t,e){var n=function(t){if("function"==typeof ys)return ys(t)}(e);n&&(n.each(function(e,n){null!=e&&(t[(rk+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[rk+"silent"]="true"))}function kk(t){return Ro(t[0]-1)&&Ro(t[1])&&Ro(t[2])&&Ro(t[3]-1)}function Tk(t,e,n){if(e&&(!function(t){return Ro(t[4])&&Ro(t[5])}(e)||!kk(e))){var i=1e4;t.transform=kk(e)?"translate("+xk(e[4]*i)/i+" "+xk(e[5]*i)/i+")":function(t){return"matrix("+Ho(t[0])+","+Ho(t[1])+","+Ho(t[2])+","+Ho(t[3])+","+Bo(t[4])+","+Bo(t[5])+")"}(e)}}function Ak(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=Eo(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var c={cursor:"pointer"};r&&(c.fill=r),i.stroke&&(c.stroke=i.stroke),l&&(c["stroke-width"]=l),bk(c,e,n)}}(t,o,e),ak(s,t.id+"",o)}function Ok(t,e){return t instanceof Qc?Ek(t,e):t instanceof iu?function(t,e){var n=t.style,i=n.image;if(i&&!Nn(i)&&(wk(i)?i=i.src:Sk(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),Tk(a,t.transform),Ck(a,n,t,e),Mk(a,t),e.animation&&_k(t,a,e),ak("image",t.id+"",a)}}(t,e):t instanceof tu?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||tn,o=n.x||0,a=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,es(r),n.textBaseline),s={"dominant-baseline":"central","text-anchor":Fo[n.textAlign]||n.textAlign};if(bu(n)){var l="",c=n.fontStyle,u=yu(n.fontSize);if(!parseFloat(u))return;var d=n.fontFamily||Je,h=n.fontWeight;l+="font-size:"+u+";font-family:"+d+";",c&&"normal"!==c&&(l+="font-style:"+c+";"),h&&"normal"!==h&&(l+="font-weight:"+h+";"),s.style=l}else s.style="font: "+r;return i.match(/\s/)&&(s["xml:space"]="preserve"),o&&(s.x=o),a&&(s.y=a),Tk(s,t.transform),Ck(s,n,t,e),Mk(s,t),e.animation&&_k(t,s,e),ak("text",t.id+"",s,void 0,i)}}(t,e):void 0}function zk(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(Wo(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!Uo(o))return;r="radialGradient",a.cx=qn(o.x,.5),a.cy=qn(o.y,.5),a.r=qn(o.r,.5)}for(var s=o.colorStops,l=[],c=0,u=s.length;cl?Kk(t,null==n[d+1]?null:n[d+1].elm,n,s,d):Qk(t,e,a,l))}(n,i,r):jk(r)?(jk(t.text)&&Uk(n,""),Kk(n,null,r,0,r.length-1)):jk(i)?Qk(n,i,0,i.length-1):jk(t.text)&&Uk(n,""):t.text!==e.text&&(jk(i)&&Qk(n,i,0,i.length-1),Uk(n,e.text)))}var eT=0,nT=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=function(){},this.configLayer=function(){},this.storage=e,this._opts=n=wn({},n),this.root=t,this._id="zr"+eT++,this._oldVNode=ck(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=ok("svg");Jk(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(Yk(t,e))tT(t,e);else{var n=t.elm,i=Vk(n);Zk(e),null!==i&&(Bk(i,e.elm,Wk(n)),Qk(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return Ok(t,lk(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=lk(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=ak("rect","bg",{width:t,height:e,x:"0",y:"0"}),Go(n))zk({fill:n},r.attrs,"fill",i);else if(Vo(n))Nk({style:{fill:n},dirty:si,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=zo(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=ak("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=An(Pn(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(ak("defs","defs",{},l)),t.animation){var c=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=An(Pn(t),function(e){return e+r+An(Pn(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=An(Pn(e),function(t){return"@keyframes "+t+r+An(Pn(e[t]),function(n){return n+r+An(Pn(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(c){var u=ak("style","stl",{},[],c);o.push(u)}}return ck(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},sk(this.renderToVNode({animation:qn(t.cssAnimation,!0),emphasis:qn(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:qn(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,c=0;c=0&&(!d||!r||d[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),c=s.getExtent(),u=n.getDevicePixelRatio(),d=Math.abs(c[1]-c[0])*(u||1),h=Math.round(a/d);if(isFinite(h)&&h>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/h)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/h));var p=void 0;Nn(r)?p=IS[r]:zn(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/h,p,DS))}}}}}("line"))},function(t){Sw(UC),Sw(SM)},function(t){Sw(SM),t.registerComponentModel(CM),t.registerComponentView($M),t.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},si),t.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},si)},function(t){t.registerPainter("svg",nT)}]);class iT extends Pt{constructor(){super(...arguments),this.options=null,this.data=[],this.height="120px",this._chart=null,this._resizeObserver=null}render(){return ht`
`}connectedCallback(){super.connectedCallback(),this.hasUpdated&&!this._chart&&this.options&&(this._mount(),this._observeResize())}firstUpdated(){this._mount(),this._observeResize()}updated(t){(t.has("options")||t.has("data"))&&(this._chart&&this.options?this._chart.setOption(this._mergedOptions(),!0):!this._chart&&this.options&&this._mount()),t.has("height")&&this._chart&&this._chart.resize()}disconnectedCallback(){this._destroy(),super.disconnectedCallback()}_mount(){if(!this.options)return;const t=this.shadowRoot?.querySelector(".chart-host");t&&(this._chart=Ib(t,void 0,{renderer:"svg"}),this._chart.setOption(this._mergedOptions(),!0))}_mergedOptions(){if(!this.options)throw new Error("span-chart: options not set");return{...this.options,series:this.data}}_observeResize(){if("undefined"==typeof ResizeObserver)return;this._resizeObserver=new ResizeObserver(()=>{this._chart&&this._chart.resize()});const t=this.shadowRoot?.querySelector(".chart-host");t&&this._resizeObserver.observe(t)}_destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._chart&&(this._chart.dispose(),this._chart=null)}}iT.styles=A` :host { display: block; width: 100%; @@ -55,13 +55,13 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft width: 100%; height: 100%; } - `,x([zt({attribute:!1})],Lk.prototype,"options",void 0),x([zt({attribute:!1})],Lk.prototype,"data",void 0),x([zt({type:String})],Lk.prototype,"height",void 0);try{customElements.get("span-chart")||customElements.define("span-chart",Lk)}catch{}function Ek(t,e,n,i,r,a,s,l,c){const{options:u,series:h}=function(t,e,n,i,r,a=!1){n||(n=g[o]);const s=i?"140, 160, 220":"77, 217, 175",l=`rgb(${s})`,c=Date.now(),u=c-e,h=void 0!==n.fixedMin&&void 0!==n.fixedMax,d=(t??[]).filter(t=>t.time>=u).map(t=>[t.time,Math.abs(t.value)]),p=[{type:"line",data:d,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${s}, 0.18)`},{offset:1,color:`rgba(${s}, 0.18)`}]}},itemStyle:{color:l}}],f=d.length>0?function(t){let e=0;for(const n of t)n[1]>e&&(e=n[1]);return e}(d):0,v={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:f<10?t=>0===t?"0":t.toFixed(1):t=>n.format(t)},splitLine:{lineStyle:{opacity:.15}}};h?(v.min=n.fixedMin,v.max=n.fixedMax):f<1&&(v.min=0,v.max=1),r&&"current"===n.entityRole&&(v.min=0,v.max=Math.ceil(1.25*r),p.push({type:"line",data:[[u,.8*r],[c,.8*r]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),p.push({type:"line",data:[[u,r],[c,r]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:u,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:v,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:t=>{if(!t||0===t.length)return"";const e=t[0],i=new Date(e.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),r=parseFloat(e.value[1].toFixed(2));return`
${i}
${n.format(r)} ${n.unit(r)}
`}},animation:!1};return{options:m,series:p}}(n,i,r,a,l,c),d=s??120;t.style.minHeight=d+"px";let p=t.querySelector("span-chart");p||(p=document.createElement("span-chart"),p.style.display="block",p.style.width="100%",t.innerHTML="",t.appendChild(p));const f=t.clientHeight;p.height=(f>0?f:d)+"px",p.options=u,p.data=h}function zk(t){return"function"==typeof globalThis.CSS?.escape?CSS.escape(t):t.replace(/["\\]/g,"\\$&")}function Ok(t,e,n,i,r){const o=t.querySelector(".panel-stats");o&&function(t,e,n,i,r){const o="current"===(i.chart_metric||"power"),a=t.querySelector(".stat-consumption .stat-value"),s=t.querySelector(".stat-consumption .stat-unit");if(o){const t=n.panel_entities?.site_power,i=t?e.states[t]:null,r=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(r)?Math.abs(r).toFixed(1):"--"),s&&(s.textContent="A")}else{let t=r;const i=n.panel_entities?.site_power;if(i){const n=e.states[i];n&&(t=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=Gt(t)),s&&(s.textContent="kW")}const l=t.querySelector(".stat-upstream .stat-value"),c=t.querySelector(".stat-upstream .stat-unit");if(l){const t=n.panel_entities?.current_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",c&&(c.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=Gt(t),c&&(c.textContent="kW")}}const u=t.querySelector(".stat-downstream .stat-value"),h=t.querySelector(".stat-downstream .stat-unit");if(u){const t=n.panel_entities?.feedthrough_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;u.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",h&&(h.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;u.textContent=Gt(t),h&&(h.textContent="kW")}}const d=t.querySelector(".stat-solar .stat-value"),p=t.querySelector(".stat-solar .stat-unit");if(d){const t=n.panel_entities?.pv_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;d.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",p&&(p.textContent="A")}else{if(i){const t=Math.abs(parseFloat(i.state)||0);d.textContent=Gt(t)}else d.textContent="--";p&&(p.textContent="kW")}}const f=t.querySelector(".stat-battery .stat-value");if(f){const t=n.panel_entities?.battery_level,i=t?e.states[t]:null;i&&(f.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const g=t.querySelector(".stat-grid-state .stat-value");if(g){const t=n.panel_entities?.dsm_state,i=t?e.states[t]:null;g.textContent=i?e.formatEntityState?.(i)||i.state:"--"}}(o,e,n,i,r)}class Nk{get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new Qt(t):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(t,e){const n=Date.now();if(this._fetching)return this._settings;if(this._settings&&n-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const n={};e&&(n.config_entry_id=e);const r={type:"call_service",domain:l,service:"get_graph_settings",service_data:n,return_response:!0},o=this._retry?await this._retry.callWS(t,r,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await t.callWS(r);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function Rk(t,e){if(!t)return a;const n=t.circuits?.[e];return n?.has_override?n.horizon:t.global_horizon??a}function Hk(t,e){if(!t)return a;const n=t.sub_devices?.[e];return n?.has_override?n.horizon:t.global_horizon??a}class Bk{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new Jt,this.monitoringMultiCache=new te,this.graphSettingsCache=new Nk,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this.monitoringCache.errorStore=t,this.graphSettingsCache.errorStore=t,this.monitoringMultiCache.errorStore=t}get hass(){return this._hass}set hass(t){this._hass=t}get topology(){return this._topology}get config(){return this._config}set showMonitoring(t){this._showMonitoring=t}init(t,e,n,i){this._topology=t,this._config=e,this._hass=n,this._configEntryId=i}setFavoriteRefs(t){this._favRefs=t}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(t){this._panelFavorites=t}setFavoritesPerPanelInfo(t){this._perPanelInfo=t??new Map}get _inFavoritesView(){return null!==this._favRefs}setConfig(t){this._config=t}buildHorizonMaps(t){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),t&&this._topology?.circuits)for(const e of Object.keys(this._topology.circuits))this.horizonMap.set(e,Rk(t,e));if(t&&this._topology?.sub_devices)for(const e of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(e,Hk(t,e))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(t){if(!this._hass||0===t.length)return null;const e=this._hass;return function(t){let e=!1;const n={},i={};for(const r of t)r&&(e=!0,r.circuits&&Object.assign(n,r.circuits),r.mains&&Object.assign(i,r.mains));return e?{circuits:n,mains:i}:null}(await Promise.all(t.map(t=>this.monitoringMultiCache.fetchOne(e,t))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const t=new Set;for(const e of Object.values(this._favRefs))e.configEntryId&&t.add(e.configEntryId);const e=new Map;await Promise.all(Array.from(t).map(async t=>{e.set(t,await this._fetchGraphSettingsFresh(t))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const t of Object.keys(this._topology.circuits)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.horizonMap.set(t,Rk(i,r))}if(this._topology.sub_devices)for(const t of Object.keys(this._topology.sub_devices)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.subDeviceHorizonMap.set(t,Hk(i,r))}}async loadHistory(){await Me(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const t=Date.now();for(const[e,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(e)??a;if(!s[i]?.useRealtime)continue;const r=Zt(n,this._config);if(!r)continue;const o=this._hass.states[r];if(!o)continue;const l=parseFloat(o.state);if(isNaN(l))continue;const c=me(i),u=ye(c),h=_e(c),d=t-c,p=this.powerHistory.get(e)??[];p.length>0&&t-p[p.length-1].time0&&t-p[p.length-1].time0&&this._topology)for(const{key:t,devId:e}of Ce(this._topology))n.has(e)&&r.add(t);const o=new Map;try{await Me(this._hass,this._topology,this._config,o,e,n);for(const t of e.keys()){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}for(const t of r){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}this.updateDOM(t)}catch(t){console.warn("SPAN Panel: history refresh failed",t),this._errorStore?.add({key:"fetch:history",level:"warning",message:i("error.history_failed"),persistent:!1})}}updateDOM(t){this._hass&&this._topology&&this._config&&(function(t,e,n,r,o,a){if(!t||!n||!e)return;const s=ve(r);let l=0;for(const[,t]of Object.entries(n.circuits)){const n=t.entities?.power;if(!n)continue;const i=e.states[n],r=i&&parseFloat(i.state)||0;t.device_type!==u&&(l+=Math.abs(r))}Ok(t,e,n,r,l);const h=Yt(r),d="current"===h.entityRole;for(const[r,l]of Object.entries(n.circuits)){const n=t.querySelector(`.circuit-slot[data-uuid="${zk(r)}"]`);if(!n)continue;const p=l.entities?.power,f=p?e.states[p]:null,g=f&&parseFloat(f.state)||0,v=l.device_type===u||g<0,y=l.entities?.switch,_=y?e.states[y]:null,x=_?"on"===_.state:(f?.attributes?.relay_state||l.relay_state)===c,b=n.querySelector(".power-value");if(b)if(d){const t=l.entities?.current,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;b.innerHTML=`${h.format(i)}A`}else b.innerHTML=`${Ut(g)}${Wt(g)}`;const w=n.querySelector(".toggle-pill");if(w){w.className="toggle-pill "+(x?"toggle-on":"toggle-off");const t=w.querySelector(".toggle-label");t&&(t.textContent=i(x?"grid.on":"grid.off"))}let S;if(n.classList.toggle("circuit-off",!x),n.classList.toggle("circuit-producer",v),l.always_on)S="always_on";else{const t=l.entities?.select,n=t?e.states[t]:null;S=n?n.state:"unknown"}const C=m[S]??m.unknown,M=n.querySelector(".shedding-icon");M&&(M.setAttribute("icon",C.icon),M.style.color=C.color,M.title=C.label());const k=n.querySelector(".shedding-icon-secondary");k&&(C.icon2?(k.setAttribute("icon",C.icon2),k.style.color=C.color,k.style.display=""):k.style.display="none");const T=n.querySelector(".shedding-label");T&&(C.textLabel?(T.textContent=C.textLabel,T.style.color=C.color,T.style.display=""):T.style.display="none");const D=n.querySelector(".chart-container");if(D){const t=o.get(r)||[],e=n.classList.contains("circuit-col-span")?200:100,i=a?.has(r)?me(a.get(r)):s,c=l.device_type===u;Ek(D,0,t,i,h,v,e,l.breaker_rating_a??void 0,c)}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(t,e,n,i,r,o){if(!n.sub_devices)return;const a=ve(i);for(const[i,s]of Object.entries(n.sub_devices)){const n=t.querySelector(`[data-subdev="${zk(i)}"]`);if(!n)continue;const l=ue(s);if(l){const t=e.states[l],i=t&&parseFloat(t.state)||0,r=n.querySelector(".sub-power-value");r&&(r.innerHTML=`${Ut(i)} ${Wt(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const t of c){const e=t.dataset.chartKey;if(!e)continue;const n=r.get(e)||[];let s=v.power;e.endsWith("_soc")?s=v.soc:e.endsWith("_soe")&&(s=v.soe);const l=!!t.closest(".bess-chart-col");Ek(t,0,n,o?.has(i)?me(o.get(i)):a,s,!1,l?120:150,void 0,e.endsWith("_soc")||e.endsWith("_soe"))}for(const t of Object.keys(s.entities||{})){const i=n.querySelector(`[data-eid="${zk(t)}"]`);if(!i)continue;const r=e.states[t];if(r){let t;if(e.formatEntityState)t=e.formatEntityState(r);else{t=r.state;const e=r.attributes.unit_of_measurement||"";e&&(t+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(t=(e/1e3).toFixed(1)+" kWh")}i.textContent=t}}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(t){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(t)}}onToggleClick(t,e){const n=t.target,r=n?.closest(".toggle-pill");if(!r)return;const o=e.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;t.stopPropagation(),t.preventDefault();const a=r.closest("[data-uuid]");if(!a||!this._topology||!this._hass)return;const s=a.dataset.uuid;if(!s)return;const l=this._topology.circuits[s];if(!l)return;const c=l.entities?.switch;if(!c)return;const u=this._hass.states[c];if(!u)return void console.warn("SPAN Panel: switch entity not found:",c);const h="on"===u.state?"turn_off":"turn_on";this._hass.callService("switch",h,{},{entity_id:c}).catch(t=>{console.warn("SPAN Panel: switch service call failed",t),this._errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}async onGearClick(t,e){const n=t.target,i=n?.closest(".gear-icon");if(!i)return;const r=e.querySelector("span-side-panel");if(!r||!this._hass)return;if(r.hass=this._hass,r.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const t=await this._buildFavoritesSections();if(0===t.length)return;return void r.open({favoritesMode:!0,perPanelSections:t})}return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void r.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const o=i.dataset.uuid;if(o&&this._topology){const t=this._topology.circuits[o];if(t){const e=this._favRefs?.[o]??null,n=e&&"circuit"===e.kind?e.targetId:o,i=e?.configEntryId??this._configEntryId;let s,l;e?[s,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),s=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=t.entities?.current??t.entities?.power,u=c?l?.circuits?.[c]??null:null,h=s?.global_horizon??a,d=s?.circuits?.[n],p=d?{...d,globalHorizon:h}:{horizon:h,has_override:!1,globalHorizon:h},f=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,g=null!==e||(this._panelFavorites?.circuitUuids.has(n)??!1),v=this._inFavoritesView||null!==this._panelFavorites;return void r.open({...t,uuid:n,monitoringInfo:u,showMonitoring:this._showMonitoring,graphHorizonInfo:p,showFavorites:v,favoritePanelDeviceId:f,isFavorite:g,configEntryId:i})}}const s=i.dataset.subdevId;if(s&&this._topology?.sub_devices?.[s]){const t=this._topology.sub_devices[s],e=this._favRefs?.[s]??null,n=e&&"sub_device"===e.kind?e.targetId:s,i=e?.configEntryId??this._configEntryId;let o;e?o=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),o=this.graphSettingsCache.settings);const l=o?.global_horizon??a,c=o?.sub_devices?.[n],u=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},h=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,d=null!==e||(this._panelFavorites?.subDeviceIds.has(n)??!1),p=this._inFavoritesView||null!==this._panelFavorites;r.open({subDeviceMode:!0,subDeviceId:n,name:t.name??n,deviceType:t.type??"",entities:t.entities,graphHorizonInfo:u,showFavorites:p,favoritePanelDeviceId:h,isFavorite:d,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const t=function(t,e){const n=new Map;for(const i of Object.values(t)){if("circuit"!==i.kind)continue;const t=e.get(i.panelDeviceId);if(void 0===t)continue;let r=n.get(i.panelDeviceId);void 0===r&&(r={panelDeviceId:i.panelDeviceId,panelName:t.panelName,topology:t.topology,configEntryId:t.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,r)),r.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((t,e)=>t.panelName.localeCompare(e.panelName))}(this._favRefs,this._perPanelInfo);if(0===t.length)return[];return await Promise.all(t.map(async t=>({panelDeviceId:t.panelDeviceId,panelName:t.panelName,topology:t.topology,graphSettings:await this._fetchGraphSettingsFresh(t.configEntryId),favoriteCircuitUuids:t.favoriteCircuitUuids,configEntryId:t.configEntryId})))}async _fetchGraphSettingsFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_graph_settings",service_data:e,return_response:!0},r=this._errorStore?new Qt(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await this._hass.callWS(n);return o?.response??null}catch(t){return console.warn("SPAN Panel: fresh graph settings fetch failed",t),null}}async _fetchMonitoringStatusFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_monitoring_status",service_data:e,return_response:!0},r=this._errorStore?new Qt(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await this._hass.callWS(n),a=o?.response;return a?{circuits:a.circuits,mains:a.mains}:null}catch(t){return console.warn("SPAN Panel: fresh monitoring status fetch failed",t),null}}bindSlideConfirm(t,e){const n=t.querySelector(".slide-confirm-knob"),i=t.querySelector(".slide-confirm-text");if(!n||!i)return;let r=!1,o=0,a=0;const s=e=>{t.classList.contains("confirmed")||(r=!0,o=e-n.offsetLeft,a=t.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=t=>{if(!r)return;const e=Math.max(2,Math.min(t-o,a));n.style.left=e+"px"},c=()=>{if(!r)return;r=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",t.classList.add("confirmed"),n.querySelector("span-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=t.dataset.textOn??"",e&&e.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",t=>{t.preventDefault(),s(t.clientX)}),t.addEventListener("mousemove",t=>l(t.clientX)),t.addEventListener("mouseup",c),t.addEventListener("mouseleave",c),n.addEventListener("touchstart",t=>{t.preventDefault(),s(t.touches[0].clientX)},{passive:!1}),t.addEventListener("touchmove",t=>l(t.touches[0].clientX),{passive:!0}),t.addEventListener("touchend",c),t.addEventListener("touchcancel",c),t.addEventListener("click",()=>{t.classList.contains("confirmed")&&(t.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("span-icon")?.setAttribute("icon","mdi:lock"),i.textContent=t.dataset.textOff??"",e&&e.classList.add("switches-disabled"))})}startIntervals(t,e){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(t),e&&e()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(t)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(t,e){this.cleanupResizeObserver(),e&&(this._lastWidth=e.clientWidth,this._resizeObserver=new ResizeObserver(e=>{const n=e[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const e of t.querySelectorAll(".chart-container")){const t=e.querySelector("span-chart");t&&t.remove()}this.updateDOM(t)},150))}),this._resizeObserver.observe(e))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function Fk(t=""){const e=t?` value="${Rt(t)}"`:"",n=t?"":"display:none;";return`\n
\n \n \n
\n `}function $k(t,e,n,r,o,a,s){const l=e.entities?.power,u=l?n.states[l]:null,h=u&&parseFloat(u.state)||0,d=e.entities?.switch,p=d?n.states[d]:null,f=p?"on"===p.state:(u?.attributes?.relay_state||e.relay_state)===c,g=e.breaker_rating_a,v=g?`${Math.round(g)}A`:"",y=Rt(e.name||i("grid.unknown")),_=Yt(r),x="current"===_.entityRole;let b;if(f)if(x){const t=e.entities?.current,i=t?n.states[t]:null,r=i&&parseFloat(i.state)||0;b=`${_.format(r)}A`}else b=`${Ut(h)}${Wt(h)}`;else b="";const w=a||"unknown";let S="";if("unknown"!==w){const t=m[w]??m.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};S=t.icon2?`\n \n \n `:t.textLabel?`\n \n ${t.textLabel}\n `:``}let C="",M=o?.utilization_pct??null;if(null==M&&e.breaker_rating_a){const t=e.entities?.current,i=t?n.states[t]:null,r=i?Math.abs(parseFloat(i.state)||0):0;M=Math.round(r/e.breaker_rating_a*1e3)/10}if(null!=M){C=`=80?"utilization-warning":"utilization-normal"}">${Math.round(M)}%`}const k=``,T=!1!==e.is_user_controllable&&!!e.entities?.switch?`
\n ${i(f?"grid.on":"grid.off")}\n \n
`:`${f?"ON":"OFF"}`;return`\n
\n ${v?`${v}`:""}\n ${C}\n ${y}\n ${S}\n ${T}\n \n ${b}\n \n ${k}\n \n
\n `}function Vk(t,e,n,i,r){const o=e.entities?.power,a=o?n.states[o]:null,s=a&&parseFloat(a.state)||0,l=e.device_type===u||s<0,h=e.entities?.switch,d=h?n.states[h]:null,p=ne(0,r,d?"on"===d.state:(a?.attributes?.relay_state||e.relay_state)===c,l),f=Rt(t);return`\n
\n
\n
\n
\n
\n `}function Wk(t){return`
${Rt(t)}
`}function Uk(t,e){const n=e.hysteresisPx??48,i=new WeakSet;let r=null;const o=t=>{const n=t.querySelector(e.nameSelector);return!!n&&(0!==t.clientWidth&&(n.clientWidth<1||n.scrollWidth>n.clientWidth+1))},a=()=>{const i=t.querySelectorAll(e.rowSelector);if(0===i.length)return;const a=i[0].clientWidth;if(0===a)return;if(null!==r){if(a>r+n){for(const t of i)t.classList.remove(e.foldClass);r=null}else for(const t of i)t.classList.add(e.foldClass);return}let s=!1;for(const t of i)if(o(t)){s=!0;break}if(s){for(const t of i)t.classList.add(e.foldClass);r=a}},s=new ResizeObserver(()=>a()),l=()=>{const n=t.querySelectorAll(e.rowSelector);for(const t of n)i.has(t)||(i.add(t),s.observe(t));requestAnimationFrame(()=>a())};l();const c=new MutationObserver(t=>{(t=>{const n=t=>{for(const n of t)if(n instanceof Element){if(n.matches(e.rowSelector))return!0;if(n.querySelector(e.rowSelector))return!0}return!1};for(const e of t)if(n(e.addedNodes)||n(e.removedNodes))return!0;return!1})(t)&&l()});return c.observe(t,{childList:!0,subtree:!0}),()=>{s.disconnect(),c.disconnect()}}function Gk(t,e,n){const i=t.entities?.switch,r=i?e.states[i]:null,o=t.entities?.power,a=o?e.states[o]:null,s=r?"on"===r.state:(a?.attributes?.relay_state||t.relay_state)===c;let l;if("current"===(n.chart_metric||"power")){const n=t.entities?.current,i=n?e.states[n]:null;l=i?Math.abs(parseFloat(i.state)||0):0}else l=a?Math.abs(parseFloat(a.state)||0):0;return{isOn:s,value:l}}function qk(t,e){if(t.always_on)return"always_on";const n=t.entities?.select,i=n?e.states[n]:null;return i?i.state:"unknown"}function jk(t,e,n,i){const r=Gk(t,n,i),o=Gk(e,n,i);return r.isOn&&!o.isOn?-1:!r.isOn&&o.isOn?1:o.value-r.value}function Xk(t,e,n){return t.sort((t,i)=>jk(t[1],i[1],e,n))}function Yk(t){return t.entities?.current??t.entities?.power??""}class Zk{constructor(t){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._foldUnobserve=null,this._ctrl=t}setColumns(t){const e=Math.max(1,Math.min(3,Math.floor(t)));this._columns=e}setInitialExpansion(t){this._expandedUuids=new Set(t)}setInitialSearchQuery(t){this._searchQuery=t}setViewName(t){this._viewName=t}renderActivityView(t,e,n,i,r,o){this._unbindEvents(),this._hass=e,this._topology=n,this._config=i,this._monitoringStatus=r;const a=Xk(Object.entries(n.circuits),e,i);let s=o+Fk(this._searchQuery);s+=`
`;for(const[t,n]of a){const o=ee(r,Yk(n)),a=qk(n,e),l=this._expandedUuids.has(t);s+=`
`,s+=$k(t,n,e,i,o,a,l),l&&(s+=Vk(t,n,e,0,o)),s+="
"}s+="
",s+="",t.innerHTML=s;const l=t.querySelector("span-side-panel");l&&(l.hass=e,l.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}renderAreaView(t,e,n,r,o,a){this._unbindEvents(),this._hass=e,this._topology=n,this._config=r,this._monitoringStatus=o;const s=i("list.unassigned_area"),l=new Map;for(const[t,e]of Object.entries(n.circuits)){const n=e.area??s,i=l.get(n);i?i.push([t,e]):l.set(n,[[t,e]])}const c=[...l.keys()].sort((t,e)=>t===s?1:e===s?-1:t.localeCompare(e));let u=a+Fk(this._searchQuery);u+=`
`;for(const t of c){const n=l.get(t);if(!n)continue;const i=Xk(n,e,r);u+=Wk(t);for(const[t,n]of i){const i=ee(o,Yk(n)),a=qk(n,e),s=this._expandedUuids.has(t);u+=`
`,u+=$k(t,n,e,r,i,a,s),s&&(u+=Vk(t,n,e,0,i)),u+="
"}}u+="
",u+="",t.innerHTML=u;const h=t.querySelector("span-side-panel");h&&(h.hass=e,h.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}updateCollapsedRows(t,e,n,r){const o=Yt(r),a="current"===o.entityRole,s=t.querySelectorAll(".list-row[data-row-uuid]");for(const t of s){const s=t.dataset.rowUuid;if(!s)continue;const l=n.circuits[s];if(!l)continue;const{isOn:c,value:u}=Gk(l,e,r),h=t.querySelector(".list-power-value");if(h)if(c)if(a)h.innerHTML=`${o.format(u)}A`;else{const t=l.entities?.power,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;h.innerHTML=`${Ut(i)}${Wt(i)}`}else h.innerHTML="";const d=t.querySelector(".toggle-pill");if(d){d.classList.toggle("toggle-on",c),d.classList.toggle("toggle-off",!c);const t=d.querySelector(".toggle-label");t&&(t.textContent=i(c?"grid.on":"grid.off"))}const p=t.querySelector(".list-status-badge");p&&(p.textContent=c?"ON":"OFF",p.classList.toggle("list-status-on",c),p.classList.toggle("list-status-off",!c)),t.classList.toggle("circuit-off",!c)}!function(t,e,n,i){const r=t.querySelector(".list-view");if(r)for(const t of function(t,e){let n={anchor:null,units:[]};const i=[n];for(const r of[...t.children])if(r.classList.contains("area-header"))n={anchor:r,units:[]},i.push(n);else if(r.classList.contains("list-cell")){const t=r.dataset.cellUuid,i=t?e.circuits[t]:void 0;t&&i&&n.units.push({cell:r,uuid:t,circuit:i})}return i}(r,n)){if(t.units.length<2)continue;const n=[...t.units].sort((t,n)=>jk(t.circuit,n.circuit,e,i));if(!n.some((e,n)=>e.uuid!==t.units[n].uuid))continue;let o=t.anchor;for(const t of n)o?o.after(t.cell):r.prepend(t.cell),o=t.cell}}(t,e,n,r)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const t={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:t,bubbles:!0,composed:!0}))}_bindEvents(t){this._container=t,this._clickHandler=e=>{const n=e.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const t=i.dataset.expandUuid;return void(t&&this._toggleExpand(t))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(e,t);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,t);if(n.closest(".list-search-clear")){const e=t.querySelector(".list-search");return void(e&&(e.value="",e.dispatchEvent(new Event("input",{bubbles:!0}))))}const r=n.closest(".unit-btn");if(r){const e=r.dataset.unit;e&&t.dispatchEvent(new CustomEvent("unit-changed",{detail:e,bubbles:!0,composed:!0}))}},this._inputHandler=e=>{const n=e.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(t),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(t).then(()=>{this._ctrl.updateDOM(t)}).catch(()=>{})},t.addEventListener("click",this._clickHandler),t.addEventListener("input",this._inputHandler),t.addEventListener("graph-settings-changed",this._graphSettingsHandler);const e=t.querySelector(".slide-confirm");e&&(this._ctrl.bindSlideConfirm(e,t),t.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_attachFoldObserver(t){this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._foldUnobserve=Uk(t,{rowSelector:".list-row",nameSelector:".list-circuit-name",foldClass:"is-folded"})}_applyFilter(t){const e=t.querySelector(".list-search-clear");e&&(e.style.display=this._searchQuery?"":"none");const n=t.querySelectorAll(".list-cell[data-cell-uuid]");for(const t of n){const e=t.querySelector(".list-circuit-name"),n=(e?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=n?"":"none"}const i=t.querySelectorAll(".area-header");for(const t of i){let e=!1,n=t.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){e=!0;break}n=n.nextElementSibling}t.style.display=e?"":"none"}}_toggleExpand(t){if(!(this._container&&this._hass&&this._topology&&this._config))return;const e=zk(t),n=this._container.querySelector(`.list-cell[data-cell-uuid="${e}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${e}"]`),r=n.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(i){if(this._expandedUuids.has(t)){this._expandedUuids.delete(t);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);o&&o.remove(),r&&r.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(t);const e=this._topology.circuits[t];if(!e)return;const n=ee(this._monitoringStatus,Yk(e)),o=Vk(t,e,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),r&&r.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}async function Kk(t,e){const[n,i,r]=await Promise.all([t.callWS({type:"config/area_registry/list"}),t.callWS({type:"config/entity_registry/list"}),t.callWS({type:"config/device_registry/list"})]),o=new Map;for(const t of n)o.set(t.area_id,t.name);const a=new Map;for(const t of i)t.area_id&&a.set(t.entity_id,t.area_id);const s=new Map;for(const t of r)s.set(t.id,t.area_id);let l;if(e.device_id){const t=s.get(e.device_id);t&&(l=o.get(t))}for(const t of Object.values(e.circuits)){let e;for(const n of Object.values(t.entities)){if(!n)continue;const t=a.get(n);if(t){e=o.get(t);break}}e||(e=l),t.area=e}}class Qk{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(t){const e={...t,timestamp:Date.now()};if(e.persistent)this._persistent.set(e.key,e);else{this._clearTransient(),this._transient=e;const t=e.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},t)}this._notify()}remove(t){if(this._persistent.has(t))return this._persistent.delete(t),void this._notify();this._transient?.key===t&&(this._clearTransient(),this._notify())}clear(t){void 0===t?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===t.persistent?this._persistent.clear():!1===t.persistent&&this._clearTransient(),this._notify()}get active(){const t=[...this._persistent.values()];return null!==this._transient&&t.push(this._transient),t}hasPersistent(t){return this._persistent.has(t)}hasAnyPanelOffline(){for(const t of this._persistent.keys())if("panel-offline"===t||t.startsWith("panel-offline:"))return!0;return!1}subscribe(t){return this._subscribers.add(t),()=>{this._subscribers.delete(t)}}watchPanelStatus(t){this.watchPanelStatuses([{entityId:t,panelName:null}])}watchPanelStatuses(t){const e=this._watchedPanels,n=new Map;for(const i of t){const t=e.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:t?.wasOffline??!1})}const i=this._isSingleUnnamed(e),r=this._isSingleUnnamed(n);for(const t of e.keys()){n.has(t)&&i===r||this._persistent.delete(this._offlineKey(t,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const e of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(e,t));this._watchedPanels.clear(),this._notify()}updateHass(t){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const[n,o]of this._watchedPanels){const a=t.states[n]?.state,s="on"===a,l=this._offlineKey(n,e),c=this._reconnectKey(n,e);if(s){const t=o.wasOffline;o.wasOffline=!1,this.remove(l),t&&this.add({key:c,level:"info",message:null===o.panelName?i("error.panel_reconnected"):r("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?i("error.panel_offline"):r("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(t){if(1!==t.size)return!1;for(const e of t.values())return null===e.panelName;return!1}_offlineKey(t,e){return e?"panel-offline":`panel-offline:${t}`}_reconnectKey(t,e){return e?"panel-reconnected":`panel-reconnected:${t}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const t of this._subscribers)try{t()}catch(t){console.warn("SPAN Panel: error-store subscriber threw",t)}}}function Jk(t){let e=0;for(const n of Object.values(t))if(n)for(const t of n.tabs)t>e&&(e=t);return e>0?e+e%2:0}function tT(t){return t?{id:t.id,name:t.name,name_by_user:t.name_by_user,config_entries:t.config_entries,identifiers:t.identifiers,via_device_id:t.via_device_id,sw_version:t.sw_version,model:t.model}:null}const eT="favorites-changed";async function nT(t,e,n={}){const i=await t.callWS({type:"call_service",domain:l,service:e,service_data:n,return_response:!0});return i?.response??null}const iT=Object.keys(m).filter(t=>"unknown"!==t&&"always_on"!==t);class rT extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(t){this._hass=t,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(t){this._config=t,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(t))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const t of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[t]);this._debounceTimers={}}_modeFor(t){return t.favoritesMode?"favorites":t.panelMode?"panel":t.subDeviceMode?"subDevice":"circuit"}_render(){const t=this._config;if(!t)return;const e=this.shadowRoot;if(!e)return;e.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart span-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',e.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),e.appendChild(i);const r=document.createElement("div");r.className="panel",e.appendChild(r),t.favoritesMode?this._renderFavoritesMode(r):t.panelMode?this._renderPanelMode(r):t.subDeviceMode?this._renderSubDeviceMode(r,t):this._renderCircuitMode(r,t)}_renderPanelMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.global_defaults"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body";const o=e.graphSettings,l=e.topology,c=o?.global_horizon??a,u=o?.circuits??{};r.appendChild(this._buildListColumnsSection());const h=document.createElement("div");h.className="section";const d=document.createElement("div");d.className="section-label",d.textContent=i("sidepanel.graph_horizon"),h.appendChild(d);const p=document.createElement("div");p.className="field-row";const g=document.createElement("span");g.className="field-label",g.textContent=i("sidepanel.global_default"),p.appendChild(g);const v=document.createElement("select");for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===c&&(e.selected=!0),v.appendChild(e)}if(v.addEventListener("change",()=>{const t={horizon:v.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_graph_time_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),p.appendChild(v),h.appendChild(p),r.appendChild(h),l?.circuits){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.circuit_scales"),t.appendChild(n);const o=Object.entries(l.circuits).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,i]of o){const r=this._buildPanelModeCircuitRow(n,i,u[n],c,e.configEntryId??null,e.showFavorites??!1,e.favoritePanelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}r.appendChild(t)}const m=o?.sub_devices??{};if(l?.sub_devices){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.subdevice_scales"),t.appendChild(n);const o=Object.entries(l.sub_devices).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,r]of o){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");if(a.className="field-label",a.textContent=r.name||n,a.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",o.appendChild(a),e.showFavorites&&e.favoritePanelDeviceId){const t=this._buildSubDeviceFavoriteHeart(r.entities,e.favoriteSubDeviceIds?.has(n)??!1);t&&o.appendChild(t)}const l=m[n]||{horizon:c,has_override:!1},u=l.has_override?l.horizon:c,h=document.createElement("select");h.dataset.subdevId=n;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===u&&(e.selected=!0),h.appendChild(e)}if(h.addEventListener("change",()=>{this._debounce(`subdev-${n}`,f,()=>{const t={subdevice_id:n,horizon:h.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_subdevice_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),o.appendChild(h),l.has_override){const t=document.createElement("button");t.textContent="↺",t.title=i("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const r={subdevice_id:n};e.configEntryId&&(r.config_entry_id=e.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",r).then(()=>{h.value=c,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),o.appendChild(t)}t.appendChild(o)}r.appendChild(t)}t.appendChild(r)}_buildPanelModeCircuitRow(t,e,n,r,o,a,l,c){const u=document.createElement("div");u.className="field-row";const h=document.createElement("span");if(h.className="field-label",h.textContent=e.name||t,h.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",u.appendChild(h),a&&l){const n=this._buildFavoriteHeart(e.entities,c?.has(t)??!1);n&&u.appendChild(n)}const d=n||{horizon:r,has_override:!1},p=d.has_override?d.horizon:r,g=document.createElement("select");g.dataset.uuid=t;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===p&&(e.selected=!0),g.appendChild(e)}if(g.addEventListener("change",()=>{this._debounce(`circuit-${t}`,f,()=>{const e={circuit_id:t,horizon:g.value};o&&(e.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),u.appendChild(g),d.has_override){const e=document.createElement("button");e.textContent="↺",e.title=i("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const n={circuit_id:t};o&&(n.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{g.value=r,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),u.appendChild(e)}return u}_renderFavoritesMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.favorites_subtitle"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body",r.appendChild(this._buildListColumnsSection());for(const t of e.perPanelSections)r.appendChild(this._buildFavoritesPanelSection(t));t.appendChild(r)}_buildFavoritesPanelSection(t){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=t.panelName,e.appendChild(n);const i=t.graphSettings?.global_horizon??a,r=t.graphSettings?.circuits??{},o=function(t){const e=t.circuits??{};return Object.entries(e).map(([t,e])=>({uuid:t,circuit:e})).sort((t,e)=>(t.circuit.name||"").localeCompare(e.circuit.name||""))}(t.topology);for(const{uuid:n,circuit:a}of o){const o=this._buildPanelModeCircuitRow(n,a,r[n],i,t.configEntryId,!0,t.panelDeviceId,t.favoriteCircuitUuids);e.appendChild(o)}return e}_renderCircuitMode(t,e){const n=`${Rt(String(e.breaker_rating_a))}A · ${Rt(String(e.voltage))}V · Tabs [${Rt(String(e.tabs))}]`,i=this._createHeader(Rt(e.name),n);t.appendChild(i);const r=document.createElement("div");r.className="panel-body",t.appendChild(r),this._renderRelaySection(r,e),e.showFavorites&&this._renderFavoriteSection(r,e),this._renderSheddingSection(r,e),this._renderGraphHorizonSection(r,e),e.showMonitoring&&this._renderMonitoringSection(r,e)}_favoriteEntityId(t){return t?.current??t?.power??null}_subDeviceFavoriteEntityId(t){if(!t)return null;let e=null;for(const[n,i]of Object.entries(t)){if("sensor"===i.domain)return n;e||(e=n)}return e}_buildSubDeviceFavoriteHeart(t,e){const n=this._subDeviceFavoriteEntityId(t);return n?this._buildHeartButton(n,e):null}_buildListColumnsSection(){const t=document.createElement("div");t.className="section";const e=document.createElement("div");e.className="section-label",e.textContent=i("sidepanel.list_view_columns"),t.appendChild(e);const n=document.createElement("div");n.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=i("sidepanel.columns"),n.appendChild(r);const o=Bt(),a=document.createElement("div");a.className="unit-toggle";for(const t of[1,2,3]){const e=document.createElement("button");e.type="button",e.className="unit-btn"+(t===o?" unit-active":""),e.dataset.columns=String(t),e.textContent=String(t),e.addEventListener("click",()=>{Ft(t);for(const t of a.querySelectorAll(".unit-btn"))t.classList.toggle("unit-active",t===e);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:t,bubbles:!0,composed:!0}))}),a.appendChild(e)}return n.appendChild(a),t.appendChild(n),t}_buildFavoriteHeart(t,e){const n=this._favoriteEntityId(t);return n?this._buildHeartButton(n,e):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(t,e){const n=document.createElement("button");n.type="button",n.className=e?"fav-heart active":"fav-heart",n.dataset.role="fav-heart",n.title=i("sidepanel.save_to_favorites"),n.setAttribute("role","switch"),n.setAttribute("aria-checked",String(e)),n.setAttribute("aria-label",i("sidepanel.save_to_favorites"));const r=document.createElement("span-icon");return r.setAttribute("icon",e?"mdi:heart":"mdi:heart-outline"),n.appendChild(r),n.addEventListener("click",e=>{e.stopPropagation(),this._toggleFavoriteEntity(n,r,t).catch(()=>{})}),n}async _toggleFavoriteEntity(t,e,n){if(!this._hass)return;const r=t.classList.contains("active"),o=!r;t.classList.toggle("active",o),e.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(o));try{o?await async function(t,e){const n=await nT(t,"add_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(eT)),n?.favorites??{}}(this._hass,n):await async function(t,e){const n=await nT(t,"remove_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(eT)),n?.favorites??{}}(this._hass,n)}catch(n){throw t.classList.toggle("active",r),e.setAttribute("icon",r?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(r)),console.warn("SPAN Panel: favorite toggle failed",n),this.errorStore?.add({key:"service:favorites",level:"error",message:i("error.favorites_toggle_failed"),persistent:!1}),n}}_renderFavoriteSection(t,e){const n=this._favoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_appendFavoriteHeartSection(t,e,n){const r=document.createElement("div");r.className="section",r.innerHTML=``;const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=i("sidepanel.save_to_favorites"),o.appendChild(a),o.appendChild(this._buildHeartButton(e,n)),r.appendChild(o),t.appendChild(r)}_renderSubDeviceMode(t,e){const n=this._createHeader(Rt(e.name),Rt(e.deviceType));t.appendChild(n);const i=document.createElement("div");i.className="panel-body",t.appendChild(i),e.showFavorites&&this._renderSubDeviceFavoriteSection(i,e),this._renderSubDeviceHorizonSection(i,e)}_renderSubDeviceFavoriteSection(t,e){const n=this._subDeviceFavoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_renderSubDeviceHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,h=document.createElement("div");h.className="horizon-bar";const d=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))d.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of h.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of d){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={subdevice_id:e.subDeviceId};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_subdevice_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_subdevice_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(r)}n.appendChild(h),t.appendChild(n)}_createHeader(t,e){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),r=Rt(t),o=Rt(e);i.innerHTML=`
${r}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(t,e){if(!1===e.is_user_controllable||!e.entities?.switch)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.breaker");const a=document.createElement("span-switch");a.dataset.role="relay-toggle";const s=e.entities.switch,l=this._hass?.states?.[s]?.state;"on"===l&&a.setAttribute("checked",""),a.addEventListener("change",()=>{const t=a.hasAttribute("checked")||a.checked;this._callService("switch",t?"turn_on":"turn_off",{entity_id:s}).catch(t=>{console.warn("SPAN Panel: relay toggle failed",t),this.errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderSheddingSection(t,e){if(!e.entities?.select)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const s=e.entities.select,l=this._hass?.states?.[s]?.state||"";for(const t of iT){const e=m[t];if(!e)continue;const n=document.createElement("option");n.value=t,n.textContent=i(`shedding.select.${t}`)||e.label(),t===l&&(n.selected=!0),a.appendChild(n)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:s,option:a.value}).catch(t=>{console.warn("SPAN Panel: shedding update failed",t),this.errorStore?.add({key:"service:shedding",level:"error",message:i("error.shedding_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderGraphHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,h=document.createElement("div");h.className="horizon-bar";const d=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))d.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of h.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of d){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={circuit_id:e.uuid};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_circuit_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(r)}n.appendChild(h),t.appendChild(n)}_renderMonitoringSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=i("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("span-switch");a.dataset.role="monitoring-toggle";const s=e.monitoringInfo,l=null!=s&&!1!==s.monitoring_enabled;l&&a.setAttribute("checked",""),r.appendChild(o),r.appendChild(a),n.appendChild(r);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",n.appendChild(c);const u=!0===s?.has_override,h=document.createElement("div");h.className="radio-group",h.innerHTML=`\n \n \n `,c.appendChild(h);const d=document.createElement("div");d.dataset.role="threshold-fields",d.style.display=u?"block":"none";const p=s?.continuous_threshold_pct??80,f=s?.spike_threshold_pct??100,g=s?.window_duration_m??15,v=s?.cooldown_duration_m??15;d.appendChild(this._createThresholdRow(i("sidepanel.continuous_pct"),"continuous",p,e)),d.appendChild(this._createThresholdRow(i("sidepanel.spike_pct"),"spike",f,e)),d.appendChild(this._createDurationRow(i("sidepanel.window_duration"),"window-m",g,1,180,"m",e)),d.appendChild(this._createDurationRow(i("sidepanel.cooldown"),"cooldown-m",v,1,180,"m",e)),c.appendChild(d),a.addEventListener("change",()=>{const t=a.checked;c.style.display=t?"block":"none";const n={circuit_id:e.entities?.power||e.uuid,monitoring_enabled:t};e.configEntryId&&(n.config_entry_id=e.configEntryId),this._callDomainService("set_circuit_threshold",n).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})});const m=h.querySelectorAll('input[type="radio"]');for(const t of m)t.addEventListener("change",()=>{const n="custom"===t.value&&t.checked;if(d.style.display=n?"block":"none",!n&&t.checked){const t={circuit_id:e.entities?.power||e.uuid};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("clear_circuit_threshold",t).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})}});t.appendChild(n)}_createThresholdRow(t,e,n,r){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=t;const s=document.createElement("input");return s.type="number",s.min="0",s.max="200",s.value=String(n),s.dataset.role=`threshold-${e}`,s.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),o=t.querySelector('[data-role="threshold-window-m"]'),a=t.querySelector('[data-role="threshold-cooldown-m"]'),s={circuit_id:r.entities?.power||r.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0};r.configEntryId&&(s.config_entry_id=r.configEntryId),this._callDomainService("set_circuit_threshold",s).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),o.appendChild(a),o.appendChild(s),o}_createDurationRow(t,e,n,r,o,a,s,l=!1){const c=document.createElement("div");c.className="field-row";const u=document.createElement("span");u.className="field-label",u.textContent=t;const h=document.createElement("div"),d=document.createElement("input");d.type="number",d.min=String(r),d.max=String(o),d.value=String(n),d.dataset.role=`threshold-${e}`,l&&(d.disabled=!0);const p=document.createElement("span");return p.textContent=a,h.appendChild(d),h.appendChild(p),l||d.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),r=t.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:s.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:r?Number(r.value):void 0};s.configEntryId&&(o.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),c.appendChild(u),c.appendChild(h),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const t=this._config;if(!t.subDeviceMode&&!t.favoritesMode){if(t.entities?.switch){const e=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(e){const n=this._hass?.states?.[t.entities.switch]?.state;"on"===n?e.setAttribute("checked",""):e.removeAttribute("checked")}}if(t.entities?.select){const e=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(e){const n=this._hass?.states?.[t.entities.select]?.state||"";e.value=n}}}}_callService(t,e,n){return this._hass?Promise.resolve(this._hass.callService(t,e,n)):Promise.resolve()}_callDomainService(t,e){return this._hass?this._hass.callWS({type:"call_service",domain:l,service:t,service_data:e}):Promise.resolve()}_debounce(t,e,n){this._debounceTimers[t]&&clearTimeout(this._debounceTimers[t]),this._debounceTimers[t]=setTimeout(()=>{delete this._debounceTimers[t],n()},e)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",rT)}catch{}class oT extends At{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(t){if(this._store===t)return;this._unsub?.(),this._unsub=null,this._store=t,this._errors=t.active;const e=t;this._unsub=t.subscribe(()=>{this._errors=e.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const t=this._store;this._errors=t.active,this._unsub=t.subscribe(()=>{this._errors=t.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?ft:ht`${this._errors.map(t=>ht` + `,x([zt({attribute:!1})],iT.prototype,"options",void 0),x([zt({attribute:!1})],iT.prototype,"data",void 0),x([zt({type:String})],iT.prototype,"height",void 0);try{customElements.get("span-chart")||customElements.define("span-chart",iT)}catch{}function rT(t,e,n,i,r,a,s,l,c){const{options:u,series:d}=function(t,e,n,i,r,a=!1){n||(n=g[o]);const s=i?"140, 160, 220":"77, 217, 175",l=`rgb(${s})`,c=Date.now(),u=c-e,d=void 0!==n.fixedMin&&void 0!==n.fixedMax,h=(t??[]).filter(t=>t.time>=u).map(t=>[t.time,Math.abs(t.value)]),p=[{type:"line",data:h,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${s}, 0.18)`},{offset:1,color:`rgba(${s}, 0.18)`}]}},itemStyle:{color:l}}],f=h.length>0?function(t){let e=0;for(const n of t)n[1]>e&&(e=n[1]);return e}(h):0,v={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:f<10?t=>0===t?"0":t.toFixed(1):t=>n.format(t)},splitLine:{lineStyle:{opacity:.15}}};d?(v.min=n.fixedMin,v.max=n.fixedMax):f<1&&(v.min=0,v.max=1),r&&"current"===n.entityRole&&(v.min=0,v.max=Math.ceil(1.25*r),p.push({type:"line",data:[[u,.8*r],[c,.8*r]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),p.push({type:"line",data:[[u,r],[c,r]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:u,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:v,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:t=>{if(!t||0===t.length)return"";const e=t[0],i=new Date(e.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),r=parseFloat(e.value[1].toFixed(2));return`
${i}
${n.format(r)} ${n.unit(r)}
`}},animation:!1};return{options:m,series:p}}(n,i,r,a,l,c),h=s??120;t.style.minHeight=h+"px";let p=t.querySelector("span-chart");p||(p=document.createElement("span-chart"),p.style.display="block",p.style.width="100%",t.innerHTML="",t.appendChild(p));const f=t.clientHeight;p.height=(f>0?f:h)+"px",p.options=u,p.data=d}function oT(t){return"function"==typeof globalThis.CSS?.escape?CSS.escape(t):t.replace(/["\\]/g,"\\$&")}function aT(t,e,n,i,r){const o=t.querySelector(".panel-stats");o&&function(t,e,n,i,r){const o="current"===(i.chart_metric||"power"),a=t.querySelector(".stat-consumption .stat-value"),s=t.querySelector(".stat-consumption .stat-unit");if(o){const t=n.panel_entities?.site_power,i=t?e.states[t]:null,r=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(r)?Math.abs(r).toFixed(1):"--"),s&&(s.textContent="A")}else{let t=r;const i=n.panel_entities?.site_power;if(i){const n=e.states[i];n&&(t=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=jt(t)),s&&(s.textContent="kW")}const l=t.querySelector(".stat-upstream .stat-value"),c=t.querySelector(".stat-upstream .stat-unit");if(l){const t=n.panel_entities?.current_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",c&&(c.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=jt(t),c&&(c.textContent="kW")}}const u=t.querySelector(".stat-downstream .stat-value"),d=t.querySelector(".stat-downstream .stat-unit");if(u){const t=n.panel_entities?.feedthrough_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;u.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",d&&(d.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;u.textContent=jt(t),d&&(d.textContent="kW")}}const h=t.querySelector(".stat-solar .stat-value"),p=t.querySelector(".stat-solar .stat-unit");if(h){const t=n.panel_entities?.pv_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;h.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",p&&(p.textContent="A")}else{if(i){const t=Math.abs(parseFloat(i.state)||0);h.textContent=jt(t)}else h.textContent="--";p&&(p.textContent="kW")}}const f=t.querySelector(".stat-battery .stat-value");if(f){const t=n.panel_entities?.battery_level,i=t?e.states[t]:null;i&&(f.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const g=t.querySelector(".stat-grid-state .stat-value");if(g){const t=n.panel_entities?.dsm_state,i=t?e.states[t]:null;g.textContent=i?e.formatEntityState?.(i)||i.state:"--"}}(o,e,n,i,r)}class sT{get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new ee(t):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(t,e){const n=Date.now();if(this._fetching)return this._settings;if(this._settings&&n-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const n={};e&&(n.config_entry_id=e);const r={type:"call_service",domain:l,service:"get_graph_settings",service_data:n,return_response:!0},o=this._retry?await this._retry.callWS(t,r,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await t.callWS(r);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function lT(t,e){if(!t)return a;const n=t.circuits?.[e];return n?.has_override?n.horizon:t.global_horizon??a}function cT(t,e){if(!t)return a;const n=t.sub_devices?.[e];return n?.has_override?n.horizon:t.global_horizon??a}class uT{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new ne,this.monitoringMultiCache=new ie,this.graphSettingsCache=new sT,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this.monitoringCache.errorStore=t,this.graphSettingsCache.errorStore=t,this.monitoringMultiCache.errorStore=t}get hass(){return this._hass}set hass(t){this._hass=t}get topology(){return this._topology}get config(){return this._config}set showMonitoring(t){this._showMonitoring=t}init(t,e,n,i){this._topology=t,this._config=e,this._hass=n,this._configEntryId=i}setFavoriteRefs(t){this._favRefs=t}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(t){this._panelFavorites=t}setFavoritesPerPanelInfo(t){this._perPanelInfo=t??new Map}get _inFavoritesView(){return null!==this._favRefs}get showFavorites(){return this._inFavoritesView||null!==this._panelFavorites}setConfig(t){this._config=t}buildHorizonMaps(t){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),t&&this._topology?.circuits)for(const e of Object.keys(this._topology.circuits))this.horizonMap.set(e,lT(t,e));if(t&&this._topology?.sub_devices)for(const e of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(e,cT(t,e))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:i("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(t){if(!this._hass||0===t.length)return null;const e=this._hass;return function(t){let e=!1;const n={},i={};for(const r of t)r&&(e=!0,r.circuits&&Object.assign(n,r.circuits),r.mains&&Object.assign(i,r.mains));return e?{circuits:n,mains:i}:null}(await Promise.all(t.map(t=>this.monitoringMultiCache.fetchOne(e,t))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const t=new Set;for(const e of Object.values(this._favRefs))e.configEntryId&&t.add(e.configEntryId);const e=new Map;await Promise.all(Array.from(t).map(async t=>{e.set(t,await this._fetchGraphSettingsFresh(t))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const t of Object.keys(this._topology.circuits)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.horizonMap.set(t,lT(i,r))}if(this._topology.sub_devices)for(const t of Object.keys(this._topology.sub_devices)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.subDeviceHorizonMap.set(t,cT(i,r))}}async loadHistory(){await Ze(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const t=Date.now();for(const[e,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(e)??a;if(!s[i]?.useRealtime)continue;const r=Qt(n,this._config);if(!r)continue;const o=this._hass.states[r];if(!o)continue;const l=parseFloat(o.state);if(isNaN(l))continue;const c=$e(i),u=Ve(c),d=We(c),h=t-c,p=this.powerHistory.get(e)??[];p.length>0&&t-p[p.length-1].time0&&t-p[p.length-1].time0&&this._topology)for(const{key:t,devId:e}of Ye(this._topology))n.has(e)&&r.add(t);const o=new Map;try{await Ze(this._hass,this._topology,this._config,o,e,n);for(const t of e.keys()){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}for(const t of r){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}this.updateDOM(t)}catch(t){console.warn("SPAN Panel: history refresh failed",t),this._errorStore?.add({key:"fetch:history",level:"warning",message:i("error.history_failed"),persistent:!1})}}updateDOM(t){this._hass&&this._topology&&this._config&&(function(t,e,n,i,r,o){if(!t||!n||!e)return;const a=Fe(i);let s=0;for(const[,t]of Object.entries(n.circuits)){const n=t.entities?.power;if(!n)continue;const i=e.states[n],r=i&&parseFloat(i.state)||0;t.device_type!==c&&(s+=Math.abs(r))}aT(t,e,n,i,s);const l=Kt(i),u="current"===l.entityRole;for(const[i,s]of Object.entries(n.circuits)){const n=t.querySelector(`.circuit-slot[data-uuid="${oT(i)}"]`);if(!n)continue;const d=s.entities?.power,h=d?e.states[d]:null,p=h&&parseFloat(h.state)||0,f=s.device_type===c||p<0,g=ue(s,e),v=n.querySelector(".power-value");if(v)if(u){const t=s.entities?.current,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;v.innerHTML=`${l.format(i)}A`}else v.innerHTML=`${Gt(p)}${Ut(p)}`;ve(n,g,de(s,e)),n.classList.toggle("circuit-off",!g),n.classList.toggle("circuit-producer",f),be(n,fe(s,e));const m=n.querySelector(".chart-container");if(m){const t=r.get(i)||[],e=n.classList.contains("circuit-col-span")?200:100,u=o?.has(i)?$e(o.get(i)):a,d=s.device_type===c;rT(m,0,t,u,l,f,e,s.breaker_rating_a??void 0,d)}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(t,e,n,i,r,o){if(!n.sub_devices)return;const a=Fe(i);for(const[i,s]of Object.entries(n.sub_devices)){const n=t.querySelector(`[data-subdev="${oT(i)}"]`);if(!n)continue;const l=Ie(s);if(l.headlineEid){const t=n.querySelector(".sub-power-value");t&&(t.innerHTML=qt(Le(e,l.headlineEid)))}if(l.siteTotalEid){const t=n.querySelector(".sub-site-total-value");t&&(t.innerHTML=qt(Le(e,l.siteTotalEid)))}const c=n.querySelectorAll("[data-chart-key]");for(const t of c){const e=t.dataset.chartKey;if(!e)continue;const n=r.get(e)||[];let s=v.power;e.endsWith("_soc")?s=v.soc:e.endsWith("_soe")&&(s=v.soe);const l=!!t.closest(".bess-chart-col");rT(t,0,n,o?.has(i)?$e(o.get(i)):a,s,!1,l?120:150,void 0,e.endsWith("_soc")||e.endsWith("_soe"))}for(const t of Object.keys(s.entities||{})){const i=n.querySelector(`[data-eid="${oT(t)}"]`);if(!i)continue;const r=e.states[t];if(r){let t;if(e.formatEntityState)t=e.formatEntityState(r);else{t=r.state;const e=r.attributes.unit_of_measurement||"";e&&(t+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(t=(e/1e3).toFixed(1)+" kWh")}i.textContent=t}}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(t){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(t)}}onToggleClick(t,e){const n=t.target,r=n?.closest(".toggle-pill");if(!r)return;const o=e.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;t.stopPropagation(),t.preventDefault();const a=r.closest("[data-uuid]");if(!a||!this._topology||!this._hass)return;const s=a.dataset.uuid;if(!s)return;const l=this._topology.circuits[s];if(!l)return;const c=l.entities?.switch;if(!c)return;if("operable"!==de(l,this._hass))return;const u="on"===this._hass.states[c]?.state?"turn_off":"turn_on";this._hass.callService("switch",u,{},{entity_id:c}).catch(t=>{console.warn("SPAN Panel: switch service call failed",t),this._errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}async onGearClick(t,e){const n=t.target,i=n?.closest(".gear-icon");if(!i)return;const r=e.querySelector("span-side-panel");if(!r||!this._hass)return;if(r.hass=this._hass,r.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const t=await this._buildFavoritesSections();if(0===t.length)return;return void r.open({favoritesMode:!0,perPanelSections:t})}if(!this._topology)return;return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void r.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const o=i.dataset.uuid;if(o&&this._topology){const t=this._topology.circuits[o];if(t){const e=this._favRefs?.[o]??null,n=e&&"circuit"===e.kind?e.targetId:o,i=e?.configEntryId??this._configEntryId;let s,l;e?[s,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),s=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=t.entities?.current??t.entities?.power,u=c?l?.circuits?.[c]??null:null,d=s?.global_horizon??a,h=s?.circuits?.[n],p=h?{...h,globalHorizon:d}:{horizon:d,has_override:!1,globalHorizon:d},f=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,g=null!==e||(this._panelFavorites?.circuitUuids.has(n)??!1),v={uuid:n,name:t.name,tabs:t.tabs,entities:t.entities,breaker_rating_a:t.breaker_rating_a??void 0,voltage:t.voltage,is_user_controllable:t.is_user_controllable,always_on:t.always_on,relay_state:t.relay_state,priority:t.priority,device_type:t.device_type,monitoringInfo:u,showMonitoring:this._showMonitoring,graphHorizonInfo:p,showFavorites:this.showFavorites,favoritePanelDeviceId:f,isFavorite:g,configEntryId:i};return void r.open(v)}}const s=i.dataset.subdevId;if(s&&this._topology?.sub_devices?.[s]){const t=this._topology.sub_devices[s],e=this._favRefs?.[s]??null,n=e&&"sub_device"===e.kind?e.targetId:s,i=e?.configEntryId??this._configEntryId;let o;e?o=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),o=this.graphSettingsCache.settings);const l=o?.global_horizon??a,c=o?.sub_devices?.[n],u=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},d=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,h=null!==e||(this._panelFavorites?.subDeviceIds.has(n)??!1);r.open({subDeviceMode:!0,subDeviceId:n,name:t.name??n,deviceType:t.type??"",entities:t.entities,hasChart:Pe(t).length>0,graphHorizonInfo:u,showFavorites:this.showFavorites,favoritePanelDeviceId:d,isFavorite:h,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const t=function(t,e){const n=new Map;for(const i of Object.values(t)){if("circuit"!==i.kind)continue;const t=e.get(i.panelDeviceId);if(void 0===t)continue;let r=n.get(i.panelDeviceId);void 0===r&&(r={panelDeviceId:i.panelDeviceId,panelName:t.panelName,topology:t.topology,configEntryId:t.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,r)),r.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((t,e)=>t.panelName.localeCompare(e.panelName))}(this._favRefs,this._perPanelInfo);if(0===t.length)return[];return await Promise.all(t.map(async t=>({panelDeviceId:t.panelDeviceId,panelName:t.panelName,topology:t.topology,graphSettings:await this._fetchGraphSettingsFresh(t.configEntryId),favoriteCircuitUuids:t.favoriteCircuitUuids,configEntryId:t.configEntryId})))}async _fetchGraphSettingsFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_graph_settings",service_data:e,return_response:!0},r=this._errorStore?new ee(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:graph_settings",errorMessage:i("error.graph_settings_failed")}):await this._hass.callWS(n);return o?.response??null}catch(t){return console.warn("SPAN Panel: fresh graph settings fetch failed",t),null}}async _fetchMonitoringStatusFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const n={type:"call_service",domain:l,service:"get_monitoring_status",service_data:e,return_response:!0},r=this._errorStore?new ee(this._errorStore):null,o=r?await r.callWS(this._hass,n,{errorId:"fetch:monitoring",errorMessage:i("error.monitoring_failed")}):await this._hass.callWS(n),a=o?.response;return a?{circuits:a.circuits,mains:a.mains}:null}catch(t){return console.warn("SPAN Panel: fresh monitoring status fetch failed",t),null}}bindSlideConfirm(t,e){const n=t.querySelector(".slide-confirm-knob"),i=t.querySelector(".slide-confirm-text");if(!n||!i)return;let r=!1,o=0,a=0;const s=e=>{t.classList.contains("confirmed")||(r=!0,o=e-n.offsetLeft,a=t.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=t=>{if(!r)return;const e=Math.max(2,Math.min(t-o,a));n.style.left=e+"px"},c=()=>{if(!r)return;r=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",t.classList.add("confirmed"),n.querySelector("span-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=t.dataset.textOn??"",e&&e.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",t=>{t.preventDefault(),s(t.clientX)}),t.addEventListener("mousemove",t=>l(t.clientX)),t.addEventListener("mouseup",c),t.addEventListener("mouseleave",c),n.addEventListener("touchstart",t=>{t.preventDefault(),s(t.touches[0].clientX)},{passive:!1}),t.addEventListener("touchmove",t=>l(t.touches[0].clientX),{passive:!0}),t.addEventListener("touchend",c),t.addEventListener("touchcancel",c),t.addEventListener("click",()=>{t.classList.contains("confirmed")&&(t.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("span-icon")?.setAttribute("icon","mdi:lock"),i.textContent=t.dataset.textOff??"",e&&e.classList.add("switches-disabled"))})}startIntervals(t,e){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(t),e&&e()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(t)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(t,e){this.cleanupResizeObserver(),e&&(this._lastWidth=e.clientWidth,this._resizeObserver=new ResizeObserver(e=>{const n=e[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const e of t.querySelectorAll(".chart-container")){const t=e.querySelector("span-chart");t&&t.remove()}this.updateDOM(t)},150))}),this._resizeObserver.observe(e))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function dT(t=""){const e=t?` value="${Ht(t)}"`:"",n=t?"":"display:none;";return`\n
\n \n \n
\n `}function hT(t,e,n,r,o,a,s){const l=e.entities?.power,c=l?n.states[l]:null,u=c&&parseFloat(c.state)||0,d=ue(e,n),h=de(e,n),p=e.breaker_rating_a,f=p?`${Math.round(p)}A`:"",g=Ht(e.name||i("grid.unknown")),v=Kt(r),m="current"===v.entityRole;let y;if(d)if(m){const t=e.entities?.current,i=t?n.states[t]:null,r=i&&parseFloat(i.state)||0;y=`${v.format(r)}A`}else y=`${Gt(u)}${Ut(u)}`;else y="";const _=_e(a||"unknown");let b="",x=o?.utilization_pct??null;if(null==x&&e.breaker_rating_a){const t=e.entities?.current,i=t?n.states[t]:null,r=i?Math.abs(parseFloat(i.state)||0):0;x=Math.round(r/e.breaker_rating_a*1e3)/10}if(null!=x){b=`=80?"utilization-warning":"utilization-normal"}">${Math.round(x)}%`}const w=``,S="none"===h?`${d?"ON":"OFF"}`:ge(d,h);return`\n
\n ${f?`${f}`:""}\n ${b}\n ${g}\n ${_}\n ${S}\n \n ${y}\n \n ${w}\n \n
\n `}function pT(t,e,n,i,r){const o=e.entities?.power,a=o?n.states[o]:null,s=a&&parseFloat(a.state)||0,l=e.device_type===c||s<0,u=oe(0,r,ue(e,n),l),d=Ht(t);return`\n
\n
\n
\n
\n
\n `}function fT(t){return`
${Ht(t)}
`}function gT(t,e){const n=e.hysteresisPx??48,i=new WeakSet;let r=null;const o=t=>{const n=t.querySelector(e.nameSelector);return!!n&&(0!==t.clientWidth&&(n.clientWidth<1||n.scrollWidth>n.clientWidth+1))},a=()=>{const i=t.querySelectorAll(e.rowSelector);if(0===i.length)return;const a=i[0].clientWidth;if(0===a)return;if(null!==r){if(a>r+n){for(const t of i)t.classList.remove(e.foldClass);r=null}else for(const t of i)t.classList.add(e.foldClass);return}let s=!1;for(const t of i)if(o(t)){s=!0;break}if(s){for(const t of i)t.classList.add(e.foldClass);r=a}},s=new ResizeObserver(()=>a()),l=()=>{const n=t.querySelectorAll(e.rowSelector);for(const t of n)i.has(t)||(i.add(t),s.observe(t));requestAnimationFrame(()=>a())};l();const c=new MutationObserver(t=>{(t=>{const n=t=>{for(const n of t)if(n instanceof Element){if(n.matches(e.rowSelector))return!0;if(n.querySelector(e.rowSelector))return!0}return!1};for(const e of t)if(n(e.addedNodes)||n(e.removedNodes))return!0;return!1})(t)&&l()});return c.observe(t,{childList:!0,subtree:!0}),()=>{s.disconnect(),c.disconnect()}}function vT(t,e,n){const i=t.entities?.power,r=i?e.states[i]:null,o=ue(t,e);let a;if("current"===(n.chart_metric||"power")){const n=t.entities?.current,i=n?e.states[n]:null;a=i?Math.abs(parseFloat(i.state)||0):0}else a=r?Math.abs(parseFloat(r.state)||0):0;return{isOn:o,value:a}}function mT(t,e,n,i){const r=vT(t,n,i),o=vT(e,n,i);return r.isOn&&!o.isOn?-1:!r.isOn&&o.isOn?1:o.value-r.value}function yT(t,e,n){return t.sort((t,i)=>mT(t[1],i[1],e,n))}function _T(t){return t.entities?.current??t.entities?.power??""}class bT{constructor(t){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._foldUnobserve=null,this._ctrl=t}setColumns(t){const e=Math.max(1,Math.min(3,Math.floor(t)));this._columns=e}setInitialExpansion(t){this._expandedUuids=new Set(t)}setInitialSearchQuery(t){this._searchQuery=t}setViewName(t){this._viewName=t}renderActivityView(t,e,n,i,r,o){this._unbindEvents(),this._hass=e,this._topology=n,this._config=i,this._monitoringStatus=r;const a=yT(Object.entries(n.circuits),e,i);let s=o+dT(this._searchQuery);s+=`
`;for(const[t,n]of a){const o=re(r,_T(n)),a=fe(n,e),l=this._expandedUuids.has(t);s+=`
`,s+=hT(t,n,e,i,o,a,l),l&&(s+=pT(t,n,e,0,o)),s+="
"}s+="
",s+="",t.innerHTML=s;const l=t.querySelector("span-side-panel");l&&(l.hass=e,l.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}renderAreaView(t,e,n,r,o,a){this._unbindEvents(),this._hass=e,this._topology=n,this._config=r,this._monitoringStatus=o;const s=i("list.unassigned_area"),l=new Map;for(const[t,e]of Object.entries(n.circuits)){const n=e.area??s,i=l.get(n);i?i.push([t,e]):l.set(n,[[t,e]])}const c=[...l.keys()].sort((t,e)=>t===s?1:e===s?-1:t.localeCompare(e));let u=a+dT(this._searchQuery);u+=`
`;for(const t of c){const n=l.get(t);if(!n)continue;const i=yT(n,e,r);u+=fT(t);for(const[t,n]of i){const i=re(o,_T(n)),a=fe(n,e),s=this._expandedUuids.has(t);u+=`
`,u+=hT(t,n,e,r,i,a,s),s&&(u+=pT(t,n,e,0,i)),u+="
"}}u+="
",u+="",t.innerHTML=u;const d=t.querySelector("span-side-panel");d&&(d.hass=e,d.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}updateCollapsedRows(t,e,n,i){const r=Kt(i),o="current"===r.entityRole,a=t.querySelectorAll(".list-row[data-row-uuid]");for(const t of a){const a=t.dataset.rowUuid;if(!a)continue;const s=n.circuits[a];if(!s)continue;const{isOn:l,value:c}=vT(s,e,i),u=t.querySelector(".list-power-value");if(u)if(l)if(o)u.innerHTML=`${r.format(c)}A`;else{const t=s.entities?.power,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;u.innerHTML=`${Gt(i)}${Ut(i)}`}else u.innerHTML="";ve(t,l,de(s,e)),be(t,fe(s,e));const d=t.querySelector(".list-status-badge");d&&(d.textContent=l?"ON":"OFF",d.classList.toggle("list-status-on",l),d.classList.toggle("list-status-off",!l)),t.classList.toggle("circuit-off",!l)}!function(t,e,n,i){const r=t.querySelector(".list-view");if(r)for(const t of function(t,e){let n={anchor:null,units:[]};const i=[n];for(const r of[...t.children])if(r.classList.contains("area-header"))n={anchor:r,units:[]},i.push(n);else if(r.classList.contains("list-cell")){const t=r.dataset.cellUuid,i=t?e.circuits[t]:void 0;t&&i&&n.units.push({cell:r,uuid:t,circuit:i})}return i}(r,n)){if(t.units.length<2)continue;const n=[...t.units].sort((t,n)=>mT(t.circuit,n.circuit,e,i));if(!n.some((e,n)=>e.uuid!==t.units[n].uuid))continue;let o=t.anchor;for(const t of n)o?o.after(t.cell):r.prepend(t.cell),o=t.cell}}(t,e,n,i)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const t={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:t,bubbles:!0,composed:!0}))}_bindEvents(t){this._container=t,this._clickHandler=e=>{const n=e.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const t=i.dataset.expandUuid;return void(t&&this._toggleExpand(t))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(e,t);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,t);if(n.closest(".list-search-clear")){const e=t.querySelector(".list-search");return void(e&&(e.value="",e.dispatchEvent(new Event("input",{bubbles:!0}))))}const r=n.closest(".unit-btn");if(r){const e=r.dataset.unit;e&&t.dispatchEvent(new CustomEvent("unit-changed",{detail:e,bubbles:!0,composed:!0}))}},this._inputHandler=e=>{const n=e.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(t),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(t).then(()=>{this._ctrl.updateDOM(t)}).catch(()=>{})},t.addEventListener("click",this._clickHandler),t.addEventListener("input",this._inputHandler),t.addEventListener("graph-settings-changed",this._graphSettingsHandler);const e=t.querySelector(".slide-confirm");e&&(this._ctrl.bindSlideConfirm(e,t),t.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_attachFoldObserver(t){this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._foldUnobserve=gT(t,{rowSelector:".list-row",nameSelector:".list-circuit-name",foldClass:"is-folded"})}_applyFilter(t){const e=t.querySelector(".list-search-clear");e&&(e.style.display=this._searchQuery?"":"none");const n=t.querySelectorAll(".list-cell[data-cell-uuid]");for(const t of n){const e=t.querySelector(".list-circuit-name"),n=(e?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=n?"":"none"}const i=t.querySelectorAll(".area-header");for(const t of i){let e=!1,n=t.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){e=!0;break}n=n.nextElementSibling}t.style.display=e?"":"none"}}_toggleExpand(t){if(!(this._container&&this._hass&&this._topology&&this._config))return;const e=oT(t),n=this._container.querySelector(`.list-cell[data-cell-uuid="${e}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${e}"]`),r=n.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(i){if(this._expandedUuids.has(t)){this._expandedUuids.delete(t);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);o&&o.remove(),r&&r.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(t);const e=this._topology.circuits[t];if(!e)return;const n=re(this._monitoringStatus,_T(e)),o=pT(t,e,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),r&&r.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}async function xT(t,e){const[n,i,r]=await Promise.all([t.callWS({type:"config/area_registry/list"}),t.callWS({type:"config/entity_registry/list"}),t.callWS({type:"config/device_registry/list"})]),o=new Map;for(const t of n)o.set(t.area_id,t.name);const a=new Map;for(const t of i)t.area_id&&a.set(t.entity_id,t.area_id);const s=new Map;for(const t of r)s.set(t.id,t.area_id);let l;const c=e.panel_device_id??e.device_id;if(c){const t=s.get(c);t&&(l=o.get(t))}for(const t of Object.values(e.circuits)){let e;for(const n of Object.values(t.entities)){if(!n)continue;const t=a.get(n);if(t){e=o.get(t);break}}e||(e=l),t.area=e}}const wT="loaded";class ST{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(t){const e={...t,timestamp:Date.now()};if(e.persistent)this._persistent.set(e.key,e);else{this._clearTransient(),this._transient=e;const t=e.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},t)}this._notify()}remove(t){if(this._persistent.has(t))return this._persistent.delete(t),void this._notify();this._transient?.key===t&&(this._clearTransient(),this._notify())}clear(t){void 0===t?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===t.persistent?this._persistent.clear():!1===t.persistent&&this._clearTransient(),this._notify()}get active(){const t=[...this._persistent.values()];return null!==this._transient&&t.push(this._transient),t}hasPersistent(t){return this._persistent.has(t)}hasAnyPanelOffline(){for(const t of this._persistent.keys())if("panel-offline"===t||t.startsWith("panel-offline:"))return!0;return!1}subscribe(t){return this._subscribers.add(t),()=>{this._subscribers.delete(t)}}watchPanelStatus(t){this.watchPanelStatuses([{entityId:t,panelName:null}])}watchPanelStatuses(t){const e=this._watchedPanels,n=new Map;for(const i of t){const t=e.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:t?.wasOffline??!1})}const i=this._isSingleUnnamed(e),r=this._isSingleUnnamed(n);for(const t of e.keys()){n.has(t)&&i===r||this._persistent.delete(this._offlineKey(t,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const e of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(e,t));this._watchedPanels.clear(),this._notify()}updateHass(t){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const[n,o]of this._watchedPanels){const a=t.states[n]?.state,s="on"===a,l=this._offlineKey(n,e),c=this._reconnectKey(n,e);if(s){const t=o.wasOffline;o.wasOffline=!1,this.remove(l),t&&this.add({key:c,level:"info",message:null===o.panelName?i("error.panel_reconnected"):r("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?i("error.panel_offline"):r("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(t){if(1!==t.size)return!1;for(const e of t.values())return null===e.panelName;return!1}_offlineKey(t,e){return e?"panel-offline":`panel-offline:${t}`}_reconnectKey(t,e){return e?"panel-reconnected":`panel-reconnected:${t}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const t of this._subscribers)try{t()}catch(t){console.warn("SPAN Panel: error-store subscriber threw",t)}}}async function CT(t,e,n){if(!e)throw new Error(i("card.device_not_found"));const r={type:`${l}/panel_topology`,device_id:e},o=n?await n.callWS(t,r,{errorId:"fetch:topology"}):await t.callWS(r),a=o.panel_size??MT(o.circuits);if(!a)throw new Error(i("card.topology_error"));const s={type:"config/device_registry/list"},c=n?await n.callWS(t,s,{errorId:"fetch:topology"}):await t.callWS(s),u=o.panel_device_id??e,d=kT(c.find(t=>t.id===u));return await xT(t,o),{topology:o,panelDevice:d,panelSize:a}}function MT(t){let e=0;for(const n of Object.values(t))if(n)for(const t of n.tabs)t>e&&(e=t);return e>0?e+e%2:0}function kT(t){return t?{id:t.id,name:t.name,name_by_user:t.name_by_user,config_entry_id:t.config_entry_id,identifiers:t.identifiers,via_device_id:t.via_device_id,sw_version:t.sw_version,model:t.model}:null}async function TT(t,e,n){const r={type:"config/device_registry/list"},o={type:"config/entity_registry/list"},[a,s]=await Promise.all([n?n.callWS(t,r,{errorId:"fetch:topology"}):t.callWS(r),n?n.callWS(t,o,{errorId:"fetch:topology"}):t.callWS(o)]),c=kT(a.find(t=>t.id===e));if(!c)return{topology:null,panelDevice:null,panelSize:0};const u=s.filter(t=>t.device_id===e),d=a.filter(t=>t.via_device_id===e),h=new Set(d.map(t=>t.id)),p=s.filter(t=>void 0!==t.device_id&&h.has(t.device_id)),f={},g=c.name_by_user??c.name??"";for(const e of[...u,...p]){const n=t.states[e.entity_id];if(!n)continue;const i=n.attributes,r=i.tabs;if("string"!=typeof r||!r.startsWith("tabs ["))continue;const o=r.slice(6,-1);let a;if(a=o.includes(":")?o.split(":").map(Number):[Number(o)],!a.every(Number.isFinite))continue;const s=e.unique_id.split("_");let l=null;for(let t=2;t=16&&/^[a-f0-9]+$/i.test(e)){l=e;break}}if(!l)continue;let c=("string"==typeof i.friendly_name?i.friendly_name:void 0)??e.entity_id;for(const t of[" Power"," Consumed Energy"," Produced Energy"])if(c.endsWith(t)){c=c.slice(0,-t.length);break}g&&c.startsWith(g+" ")&&(c=c.slice(g.length+1));const u=e.entity_id.replace(/^sensor\./,"").replace(/_power$/,""),d="number"==typeof i.voltage?i.voltage:2===a.length?240:120,h={power:e.entity_id,switch:`switch.${u}_breaker`,breaker_rating:`sensor.${u}_breaker_rating`};f[l]={tabs:a,name:c,voltage:d,device_type:"string"==typeof i.device_type?i.device_type:"circuit",relay_state:"string"==typeof i.relay_state?i.relay_state:"UNKNOWN",is_user_controllable:!0,breaker_rating_a:null,entities:h}}let v="";if(c.identifiers)for(const t of c.identifiers){if(!Array.isArray(t)||t.length<2)continue;const[e,n]=t;e===l&&"string"==typeof n&&(v=n)}let m=0;for(const e of u){const n=t.states[e.entity_id];if(n&&"number"==typeof n.attributes.panel_size){m=n.attributes.panel_size;break}}if(m||(m=MT(f)),!m)throw new Error(i("card.panel_size_error"));const y={};for(const e of d){const n=s.filter(t=>t.device_id===e.id),i=(e.model??"").toLowerCase(),r=i.includes("battery")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("bess")),o=i.includes("drive")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("evse")),a={};for(const e of n){const n=e.entity_id.split(".")[0],i=t.states[e.entity_id],r=i?.attributes?.friendly_name;a[e.entity_id]={domain:n??"",original_name:"string"==typeof r?r:e.entity_id}}y[e.id]={name:e.name_by_user??e.name??"",type:r?"bess":o?"evse":"unknown",entities:a}}const _={serial:v,firmware:c.sw_version??"",panel_size:m,device_id:e,device_name:c.name_by_user??c.name??i("header.default_name"),circuits:f,sub_devices:y};return await xT(t,_),{topology:_,panelDevice:c,panelSize:m}}function AT(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function IT(t,e){if(Object.is(t,e))return!0;if(Array.isArray(t)||Array.isArray(e))return Array.isArray(t)&&Array.isArray(e)&&t.length===e.length&&t.every((t,n)=>IT(t,e[n]));if(!AT(t)||!AT(e))return!1;const n=Object.keys(t).filter(e=>void 0!==t[e]),i=Object.keys(e).filter(t=>void 0!==e[t]);return n.length===i.length&&n.every(n=>IT(t[n],e[n]))}const DT=["relay_state","relay_state_target","priority","priority_target"];function PT(t){const e={...t};for(const t of DT)delete e[t];return e}function LT(t){const e=Object.fromEntries(Object.entries(t.circuits).map(([t,e])=>[t,PT(e)]));return{...t,circuits:e}}const ET="favorites-changed";async function OT(t,e,n={}){const i=await t.callWS({type:"call_service",domain:l,service:e,service_data:n,return_response:!0});return i?.response??null}class zT extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(t){this._hass=t,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(t){this._config=t,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(t))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const t of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[t]);this._debounceTimers={}}_modeFor(t){return t.favoritesMode?"favorites":t.panelMode?"panel":t.subDeviceMode?"subDevice":"circuit"}_render(){const t=this._config;if(!t)return;const e=this.shadowRoot;if(!e)return;e.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart span-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',e.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),e.appendChild(i);const r=document.createElement("div");r.className="panel",e.appendChild(r),t.favoritesMode?this._renderFavoritesMode(r):t.panelMode?this._renderPanelMode(r):t.subDeviceMode?this._renderSubDeviceMode(r,t):this._renderCircuitMode(r,t)}_renderPanelMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.global_defaults"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body";const o=e.graphSettings,l=e.topology,c=o?.global_horizon??a,u=o?.circuits??{};r.appendChild(this._buildListColumnsSection());const d=document.createElement("div");d.className="section";const h=document.createElement("div");h.className="section-label",h.textContent=i("sidepanel.graph_horizon"),d.appendChild(h);const p=document.createElement("div");p.className="field-row";const g=document.createElement("span");g.className="field-label",g.textContent=i("sidepanel.global_default"),p.appendChild(g);const v=document.createElement("select");for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===c&&(e.selected=!0),v.appendChild(e)}if(v.addEventListener("change",()=>{const t={horizon:v.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_graph_time_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),p.appendChild(v),d.appendChild(p),r.appendChild(d),l?.circuits){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.circuit_scales"),t.appendChild(n);const o=Object.entries(l.circuits).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,i]of o){const r=this._buildPanelModeCircuitRow(n,i,u[n],c,e.configEntryId??null,e.showFavorites??!1,e.favoritePanelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}r.appendChild(t)}const m=o?.sub_devices??{};if(l?.sub_devices){const t=document.createElement("div");t.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=i("sidepanel.subdevice_scales"),t.appendChild(n);const o=Object.entries(l.sub_devices).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,r]of o){const o=Pe(r).length>0,a=e.showFavorites&&e.favoritePanelDeviceId?this._buildSubDeviceFavoriteHeart(r.entities,e.favoriteSubDeviceIds?.has(n)??!1):null;if(!o&&!a)continue;const l=document.createElement("div");l.className="field-row";const u=document.createElement("span");if(u.className="field-label",u.textContent=r.name||n,u.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",l.appendChild(u),a&&l.appendChild(a),!o){t.appendChild(l);continue}const d=m[n]||{horizon:c,has_override:!1},h=d.has_override?d.horizon:c,p=document.createElement("select");p.dataset.subdevId=n;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===h&&(e.selected=!0),p.appendChild(e)}if(p.addEventListener("change",()=>{this._debounce(`subdev-${n}`,f,()=>{const t={subdevice_id:n,horizon:p.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_subdevice_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),l.appendChild(p),d.has_override){const t=document.createElement("button");t.textContent="↺",t.title=i("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const r={subdevice_id:n};e.configEntryId&&(r.config_entry_id=e.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",r).then(()=>{p.value=c,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),l.appendChild(t)}t.appendChild(l)}t.children.length>1&&r.appendChild(t)}t.appendChild(r)}_buildPanelModeCircuitRow(t,e,n,r,o,a,l,c){const u=document.createElement("div");u.className="field-row";const d=document.createElement("span");if(d.className="field-label",d.textContent=e.name||t,d.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",u.appendChild(d),a&&l){const n=this._buildFavoriteHeart(e.entities,c?.has(t)??!1);n&&u.appendChild(n)}const h=n||{horizon:r,has_override:!1},p=h.has_override?h.horizon:r,g=document.createElement("select");g.dataset.uuid=t;for(const t of Object.keys(s)){const e=document.createElement("option");e.value=t;const n=`horizon.${t}`,r=i(n);e.textContent=r!==n?r:t,t===p&&(e.selected=!0),g.appendChild(e)}if(g.addEventListener("change",()=>{this._debounce(`circuit-${t}`,f,()=>{const e={circuit_id:t,horizon:g.value};o&&(e.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})})}),u.appendChild(g),h.has_override){const e=document.createElement("button");e.textContent="↺",e.title=i("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const n={circuit_id:t};o&&(n.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{g.value=r,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})}),u.appendChild(e)}return u}_renderFavoritesMode(t){const e=this._config,n=this._createHeader(i("sidepanel.graph_settings"),i("sidepanel.favorites_subtitle"));t.appendChild(n);const r=document.createElement("div");r.className="panel-body",r.appendChild(this._buildListColumnsSection());for(const t of e.perPanelSections)r.appendChild(this._buildFavoritesPanelSection(t));t.appendChild(r)}_buildFavoritesPanelSection(t){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=t.panelName,e.appendChild(n);const i=t.graphSettings?.global_horizon??a,r=t.graphSettings?.circuits??{},o=function(t){const e=t.circuits??{};return Object.entries(e).map(([t,e])=>({uuid:t,circuit:e})).sort((t,e)=>(t.circuit.name||"").localeCompare(e.circuit.name||""))}(t.topology);for(const{uuid:n,circuit:a}of o){const o=this._buildPanelModeCircuitRow(n,a,r[n],i,t.configEntryId,!0,t.panelDeviceId,t.favoriteCircuitUuids);e.appendChild(o)}return e}_renderCircuitMode(t,e){const n=`${Ht(String(e.breaker_rating_a))}A · ${Ht(String(e.voltage))}V · Tabs [${Ht(String(e.tabs))}]`,i=this._createHeader(Ht(e.name),n);t.appendChild(i);const r=document.createElement("div");r.className="panel-body",t.appendChild(r),this._renderRelaySection(r,e),e.showFavorites&&this._renderFavoriteSection(r,e),this._renderSheddingSection(r,e),this._renderGraphHorizonSection(r,e),e.showMonitoring&&this._renderMonitoringSection(r,e)}_favoriteEntityId(t){return t?.current??t?.power??null}_buildSubDeviceFavoriteHeart(t,e){const n=Ee(t);return n?this._buildHeartButton(n,e):null}_buildListColumnsSection(){const t=document.createElement("div");t.className="section";const e=document.createElement("div");e.className="section-label",e.textContent=i("sidepanel.list_view_columns"),t.appendChild(e);const n=document.createElement("div");n.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=i("sidepanel.columns"),n.appendChild(r);const o=Ft(),a=document.createElement("div");a.className="unit-toggle";for(const t of[1,2,3]){const e=document.createElement("button");e.type="button",e.className="unit-btn"+(t===o?" unit-active":""),e.dataset.columns=String(t),e.textContent=String(t),e.addEventListener("click",()=>{$t(t);for(const t of a.querySelectorAll(".unit-btn"))t.classList.toggle("unit-active",t===e);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:t,bubbles:!0,composed:!0}))}),a.appendChild(e)}return n.appendChild(a),t.appendChild(n),t}_buildFavoriteHeart(t,e){const n=this._favoriteEntityId(t);return n?this._buildHeartButton(n,e):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(t,e){const n=document.createElement("button");n.type="button",n.className=e?"fav-heart active":"fav-heart",n.dataset.role="fav-heart",n.title=i("sidepanel.save_to_favorites"),n.setAttribute("role","switch"),n.setAttribute("aria-checked",String(e)),n.setAttribute("aria-label",i("sidepanel.save_to_favorites"));const r=document.createElement("span-icon");return r.setAttribute("icon",e?"mdi:heart":"mdi:heart-outline"),n.appendChild(r),n.addEventListener("click",e=>{e.stopPropagation(),this._toggleFavoriteEntity(n,r,t).catch(()=>{})}),n}async _toggleFavoriteEntity(t,e,n){if(!this._hass)return;const r=t.classList.contains("active"),o=!r;t.classList.toggle("active",o),e.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(o));try{o?await async function(t,e){const n=await OT(t,"add_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(ET)),n?.favorites??{}}(this._hass,n):await async function(t,e){const n=await OT(t,"remove_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(ET)),n?.favorites??{}}(this._hass,n)}catch(n){throw t.classList.toggle("active",r),e.setAttribute("icon",r?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(r)),console.warn("SPAN Panel: favorite toggle failed",n),this.errorStore?.add({key:"service:favorites",level:"error",message:i("error.favorites_toggle_failed"),persistent:!1}),n}}_renderFavoriteSection(t,e){const n=this._favoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_appendFavoriteHeartSection(t,e,n){const r=document.createElement("div");r.className="section",r.innerHTML=``;const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=i("sidepanel.save_to_favorites"),o.appendChild(a),o.appendChild(this._buildHeartButton(e,n)),r.appendChild(o),t.appendChild(r)}_renderSubDeviceMode(t,e){const n=this._createHeader(Ht(e.name),Ht(e.deviceType));t.appendChild(n);const i=document.createElement("div");i.className="panel-body",t.appendChild(i),e.showFavorites&&this._renderSubDeviceFavoriteSection(i,e),e.hasChart&&this._renderSubDeviceHorizonSection(i,e)}_renderSubDeviceFavoriteSection(t,e){const n=Ee(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_renderSubDeviceHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,d=document.createElement("div");d.className="horizon-bar";const h=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))h.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of d.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of h){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={subdevice_id:e.subDeviceId};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_subdevice_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_subdevice_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),d.appendChild(r)}n.appendChild(d),t.appendChild(n)}_createHeader(t,e){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),r=Ht(t),o=Ht(e);i.innerHTML=`
${r}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(t,e){if(!this._hass||"none"===de(e,this._hass))return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.breaker");const a=document.createElement("span-switch");a.dataset.role="relay-toggle";const s=e.entities.switch;this._applyRelayToggle(a,e),a.addEventListener("change",()=>{if(!this._hass||"operable"!==de(e,this._hass))return;const t=a.checked;this._callService("switch",t?"turn_on":"turn_off",{entity_id:s}).catch(t=>{console.warn("SPAN Panel: relay toggle failed",t),this.errorStore?.add({key:"service:relay",level:"error",message:i("error.relay_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n)}_renderSheddingSection(t,e){if(!e.entities?.select)return;const n=document.createElement("div");n.className="section",n.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=i("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const s=e.entities.select;for(const t of y){const e=m[t];if(!e)continue;const n=document.createElement("option");n.value=t,n.textContent=i(`shedding.select.${t}`)||e.label(),a.appendChild(n)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:s,option:a.value}).catch(t=>{console.warn("SPAN Panel: shedding update failed",t),this.errorStore?.add({key:"service:shedding",level:"error",message:i("error.shedding_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),n.appendChild(r),t.appendChild(n),this._applySheddingSelect(a,e)}_renderGraphHorizonSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=i("sidepanel.graph_horizon"),n.appendChild(r);const o=e.graphHorizonInfo,l=!0===o?.has_override,c=o?.horizon||a,u=o?.globalHorizon||a,d=document.createElement("div");d.className="horizon-bar";const h=[{key:"global",label:i("sidepanel.global")}];for(const t of Object.keys(s))h.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of d.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:n}of h){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=n,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const n={circuit_id:e.uuid};e.configEntryId&&(n.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_circuit_graph_horizon",n).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_circuit_graph_horizon",{...n,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:i("error.graph_horizon_failed"),persistent:!1})}))}),d.appendChild(r)}n.appendChild(d),t.appendChild(n)}_renderMonitoringSection(t,e){const n=document.createElement("div");n.className="section";const r=document.createElement("div");r.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=i("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("span-switch");a.dataset.role="monitoring-toggle";const s=e.monitoringInfo,l=null!=s&&!1!==s.monitoring_enabled;l&&a.setAttribute("checked",""),r.appendChild(o),r.appendChild(a),n.appendChild(r);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",n.appendChild(c);const u=!0===s?.has_override,d=document.createElement("div");d.className="radio-group",d.innerHTML=`\n \n \n `,c.appendChild(d);const h=document.createElement("div");h.dataset.role="threshold-fields",h.style.display=u?"block":"none";const p=s?.continuous_threshold_pct??80,f=s?.spike_threshold_pct??100,g=s?.window_duration_m??15,v=s?.cooldown_duration_m??15;h.appendChild(this._createThresholdRow(i("sidepanel.continuous_pct"),"continuous",p,e)),h.appendChild(this._createThresholdRow(i("sidepanel.spike_pct"),"spike",f,e)),h.appendChild(this._createDurationRow(i("sidepanel.window_duration"),"window-m",g,1,180,"m",e)),h.appendChild(this._createDurationRow(i("sidepanel.cooldown"),"cooldown-m",v,1,180,"m",e)),c.appendChild(h),a.addEventListener("change",()=>{const t=a.checked;c.style.display=t?"block":"none";const n={circuit_id:e.entities?.power||e.uuid,monitoring_enabled:t};e.configEntryId&&(n.config_entry_id=e.configEntryId),this._callDomainService("set_circuit_threshold",n).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})});const m=d.querySelectorAll('input[type="radio"]');for(const t of m)t.addEventListener("change",()=>{const n="custom"===t.value&&t.checked;if(h.style.display=n?"block":"none",!n&&t.checked){const t={circuit_id:e.entities?.power||e.uuid};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("clear_circuit_threshold",t).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})}});t.appendChild(n)}_createThresholdRow(t,e,n,r){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=t;const s=document.createElement("input");return s.type="number",s.min="0",s.max="200",s.value=String(n),s.dataset.role=`threshold-${e}`,s.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),o=t.querySelector('[data-role="threshold-window-m"]'),a=t.querySelector('[data-role="threshold-cooldown-m"]'),s={circuit_id:r.entities?.power||r.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0};r.configEntryId&&(s.config_entry_id=r.configEntryId),this._callDomainService("set_circuit_threshold",s).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),o.appendChild(a),o.appendChild(s),o}_createDurationRow(t,e,n,r,o,a,s,l=!1){const c=document.createElement("div");c.className="field-row";const u=document.createElement("span");u.className="field-label",u.textContent=t;const d=document.createElement("div"),h=document.createElement("input");h.type="number",h.min=String(r),h.max=String(o),h.value=String(n),h.dataset.role=`threshold-${e}`,l&&(h.disabled=!0);const p=document.createElement("span");return p.textContent=a,d.appendChild(h),d.appendChild(p),l||h.addEventListener("input",()=>{this._debounce(`threshold-${e}`,f,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),n=t.querySelector('[data-role="threshold-spike"]'),r=t.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:s.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:n?Number(n.value):void 0,window_duration_m:r?Number(r.value):void 0};s.configEntryId&&(o.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:i("error.threshold_failed"),persistent:!1})})})}),c.appendChild(u),c.appendChild(d),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const t=this._config;if(!t.subDeviceMode&&!t.favoritesMode){if(t.entities?.switch){const e=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');e&&this._applyRelayToggle(e,t)}if(t.entities?.select){const e=this.shadowRoot?.querySelector('[data-role="shedding-select"]');e&&this._applySheddingSelect(e,t)}}}_applyRelayToggle(t,e){this._hass&&(t.disabled="operable"!==de(e,this._hass),ue(e,this._hass)?t.setAttribute("checked",""):t.removeAttribute("checked"))}_applySheddingSelect(t,e){if(!this._hass)return;const n=fe(e,this._hass);y.includes(n)?t.value=n:t.selectedIndex=-1,t.disabled=!function(t,e){const n=pe(t,e);return void 0!==n&&ae.has(n)}(e,this._hass)}_callService(t,e,n){return this._hass?Promise.resolve(this._hass.callService(t,e,n)):Promise.resolve()}_callDomainService(t,e){return this._hass?this._hass.callWS({type:"call_service",domain:l,service:t,service_data:e}):Promise.resolve()}_debounce(t,e,n){this._debounceTimers[t]&&clearTimeout(this._debounceTimers[t]),this._debounceTimers[t]=setTimeout(()=>{delete this._debounceTimers[t],n()},e)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",zT)}catch{}class NT extends Pt{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(t){if(this._store===t)return;this._unsub?.(),this._unsub=null,this._store=t,this._errors=t.active;const e=t;this._unsub=t.subscribe(()=>{this._errors=e.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const t=this._store;this._errors=t.active,this._unsub=t.subscribe(()=>{this._errors=t.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?gt:ht`${this._errors.map(t=>ht` - `)}`}_iconForLevel(t){switch(t){case"error":return"mdi:alert-circle";case"warning":return"mdi:alert";default:return"mdi:information"}}}oT.styles=T` + `)}`}_iconForLevel(t){switch(t){case"error":return"mdi:alert-circle";case"warning":return"mdi:alert";default:return"mdi:information"}}}NT.styles=A` :host { display: block; } @@ -111,7 +111,7 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft .retry-btn:hover { opacity: 0.8; } - `,x([Ot()],oT.prototype,"_errors",void 0);try{customElements.get("span-error-banner")||customElements.define("span-error-banner",oT)}catch{}const aT=Object.freeze({"mdi:alert":"M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z","mdi:alert-circle":"M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:battery":"M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0 0,0 18,20.67V5.33C18,4.6 17.4,4 16.67,4Z","mdi:battery-alert-variant-outline":"M14 20H6V6H14M14.67 4H13V2H7V4H5.33C4.6 4 4 4.6 4 5.33V20.67C4 21.4 4.6 22 5.33 22H14.67C15.4 22 16 21.4 16 20.67V5.33C16 4.6 15.4 4 14.67 4M21 7H19V13H21V8M21 15H19V17H21V15Z","mdi:chevron-down":"M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z","mdi:close":"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z","mdi:cog":"M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z","mdi:heart":"M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z","mdi:heart-outline":"M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z","mdi:help":"M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z","mdi:help-circle-outline":"M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z","mdi:home-group":"M17,16H15V22H12V17H8V22H5V16H3L10,10L17,16M6,2L10,6H9V9H7V6H5V9H3V6H2L6,2M18,3L23,8H22V12H19V9H17V12H15.34L14,10.87V8H13L18,3Z","mdi:information":"M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:lock":"M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z","mdi:lock-open":"M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10A2,2 0 0,1 6,8H15V6A3,3 0 0,0 12,3A3,3 0 0,0 9,6H7A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,17A2,2 0 0,0 14,15A2,2 0 0,0 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17Z","mdi:menu":"M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z","mdi:monitor-eye":"M3 4V16H21V4H3M3 2H21C22.1 2 23 2.89 23 4V16C23 16.53 22.79 17.04 22.41 17.41C22.04 17.79 21.53 18 21 18H14V20H16V22H8V20H10V18H3C2.47 18 1.96 17.79 1.59 17.41C1.21 17.04 1 16.53 1 16V4C1 2.89 1.89 2 3 2M10.84 8.93C11.15 8.63 11.57 8.45 12 8.45C12.43 8.46 12.85 8.63 13.16 8.94C13.46 9.24 13.64 9.66 13.64 10.09C13.64 10.53 13.46 10.94 13.16 11.25C12.85 11.56 12.43 11.73 12 11.73C11.57 11.73 11.15 11.55 10.84 11.25C10.54 10.94 10.36 10.53 10.36 10.09C10.36 9.66 10.54 9.24 10.84 8.93M10.07 12C10.58 12.53 11.28 12.82 12 12.82C12.72 12.82 13.42 12.53 13.93 12C14.44 11.5 14.73 10.81 14.73 10.09C14.73 9.37 14.44 8.67 13.93 8.16C13.42 7.65 12.72 7.36 12 7.36C11.28 7.36 10.58 7.65 10.07 8.16C9.56 8.67 9.27 9.37 9.27 10.09C9.27 10.81 9.56 11.5 10.07 12M6 10.09C6.94 7.7 9.27 6 12 6C14.73 6 17.06 7.7 18 10.09C17.06 12.5 14.73 14.18 12 14.18C9.27 14.18 6.94 12.5 6 10.09Z","mdi:router-wireless":"M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z","mdi:sort-descending":"M19 7H22L18 3L14 7H17V21H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z","mdi:transmission-tower":"M8.28,5.45L6.5,4.55L7.76,2H16.23L17.5,4.55L15.72,5.44L15,4H9L8.28,5.45M18.62,8H14.09L13.3,5H10.7L9.91,8H5.38L4.1,10.55L5.89,11.44L6.62,10H17.38L18.1,11.45L19.89,10.56L18.62,8M17.77,22H15.7L15.46,21.1L12,15.9L8.53,21.1L8.3,22H6.23L9.12,11H11.19L10.83,12.35L12,14.1L13.16,12.35L12.81,11H14.88L17.77,22M11.4,15L10.5,13.65L9.32,18.13L11.4,15M14.68,18.12L13.5,13.64L12.6,15L14.68,18.12Z","mdi:view-dashboard":"M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z"}),sT=new Set;class lT extends At{constructor(){super(...arguments),this.icon=""}render(){if(!this.icon)return ft;const t=aT[this.icon];return t?ht``:(e=this.icon,sT.has(e)||(sT.add(e),console.warn(`SPAN: unknown icon "${e}". Add it to MDI_PATHS in span-icon.ts.`)),ft);var e}}lT.styles=T` + `,x([Nt()],NT.prototype,"_errors",void 0);try{customElements.get("span-error-banner")||customElements.define("span-error-banner",NT)}catch{}const RT=Object.freeze({"mdi:alert":"M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z","mdi:alert-circle":"M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:battery":"M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0 0,0 18,20.67V5.33C18,4.6 17.4,4 16.67,4Z","mdi:battery-alert-variant-outline":"M14 20H6V6H14M14.67 4H13V2H7V4H5.33C4.6 4 4 4.6 4 5.33V20.67C4 21.4 4.6 22 5.33 22H14.67C15.4 22 16 21.4 16 20.67V5.33C16 4.6 15.4 4 14.67 4M21 7H19V13H21V8M21 15H19V17H21V15Z","mdi:chevron-down":"M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z","mdi:close":"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z","mdi:cog":"M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z","mdi:heart":"M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z","mdi:heart-outline":"M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z","mdi:help":"M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z","mdi:help-circle-outline":"M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z","mdi:home-group":"M17,16H15V22H12V17H8V22H5V16H3L10,10L17,16M6,2L10,6H9V9H7V6H5V9H3V6H2L6,2M18,3L23,8H22V12H19V9H17V12H15.34L14,10.87V8H13L18,3Z","mdi:information":"M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:lock":"M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z","mdi:lock-open":"M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10A2,2 0 0,1 6,8H15V6A3,3 0 0,0 12,3A3,3 0 0,0 9,6H7A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,17A2,2 0 0,0 14,15A2,2 0 0,0 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17Z","mdi:menu":"M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z","mdi:monitor-eye":"M3 4V16H21V4H3M3 2H21C22.1 2 23 2.89 23 4V16C23 16.53 22.79 17.04 22.41 17.41C22.04 17.79 21.53 18 21 18H14V20H16V22H8V20H10V18H3C2.47 18 1.96 17.79 1.59 17.41C1.21 17.04 1 16.53 1 16V4C1 2.89 1.89 2 3 2M10.84 8.93C11.15 8.63 11.57 8.45 12 8.45C12.43 8.46 12.85 8.63 13.16 8.94C13.46 9.24 13.64 9.66 13.64 10.09C13.64 10.53 13.46 10.94 13.16 11.25C12.85 11.56 12.43 11.73 12 11.73C11.57 11.73 11.15 11.55 10.84 11.25C10.54 10.94 10.36 10.53 10.36 10.09C10.36 9.66 10.54 9.24 10.84 8.93M10.07 12C10.58 12.53 11.28 12.82 12 12.82C12.72 12.82 13.42 12.53 13.93 12C14.44 11.5 14.73 10.81 14.73 10.09C14.73 9.37 14.44 8.67 13.93 8.16C13.42 7.65 12.72 7.36 12 7.36C11.28 7.36 10.58 7.65 10.07 8.16C9.56 8.67 9.27 9.37 9.27 10.09C9.27 10.81 9.56 11.5 10.07 12M6 10.09C6.94 7.7 9.27 6 12 6C14.73 6 17.06 7.7 18 10.09C17.06 12.5 14.73 14.18 12 14.18C9.27 14.18 6.94 12.5 6 10.09Z","mdi:router-wireless":"M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z","mdi:sort-descending":"M19 7H22L18 3L14 7H17V21H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z","mdi:transmission-tower":"M8.28,5.45L6.5,4.55L7.76,2H16.23L17.5,4.55L15.72,5.44L15,4H9L8.28,5.45M18.62,8H14.09L13.3,5H10.7L9.91,8H5.38L4.1,10.55L5.89,11.44L6.62,10H17.38L18.1,11.45L19.89,10.56L18.62,8M17.77,22H15.7L15.46,21.1L12,15.9L8.53,21.1L8.3,22H6.23L9.12,11H11.19L10.83,12.35L12,14.1L13.16,12.35L12.81,11H14.88L17.77,22M11.4,15L10.5,13.65L9.32,18.13L11.4,15M14.68,18.12L13.5,13.64L12.6,15L14.68,18.12Z","mdi:view-dashboard":"M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z"}),HT=new Set;class BT extends Pt{constructor(){super(...arguments),this.icon=""}render(){if(!this.icon)return gt;const t=RT[this.icon];return t?ht``:(e=this.icon,HT.has(e)||(HT.add(e),console.warn(`SPAN: unknown icon "${e}". Add it to MDI_PATHS in span-icon.ts.`)),gt);var e}}BT.styles=A` :host { display: inline-flex; align-items: center; @@ -128,11 +128,11 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft display: block; fill: currentColor; } - `,x([zt({type:String})],lT.prototype,"icon",void 0);try{customElements.get("span-icon")||customElements.define("span-icon",lT)}catch{}class cT extends At{constructor(){super(...arguments),this.checked=!1,this.disabled=!1,this._onActivate=t=>{if(this.disabled)return t.preventDefault(),void t.stopPropagation();this.checked=!this.checked,this.dispatchEvent(new Event("change",{bubbles:!0,composed:!0}))},this._onKeydown=t=>{" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this._onActivate(t))}}connectedCallback(){super.connectedCallback(),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0"),this.hasAttribute("role")||this.setAttribute("role","switch"),this.setAttribute("aria-checked",String(this.checked)),this.addEventListener("click",this._onActivate),this.addEventListener("keydown",this._onKeydown)}disconnectedCallback(){this.removeEventListener("click",this._onActivate),this.removeEventListener("keydown",this._onKeydown),super.disconnectedCallback()}updated(t){t.has("checked")&&this.setAttribute("aria-checked",String(this.checked)),t.has("disabled")&&this.setAttribute("aria-disabled",String(this.disabled))}render(){return ht` + `,x([zt({type:String})],BT.prototype,"icon",void 0);try{customElements.get("span-icon")||customElements.define("span-icon",BT)}catch{}class FT extends Pt{constructor(){super(...arguments),this.checked=!1,this.disabled=!1,this._onActivate=t=>{if(this.disabled)return t.preventDefault(),void t.stopPropagation();this.checked=!this.checked,this.dispatchEvent(new Event("change",{bubbles:!0,composed:!0}))},this._onKeydown=t=>{" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this._onActivate(t))}}connectedCallback(){super.connectedCallback(),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0"),this.hasAttribute("role")||this.setAttribute("role","switch"),this.setAttribute("aria-checked",String(this.checked)),this.addEventListener("click",this._onActivate),this.addEventListener("keydown",this._onKeydown)}disconnectedCallback(){this.removeEventListener("click",this._onActivate),this.removeEventListener("keydown",this._onKeydown),super.disconnectedCallback()}updated(t){t.has("checked")&&this.setAttribute("aria-checked",String(this.checked)),t.has("disabled")&&this.setAttribute("aria-disabled",String(this.disabled))}render(){return ht`
- `}}cT.styles=T` + `}}FT.styles=A` :host { --span-switch-on: var(--switch-checked-color, var(--primary-color, #4dd9af)); --span-switch-off: var(--switch-unchecked-track-color, rgba(120, 120, 120, 0.45)); @@ -185,10 +185,10 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft outline: 2px solid var(--span-switch-on); outline-offset: 2px; } - `,x([zt({type:Boolean,reflect:!0})],cT.prototype,"checked",void 0),x([zt({type:Boolean,reflect:!0})],cT.prototype,"disabled",void 0);try{customElements.get("span-switch")||customElements.define("span-switch",cT)}catch{}const uT=[{name:"Kitchen",watts:"120",path:"M0,28 L8,26 L16,24 L24,22 L32,25 L40,20 L48,18 L56,22 L64,19 L72,16 L80,18 L88,15 L96,17 L104,14 L112,16 L120,13"},{name:"Living Room",watts:"85",path:"M0,22 L8,24 L16,20 L24,26 L32,18 L40,22 L48,16 L56,20 L64,24 L72,18 L80,22 L88,20 L96,16 L104,22 L112,18 L120,20"},{name:"Master Bed",watts:"193",path:"M0,8 L8,10 L16,8 L24,12 L32,10 L40,8 L48,10 L56,8 L64,10 L72,8 L80,12 L88,10 L96,8 L104,10 L112,8 L120,10"},{name:"HVAC",watts:"64",path:"M0,30 L8,28 L16,26 L24,22 L32,18 L40,14 L48,18 L56,22 L64,26 L72,22 L80,18 L88,22 L96,26 L104,22 L112,18 L120,22"}];let hT=class extends At{constructor(){super(...arguments),this._config={},this._discovered=!1,this._discovering=!1,this._topology=null,this._activeTab="panel",this._panelDevice=null,this._panelSize=0,this._historyLoaded=!1,this._ctrl=new Bk,this._listCtrl=new Zk(this._ctrl),this._errorStore=new Qk,this._areaUnsub=null,this._areaSubscribing=!1,this._tabBarCleanup=null}get _configEntryId(){return this._panelDevice?.config_entries?.[0]??null}get _root(){const t=this.shadowRoot;if(!t)throw new Error("span-panel-card: shadow root is not available");return t}connectedCallback(){super.connectedCallback(),this._ctrl.startIntervals(this._root)}disconnectedCallback(){this._ctrl.stopIntervals(),this._listCtrl.stop(),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._errorStore.dispose(),super.disconnectedCallback()}setConfig(t){this._errorStore.clear(),this._config=t,this._discovered=!1,this._discovering=!1,this._historyLoaded=!1,this._topology=null,this._panelDevice=null,this._panelSize=0,this._activeTab="panel",this._ctrl.reset(),this._ctrl.setConfig(t),this._ctrl.errorStore=this._errorStore}getCardSize(){return Math.ceil(this._panelSize/2)+3}static getConfigElement(){return document.createElement("span-panel-card-editor")}static getStubConfig(){return{device_id:"",history_days:0,history_hours:0,history_minutes:5,chart_metric:o,show_panel:!0,show_battery:!0,show_evse:!0}}render(){if(n(this.hass?.language),!this._config.device_id)return this._renderPreview();if(!this._discovered){const t=this._errorStore.hasPersistent("discovery-failed");return ht` + `,x([zt({type:Boolean,reflect:!0})],FT.prototype,"checked",void 0),x([zt({type:Boolean,reflect:!0})],FT.prototype,"disabled",void 0);try{customElements.get("span-switch")||customElements.define("span-switch",FT)}catch{}const $T=[{name:"Kitchen",watts:"120",path:"M0,28 L8,26 L16,24 L24,22 L32,25 L40,20 L48,18 L56,22 L64,19 L72,16 L80,18 L88,15 L96,17 L104,14 L112,16 L120,13"},{name:"Living Room",watts:"85",path:"M0,22 L8,24 L16,20 L24,26 L32,18 L40,22 L48,16 L56,20 L64,24 L72,18 L80,22 L88,20 L96,16 L104,22 L112,18 L120,20"},{name:"Master Bed",watts:"193",path:"M0,8 L8,10 L16,8 L24,12 L32,10 L40,8 L48,10 L56,8 L64,10 L72,8 L80,12 L88,10 L96,8 L104,10 L112,8 L120,10"},{name:"HVAC",watts:"64",path:"M0,30 L8,28 L16,26 L24,22 L32,18 L40,14 L48,18 L56,22 L64,26 L72,22 L80,18 L88,22 L96,26 L104,22 L112,18 L120,22"}];let VT=class extends Pt{constructor(){super(...arguments),this._config={},this._discovered=!1,this._discovering=!1,this._topology=null,this._activeTab="panel",this._panelDevice=null,this._panelSize=0,this._historyLoaded=!1,this._ctrl=new uT,this._listCtrl=new bT(this._ctrl),this._errorStore=new ST,this._areaSub=null,this._reloadSub=null,this._topologySource=null,this._topologyRefresh=function(t){let e=null,n=!1;return async function i(){if(e)return n=!0,void await e.catch(()=>{});const r=(async()=>{try{await t()}finally{e=null,n&&(n=!1,await i())}})();e=r,await r}}(()=>this._refreshTopology()),this._beginTopologyEpoch=function(){let t=0;return()=>{t+=1;const e=t;return()=>t!==e}}(),this._tabBarCleanup=null}get _configEntryId(){return t=this._topology,e=this._panelDevice,t?.config_entry_id??e?.config_entry_id??null;var t,e}get _root(){const t=this.shadowRoot;if(!t)throw new Error("span-panel-card: shadow root is not available");return t}connectedCallback(){super.connectedCallback(),this._ctrl.startIntervals(this._root),this._discovered&&this._attachSubscriptions(!0)}disconnectedCallback(){this._ctrl.stopIntervals(),this._listCtrl.stop(),this._detachSubscriptions(),this._beginTopologyEpoch(),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._errorStore.dispose(),super.disconnectedCallback()}setConfig(t){this._detachSubscriptions(),this._beginTopologyEpoch(),this._errorStore.clear(),this._config=t,this._discovered=!1,this._discovering=!1,this._historyLoaded=!1,this._topology=null,this._topologySource=null,this._panelDevice=null,this._panelSize=0,this._activeTab="panel",this._ctrl.reset(),this._ctrl.setConfig(t),this._ctrl.errorStore=this._errorStore}getCardSize(){return Math.ceil(this._panelSize/2)+3}static getConfigElement(){return document.createElement("span-panel-card-editor")}static getStubConfig(){return{device_id:"",history_days:0,history_hours:0,history_minutes:5,chart_metric:o,show_panel:!0,show_battery:!0,show_evse:!0,show_solar:!0}}render(){if(n(this.hass?.language),!this._config.device_id)return this._renderPreview();if(!this._discovered){const t=this._errorStore.hasPersistent("discovery-failed");return ht`
- ${t?ft:ht`
${Rt(i("card.connecting"))}
`} + ${t?gt:ht`
${Ht(i("card.connecting"))}
`}
`}return ht`
- `}updated(t){if(t.has("hass")&&this.hass&&(n(this.hass.language),this._ctrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._config.device_id))if(this._discovered||this._discovering){if(this._discovered){this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root);const t=this._root.querySelector("span-side-panel");t&&(t.hass=this.hass,t.errorStore=this._errorStore)}this._discovered&&"panel"!==this._activeTab&&this._topology&&this._listCtrl.updateCollapsedRows(this._root,this.hass,this._topology,this._config)}else this._startDiscovery()}async _startDiscovery(){this._discovering||(this._discovering=!0,await this._discoverTopology(),this._errorStore.hasPersistent("discovery-failed")?this._discovering=!1:(this._discovered=!0,this._discovering=!1,this._ctrl.init(this._topology,this._config,this.hass,this._configEntryId),this._topology?.panel_entities?.panel_status&&(this._errorStore.watchPanelStatus(this._topology.panel_entities.panel_status),this._errorStore.updateHass(this.hass)),this._topology&&(this._areaSubscribing=!0,async function(t,e,n,r){if(!t.connection)return()=>{};const o=async()=>{try{const i=new Map;for(const[t,n]of Object.entries(e.circuits))i.set(t,n.area);await Kk(t,e);for(const[t,r]of Object.entries(e.circuits))if(r.area!==i.get(t))return void n()}catch(t){console.warn("[span-panel] area registry update failed:",t),r?.add({key:"fetch:areas",level:"warning",message:i("error.areas_failed"),persistent:!1})}},[a,s]=await Promise.all([t.connection.subscribeEvents(o,"entity_registry_updated"),t.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{a(),s()}}(this.hass,this._topology,()=>{"area"===this._activeTab&&this._discovered&&this._populateCardContent()},this._errorStore).then(t=>{this._areaSubscribing?this._areaUnsub=t:t()}).catch(t=>{this._areaSubscribing=!1,console.warn("SPAN Panel: area subscription failed",t),this._errorStore.add({key:"subscribe:area",level:"warning",message:i("error.areas_failed"),persistent:!1})})),await this.updateComplete,this._populateCardContent(),this._loadHistory(),this._ctrl.monitoringCache.fetch(this.hass,this._configEntryId).then(()=>{this._discovered&&this._ctrl.updateDOM(this._root)})))}async _discoverTopology(){if(!this.hass)return;const t=new Qt(this._errorStore);try{const e=await async function(t,e,n){if(!e)throw new Error(i("card.device_not_found"));const r={type:`${l}/panel_topology`,device_id:e},o=n?await n.callWS(t,r,{errorId:"fetch:topology"}):await t.callWS(r),a=o.panel_size??Jk(o.circuits);if(!a)throw new Error(i("card.topology_error"));const s={type:"config/device_registry/list"},c=tT((n?await n.callWS(t,s,{errorId:"fetch:topology"}):await t.callWS(s)).find(t=>t.id===e));return await Kk(t,o),{topology:o,panelDevice:c,panelSize:a}}(this.hass,this._config.device_id,t);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(e){console.error("SPAN Panel: topology fetch failed, falling back to entity discovery",e);try{const e=await async function(t,e,n){const r={type:"config/device_registry/list"},o={type:"config/entity_registry/list"},[a,s]=await Promise.all([n?n.callWS(t,r,{errorId:"fetch:topology"}):t.callWS(r),n?n.callWS(t,o,{errorId:"fetch:topology"}):t.callWS(o)]),c=tT(a.find(t=>t.id===e));if(!c)return{topology:null,panelDevice:null,panelSize:0};const u=s.filter(t=>t.device_id===e),h=a.filter(t=>t.via_device_id===e),d=new Set(h.map(t=>t.id)),p=s.filter(t=>void 0!==t.device_id&&d.has(t.device_id)),f={},g=c.name_by_user??c.name??"";for(const e of[...u,...p]){const n=t.states[e.entity_id];if(!n)continue;const i=n.attributes,r=i.tabs;if("string"!=typeof r||!r.startsWith("tabs ["))continue;const o=r.slice(6,-1);let a;if(a=o.includes(":")?o.split(":").map(Number):[Number(o)],!a.every(Number.isFinite))continue;const s=e.unique_id.split("_");let l=null;for(let t=2;t=16&&/^[a-f0-9]+$/i.test(e)){l=e;break}}if(!l)continue;let c=("string"==typeof i.friendly_name?i.friendly_name:void 0)??e.entity_id;for(const t of[" Power"," Consumed Energy"," Produced Energy"])if(c.endsWith(t)){c=c.slice(0,-t.length);break}g&&c.startsWith(g+" ")&&(c=c.slice(g.length+1));const u=e.entity_id.replace(/^sensor\./,"").replace(/_power$/,""),h="number"==typeof i.voltage?i.voltage:2===a.length?240:120,d={power:e.entity_id,switch:`switch.${u}_breaker`,breaker_rating:`sensor.${u}_breaker_rating`};f[l]={tabs:a,name:c,voltage:h,device_type:"string"==typeof i.device_type?i.device_type:"circuit",relay_state:"string"==typeof i.relay_state?i.relay_state:"UNKNOWN",is_user_controllable:!0,breaker_rating_a:null,entities:d}}let v="";if(c.identifiers)for(const t of c.identifiers){if(!Array.isArray(t)||t.length<2)continue;const[e,n]=t;e===l&&"string"==typeof n&&(v=n)}let m=0;for(const e of u){const n=t.states[e.entity_id];if(n&&"number"==typeof n.attributes.panel_size){m=n.attributes.panel_size;break}}if(m||(m=Jk(f)),!m)throw new Error(i("card.panel_size_error"));const y={};for(const e of h){const n=s.filter(t=>t.device_id===e.id),i=(e.model??"").toLowerCase(),r=i.includes("battery")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("bess")),o=i.includes("drive")||(e.identifiers??[]).some(t=>t[1].toLowerCase().includes("evse")),a={};for(const e of n){const n=e.entity_id.split(".")[0],i=t.states[e.entity_id],r=i?.attributes?.friendly_name;a[e.entity_id]={domain:n??"",original_name:"string"==typeof r?r:e.entity_id}}y[e.id]={name:e.name_by_user??e.name??"",type:r?"bess":o?"evse":"unknown",entities:a}}const _={serial:v,firmware:c.sw_version??"",panel_size:m,device_id:e,device_name:c.name_by_user??c.name??i("header.default_name"),circuits:f,sub_devices:y};return await Kk(t,_),{topology:_,panelDevice:c,panelSize:m}}(this.hass,this._config.device_id,t);this._topology=e.topology,this._panelDevice=e.panelDevice,this._panelSize=e.panelSize}catch(t){console.error("SPAN Panel: fallback discovery also failed",t),this._errorStore.add({key:"discovery-failed",level:"error",message:i("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._startDiscovery()}})}}}async _loadHistory(){if(!this._historyLoaded&&this._topology&&this.hass){this._historyLoaded=!0,await this._ctrl.fetchAndBuildHorizonMaps();try{await this._ctrl.loadHistory(),this._ctrl.updateDOM(this._root)}catch(t){console.warn("SPAN Panel: history fetch failed, charts will populate live",t)}}}_populateCardContent(){const t=this._root.querySelector("#card-content");if(!(t&&this.hass&&this._topology&&this._panelSize))return;const e=this._root.querySelector("#card-tabs");if(e){const t=[{id:"panel",label:i("tab.by_panel"),icon:"mdi:view-dashboard"},{id:"activity",label:i("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:i("tab.by_area"),icon:"mdi:home-group"}];e.innerHTML=(n=t,r=this._activeTab,o=this._config.tab_style??"text",`
${n.map(t=>{const e=t.id===r?" active":"",n=Rt(t.id);return"icon"===o?``:``}).join("")}
`),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._tabBarCleanup=function(t,e){const n=t=>{const n=t.target.closest(".shared-tab");if(n){const t=n.dataset.tab;t&&e(t)}};return t.addEventListener("click",n),()=>{t.removeEventListener("click",n)}}(e,t=>{["panel","activity","area"].includes(t)&&(this._activeTab=t,this._listCtrl.stop(),this._populateCardContent())})}var n,r,o;if("panel"===this._activeTab){const e=Math.ceil(this._panelSize/2),n=$t(this._topology,this._config),r=this._ctrl.monitoringCache.status,o=function(t){if(!t)return"";const e=Object.values(t.circuits??{}),n=Object.values(t.mains??{}),r=[...e,...n],o=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=80&&t.utilization_pct<100).length,a=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=100).length,s=r.filter(t=>t.has_override).length;return`\n
\n ✓ ${i("status.monitoring")} · ${e.length} ${i("status.circuits")} · ${n.length} ${i("status.mains")}\n \n ${o>0?`${o} ${i(o>1?"status.warnings":"status.warning")}`:""}\n ${a>0?`${a} ${i(a>1?"status.alerts":"status.alert")}`:""}\n ${s>0?`${s} ${i(s>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(r),a=function(t,e,n,i,r){const o=new Map,a=new Set;for(const[e,n]of Object.entries(t.circuits)){const t=n.tabs;if(!t||0===t.length)continue;const i=Math.min(...t),r=1===t.length?"single":Xt(t)??"single";o.set(i,{uuid:e,circuit:n,layout:r});for(const e of t)a.add(e)}const s=new Set,l=new Set;for(const[t,e]of o)if("col-span"===e.layout){const n=e.circuit.tabs,i=qt(Math.max(...n));0===jt(t)?s.add(i):l.add(i)}function c(t){const e=t.circuit.entities?.current??t.circuit.entities?.power,i=r?ee(r,e??""):null;let o;if(t.circuit.always_on)o="always_on";else{const e=t.circuit.entities?.select;o=e&&n.states[e]?n.states[e].state:"unknown"}return{monInfo:i,sheddingPriority:o}}let u="";for(let t=1;t<=e;t++){const e=2*t-1,r=2*t,h=o.get(e),d=o.get(r);if(u+=`
${e}
`,h&&"row-span"===h.layout){const{monInfo:e,sheddingPriority:o}=c(h);u+=ie(h.uuid,h.circuit,t,"2 / 5","row-span",n,i,e,o),u+=`
${r}
`;continue}if(!s.has(t))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(e)||(u+=re(t,"2"));else{const{monInfo:e,sheddingPriority:r}=c(h);u+=ie(h.uuid,h.circuit,t,"2",h.layout,n,i,e,r)}if(!l.has(t))if(!d||"col-span"!==d.layout&&"single"!==d.layout)a.has(r)||(u+=re(t,"4"));else{const{monInfo:e,sheddingPriority:r}=c(d);u+=ie(d.uuid,d.circuit,t,"4",d.layout,n,i,e,r)}u+=`
${r}
`}return u}(this._topology,e,this.hass,this._config,r),s=function(t,e,n){const r=!1!==n.show_battery,o=!1!==n.show_evse;if(!t.sub_devices)return"";const a=Object.entries(t.sub_devices).filter(([,t])=>!(t.type===h&&!r||t.type===d&&!o));if(0===a.length)return"";const s=a.filter(([,t])=>t.type===d).length;let l=0,c="";for(const[t,r]of a){const o=r.type===d?i("subdevice.ev_charger"):r.type===h?i("subdevice.battery"):i("subdevice.fallback"),a=ue(r),u=a?e.states[a]:void 0,p=u&&parseFloat(u.state)||0,f=r.type===h,g=r.type===d,v=f?he(r):null,m=f?de(r):null,y=f?pe(r):null,_=fe(r,e,n,new Set([a,v,m,y].filter(t=>null!==t))),x=ge(t,0,f,a,v,m);let b="";f?b="sub-device-bess":g&&(l++,l===s&&s%2==1&&(b="sub-device-full")),c+=`\n
\n
\n ${Rt(o)}\n ${Rt(r.name||"")}\n ${a?`${Ut(p)} ${Wt(p)}`:""}\n \n
\n ${x}\n ${_}\n
\n `}return c}(this._topology,this.hass,this._config);t.innerHTML=`\n ${n}\n ${o}\n ${s?`
${s}
`:""}\n ${!1!==this._config.show_panel?`
${a}
`:""}\n `;const l=t.querySelector(".slide-confirm");if(l){const t=this._root.querySelector(".span-card");this._ctrl.bindSlideConfirm(l,t),t&&t.classList.add("switches-disabled")}const c=this._root.querySelector("span-side-panel");c&&(c.hass=this.hass,c.errorStore=this._errorStore),this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root),this._ctrl.setupResizeObserver(this._root,this._root.querySelector(".span-card"))}else if("activity"===this._activeTab){t.innerHTML="";const e=$t(this._topology,this._config);this._listCtrl.setColumns(Bt()),this._listCtrl.renderActivityView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}else if("area"===this._activeTab){t.innerHTML="";const e=$t(this._topology,this._config);this._listCtrl.setColumns(Bt()),this._listCtrl.renderAreaView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}}_onCardClick(t){if("panel"!==this._activeTab)return;const e=t.target;if(!e)return;const n=e.closest(".unit-btn");if(n)return void this._onUnitToggle(n);if(e.closest(".toggle-pill"))return void this._ctrl.onToggleClick(t,this._root);e.closest(".gear-icon")&&this._ctrl.onGearClick(t,this._root)}async _onUnitToggle(t){const e=t.dataset.unit;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}async _onListUnitChanged(t){const e=t.detail;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}_onGraphSettingsChanged(){this._ctrl.onGraphSettingsChanged(this._root)}_onListColumnsChanged(t){const e=t.detail;"number"!=typeof e||1!==e&&2!==e&&3!==e||"activity"!==this._activeTab&&"area"!==this._activeTab||this._populateCardContent()}_onSidePanelClosed(){this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()}_renderPreview(){const t=uT.map(t=>ht` + `}updated(t){if(t.has("hass")&&this.hass&&(n(this.hass.language),this._ctrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._config.device_id))if(this._discovered||this._discovering){if(this._discovered){this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root);const t=this._root.querySelector("span-side-panel");t&&(t.hass=this.hass,t.errorStore=this._errorStore)}this._discovered&&"panel"!==this._activeTab&&this._topology&&this._listCtrl.updateCollapsedRows(this._root,this.hass,this._topology,this._config)}else this._startDiscovery()}async _startDiscovery(){this._discovering||(this._discovering=!0,await this._discoverTopology(),this._errorStore.hasPersistent("discovery-failed")?this._discovering=!1:(this._discovered=!0,this._discovering=!1,this._ctrl.init(this._topology,this._config,this.hass,this._configEntryId),this.isConnected&&this._attachSubscriptions(!1),await this.updateComplete,this._populateCardContent(),this._loadHistory(),this._ctrl.monitoringCache.fetch(this.hass,this._configEntryId).then(()=>{this._discovered&&this._ctrl.updateDOM(this._root)})))}_adoptTopology(t){this._topology=t.topology,this._panelDevice=t.panelDevice,this._panelSize=t.panelSize}async _discoverTopology(){if(!this.hass)return;const t=new ee(this._errorStore);try{return this._adoptTopology(await CT(this.hass,this._config.device_id,t)),void(this._topologySource="panel_topology")}catch(t){console.error("SPAN Panel: topology fetch failed, falling back to entity discovery",t)}try{this._adoptTopology(await TT(this.hass,this._config.device_id,t)),this._topologySource="entity_fallback"}catch(t){console.error("SPAN Panel: fallback discovery also failed",t),this._errorStore.add({key:"discovery-failed",level:"error",message:i("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._startDiscovery()}})}}async _refreshTopology(){const t=this._topologySource;if(!this.hass||!this._discovered||!t)return;const e=this._beginTopologyEpoch();let n;try{n="panel_topology"===t?await CT(this.hass,this._config.device_id,null):await TT(this.hass,this._config.device_id,null)}catch(t){return void console.warn("SPAN Panel: topology refresh failed; keeping the current topology:",Jt(t))}if(e())return;if(!n.topology)return void console.warn("SPAN Panel: topology refresh found no panel; keeping the current topology");if(IT(n.topology,this._topology))return;const i=(r=n.topology,!(null!==(o=this._topology)&&IT(LT(r),LT(o))));var r,o;this._adoptTopology(n),this._ctrl.init(this._topology,this._config,this.hass,this._configEntryId),i&&(this._watchPanelStatus(),this._populateCardContent()),this._ctrl.updateDOM(this._root)}_attachSubscriptions(t){this._detachSubscriptions(),this.hass&&(this._areaSub=async function(t,e,n,r){if(!t.connection)return()=>{};const o=async()=>{const o=e();if(o)try{const e=new Map;for(const[t,n]of Object.entries(o.circuits))e.set(t,n.area);await xT(t,o);for(const[t,i]of Object.entries(o.circuits))if(i.area!==e.get(t))return void n()}catch(t){console.warn("[span-panel] area registry update failed:",t),r?.add({key:"fetch:areas",level:"warning",message:i("error.areas_failed"),persistent:!1})}},[a,s]=await Promise.all([t.connection.subscribeEvents(o,"entity_registry_updated"),t.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{a(),s()}}(this.hass,()=>this._topology,()=>{"area"===this._activeTab&&this._discovered&&this._populateCardContent()},this._errorStore).catch(t=>(console.warn("SPAN Panel: area subscription failed",t),this._errorStore.add({key:"subscribe:area",level:"warning",message:i("error.areas_failed"),persistent:!1}),()=>{})),this._reloadSub=async function(t,e,n,{initialIsReseed:i}){const r=t.connection;if(!r)return async()=>{};const o=new Map;let a=!1;return r.subscribeMessage(t=>{if(t.every(t=>null===t.type)){const r=a||i;a=!0,o.clear();for(const{entry:i}of t)e(i)&&(o.set(i.entry_id,i.state),r&&i.state===wT&&n(i.entry_id));return}for(const{type:i,entry:r}of t){if(!e(r))continue;if("removed"===i){o.delete(r.entry_id);continue}const t=o.get(r.entry_id);o.set(r.entry_id,r.state),"updated"===i&&r.state===wT&&t!==wT&&n(r.entry_id)}},{type:"config_entries/subscribe",type_filter:["device"]})}(this.hass,t=>t.entry_id===this._configEntryId,()=>{this._topologyRefresh()},{initialIsReseed:t}).catch(t=>(console.warn("SPAN Panel: config entry subscription failed",t),async()=>{})),this._watchPanelStatus())}_detachSubscriptions(){const t=this._areaSub,e=this._reloadSub;this._areaSub=null,this._reloadSub=null,t?.then(t=>t()),e?.then(t=>t())}_watchPanelStatus(){const t=this._topology?.panel_entities?.panel_status;t&&this.hass&&(this._errorStore.watchPanelStatus(t),this._errorStore.updateHass(this.hass))}async _loadHistory(){if(!this._historyLoaded&&this._topology&&this.hass){this._historyLoaded=!0,await this._ctrl.fetchAndBuildHorizonMaps();try{await this._ctrl.loadHistory(),this._ctrl.updateDOM(this._root)}catch(t){console.warn("SPAN Panel: history fetch failed, charts will populate live",t)}}}_populateCardContent(){const t=this._root.querySelector("#card-content");if(!(t&&this.hass&&this._topology&&this._panelSize))return;const e=this._root.querySelector("#card-tabs");if(e){const t=[{id:"panel",label:i("tab.by_panel"),icon:"mdi:view-dashboard"},{id:"activity",label:i("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:i("tab.by_area"),icon:"mdi:home-group"}];e.innerHTML=(n=t,r=this._activeTab,o=this._config.tab_style??"text",`
${n.map(t=>{const e=t.id===r?" active":"",n=Ht(t.id);return"icon"===o?``:``}).join("")}
`),this._tabBarCleanup&&(this._tabBarCleanup(),this._tabBarCleanup=null),this._tabBarCleanup=function(t,e){const n=t=>{const n=t.target.closest(".shared-tab");if(n){const t=n.dataset.tab;t&&e(t)}};return t.addEventListener("click",n),()=>{t.removeEventListener("click",n)}}(e,t=>{["panel","activity","area"].includes(t)&&(this._activeTab=t,this._listCtrl.stop(),this._populateCardContent())})}var n,r,o;if("panel"===this._activeTab){const e=Math.ceil(this._panelSize/2),n=Vt(this._topology,this._config),r=this._ctrl.monitoringCache.status,o=function(t){if(!t)return"";const e=Object.values(t.circuits??{}),n=Object.values(t.mains??{}),r=[...e,...n],o=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=80&&t.utilization_pct<100).length,a=r.filter(t=>void 0!==t.utilization_pct&&t.utilization_pct>=100).length,s=r.filter(t=>t.has_override).length;return`\n
\n ✓ ${i("status.monitoring")} · ${e.length} ${i("status.circuits")} · ${n.length} ${i("status.mains")}\n \n ${o>0?`${o} ${i(o>1?"status.warnings":"status.warning")}`:""}\n ${a>0?`${a} ${i(a>1?"status.alerts":"status.alert")}`:""}\n ${s>0?`${s} ${i(s>1?"status.overrides":"status.override")}`:""}\n \n
\n `}(r),a=function(t,e,n,i,r){const o=new Map,a=new Set;for(const[e,n]of Object.entries(t.circuits)){const t=n.tabs;if(!t||0===t.length)continue;const i=Math.min(...t),r=1===t.length?"single":Zt(t)??"single";o.set(i,{uuid:e,circuit:n,layout:r});for(const e of t)a.add(e)}const s=new Set,l=new Set;for(const[t,e]of o)if("col-span"===e.layout){const n=e.circuit.tabs,i=Xt(Math.max(...n));0===Yt(t)?s.add(i):l.add(i)}function c(t){const e=t.circuit.entities?.current??t.circuit.entities?.power;return{monInfo:r?re(r,e??""):null,sheddingPriority:fe(t.circuit,n)}}let u="";for(let t=1;t<=e;t++){const e=2*t-1,r=2*t,d=o.get(e),h=o.get(r);if(u+=`
${e}
`,d&&"row-span"===d.layout){const{monInfo:e,sheddingPriority:o}=c(d);u+=xe(d.uuid,d.circuit,t,"2 / 5","row-span",n,i,e,o),u+=`
${r}
`;continue}if(!s.has(t))if(!d||"col-span"!==d.layout&&"single"!==d.layout)a.has(e)||(u+=we(t,"2"));else{const{monInfo:e,sheddingPriority:r}=c(d);u+=xe(d.uuid,d.circuit,t,"2",d.layout,n,i,e,r)}if(!l.has(t))if(!h||"col-span"!==h.layout&&"single"!==h.layout)a.has(r)||(u+=we(t,"4"));else{const{monInfo:e,sheddingPriority:r}=c(h);u+=xe(h.uuid,h.circuit,t,"4",h.layout,n,i,e,r)}u+=`
${r}
`}return u}(this._topology,e,this.hass,this._config,r),s=function(t,e,n,r){const o=!1!==n.show_battery,a=!1!==n.show_evse,s=!1!==n.show_solar;if(!t.sub_devices)return"";const l=ze(Object.entries(t.sub_devices).filter(([,t])=>!(t.type===u&&!o||t.type===d&&!a||t.type===h&&!s)),function(t){if(!function(t){return"_favoriteRefs"in t}(t))return()=>"";const e=t._favoriteRefs;return t=>e[t]?.panelDeviceId??""}(t));if(0===l.length)return"";const c=[];for(const[t,i]of l){const r=Ie(i),o=i.type===u,a=o?Te(i):null,s=o?Ae(i):null,l=He(i,e,n,De(i)),d=Be(t,0,o,r.headlineEid,a,s),h=i.solar?Re(i.solar,r,e):"";(r.headlineEid||d||l||h)&&c.push({devId:t,sub:i,power:r,chartsHTML:d,entHTML:l,solarHTML:h})}if(0===c.length)return"";const p=c.filter(t=>t.sub.type===d).length;let f=0,g="";for(const{devId:t,sub:n,power:o,chartsHTML:a,entHTML:s,solarHTML:l}of c){const c=Ne(n),h=o.headlineIsSiteTotal?`${Ht(i("subdevice.site_total"))}`:"",v=o.headlineEid?`${h}${qt(Le(e,o.headlineEid))}`:"",m=Oe(n,r.showFavorites)?``:"",y=n.type===u,_=n.type===d;let b="";y?b="sub-device-bess":_&&(f++,f===p&&p%2==1&&(b="sub-device-full")),g+=`\n
\n
\n ${Ht(c)}\n ${Ht(n.name||"")}\n ${v}\n ${m}\n
\n ${a}\n ${l}\n ${s}\n
\n `}return g}(this._topology,this.hass,this._config,{showFavorites:this._ctrl.showFavorites});t.innerHTML=`\n ${n}\n ${o}\n ${s?`
${s}
`:""}\n ${!1!==this._config.show_panel?`
${a}
`:""}\n `;const l=t.querySelector(".slide-confirm");if(l){const t=this._root.querySelector(".span-card");this._ctrl.bindSlideConfirm(l,t),t&&t.classList.add("switches-disabled")}const c=this._root.querySelector("span-side-panel");c&&(c.hass=this.hass,c.errorStore=this._errorStore),this._ctrl.recordSamples(),this._ctrl.updateDOM(this._root),this._ctrl.setupResizeObserver(this._root,this._root.querySelector(".span-card"))}else if("activity"===this._activeTab){t.innerHTML="";const e=Vt(this._topology,this._config);this._listCtrl.setColumns(Ft()),this._listCtrl.renderActivityView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}else if("area"===this._activeTab){t.innerHTML="";const e=Vt(this._topology,this._config);this._listCtrl.setColumns(Ft()),this._listCtrl.renderAreaView(t,this.hass,this._topology,this._config,this._ctrl.monitoringCache.status,e),this._ctrl.updateDOM(this._root)}}_onCardClick(t){if("panel"!==this._activeTab)return;const e=t.target;if(!e)return;const n=e.closest(".unit-btn");if(n)return void this._onUnitToggle(n);if(e.closest(".toggle-pill"))return void this._ctrl.onToggleClick(t,this._root);e.closest(".gear-icon")&&this._ctrl.onGearClick(t,this._root)}async _onUnitToggle(t){const e=t.dataset.unit;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}async _onListUnitChanged(t){const e=t.detail;e&&e!==(this._config.chart_metric??"power")&&(this._config={...this._config,chart_metric:e},this._ctrl.setConfig(this._config),this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config},bubbles:!0,composed:!0})),this._ctrl.powerHistory.clear(),this._historyLoaded=!1,this._populateCardContent(),await this._loadHistory(),this._ctrl.updateDOM(this._root))}_onGraphSettingsChanged(){this._ctrl.onGraphSettingsChanged(this._root)}_onListColumnsChanged(t){const e=t.detail;"number"!=typeof e||1!==e&&2!==e&&3!==e||"activity"!==this._activeTab&&"area"!==this._activeTab||this._populateCardContent()}_onSidePanelClosed(){this._ctrl.monitoringCache.invalidate(),this._ctrl.graphSettingsCache.invalidate()}_renderPreview(){const t=$T.map(t=>ht`
${t.name} @@ -227,4 +227,4 @@ var Ga={},qa={};var ja,Xa=function(){function t(t,e,n){var i=this;this._sleepAft
${i("card.no_device")}
- `}};hT.styles=k('\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n /* Card shell — replaces . Theme variables (--ha-card-*) are\n stable HA contracts (not the deprecated component APIs flagged by the\n 2026.4 frontend blog), so they stay in place to keep visual parity\n with the rest of HA\'s dashboards. */\n .span-card {\n display: block;\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */\n .favorites-summary {\n padding: 8px 24px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n align-items: center;\n gap: 12px;\n }\n /* Override the generic .gear-icon { margin-left: auto } rule so the\n favorites gear stays flush-left instead of floating to the right of\n the flex row (same idea as .panel-identity .panel-gear does for\n real-panel headers). */\n .favorites-summary .favorites-gear {\n margin-left: 0;\n }\n /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle.\n margin-left:auto moved here from .favorites-summary-unit-toggle so the\n legend and toggle cluster together, matching the real-panel header\n layout. */\n .favorites-summary-right {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .favorites-subdevices-section {\n padding: 8px 16px 0;\n }\n\n /* Favorites view: responsive grid of per-contributing-panel status cards. */\n .favorites-panel-stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n gap: 12px;\n padding: 12px 24px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .favorites-panel-card {\n background: var(--secondary-background-color, rgba(255, 255, 255, 0.04));\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n padding: 10px 14px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .favorites-panel-card-title {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--primary-text-color);\n opacity: 0.85;\n }\n .favorites-panel-card .panel-stats {\n gap: 10px 20px;\n }\n .favorites-panel-card .stat-value {\n font-size: 1.15em;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item span-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob span-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n /* Five columns: left tab label, left cell, explicit 8px spacer,\n right cell, right tab label. Spacer is in-band rather than a\n column-gap so we can keep inter-cell space without paying an\n equal gap between each cell and its tab label. The tab columns\n are sized to fit a 2-digit breaker number (the font is 0.85em\n of the panel body ≈ 14px glyph width). */\n grid-template-columns: 14px 1fr 8px 1fr 14px;\n column-gap: 0;\n row-gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 3px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n /* Truncation-driven fold for By Panel breaker cells. The .is-folded\n class is added/removed by the JS observer in\n src/core/truncation-fold.ts when the .circuit-name actually\n ellipsizes. Pixel thresholds can\'t get this right because name\n length varies wildly per circuit (e.g. "Spa" vs\n "Commissioned PV System") — only measuring the live name vs its\n container catches the exact moment of truncation.\n\n When folded the nested flex wrappers (.circuit-header,\n .circuit-info, .circuit-controls, .circuit-status) collapse via\n \'display: contents\' so the leaf elements participate directly in\n the outer grid: name gets the whole first row, readings/controls/\n gear drop to a second row, chart stays as the full-width third. */\n .circuit-slot.is-folded {\n display: grid;\n /* Columns: badges + relay-toggle pack tight on the left, slack\n absorbed by the 1fr column between the relay and the power\n reading, keeping power + gear pinned to the right edge. The\n previous layout placed the slack between the shedding icon and\n the relay, which read as wasted padding the user pointed out. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n /* Rows: name and controls sized to content; chart absorbs any\n extra cell height. Without the explicit 1fr on row 3, a tall\n cell (e.g. .circuit-col-span\'s 280px min-height for 240V\n double-pole breakers) distributes excess space equally across\n all three rows via the default align-content:stretch, which\n pushes the chart down and vertically inflates the badge and\n relay toggle to fill the controls row. */\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n "name name name name name name name"\n "badge util shed status . power gear"\n "chart chart chart chart chart chart chart";\n row-gap: 6px;\n column-gap: 8px;\n }\n .circuit-slot.is-folded > .circuit-header,\n .circuit-slot.is-folded > .circuit-status,\n .circuit-slot.is-folded > .circuit-header > .circuit-info,\n .circuit-slot.is-folded > .circuit-header > .circuit-controls {\n display: contents;\n }\n .circuit-slot.is-folded .circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .circuit-slot.is-folded .breaker-badge {\n grid-area: badge;\n }\n .circuit-slot.is-folded .utilization {\n grid-area: util;\n }\n .circuit-slot.is-folded .shedding-icon,\n .circuit-slot.is-folded .shedding-composite {\n grid-area: shed;\n }\n .circuit-slot.is-folded .toggle-pill {\n grid-area: status;\n justify-self: end;\n }\n .circuit-slot.is-folded .power-value {\n grid-area: power;\n justify-self: end;\n }\n .circuit-slot.is-folded .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n .circuit-slot.is-folded > .chart-container {\n grid-area: chart;\n }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n /* Each circuit is wrapped in a .list-cell so the row + its optional\n expanded chart stay together. In single-column flex mode the cell\n just stacks naturally. In multi-column grid mode the cell becomes\n one grid item, so the chart is always in the same column as its\n row. Area headers (rendered as siblings, not inside a cell) span\n all columns via their inline "grid-column: 1 / -1". */\n .list-cell {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: grid;\n grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr));\n gap: 6px 8px;\n flex-direction: initial;\n }\n /* On narrow viewports a 2/3-column list would squeeze rows into an\n unreadable shape, so force stacking regardless of user preference. */\n @media (max-width: 599px) {\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: flex;\n flex-direction: column;\n }\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n /* min-width: 0 lets the row shrink below the sum of its\n non-shrinking children when its parent .list-cell is in a\n narrow CSS-grid track (multi-column list mode). Without this\n the row would maintain its intrinsic min-content width and\n overflow the cell, leaving the name unshrunk and the\n truncation-fold observer with no signal to react to. */\n min-width: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n flex-shrink: 0;\n /* No min-width / text-align:right: the old 70px right-aligned\n cell left a visible blank column for short readings (e.g.\n "1.3A" in a 70px slot), which robbed horizontal space from\n .list-circuit-name on narrow rows. Let the value hug the\n preceding relay control and size to its content so the freed\n width flows back into the flex:1 name column. */\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n .list-row .gear-icon {\n background: transparent;\n border: none;\n padding: 2px;\n cursor: pointer;\n color: #555;\n display: inline-flex;\n align-items: center;\n }\n .list-row .gear-icon:hover {\n color: var(--primary-text-color);\n }\n\n /* Truncation-driven fold for list rows. The .is-folded class is\n added/removed by the JS observer in src/core/truncation-fold.ts\n when the .list-circuit-name actually ellipsizes — pixel breakpoints\n can\'t track this because name length varies wildly per circuit\n ("Spa" vs "Commissioned PV System") and any single threshold\n misfires for the other end of the range. Switch to a two-row grid\n so the name gets the full width (paired only with the expand\n chevron) and the badges/controls/reading/gear drop to a secondary\n row underneath. Named areas keep the CSS readable despite the flat\n HTML child order. */\n .list-row.is-folded {\n display: grid;\n /* Row 1: name spans the row up to the chevron at the trailing\n column. Row 2: badge + util + shed + relay-toggle pack left,\n the 1fr column absorbs slack between the relay and the power\n reading, power + gear stay pinned to the right edge. The\n earlier layout placed the slack between the shedding icon and\n the relay, which the user flagged as wasted padding. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n grid-template-areas:\n "name name name name name name chevron"\n "badge util shed status . power gear";\n row-gap: 6px;\n column-gap: 8px;\n }\n .list-row.is-folded > .list-circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .list-row.is-folded > .list-expand-toggle {\n grid-area: chevron;\n }\n .list-row.is-folded > .breaker-badge {\n grid-area: badge;\n }\n .list-row.is-folded > .utilization {\n grid-area: util;\n }\n .list-row.is-folded > .shedding-icon,\n .list-row.is-folded > .shedding-composite {\n grid-area: shed;\n }\n .list-row.is-folded > .toggle-pill,\n .list-row.is-folded > .list-status-badge {\n grid-area: status;\n }\n .list-row.is-folded > .list-power-value {\n grid-area: power;\n justify-self: end;\n }\n .list-row.is-folded > .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .circuit-slot.circuit-chart-only {\n border: none;\n margin: 0;\n background: none;\n padding: 8px 12px;\n min-height: 0;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n'),x([zt({attribute:!1})],hT.prototype,"hass",void 0),x([Ot()],hT.prototype,"_config",void 0),x([Ot()],hT.prototype,"_discovered",void 0),x([Ot()],hT.prototype,"_discovering",void 0),x([Ot()],hT.prototype,"_topology",void 0),x([Ot()],hT.prototype,"_activeTab",void 0),hT=x([(t=>(e,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)})("span-panel-card")],hT);class dT extends HTMLElement{constructor(){super(...arguments),this._config={},this._hass=null,this._panels=null,this._availableRoles=null,this._built=!1,this._panelSelect=null,this._daysInput=null,this._hoursInput=null,this._minsInput=null,this._metricSelect=null,this._checkboxes={},this._entityContainers={},this._tabStyleSelect=null}setConfig(t){this._config={...t},this._updateControls()}set hass(t){this._hass=t,this._panels?this._built||this._buildEditor():this._discoverPanels()}async _discoverPanels(){if(!this._hass)return;const t=await this._hass.callWS({type:"config/device_registry/list"});this._panels=t.filter(t=>(t.identifiers??[]).some(t=>t[0]===l)&&!t.via_device_id).map(t=>{const e=(t.identifiers??[]).find(t=>t[0]===l)?.[1]??"",n=t.name_by_user??t.name??i("editor.panel_label");return{device_id:t.id,label:`${n} (${e})`}}),this._buildEditor()}_buildEditor(){this.innerHTML="",this._built=!0;const t=document.createElement("div");t.style.padding="16px";const e="\n width: 100%;\n padding: 10px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--card-background-color, var(--secondary-background-color, #1c1c1c));\n color: var(--primary-text-color, #e0e0e0);\n font-size: 1em;\n cursor: pointer;\n appearance: auto;\n box-sizing: border-box;\n ",n="display: block; font-weight: 500; margin-bottom: 8px; color: var(--primary-text-color);",i="margin-bottom: 16px;";this._buildPanelSelector(t,e,n,i),this._buildTimeWindow(t,e,n,i),this._buildMetricSelector(t,e,n,i),this._buildTabStyleSelector(t,e,n,i),this._buildSectionCheckboxes(t,n,i),this.appendChild(t),this._populateMetricSelect(),this._config.device_id&&this._discoverAvailableRoles(this._config.device_id)}_buildPanelSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.panel_label"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e;const l=document.createElement("option");if(l.value="",l.textContent=i("editor.select_panel"),s.appendChild(l),this._panels)for(const t of this._panels){const e=document.createElement("option");e.value=t.device_id,e.textContent=t.label,t.device_id===this._config.device_id&&(e.selected=!0),s.appendChild(e)}s.addEventListener("change",()=>{this._config={...this._config,device_id:s.value},this._fireConfigChanged(),this._discoverAvailableRoles(s.value)}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._panelSelect=s}_buildTimeWindow(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.chart_window"),a.style.cssText=n;const s=document.createElement("div");s.style.cssText="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;";const l=e+"width: 70px; cursor: text;",c=(t,e,n,i)=>{const r=document.createElement("div");r.style.cssText="display: flex; align-items: center; gap: 6px;";const o=document.createElement("input");o.type="number",o.min=e,o.max=n,o.value=String(t),o.style.cssText=l;const a=document.createElement("span");return a.textContent=i,a.style.cssText="font-size: 0.9em; color: var(--secondary-text-color);",r.appendChild(o),r.appendChild(a),{wrap:r,input:o}},u=parseInt(String(this._config.history_days))||0,h=parseInt(String(this._config.history_hours))||0,d=parseInt(String(this._config.history_minutes))||0,p=c(u,"0","30",i("editor.days")),f=c(h,"0","23",i("editor.hours")),g=c(d,"0","59",i("editor.minutes")),v=()=>{this._config={...this._config,history_days:parseInt(p.input.value)||0,history_hours:parseInt(f.input.value)||0,history_minutes:parseInt(g.input.value)||0},this._fireConfigChanged()};p.input.addEventListener("change",v),f.input.addEventListener("change",v),g.input.addEventListener("change",v),s.appendChild(p.wrap),s.appendChild(f.wrap),s.appendChild(g.wrap),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._daysInput=p.input,this._hoursInput=f.input,this._minsInput=g.input}_buildMetricSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.chart_metric"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e,s.addEventListener("change",()=>{this._config={...this._config,chart_metric:s.value},this._fireConfigChanged()}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._metricSelect=s}_buildTabStyleSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.tab_style"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e;const l=[{value:"text",text:i("editor.tab_style_text")},{value:"icon",text:i("editor.tab_style_icon")}];for(const t of l){const e=document.createElement("option");e.value=t.value,e.textContent=t.text,t.value===(this._config.tab_style??"text")&&(e.selected=!0),s.appendChild(e)}s.addEventListener("change",()=>{this._config={...this._config,tab_style:s.value},this._fireConfigChanged()}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._tabStyleSelect=s}_buildSectionCheckboxes(t,e,n){const r=document.createElement("div");r.style.cssText=n;const o=document.createElement("label");o.textContent=i("editor.visible_sections"),o.style.cssText=e,r.appendChild(o);const a=[{key:"show_panel",label:i("editor.panel_circuits"),subDeviceType:null},{key:"show_battery",label:i("editor.battery_bess"),subDeviceType:"bess"},{key:"show_evse",label:i("editor.ev_charger_evse"),subDeviceType:"evse"}];this._checkboxes={},this._entityContainers={};for(const t of a){const e=document.createElement("div");e.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 6px; cursor: pointer;";const n=document.createElement("input");n.type="checkbox",n.checked=!1!==this._config[t.key],n.style.cssText="width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary-color);";const i=document.createElement("span");i.textContent=t.label,i.style.cssText="font-size: 0.9em; color: var(--primary-text-color); cursor: pointer;",e.appendChild(n),e.appendChild(i),r.appendChild(e),this._checkboxes[t.key]=n;let o=null;t.subDeviceType&&(o=document.createElement("div"),o.style.cssText="padding-left: 26px;",o.style.display=n.checked?"block":"none",r.appendChild(o),this._entityContainers[t.subDeviceType]=o),n.addEventListener("change",()=>{this._config={...this._config,[t.key]:n.checked},o&&(o.style.display=n.checked?"block":"none"),this._fireConfigChanged()})}t.appendChild(r)}_isChartEntity(t,e,n){const i=(e.original_name??"").toLowerCase(),r=e.unique_id??"";if("power"===i||"battery power"===i||r.endsWith("_power"))return!0;if("bess"===n){if("battery level"===i||"battery percentage"===i||r.endsWith("_battery_level")||r.endsWith("_battery_percentage"))return!0;if("state of energy"===i||r.endsWith("_soe_kwh"))return!0;if("nameplate capacity"===i||r.endsWith("_nameplate_capacity"))return!0}return!1}_populateEntityCheckboxes(t){const e=this._config.visible_sub_entities??{};for(const[,n]of Object.entries(t)){const t=n.type?this._entityContainers[n.type]:void 0;if(t&&(t.innerHTML="",n.entities))for(const[i,r]of Object.entries(n.entities)){if("sensor"===r.domain&&this._isChartEntity(i,r,n.type??""))continue;const o=document.createElement("div");o.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 5px; cursor: pointer;";const a=document.createElement("input");a.type="checkbox",a.checked=!0===e[i],a.style.cssText="width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary-color);";const s=document.createElement("span");let l=r.original_name??i;const c=n.name??"";l.startsWith(c+" ")&&(l=l.slice(c.length+1)),s.textContent=l,s.style.cssText="font-size: 0.85em; color: var(--primary-text-color); cursor: pointer;",o.appendChild(a),o.appendChild(s),t.appendChild(o),a.addEventListener("change",()=>{const t={...this._config.visible_sub_entities??{}};a.checked?t[i]=!0:delete t[i],this._config={...this._config,visible_sub_entities:t},this._fireConfigChanged()})}}}async _discoverAvailableRoles(t){if(this._hass&&t)try{const e=await this._hass.callWS({type:`${l}/panel_topology`,device_id:t}),n=new Set;for(const t of Object.values(e.circuits??{}))for(const e of Object.keys(t.entities??{}))n.add(e);this._availableRoles=n,this._populateMetricSelect(),e.sub_devices&&this._populateEntityCheckboxes(e.sub_devices)}catch{this._availableRoles=null,this._populateMetricSelect()}}_populateMetricSelect(){const t=this._metricSelect;if(!t)return;const e=this._config.chart_metric??o;t.innerHTML="";for(const[n,i]of Object.entries(g)){if(this._availableRoles&&!this._availableRoles.has(i.entityRole))continue;const r=document.createElement("option");r.value=n,r.textContent=i.label(),n===e&&(r.selected=!0),t.appendChild(r)}}_updateControls(){if(this._panelSelect&&(this._panelSelect.value=this._config.device_id??""),this._daysInput&&(this._daysInput.value=String(parseInt(String(this._config.history_days))||0)),this._hoursInput&&(this._hoursInput.value=String(parseInt(String(this._config.history_hours))||0)),this._minsInput&&(this._minsInput.value=String(parseInt(String(this._config.history_minutes))||0)),this._metricSelect&&(this._metricSelect.value=this._config.chart_metric??o),this._checkboxes)for(const[t,e]of Object.entries(this._checkboxes))e.checked=!1!==this._config[t];this._tabStyleSelect&&(this._tabStyleSelect.value=this._config.tab_style??"text")}_fireConfigChanged(){this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}}try{customElements.get("span-panel-card-editor")||customElements.define("span-panel-card-editor",dT)}catch{}window.customCards=window.customCards??[],window.customCards.push({type:"span-panel-card",name:"SPAN Panel",description:"Physical panel layout with live power charts matching the SPAN frontend",preview:!0}),console.warn("%c SPAN-PANEL-CARD %c v0.9.4 ","background: var(--primary-color, #4dd9af); color: var(--text-primary-color, #000); font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;"); + `}};function WT(t,e){return ze(Object.entries(t)).filter(([,t])=>t.type===e).map(([t,e])=>{const n=De(e),i=e.name??"",r=Object.entries(e.entities??{}).filter(([t])=>!n.has(t)).map(([t,e])=>{let n=e.original_name??t;return i&&n.startsWith(i+" ")&&(n=n.slice(i.length+1)),{entityId:t,label:n}});return{devId:t,name:i||t,entities:r}})}VT.styles=T('\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n /* Card shell — replaces . Theme variables (--ha-card-*) are\n stable HA contracts (not the deprecated component APIs flagged by the\n 2026.4 frontend blog), so they stay in place to keep visual parity\n with the rest of HA\'s dashboards. */\n .span-card {\n display: block;\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */\n .favorites-summary {\n padding: 8px 24px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n align-items: center;\n gap: 12px;\n }\n /* Override the generic .gear-icon { margin-left: auto } rule so the\n favorites gear stays flush-left instead of floating to the right of\n the flex row (same idea as .panel-identity .panel-gear does for\n real-panel headers). */\n .favorites-summary .favorites-gear {\n margin-left: 0;\n }\n /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle.\n margin-left:auto moved here from .favorites-summary-unit-toggle so the\n legend and toggle cluster together, matching the real-panel header\n layout. */\n .favorites-summary-right {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .favorites-subdevices-section {\n padding: 8px 16px 0;\n }\n\n /* Favorites view: responsive grid of per-contributing-panel status cards. */\n .favorites-panel-stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n gap: 12px;\n padding: 12px 24px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .favorites-panel-card {\n background: var(--secondary-background-color, rgba(255, 255, 255, 0.04));\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n padding: 10px 14px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .favorites-panel-card-title {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--primary-text-color);\n opacity: 0.85;\n }\n .favorites-panel-card .panel-stats {\n gap: 10px 20px;\n }\n .favorites-panel-card .stat-value {\n font-size: 1.15em;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item span-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob span-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n /* Five columns: left tab label, left cell, explicit 8px spacer,\n right cell, right tab label. Spacer is in-band rather than a\n column-gap so we can keep inter-cell space without paying an\n equal gap between each cell and its tab label. The tab columns\n are sized to fit a 2-digit breaker number (the font is 0.85em\n of the panel body ≈ 14px glyph width). */\n grid-template-columns: 14px 1fr 8px 1fr 14px;\n column-gap: 0;\n row-gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 3px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n /* Truncation-driven fold for By Panel breaker cells. The .is-folded\n class is added/removed by the JS observer in\n src/core/truncation-fold.ts when the .circuit-name actually\n ellipsizes. Pixel thresholds can\'t get this right because name\n length varies wildly per circuit (e.g. "Spa" vs\n "Commissioned PV System") — only measuring the live name vs its\n container catches the exact moment of truncation.\n\n When folded the nested flex wrappers (.circuit-header,\n .circuit-info, .circuit-controls, .circuit-status) collapse via\n \'display: contents\' so the leaf elements participate directly in\n the outer grid: name gets the whole first row, readings/controls/\n gear drop to a second row, chart stays as the full-width third. */\n .circuit-slot.is-folded {\n display: grid;\n /* Columns: badges + relay-toggle pack tight on the left, slack\n absorbed by the 1fr column between the relay and the power\n reading, keeping power + gear pinned to the right edge. The\n previous layout placed the slack between the shedding icon and\n the relay, which read as wasted padding the user pointed out. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n /* Rows: name and controls sized to content; chart absorbs any\n extra cell height. Without the explicit 1fr on row 3, a tall\n cell (e.g. .circuit-col-span\'s 280px min-height for 240V\n double-pole breakers) distributes excess space equally across\n all three rows via the default align-content:stretch, which\n pushes the chart down and vertically inflates the badge and\n relay toggle to fill the controls row. */\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n "name name name name name name name"\n "badge util shed status . power gear"\n "chart chart chart chart chart chart chart";\n row-gap: 6px;\n column-gap: 8px;\n }\n .circuit-slot.is-folded > .circuit-header,\n .circuit-slot.is-folded > .circuit-status,\n .circuit-slot.is-folded > .circuit-header > .circuit-info,\n .circuit-slot.is-folded > .circuit-header > .circuit-controls {\n display: contents;\n }\n .circuit-slot.is-folded .circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .circuit-slot.is-folded .breaker-badge {\n grid-area: badge;\n }\n .circuit-slot.is-folded .utilization {\n grid-area: util;\n }\n .circuit-slot.is-folded .shedding-icon,\n .circuit-slot.is-folded .shedding-composite {\n grid-area: shed;\n }\n .circuit-slot.is-folded .toggle-pill {\n grid-area: status;\n justify-self: end;\n }\n .circuit-slot.is-folded .power-value {\n grid-area: power;\n justify-self: end;\n }\n .circuit-slot.is-folded .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n .circuit-slot.is-folded > .chart-container {\n grid-area: chart;\n }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n /* A switch Home Assistant reports unavailable: the relay state is still\n shown, but the pill cannot be operated. */\n .toggle-pill.toggle-unavailable {\n opacity: 0.4;\n pointer-events: none;\n cursor: default;\n }\n /* Locked switches dim every pill to 0.3; an inert one stays dimmer still. */\n .switches-disabled .toggle-pill.toggle-unavailable {\n opacity: 0.2;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n .sub-identity { font-size: 0.85em; color: var(--secondary-text-color, #999); margin: 2px 0 6px; }\n .sub-note { font-size: 0.85em; color: var(--secondary-text-color, #999); font-style: italic; margin-top: 4px; }\n .sub-power-caption { font-size: 0.75em; color: var(--secondary-text-color, #999); margin-right: 6px; text-transform: uppercase; }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n /* Each circuit is wrapped in a .list-cell so the row + its optional\n expanded chart stay together. In single-column flex mode the cell\n just stacks naturally. In multi-column grid mode the cell becomes\n one grid item, so the chart is always in the same column as its\n row. Area headers (rendered as siblings, not inside a cell) span\n all columns via their inline "grid-column: 1 / -1". */\n .list-cell {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: grid;\n grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr));\n gap: 6px 8px;\n flex-direction: initial;\n }\n /* On narrow viewports a 2/3-column list would squeeze rows into an\n unreadable shape, so force stacking regardless of user preference. */\n @media (max-width: 599px) {\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: flex;\n flex-direction: column;\n }\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n /* min-width: 0 lets the row shrink below the sum of its\n non-shrinking children when its parent .list-cell is in a\n narrow CSS-grid track (multi-column list mode). Without this\n the row would maintain its intrinsic min-content width and\n overflow the cell, leaving the name unshrunk and the\n truncation-fold observer with no signal to react to. */\n min-width: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n flex-shrink: 0;\n /* No min-width / text-align:right: the old 70px right-aligned\n cell left a visible blank column for short readings (e.g.\n "1.3A" in a 70px slot), which robbed horizontal space from\n .list-circuit-name on narrow rows. Let the value hug the\n preceding relay control and size to its content so the freed\n width flows back into the flex:1 name column. */\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n .list-row .gear-icon {\n background: transparent;\n border: none;\n padding: 2px;\n cursor: pointer;\n color: #555;\n display: inline-flex;\n align-items: center;\n }\n .list-row .gear-icon:hover {\n color: var(--primary-text-color);\n }\n\n /* Truncation-driven fold for list rows. The .is-folded class is\n added/removed by the JS observer in src/core/truncation-fold.ts\n when the .list-circuit-name actually ellipsizes — pixel breakpoints\n can\'t track this because name length varies wildly per circuit\n ("Spa" vs "Commissioned PV System") and any single threshold\n misfires for the other end of the range. Switch to a two-row grid\n so the name gets the full width (paired only with the expand\n chevron) and the badges/controls/reading/gear drop to a secondary\n row underneath. Named areas keep the CSS readable despite the flat\n HTML child order. */\n .list-row.is-folded {\n display: grid;\n /* Row 1: name spans the row up to the chevron at the trailing\n column. Row 2: badge + util + shed + relay-toggle pack left,\n the 1fr column absorbs slack between the relay and the power\n reading, power + gear stay pinned to the right edge. The\n earlier layout placed the slack between the shedding icon and\n the relay, which the user flagged as wasted padding. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n grid-template-areas:\n "name name name name name name chevron"\n "badge util shed status . power gear";\n row-gap: 6px;\n column-gap: 8px;\n }\n .list-row.is-folded > .list-circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .list-row.is-folded > .list-expand-toggle {\n grid-area: chevron;\n }\n .list-row.is-folded > .breaker-badge {\n grid-area: badge;\n }\n .list-row.is-folded > .utilization {\n grid-area: util;\n }\n .list-row.is-folded > .shedding-icon,\n .list-row.is-folded > .shedding-composite {\n grid-area: shed;\n }\n .list-row.is-folded > .toggle-pill,\n .list-row.is-folded > .list-status-badge {\n grid-area: status;\n }\n .list-row.is-folded > .list-power-value {\n grid-area: power;\n justify-self: end;\n }\n .list-row.is-folded > .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .circuit-slot.circuit-chart-only {\n border: none;\n margin: 0;\n background: none;\n padding: 8px 12px;\n min-height: 0;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n'),x([zt({attribute:!1})],VT.prototype,"hass",void 0),x([Nt()],VT.prototype,"_config",void 0),x([Nt()],VT.prototype,"_discovered",void 0),x([Nt()],VT.prototype,"_discovering",void 0),x([Nt()],VT.prototype,"_topology",void 0),x([Nt()],VT.prototype,"_activeTab",void 0),VT=x([(t=>(e,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)})("span-panel-card")],VT);class UT extends HTMLElement{constructor(){super(...arguments),this._config={},this._hass=null,this._panels=null,this._availableRoles=null,this._built=!1,this._panelSelect=null,this._daysInput=null,this._hoursInput=null,this._minsInput=null,this._metricSelect=null,this._checkboxes={},this._entityContainers={},this._tabStyleSelect=null}setConfig(t){this._config={...t},this._updateControls()}set hass(t){this._hass=t,this._panels?this._built||this._buildEditor():this._discoverPanels()}async _discoverPanels(){if(!this._hass)return;const t=await this._hass.callWS({type:"config/device_registry/list"});this._panels=t.filter(t=>(t.identifiers??[]).some(t=>t[0]===l)&&!t.via_device_id).map(t=>{const e=(t.identifiers??[]).find(t=>t[0]===l)?.[1]??"",n=t.name_by_user??t.name??i("editor.panel_label");return{device_id:t.id,label:`${n} (${e})`}}),this._buildEditor()}_buildEditor(){this.innerHTML="",this._built=!0;const t=document.createElement("div");t.style.padding="16px";const e="\n width: 100%;\n padding: 10px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--card-background-color, var(--secondary-background-color, #1c1c1c));\n color: var(--primary-text-color, #e0e0e0);\n font-size: 1em;\n cursor: pointer;\n appearance: auto;\n box-sizing: border-box;\n ",n="display: block; font-weight: 500; margin-bottom: 8px; color: var(--primary-text-color);",i="margin-bottom: 16px;";this._buildPanelSelector(t,e,n,i),this._buildTimeWindow(t,e,n,i),this._buildMetricSelector(t,e,n,i),this._buildTabStyleSelector(t,e,n,i),this._buildSectionCheckboxes(t,n,i),this.appendChild(t),this._populateMetricSelect(),this._config.device_id&&this._discoverAvailableRoles(this._config.device_id)}_buildPanelSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.panel_label"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e;const l=document.createElement("option");if(l.value="",l.textContent=i("editor.select_panel"),s.appendChild(l),this._panels)for(const t of this._panels){const e=document.createElement("option");e.value=t.device_id,e.textContent=t.label,t.device_id===this._config.device_id&&(e.selected=!0),s.appendChild(e)}s.addEventListener("change",()=>{this._config={...this._config,device_id:s.value},this._fireConfigChanged(),this._discoverAvailableRoles(s.value)}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._panelSelect=s}_buildTimeWindow(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.chart_window"),a.style.cssText=n;const s=document.createElement("div");s.style.cssText="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;";const l=e+"width: 70px; cursor: text;",c=(t,e,n,i)=>{const r=document.createElement("div");r.style.cssText="display: flex; align-items: center; gap: 6px;";const o=document.createElement("input");o.type="number",o.min=e,o.max=n,o.value=String(t),o.style.cssText=l;const a=document.createElement("span");return a.textContent=i,a.style.cssText="font-size: 0.9em; color: var(--secondary-text-color);",r.appendChild(o),r.appendChild(a),{wrap:r,input:o}},u=parseInt(String(this._config.history_days))||0,d=parseInt(String(this._config.history_hours))||0,h=parseInt(String(this._config.history_minutes))||0,p=c(u,"0","30",i("editor.days")),f=c(d,"0","23",i("editor.hours")),g=c(h,"0","59",i("editor.minutes")),v=()=>{this._config={...this._config,history_days:parseInt(p.input.value)||0,history_hours:parseInt(f.input.value)||0,history_minutes:parseInt(g.input.value)||0},this._fireConfigChanged()};p.input.addEventListener("change",v),f.input.addEventListener("change",v),g.input.addEventListener("change",v),s.appendChild(p.wrap),s.appendChild(f.wrap),s.appendChild(g.wrap),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._daysInput=p.input,this._hoursInput=f.input,this._minsInput=g.input}_buildMetricSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.chart_metric"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e,s.addEventListener("change",()=>{this._config={...this._config,chart_metric:s.value},this._fireConfigChanged()}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._metricSelect=s}_buildTabStyleSelector(t,e,n,r){const o=document.createElement("div");o.style.cssText=r;const a=document.createElement("label");a.textContent=i("editor.tab_style"),a.style.cssText=n;const s=document.createElement("select");s.style.cssText=e;const l=[{value:"text",text:i("editor.tab_style_text")},{value:"icon",text:i("editor.tab_style_icon")}];for(const t of l){const e=document.createElement("option");e.value=t.value,e.textContent=t.text,t.value===(this._config.tab_style??"text")&&(e.selected=!0),s.appendChild(e)}s.addEventListener("change",()=>{this._config={...this._config,tab_style:s.value},this._fireConfigChanged()}),o.appendChild(a),o.appendChild(s),t.appendChild(o),this._tabStyleSelect=s}_buildSectionCheckboxes(t,e,n){const r=document.createElement("div");r.style.cssText=n;const o=document.createElement("label");o.textContent=i("editor.visible_sections"),o.style.cssText=e,r.appendChild(o);const a=[{key:"show_panel",label:i("editor.panel_circuits"),subDeviceType:null},{key:"show_battery",label:i("editor.battery_bess"),subDeviceType:"bess"},{key:"show_evse",label:i("editor.ev_charger_evse"),subDeviceType:"evse"},{key:"show_solar",label:i("editor.solar_pv"),subDeviceType:"pv"}];this._checkboxes={},this._entityContainers={};for(const t of a){const e=document.createElement("div");e.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 6px; cursor: pointer;";const n=document.createElement("input");n.type="checkbox",n.checked=!1!==this._config[t.key],n.style.cssText="width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary-color);";const i=document.createElement("span");i.textContent=t.label,i.style.cssText="font-size: 0.9em; color: var(--primary-text-color); cursor: pointer;",e.appendChild(n),e.appendChild(i),r.appendChild(e),this._checkboxes[t.key]=n;let o=null;t.subDeviceType&&(o=document.createElement("div"),o.style.cssText="padding-left: 26px;",o.style.display=n.checked?"block":"none",r.appendChild(o),this._entityContainers[t.subDeviceType]=o),n.addEventListener("change",()=>{this._config={...this._config,[t.key]:n.checked},o&&(o.style.display=n.checked?"block":"none"),this._fireConfigChanged()})}t.appendChild(r)}_populateEntityCheckboxes(t){const e=this._config.visible_sub_entities??{},n="font-size: 0.85em; color: var(--primary-text-color); cursor: pointer;";for(const[r,o]of Object.entries(this._entityContainers)){o.innerHTML="";for(const a of WT(t,r)){const t=document.createElement("div");if(t.textContent=a.name,t.style.cssText="font-size: 0.85em; font-weight: 500; color: var(--secondary-text-color); margin: 8px 0 4px;",o.appendChild(t),0===a.entities.length){const t=document.createElement("div");t.textContent=i("editor.no_entities"),t.style.cssText=n,o.appendChild(t);continue}for(const{entityId:t,label:i}of a.entities){const r=document.createElement("div");r.style.cssText="display: flex; align-items: center; gap: 8px; margin-bottom: 5px; cursor: pointer;";const a=document.createElement("input");a.type="checkbox",a.checked=!0===e[t],a.style.cssText="width: 16px; height: 16px; cursor: pointer; accent-color: var(--primary-color);";const s=document.createElement("span");s.textContent=i,s.style.cssText=n,r.appendChild(a),r.appendChild(s),o.appendChild(r),a.addEventListener("change",()=>{const e={...this._config.visible_sub_entities??{}};a.checked?e[t]=!0:delete e[t],this._config={...this._config,visible_sub_entities:e},this._fireConfigChanged()})}}}}async _discoverAvailableRoles(t){if(this._hass&&t)try{const e=await this._hass.callWS({type:`${l}/panel_topology`,device_id:t}),n=new Set;for(const t of Object.values(e.circuits??{}))for(const e of Object.keys(t.entities??{}))n.add(e);this._availableRoles=n,this._populateMetricSelect(),e.sub_devices&&this._populateEntityCheckboxes(e.sub_devices)}catch{this._availableRoles=null,this._populateMetricSelect()}}_populateMetricSelect(){const t=this._metricSelect;if(!t)return;const e=this._config.chart_metric??o;t.innerHTML="";for(const[n,i]of Object.entries(g)){if(this._availableRoles&&!this._availableRoles.has(i.entityRole))continue;const r=document.createElement("option");r.value=n,r.textContent=i.label(),n===e&&(r.selected=!0),t.appendChild(r)}}_updateControls(){if(this._panelSelect&&(this._panelSelect.value=this._config.device_id??""),this._daysInput&&(this._daysInput.value=String(parseInt(String(this._config.history_days))||0)),this._hoursInput&&(this._hoursInput.value=String(parseInt(String(this._config.history_hours))||0)),this._minsInput&&(this._minsInput.value=String(parseInt(String(this._config.history_minutes))||0)),this._metricSelect&&(this._metricSelect.value=this._config.chart_metric??o),this._checkboxes)for(const[t,e]of Object.entries(this._checkboxes))e.checked=!1!==this._config[t];this._tabStyleSelect&&(this._tabStyleSelect.value=this._config.tab_style??"text")}_fireConfigChanged(){this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:this._config}}))}}try{customElements.get("span-panel-card-editor")||customElements.define("span-panel-card-editor",UT)}catch{}window.customCards=window.customCards??[],window.customCards.push({type:"span-panel-card",name:"SPAN Panel",description:"Physical panel layout with live power charts matching the SPAN frontend",preview:!0}),console.warn("%c SPAN-PANEL-CARD %c v0.9.5 ","background: var(--primary-color, #4dd9af); color: var(--text-primary-color, #000); font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;"); diff --git a/custom_components/span_panel/frontend/dist/span-panel.js b/custom_components/span_panel/frontend/dist/span-panel.js index cf302770..f02f763e 100644 --- a/custom_components/span_panel/frontend/dist/span-panel.js +++ b/custom_components/span_panel/frontend/dist/span-panel.js @@ -1,58 +1,58 @@ -let t="en";const e={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){return e[t]?.[n]??e.en?.[n]??n}function i(n,i){return(e[t]?.[n]??e.en?.[n]??n).replace(/\{(\w+)\}/g,(t,e)=>Object.prototype.hasOwnProperty.call(i,e)?i[e]:`{${e}}`)}const r="power",o="5m",a={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},s="span_panel",l="CLOSED",c="pv",u="bess",h="evse",d="sub_",p=500,f={power:{entityRole:"power",label:()=>n("metric.power"),unit:t=>Math.abs(t)>=1e3?"kW":"W",format:t=>{const e=Math.abs(t);return e>=1e3?(e/1e3).toFixed(1):e<10&&e>0?e.toFixed(1):String(Math.round(e))}},current:{entityRole:"current",label:()=>n("metric.current"),unit:()=>"A",format:t=>Math.abs(t).toFixed(1)}},g={soc:{entityRole:"soc",label:()=>n("metric.soc"),unit:()=>"%",format:t=>String(Math.round(t)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>n("metric.soe"),unit:()=>"kWh",format:t=>t.toFixed(1)},power:f.power},v={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>n("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>n("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>n("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>n("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>n("shedding.unknown")}};var m=function(t,e){return m=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},m(t,e)};function y(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}m(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}function _(t,e,n,i){var r,o=arguments.length,a=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,n,i);else for(var s=t.length-1;s>=0;s--)(r=t[s])&&(a=(o<3?r(a):o>3?r(e,n,a):r(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}"function"==typeof SuppressedError&&SuppressedError; +let t="en";const e={en:{"tab.panel":"Panel","tab.by_panel":"By Panel","tab.by_activity":"By Activity","tab.by_area":"By Area","tab.monitoring":"Monitoring","tab.settings":"Settings","tab.adopted":"Adopted","list.search_placeholder":"Search circuits...","list.unassigned_area":"Unassigned","list.no_results":"No circuits found","monitoring.heading":"Monitoring","monitoring.global_settings":"Global Settings","monitoring.enabled":"Enabled","monitoring.continuous":"Continuous (%)","monitoring.spike":"Spike (%)","monitoring.window":"Window (min)","monitoring.cooldown":"Cooldown (min)","monitoring.monitored_points":"Monitored Points","monitoring.col.name":"Name","monitoring.col.continuous":"Continuous","monitoring.col.spike":"Spike","monitoring.col.window":"Window","monitoring.col.cooldown":"Cooldown","monitoring.all_none":"All / None","monitoring.reset":"Reset","notification.heading":"Notification Settings","notification.targets":"Notify Targets","notification.none_selected":"None selected","notification.no_targets":"No notify targets found","notification.all_targets":"All","notification.event_bus_target":"Event Bus (HA event bus)","notification.priority":"Priority","notification.priority.default":"Default","notification.priority.passive":"Passive","notification.priority.active":"Active","notification.priority.time_sensitive":"Time-sensitive","notification.priority.critical":"Critical","notification.hint.critical":"Overrides silent/DND","notification.hint.time_sensitive":"Breaks through Focus","notification.hint.passive":"Delivers silently","notification.hint.active":"Standard delivery","notification.title_template":"Title Template","notification.message_template":"Message Template","notification.placeholders":"Placeholders:","notification.event_bus_help":"Event Bus fires event type","notification.event_bus_payload":"with payload:","notification.test_label":"Test Notification","notification.test_button":"Send Test","notification.test_sending":"Sending...","notification.test_sent":"Test notification sent","error.prefix":"Error:","error.failed_save":"Failed to save","error.failed":"Failed","error.panel_offline":"SPAN Panel unreachable","error.panel_reconnected":"SPAN Panel reconnected","error.panel_offline_named":"{name} unreachable","error.panel_reconnected_named":"{name} reconnected","error.discovery_failed":"Unable to connect to SPAN Panel","error.relay_failed":"Unable to toggle relay","error.shedding_failed":"Unable to update shedding priority","error.threshold_failed":"Unable to save threshold","error.graph_horizon_failed":"Unable to update graph time horizon","error.favorites_fetch_failed":"Unable to load favorites","error.favorites_toggle_failed":"Unable to update favorite","error.history_failed":"Unable to load historical data","error.monitoring_failed":"Unable to load monitoring status","error.graph_settings_failed":"Unable to load graph settings","error.areas_failed":"Area assignments may be out of sync","error.retry":"Retry","card.connecting":"Connecting to SPAN Panel...","settings.heading":"Settings","settings.description":"General integration settings (entity naming, device prefix, circuit numbers) are managed through the integration's options flow.","settings.open_link":"Open SPAN Panel Integration Settings","adopted.heading":"Adopted entities","adopted.description":"Vendor readings and adopted devices arrive with minimal metadata. Curate one to set its device class, statistics class, and prominence — saved changes reload the integration and apply from the next startup on.","adopted.filter_placeholder":"Filter by entity or device name","adopted.no_results":"No adopted entities match this filter","adopted.none":"This panel publishes nothing to curate.","adopted.load_failed":"Unable to load adopted entities","adopted.count":"{count} adopted","adopted.vendor_readings":"VENDOR READINGS","adopted.adopted_device":"ADOPTED DEVICE","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURATED","adopted.stale":"STALE","adopted.stale_note":"The panel no longer supports what was saved for: {fields}","adopted.enable_entity":"Enable entity","adopted.enabled":"ENABLED","adopted.disabled":"DISABLED","adopted.enable_note":"Enabled when saved — curating never enables on its own","adopted.registry_unavailable":"Enable and name become available once this entity exists in the registry.","adopted.name":"Name","adopted.device_class":"Device class","adopted.device_class_note":"choices limited by the panel's unit ({unit})","adopted.device_class_note_unitless":"choices limited by what the panel publishes","adopted.no_device_class":"No device class","adopted.statistics_class":"Statistics class","adopted.statistics_note":"long-term statistics begin after the next reload","adopted.no_statistics":"No statistics","adopted.prominence":"Prominence","adopted.diagnostic":"Diagnostic","adopted.standard":"Standard","adopted.display_unit":"Display unit","adopted.unit_as_published":"As published","adopted.precision":"Precision","adopted.precision_default":"Default","adopted.read_only":"read-only","adopted.settable":"settable","adopted.save":"Save","adopted.clear":"Clear curation","adopted.reload_note":"Saving reloads the SPAN Panel integration","adopted.saved":"Saved — the integration is reloading","adopted.confirm_heading":"Confirm statistics class","adopted.confirm_setting":"You are setting {name} to {value}.","adopted.confirm_clearing_subject":"You are clearing the statistics class on {name}.","adopted.confirm_total_increasing":"Total increasing treats every drop in the value as a meter reset. If this reading can decrease for any other reason, long-term statistics will be permanently corrupted — fixing the class later does not repair history already written.","adopted.confirm_clearing":"Long-term statistics stop being compiled for this entity, and Home Assistant raises a repair against the statistics already collected under the class you are removing.","adopted.save_anyway":"Save anyway","adopted.cancel":"Cancel","adopted.warn_total_increasing":"Saved as total increasing — every drop in the reading now counts as a meter reset.","adopted.warn_statistics_removed":"This entity has no statistics class any more; Home Assistant will raise a repair against the statistics already collected.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Panel monitoring settings","header.graph_settings":"Graph time horizon settings","header.site":"Site","header.grid":"Grid","header.upstream":"Upstream","header.downstream":"Downstream","header.solar":"Solar","header.battery":"Battery","header.toggle_units":"Toggle Watts / Amps","header.enable_switches":"Enable Switches","header.switches_enabled":"Switches Enabled","grid.unknown":"Unknown","grid.configure":"Configure circuit","grid.configure_subdevice":"Configure device","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"EV Charger","subdevice.battery":"Battery","subdevice.fallback":"Sub-device","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Power","subdevice.solar":"Solar","subdevice.solar_inverter":"Solar inverter","subdevice.site_total":"Site total","subdevice.in_site_total":"Included in the site total","sidepanel.graph_settings":"Graph Settings","sidepanel.global_defaults":"Global defaults for all circuits","sidepanel.favorites_subtitle":"Favorites","sidepanel.global_default":"Global Default","sidepanel.list_view_columns":"List View Columns","sidepanel.columns":"Columns","sidepanel.circuit_scales":"Circuit Graph Scales","sidepanel.subdevice_scales":"Sub-Device Graph Scales","sidepanel.reset_to_global":"Reset to global default","sidepanel.relay":"Relay","sidepanel.breaker":"Breaker","sidepanel.shedding_priority":"Shedding Priority","sidepanel.priority_label":"Priority","sidepanel.monitoring":"Monitoring","sidepanel.global":"Global","sidepanel.custom":"Custom","sidepanel.continuous_pct":"Continuous %","sidepanel.spike_pct":"Spike %","sidepanel.window_duration":"Window duration","sidepanel.cooldown":"Cooldown","sidepanel.favorite":"Favorite","sidepanel.save_to_favorites":"Save to favorites","panel.favorites":"Favorites","status.monitoring":"Monitoring","status.circuits":"circuits","status.mains":"mains","status.warning":"warning","status.warnings":"warnings","status.alert":"alert","status.alerts":"alerts","status.override":"override","status.overrides":"overrides","card.no_device":"Open the card editor and select your SPAN Panel device.","card.device_not_found":"Panel device not found. Check device_id in card config.","card.topology_error":"Topology response missing panel_size and no circuits found. Update the SPAN Panel integration.","card.panel_size_error":"Could not determine panel_size. No circuits found and no panel_size attribute. Update the SPAN Panel integration.","editor.panel_label":"SPAN Panel","editor.select_panel":"Select a panel...","editor.chart_window":"Chart time window","editor.days":"days","editor.hours":"hours","editor.minutes":"minutes","editor.chart_metric":"Chart metric","editor.visible_sections":"Visible sections","editor.panel_circuits":"Panel circuits","editor.battery_bess":"Battery (BESS)","editor.ev_charger_evse":"EV Charger (EVSE)","editor.solar_pv":"Solar (PV)","editor.no_entities":"No entities to show","editor.tab_style":"Tab Style","editor.tab_style_text":"Text","editor.tab_style_icon":"Icon","metric.power":"Power","metric.current":"Current","metric.soc":"State of Charge","metric.soe":"State of Energy","shedding.always_on":"Critical","shedding.never":"Non-sheddable","shedding.soc_threshold":"SoC Threshold","shedding.off_grid":"Sheddable","shedding.unknown":"Unknown","shedding.select.never":"Stays on in an outage","shedding.select.soc_threshold":"Stays on until battery threshold","shedding.select.off_grid":"Turns off in an outage"},es:{"tab.panel":"Panel","tab.by_panel":"Por Panel","tab.by_activity":"Por Actividad","tab.by_area":"Por Área","tab.monitoring":"Monitoreo","tab.settings":"Configuración","tab.adopted":"Adoptadas","list.search_placeholder":"Buscar circuitos...","list.unassigned_area":"Sin asignar","list.no_results":"No se encontraron circuitos","monitoring.heading":"Monitoreo","monitoring.global_settings":"Configuración Global","monitoring.enabled":"Activado","monitoring.continuous":"Continuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Ventana (min)","monitoring.cooldown":"Enfriamiento (min)","monitoring.monitored_points":"Puntos Monitoreados","monitoring.col.name":"Nombre","monitoring.col.continuous":"Continuo","monitoring.col.spike":"Pico","monitoring.col.window":"Ventana","monitoring.col.cooldown":"Enfriamiento","monitoring.all_none":"Todos / Ninguno","monitoring.reset":"Restablecer","notification.heading":"Configuración de Notificaciones","notification.targets":"Destinos de Notificación","notification.none_selected":"Ninguno seleccionado","notification.no_targets":"No se encontraron destinos de notificación","notification.all_targets":"Todos","notification.event_bus_target":"Bus de Eventos (bus de eventos de HA)","notification.priority":"Prioridad","notification.priority.default":"Predeterminado","notification.priority.passive":"Pasivo","notification.priority.active":"Activo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Anula silencio/No molestar","notification.hint.time_sensitive":"Atraviesa el modo Concentración","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega estándar","notification.title_template":"Plantilla de Título","notification.message_template":"Plantilla de Mensaje","notification.placeholders":"Variables:","notification.event_bus_help":"El Bus de Eventos dispara el tipo de evento","notification.event_bus_payload":"con datos:","notification.test_label":"Notificación de prueba","notification.test_button":"Enviar prueba","notification.test_sending":"Enviando...","notification.test_sent":"Notificación de prueba enviada","error.prefix":"Error:","error.failed_save":"Error al guardar","error.failed":"Falló","error.panel_offline":"SPAN Panel inaccesible","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inaccesible","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"No se puede conectar al SPAN Panel","error.relay_failed":"No se pudo cambiar el relé","error.shedding_failed":"No se pudo actualizar la prioridad de desconexión","error.threshold_failed":"No se pudo guardar el umbral","error.graph_horizon_failed":"No se pudo actualizar el horizonte temporal del gráfico","error.favorites_fetch_failed":"No se pudieron cargar los favoritos","error.favorites_toggle_failed":"No se pudo actualizar el favorito","error.history_failed":"No se pudieron cargar los datos históricos","error.monitoring_failed":"No se pudo cargar el estado de monitoreo","error.graph_settings_failed":"No se pudo cargar la configuración del gráfico","error.areas_failed":"Las asignaciones de áreas pueden estar desincronizadas","error.retry":"Reintentar","card.connecting":"Conectando al SPAN Panel...","settings.heading":"Configuración","settings.description":"La configuración general de la integración (nombres de entidades, prefijo de dispositivo, números de circuito) se administra a través del flujo de opciones de la integración.","settings.open_link":"Abrir Configuración de Integración SPAN Panel","adopted.heading":"Entidades adoptadas","adopted.description":"Las lecturas del proveedor y los dispositivos adoptados llegan con metadatos mínimos. Cura una para definir su clase de dispositivo, clase de estadísticas y prominencia — los cambios guardados recargan la integración y se aplican desde el siguiente arranque.","adopted.filter_placeholder":"Filtrar por nombre de entidad o dispositivo","adopted.no_results":"Ninguna entidad adoptada coincide con este filtro","adopted.none":"Este panel no publica nada que curar.","adopted.load_failed":"No se pudieron cargar las entidades adoptadas","adopted.count":"{count} adoptadas","adopted.vendor_readings":"LECTURAS DEL PROVEEDOR","adopted.adopted_device":"DISPOSITIVO ADOPTADO","adopted.via_panel":"vía SPAN Panel","adopted.curated":"CURADA","adopted.stale":"OBSOLETA","adopted.stale_note":"El panel ya no admite lo guardado para: {fields}","adopted.enable_entity":"Activar entidad","adopted.enabled":"ACTIVADA","adopted.disabled":"DESACTIVADA","adopted.enable_note":"Se activa al guardar — curar nunca activa por sí solo","adopted.registry_unavailable":"La activación y el nombre estarán disponibles cuando esta entidad exista en el registro.","adopted.name":"Nombre","adopted.device_class":"Clase de dispositivo","adopted.device_class_note":"opciones limitadas por la unidad del panel ({unit})","adopted.device_class_note_unitless":"opciones limitadas por lo que publica el panel","adopted.no_device_class":"Sin clase de dispositivo","adopted.statistics_class":"Clase de estadísticas","adopted.statistics_note":"las estadísticas a largo plazo comienzan tras la próxima recarga","adopted.no_statistics":"Sin estadísticas","adopted.prominence":"Prominencia","adopted.diagnostic":"Diagnóstico","adopted.standard":"Estándar","adopted.display_unit":"Unidad mostrada","adopted.unit_as_published":"Tal como se publica","adopted.precision":"Precisión","adopted.precision_default":"Predeterminada","adopted.read_only":"solo lectura","adopted.settable":"editable","adopted.save":"Guardar","adopted.clear":"Borrar curación","adopted.reload_note":"Guardar recarga la integración SPAN Panel","adopted.saved":"Guardado — la integración se está recargando","adopted.confirm_heading":"Confirmar clase de estadísticas","adopted.confirm_setting":"Vas a definir {name} como {value}.","adopted.confirm_clearing_subject":"Vas a borrar la clase de estadísticas de {name}.","adopted.confirm_total_increasing":"Total creciente interpreta cada caída del valor como un reinicio del contador. Si esta lectura puede disminuir por cualquier otro motivo, las estadísticas a largo plazo quedarán corrompidas de forma permanente — corregir la clase más tarde no repara el historial ya escrito.","adopted.confirm_clearing":"Dejarán de compilarse estadísticas a largo plazo para esta entidad, y Home Assistant abrirá una reparación sobre las estadísticas ya recogidas bajo la clase que estás quitando.","adopted.save_anyway":"Guardar de todos modos","adopted.cancel":"Cancelar","adopted.warn_total_increasing":"Guardado como total creciente — cada caída de la lectura cuenta ahora como un reinicio del contador.","adopted.warn_statistics_removed":"Esta entidad ya no tiene clase de estadísticas; Home Assistant abrirá una reparación sobre las estadísticas ya recogidas.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configuración de monitoreo del panel","header.graph_settings":"Configuración del horizonte temporal del gráfico","header.site":"Sitio","header.grid":"Red","header.upstream":"Aguas arriba","header.downstream":"Aguas abajo","header.solar":"Solar","header.battery":"Batería","header.toggle_units":"Alternar Watts / Amperios","header.enable_switches":"Habilitar Interruptores","header.switches_enabled":"Interruptores Habilitados","grid.unknown":"Desconocido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Enc","grid.off":"Apag","subdevice.ev_charger":"Cargador EV","subdevice.battery":"Batería","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potencia","subdevice.solar":"Solar","subdevice.solar_inverter":"Inversor solar","subdevice.site_total":"Total del sitio","subdevice.in_site_total":"Incluido en el total del sitio","sidepanel.graph_settings":"Configuración de Gráficos","sidepanel.global_defaults":"Valores predeterminados globales para todos los circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Predeterminado Global","sidepanel.list_view_columns":"Columnas de la lista","sidepanel.columns":"Columnas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Restablecer al valor global","sidepanel.relay":"Relé","sidepanel.breaker":"Interruptor","sidepanel.shedding_priority":"Prioridad de Desconexción","sidepanel.priority_label":"Prioridad","sidepanel.monitoring":"Monitoreo","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Continuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duración de ventana","sidepanel.cooldown":"Enfriamiento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Guardar en favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoreo","status.circuits":"circuitos","status.mains":"alimentación","status.warning":"advertencia","status.warnings":"advertencias","status.alert":"alerta","status.alerts":"alertas","status.override":"anulación","status.overrides":"anulaciones","card.no_device":"Abra el editor de tarjeta y seleccione su dispositivo SPAN Panel.","card.device_not_found":"Dispositivo de panel no encontrado. Verifique device_id en la configuración de la tarjeta.","card.topology_error":"La respuesta de topología no contiene panel_size y no se encontraron circuitos. Actualice la integración SPAN Panel.","card.panel_size_error":"No se pudo determinar panel_size. No se encontraron circuitos ni atributo panel_size. Actualice la integración SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Seleccione un panel...","editor.chart_window":"Ventana de tiempo del gráfico","editor.days":"días","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica del gráfico","editor.visible_sections":"Secciones visibles","editor.panel_circuits":"Circuitos del panel","editor.battery_bess":"Batería (BESS)","editor.ev_charger_evse":"Cargador EV (EVSE)","editor.solar_pv":"Solar (FV)","editor.no_entities":"No hay entidades para mostrar","editor.tab_style":"Estilo de pestañas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícono","metric.power":"Potencia","metric.current":"Corriente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energía","shedding.always_on":"Crítico","shedding.never":"No desconectable","shedding.soc_threshold":"Umbral SoC","shedding.off_grid":"Desconectable","shedding.unknown":"Desconocido","shedding.select.never":"Permanece encendido en un corte","shedding.select.soc_threshold":"Encendido hasta umbral de batería","shedding.select.off_grid":"Se apaga en un corte"},fr:{"tab.panel":"Panneau","tab.by_panel":"Par Panneau","tab.by_activity":"Par Activité","tab.by_area":"Par Zone","tab.monitoring":"Surveillance","tab.settings":"Paramètres","tab.adopted":"Adoptées","list.search_placeholder":"Rechercher des circuits...","list.unassigned_area":"Non attribué","list.no_results":"Aucun circuit trouvé","monitoring.heading":"Surveillance","monitoring.global_settings":"Paramètres Globaux","monitoring.enabled":"Activé","monitoring.continuous":"Continu (%)","monitoring.spike":"Pic (%)","monitoring.window":"Fenêtre (min)","monitoring.cooldown":"Refroidissement (min)","monitoring.monitored_points":"Points Surveillés","monitoring.col.name":"Nom","monitoring.col.continuous":"Continu","monitoring.col.spike":"Pic","monitoring.col.window":"Fenêtre","monitoring.col.cooldown":"Refroidissement","monitoring.all_none":"Tous / Aucun","monitoring.reset":"Réinitialiser","notification.heading":"Paramètres de Notification","notification.targets":"Cibles de Notification","notification.none_selected":"Aucune sélection","notification.no_targets":"Aucune cible de notification trouvée","notification.all_targets":"Tous","notification.event_bus_target":"Bus d'événements (bus d'événements HA)","notification.priority":"Priorité","notification.priority.default":"Par défaut","notification.priority.passive":"Passif","notification.priority.active":"Actif","notification.priority.time_sensitive":"Urgent","notification.priority.critical":"Critique","notification.hint.critical":"Outrepasse silencieux/NPD","notification.hint.time_sensitive":"Traverse le mode Concentration","notification.hint.passive":"Livraison silencieuse","notification.hint.active":"Livraison standard","notification.title_template":"Modèle de Titre","notification.message_template":"Modèle de Message","notification.placeholders":"Variables :","notification.event_bus_help":"Le Bus d'événements déclenche le type d'événement","notification.event_bus_payload":"avec les données :","notification.test_label":"Notification de test","notification.test_button":"Envoyer un test","notification.test_sending":"Envoi...","notification.test_sent":"Notification de test envoyée","error.prefix":"Erreur :","error.failed_save":"Échec de la sauvegarde","error.failed":"Échoué","error.panel_offline":"SPAN Panel inaccessible","error.panel_reconnected":"SPAN Panel reconnecté","error.panel_offline_named":"{name} inaccessible","error.panel_reconnected_named":"{name} reconnecté","error.discovery_failed":"Impossible de se connecter au SPAN Panel","error.relay_failed":"Impossible de basculer le relais","error.shedding_failed":"Impossible de mettre à jour la priorité de délestage","error.threshold_failed":"Impossible d'enregistrer le seuil","error.graph_horizon_failed":"Impossible de mettre à jour l'horizon temporel du graphique","error.favorites_fetch_failed":"Impossible de charger les favoris","error.favorites_toggle_failed":"Impossible de mettre à jour le favori","error.history_failed":"Impossible de charger les données historiques","error.monitoring_failed":"Impossible de charger l'état de surveillance","error.graph_settings_failed":"Impossible de charger les paramètres du graphique","error.areas_failed":"Les affectations de zones peuvent être désynchronisées","error.retry":"Réessayer","card.connecting":"Connexion au SPAN Panel...","settings.heading":"Paramètres","settings.description":"Les paramètres généraux de l'intégration (noms d'entités, préfixe de l'appareil, numéros de circuit) sont gérés via le flux d'options de l'intégration.","settings.open_link":"Ouvrir les Paramètres d'Intégration SPAN Panel","adopted.heading":"Entités adoptées","adopted.description":"Les relevés du fournisseur et les appareils adoptés arrivent avec des métadonnées minimales. Curez-en un pour définir sa classe d'appareil, sa classe de statistiques et sa proéminence — les modifications enregistrées rechargent l'intégration et s'appliquent dès le prochain démarrage.","adopted.filter_placeholder":"Filtrer par nom d'entité ou d'appareil","adopted.no_results":"Aucune entité adoptée ne correspond à ce filtre","adopted.none":"Ce panneau ne publie rien à curer.","adopted.load_failed":"Impossible de charger les entités adoptées","adopted.count":"{count} adoptées","adopted.vendor_readings":"RELEVÉS DU FOURNISSEUR","adopted.adopted_device":"APPAREIL ADOPTÉ","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURÉE","adopted.stale":"OBSOLÈTE","adopted.stale_note":"Le panneau ne prend plus en charge ce qui a été enregistré pour : {fields}","adopted.enable_entity":"Activer l'entité","adopted.enabled":"ACTIVÉE","adopted.disabled":"DÉSACTIVÉE","adopted.enable_note":"Activée à l'enregistrement — la curation n'active jamais d'elle-même","adopted.registry_unavailable":"L'activation et le nom seront disponibles dès que cette entité existera dans le registre.","adopted.name":"Nom","adopted.device_class":"Classe d'appareil","adopted.device_class_note":"choix limités par l'unité du panneau ({unit})","adopted.device_class_note_unitless":"choix limités par ce que le panneau publie","adopted.no_device_class":"Aucune classe d'appareil","adopted.statistics_class":"Classe de statistiques","adopted.statistics_note":"les statistiques à long terme commencent après le prochain rechargement","adopted.no_statistics":"Aucune statistique","adopted.prominence":"Proéminence","adopted.diagnostic":"Diagnostic","adopted.standard":"Standard","adopted.display_unit":"Unité affichée","adopted.unit_as_published":"Telle que publiée","adopted.precision":"Précision","adopted.precision_default":"Par défaut","adopted.read_only":"lecture seule","adopted.settable":"modifiable","adopted.save":"Enregistrer","adopted.clear":"Effacer la curation","adopted.reload_note":"L'enregistrement recharge l'intégration SPAN Panel","adopted.saved":"Enregistré — l'intégration se recharge","adopted.confirm_heading":"Confirmer la classe de statistiques","adopted.confirm_setting":"Vous définissez {name} sur {value}.","adopted.confirm_clearing_subject":"Vous effacez la classe de statistiques de {name}.","adopted.confirm_total_increasing":"Total croissant interprète chaque baisse de la valeur comme une remise à zéro du compteur. Si ce relevé peut diminuer pour une autre raison, les statistiques à long terme seront corrompues de façon permanente — corriger la classe plus tard ne répare pas l'historique déjà écrit.","adopted.confirm_clearing":"Les statistiques à long terme cesseront d'être compilées pour cette entité, et Home Assistant ouvrira une réparation sur les statistiques déjà collectées sous la classe que vous retirez.","adopted.save_anyway":"Enregistrer quand même","adopted.cancel":"Annuler","adopted.warn_total_increasing":"Enregistré en total croissant — chaque baisse du relevé compte désormais comme une remise à zéro du compteur.","adopted.warn_statistics_removed":"Cette entité n'a plus de classe de statistiques ; Home Assistant ouvrira une réparation sur les statistiques déjà collectées.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Paramètres de surveillance du panneau","header.graph_settings":"Paramètres d'horizon temporel du graphique","header.site":"Site","header.grid":"Réseau","header.upstream":"Amont","header.downstream":"Aval","header.solar":"Solaire","header.battery":"Batterie","header.toggle_units":"Basculer Watts / Ampères","header.enable_switches":"Activer les interrupteurs","header.switches_enabled":"Interrupteurs activés","grid.unknown":"Inconnu","grid.configure":"Configurer le circuit","grid.configure_subdevice":"Configurer l'appareil","grid.on":"On","grid.off":"Off","subdevice.ev_charger":"Chargeur VE","subdevice.battery":"Batterie","subdevice.fallback":"Sous-appareil","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Puissance","subdevice.solar":"Solaire","subdevice.solar_inverter":"Onduleur solaire","subdevice.site_total":"Total du site","subdevice.in_site_total":"Inclus dans le total du site","sidepanel.graph_settings":"Paramètres des Graphiques","sidepanel.global_defaults":"Valeurs par défaut globales pour tous les circuits","sidepanel.favorites_subtitle":"Favoris","sidepanel.global_default":"Défaut Global","sidepanel.list_view_columns":"Colonnes de la liste","sidepanel.columns":"Colonnes","sidepanel.circuit_scales":"Échelles des Graphiques de Circuits","sidepanel.subdevice_scales":"Échelles des Graphiques de Sous-Appareils","sidepanel.reset_to_global":"Réinitialiser à la valeur globale","sidepanel.relay":"Relais","sidepanel.breaker":"Disjoncteur","sidepanel.shedding_priority":"Priorité de Délestage","sidepanel.priority_label":"Priorité","sidepanel.monitoring":"Surveillance","sidepanel.global":"Global","sidepanel.custom":"Personnalisé","sidepanel.continuous_pct":"Continu %","sidepanel.spike_pct":"Pic %","sidepanel.window_duration":"Durée de fenêtre","sidepanel.cooldown":"Refroidissement","sidepanel.favorite":"Favori","sidepanel.save_to_favorites":"Enregistrer dans les favoris","panel.favorites":"Favoris","status.monitoring":"Surveillance","status.circuits":"circuits","status.mains":"alimentation","status.warning":"avertissement","status.warnings":"avertissements","status.alert":"alerte","status.alerts":"alertes","status.override":"remplacement","status.overrides":"remplacements","card.no_device":"Ouvrez l'éditeur de carte et sélectionnez votre appareil SPAN Panel.","card.device_not_found":"Appareil de panneau introuvable. Vérifiez device_id dans la configuration de la carte.","card.topology_error":"La réponse de topologie ne contient pas panel_size et aucun circuit trouvé. Mettez à jour l'intégration SPAN Panel.","card.panel_size_error":"Impossible de déterminer panel_size. Aucun circuit trouvé et aucun attribut panel_size. Mettez à jour l'intégration SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Sélectionnez un panneau...","editor.chart_window":"Fenêtre de temps du graphique","editor.days":"jours","editor.hours":"heures","editor.minutes":"minutes","editor.chart_metric":"Métrique du graphique","editor.visible_sections":"Sections visibles","editor.panel_circuits":"Circuits du panneau","editor.battery_bess":"Batterie (BESS)","editor.ev_charger_evse":"Chargeur VE (EVSE)","editor.solar_pv":"Solaire (PV)","editor.no_entities":"Aucune entité à afficher","editor.tab_style":"Style des onglets","editor.tab_style_text":"Texte","editor.tab_style_icon":"Icône","metric.power":"Puissance","metric.current":"Courant","metric.soc":"État de Charge","metric.soe":"État d'Énergie","shedding.always_on":"Critique","shedding.never":"Non délestable","shedding.soc_threshold":"Seuil SoC","shedding.off_grid":"Délestable","shedding.unknown":"Inconnu","shedding.select.never":"Reste allumé en cas de coupure","shedding.select.soc_threshold":"Allumé jusqu'au seuil batterie","shedding.select.off_grid":"S'éteint en cas de coupure"},ja:{"tab.panel":"パネル","tab.by_panel":"パネル別","tab.by_activity":"活動別","tab.by_area":"エリア別","tab.monitoring":"モニタリング","tab.settings":"設定","tab.adopted":"採用済み","list.search_placeholder":"回路を検索...","list.unassigned_area":"未割り当て","list.no_results":"回路が見つかりません","monitoring.heading":"モニタリング","monitoring.global_settings":"グローバル設定","monitoring.enabled":"有効","monitoring.continuous":"継続 (%)","monitoring.spike":"スパイク (%)","monitoring.window":"ウィンドウ (分)","monitoring.cooldown":"クールダウン (分)","monitoring.monitored_points":"監視ポイント","monitoring.col.name":"名前","monitoring.col.continuous":"継続","monitoring.col.spike":"スパイク","monitoring.col.window":"ウィンドウ","monitoring.col.cooldown":"クールダウン","monitoring.all_none":"全選択 / 全解除","monitoring.reset":"リセット","notification.heading":"通知設定","notification.targets":"通知先","notification.none_selected":"未選択","notification.no_targets":"通知先が見つかりません","notification.all_targets":"すべて","notification.event_bus_target":"イベントバス (HAイベントバス)","notification.priority":"優先度","notification.priority.default":"デフォルト","notification.priority.passive":"パッシブ","notification.priority.active":"アクティブ","notification.priority.time_sensitive":"緊急","notification.priority.critical":"重大","notification.hint.critical":"サイレント/おやすみモードを無視","notification.hint.time_sensitive":"集中モードを突破","notification.hint.passive":"サイレント配信","notification.hint.active":"標準配信","notification.title_template":"タイトルテンプレート","notification.message_template":"メッセージテンプレート","notification.placeholders":"プレースホルダー:","notification.event_bus_help":"イベントバスが発行するイベントタイプ","notification.event_bus_payload":"ペイロード:","notification.test_label":"テスト通知","notification.test_button":"テスト送信","notification.test_sending":"送信中...","notification.test_sent":"テスト通知を送信しました","error.prefix":"エラー:","error.failed_save":"保存に失敗","error.failed":"失敗","error.panel_offline":"SPANパネルに接続できません","error.panel_reconnected":"SPANパネルが再接続されました","error.panel_offline_named":"{name}に接続できません","error.panel_reconnected_named":"{name}が再接続されました","error.discovery_failed":"SPANパネルへの接続に失敗しました","error.relay_failed":"リレーの切り替えに失敗しました","error.shedding_failed":"シェディング優先度の更新に失敗しました","error.threshold_failed":"しきい値の保存に失敗しました","error.graph_horizon_failed":"グラフの時間範囲の更新に失敗しました","error.favorites_fetch_failed":"お気に入りの読み込みに失敗しました","error.favorites_toggle_failed":"お気に入りの更新に失敗しました","error.history_failed":"履歴データの読み込みに失敗しました","error.monitoring_failed":"監視ステータスの読み込みに失敗しました","error.graph_settings_failed":"グラフ設定の読み込みに失敗しました","error.areas_failed":"エリア割り当てが同期されていない可能性があります","error.retry":"再試行","card.connecting":"SPANパネルに接続中...","settings.heading":"設定","settings.description":"統合の一般設定(エンティティ名、デバイスプレフィックス、回路番号)は統合のオプションフローで管理されます。","settings.open_link":"SPAN Panel統合設定を開く","adopted.heading":"採用済みエンティティ","adopted.description":"ベンダーの測定値と採用済みデバイスは最小限のメタデータで登録されます。キュレーションでデバイスクラス、統計クラス、表示区分を設定できます。保存すると統合が再読み込みされ、次回の起動から適用されます。","adopted.filter_placeholder":"エンティティ名またはデバイス名で絞り込み","adopted.no_results":"この条件に一致する採用済みエンティティはありません","adopted.none":"このパネルにキュレーション対象はありません。","adopted.load_failed":"採用済みエンティティを読み込めません","adopted.count":"{count} 件","adopted.vendor_readings":"ベンダー測定値","adopted.adopted_device":"採用済みデバイス","adopted.via_panel":"SPAN Panel 経由","adopted.curated":"キュレーション済み","adopted.stale":"無効","adopted.stale_note":"パネルは保存された次の項目をサポートしなくなりました: {fields}","adopted.enable_entity":"エンティティを有効化","adopted.enabled":"有効","adopted.disabled":"無効","adopted.enable_note":"保存時に有効化されます — キュレーション自体が有効化することはありません","adopted.registry_unavailable":"有効化・名前は、このエンティティがレジストリに登録された後に利用できます。","adopted.name":"名前","adopted.device_class":"デバイスクラス","adopted.device_class_note":"パネルの単位({unit})により選択肢が制限されます","adopted.device_class_note_unitless":"パネルが公開する内容により選択肢が制限されます","adopted.no_device_class":"デバイスクラスなし","adopted.statistics_class":"統計クラス","adopted.statistics_note":"長期統計は次回の再読み込み後に開始されます","adopted.no_statistics":"統計なし","adopted.prominence":"表示区分","adopted.diagnostic":"診断","adopted.standard":"標準","adopted.display_unit":"表示単位","adopted.unit_as_published":"公開されたまま","adopted.precision":"小数点以下桁数","adopted.precision_default":"既定","adopted.read_only":"読み取り専用","adopted.settable":"書き込み可","adopted.save":"保存","adopted.clear":"キュレーションを消去","adopted.reload_note":"保存すると SPAN Panel 統合が再読み込みされます","adopted.saved":"保存しました — 統合を再読み込みしています","adopted.confirm_heading":"統計クラスの確認","adopted.confirm_setting":"{name} を {value} に設定しようとしています。","adopted.confirm_clearing_subject":"{name} の統計クラスを消去しようとしています。","adopted.confirm_total_increasing":"積算増加は値の低下をすべてメーターのリセットとして扱います。この測定値が他の理由でも下がる場合、長期統計は恒久的に破損します。後からクラスを直しても、既に書き込まれた履歴は修復されません。","adopted.confirm_clearing":"このエンティティの長期統計は収集されなくなり、削除するクラスの下で既に収集された統計について Home Assistant が修復項目を作成します。","adopted.save_anyway":"それでも保存","adopted.cancel":"キャンセル","adopted.warn_total_increasing":"積算増加として保存しました — 測定値の低下はすべてメーターのリセットとして数えられます。","adopted.warn_statistics_removed":"このエンティティに統計クラスはなくなりました。既に収集された統計について Home Assistant が修復項目を作成します。","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"パネルモニタリング設定","header.graph_settings":"グラフ時間範囲設定","header.site":"サイト","header.grid":"グリッド","header.upstream":"上流","header.downstream":"下流","header.solar":"ソーラー","header.battery":"バッテリー","header.toggle_units":"ワット/アンペア切り替え","header.enable_switches":"スイッチを有効化","header.switches_enabled":"スイッチ有効","grid.unknown":"不明","grid.configure":"回路を設定","grid.configure_subdevice":"デバイスを設定","grid.on":"オン","grid.off":"オフ","subdevice.ev_charger":"EV充電器","subdevice.battery":"バッテリー","subdevice.fallback":"サブデバイス","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"電力","subdevice.solar":"ソーラー","subdevice.solar_inverter":"ソーラーインバーター","subdevice.site_total":"サイト合計","subdevice.in_site_total":"サイト合計に含まれます","sidepanel.graph_settings":"グラフ設定","sidepanel.global_defaults":"全回路のグローバルデフォルト","sidepanel.favorites_subtitle":"お気に入り","sidepanel.global_default":"グローバルデフォルト","sidepanel.list_view_columns":"リスト表示の列数","sidepanel.columns":"列","sidepanel.circuit_scales":"回路グラフスケール","sidepanel.subdevice_scales":"サブデバイスグラフスケール","sidepanel.reset_to_global":"グローバルにリセット","sidepanel.relay":"リレー","sidepanel.breaker":"ブレーカー","sidepanel.shedding_priority":"シェディング優先度","sidepanel.priority_label":"優先度","sidepanel.monitoring":"モニタリング","sidepanel.global":"グローバル","sidepanel.custom":"カスタム","sidepanel.continuous_pct":"継続 %","sidepanel.spike_pct":"スパイク %","sidepanel.window_duration":"ウィンドウ時間","sidepanel.cooldown":"クールダウン","sidepanel.favorite":"お気に入り","sidepanel.save_to_favorites":"お気に入りに保存","panel.favorites":"お気に入り","status.monitoring":"モニタリング","status.circuits":"回路","status.mains":"主電源","status.warning":"警告","status.warnings":"警告","status.alert":"アラート","status.alerts":"アラート","status.override":"上書き","status.overrides":"上書き","card.no_device":"カードエディタを開いてSPAN Panelデバイスを選択してください。","card.device_not_found":"パネルデバイスが見つかりません。カード設定のdevice_idを確認してください。","card.topology_error":"トポロジー応答にpanel_sizeがなく、回路が見つかりません。SPAN Panel統合を更新してください。","card.panel_size_error":"panel_sizeを判定できません。回路がpanel_size属性が見つかりません。SPAN Panel統合を更新してください。","editor.panel_label":"SPAN Panel","editor.select_panel":"パネルを選択...","editor.chart_window":"グラフ時間ウィンドウ","editor.days":"日","editor.hours":"時間","editor.minutes":"分","editor.chart_metric":"グラフ指標","editor.visible_sections":"表示セクション","editor.panel_circuits":"パネル回路","editor.battery_bess":"バッテリー (BESS)","editor.ev_charger_evse":"EV充電器 (EVSE)","editor.solar_pv":"ソーラー (PV)","editor.no_entities":"表示するエンティティがありません","editor.tab_style":"タブスタイル","editor.tab_style_text":"テキスト","editor.tab_style_icon":"アイコン","metric.power":"電力","metric.current":"電流","metric.soc":"充電状態","metric.soe":"エネルギー状態","shedding.always_on":"重要","shedding.never":"切断不可","shedding.soc_threshold":"SoCしきい値","shedding.off_grid":"切断可能","shedding.unknown":"不明","shedding.select.never":"停電時もオンを維持","shedding.select.soc_threshold":"バッテリーしきい値までオン","shedding.select.off_grid":"停電時にオフ"},pt:{"tab.panel":"Painel","tab.by_panel":"Por Painel","tab.by_activity":"Por Atividade","tab.by_area":"Por Área","tab.monitoring":"Monitoramento","tab.settings":"Configurações","tab.adopted":"Adotadas","list.search_placeholder":"Pesquisar circuitos...","list.unassigned_area":"Não atribuído","list.no_results":"Nenhum circuito encontrado","monitoring.heading":"Monitoramento","monitoring.global_settings":"Configurações Globais","monitoring.enabled":"Ativado","monitoring.continuous":"Contínuo (%)","monitoring.spike":"Pico (%)","monitoring.window":"Janela (min)","monitoring.cooldown":"Resfriamento (min)","monitoring.monitored_points":"Pontos Monitorados","monitoring.col.name":"Nome","monitoring.col.continuous":"Contínuo","monitoring.col.spike":"Pico","monitoring.col.window":"Janela","monitoring.col.cooldown":"Resfriamento","monitoring.all_none":"Todos / Nenhum","monitoring.reset":"Redefinir","notification.heading":"Configurações de Notificação","notification.targets":"Destinos de Notificação","notification.none_selected":"Nenhum selecionado","notification.no_targets":"Nenhum destino de notificação encontrado","notification.all_targets":"Todos","notification.event_bus_target":"Barramento de Eventos (barramento de eventos do HA)","notification.priority":"Prioridade","notification.priority.default":"Padrão","notification.priority.passive":"Passivo","notification.priority.active":"Ativo","notification.priority.time_sensitive":"Urgente","notification.priority.critical":"Crítico","notification.hint.critical":"Substitui silencioso/Não perturbar","notification.hint.time_sensitive":"Atravessa o modo Foco","notification.hint.passive":"Entrega silenciosa","notification.hint.active":"Entrega padrão","notification.title_template":"Modelo de Título","notification.message_template":"Modelo de Mensagem","notification.placeholders":"Variáveis:","notification.event_bus_help":"O Barramento de Eventos dispara o tipo de evento","notification.event_bus_payload":"com dados:","notification.test_label":"Notificação de teste","notification.test_button":"Enviar teste","notification.test_sending":"Enviando...","notification.test_sent":"Notificação de teste enviada","error.prefix":"Erro:","error.failed_save":"Falha ao salvar","error.failed":"Falhou","error.panel_offline":"SPAN Panel inacessível","error.panel_reconnected":"SPAN Panel reconectado","error.panel_offline_named":"{name} inacessível","error.panel_reconnected_named":"{name} reconectado","error.discovery_failed":"Não foi possível conectar ao SPAN Panel","error.relay_failed":"Não foi possível alternar o relé","error.shedding_failed":"Não foi possível atualizar a prioridade de desligamento","error.threshold_failed":"Não foi possível salvar o limite","error.graph_horizon_failed":"Não foi possível atualizar o horizonte temporal do gráfico","error.favorites_fetch_failed":"Não foi possível carregar os favoritos","error.favorites_toggle_failed":"Não foi possível atualizar o favorito","error.history_failed":"Não foi possível carregar os dados históricos","error.monitoring_failed":"Não foi possível carregar o status de monitoramento","error.graph_settings_failed":"Não foi possível carregar as configurações do gráfico","error.areas_failed":"As atribuições de áreas podem estar fora de sincronização","error.retry":"Tentar novamente","card.connecting":"Conectando ao SPAN Panel...","settings.heading":"Configurações","settings.description":"As configurações gerais da integração (nomes de entidades, prefixo do dispositivo, números de circuito) são gerenciadas através do fluxo de opções da integração.","settings.open_link":"Abrir Configurações de Integração SPAN Panel","adopted.heading":"Entidades adotadas","adopted.description":"As leituras do fornecedor e os dispositivos adotados chegam com metadados mínimos. Faça a curadoria de uma para definir sua classe de dispositivo, classe de estatísticas e proeminência — alterações salvas recarregam a integração e valem a partir da próxima inicialização.","adopted.filter_placeholder":"Filtrar por nome de entidade ou dispositivo","adopted.no_results":"Nenhuma entidade adotada corresponde a este filtro","adopted.none":"Este painel não publica nada para curadoria.","adopted.load_failed":"Não foi possível carregar as entidades adotadas","adopted.count":"{count} adotadas","adopted.vendor_readings":"LEITURAS DO FORNECEDOR","adopted.adopted_device":"DISPOSITIVO ADOTADO","adopted.via_panel":"via SPAN Panel","adopted.curated":"CURADA","adopted.stale":"OBSOLETA","adopted.stale_note":"O painel não suporta mais o que foi salvo para: {fields}","adopted.enable_entity":"Ativar entidade","adopted.enabled":"ATIVADA","adopted.disabled":"DESATIVADA","adopted.enable_note":"Ativada ao salvar — a curadoria nunca ativa por conta própria","adopted.registry_unavailable":"Ativação e nome ficam disponíveis assim que esta entidade existir no registro.","adopted.name":"Nome","adopted.device_class":"Classe de dispositivo","adopted.device_class_note":"opções limitadas pela unidade do painel ({unit})","adopted.device_class_note_unitless":"opções limitadas pelo que o painel publica","adopted.no_device_class":"Sem classe de dispositivo","adopted.statistics_class":"Classe de estatísticas","adopted.statistics_note":"as estatísticas de longo prazo começam após a próxima recarga","adopted.no_statistics":"Sem estatísticas","adopted.prominence":"Proeminência","adopted.diagnostic":"Diagnóstico","adopted.standard":"Padrão","adopted.display_unit":"Unidade exibida","adopted.unit_as_published":"Como publicada","adopted.precision":"Precisão","adopted.precision_default":"Padrão","adopted.read_only":"somente leitura","adopted.settable":"editável","adopted.save":"Salvar","adopted.clear":"Limpar curadoria","adopted.reload_note":"Salvar recarrega a integração SPAN Panel","adopted.saved":"Salvo — a integração está recarregando","adopted.confirm_heading":"Confirmar classe de estatísticas","adopted.confirm_setting":"Você está definindo {name} como {value}.","adopted.confirm_clearing_subject":"Você está limpando a classe de estatísticas de {name}.","adopted.confirm_total_increasing":"Total crescente trata toda queda do valor como uma reinicialização do medidor. Se esta leitura puder diminuir por qualquer outro motivo, as estatísticas de longo prazo ficarão permanentemente corrompidas — corrigir a classe depois não repara o histórico já gravado.","adopted.confirm_clearing":"As estatísticas de longo prazo deixarão de ser compiladas para esta entidade, e o Home Assistant abrirá um reparo sobre as estatísticas já coletadas na classe que você está removendo.","adopted.save_anyway":"Salvar mesmo assim","adopted.cancel":"Cancelar","adopted.warn_total_increasing":"Salvo como total crescente — cada queda da leitura agora conta como uma reinicialização do medidor.","adopted.warn_statistics_removed":"Esta entidade não tem mais classe de estatísticas; o Home Assistant abrirá um reparo sobre as estatísticas já coletadas.","horizon.5m":"5 Minutes","horizon.1h":"1 Hour","horizon.1d":"1 Day","horizon.1w":"1 Week","horizon.1M":"1 Month","settings.graph_horizon_heading":"Graph Time Horizon","settings.graph_horizon_description":"Default time window for all circuit graphs. Individual circuits can override this in their settings panel.","settings.global_default":"Global Default","settings.default_scale":"Default Scale","settings.circuit_graph_scales":"Circuit Graph Scales","settings.col.circuit":"Circuit","settings.col.scale":"Scale","sidepanel.graph_horizon":"Graph Time Horizon","header.default_name":"SPAN Panel","header.monitoring_settings":"Configurações de monitoramento do painel","header.graph_settings":"Configurações do horizonte temporal do gráfico","header.site":"Local","header.grid":"Rede","header.upstream":"Montante","header.downstream":"Jusante","header.solar":"Solar","header.battery":"Bateria","header.toggle_units":"Alternar Watts / Amperes","header.enable_switches":"Ativar Interruptores","header.switches_enabled":"Interruptores Ativados","grid.unknown":"Desconhecido","grid.configure":"Configurar circuito","grid.configure_subdevice":"Configurar dispositivo","grid.on":"Lig","grid.off":"Des","subdevice.ev_charger":"Carregador VE","subdevice.battery":"Bateria","subdevice.fallback":"Sub-dispositivo","subdevice.soc":"SoC","subdevice.soe":"SoE","subdevice.power":"Potência","subdevice.solar":"Solar","subdevice.solar_inverter":"Inversor solar","subdevice.site_total":"Total do local","subdevice.in_site_total":"Incluído no total do local","sidepanel.graph_settings":"Configurações de Gráficos","sidepanel.global_defaults":"Padrões globais para todos os circuitos","sidepanel.favorites_subtitle":"Favoritos","sidepanel.global_default":"Padrão Global","sidepanel.list_view_columns":"Colunas da Lista","sidepanel.columns":"Colunas","sidepanel.circuit_scales":"Escalas de Gráficos de Circuitos","sidepanel.subdevice_scales":"Escalas de Gráficos de Sub-Dispositivos","sidepanel.reset_to_global":"Redefinir para o padrão global","sidepanel.relay":"Relé","sidepanel.breaker":"Disjuntor","sidepanel.shedding_priority":"Prioridade de Desligamento","sidepanel.priority_label":"Prioridade","sidepanel.monitoring":"Monitoramento","sidepanel.global":"Global","sidepanel.custom":"Personalizado","sidepanel.continuous_pct":"Contínuo %","sidepanel.spike_pct":"Pico %","sidepanel.window_duration":"Duração da janela","sidepanel.cooldown":"Resfriamento","sidepanel.favorite":"Favorito","sidepanel.save_to_favorites":"Salvar nos favoritos","panel.favorites":"Favoritos","status.monitoring":"Monitoramento","status.circuits":"circuitos","status.mains":"alimentação","status.warning":"aviso","status.warnings":"avisos","status.alert":"alerta","status.alerts":"alertas","status.override":"substituição","status.overrides":"substituições","card.no_device":"Abra o editor do cartão e selecione seu dispositivo SPAN Panel.","card.device_not_found":"Dispositivo do painel não encontrado. Verifique device_id na configuração do cartão.","card.topology_error":"A resposta de topologia não contém panel_size e nenhum circuito encontrado. Atualize a integração SPAN Panel.","card.panel_size_error":"Não foi possível determinar panel_size. Nenhum circuito encontrado e nenhum atributo panel_size. Atualize a integração SPAN Panel.","editor.panel_label":"SPAN Panel","editor.select_panel":"Selecione um painel...","editor.chart_window":"Janela de tempo do gráfico","editor.days":"dias","editor.hours":"horas","editor.minutes":"minutos","editor.chart_metric":"Métrica do gráfico","editor.visible_sections":"Seções visíveis","editor.panel_circuits":"Circuitos do painel","editor.battery_bess":"Bateria (BESS)","editor.ev_charger_evse":"Carregador VE (EVSE)","editor.solar_pv":"Solar (FV)","editor.no_entities":"Nenhuma entidade para mostrar","editor.tab_style":"Estilo das abas","editor.tab_style_text":"Texto","editor.tab_style_icon":"Ícone","metric.power":"Potência","metric.current":"Corrente","metric.soc":"Estado de Carga","metric.soe":"Estado de Energia","shedding.always_on":"Crítico","shedding.never":"Não desligável","shedding.soc_threshold":"Limite SoC","shedding.off_grid":"Desligável","shedding.unknown":"Desconhecido","shedding.select.never":"Permanece ligado em uma queda","shedding.select.soc_threshold":"Ligado até limite da bateria","shedding.select.off_grid":"Desliga em uma queda"}};function n(n){return e[t]?.[n]??e.en?.[n]??n}function i(n,i){return(e[t]?.[n]??e.en?.[n]??n).replace(/\{(\w+)\}/g,(t,e)=>Object.prototype.hasOwnProperty.call(i,e)?i[e]:`{${e}}`)}const r="power",o="5m",a={"5m":{ms:3e5,refreshMs:1e3,useRealtime:!0},"1h":{ms:36e5,refreshMs:3e4,useRealtime:!1},"1d":{ms:864e5,refreshMs:6e4,useRealtime:!1},"1w":{ms:6048e5,refreshMs:6e4,useRealtime:!1},"1M":{ms:2592e6,refreshMs:6e4,useRealtime:!1}},s="span_panel",l="pv",c="bess",d="evse",u="pv",h="sub_",p=500,f={power:{entityRole:"power",label:()=>n("metric.power"),unit:t=>Math.abs(t)>=1e3?"kW":"W",format:t=>{const e=Math.abs(t);return e>=1e3?(e/1e3).toFixed(1):e<10&&e>0?e.toFixed(1):String(Math.round(e))}},current:{entityRole:"current",label:()=>n("metric.current"),unit:()=>"A",format:t=>Math.abs(t).toFixed(1)}},g={soc:{entityRole:"soc",label:()=>n("metric.soc"),unit:()=>"%",format:t=>String(Math.round(t)),fixedMin:0,fixedMax:100},soe:{entityRole:"soe",label:()=>n("metric.soe"),unit:()=>"kWh",format:t=>t.toFixed(1)},power:f.power},v={always_on:{icon:"mdi:battery",icon2:"mdi:router-wireless",color:"#4caf50",label:()=>n("shedding.always_on")},never:{icon:"mdi:battery",color:"#4caf50",label:()=>n("shedding.never")},soc_threshold:{icon:"mdi:battery-alert-variant-outline",color:"#9c27b0",label:()=>n("shedding.soc_threshold"),textLabel:"SoC"},off_grid:{icon:"mdi:transmission-tower",color:"#ff9800",label:()=>n("shedding.off_grid")},unknown:{icon:"mdi:help-circle-outline",color:"#888",label:()=>n("shedding.unknown")}},m=Object.keys(v).filter(t=>"unknown"!==t&&"always_on"!==t);var y=function(t,e){return y=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},y(t,e)};function _(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function n(){this.constructor=t}y(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)}function b(t,e,n,i){var r,o=arguments.length,a=o<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,n):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(t,e,n,i);else for(var s=t.length-1;s>=0;s--)(r=t[s])&&(a=(o<3?r(a):o>3?r(e,n,a):r(e,n))||a);return o>3&&a&&Object.defineProperty(e,n,a),a}"function"==typeof SuppressedError&&SuppressedError; /** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const b=globalThis,x=b.ShadowRoot&&(void 0===b.ShadyCSS||b.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,w=Symbol(),S=new WeakMap;let C=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==w)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(x&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=S.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&S.set(e,t))}return t}toString(){return this.cssText}};const k=t=>new C("string"==typeof t?t:t+"",void 0,w),M=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new C(n,t,w)},T=x?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return k(e)})(t):t,{is:I,defineProperty:D,getOwnPropertyDescriptor:A,getOwnPropertyNames:P,getOwnPropertySymbols:L,getPrototypeOf:E}=Object,z=globalThis,N=z.trustedTypes,O=N?N.emptyScript:"",R=z.reactiveElementPolyfillSupport,$=(t,e)=>t,H={toAttribute(t,e){switch(e){case Boolean:t=t?O:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},F=(t,e)=>!I(t,e),B={attribute:!0,type:String,converter:H,reflect:!1,useDefault:!1,hasChanged:F}; +const x=globalThis,w=x.ShadowRoot&&(void 0===x.ShadyCSS||x.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,S=Symbol(),C=new WeakMap;let k=class{constructor(t,e,n){if(this._$cssResult$=!0,n!==S)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(w&&void 0===t){const n=void 0!==e&&1===e.length;n&&(t=C.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),n&&C.set(e,t))}return t}toString(){return this.cssText}};const T=t=>new k("string"==typeof t?t:t+"",void 0,S),M=(t,...e)=>{const n=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new k(n,t,S)},I=w?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return T(e)})(t):t,{is:A,defineProperty:D,getOwnPropertyDescriptor:P,getOwnPropertyNames:L,getOwnPropertySymbols:E,getPrototypeOf:z}=Object,N=globalThis,O=N.trustedTypes,$=O?O.emptyScript:"",R=N.reactiveElementPolyfillSupport,H=(t,e)=>t,F={toAttribute(t,e){switch(e){case Boolean:t=t?$:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},B=(t,e)=>!A(t,e),V={attribute:!0,type:String,converter:F,reflect:!1,useDefault:!1,hasChanged:B}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */Symbol.metadata??=Symbol("metadata"),z.litPropertyMetadata??=new WeakMap;let V=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=B){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&D(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=A(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??B}static _$Ei(){if(this.hasOwnProperty($("elementProperties")))return;const t=E(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty($("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty($("properties"))){const t=this.properties,e=[...P(t),...L(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(T(t))}else void 0!==t&&e.push(T(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.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(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,e)=>{if(x)t.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of e){const e=document.createElement("style"),i=b.litNonce;void 0!==i&&e.setAttribute("nonce",i),e.textContent=n.cssText,t.appendChild(e)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:H).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:H;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??F)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};V.elementStyles=[],V.shadowRootOptions={mode:"open"},V[$("elementProperties")]=new Map,V[$("finalized")]=new Map,R?.({ReactiveElement:V}),(z.reactiveElementVersions??=[]).push("2.1.2"); + */Symbol.metadata??=Symbol("metadata"),N.litPropertyMetadata??=new WeakMap;let W=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=V){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&D(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:r}=P(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const o=i?.call(this);r?.call(this,e),this.requestUpdate(t,o,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??V}static _$Ei(){if(this.hasOwnProperty(H("elementProperties")))return;const t=z(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(H("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(H("properties"))){const t=this.properties,e=[...L(t),...E(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(I(t))}else void 0!==t&&e.push(I(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.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(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,e)=>{if(w)t.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const n of e){const e=document.createElement("style"),i=x.litNonce;void 0!==i&&e.setAttribute("nonce",i),e.textContent=n.cssText,t.appendChild(e)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const r=(void 0!==n.converter?.toAttribute?n.converter:F).toAttribute(e,n.type);this._$Em=t,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),r="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:F;this._$Em=i;const o=r.fromAttribute(e,t.type);this[i]=o??this._$Ej?.get(i)??o,this._$Em=null}}requestUpdate(t,e,n,i=!1,r){if(void 0!==t){const o=this.constructor;if(!1===i&&(r=this[t]),n??=o.getPropertyOptions(t),!((n.hasChanged??B)(r,e)||n.useDefault&&n.reflect&&r===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:r},o){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==r||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};W.elementStyles=[],W.shadowRootOptions={mode:"open"},W[H("elementProperties")]=new Map,W[H("finalized")]=new Map,R?.({ReactiveElement:W}),(N.reactiveElementVersions??=[]).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const W=globalThis,U=t=>t,G=W.trustedTypes,q=G?G.createPolicy("lit-html",{createHTML:t=>t}):void 0,j="$lit$",X=`lit$${Math.random().toFixed(9).slice(2)}$`,Y="?"+X,Z=`<${Y}>`,K=document,Q=()=>K.createComment(""),J=t=>null===t||"object"!=typeof t&&"function"!=typeof t,tt=Array.isArray,et="[ \t\n\f\r]",nt=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,it=/-->/g,rt=/>/g,ot=RegExp(`>|${et}(?:([^\\s"'>=/]+)(${et}*=${et}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),at=/'/g,st=/"/g,lt=/^(?:script|style|textarea|title)$/i,ct=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),ut=ct(1),ht=ct(2),dt=Symbol.for("lit-noChange"),pt=Symbol.for("lit-nothing"),ft=new WeakMap,gt=K.createTreeWalker(K,129);function vt(t,e){if(!tt(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==q?q.createHTML(e):e}const mt=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=nt;for(let e=0;e"===l[0]?(a=r??nt,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,s=l[1],a=void 0===l[3]?ot:'"'===l[3]?st:at):a===st||a===at?a=ot:a===it||a===rt?a=nt:(a=ot,r=void 0);const h=a===ot&&t[e+1].startsWith("/>")?" ":"";o+=a===nt?n+Z:c>=0?(i.push(s),n.slice(0,c)+j+n.slice(c)+X+h):n+X+(-2===c?e:h)}return[vt(t,o+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class yt{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[l,c]=mt(t,e);if(this.el=yt.createElement(l,n),gt.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=gt.nextNode())&&s.length0){i.textContent=G?G.emptyScript:"";for(let n=0;ntt(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==pt&&J(this._$AH)?this._$AA.nextSibling.data=t:this.T(K.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,i="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=yt.createElement(vt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(e);else{const t=new bt(i,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=ft.get(t.strings);return void 0===e&&ft.set(t.strings,e=new yt(t)),e}k(t){tt(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,i=0;for(const r of t)i===e.length?e.push(n=new xt(this.O(Q()),this.O(Q()),this,this.options)):n=e[i],n._$AI(r),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=pt}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=_t(this,t,e,0),o=!J(t)||t!==this._$AH&&t!==dt,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;at,q=G.trustedTypes,j=q?q.createPolicy("lit-html",{createHTML:t=>t}):void 0,X="$lit$",Y=`lit$${Math.random().toFixed(9).slice(2)}$`,Z="?"+Y,K=`<${Z}>`,Q=document,J=()=>Q.createComment(""),tt=t=>null===t||"object"!=typeof t&&"function"!=typeof t,et=Array.isArray,nt="[ \t\n\f\r]",it=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,rt=/-->/g,ot=/>/g,at=RegExp(`>|${nt}(?:([^\\s"'>=/]+)(${nt}*=${nt}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),st=/'/g,lt=/"/g,ct=/^(?:script|style|textarea|title)$/i,dt=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),ut=dt(1),ht=dt(2),pt=Symbol.for("lit-noChange"),ft=Symbol.for("lit-nothing"),gt=new WeakMap,vt=Q.createTreeWalker(Q,129);function mt(t,e){if(!et(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==j?j.createHTML(e):e}const yt=(t,e)=>{const n=t.length-1,i=[];let r,o=2===e?"":3===e?"":"",a=it;for(let e=0;e"===l[0]?(a=r??it,c=-1):void 0===l[1]?c=-2:(c=a.lastIndex-l[2].length,s=l[1],a=void 0===l[3]?at:'"'===l[3]?lt:st):a===lt||a===st?a=at:a===rt||a===ot?a=it:(a=at,r=void 0);const u=a===at&&t[e+1].startsWith("/>")?" ":"";o+=a===it?n+K:c>=0?(i.push(s),n.slice(0,c)+X+n.slice(c)+Y+u):n+Y+(-2===c?e:u)}return[mt(t,o+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class _t{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let r=0,o=0;const a=t.length-1,s=this.parts,[l,c]=yt(t,e);if(this.el=_t.createElement(l,n),vt.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=vt.nextNode())&&s.length0){i.textContent=q?q.emptyScript:"";for(let n=0;net(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==ft&&tt(this._$AH)?this._$AA.nextSibling.data=t:this.T(Q.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:n}=t,i="number"==typeof n?this._$AC(t):(void 0===n.el&&(n.el=_t.createElement(mt(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===i)this._$AH.p(e);else{const t=new xt(i,this),n=t.u(this.options);t.p(e),this.T(n),this._$AH=t}}_$AC(t){let e=gt.get(t.strings);return void 0===e&>.set(t.strings,e=new _t(t)),e}k(t){et(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let n,i=0;for(const r of t)i===e.length?e.push(n=new wt(this.O(J()),this.O(J()),this,this.options)):n=e[i],n._$AI(r),i++;i2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=ft}_$AI(t,e=this,n,i){const r=this.strings;let o=!1;if(void 0===r)t=bt(this,t,e,0),o=!tt(t)||t!==this._$AH&&t!==pt,o&&(this._$AH=t);else{const i=t;let a,s;for(t=r[0],a=0;a{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new xt(e.insertBefore(Q(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return dt}};Dt._$litElement$=!0,Dt.finalized=!0,It.litElementHydrateSupport?.({LitElement:Dt});const At=It.litElementPolyfillSupport;At?.({LitElement:Dt}),(It.litElementVersions??=[]).push("4.2.2"); + */let Dt=class extends W{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=((t,e,n)=>{const i=n?.renderBefore??e;let r=i._$litPart$;if(void 0===r){const t=n?.renderBefore??null;i._$litPart$=r=new wt(e.insertBefore(J(),t),t,void 0,n??{})}return r._$AI(t),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return pt}};Dt._$litElement$=!0,Dt.finalized=!0,At.litElementHydrateSupport?.({LitElement:Dt});const Pt=At.litElementPolyfillSupport;Pt?.({LitElement:Dt}),(At.litElementVersions??=[]).push("4.2.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const Pt={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:F},Lt=(t=Pt,e,n)=>{const{kind:i,metadata:r}=n;let o=globalThis.litPropertyMetadata.get(r);if(void 0===o&&globalThis.litPropertyMetadata.set(r,o=new Map),"setter"===i&&((t=Object.create(t)).wrapped=!0),o.set(n.name,t),"accessor"===i){const{name:i}=n;return{set(n){const r=e.get.call(this);e.set.call(this,n),this.requestUpdate(i,r,t,!0,n)},init(e){return void 0!==e&&this.C(i,void 0,t,e),e}}}if("setter"===i){const{name:i}=n;return function(n){const r=this[i];e.call(this,n),this.requestUpdate(i,r,t,!0,n)}}throw Error("Unsupported decorator location: "+i)}; +const Lt={attribute:!0,type:String,converter:F,reflect:!1,hasChanged:B},Et=(t=Lt,e,n)=>{const{kind:i,metadata:r}=n;let o=globalThis.litPropertyMetadata.get(r);if(void 0===o&&globalThis.litPropertyMetadata.set(r,o=new Map),"setter"===i&&((t=Object.create(t)).wrapped=!0),o.set(n.name,t),"accessor"===i){const{name:i}=n;return{set(n){const r=e.get.call(this);e.set.call(this,n),this.requestUpdate(i,r,t,!0,n)},init(e){return void 0!==e&&this.C(i,void 0,t,e),e}}}if("setter"===i){const{name:i}=n;return function(n){const r=this[i];e.call(this,n),this.requestUpdate(i,r,t,!0,n)}}throw Error("Unsupported decorator location: "+i)}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function Et(t){return(e,n)=>"object"==typeof n?Lt(t,e,n):((t,e,n)=>{const i=e.hasOwnProperty(n);return e.constructor.createProperty(n,t),i?Object.getOwnPropertyDescriptor(e,n):void 0})(t,e,n)} + */function zt(t){return(e,n)=>"object"==typeof n?Et(t,e,n):((t,e,n)=>{const i=e.hasOwnProperty(n);return e.constructor.createProperty(n,t),i?Object.getOwnPropertyDescriptor(e,n):void 0})(t,e,n)} /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function zt(t){return Et({...t,state:!0,attribute:!1})} + */function Nt(t){return zt({...t,state:!0,attribute:!1})} /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */const Nt=2;class Ot{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,n){this._$Ct=t,this._$AM=e,this._$Ci=n}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}} + */const Ot=2;class $t{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,n){this._$Ct=t,this._$AM=e,this._$Ci=n}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}} /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */let Rt=class extends Ot{constructor(t){if(super(t),this.it=pt,t.type!==Nt)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(t){if(t===pt||null==t)return this._t=void 0,this.it=t;if(t===dt)return t;if("string"!=typeof t)throw Error(this.constructor.directiveName+"() called with a non-string value");if(t===this.it)return this._t;this.it=t;const e=[t];return e.raw=e,this._t={_$litType$:this.constructor.resultType,strings:e,values:[]}}};Rt.directiveName="unsafeHTML",Rt.resultType=1;const $t=(t=>(...e)=>({_$litDirective$:t,values:e}))(Rt);class Ht{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(t){const e={...t,timestamp:Date.now()};if(e.persistent)this._persistent.set(e.key,e);else{this._clearTransient(),this._transient=e;const t=e.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},t)}this._notify()}remove(t){if(this._persistent.has(t))return this._persistent.delete(t),void this._notify();this._transient?.key===t&&(this._clearTransient(),this._notify())}clear(t){void 0===t?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===t.persistent?this._persistent.clear():!1===t.persistent&&this._clearTransient(),this._notify()}get active(){const t=[...this._persistent.values()];return null!==this._transient&&t.push(this._transient),t}hasPersistent(t){return this._persistent.has(t)}hasAnyPanelOffline(){for(const t of this._persistent.keys())if("panel-offline"===t||t.startsWith("panel-offline:"))return!0;return!1}subscribe(t){return this._subscribers.add(t),()=>{this._subscribers.delete(t)}}watchPanelStatus(t){this.watchPanelStatuses([{entityId:t,panelName:null}])}watchPanelStatuses(t){const e=this._watchedPanels,n=new Map;for(const i of t){const t=e.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:t?.wasOffline??!1})}const i=this._isSingleUnnamed(e),r=this._isSingleUnnamed(n);for(const t of e.keys()){n.has(t)&&i===r||this._persistent.delete(this._offlineKey(t,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const e of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(e,t));this._watchedPanels.clear(),this._notify()}updateHass(t){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const[r,o]of this._watchedPanels){const a=t.states[r]?.state,s="on"===a,l=this._offlineKey(r,e),c=this._reconnectKey(r,e);if(s){const t=o.wasOffline;o.wasOffline=!1,this.remove(l),t&&this.add({key:c,level:"info",message:null===o.panelName?n("error.panel_reconnected"):i("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?n("error.panel_offline"):i("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(t){if(1!==t.size)return!1;for(const e of t.values())return null===e.panelName;return!1}_offlineKey(t,e){return e?"panel-offline":`panel-offline:${t}`}_reconnectKey(t,e){return e?"panel-reconnected":`panel-reconnected:${t}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const t of this._subscribers)try{t()}catch(t){console.warn("SPAN Panel: error-store subscriber threw",t)}}}const Ft={"&":"&","<":"<",">":">",'"':""","'":"'"};function Bt(t){return String(t).replace(/[&<>"']/g,t=>Ft[t]??t)}const Vt="span_panel_list_columns";function Wt(){try{const t=localStorage.getItem(Vt);if(!t)return 1;const e=parseInt(t,10);return 1===e||2===e||3===e?e:1}catch{return 1}}function Ut(t){try{localStorage.setItem(Vt,String(t))}catch{}}function Gt(t){return new Promise(e=>setTimeout(e,t))}class qt{constructor(t){this._store=t}async callWS(t,e,n){const i=n?.retries??3,r=n?.errorId??`ws:${String(e.type??"unknown")}`;return this._withRetry(()=>t.callWS(e),i,r,n?.errorMessage)}async callService(t,e,n,i,r,o){const a=o?.retries??3,s=o?.errorId??`svc:${e}.${n}`;return this._withRetry(()=>t.callService(e,n,i,r),a,s,o?.errorMessage)}async _withRetry(t,e,i,r){if(this._store.hasAnyPanelOffline())try{const e=await t();return this._store.remove(i),e}catch(t){const e=t instanceof Error?t:new Error(String(t));throw this._store.add({key:i,level:"error",message:r??n("error.panel_offline"),persistent:!1}),e}let o;for(let n=0;n<=e;n++)try{const e=await t();return this._store.remove(i),e}catch(t){if(o=t instanceof Error?t:new Error(String(t)),n{try{const e={type:"call_service",domain:s,service:"get_favorites",service_data:{},return_response:!0},r=this._retry?await this._retry.callWS(t,e,{errorId:"fetch:favorites",errorMessage:n("error.favorites_fetch_failed")}):await t.callWS(e),o=r?.response?.favorites??{};return i===this._generation&&(this._map=o,this._lastFetch=Date.now()),o}catch(t){return console.warn("SPAN Panel: favorites fetch failed",t),this._retry||this._errorStore?.add({key:"fetch:favorites",level:"warning",message:n("error.favorites_fetch_failed"),persistent:!1}),this._map??{}}finally{this._inflight?.gen===i&&(this._inflight=null)}})();return this._inflight={gen:i,promise:r},r}invalidate(){this._lastFetch=0,this._generation++}clear(){this._map=null,this._lastFetch=0,this._generation++}get map(){return this._map??{}}}function Zt(t){for(const e of Object.values(t)){if((e.circuits?.length??0)>0)return!0;if((e.sub_devices?.length??0)>0)return!0}return!1}const Kt=Object.keys(v).filter(t=>"unknown"!==t&&"always_on"!==t);class Qt extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(t){this._hass=t,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(t){this._config=t,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(t))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const t of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[t]);this._debounceTimers={}}_modeFor(t){return t.favoritesMode?"favorites":t.panelMode?"panel":t.subDeviceMode?"subDevice":"circuit"}_render(){const t=this._config;if(!t)return;const e=this.shadowRoot;if(!e)return;e.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart span-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',e.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),e.appendChild(i);const r=document.createElement("div");r.className="panel",e.appendChild(r),t.favoritesMode?this._renderFavoritesMode(r):t.panelMode?this._renderPanelMode(r):t.subDeviceMode?this._renderSubDeviceMode(r,t):this._renderCircuitMode(r,t)}_renderPanelMode(t){const e=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.global_defaults"));t.appendChild(i);const r=document.createElement("div");r.className="panel-body";const s=e.graphSettings,l=e.topology,c=s?.global_horizon??o,u=s?.circuits??{};r.appendChild(this._buildListColumnsSection());const h=document.createElement("div");h.className="section";const d=document.createElement("div");d.className="section-label",d.textContent=n("sidepanel.graph_horizon"),h.appendChild(d);const f=document.createElement("div");f.className="field-row";const g=document.createElement("span");g.className="field-label",g.textContent=n("sidepanel.global_default"),f.appendChild(g);const v=document.createElement("select");for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===c&&(e.selected=!0),v.appendChild(e)}if(v.addEventListener("change",()=>{const t={horizon:v.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_graph_time_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),f.appendChild(v),h.appendChild(f),r.appendChild(h),l?.circuits){const t=document.createElement("div");t.className="section";const i=document.createElement("div");i.className="section-label",i.textContent=n("sidepanel.circuit_scales"),t.appendChild(i);const o=Object.entries(l.circuits).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,i]of o){const r=this._buildPanelModeCircuitRow(n,i,u[n],c,e.configEntryId??null,e.showFavorites??!1,e.favoritePanelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}r.appendChild(t)}const m=s?.sub_devices??{};if(l?.sub_devices){const t=document.createElement("div");t.className="section";const i=document.createElement("div");i.className="section-label",i.textContent=n("sidepanel.subdevice_scales"),t.appendChild(i);const o=Object.entries(l.sub_devices).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[i,r]of o){const o=document.createElement("div");o.className="field-row";const s=document.createElement("span");if(s.className="field-label",s.textContent=r.name||i,s.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",o.appendChild(s),e.showFavorites&&e.favoritePanelDeviceId){const t=this._buildSubDeviceFavoriteHeart(r.entities,e.favoriteSubDeviceIds?.has(i)??!1);t&&o.appendChild(t)}const l=m[i]||{horizon:c,has_override:!1},u=l.has_override?l.horizon:c,h=document.createElement("select");h.dataset.subdevId=i;for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===u&&(e.selected=!0),h.appendChild(e)}if(h.addEventListener("change",()=>{this._debounce(`subdev-${i}`,p,()=>{const t={subdevice_id:i,horizon:h.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_subdevice_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})})}),o.appendChild(h),l.has_override){const t=document.createElement("button");t.textContent="↺",t.title=n("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const r={subdevice_id:i};e.configEntryId&&(r.config_entry_id=e.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",r).then(()=>{h.value=c,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),o.appendChild(t)}t.appendChild(o)}r.appendChild(t)}t.appendChild(r)}_buildPanelModeCircuitRow(t,e,i,r,o,s,l,c){const u=document.createElement("div");u.className="field-row";const h=document.createElement("span");if(h.className="field-label",h.textContent=e.name||t,h.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",u.appendChild(h),s&&l){const n=this._buildFavoriteHeart(e.entities,c?.has(t)??!1);n&&u.appendChild(n)}const d=i||{horizon:r,has_override:!1},f=d.has_override?d.horizon:r,g=document.createElement("select");g.dataset.uuid=t;for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===f&&(e.selected=!0),g.appendChild(e)}if(g.addEventListener("change",()=>{this._debounce(`circuit-${t}`,p,()=>{const e={circuit_id:t,horizon:g.value};o&&(e.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})})}),u.appendChild(g),d.has_override){const e=document.createElement("button");e.textContent="↺",e.title=n("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const i={circuit_id:t};o&&(i.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",i).then(()=>{g.value=r,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),u.appendChild(e)}return u}_renderFavoritesMode(t){const e=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.favorites_subtitle"));t.appendChild(i);const r=document.createElement("div");r.className="panel-body",r.appendChild(this._buildListColumnsSection());for(const t of e.perPanelSections)r.appendChild(this._buildFavoritesPanelSection(t));t.appendChild(r)}_buildFavoritesPanelSection(t){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=t.panelName,e.appendChild(n);const i=t.graphSettings?.global_horizon??o,r=t.graphSettings?.circuits??{},a=function(t){const e=t.circuits??{};return Object.entries(e).map(([t,e])=>({uuid:t,circuit:e})).sort((t,e)=>(t.circuit.name||"").localeCompare(e.circuit.name||""))}(t.topology);for(const{uuid:n,circuit:o}of a){const a=this._buildPanelModeCircuitRow(n,o,r[n],i,t.configEntryId,!0,t.panelDeviceId,t.favoriteCircuitUuids);e.appendChild(a)}return e}_renderCircuitMode(t,e){const n=`${Bt(String(e.breaker_rating_a))}A · ${Bt(String(e.voltage))}V · Tabs [${Bt(String(e.tabs))}]`,i=this._createHeader(Bt(e.name),n);t.appendChild(i);const r=document.createElement("div");r.className="panel-body",t.appendChild(r),this._renderRelaySection(r,e),e.showFavorites&&this._renderFavoriteSection(r,e),this._renderSheddingSection(r,e),this._renderGraphHorizonSection(r,e),e.showMonitoring&&this._renderMonitoringSection(r,e)}_favoriteEntityId(t){return t?.current??t?.power??null}_subDeviceFavoriteEntityId(t){if(!t)return null;let e=null;for(const[n,i]of Object.entries(t)){if("sensor"===i.domain)return n;e||(e=n)}return e}_buildSubDeviceFavoriteHeart(t,e){const n=this._subDeviceFavoriteEntityId(t);return n?this._buildHeartButton(n,e):null}_buildListColumnsSection(){const t=document.createElement("div");t.className="section";const e=document.createElement("div");e.className="section-label",e.textContent=n("sidepanel.list_view_columns"),t.appendChild(e);const i=document.createElement("div");i.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=n("sidepanel.columns"),i.appendChild(r);const o=Wt(),a=document.createElement("div");a.className="unit-toggle";for(const t of[1,2,3]){const e=document.createElement("button");e.type="button",e.className="unit-btn"+(t===o?" unit-active":""),e.dataset.columns=String(t),e.textContent=String(t),e.addEventListener("click",()=>{Ut(t);for(const t of a.querySelectorAll(".unit-btn"))t.classList.toggle("unit-active",t===e);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:t,bubbles:!0,composed:!0}))}),a.appendChild(e)}return i.appendChild(a),t.appendChild(i),t}_buildFavoriteHeart(t,e){const n=this._favoriteEntityId(t);return n?this._buildHeartButton(n,e):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(t,e){const i=document.createElement("button");i.type="button",i.className=e?"fav-heart active":"fav-heart",i.dataset.role="fav-heart",i.title=n("sidepanel.save_to_favorites"),i.setAttribute("role","switch"),i.setAttribute("aria-checked",String(e)),i.setAttribute("aria-label",n("sidepanel.save_to_favorites"));const r=document.createElement("span-icon");return r.setAttribute("icon",e?"mdi:heart":"mdi:heart-outline"),i.appendChild(r),i.addEventListener("click",e=>{e.stopPropagation(),this._toggleFavoriteEntity(i,r,t).catch(()=>{})}),i}async _toggleFavoriteEntity(t,e,i){if(!this._hass)return;const r=t.classList.contains("active"),o=!r;t.classList.toggle("active",o),e.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(o));try{o?await async function(t,e){const n=await Xt(t,"add_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(jt)),n?.favorites??{}}(this._hass,i):await async function(t,e){const n=await Xt(t,"remove_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(jt)),n?.favorites??{}}(this._hass,i)}catch(i){throw t.classList.toggle("active",r),e.setAttribute("icon",r?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(r)),console.warn("SPAN Panel: favorite toggle failed",i),this.errorStore?.add({key:"service:favorites",level:"error",message:n("error.favorites_toggle_failed"),persistent:!1}),i}}_renderFavoriteSection(t,e){const n=this._favoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_appendFavoriteHeartSection(t,e,i){const r=document.createElement("div");r.className="section",r.innerHTML=``;const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=n("sidepanel.save_to_favorites"),o.appendChild(a),o.appendChild(this._buildHeartButton(e,i)),r.appendChild(o),t.appendChild(r)}_renderSubDeviceMode(t,e){const n=this._createHeader(Bt(e.name),Bt(e.deviceType));t.appendChild(n);const i=document.createElement("div");i.className="panel-body",t.appendChild(i),e.showFavorites&&this._renderSubDeviceFavoriteSection(i,e),this._renderSubDeviceHorizonSection(i,e)}_renderSubDeviceFavoriteSection(t,e){const n=this._subDeviceFavoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_renderSubDeviceHorizonSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=n("sidepanel.graph_horizon"),i.appendChild(r);const s=e.graphHorizonInfo,l=!0===s?.has_override,c=s?.horizon||o,u=s?.globalHorizon||o,h=document.createElement("div");h.className="horizon-bar";const d=[{key:"global",label:n("sidepanel.global")}];for(const t of Object.keys(a))d.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of h.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:i}of d){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=i,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const i={subdevice_id:e.subDeviceId};e.configEntryId&&(i.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_subdevice_graph_horizon",i).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_subdevice_graph_horizon",{...i,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(r)}i.appendChild(h),t.appendChild(i)}_createHeader(t,e){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),r=Bt(t),o=Bt(e);i.innerHTML=`
${r}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(t,e){if(!1===e.is_user_controllable||!e.entities?.switch)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=n("sidepanel.breaker");const a=document.createElement("span-switch");a.dataset.role="relay-toggle";const s=e.entities.switch,l=this._hass?.states?.[s]?.state;"on"===l&&a.setAttribute("checked",""),a.addEventListener("change",()=>{const t=a.hasAttribute("checked")||a.checked;this._callService("switch",t?"turn_on":"turn_off",{entity_id:s}).catch(t=>{console.warn("SPAN Panel: relay toggle failed",t),this.errorStore?.add({key:"service:relay",level:"error",message:n("error.relay_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),i.appendChild(r),t.appendChild(i)}_renderSheddingSection(t,e){if(!e.entities?.select)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=n("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const s=e.entities.select,l=this._hass?.states?.[s]?.state||"";for(const t of Kt){const e=v[t];if(!e)continue;const i=document.createElement("option");i.value=t,i.textContent=n(`shedding.select.${t}`)||e.label(),t===l&&(i.selected=!0),a.appendChild(i)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:s,option:a.value}).catch(t=>{console.warn("SPAN Panel: shedding update failed",t),this.errorStore?.add({key:"service:shedding",level:"error",message:n("error.shedding_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),i.appendChild(r),t.appendChild(i)}_renderGraphHorizonSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=n("sidepanel.graph_horizon"),i.appendChild(r);const s=e.graphHorizonInfo,l=!0===s?.has_override,c=s?.horizon||o,u=s?.globalHorizon||o,h=document.createElement("div");h.className="horizon-bar";const d=[{key:"global",label:n("sidepanel.global")}];for(const t of Object.keys(a))d.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of h.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===u)}};for(const{key:t,label:i}of d){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=i,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===u),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const i={circuit_id:e.uuid};e.configEntryId&&(i.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_circuit_graph_horizon",i).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_circuit_graph_horizon",{...i,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})}))}),h.appendChild(r)}i.appendChild(h),t.appendChild(i)}_renderMonitoringSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=n("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("span-switch");a.dataset.role="monitoring-toggle";const s=e.monitoringInfo,l=null!=s&&!1!==s.monitoring_enabled;l&&a.setAttribute("checked",""),r.appendChild(o),r.appendChild(a),i.appendChild(r);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",i.appendChild(c);const u=!0===s?.has_override,h=document.createElement("div");h.className="radio-group",h.innerHTML=`\n \n \n `,c.appendChild(h);const d=document.createElement("div");d.dataset.role="threshold-fields",d.style.display=u?"block":"none";const p=s?.continuous_threshold_pct??80,f=s?.spike_threshold_pct??100,g=s?.window_duration_m??15,v=s?.cooldown_duration_m??15;d.appendChild(this._createThresholdRow(n("sidepanel.continuous_pct"),"continuous",p,e)),d.appendChild(this._createThresholdRow(n("sidepanel.spike_pct"),"spike",f,e)),d.appendChild(this._createDurationRow(n("sidepanel.window_duration"),"window-m",g,1,180,"m",e)),d.appendChild(this._createDurationRow(n("sidepanel.cooldown"),"cooldown-m",v,1,180,"m",e)),c.appendChild(d),a.addEventListener("change",()=>{const t=a.checked;c.style.display=t?"block":"none";const i={circuit_id:e.entities?.power||e.uuid,monitoring_enabled:t};e.configEntryId&&(i.config_entry_id=e.configEntryId),this._callDomainService("set_circuit_threshold",i).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})});const m=h.querySelectorAll('input[type="radio"]');for(const t of m)t.addEventListener("change",()=>{const i="custom"===t.value&&t.checked;if(d.style.display=i?"block":"none",!i&&t.checked){const t={circuit_id:e.entities?.power||e.uuid};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("clear_circuit_threshold",t).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})}});t.appendChild(i)}_createThresholdRow(t,e,i,r){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=t;const s=document.createElement("input");return s.type="number",s.min="0",s.max="200",s.value=String(i),s.dataset.role=`threshold-${e}`,s.addEventListener("input",()=>{this._debounce(`threshold-${e}`,p,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),i=t.querySelector('[data-role="threshold-spike"]'),o=t.querySelector('[data-role="threshold-window-m"]'),a=t.querySelector('[data-role="threshold-cooldown-m"]'),s={circuit_id:r.entities?.power||r.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0};r.configEntryId&&(s.config_entry_id=r.configEntryId),this._callDomainService("set_circuit_threshold",s).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})})}),o.appendChild(a),o.appendChild(s),o}_createDurationRow(t,e,i,r,o,a,s,l=!1){const c=document.createElement("div");c.className="field-row";const u=document.createElement("span");u.className="field-label",u.textContent=t;const h=document.createElement("div"),d=document.createElement("input");d.type="number",d.min=String(r),d.max=String(o),d.value=String(i),d.dataset.role=`threshold-${e}`,l&&(d.disabled=!0);const f=document.createElement("span");return f.textContent=a,h.appendChild(d),h.appendChild(f),l||d.addEventListener("input",()=>{this._debounce(`threshold-${e}`,p,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),i=t.querySelector('[data-role="threshold-spike"]'),r=t.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:s.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:r?Number(r.value):void 0};s.configEntryId&&(o.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})})}),c.appendChild(u),c.appendChild(h),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const t=this._config;if(!t.subDeviceMode&&!t.favoritesMode){if(t.entities?.switch){const e=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');if(e){const n=this._hass?.states?.[t.entities.switch]?.state;"on"===n?e.setAttribute("checked",""):e.removeAttribute("checked")}}if(t.entities?.select){const e=this.shadowRoot?.querySelector('[data-role="shedding-select"]');if(e){const n=this._hass?.states?.[t.entities.select]?.state||"";e.value=n}}}}_callService(t,e,n){return this._hass?Promise.resolve(this._hass.callService(t,e,n)):Promise.resolve()}_callDomainService(t,e){return this._hass?this._hass.callWS({type:"call_service",domain:s,service:t,service_data:e}):Promise.resolve()}_debounce(t,e,n){this._debounceTimers[t]&&clearTimeout(this._debounceTimers[t]),this._debounceTimers[t]=setTimeout(()=>{delete this._debounceTimers[t],n()},e)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",Qt)}catch{}class Jt extends Dt{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(t){if(this._store===t)return;this._unsub?.(),this._unsub=null,this._store=t,this._errors=t.active;const e=t;this._unsub=t.subscribe(()=>{this._errors=e.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const t=this._store;this._errors=t.active,this._unsub=t.subscribe(()=>{this._errors=t.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?pt:ut`${this._errors.map(t=>ut` + */let Rt=class extends $t{constructor(t){if(super(t),this.it=ft,t.type!==Ot)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(t){if(t===ft||null==t)return this._t=void 0,this.it=t;if(t===pt)return t;if("string"!=typeof t)throw Error(this.constructor.directiveName+"() called with a non-string value");if(t===this.it)return this._t;this.it=t;const e=[t];return e.raw=e,this._t={_$litType$:this.constructor.resultType,strings:e,values:[]}}};Rt.directiveName="unsafeHTML",Rt.resultType=1;const Ht=(t=>(...e)=>({_$litDirective$:t,values:e}))(Rt);class Ft{constructor(){this._persistent=new Map,this._transient=null,this._transientTimer=null,this._subscribers=new Set,this._watchedPanels=new Map}add(t){const e={...t,timestamp:Date.now()};if(e.persistent)this._persistent.set(e.key,e);else{this._clearTransient(),this._transient=e;const t=e.ttl??5e3;this._transientTimer=setTimeout(()=>{this._transient=null,this._transientTimer=null,this._notify()},t)}this._notify()}remove(t){if(this._persistent.has(t))return this._persistent.delete(t),void this._notify();this._transient?.key===t&&(this._clearTransient(),this._notify())}clear(t){void 0===t?(this._persistent.clear(),this._clearTransient(),this._watchedPanels.clear()):!0===t.persistent?this._persistent.clear():!1===t.persistent&&this._clearTransient(),this._notify()}get active(){const t=[...this._persistent.values()];return null!==this._transient&&t.push(this._transient),t}hasPersistent(t){return this._persistent.has(t)}hasAnyPanelOffline(){for(const t of this._persistent.keys())if("panel-offline"===t||t.startsWith("panel-offline:"))return!0;return!1}subscribe(t){return this._subscribers.add(t),()=>{this._subscribers.delete(t)}}watchPanelStatus(t){this.watchPanelStatuses([{entityId:t,panelName:null}])}watchPanelStatuses(t){const e=this._watchedPanels,n=new Map;for(const i of t){const t=e.get(i.entityId);n.set(i.entityId,{panelName:i.panelName??null,wasOffline:t?.wasOffline??!1})}const i=this._isSingleUnnamed(e),r=this._isSingleUnnamed(n);for(const t of e.keys()){n.has(t)&&i===r||this._persistent.delete(this._offlineKey(t,i))}this._watchedPanels=n,this._notify()}clearPanelStatusWatch(){if(0===this._watchedPanels.size)return;const t=this._isSingleUnnamed(this._watchedPanels);for(const e of this._watchedPanels.keys())this._persistent.delete(this._offlineKey(e,t));this._watchedPanels.clear(),this._notify()}updateHass(t){if(0===this._watchedPanels.size)return;const e=this._isSingleUnnamed(this._watchedPanels);for(const[r,o]of this._watchedPanels){const a=t.states[r]?.state,s="on"===a,l=this._offlineKey(r,e),c=this._reconnectKey(r,e);if(s){const t=o.wasOffline;o.wasOffline=!1,this.remove(l),t&&this.add({key:c,level:"info",message:null===o.panelName?n("error.panel_reconnected"):i("error.panel_reconnected_named",{name:o.panelName}),persistent:!1})}else o.wasOffline=!0,this.hasPersistent(l)||this.add({key:l,level:"error",message:null===o.panelName?n("error.panel_offline"):i("error.panel_offline_named",{name:o.panelName}),persistent:!0})}}dispose(){this._clearTransient(),this._persistent.clear(),this._subscribers.clear(),this._watchedPanels.clear()}_isSingleUnnamed(t){if(1!==t.size)return!1;for(const e of t.values())return null===e.panelName;return!1}_offlineKey(t,e){return e?"panel-offline":`panel-offline:${t}`}_reconnectKey(t,e){return e?"panel-reconnected":`panel-reconnected:${t}`}_clearTransient(){null!==this._transientTimer&&(clearTimeout(this._transientTimer),this._transientTimer=null),this._transient=null}_notify(){for(const t of this._subscribers)try{t()}catch(t){console.warn("SPAN Panel: error-store subscriber threw",t)}}}const Bt={"&":"&","<":"<",">":">",'"':""","'":"'"};function Vt(t){return String(t).replace(/[&<>"']/g,t=>Bt[t]??t)}const Wt="span_panel_list_columns";function Gt(){try{const t=localStorage.getItem(Wt);if(!t)return 1;const e=parseInt(t,10);return 1===e||2===e||3===e?e:1}catch{return 1}}function Ut(t){try{localStorage.setItem(Wt,String(t))}catch{}}function qt(t){return t instanceof Error||"object"==typeof t&&null!==t&&"message"in t&&"string"==typeof t.message?t.message:String(t)}function jt(t){return new Promise(e=>setTimeout(e,t))}class Xt{constructor(t){this._store=t}async callWS(t,e,n){const i=n?.retries??3,r=n?.errorId??`ws:${String(e.type??"unknown")}`;return this._withRetry(()=>t.callWS(e),i,r,n?.errorMessage)}async callService(t,e,n,i,r,o){const a=o?.retries??3,s=o?.errorId??`svc:${e}.${n}`;return this._withRetry(()=>t.callService(e,n,i,r),a,s,o?.errorMessage)}async _withRetry(t,e,i,r){if(this._store.hasAnyPanelOffline())try{const e=await t();return this._store.remove(i),e}catch(t){const e=t instanceof Error?t:new Error(qt(t));throw this._store.add({key:i,level:"error",message:r??n("error.panel_offline"),persistent:!1}),e}let o;for(let n=0;n<=e;n++)try{const e=await t();return this._store.remove(i),e}catch(t){if(o=t instanceof Error?t:new Error(qt(t)),n{try{const e={type:"call_service",domain:s,service:"get_favorites",service_data:{},return_response:!0},r=this._retry?await this._retry.callWS(t,e,{errorId:"fetch:favorites",errorMessage:n("error.favorites_fetch_failed")}):await t.callWS(e),o=r?.response?.favorites??{};return i===this._generation&&(this._map=o,this._lastFetch=Date.now()),o}catch(t){return console.warn("SPAN Panel: favorites fetch failed",t),this._retry||this._errorStore?.add({key:"fetch:favorites",level:"warning",message:n("error.favorites_fetch_failed"),persistent:!1}),this._map??{}}finally{this._inflight?.gen===i&&(this._inflight=null)}})();return this._inflight={gen:i,promise:r},r}invalidate(){this._lastFetch=0,this._generation++}clear(){this._map=null,this._lastFetch=0,this._generation++}get map(){return this._map??{}}}function Qt(t){return Object.keys(t).some(e=>Jt(t,e))}function Jt(t,e){const n=t[e];return(n?.circuits?.length??0)>0||(n?.sub_devices?.length??0)>0}const te={names:["power","battery power"],suffixes:["_power"]},ee={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},ne={names:["state of energy"],suffixes:["_soe_kwh"]};function ie(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function re(t){return ie(t,ee)}function oe(t){return ie(t,ne)}function ae(t){const e=t.solar;return e?"inverter"===e.role?{headlineEid:e.power_entity_id,headlineIsSiteTotal:!1,siteTotalEid:null}:e.power_entity_id?{headlineEid:e.power_entity_id,headlineIsSiteTotal:!1,siteTotalEid:e.site_power_entity_id}:{headlineEid:e.site_power_entity_id,headlineIsSiteTotal:null!==e.site_power_entity_id,siteTotalEid:null}:{headlineEid:(n=t,ie(n,te)),headlineIsSiteTotal:!1,siteTotalEid:null};var n}function se(t){const{headlineEid:e,siteTotalEid:n}=ae(t),i=[e,n];return t.type===c&&i.push(re(t),oe(t)),new Set(i.filter(t=>null!==t))}function le(t){const e=[{role:"power",entityId:ae(t).headlineEid}];return t.type===c&&e.push({role:"soc",entityId:re(t)},{role:"soe",entityId:oe(t)}),e.filter(t=>null!==t.entityId)}function ce(t,e){const n=t.states[e];return n&&parseFloat(n.state)||0}function de(t){if(!t)return null;let e=null;for(const[n,i]of Object.entries(t)){if("sensor"===i.domain)return n;e||(e=n)}return e}function ue(t,e){return le(t).length>0||e&&null!==de(t.entities)}class he{constructor(){this._status=null,this._lastFetch=0,this._inflight=null,this._generation=0,this._errorStore=null,this._retry=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new Xt(t):null}async fetch(t,e){const i=Date.now();if(this._inflight&&this._inflight.gen===this._generation)return this._inflight.promise;if(this._status&&i-this._lastFetch<3e4)return this._status;const r=this._generation,o=(async()=>{try{const i={};e&&(i.config_entry_id=e);const o={type:"call_service",domain:s,service:"get_monitoring_status",service_data:i,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await t.callWS(o),l=a?.response??null;return r===this._generation&&(this._status=l,this._lastFetch=Date.now()),l}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:n("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class pe{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new he,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function fe(t,e){return t?.circuits?t.circuits[e]??null:null}function ge(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}const ve=new Set(m),me={NEVER:"never",SOC_THRESHOLD:"soc_threshold",OFF_GRID:"off_grid"};function ye(t,e){const n=t.entities?.switch;return n?e.states[n]?.state:void 0}function _e(t,e,n){const i=t.entities?.power,r=i?e.states[i]?.attributes?.[n]:void 0;return"string"==typeof r&&r?r:void 0}function be(t,e){const n=ye(t,e);return"on"===n||"off"===n?"on"===n:"CLOSED"===(_e(t,e,"relay_state")??t.relay_state)}function xe(t,e){if(!t.entities?.switch||!1===t.is_user_controllable)return"none";const n=ye(t,e);return void 0===n?"none":"on"===n||"off"===n?"operable":"inert"}function we(t){return void 0===t?void 0:me[t]}function Se(t,e){const n=t.entities?.select;return n?e.states[n]?.state:void 0}function Ce(t,e){if(t.always_on)return"always_on";if(!t.entities?.select)return"unknown";const n=Se(t,e);return void 0!==n&&ve.has(n)?n:we(_e(t,e,"shed_priority"))??we(t.priority)??"unknown"}class ke extends HTMLElement{constructor(){super(),this.errorStore=null,this.attachShadow({mode:"open"}),this._hass=null,this._config=null,this._debounceTimers={}}set hass(t){this._hass=t,this.hasAttribute("open")&&this._config&&this._updateLiveState()}get hass(){return this._hass}disconnectedCallback(){this._clearDebounceTimers(),this._config=null}open(t){this._config=t,this._render(),this.offsetHeight,this.setAttribute("open",""),this.setAttribute("data-mode",this._modeFor(t))}close(){this._clearDebounceTimers(),this.removeAttribute("open"),this.removeAttribute("data-mode"),this._config=null,this.dispatchEvent(new CustomEvent("side-panel-closed",{bubbles:!0,composed:!0}))}_clearDebounceTimers(){for(const t of Object.keys(this._debounceTimers))clearTimeout(this._debounceTimers[t]);this._debounceTimers={}}_modeFor(t){return t.favoritesMode?"favorites":t.panelMode?"panel":t.subDeviceMode?"subDevice":"circuit"}_render(){const t=this._config;if(!t)return;const e=this.shadowRoot;if(!e)return;e.innerHTML="";const n=document.createElement("style");n.textContent='\n :host {\n display: block;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n width: 360px;\n max-width: 90vw;\n z-index: 1000;\n transform: translateX(100%);\n transition: transform 0.3s ease;\n pointer-events: none;\n }\n :host([open]) {\n transform: translateX(0);\n pointer-events: auto;\n }\n\n .backdrop {\n display: none;\n position: fixed;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: -1;\n }\n :host([open]) .backdrop {\n display: block;\n }\n\n .panel {\n height: 100%;\n background: var(--card-background-color, #fff);\n border-left: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n flex-direction: column;\n overflow: hidden;\n }\n\n .panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n }\n .panel-header .title {\n font-size: 18px;\n font-weight: 500;\n color: var(--primary-text-color, #212121);\n margin: 0;\n }\n .panel-header .subtitle {\n font-size: 13px;\n color: var(--secondary-text-color, #727272);\n margin: 2px 0 0 0;\n }\n .close-btn {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color, #727272);\n padding: 4px;\n line-height: 1;\n font-size: 20px;\n }\n\n .panel-body {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n .section {\n margin-bottom: 20px;\n }\n .section-label {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n color: var(--secondary-text-color, #727272);\n margin: 0 0 8px 0;\n letter-spacing: 0.5px;\n }\n\n .field-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 8px 0;\n }\n .field-label {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n }\n\n select {\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n }\n\n input[type="number"] {\n width: 72px;\n padding: 6px 8px;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 4px;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 14px;\n text-align: right;\n }\n input[type="number"]:disabled {\n opacity: 0.5;\n }\n\n .radio-group {\n display: flex;\n gap: 16px;\n padding: 8px 0;\n }\n .radio-group label {\n display: flex;\n align-items: center;\n gap: 6px;\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n cursor: pointer;\n }\n\n .horizon-bar {\n display: flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n margin-top: 4px;\n }\n .horizon-segment {\n flex: 1;\n padding: 6px 0;\n text-align: center;\n font-size: 13px;\n cursor: pointer;\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n transition: background 0.15s ease, color 0.15s ease;\n user-select: none;\n line-height: 1.4;\n }\n .horizon-segment:last-child {\n border-right: none;\n }\n .horizon-segment:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .horizon-segment.active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n .horizon-segment.referenced {\n box-shadow: inset 0 -3px 0 var(--primary-color, #03a9f4);\n }\n\n .unit-toggle {\n display: inline-flex;\n border: 1px solid var(--divider-color, #e0e0e0);\n border-radius: 6px;\n overflow: hidden;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n border-right: 1px solid var(--divider-color, #e0e0e0);\n background: var(--card-background-color, #fff);\n color: var(--primary-text-color, #212121);\n font-size: 13px;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s ease, color 0.15s ease;\n }\n .unit-btn:last-child {\n border-right: none;\n }\n .unit-btn:hover:not(.unit-active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #03a9f4);\n color: #fff;\n font-weight: 600;\n }\n\n .monitoring-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n }\n\n .fav-heart {\n background: none;\n border: 1px solid var(--divider-color, #e0e0e0);\n color: var(--secondary-text-color, #727272);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n font-size: 0.9em;\n margin-right: 6px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n }\n .fav-heart.active {\n color: var(--primary-color, #03a9f4);\n border-color: var(--primary-color, #03a9f4);\n }\n .fav-heart:hover:not(.active) {\n background: var(--secondary-background-color, #f5f5f5);\n }\n .fav-heart span-icon {\n --mdc-icon-size: 16px;\n }\n\n .panel-mode-info {\n font-size: 14px;\n color: var(--primary-text-color, #212121);\n line-height: 1.6;\n }\n .panel-mode-info p {\n margin: 0 0 12px 0;\n }\n\n',e.appendChild(n);const i=document.createElement("div");i.className="backdrop",i.addEventListener("click",()=>this.close()),e.appendChild(i);const r=document.createElement("div");r.className="panel",e.appendChild(r),t.favoritesMode?this._renderFavoritesMode(r):t.panelMode?this._renderPanelMode(r):t.subDeviceMode?this._renderSubDeviceMode(r,t):this._renderCircuitMode(r,t)}_renderPanelMode(t){const e=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.global_defaults"));t.appendChild(i);const r=document.createElement("div");r.className="panel-body";const s=e.graphSettings,l=e.topology,c=s?.global_horizon??o,d=s?.circuits??{};r.appendChild(this._buildListColumnsSection());const u=document.createElement("div");u.className="section";const h=document.createElement("div");h.className="section-label",h.textContent=n("sidepanel.graph_horizon"),u.appendChild(h);const f=document.createElement("div");f.className="field-row";const g=document.createElement("span");g.className="field-label",g.textContent=n("sidepanel.global_default"),f.appendChild(g);const v=document.createElement("select");for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===c&&(e.selected=!0),v.appendChild(e)}if(v.addEventListener("change",()=>{const t={horizon:v.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_graph_time_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),f.appendChild(v),u.appendChild(f),r.appendChild(u),l?.circuits){const t=document.createElement("div");t.className="section";const i=document.createElement("div");i.className="section-label",i.textContent=n("sidepanel.circuit_scales"),t.appendChild(i);const o=Object.entries(l.circuits).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[n,i]of o){const r=this._buildPanelModeCircuitRow(n,i,d[n],c,e.configEntryId??null,e.showFavorites??!1,e.favoritePanelDeviceId,e.favoriteCircuitUuids);t.appendChild(r)}r.appendChild(t)}const m=s?.sub_devices??{};if(l?.sub_devices){const t=document.createElement("div");t.className="section";const i=document.createElement("div");i.className="section-label",i.textContent=n("sidepanel.subdevice_scales"),t.appendChild(i);const o=Object.entries(l.sub_devices).sort(([,t],[,e])=>(t.name||"").localeCompare(e.name||""));for(const[i,r]of o){const o=le(r).length>0,s=e.showFavorites&&e.favoritePanelDeviceId?this._buildSubDeviceFavoriteHeart(r.entities,e.favoriteSubDeviceIds?.has(i)??!1):null;if(!o&&!s)continue;const l=document.createElement("div");l.className="field-row";const d=document.createElement("span");if(d.className="field-label",d.textContent=r.name||i,d.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",l.appendChild(d),s&&l.appendChild(s),!o){t.appendChild(l);continue}const u=m[i]||{horizon:c,has_override:!1},h=u.has_override?u.horizon:c,f=document.createElement("select");f.dataset.subdevId=i;for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===h&&(e.selected=!0),f.appendChild(e)}if(f.addEventListener("change",()=>{this._debounce(`subdev-${i}`,p,()=>{const t={subdevice_id:i,horizon:f.value};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("set_subdevice_graph_horizon",t).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})})}),l.appendChild(f),u.has_override){const t=document.createElement("button");t.textContent="↺",t.title=n("sidepanel.reset_to_global"),Object.assign(t.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),t.addEventListener("click",()=>{const r={subdevice_id:i};e.configEntryId&&(r.config_entry_id=e.configEntryId),this._callDomainService("clear_subdevice_graph_horizon",r).then(()=>{f.value=c,t.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),l.appendChild(t)}t.appendChild(l)}t.children.length>1&&r.appendChild(t)}t.appendChild(r)}_buildPanelModeCircuitRow(t,e,i,r,o,s,l,c){const d=document.createElement("div");d.className="field-row";const u=document.createElement("span");if(u.className="field-label",u.textContent=e.name||t,u.style.cssText="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;",d.appendChild(u),s&&l){const n=this._buildFavoriteHeart(e.entities,c?.has(t)??!1);n&&d.appendChild(n)}const h=i||{horizon:r,has_override:!1},f=h.has_override?h.horizon:r,g=document.createElement("select");g.dataset.uuid=t;for(const t of Object.keys(a)){const e=document.createElement("option");e.value=t;const i=`horizon.${t}`,r=n(i);e.textContent=r!==i?r:t,t===f&&(e.selected=!0),g.appendChild(e)}if(g.addEventListener("change",()=>{this._debounce(`circuit-${t}`,p,()=>{const e={circuit_id:t,horizon:g.value};o&&(e.config_entry_id=o),this._callDomainService("set_circuit_graph_horizon",e).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})})}),d.appendChild(g),h.has_override){const e=document.createElement("button");e.textContent="↺",e.title=n("sidepanel.reset_to_global"),Object.assign(e.style,{background:"none",border:"1px solid var(--divider-color, #e0e0e0)",color:"var(--primary-text-color)",borderRadius:"4px",padding:"3px 6px",cursor:"pointer",marginLeft:"4px",fontSize:"0.85em"}),e.addEventListener("click",()=>{const i={circuit_id:t};o&&(i.config_entry_id=o),this._callDomainService("clear_circuit_graph_horizon",i).then(()=>{g.value=r,e.remove(),this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})}),d.appendChild(e)}return d}_renderFavoritesMode(t){const e=this._config,i=this._createHeader(n("sidepanel.graph_settings"),n("sidepanel.favorites_subtitle"));t.appendChild(i);const r=document.createElement("div");r.className="panel-body",r.appendChild(this._buildListColumnsSection());for(const t of e.perPanelSections)r.appendChild(this._buildFavoritesPanelSection(t));t.appendChild(r)}_buildFavoritesPanelSection(t){const e=document.createElement("div");e.className="section";const n=document.createElement("div");n.className="section-label",n.textContent=t.panelName,e.appendChild(n);const i=t.graphSettings?.global_horizon??o,r=t.graphSettings?.circuits??{},a=function(t){const e=t.circuits??{};return Object.entries(e).map(([t,e])=>({uuid:t,circuit:e})).sort((t,e)=>(t.circuit.name||"").localeCompare(e.circuit.name||""))}(t.topology);for(const{uuid:n,circuit:o}of a){const a=this._buildPanelModeCircuitRow(n,o,r[n],i,t.configEntryId,!0,t.panelDeviceId,t.favoriteCircuitUuids);e.appendChild(a)}return e}_renderCircuitMode(t,e){const n=`${Vt(String(e.breaker_rating_a))}A · ${Vt(String(e.voltage))}V · Tabs [${Vt(String(e.tabs))}]`,i=this._createHeader(Vt(e.name),n);t.appendChild(i);const r=document.createElement("div");r.className="panel-body",t.appendChild(r),this._renderRelaySection(r,e),e.showFavorites&&this._renderFavoriteSection(r,e),this._renderSheddingSection(r,e),this._renderGraphHorizonSection(r,e),e.showMonitoring&&this._renderMonitoringSection(r,e)}_favoriteEntityId(t){return t?.current??t?.power??null}_buildSubDeviceFavoriteHeart(t,e){const n=de(t);return n?this._buildHeartButton(n,e):null}_buildListColumnsSection(){const t=document.createElement("div");t.className="section";const e=document.createElement("div");e.className="section-label",e.textContent=n("sidepanel.list_view_columns"),t.appendChild(e);const i=document.createElement("div");i.className="field-row";const r=document.createElement("span");r.className="field-label",r.textContent=n("sidepanel.columns"),i.appendChild(r);const o=Gt(),a=document.createElement("div");a.className="unit-toggle";for(const t of[1,2,3]){const e=document.createElement("button");e.type="button",e.className="unit-btn"+(t===o?" unit-active":""),e.dataset.columns=String(t),e.textContent=String(t),e.addEventListener("click",()=>{Ut(t);for(const t of a.querySelectorAll(".unit-btn"))t.classList.toggle("unit-active",t===e);this.dispatchEvent(new CustomEvent("list-columns-changed",{detail:t,bubbles:!0,composed:!0}))}),a.appendChild(e)}return i.appendChild(a),t.appendChild(i),t}_buildFavoriteHeart(t,e){const n=this._favoriteEntityId(t);return n?this._buildHeartButton(n,e):(console.warn("SPAN Panel: circuit has no current/power sensor; favorite heart suppressed"),null)}_buildHeartButton(t,e){const i=document.createElement("button");i.type="button",i.className=e?"fav-heart active":"fav-heart",i.dataset.role="fav-heart",i.title=n("sidepanel.save_to_favorites"),i.setAttribute("role","switch"),i.setAttribute("aria-checked",String(e)),i.setAttribute("aria-label",n("sidepanel.save_to_favorites"));const r=document.createElement("span-icon");return r.setAttribute("icon",e?"mdi:heart":"mdi:heart-outline"),i.appendChild(r),i.addEventListener("click",e=>{e.stopPropagation(),this._toggleFavoriteEntity(i,r,t).catch(()=>{})}),i}async _toggleFavoriteEntity(t,e,i){if(!this._hass)return;const r=t.classList.contains("active"),o=!r;t.classList.toggle("active",o),e.setAttribute("icon",o?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(o));try{o?await async function(t,e){const n=await Zt(t,"add_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(Yt)),n?.favorites??{}}(this._hass,i):await async function(t,e){const n=await Zt(t,"remove_favorite",{entity_id:e});return document.dispatchEvent(new CustomEvent(Yt)),n?.favorites??{}}(this._hass,i)}catch(i){throw t.classList.toggle("active",r),e.setAttribute("icon",r?"mdi:heart":"mdi:heart-outline"),t.setAttribute("aria-checked",String(r)),console.warn("SPAN Panel: favorite toggle failed",i),this.errorStore?.add({key:"service:favorites",level:"error",message:n("error.favorites_toggle_failed"),persistent:!1}),i}}_renderFavoriteSection(t,e){const n=this._favoriteEntityId(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_appendFavoriteHeartSection(t,e,i){const r=document.createElement("div");r.className="section",r.innerHTML=``;const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=n("sidepanel.save_to_favorites"),o.appendChild(a),o.appendChild(this._buildHeartButton(e,i)),r.appendChild(o),t.appendChild(r)}_renderSubDeviceMode(t,e){const n=this._createHeader(Vt(e.name),Vt(e.deviceType));t.appendChild(n);const i=document.createElement("div");i.className="panel-body",t.appendChild(i),e.showFavorites&&this._renderSubDeviceFavoriteSection(i,e),e.hasChart&&this._renderSubDeviceHorizonSection(i,e)}_renderSubDeviceFavoriteSection(t,e){const n=de(e.entities);n&&this._appendFavoriteHeartSection(t,n,!0===e.isFavorite)}_renderSubDeviceHorizonSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=n("sidepanel.graph_horizon"),i.appendChild(r);const s=e.graphHorizonInfo,l=!0===s?.has_override,c=s?.horizon||o,d=s?.globalHorizon||o,u=document.createElement("div");u.className="horizon-bar";const h=[{key:"global",label:n("sidepanel.global")}];for(const t of Object.keys(a))h.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of u.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===d)}};for(const{key:t,label:i}of h){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=i,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===d),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const i={subdevice_id:e.subDeviceId};e.configEntryId&&(i.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_subdevice_graph_horizon",i).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_subdevice_graph_horizon",{...i,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})}))}),u.appendChild(r)}i.appendChild(u),t.appendChild(i)}_createHeader(t,e){const n=document.createElement("div");n.className="panel-header";const i=document.createElement("div"),r=Vt(t),o=Vt(e);i.innerHTML=`
${r}
`+(o?`
${o}
`:"");const a=document.createElement("button");return a.className="close-btn",a.innerHTML="✕",a.addEventListener("click",()=>this.close()),n.appendChild(i),n.appendChild(a),n}_renderRelaySection(t,e){if(!this._hass||"none"===xe(e,this._hass))return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=n("sidepanel.breaker");const a=document.createElement("span-switch");a.dataset.role="relay-toggle";const s=e.entities.switch;this._applyRelayToggle(a,e),a.addEventListener("change",()=>{if(!this._hass||"operable"!==xe(e,this._hass))return;const t=a.checked;this._callService("switch",t?"turn_on":"turn_off",{entity_id:s}).catch(t=>{console.warn("SPAN Panel: relay toggle failed",t),this.errorStore?.add({key:"service:relay",level:"error",message:n("error.relay_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),i.appendChild(r),t.appendChild(i)}_renderSheddingSection(t,e){if(!e.entities?.select)return;const i=document.createElement("div");i.className="section",i.innerHTML=``;const r=document.createElement("div");r.className="field-row";const o=document.createElement("span");o.className="field-label",o.textContent=n("sidepanel.priority_label");const a=document.createElement("select");a.dataset.role="shedding-select";const s=e.entities.select;for(const t of m){const e=v[t];if(!e)continue;const i=document.createElement("option");i.value=t,i.textContent=n(`shedding.select.${t}`)||e.label(),a.appendChild(i)}a.addEventListener("change",()=>{this._callService("select","select_option",{entity_id:s,option:a.value}).catch(t=>{console.warn("SPAN Panel: shedding update failed",t),this.errorStore?.add({key:"service:shedding",level:"error",message:n("error.shedding_failed"),persistent:!1})})}),r.appendChild(o),r.appendChild(a),i.appendChild(r),t.appendChild(i),this._applySheddingSelect(a,e)}_renderGraphHorizonSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="section-label",r.textContent=n("sidepanel.graph_horizon"),i.appendChild(r);const s=e.graphHorizonInfo,l=!0===s?.has_override,c=s?.horizon||o,d=s?.globalHorizon||o,u=document.createElement("div");u.className="horizon-bar";const h=[{key:"global",label:n("sidepanel.global")}];for(const t of Object.keys(a))h.push({key:t,label:t});const p=l?c:"global",f=t=>{for(const e of u.querySelectorAll(".horizon-segment")){const n=e.dataset.horizon;e.classList.toggle("active",n===t),e.classList.toggle("referenced","global"===t&&n===d)}};for(const{key:t,label:i}of h){const r=document.createElement("button");r.type="button",r.className="horizon-segment",r.dataset.horizon=t,r.textContent=i,r.classList.toggle("active",t===p),r.classList.toggle("referenced","global"===p&&t===d),r.addEventListener("click",()=>{if(r.classList.contains("active"))return;const i={circuit_id:e.uuid};e.configEntryId&&(i.config_entry_id=e.configEntryId),"global"===t?(f("global"),this._callDomainService("clear_circuit_graph_horizon",i).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})})):(f(t),this._callDomainService("set_circuit_graph_horizon",{...i,horizon:t}).then(()=>{this.dispatchEvent(new CustomEvent("graph-settings-changed",{bubbles:!0,composed:!0}))}).catch(t=>{console.warn("SPAN Panel: graph horizon service failed",t),this.errorStore?.add({key:"service:graph_horizon",level:"error",message:n("error.graph_horizon_failed"),persistent:!1})}))}),u.appendChild(r)}i.appendChild(u),t.appendChild(i)}_renderMonitoringSection(t,e){const i=document.createElement("div");i.className="section";const r=document.createElement("div");r.className="monitoring-header";const o=document.createElement("div");o.className="section-label",o.textContent=n("sidepanel.monitoring"),o.style.margin="0";const a=document.createElement("span-switch");a.dataset.role="monitoring-toggle";const s=e.monitoringInfo,l=null!=s&&!1!==s.monitoring_enabled;l&&a.setAttribute("checked",""),r.appendChild(o),r.appendChild(a),i.appendChild(r);const c=document.createElement("div");c.dataset.role="monitoring-details",c.style.display=l?"block":"none",i.appendChild(c);const d=!0===s?.has_override,u=document.createElement("div");u.className="radio-group",u.innerHTML=`\n \n \n `,c.appendChild(u);const h=document.createElement("div");h.dataset.role="threshold-fields",h.style.display=d?"block":"none";const p=s?.continuous_threshold_pct??80,f=s?.spike_threshold_pct??100,g=s?.window_duration_m??15,v=s?.cooldown_duration_m??15;h.appendChild(this._createThresholdRow(n("sidepanel.continuous_pct"),"continuous",p,e)),h.appendChild(this._createThresholdRow(n("sidepanel.spike_pct"),"spike",f,e)),h.appendChild(this._createDurationRow(n("sidepanel.window_duration"),"window-m",g,1,180,"m",e)),h.appendChild(this._createDurationRow(n("sidepanel.cooldown"),"cooldown-m",v,1,180,"m",e)),c.appendChild(h),a.addEventListener("change",()=>{const t=a.checked;c.style.display=t?"block":"none";const i={circuit_id:e.entities?.power||e.uuid,monitoring_enabled:t};e.configEntryId&&(i.config_entry_id=e.configEntryId),this._callDomainService("set_circuit_threshold",i).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})});const m=u.querySelectorAll('input[type="radio"]');for(const t of m)t.addEventListener("change",()=>{const i="custom"===t.value&&t.checked;if(h.style.display=i?"block":"none",!i&&t.checked){const t={circuit_id:e.entities?.power||e.uuid};e.configEntryId&&(t.config_entry_id=e.configEntryId),this._callDomainService("clear_circuit_threshold",t).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})}});t.appendChild(i)}_createThresholdRow(t,e,i,r){const o=document.createElement("div");o.className="field-row";const a=document.createElement("span");a.className="field-label",a.textContent=t;const s=document.createElement("input");return s.type="number",s.min="0",s.max="200",s.value=String(i),s.dataset.role=`threshold-${e}`,s.addEventListener("input",()=>{this._debounce(`threshold-${e}`,p,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),i=t.querySelector('[data-role="threshold-spike"]'),o=t.querySelector('[data-role="threshold-window-m"]'),a=t.querySelector('[data-role="threshold-cooldown-m"]'),s={circuit_id:r.entities?.power||r.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:o?Number(o.value):void 0,cooldown_duration_m:a?Number(a.value):void 0};r.configEntryId&&(s.config_entry_id=r.configEntryId),this._callDomainService("set_circuit_threshold",s).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})})}),o.appendChild(a),o.appendChild(s),o}_createDurationRow(t,e,i,r,o,a,s,l=!1){const c=document.createElement("div");c.className="field-row";const d=document.createElement("span");d.className="field-label",d.textContent=t;const u=document.createElement("div"),h=document.createElement("input");h.type="number",h.min=String(r),h.max=String(o),h.value=String(i),h.dataset.role=`threshold-${e}`,l&&(h.disabled=!0);const f=document.createElement("span");return f.textContent=a,u.appendChild(h),u.appendChild(f),l||h.addEventListener("input",()=>{this._debounce(`threshold-${e}`,p,()=>{const t=this.shadowRoot;if(!t)return;const e=t.querySelector('[data-role="threshold-continuous"]'),i=t.querySelector('[data-role="threshold-spike"]'),r=t.querySelector('[data-role="threshold-window-m"]'),o={circuit_id:s.uuid,continuous_threshold_pct:e?Number(e.value):void 0,spike_threshold_pct:i?Number(i.value):void 0,window_duration_m:r?Number(r.value):void 0};s.configEntryId&&(o.config_entry_id=s.configEntryId),this._callDomainService("set_circuit_threshold",o).catch(t=>{console.warn("SPAN Panel: monitoring update failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})})}),c.appendChild(d),c.appendChild(u),c}_updateLiveState(){if(!this._config||this._config.panelMode)return;const t=this._config;if(!t.subDeviceMode&&!t.favoritesMode){if(t.entities?.switch){const e=this.shadowRoot?.querySelector('[data-role="relay-toggle"]');e&&this._applyRelayToggle(e,t)}if(t.entities?.select){const e=this.shadowRoot?.querySelector('[data-role="shedding-select"]');e&&this._applySheddingSelect(e,t)}}}_applyRelayToggle(t,e){this._hass&&(t.disabled="operable"!==xe(e,this._hass),be(e,this._hass)?t.setAttribute("checked",""):t.removeAttribute("checked"))}_applySheddingSelect(t,e){if(!this._hass)return;const n=Ce(e,this._hass);m.includes(n)?t.value=n:t.selectedIndex=-1,t.disabled=!function(t,e){const n=Se(t,e);return void 0!==n&&ve.has(n)}(e,this._hass)}_callService(t,e,n){return this._hass?Promise.resolve(this._hass.callService(t,e,n)):Promise.resolve()}_callDomainService(t,e){return this._hass?this._hass.callWS({type:"call_service",domain:s,service:t,service_data:e}):Promise.resolve()}_debounce(t,e,n){this._debounceTimers[t]&&clearTimeout(this._debounceTimers[t]),this._debounceTimers[t]=setTimeout(()=>{delete this._debounceTimers[t],n()},e)}}try{customElements.get("span-side-panel")||customElements.define("span-side-panel",ke)}catch{}class Te extends Dt{constructor(){super(...arguments),this._store=null,this._unsub=null,this._errors=[]}set store(t){if(this._store===t)return;this._unsub?.(),this._unsub=null,this._store=t,this._errors=t.active;const e=t;this._unsub=t.subscribe(()=>{this._errors=e.active})}connectedCallback(){if(super.connectedCallback(),this._store&&!this._unsub){const t=this._store;this._errors=t.active,this._unsub=t.subscribe(()=>{this._errors=t.active})}}disconnectedCallback(){super.disconnectedCallback(),this._unsub?.(),this._unsub=null}render(){return 0===this._errors.length?ft:ut`${this._errors.map(t=>ut` - `)}`}_iconForLevel(t){switch(t){case"error":return"mdi:alert-circle";case"warning":return"mdi:alert";default:return"mdi:information"}}}Jt.styles=M` + `)}`}_iconForLevel(t){switch(t){case"error":return"mdi:alert-circle";case"warning":return"mdi:alert";default:return"mdi:information"}}}Te.styles=M` :host { display: block; } @@ -102,7 +102,7 @@ const Pt={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:F},Lt=(t=Pt .retry-btn:hover { opacity: 0.8; } - `,_([zt()],Jt.prototype,"_errors",void 0);try{customElements.get("span-error-banner")||customElements.define("span-error-banner",Jt)}catch{}var te="M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z";const ee=Object.freeze({"mdi:alert":"M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z","mdi:alert-circle":"M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:battery":"M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0 0,0 18,20.67V5.33C18,4.6 17.4,4 16.67,4Z","mdi:battery-alert-variant-outline":"M14 20H6V6H14M14.67 4H13V2H7V4H5.33C4.6 4 4 4.6 4 5.33V20.67C4 21.4 4.6 22 5.33 22H14.67C15.4 22 16 21.4 16 20.67V5.33C16 4.6 15.4 4 14.67 4M21 7H19V13H21V8M21 15H19V17H21V15Z","mdi:chevron-down":"M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z","mdi:close":"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z","mdi:cog":"M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z","mdi:heart":"M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z","mdi:heart-outline":"M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z","mdi:help":"M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z","mdi:help-circle-outline":"M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z","mdi:home-group":"M17,16H15V22H12V17H8V22H5V16H3L10,10L17,16M6,2L10,6H9V9H7V6H5V9H3V6H2L6,2M18,3L23,8H22V12H19V9H17V12H15.34L14,10.87V8H13L18,3Z","mdi:information":"M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:lock":"M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z","mdi:lock-open":"M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10A2,2 0 0,1 6,8H15V6A3,3 0 0,0 12,3A3,3 0 0,0 9,6H7A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,17A2,2 0 0,0 14,15A2,2 0 0,0 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17Z","mdi:menu":te,"mdi:monitor-eye":"M3 4V16H21V4H3M3 2H21C22.1 2 23 2.89 23 4V16C23 16.53 22.79 17.04 22.41 17.41C22.04 17.79 21.53 18 21 18H14V20H16V22H8V20H10V18H3C2.47 18 1.96 17.79 1.59 17.41C1.21 17.04 1 16.53 1 16V4C1 2.89 1.89 2 3 2M10.84 8.93C11.15 8.63 11.57 8.45 12 8.45C12.43 8.46 12.85 8.63 13.16 8.94C13.46 9.24 13.64 9.66 13.64 10.09C13.64 10.53 13.46 10.94 13.16 11.25C12.85 11.56 12.43 11.73 12 11.73C11.57 11.73 11.15 11.55 10.84 11.25C10.54 10.94 10.36 10.53 10.36 10.09C10.36 9.66 10.54 9.24 10.84 8.93M10.07 12C10.58 12.53 11.28 12.82 12 12.82C12.72 12.82 13.42 12.53 13.93 12C14.44 11.5 14.73 10.81 14.73 10.09C14.73 9.37 14.44 8.67 13.93 8.16C13.42 7.65 12.72 7.36 12 7.36C11.28 7.36 10.58 7.65 10.07 8.16C9.56 8.67 9.27 9.37 9.27 10.09C9.27 10.81 9.56 11.5 10.07 12M6 10.09C6.94 7.7 9.27 6 12 6C14.73 6 17.06 7.7 18 10.09C17.06 12.5 14.73 14.18 12 14.18C9.27 14.18 6.94 12.5 6 10.09Z","mdi:router-wireless":"M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z","mdi:sort-descending":"M19 7H22L18 3L14 7H17V21H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z","mdi:transmission-tower":"M8.28,5.45L6.5,4.55L7.76,2H16.23L17.5,4.55L15.72,5.44L15,4H9L8.28,5.45M18.62,8H14.09L13.3,5H10.7L9.91,8H5.38L4.1,10.55L5.89,11.44L6.62,10H17.38L18.1,11.45L19.89,10.56L18.62,8M17.77,22H15.7L15.46,21.1L12,15.9L8.53,21.1L8.3,22H6.23L9.12,11H11.19L10.83,12.35L12,14.1L13.16,12.35L12.81,11H14.88L17.77,22M11.4,15L10.5,13.65L9.32,18.13L11.4,15M14.68,18.12L13.5,13.64L12.6,15L14.68,18.12Z","mdi:view-dashboard":"M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z"}),ne=new Set;class ie extends Dt{constructor(){super(...arguments),this.icon=""}render(){if(!this.icon)return pt;const t=ee[this.icon];return t?ut``:(e=this.icon,ne.has(e)||(ne.add(e),console.warn(`SPAN: unknown icon "${e}". Add it to MDI_PATHS in span-icon.ts.`)),pt);var e}}ie.styles=M` + `,b([Nt()],Te.prototype,"_errors",void 0);try{customElements.get("span-error-banner")||customElements.define("span-error-banner",Te)}catch{}var Me="M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z";const Ie=Object.freeze({"mdi:alert":"M13 14H11V9H13M13 18H11V16H13M1 21H23L12 2L1 21Z","mdi:alert-circle":"M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:battery":"M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0 0,0 18,20.67V5.33C18,4.6 17.4,4 16.67,4Z","mdi:battery-alert-variant-outline":"M14 20H6V6H14M14.67 4H13V2H7V4H5.33C4.6 4 4 4.6 4 5.33V20.67C4 21.4 4.6 22 5.33 22H14.67C15.4 22 16 21.4 16 20.67V5.33C16 4.6 15.4 4 14.67 4M21 7H19V13H21V8M21 15H19V17H21V15Z","mdi:chevron-down":"M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z","mdi:close":"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z","mdi:cog":"M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z","mdi:heart":"M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z","mdi:heart-outline":"M12.1,18.55L12,18.65L11.89,18.55C7.14,14.24 4,11.39 4,8.5C4,6.5 5.5,5 7.5,5C9.04,5 10.54,6 11.07,7.36H12.93C13.46,6 14.96,5 16.5,5C18.5,5 20,6.5 20,8.5C20,11.39 16.86,14.24 12.1,18.55M16.5,3C14.76,3 13.09,3.81 12,5.08C10.91,3.81 9.24,3 7.5,3C4.42,3 2,5.41 2,8.5C2,12.27 5.4,15.36 10.55,20.03L12,21.35L13.45,20.03C18.6,15.36 22,12.27 22,8.5C22,5.41 19.58,3 16.5,3Z","mdi:help":"M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z","mdi:help-circle-outline":"M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z","mdi:home-group":"M17,16H15V22H12V17H8V22H5V16H3L10,10L17,16M6,2L10,6H9V9H7V6H5V9H3V6H2L6,2M18,3L23,8H22V12H19V9H17V12H15.34L14,10.87V8H13L18,3Z","mdi:information":"M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z","mdi:lock":"M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z","mdi:lock-open":"M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10A2,2 0 0,1 6,8H15V6A3,3 0 0,0 12,3A3,3 0 0,0 9,6H7A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,17A2,2 0 0,0 14,15A2,2 0 0,0 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17Z","mdi:menu":Me,"mdi:monitor-eye":"M3 4V16H21V4H3M3 2H21C22.1 2 23 2.89 23 4V16C23 16.53 22.79 17.04 22.41 17.41C22.04 17.79 21.53 18 21 18H14V20H16V22H8V20H10V18H3C2.47 18 1.96 17.79 1.59 17.41C1.21 17.04 1 16.53 1 16V4C1 2.89 1.89 2 3 2M10.84 8.93C11.15 8.63 11.57 8.45 12 8.45C12.43 8.46 12.85 8.63 13.16 8.94C13.46 9.24 13.64 9.66 13.64 10.09C13.64 10.53 13.46 10.94 13.16 11.25C12.85 11.56 12.43 11.73 12 11.73C11.57 11.73 11.15 11.55 10.84 11.25C10.54 10.94 10.36 10.53 10.36 10.09C10.36 9.66 10.54 9.24 10.84 8.93M10.07 12C10.58 12.53 11.28 12.82 12 12.82C12.72 12.82 13.42 12.53 13.93 12C14.44 11.5 14.73 10.81 14.73 10.09C14.73 9.37 14.44 8.67 13.93 8.16C13.42 7.65 12.72 7.36 12 7.36C11.28 7.36 10.58 7.65 10.07 8.16C9.56 8.67 9.27 9.37 9.27 10.09C9.27 10.81 9.56 11.5 10.07 12M6 10.09C6.94 7.7 9.27 6 12 6C14.73 6 17.06 7.7 18 10.09C17.06 12.5 14.73 14.18 12 14.18C9.27 14.18 6.94 12.5 6 10.09Z","mdi:router-wireless":"M20.2,5.9L21,5.1C19.6,3.7 17.8,3 16,3C14.2,3 12.4,3.7 11,5.1L11.8,5.9C13,4.8 14.5,4.2 16,4.2C17.5,4.2 19,4.8 20.2,5.9M19.3,6.7C18.4,5.8 17.2,5.3 16,5.3C14.8,5.3 13.6,5.8 12.7,6.7L13.5,7.5C14.2,6.8 15.1,6.5 16,6.5C16.9,6.5 17.8,6.8 18.5,7.5L19.3,6.7M19,13H17V9H15V13H5A2,2 0 0,0 3,15V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V15A2,2 0 0,0 19,13M8,18H6V16H8V18M11.5,18H9.5V16H11.5V18M15,18H13V16H15V18Z","mdi:sort-descending":"M19 7H22L18 3L14 7H17V21H19M2 17H12V19H2M6 5V7H2V5M2 11H9V13H2V11Z","mdi:transmission-tower":"M8.28,5.45L6.5,4.55L7.76,2H16.23L17.5,4.55L15.72,5.44L15,4H9L8.28,5.45M18.62,8H14.09L13.3,5H10.7L9.91,8H5.38L4.1,10.55L5.89,11.44L6.62,10H17.38L18.1,11.45L19.89,10.56L18.62,8M17.77,22H15.7L15.46,21.1L12,15.9L8.53,21.1L8.3,22H6.23L9.12,11H11.19L10.83,12.35L12,14.1L13.16,12.35L12.81,11H14.88L17.77,22M11.4,15L10.5,13.65L9.32,18.13L11.4,15M14.68,18.12L13.5,13.64L12.6,15L14.68,18.12Z","mdi:view-dashboard":"M13,3V9H21V3M13,21H21V11H13M3,21H11V15H3M3,13H11V3H3V13Z"}),Ae=new Set;class De extends Dt{constructor(){super(...arguments),this.icon=""}render(){if(!this.icon)return ft;const t=Ie[this.icon];return t?ut``:(e=this.icon,Ae.has(e)||(Ae.add(e),console.warn(`SPAN: unknown icon "${e}". Add it to MDI_PATHS in span-icon.ts.`)),ft);var e}}De.styles=M` :host { display: inline-flex; align-items: center; @@ -119,11 +119,11 @@ const Pt={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:F},Lt=(t=Pt display: block; fill: currentColor; } - `,_([Et({type:String})],ie.prototype,"icon",void 0);try{customElements.get("span-icon")||customElements.define("span-icon",ie)}catch{}class re extends Dt{constructor(){super(...arguments),this.checked=!1,this.disabled=!1,this._onActivate=t=>{if(this.disabled)return t.preventDefault(),void t.stopPropagation();this.checked=!this.checked,this.dispatchEvent(new Event("change",{bubbles:!0,composed:!0}))},this._onKeydown=t=>{" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this._onActivate(t))}}connectedCallback(){super.connectedCallback(),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0"),this.hasAttribute("role")||this.setAttribute("role","switch"),this.setAttribute("aria-checked",String(this.checked)),this.addEventListener("click",this._onActivate),this.addEventListener("keydown",this._onKeydown)}disconnectedCallback(){this.removeEventListener("click",this._onActivate),this.removeEventListener("keydown",this._onKeydown),super.disconnectedCallback()}updated(t){t.has("checked")&&this.setAttribute("aria-checked",String(this.checked)),t.has("disabled")&&this.setAttribute("aria-disabled",String(this.disabled))}render(){return ut` + `,b([zt({type:String})],De.prototype,"icon",void 0);try{customElements.get("span-icon")||customElements.define("span-icon",De)}catch{}class Pe extends Dt{constructor(){super(...arguments),this.checked=!1,this.disabled=!1,this._onActivate=t=>{if(this.disabled)return t.preventDefault(),void t.stopPropagation();this.checked=!this.checked,this.dispatchEvent(new Event("change",{bubbles:!0,composed:!0}))},this._onKeydown=t=>{" "!==t.key&&"Enter"!==t.key||(t.preventDefault(),this._onActivate(t))}}connectedCallback(){super.connectedCallback(),this.hasAttribute("tabindex")||this.setAttribute("tabindex","0"),this.hasAttribute("role")||this.setAttribute("role","switch"),this.setAttribute("aria-checked",String(this.checked)),this.addEventListener("click",this._onActivate),this.addEventListener("keydown",this._onKeydown)}disconnectedCallback(){this.removeEventListener("click",this._onActivate),this.removeEventListener("keydown",this._onKeydown),super.disconnectedCallback()}updated(t){t.has("checked")&&this.setAttribute("aria-checked",String(this.checked)),t.has("disabled")&&this.setAttribute("aria-disabled",String(this.disabled))}render(){return ut`
- `}}re.styles=M` + `}}Pe.styles=M` :host { --span-switch-on: var(--switch-checked-color, var(--primary-color, #4dd9af)); --span-switch-off: var(--switch-unchecked-track-color, rgba(120, 120, 120, 0.45)); @@ -176,11 +176,11 @@ const Pt={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:F},Lt=(t=Pt outline: 2px solid var(--span-switch-on); outline-offset: 2px; } - `,_([Et({type:Boolean,reflect:!0})],re.prototype,"checked",void 0),_([Et({type:Boolean,reflect:!0})],re.prototype,"disabled",void 0);try{customElements.get("span-switch")||customElements.define("span-switch",re)}catch{}class oe extends Dt{constructor(){super(...arguments),this.narrow=!1,this._toggle=()=>{this.dispatchEvent(new CustomEvent("hass-toggle-menu",{bubbles:!0,composed:!0}))}}render(){return ut` + `,b([zt({type:Boolean,reflect:!0})],Pe.prototype,"checked",void 0),b([zt({type:Boolean,reflect:!0})],Pe.prototype,"disabled",void 0);try{customElements.get("span-switch")||customElements.define("span-switch",Pe)}catch{}class Le extends Dt{constructor(){super(...arguments),this.narrow=!1,this._toggle=()=>{this.dispatchEvent(new CustomEvent("hass-toggle-menu",{bubbles:!0,composed:!0}))}}render(){return ut` - `}}oe.styles=M` + `}}Le.styles=M` :host { display: none; align-items: center; @@ -214,7 +214,7 @@ const Pt={attribute:!0,type:String,converter:H,reflect:!1,hasChanged:F},Lt=(t=Pt height: 24px; fill: currentColor; } - `,_([Et({type:Boolean,reflect:!0})],oe.prototype,"narrow",void 0);try{customElements.get("span-menu-button")||customElements.define("span-menu-button",oe)}catch{}async function ae(t,e){const[n,i,r]=await Promise.all([t.callWS({type:"config/area_registry/list"}),t.callWS({type:"config/entity_registry/list"}),t.callWS({type:"config/device_registry/list"})]),o=new Map;for(const t of n)o.set(t.area_id,t.name);const a=new Map;for(const t of i)t.area_id&&a.set(t.entity_id,t.area_id);const s=new Map;for(const t of r)s.set(t.id,t.area_id);let l;if(e.device_id){const t=s.get(e.device_id);t&&(l=o.get(t))}for(const t of Object.values(e.circuits)){let e;for(const n of Object.values(t.entities)){if(!n)continue;const t=a.get(n);if(t){e=o.get(t);break}}e||(e=l),t.area=e}}async function se(t,e,i){if(!e)throw new Error(n("card.device_not_found"));const r={type:`${s}/panel_topology`,device_id:e},o=i?await i.callWS(t,r,{errorId:"fetch:topology"}):await t.callWS(r),a=o.panel_size??function(t){let e=0;for(const n of Object.values(t))if(n)for(const t of n.tabs)t>e&&(e=t);return e>0?e+e%2:0}(o.circuits);if(!a)throw new Error(n("card.topology_error"));const l={type:"config/device_registry/list"},c=i?await i.callWS(t,l,{errorId:"fetch:topology"}):await t.callWS(l),u=(h=c.find(t=>t.id===e),h?{id:h.id,name:h.name,name_by_user:h.name_by_user,config_entries:h.config_entries,identifiers:h.identifiers,via_device_id:h.via_device_id,sw_version:h.sw_version,model:h.model}:null);var h;return await ae(t,o),{topology:o,panelDevice:u,panelSize:a}}function le(){return`
\n ${Object.entries(v).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Bt(t.icon),n=Bt(t.color),i=Bt(t.label());let r;if(t.icon2){r=``}else if(t.textLabel){r=`${Bt(t.textLabel)}`}else r=``;return`
${r}${i}
`}).join("")}\n
`}function ce(t,e,i){const r="current"===(e.chart_metric||"power"),o=!!t.panel_entities?.site_power,a=!!t.panel_entities?.dsm_state,s=!!t.panel_entities?.current_power,l=!!t.panel_entities?.feedthrough_power,c=!!t.panel_entities?.pv_power,u=!!t.panel_entities?.battery_level;return`\n
\n ${o?`\n
\n ${n("header.site")}\n
\n 0\n ${r?"A":"kW"}\n
\n
`:""}\n ${a?`\n
\n ${n("header.grid")}\n
\n --\n
\n
`:""}\n ${s?`\n
\n ${n("header.upstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${n("header.downstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${n("header.solar")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${u?`\n
\n ${n("header.battery")}\n
\n —\n %\n
\n
`:""}\n
\n `}function ue(t,e,i={}){const r=Bt(t.device_name||n("header.default_name")),o=Bt(t.serial||""),a=Bt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==i.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Bt(n("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${ce(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n ${le()}\n
\n
\n `}const he=f.power;function de(t){return he.unit(t)}function pe(t){return(t<0?"-":"")+he.format(t)}function fe(t){return(Math.abs(t)/1e3).toFixed(1)}function ge(t){return Math.ceil(t/2)}function ve(t){return t%2==0?1:0}function me(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return ge(e)===ge(n)?"row-span":ve(e)===ve(n)?"col-span":"row-span"}function ye(t){const e=t.chart_metric??r;return f[e]??f[r]}function _e(t,e){const n=function(t){return ye(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}class be{constructor(){this._status=null,this._lastFetch=0,this._inflight=null,this._generation=0,this._errorStore=null,this._retry=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new qt(t):null}async fetch(t,e){const i=Date.now();if(this._inflight&&this._inflight.gen===this._generation)return this._inflight.promise;if(this._status&&i-this._lastFetch<3e4)return this._status;const r=this._generation,o=(async()=>{try{const i={};e&&(i.config_entry_id=e);const o={type:"call_service",domain:s,service:"get_monitoring_status",service_data:i,return_response:!0},a=this._retry?await this._retry.callWS(t,o,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await t.callWS(o),l=a?.response??null;return r===this._generation&&(this._status=l,this._lastFetch=Date.now()),l}catch(t){return console.warn("SPAN Panel: monitoring status fetch failed",t),r===this._generation&&(this._status=null),this._retry||this._errorStore?.add({key:"fetch:monitoring",level:"warning",message:n("error.monitoring_failed"),persistent:!1}),null}finally{this._inflight?.gen===r&&(this._inflight=null)}})();return this._inflight={gen:r,promise:o},o}invalidate(){this._lastFetch=0,this._generation++}get status(){return this._status}clear(){this._status=null,this._lastFetch=0,this._generation++}}class xe{constructor(){this._caches=new Map,this._errorStore=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t;for(const e of this._caches.values())e.errorStore=t}async fetchOne(t,e){let n=this._caches.get(e);return n||(n=new be,n.errorStore=this._errorStore,this._caches.set(e,n)),n.fetch(t,e)}invalidate(){for(const t of this._caches.values())t.invalidate()}clear(){this._caches.clear()}}function we(t,e){return t?.circuits?t.circuits[e]??null:null}function Se(t,e,n,i){const r=[];return n||r.push("circuit-off"),i&&r.push("circuit-producer"),function(t){return!!t&&null!=t.over_threshold_since}(e)&&r.push("circuit-alert"),r.join(" ")}function Ce(t,e,i,r,o,a,s,u,h,d=!1){const p=e.entities?.power,f=p?a.states[p]:null,g=f&&parseFloat(f.state)||0,m=e.device_type===c||g<0,y=e.entities?.switch,_=y?a.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||e.relay_state)===l,x=e.breaker_rating_a,w=x?`${Math.round(x)}A`:"",S=Bt(e.name||n("grid.unknown")),C=ye(s);let k;if("current"===C.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;k=`${C.format(i)}A`}else k=`${pe(g)}${de(g)}`;const M=h||"unknown";let T="";if("unknown"!==M){const t=v[M]??v.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"},e=Bt(t.label()),n=Bt(t.icon),i=Bt(t.color);if(t.icon2){T=`\n \n \n `}else if(t.textLabel){T=`\n \n ${Bt(t.textLabel)}\n `}else T=``}const I=``;let D="",A=u?.utilization_pct??null;if(null==A&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;A=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=A){D=`=80?"utilization-warning":"utilization-normal"}">${Math.round(A)}%`}return`\n
\n
\n
\n ${w?`${w}`:""}\n ${D}\n ${S}\n
\n
\n \n ${k}\n \n ${!1!==e.is_user_controllable&&e.entities?.switch?`\n
\n ${n(b?"grid.on":"grid.off")}\n \n
\n `:""}\n
\n
\n
\n ${T}\n ${I}\n
\n
\n
\n `}function ke(t,e){return`\n
\n —\n
\n `}const Me={names:["power","battery power"],suffixes:["_power"]},Te={names:["battery level","battery percentage"],suffixes:["_battery_level","_battery_percentage"]},Ie={names:["state of energy"],suffixes:["_soe_kwh"]},De={names:["nameplate capacity"],suffixes:["_nameplate_capacity"]};function Ae(t,e){if(!t.entities)return null;for(const[n,i]of Object.entries(t.entities)){if("sensor"!==i.domain)continue;const t=(i.original_name??"").toLowerCase();if(e.names.some(e=>t===e))return n;if(i.unique_id&&e.suffixes.some(t=>i.unique_id.endsWith(t)))return n}return null}function Pe(t){return Ae(t,Me)}function Le(t){return Ae(t,Te)}function Ee(t){return Ae(t,Ie)}function ze(t){return Ae(t,De)}function Ne(t,e,i){const r=!1!==i.show_battery,o=!1!==i.show_evse;if(!t.sub_devices)return"";const a=Object.entries(t.sub_devices).filter(([,t])=>!(t.type===u&&!r)&&!(t.type===h&&!o));if(0===a.length)return"";const s=a.filter(([,t])=>t.type===h).length;let l=0,c="";for(const[t,r]of a){const o=r.type===h?n("subdevice.ev_charger"):r.type===u?n("subdevice.battery"):n("subdevice.fallback"),a=Pe(r),d=a?e.states[a]:void 0,p=d&&parseFloat(d.state)||0,f=r.type===u,g=r.type===h,v=f?Le(r):null,m=f?Ee(r):null,y=f?ze(r):null,_=Oe(r,e,i,new Set([a,v,m,y].filter(t=>null!==t))),b=Re(t,r,f,a,v,m);let x="";f?x="sub-device-bess":g&&(l++,l===s&&s%2==1&&(x="sub-device-full")),c+=`\n
\n
\n ${Bt(o)}\n ${Bt(r.name||"")}\n ${a?`${pe(p)} ${de(p)}`:""}\n \n
\n ${b}\n ${_}\n
\n `}return c}function Oe(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let u;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)u=e.formatEntityState(s);else{u=s.state;const t=s.attributes.unit_of_measurement||"";t&&(u+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(u=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Bt(l)}:\n ${Bt(u)}\n
\n `}return o}function Re(t,e,i,r,o,a){if(i){const e=[{key:`${d}${t}_soc`,title:n("subdevice.soc"),available:!!o},{key:`${d}${t}_soe`,title:n("subdevice.soe"),available:!!a},{key:`${d}${t}_power`,title:n("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Bt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function $e(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function He(t){const e=a[t];return e?e.ms:a[o].ms}function Fe(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function Be(t){return Math.max(500,Math.floor(t/5e3))}function Ve(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function We(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}async function Ue(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];e.sort((t,e)=>t.time-e.time),r.set(i,e)}}}async function Ge(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=Fe(i),l=Be(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(o.length>0){const t=r.get(i)||[],e=[...o,...t];r.set(i,We(e,s,l))}}}function qe(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices)){const t={power:Pe(i)};i.type===u&&(t.soc=Le(i),t.soe=Ee(i));for(const[i,r]of Object.entries(t))r&&e.push({entityId:r,key:`${d}${n}_${i}`,devId:n})}return e}async function je(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=_e(i,n);if(!e)continue;let o;o=r&&r.has(t)?He(r.get(t)):$e(n),a.has(o)||a.set(o,{entityIds:[],uuidByEntity:new Map});const s=a.get(o);s.entityIds.push(e),s.uuidByEntity.set(e,t)}for(const{entityId:t,key:i,devId:r}of qe(e)){let e;e=o&&o.has(r)?He(o.get(r)):$e(n),a.has(e)||a.set(e,{entityIds:[],uuidByEntity:new Map});const s=a.get(e);s.entityIds.push(t),s.uuidByEntity.set(t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(Ue(t,n.entityIds,n.uuidByEntity,e,i)):s.push(Ge(t,n.entityIds,n.uuidByEntity,e,i))}await Promise.all(s)}var Xe=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},Ye=new function(){this.browser=new Xe,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(Ye.wxa=!0,Ye.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?Ye.worker=!0:!Ye.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(Ye.node=!0,Ye.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,Ye);var Ze="sans-serif",Ke="12px "+Ze;var Qe,Je,tn=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){Cn(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var u=t[c].getBoundingClientRect(),h=2*c,d=u.left,p=u.top;a.push(d,p),l=l&&o&&d===o[h]&&p===o[h+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?Si(s,a):Si(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function Ti(t){return"CANVAS"===t.nodeName.toUpperCase()}var Ii=/([&<>"'])/g,Di={"&":"&","<":"<",">":">",'"':""","'":"'"};function Ai(t){return null==t?"":(t+"").replace(Ii,function(t,e){return Di[e]})}var Pi=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Li=[],Ei=Ye.browser.firefox&&+Ye.browser.version.split(".")[0]<39;function zi(t,e,n,i){return n=n||{},i?Ni(t,e,n):Ei&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):Ni(t,e,n),n}function Ni(t,e,n){if(Ye.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(Ti(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(Mi(Li,t,i,r))return n.zrX=Li[0],void(n.zrY=Li[1])}n.zrX=n.zrY=0}function Oi(t){return t||window.event}function Ri(t,e,n){if(null!=(e=Oi(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&zi(t,r,e,n)}else{zi(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&Pi.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function $i(t,e,n,i){t.removeEventListener(e,n,i)}var Hi=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},Fi=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=Bi(r)/Bi(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function Wi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function Ui(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Gi(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function qi(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function ji(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),h=Math.cos(n);return t[0]=r*h+s*u,t[1]=-r*u+s*h,t[2]=o*h+l*u,t[3]=-o*u+h*l,t[4]=h*(a-i[0])+u*(c-i[1])+i[0],t[5]=h*(c-i[1])-u*(a-i[0])+i[1],t}function Xi(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Yi=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Zi=Math.min,Ki=Math.max,Qi=Math.abs,Ji=["x","y"],tr=["width","height"],er=new Yi,nr=new Yi,ir=new Yi,rr=new Yi,or=pr(),ar=or.minTv,sr=or.maxTv,lr=[0,0],cr=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Zi(t.x,this.x),n=Zi(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Ki(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Ki(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return qi(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),qi(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Yi.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(ur,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(hr,n.x,n.y,n.width,n.height));var s=!!i;or.reset(r,s);var l=or.touchThreshold,c=e.x+l,u=e.x+e.width-l,h=e.y+l,d=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||h>d||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}er.x=ir.x=n.x,er.y=rr.y=n.y,nr.x=rr.x=n.x+n.width,nr.y=ir.y=n.y+n.height,er.transform(i),rr.transform(i),nr.transform(i),ir.transform(i),e.x=Zi(er.x,nr.x,ir.x,rr.x),e.y=Zi(er.y,nr.y,ir.y,rr.y);var l=Ki(er.x,nr.x,ir.x,rr.x),c=Ki(er.y,nr.y,ir.y,rr.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),ur=new cr(0,0,0,0),hr=new cr(0,0,0,0);function dr(t,e,n,i,r,o,a,s){var l=Qi(e-n),c=Qi(i-t),u=Zi(l,c),h=Ji[r],d=Ji[1-r],p=tr[r];e=c||!or.bidirectional)&&(ar[h]=-c,ar[d]=0,or.useDir&&or.calcDirMTV())))}function pr(){var t=0,e=new Yi,n=new Yi,i={minTv:new Yi,maxTv:new Yi,useDir:!1,dirMinTv:new Yi,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Ki(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(_r.copy(u.getBoundingRect()),u.transform&&_r.applyTransform(u.transform),_r.intersect(l)&&o.push(u))}if(o.length)for(var h=Math.PI/12,d=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=xr(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==fr)){e.target=a;break}}}function Sr(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}Cn(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){br.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=Sr(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||pi(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function Cr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function kr(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function Mr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function Tr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function Ir(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],h=i[s+1];i[s]=c+h,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var d=Tr(t[u],t,l,c,0,e);l+=d,0!==(c-=d)&&0!==(h=Mr(t[l+c-1],t,u,h,h-1,e))&&(c<=h?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[d+l];return void(t[h]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[h--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[h--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[d+l];if(0===i){m=!0;break}}if(t[h--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-Mr(t[c],a,0,s,s-1,e))){for(s-=v,p=(h-=v)+1,d=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(h-=i)+1,d=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[d+l];t[h]=a[u]}else{if(0===s)throw new Error;for(d=h-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=Cr(t,n,i,e))s&&(l=s),kr(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var Ar=!1;function Pr(){Ar||(Ar=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function Lr(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var Er,zr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=Lr}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();Er=Ye.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var Nr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-Nr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*Nr.bounceIn(2*t):.5*Nr.bounceOut(2*t-1)+.5}},Or=Math.pow,Rr=Math.sqrt,$r=1e-8,Hr=1e-4,Fr=Rr(3),Br=1/3,Vr=ai(),Wr=ai(),Ur=ai();function Gr(t){return t>-1e-8&&t<$r}function qr(t){return t>$r||t<-1e-8}function jr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function Xr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Yr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,h=s*l-9*a*c,d=l*l-3*s*c,p=0;if(Gr(u)&&Gr(h)){if(Gr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=h*h-4*u*d;if(Gr(f)){var g=h/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=Rr(f),y=u*s+1.5*a*(-h+m),_=u*s+1.5*a*(-h-m);(C=(-s-((y=y<0?-Or(-y,Br):Or(y,Br))+(_=_<0?-Or(-_,Br):Or(_,Br))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var b=(2*u*s-3*a*h)/(2*Rr(u*u*u)),x=Math.acos(b)/3,w=Rr(u),S=Math.cos(x),C=(-s-2*w*S)/(3*a),k=(v=(-s+w*(S+Fr*Math.sin(x)))/(3*a),(-s+w*(S-Fr*Math.sin(x)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),k>=0&&k<=1&&(o[p++]=k)}}return p}function Zr(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Gr(a)){if(qr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Gr(c))r[0]=-o/(2*a);else if(c>0){var u,h=Rr(c),d=(-o-h)/(2*a);(u=(-o+h)/(2*a))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}function Kr(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,h=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=h,o[4]=h,o[5]=u,o[6]=l,o[7]=i}function Qr(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,h=0,d=1/l,p=1;p<=l;p++){var f=p*d,g=jr(t,n,r,a,f),v=jr(e,i,o,s,f),m=g-c,y=v-u;h+=Math.sqrt(m*m+y*y),c=g,u=v}return h}function Jr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function to(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function eo(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function no(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function io(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,h=1;h<=a;h++){var d=h*u,p=Jr(t,n,r,d),f=Jr(e,i,o,d),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var ro=/cubic-bezier\(([0-9,\.e ]+)\)/;function oo(t){var e=t&&ro.exec(t);if(e){var n=e[1].split(","),i=+Xn(n[0]),r=+Xn(n[1]),o=+Xn(n[2]),a=+Xn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Yr(0,i,o,1,t,s)&&jr(0,r,a,1,s[0])}}}var ao=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||ri,this.ondestroy=t.ondestroy||ri,this.onrestart=t.onrestart||ri,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=Ln(t)?t:Nr[t]||oo(t)},t}(),so=function(t){this.value=t},lo=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new so(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),co=function(){function t(t){this._list=new lo,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new so(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),uo={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function ho(t){return(t=Math.round(t))<0?0:t>255?255:t}function po(t){return t<0?0:t>1?1:t}function fo(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?ho(parseFloat(e)/100*255):ho(parseInt(e,10))}function go(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?po(parseFloat(e)/100):po(parseFloat(e))}function vo(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function mo(t,e,n){return t+(e-t)*n}function yo(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function _o(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var bo=new co(20),xo=null;function wo(t,e){xo&&_o(xo,e),xo=bo.put(t,xo||e.slice())}function So(t,e){if(t){e=e||[];var n=bo.get(t);if(n)return _o(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in uo)return _o(e,uo[i]),wo(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(yo(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),wo(t,e),e):void yo(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(yo(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),wo(t,e),e):void yo(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?yo(e,+c[0],+c[1],+c[2],1):yo(e,0,0,0,1);u=go(c.pop());case"rgb":return c.length>=3?(yo(e,fo(c[0]),fo(c[1]),fo(c[2]),3===c.length?u:go(c[3])),wo(t,e),e):void yo(e,0,0,0,1);case"hsla":return 4!==c.length?void yo(e,0,0,0,1):(c[3]=go(c[3]),Co(c,e),wo(t,e),e);case"hsl":return 3!==c.length?void yo(e,0,0,0,1):(Co(c,e),wo(t,e),e);default:return}}yo(e,0,0,0,1)}}function Co(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=go(t[1]),r=go(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return yo(e=e||[],ho(255*vo(a,o,n+1/3)),ho(255*vo(a,o,n)),ho(255*vo(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function ko(t,e){var n=So(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return To(n,4===n.length?"rgba":"rgb")}}function Mo(t,e,n,i){var r=So(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,h=((s-r)/6+l/2)/l,d=((s-o)/6+l/2)/l;i===s?e=d-h:r===s?e=1/3+u-d:o===s&&(e=2/3+h-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=go(Ln(n)?n(r[1]):n)),null!=i&&(r[2]=go(Ln(i)?i(r[2]):i)),To(Co(r),"rgba")}function To(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function Io(t,e){var n=So(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var Do=new co(100);function Ao(t){if(En(t)){var e=Do.get(t);return e||(e=ko(t,-.1),Do.put(t,e)),e}if(Fn(t)){var n=_n({},t);return n.colorStops=kn(t.colorStops,function(t){return{offset:t.offset,color:ko(t.color,-.1)}}),n}return t}var Po=Math.round;function Lo(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=So(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var Eo=1e-4;function zo(t){return t-1e-4}function No(t){return Po(1e3*t)/1e3}function Oo(t){return Po(1e4*t)/1e4}var Ro={left:"start",right:"end",center:"middle",middle:"middle"};function $o(t){return t&&!!t.image}function Ho(t){return $o(t)||function(t){return t&&!!t.svgElement}(t)}function Fo(t){return"linear"===t.type}function Bo(t){return"radial"===t.type}function Vo(t){return t&&("linear"===t.type||"radial"===t.type)}function Wo(t){return"url(#"+t+")"}function Uo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function Go(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*oi,r=Wn(t.scaleX,1),o=Wn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+Po(a*oi)+"deg, "+Po(s*oi)+"deg)"),l.join(" ")}var qo=Ye.hasGlobalWindow&&Ln(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},jo=Array.prototype.slice;function Xo(t,e,n){return(e-t)*n+t}function Yo(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(Sn(e)){var l=function(t){return Sn(t&&t[0])?2:1}(e);a=l,(1===l&&!Nn(e[0])||2===l&&!Nn(e[0][0]))&&(o=!0)}else if(Nn(e)&&!Bn(e))a=0;else if(En(e))if(isNaN(+e)){var c=So(e);c&&(s=c,a=3)}else a=0;else if(Fn(e)){var u=_n({},s);u.colorStops=kn(e.colorStops,function(t){return{offset:t.offset,color:So(t.color)}}),Fo(e)?a=4:Bo(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var h={time:t,value:s,rawValue:e,percent:0};return n&&(h.easing=n,h.easingFunc=Ln(n)?n:Nr[n]||oo(n)),i.push(h),h},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=ia(i),l=na(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=d;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:h?ra:t[u];if(!ia(s)&&!h||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(ia(s))1===s?Yo(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,ta(l),i),this._trackKeys.push(a)}s.addKeyframe(t,ta(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function sa(){return(new Date).getTime()}var la,ca,ua=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return y(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=sa()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,Er(function e(){t._running&&(Er(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=sa(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=sa(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=sa()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new aa(t,e.loop);return this.addAnimator(n),n},e}(bi),ha=Ye.domSupported,da=(ca={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:la=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:kn(la,function(t){var e=t.replace("mouse","pointer");return ca.hasOwnProperty(e)?e:t})}),pa=["mousemove","mouseup"],fa=["pointermove","pointerup"],ga=!1;function va(t){var e=t.pointerType;return"pen"===e||"touch"===e}function ma(t){t&&(t.zrByTouch=!0)}function ya(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var _a=function(t,e){this.stopPropagation=ri,this.stopImmediatePropagation=ri,this.preventDefault=ri,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},ba={mousedown:function(t){t=Ri(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=Ri(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=Ri(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){ya(this,(t=Ri(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){ga=!0,t=Ri(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){ga||(t=Ri(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){ma(t=Ri(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),ba.mousemove.call(this,t),ba.mousedown.call(this,t)},touchmove:function(t){ma(t=Ri(this.dom,t)),this.handler.processGesture(t,"change"),ba.mousemove.call(this,t)},touchend:function(t){ma(t=Ri(this.dom,t)),this.handler.processGesture(t,"end"),ba.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&ba.click.call(this,t)},pointerdown:function(t){ba.mousedown.call(this,t)},pointermove:function(t){va(t)||ba.mousemove.call(this,t)},pointerup:function(t){ba.mouseup.call(this,t)},pointerout:function(t){va(t)||ba.mouseout.call(this,t)}};Cn(["click","dblclick","contextmenu"],function(t){ba[t]=function(e){e=Ri(this.dom,e),this.trigger(t,e)}});var xa={pointermove:function(t){va(t)||xa.mousemove.call(this,t)},pointerup:function(t){xa.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function wa(t,e){var n=e.domHandlers;Ye.pointerEventsSupported?Cn(da.pointer,function(i){Ca(e,i,function(e){n[i].call(t,e)})}):(Ye.touchEventsSupported&&Cn(da.touch,function(i){Ca(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),Cn(da.mouse,function(i){Ca(e,i,function(r){r=Oi(r),e.touching||n[i].call(t,r)})}))}function Sa(t,e){function n(n){Ca(e,n,function(i){i=Oi(i),ya(t,i.target)||(i=function(t,e){return Ri(t.dom,new _a(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Ye.pointerEventsSupported?Cn(fa,n):Ye.touchEventsSupported||Cn(pa,n)}function Ca(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ka(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&$i(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var Ma=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},Ta=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new Ma(e,ba),ha&&(i._globalHandlerScope=new Ma(document,xa)),wa(i,i._localHandlerScope),i}return y(e,t),e.prototype.dispose=function(){ka(this._localHandlerScope),ha&&ka(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,ha&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?Sa(this,e):ka(e)}},e}(bi),Ia=1;Ye.hasGlobalWindow&&(Ia=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var Da=Ia,Aa="#333",Pa="#ccc",La=Wi,Ea=5e-5;function za(t){return t>Ea||t<-5e-5}var Na,Oa=[],Ra=[],$a=[1,0,0,1,0,0],Ha=Math.abs,Fa=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return za(this.rotation)||za(this.x)||za(this.y)||za(this.scaleX-1)||za(this.scaleY-1)||za(this.skewX)||za(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):La(n),t&&(e?Gi(n,t,n):Ui(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(La(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(Oa);var n=Oa[0]<0?-1:1,i=Oa[1]<0?-1:1,r=((Oa[0]-n)*e+n)/Oa[0]||0,o=((Oa[1]-i)*e+i)/Oa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],Xi(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Gi(Ra,t.invTransform,e),e=Ra);var n=this.originX,i=this.originY;(n||i)&&($a[4]=n,$a[5]=i,Gi(Ra,e,$a),Ra[4]-=n,Ra[5]-=i,e=Ra),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&gi(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&gi(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&Ha(t[0]-1)>1e-10&&Ha(t[3]-1)>1e-10?Math.sqrt(Ha(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){Va(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,h=t.skewX?Math.tan(t.skewX):0,d=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-h*f*o,e[5]=-f*o-d*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=d*r,e[2]=h*o,l&&ji(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),Ba=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function Va(t,e){for(var n=0;n=Ga)){t=t||Ke;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=en.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?Ua=Ga:r>2&&Ua++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function ja(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=en.measureText(e,t.font).width,n.put(e,i)),i}function Xa(t,e,n,i){var r=ja(Wa(e),t),o=Qa(e),a=Za(0,r,n),s=Ka(0,o,i);return new cr(a,s,r,o)}function Ya(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return Xa(r[0],e,n,i);for(var o=new cr(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function ts(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",h="top";if(i instanceof Array)l+=Ja(i[0],n.width),c+=Ja(i[1],n.height),u=null,h=null;else switch(i){case"left":l-=r,c+=s,u="right",h="middle";break;case"right":l+=r+a,c+=s,h="middle";break;case"top":l+=a/2,c-=r,u="center",h="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",h="middle";break;case"insideLeft":l+=r,c+=s,h="middle";break;case"insideRight":l+=a-r,c+=s,u="right",h="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",h="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,h="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",h="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=h,t}var es="__zr_normal__",ns=Ba.concat(["ignore"]),is=Mn(Ba,function(t,e){return t[e]=!0,t},{ignore:!1}),rs={},os=new cr(0,0,0,0),as=[],ss=function(){function t(t){this.id=gn(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,h=void 0;if((u||c)&&(h=os,n.layoutRect?h.copy(n.layoutRect):h.copy(this.getBoundingRect()),i||h.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(rs,n,h):ts(rs,n,h),r.x=rs.x,r.y=rs.y,o=rs.align,a=rs.verticalAlign;var d=n.origin;if(d&&null!=n.rotation){var p=void 0,f=void 0;"center"===d?(p=.5*h.width,f=.5*h.height):(p=Ja(d[0],h.width),f=Ja(d[1],h.height)),l=!0,r.originX=-r.x+p+(i?0:h.x),r.originY=-r.y+f+(i?0:h.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new cr(0,0,0,0);r.getLocalTransform(as),Xi(as,as),cr.copy(m,h),m.applyTransform(as)}else v.overflowRect=null;var y=void 0,_=void 0,b=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),b=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),b=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&b===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=b,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?Pa:Aa},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&So(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,To(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},_n(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(On(t))for(var n=In(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(es,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===es;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(xn(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}vn("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,h),g&&g.useStates(t,e,h),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!h&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=xn(i,t),o=xn(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var d=0;d0||r.force&&!a.length){var w,S=void 0,C=void 0,k=void 0;if(s){C={},d&&(S={});for(b=0;b<_;b++){C[m=g[b]]=n[m],d?S[m]=i[m]:n[m]=i[m]}}else if(d){k={};for(b=0;b<_;b++){k[m=g[b]]=ta(n[m]),us(n,i,m)}}(w=new aa(n,!1,!1,h?Tn(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),d&&S&&w.whenWithKeys(0,S,g),k&&w.whenWithKeys(0,k,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}wn(ss,bi),wn(ss,Fa);var ds=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return y(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=xn(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=xn(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;ne&&(e=t);return e>0?e+e%2:0}(o.circuits);if(!a)throw new Error(n("card.topology_error"));const l={type:"config/device_registry/list"},c=i?await i.callWS(t,l,{errorId:"fetch:topology"}):await t.callWS(l),d=o.panel_device_id??e,u=(h=c.find(t=>t.id===d),h?{id:h.id,name:h.name,name_by_user:h.name_by_user,config_entry_id:h.config_entry_id,identifiers:h.identifiers,via_device_id:h.via_device_id,sw_version:h.sw_version,model:h.model}:null);var h;return await Ee(t,o),{topology:o,panelDevice:u,panelSize:a}}function Ne(){return`
\n ${Object.entries(v).filter(([t])=>"unknown"!==t).map(([,t])=>{const e=Vt(t.icon),n=Vt(t.color),i=Vt(t.label());let r;if(t.icon2){r=``}else if(t.textLabel){r=`${Vt(t.textLabel)}`}else r=``;return`
${r}${i}
`}).join("")}\n
`}function Oe(t,e,i){const r="current"===(e.chart_metric||"power"),o=!!t.panel_entities?.site_power,a=!!t.panel_entities?.dsm_state,s=!!t.panel_entities?.current_power,l=!!t.panel_entities?.feedthrough_power,c=!!t.panel_entities?.pv_power,d=!!t.panel_entities?.battery_level;return`\n
\n ${o?`\n
\n ${n("header.site")}\n
\n 0\n ${r?"A":"kW"}\n
\n
`:""}\n ${a?`\n
\n ${n("header.grid")}\n
\n --\n
\n
`:""}\n ${s?`\n
\n ${n("header.upstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${l?`\n
\n ${n("header.downstream")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${c?`\n
\n ${n("header.solar")}\n
\n --\n ${r?"A":"kW"}\n
\n
`:""}\n ${d?`\n
\n ${n("header.battery")}\n
\n —\n %\n
\n
`:""}\n
\n `}function $e(t,e,i={}){const r=Vt(t.device_name||n("header.default_name")),o=Vt(t.serial||""),a=Vt(t.firmware||""),s="current"===(e.chart_metric||"power"),l=!1!==i.showSwitches;return`\n
\n
\n
\n

${r}

\n ${o}\n \n ${l?`
\n ${Vt(n("header.enable_switches"))}\n
\n \n
\n
`:""}\n
\n ${Oe(t,e)}\n
\n
\n
\n ${a}\n
\n \n \n
\n
\n ${Ne()}\n
\n
\n `}const Re=f.power;function He(t){return Re.unit(t)}function Fe(t){return(t<0?"-":"")+Re.format(t)}function Be(t){return`${Fe(t)} ${He(t)}`}function Ve(t){return(Math.abs(t)/1e3).toFixed(1)}function We(t){return Math.ceil(t/2)}function Ge(t){return t%2==0?1:0}function Ue(t){if(2!==t.length)return null;const[e,n]=[Math.min(...t),Math.max(...t)];return We(e)===We(n)?"row-span":Ge(e)===Ge(n)?"col-span":"row-span"}function qe(t){const e=t.chart_metric??r;return f[e]??f[r]}function je(t,e){const n=function(t){return qe(t).entityRole}(e);return t.entities?.[n]??t.entities?.power??null}function Xe(t,e){if("none"===e)return"";const i=["toggle-pill",t?"toggle-on":"toggle-off"];return"inert"===e&&i.push("toggle-unavailable"),`
\n ${n(t?"grid.on":"grid.off")}\n \n
`}function Ye(t,e,i){const r=t.querySelector(".toggle-pill");if(!r)return;r.classList.toggle("toggle-on",e),r.classList.toggle("toggle-off",!e),r.classList.toggle("toggle-unavailable","operable"!==i);const o=r.querySelector(".toggle-label");o&&(o.textContent=n(e?"grid.on":"grid.off"))}function Ze(t){return"unknown"===t?void 0:v[t]}const Ke="display:none;";function Qe(t){const e=Ze(t),n=e?Vt(e.color):"",i=e?` title="${Vt(e.label())}"`:"",r=e?Vt(e.icon):"",o=e?.icon2?Vt(e.icon2):"",a=e?.textLabel?Vt(e.textLabel):"";return`\n \n \n ${a}\n `}function Je(t,e){const n=t.querySelector(".shedding-composite");if(!n)return;const i=Ze(e);n.style.display=i?"":"none",n.title=i?i.label():"";const r=i?.color??"",o=n.querySelector(".shedding-icon");o&&(o.setAttribute("icon",i?.icon??""),o.style.color=r);const a=n.querySelector(".shedding-icon-secondary");a&&(a.setAttribute("icon",i?.icon2??""),a.style.color=r,a.style.display=i?.icon2?"":"none");const s=n.querySelector(".shedding-label");s&&(s.textContent=i?.textLabel??"",s.style.color=r,s.style.display=i?.textLabel?"":"none")}function tn(t,e,i,r,o,a,s,c,d,u=!1){const h=e.entities?.power,p=h?a.states[h]:null,f=p&&parseFloat(p.state)||0,g=e.device_type===l||f<0,v=be(e,a),m=xe(e,a),y=e.breaker_rating_a,_=y?`${Math.round(y)}A`:"",b=Vt(e.name||n("grid.unknown")),x=qe(s);let w;if("current"===x.entityRole){const t=e.entities?.current,n=t?a.states[t]:null,i=n&&parseFloat(n.state)||0;w=`${x.format(i)}A`}else w=`${Fe(f)}${He(f)}`;const S=Qe(d||"unknown"),C=``;let k="",T=c?.utilization_pct??null;if(null==T&&e.breaker_rating_a){const t=e.entities?.current,n=t?a.states[t]:null,i=n?Math.abs(parseFloat(n.state)||0):0;T=Math.round(i/e.breaker_rating_a*1e3)/10}if(null!=T){k=`=80?"utilization-warning":"utilization-normal"}">${Math.round(T)}%`}return`\n
\n
\n
\n ${_?`${_}`:""}\n ${k}\n ${b}\n
\n
\n \n ${w}\n \n ${Xe(v,m)}\n
\n
\n
\n ${S}\n ${C}\n
\n
\n
\n `}function en(t,e){return`\n
\n —\n
\n `}function nn(t,e,i,r){const o=!1!==i.show_battery,a=!1!==i.show_evse,s=!1!==i.show_solar;if(!t.sub_devices)return"";const l=function(t,e=()=>""){const n=t=>"site"===t.solar?.role?0:1,i=new Map;for(const n of t){if(n[1].type!==u)continue;const t=e(n[0]),r=i.get(t);r?r.push(n):i.set(t,[n])}for(const t of i.values())t.sort(([,t],[,e])=>n(t)-n(e)||(t.name??"").localeCompare(e.name??""));const r=[],o=new Set;for(const n of t){if(n[1].type!==u){r.push(n);continue}const t=e(n[0]);o.has(t)||(o.add(t),r.push(...i.get(t)??[]))}return r}(Object.entries(t.sub_devices).filter(([,t])=>!(t.type===c&&!o)&&(!(t.type===d&&!a)&&!(t.type===u&&!s))),function(t){if(!function(t){return"_favoriteRefs"in t}(t))return()=>"";const e=t._favoriteRefs;return t=>e[t]?.panelDeviceId??""}(t));if(0===l.length)return"";const h=[];for(const[t,n]of l){const r=ae(n),o=n.type===c,a=o?re(n):null,s=o?oe(n):null,l=an(n,e,i,se(n)),d=sn(t,n,o,r.headlineEid,a,s),u=n.solar?on(n.solar,r,e):"";(r.headlineEid||d||l||u)&&h.push({devId:t,sub:n,power:r,chartsHTML:d,entHTML:l,solarHTML:u})}if(0===h.length)return"";const p=h.filter(t=>t.sub.type===d).length;let f=0,g="";for(const{devId:t,sub:i,power:o,chartsHTML:a,entHTML:s,solarHTML:l}of h){const u=rn(i),h=o.headlineIsSiteTotal?`${Vt(n("subdevice.site_total"))}`:"",v=o.headlineEid?`${h}${Be(ce(e,o.headlineEid))}`:"",m=ue(i,r.showFavorites)?``:"",y=i.type===c,_=i.type===d;let b="";y?b="sub-device-bess":_&&(f++,f===p&&p%2==1&&(b="sub-device-full")),g+=`\n
\n
\n ${Vt(u)}\n ${Vt(i.name||"")}\n ${v}\n ${m}\n
\n ${a}\n ${l}\n ${s}\n
\n `}return g}function rn(t){return t.type===u?"inverter"===t.solar?.role?n("subdevice.solar_inverter"):n("subdevice.solar"):t.type===d?n("subdevice.ev_charger"):t.type===c?n("subdevice.battery"):n("subdevice.fallback")}function on(t,e,i){let r="";const o=[t.vendor,t.model].filter(t=>!!t).join(" · ");return o&&(r+=`
${Vt(o)}
`),e.siteTotalEid&&(r+=`\n
\n ${Vt(n("subdevice.site_total"))}:\n ${Be(ce(i,e.siteTotalEid))}\n
`),"inverter"!==t.role||e.headlineEid||(r+=`
${Vt(n("subdevice.in_site_total"))}
`),r}function an(t,e,n,i){const r=n.visible_sub_entities||{};let o="";if(!t.entities)return o;for(const[n,a]of Object.entries(t.entities)){if(i.has(n))continue;if(!0!==r[n])continue;const s=e.states[n];if(!s)continue;let l=a.original_name||s.attributes.friendly_name||n;const c=t.name||"";let d;if(l.startsWith(c+" ")&&(l=l.slice(c.length+1)),e.formatEntityState)d=e.formatEntityState(s);else{d=s.state;const t=s.attributes.unit_of_measurement||"";t&&(d+=" "+t)}if("Wh"===(s.attributes.unit_of_measurement||"")){const t=parseFloat(s.state);isNaN(t)||(d=(t/1e3).toFixed(1)+" kWh")}o+=`\n
\n ${Vt(l)}:\n ${Vt(d)}\n
\n `}return o}function sn(t,e,i,r,o,a){if(i){const e=[{key:`${h}${t}_soc`,title:n("subdevice.soc"),available:!!o},{key:`${h}${t}_soe`,title:n("subdevice.soe"),available:!!a},{key:`${h}${t}_power`,title:n("subdevice.power"),available:!!r}].filter(t=>t.available);return`\n
\n ${e.map(t=>`\n
\n
${Vt(t.title)}
\n
\n
\n `).join("")}\n
\n `}return r?`
`:""}function ln(t){const e=void 0!==t.history_days||void 0!==t.history_hours||void 0!==t.history_minutes,n=60*(60*(24*(e&&parseInt(String(t.history_days))||0)+(e&&parseInt(String(t.history_hours))||0))+(e?parseInt(String(t.history_minutes))||0:5))*1e3;return Math.max(n,6e4)}function cn(t){const e=a[t];return e?e.ms:a[o].ms}function dn(t){const e=t/1e3;return e<=600?Math.ceil(e):Math.min(5e3,Math.ceil(e/5))}function un(t){return Math.max(500,Math.floor(t/5e3))}function hn(t,e,n,i,r,o){t.has(e)||t.set(e,[]);const a=t.get(e);a.push({time:i,value:n});const s=a.findIndex(t=>t.time>=r);s>0?a.splice(0,s):-1===s&&(a.length=0),a.length>o&&a.splice(0,a.length-o)}function pn(t,e,n=500){if(0===t.length)return t;t.sort((t,e)=>t.time-e.time);const i=[t[0]];for(let e=1;e=n&&i.push(t[e]);return i.length>e&&i.splice(0,i.length-e),i}function fn(t,e,n,i){let r=t.get(e);r||(r={entityIds:[],keysByEntity:new Map},t.set(e,r));const o=r.keysByEntity.get(n);o?o.push(i):(r.entityIds.push(n),r.keysByEntity.set(n,[i]))}async function gn(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=i/36e5>72?"hour":"5minute",s=await t.callWS({type:"recorder/statistics_during_period",start_time:o,statistic_ids:e,period:a,types:["mean"]});for(const[t,e]of Object.entries(s)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=t.mean;if(null==e||!Number.isFinite(e))continue;const n=t.start;n>0&&o.push({time:n,value:e})}if(0!==o.length)for(const t of i){const e=r.get(t)||[],n=[...o,...e];n.sort((t,e)=>t.time-e.time),r.set(t,n)}}}async function vn(t,e,n,i,r){const o=new Date(Date.now()-i).toISOString(),a=await t.callWS({type:"history/history_during_period",start_time:o,entity_ids:e,minimal_response:!0,significant_changes_only:!0,no_attributes:!0}),s=dn(i),l=un(i);for(const[t,e]of Object.entries(a)){const i=n.get(t);if(!i||!e)continue;const o=[];for(const t of e){const e=parseFloat(t.s);if(!Number.isFinite(e))continue;const n=1e3*(t.lu||t.lc||0);n>0&&o.push({time:n,value:e})}if(0!==o.length)for(const t of i){const e=r.get(t)||[],n=[...o,...e];r.set(t,pn(n,s,l))}}}function mn(t){if(!t.sub_devices)return[];const e=[];for(const[n,i]of Object.entries(t.sub_devices))for(const{role:t,entityId:r}of le(i))e.push({entityId:r,key:`${h}${n}_${t}`,devId:n});return e}async function yn(t,e,n,i,r,o){if(!e||!t)return;const a=new Map;for(const[t,i]of Object.entries(e.circuits)){const e=je(i,n);if(!e)continue;let o;o=r&&r.has(t)?cn(r.get(t)):ln(n),fn(a,o,e,t)}for(const{entityId:t,key:i,devId:r}of mn(e)){let e;e=o&&o.has(r)?cn(o.get(r)):ln(n),fn(a,e,t,i)}const s=[];for(const[e,n]of a){if(0===n.entityIds.length)continue;e>2592e5?s.push(gn(t,n.entityIds,n.keysByEntity,e,i)):s.push(vn(t,n.entityIds,n.keysByEntity,e,i))}await Promise.all(s)}var _n=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},bn=new function(){this.browser=new _n,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(bn.wxa=!0,bn.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?bn.worker=!0:!bn.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(bn.node=!0,bn.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,bn);var xn="sans-serif",wn="12px "+xn;var Sn,Cn,kn=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n=0)o=r*t.length;else for(var a=0;a>1)%2;a.style.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",i[s]+":0",r[l]+":0",i[1-s]+":auto",r[1-l]+":auto",""].join("!important;"),t.appendChild(a),n.push(a)}return e.clearMarkers=function(){Xn(n,function(t){t.parentNode&&t.parentNode.removeChild(t)})},n}(e,o),s=function(t,e,n){for(var i=n?"invTrans":"trans",r=e[i],o=e.srcCoords,a=[],s=[],l=!0,c=0;c<4;c++){var d=t[c].getBoundingClientRect(),u=2*c,h=d.left,p=d.top;a.push(h,p),l=l&&o&&h===o[u]&&p===o[u+1],s.push(t[c].offsetLeft,t[c].offsetTop)}return l&&r?r:(e.srcCoords=a,e[i]=n?ji(s,a):ji(a,s))}(a,o,r);if(s)return s(t,n,i),!0}return!1}function Ki(t){return"CANVAS"===t.nodeName.toUpperCase()}var Qi=/([&<>"'])/g,Ji={"&":"&","<":"<",">":">",'"':""","'":"'"};function tr(t){return null==t?"":(t+"").replace(Qi,function(t,e){return Ji[e]})}var er=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,nr=[],ir=bn.browser.firefox&&+bn.browser.version.split(".")[0]<39;function rr(t,e,n,i){return n=n||{},i?or(t,e,n):ir&&null!=e.layerX&&e.layerX!==e.offsetX?(n.zrX=e.layerX,n.zrY=e.layerY):null!=e.offsetX?(n.zrX=e.offsetX,n.zrY=e.offsetY):or(t,e,n),n}function or(t,e,n){if(bn.domSupported&&t.getBoundingClientRect){var i=e.clientX,r=e.clientY;if(Ki(t)){var o=t.getBoundingClientRect();return n.zrX=i-o.left,void(n.zrY=r-o.top)}if(Zi(nr,t,i,r))return n.zrX=nr[0],void(n.zrY=nr[1])}n.zrX=n.zrY=0}function ar(t){return t||window.event}function sr(t,e,n){if(null!=(e=ar(e)).zrX)return e;var i=e.type;if(i&&i.indexOf("touch")>=0){var r="touchend"!==i?e.targetTouches[0]:e.changedTouches[0];r&&rr(t,r,e,n)}else{rr(t,e,e,n);var o=function(t){var e=t.wheelDelta;if(e)return e;var n=t.deltaX,i=t.deltaY;if(null==n||null==i)return e;return 3*(0!==i?Math.abs(i):Math.abs(n))*(i>0?-1:i<0?1:n>0?-1:1)}(e);e.zrDelta=o?o/120:-(e.detail||0)/3}var a=e.button;return null==e.which&&void 0!==a&&er.test(e.type)&&(e.which=1&a?1:2&a?3:4&a?2:0),e}function lr(t,e,n,i){t.removeEventListener(e,n,i)}var cr=function(t){t.preventDefault(),t.stopPropagation(),t.cancelBubble=!0},dr=function(){function t(){this._track=[]}return t.prototype.recognize=function(t,e,n){return this._doTrack(t,e,n),this._recognize(t)},t.prototype.clear=function(){return this._track.length=0,this},t.prototype._doTrack=function(t,e,n){var i=t.touches;if(i){for(var r={points:[],touches:[],target:e,event:t},o=0,a=i.length;o1&&r&&r.length>1){var a=ur(r)/ur(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function pr(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function fr(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function gr(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function vr(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function mr(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],d=Math.sin(n),u=Math.cos(n);return t[0]=r*u+s*d,t[1]=-r*d+s*u,t[2]=o*u+l*d,t[3]=-o*d+u*l,t[4]=u*(a-i[0])+d*(c-i[1])+i[0],t[5]=u*(c-i[1])-d*(a-i[0])+i[1],t}function yr(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var _r=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),br=Math.min,xr=Math.max,wr=Math.abs,Sr=["x","y"],Cr=["width","height"],kr=new _r,Tr=new _r,Mr=new _r,Ir=new _r,Ar=$r(),Dr=Ar.minTv,Pr=Ar.maxTv,Lr=[0,0],Er=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=br(t.x,this.x),n=br(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=xr(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=xr(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return vr(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),vr(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&_r.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(zr,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(Nr,n.x,n.y,n.width,n.height));var s=!!i;Ar.reset(r,s);var l=Ar.touchThreshold,c=e.x+l,d=e.x+e.width-l,u=e.y+l,h=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>d||u>h||p>f||g>v)return!1;var m=!(d=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}kr.x=Mr.x=n.x,kr.y=Ir.y=n.y,Tr.x=Ir.x=n.x+n.width,Tr.y=Mr.y=n.y+n.height,kr.transform(i),Ir.transform(i),Tr.transform(i),Mr.transform(i),e.x=br(kr.x,Tr.x,Mr.x,Ir.x),e.y=br(kr.y,Tr.y,Mr.y,Ir.y);var l=xr(kr.x,Tr.x,Mr.x,Ir.x),c=xr(kr.y,Tr.y,Mr.y,Ir.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),zr=new Er(0,0,0,0),Nr=new Er(0,0,0,0);function Or(t,e,n,i,r,o,a,s){var l=wr(e-n),c=wr(i-t),d=br(l,c),u=Sr[r],h=Sr[1-r],p=Cr[r];e=c||!Ar.bidirectional)&&(Dr[u]=-c,Dr[h]=0,Ar.useDir&&Ar.calcDirMTV())))}function $r(){var t=0,e=new _r,n=new _r,i={minTv:new _r,maxTv:new _r,useDir:!1,dirMinTv:new _r,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=xr(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),d=l*o.y+c*o.x;r(d)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/d,n.y=s*l/d,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var d=i[c];d===n||d.ignore||d.ignoreCoarsePointer||d.parent&&d.parent.ignoreCoarsePointer||(Wr.copy(d.getBoundingRect()),d.transform&&Wr.applyTransform(d.transform),Wr.intersect(l)&&o.push(d))}if(o.length)for(var u=Math.PI/12,h=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=Ur(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==Rr)){e.target=a;break}}}function jr(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}Xn(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){Gr.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=jr(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||$i(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function Xr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function Yr(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function Zr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+d])>0?a=d+1:l=d}return l}function Kr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+d])<0?l=d:a=d+1}return l}function Qr(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],d=n[s+1],u=i[s+1];i[s]=c+u,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var h=Kr(t[d],t,l,c,0,e);l+=h,0!==(c-=h)&&0!==(u=Zr(t[l+c-1],t,d,u,u-1,e))&&(c<=u?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[h+l];return void(t[u]=a[d])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[d],t[c])<0){if(t[u--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[u--]=a[d--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[h+l];if(0===i){m=!0;break}}if(t[u--]=a[d--],1===--s){m=!0;break}if(0!==(v=s-Zr(t[c],a,0,s,s-1,e))){for(s-=v,p=(u-=v)+1,h=(d-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(u-=i)+1,h=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[h+l];t[u]=a[d]}else{if(0===s)throw new Error;for(h=u-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=Xr(t,n,i,e))s&&(l=s),Yr(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var to=!1;function eo(){to||(to=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function no(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var io,ro=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=no}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();io=bn.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var oo={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-oo.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*oo.bounceIn(2*t):.5*oo.bounceOut(2*t-1)+.5}},ao=Math.pow,so=Math.sqrt,lo=1e-8,co=1e-4,uo=so(3),ho=1/3,po=Di(),fo=Di(),go=Di();function vo(t){return t>-1e-8&&tlo||t<-1e-8}function yo(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function _o(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function bo(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,d=s*s-3*a*l,u=s*l-9*a*c,h=l*l-3*s*c,p=0;if(vo(d)&&vo(u)){if(vo(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=u*u-4*d*h;if(vo(f)){var g=u/d,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=so(f),y=d*s+1.5*a*(-u+m),_=d*s+1.5*a*(-u-m);(C=(-s-((y=y<0?-ao(-y,ho):ao(y,ho))+(_=_<0?-ao(-_,ho):ao(_,ho))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var b=(2*d*s-3*a*u)/(2*so(d*d*d)),x=Math.acos(b)/3,w=so(d),S=Math.cos(x),C=(-s-2*w*S)/(3*a),k=(v=(-s+w*(S+uo*Math.sin(x)))/(3*a),(-s+w*(S-uo*Math.sin(x)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),k>=0&&k<=1&&(o[p++]=k)}}return p}function xo(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(vo(a)){if(mo(o))(d=-s/o)>=0&&d<=1&&(r[l++]=d)}else{var c=o*o-4*a*s;if(vo(c))r[0]=-o/(2*a);else if(c>0){var d,u=so(c),h=(-o-u)/(2*a);(d=(-o+u)/(2*a))>=0&&d<=1&&(r[l++]=d),h>=0&&h<=1&&(r[l++]=h)}}return l}function wo(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,d=(l-s)*r+s,u=(d-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=u,o[4]=u,o[5]=d,o[6]=l,o[7]=i}function So(t,e,n,i,r,o,a,s,l){for(var c=t,d=e,u=0,h=1/l,p=1;p<=l;p++){var f=p*h,g=yo(t,n,r,a,f),v=yo(e,i,o,s,f),m=g-c,y=v-d;u+=Math.sqrt(m*m+y*y),c=g,d=v}return u}function Co(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function ko(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function To(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function Mo(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function Io(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,d=1/a,u=1;u<=a;u++){var h=u*d,p=Co(t,n,r,h),f=Co(e,i,o,h),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var Ao=/cubic-bezier\(([0-9,\.e ]+)\)/;function Do(t){var e=t&&Ao.exec(t);if(e){var n=e[1].split(","),i=+yi(n[0]),r=+yi(n[1]),o=+yi(n[2]),a=+yi(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:bo(0,i,o,1,t,s)&&yo(0,r,a,1,s[0])}}}var Po=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||Ii,this.ondestroy=t.ondestroy||Ii,this.onrestart=t.onrestart||Ii,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=ni(t)?t:oo[t]||Do(t)},t}(),Lo=function(t){this.value=t},Eo=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new Lo(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),zo=function(){function t(t){this._list=new Eo,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new Lo(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),No={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function Oo(t){return(t=Math.round(t))<0?0:t>255?255:t}function $o(t){return t<0?0:t>1?1:t}function Ro(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?Oo(parseFloat(e)/100*255):Oo(parseInt(e,10))}function Ho(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?$o(parseFloat(e)/100):$o(parseFloat(e))}function Fo(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function Bo(t,e,n){return t+(e-t)*n}function Vo(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function Wo(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var Go=new zo(20),Uo=null;function qo(t,e){Uo&&Wo(Uo,e),Uo=Go.put(t,Uo||e.slice())}function jo(t,e){if(t){e=e||[];var n=Go.get(t);if(n)return Wo(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in No)return Wo(e,No[i]),qo(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(Vo(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),qo(t,e),e):void Vo(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(Vo(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),qo(t,e),e):void Vo(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),d=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?Vo(e,+c[0],+c[1],+c[2],1):Vo(e,0,0,0,1);d=Ho(c.pop());case"rgb":return c.length>=3?(Vo(e,Ro(c[0]),Ro(c[1]),Ro(c[2]),3===c.length?d:Ho(c[3])),qo(t,e),e):void Vo(e,0,0,0,1);case"hsla":return 4!==c.length?void Vo(e,0,0,0,1):(c[3]=Ho(c[3]),Xo(c,e),qo(t,e),e);case"hsl":return 3!==c.length?void Vo(e,0,0,0,1):(Xo(c,e),qo(t,e),e);default:return}}Vo(e,0,0,0,1)}}function Xo(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=Ho(t[1]),r=Ho(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return Vo(e=e||[],Oo(255*Fo(a,o,n+1/3)),Oo(255*Fo(a,o,n)),Oo(255*Fo(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function Yo(t,e){var n=jo(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return Ko(n,4===n.length?"rgba":"rgb")}}function Zo(t,e,n,i){var r=jo(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var d=((s-i)/6+l/2)/l,u=((s-r)/6+l/2)/l,h=((s-o)/6+l/2)/l;i===s?e=h-u:r===s?e=1/3+d-h:o===s&&(e=2/3+u-d),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=Ho(ni(n)?n(r[1]):n)),null!=i&&(r[2]=Ho(ni(i)?i(r[2]):i)),Ko(Xo(r),"rgba")}function Ko(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function Qo(t,e){var n=jo(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var Jo=new zo(100);function ta(t){if(ii(t)){var e=Jo.get(t);return e||(e=Yo(t,-.1),Jo.put(t,e)),e}if(di(t)){var n=Wn({},t);return n.colorStops=Yn(t.colorStops,function(t){return{offset:t.offset,color:Yo(t.color,-.1)}}),n}return t}var ea=Math.round;function na(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=jo(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var ia=1e-4;function ra(t){return t-1e-4}function oa(t){return ea(1e3*t)/1e3}function aa(t){return ea(1e4*t)/1e4}var sa={left:"start",right:"end",center:"middle",middle:"middle"};function la(t){return t&&!!t.image}function ca(t){return la(t)||function(t){return t&&!!t.svgElement}(t)}function da(t){return"linear"===t.type}function ua(t){return"radial"===t.type}function ha(t){return t&&("linear"===t.type||"radial"===t.type)}function pa(t){return"url(#"+t+")"}function fa(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function ga(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*Ai,r=pi(t.scaleX,1),o=pi(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+ea(a*Ai)+"deg, "+ea(s*Ai)+"deg)"),l.join(" ")}var va=bn.hasGlobalWindow&&ni(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},ma=Array.prototype.slice;function ya(t,e,n){return(e-t)*n+t}function _a(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(jn(e)){var l=function(t){return jn(t&&t[0])?2:1}(e);a=l,(1===l&&!oi(e[0])||2===l&&!oi(e[0][0]))&&(o=!0)}else if(oi(e)&&!ui(e))a=0;else if(ii(e))if(isNaN(+e)){var c=jo(e);c&&(s=c,a=3)}else a=0;else if(di(e)){var d=Wn({},s);d.colorStops=Yn(e.colorStops,function(t){return{offset:t.offset,color:jo(t.color)}}),da(e)?a=4:ua(e)&&(a=5),s=d}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var u={time:t,value:s,rawValue:e,percent:0};return n&&(u.easing=n,u.easingFunc=ni(n)?n:oo[n]||Do(n)),i.push(u),u},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=Ma(i),l=Ta(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=h;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:u?Ia:t[d];if(!Ma(s)&&!u||v||(v=this._additiveValue=[]),this.discrete)t[d]=g<1?i.rawValue:r.rawValue;else if(Ma(s))1===s?_a(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,Ca(l),i),this._trackKeys.push(a)}s.addKeyframe(t,Ca(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function Pa(){return(new Date).getTime()}var La,Ea,za=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return _(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=Pa()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,io(function e(){t._running&&(io(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=Pa(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=Pa(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=Pa()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new Da(t,e.loop);return this.addAnimator(n),n},e}(Gi),Na=bn.domSupported,Oa=(Ea={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:La=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:Yn(La,function(t){var e=t.replace("mouse","pointer");return Ea.hasOwnProperty(e)?e:t})}),$a=["mousemove","mouseup"],Ra=["pointermove","pointerup"],Ha=!1;function Fa(t){var e=t.pointerType;return"pen"===e||"touch"===e}function Ba(t){t&&(t.zrByTouch=!0)}function Va(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var Wa=function(t,e){this.stopPropagation=Ii,this.stopImmediatePropagation=Ii,this.preventDefault=Ii,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},Ga={mousedown:function(t){t=sr(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=sr(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=sr(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){Va(this,(t=sr(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){Ha=!0,t=sr(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){Ha||(t=sr(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){Ba(t=sr(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),Ga.mousemove.call(this,t),Ga.mousedown.call(this,t)},touchmove:function(t){Ba(t=sr(this.dom,t)),this.handler.processGesture(t,"change"),Ga.mousemove.call(this,t)},touchend:function(t){Ba(t=sr(this.dom,t)),this.handler.processGesture(t,"end"),Ga.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&Ga.click.call(this,t)},pointerdown:function(t){Ga.mousedown.call(this,t)},pointermove:function(t){Fa(t)||Ga.mousemove.call(this,t)},pointerup:function(t){Ga.mouseup.call(this,t)},pointerout:function(t){Fa(t)||Ga.mouseout.call(this,t)}};Xn(["click","dblclick","contextmenu"],function(t){Ga[t]=function(e){e=sr(this.dom,e),this.trigger(t,e)}});var Ua={pointermove:function(t){Fa(t)||Ua.mousemove.call(this,t)},pointerup:function(t){Ua.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function qa(t,e){var n=e.domHandlers;bn.pointerEventsSupported?Xn(Oa.pointer,function(i){Xa(e,i,function(e){n[i].call(t,e)})}):(bn.touchEventsSupported&&Xn(Oa.touch,function(i){Xa(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),Xn(Oa.mouse,function(i){Xa(e,i,function(r){r=ar(r),e.touching||n[i].call(t,r)})}))}function ja(t,e){function n(n){Xa(e,n,function(i){i=ar(i),Va(t,i.target)||(i=function(t,e){return sr(t.dom,new Wa(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}bn.pointerEventsSupported?Xn(Ra,n):bn.touchEventsSupported||Xn($a,n)}function Xa(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function Ya(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&lr(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var Za=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},Ka=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new Za(e,Ga),Na&&(i._globalHandlerScope=new Za(document,Ua)),qa(i,i._localHandlerScope),i}return _(e,t),e.prototype.dispose=function(){Ya(this._localHandlerScope),Na&&Ya(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,Na&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?ja(this,e):Ya(e)}},e}(Gi),Qa=1;bn.hasGlobalWindow&&(Qa=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var Ja=Qa,ts="#333",es="#ccc",ns=pr,is=5e-5;function rs(t){return t>is||t<-5e-5}var os,as=[],ss=[],ls=[1,0,0,1,0,0],cs=Math.abs,ds=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return rs(this.rotation)||rs(this.x)||rs(this.y)||rs(this.scaleX-1)||rs(this.scaleY-1)||rs(this.skewX)||rs(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):ns(n),t&&(e?gr(n,t,n):fr(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(ns(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(as);var n=as[0]<0?-1:1,i=as[1]<0?-1:1,r=((as[0]-n)*e+n)/as[0]||0,o=((as[1]-i)*e+i)/as[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],yr(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],gr(ss,t.invTransform,e),e=ss);var n=this.originX,i=this.originY;(n||i)&&(ls[4]=n,ls[5]=i,gr(ss,e,ls),ss[4]-=n,ss[5]-=i,e=ss),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&Hi(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&Hi(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&cs(t[0]-1)>1e-10&&cs(t[3]-1)>1e-10?Math.sqrt(cs(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){hs(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,d=t.y,u=t.skewX?Math.tan(t.skewX):0,h=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-u*f*o,e[5]=-f*o-h*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=h*r,e[2]=u*o,l&&mr(e,e,l),e[4]+=n+c,e[5]+=i+d,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),us=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function hs(t,e){for(var n=0;n=gs)){t=t||wn;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=Tn.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?fs=gs:r>2&&fs++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function ms(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=Tn.measureText(e,t.font).width,n.put(e,i)),i}function ys(t,e,n,i){var r=ms(ps(e),t),o=ws(e),a=bs(0,r,n),s=xs(0,o,i);return new Er(a,s,r,o)}function _s(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return ys(r[0],e,n,i);for(var o=new Er(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function Cs(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,d="left",u="top";if(i instanceof Array)l+=Ss(i[0],n.width),c+=Ss(i[1],n.height),d=null,u=null;else switch(i){case"left":l-=r,c+=s,d="right",u="middle";break;case"right":l+=r+a,c+=s,u="middle";break;case"top":l+=a/2,c-=r,d="center",u="bottom";break;case"bottom":l+=a/2,c+=o+r,d="center";break;case"inside":l+=a/2,c+=s,d="center",u="middle";break;case"insideLeft":l+=r,c+=s,u="middle";break;case"insideRight":l+=a-r,c+=s,d="right",u="middle";break;case"insideTop":l+=a/2,c+=r,d="center";break;case"insideBottom":l+=a/2,c+=o-r,d="center",u="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,d="right";break;case"insideBottomLeft":l+=r,c+=o-r,u="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,d="right",u="bottom"}return(t=t||{}).x=l,t.y=c,t.align=d,t.verticalAlign=u,t}var ks="__zr_normal__",Ts=us.concat(["ignore"]),Ms=Zn(us,function(t,e){return t[e]=!0,t},{ignore:!1}),Is={},As=new Er(0,0,0,0),Ds=[],Ps=function(){function t(t){this.id=Hn(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,d=n.autoOverflowArea,u=void 0;if((d||c)&&(u=As,n.layoutRect?u.copy(n.layoutRect):u.copy(this.getBoundingRect()),i||u.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(Is,n,u):Cs(Is,n,u),r.x=Is.x,r.y=Is.y,o=Is.align,a=Is.verticalAlign;var h=n.origin;if(h&&null!=n.rotation){var p=void 0,f=void 0;"center"===h?(p=.5*u.width,f=.5*u.height):(p=Ss(h[0],u.width),f=Ss(h[1],u.height)),l=!0,r.originX=-r.x+p+(i?0:u.x),r.originY=-r.y+f+(i?0:u.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(d){var m=v.overflowRect=v.overflowRect||new Er(0,0,0,0);r.getLocalTransform(Ds),yr(Ds,Ds),Er.copy(m,u),m.applyTransform(Ds)}else v.overflowRect=null;var y=void 0,_=void 0,b=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),b=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),b=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&b===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=b,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?es:ts},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&jo(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,Ko(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},Wn(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(ai(t))for(var n=Qn(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(ks,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===ks;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(Un(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,d=this._textGuide;return c&&c.useState(t,e,n,l),d&&d.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}Fn("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,u),g&&g.useStates(t,e,u),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!u&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=Un(i,t),o=Un(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var h=0;h0||r.force&&!a.length){var w,S=void 0,C=void 0,k=void 0;if(s){C={},h&&(S={});for(b=0;b<_;b++){C[m=g[b]]=n[m],h?S[m]=i[m]:n[m]=i[m]}}else if(h){k={};for(b=0;b<_;b++){k[m=g[b]]=Ca(n[m]),zs(n,i,m)}}(w=new Da(n,!1,!1,u?Kn(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),h&&S&&w.whenWithKeys(0,S,g),k&&w.whenWithKeys(0,k,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(d||0),t.addAnimator(w,e),a.push(w)}}qn(Ps,Gi),qn(Ps,ds);var Os=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=Un(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=Un(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*c+a}var Ss=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return Cs(t,e,n)};function Cs(t,e,n){return En(t)?(i=t,i.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t;var i}function ks(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function Ms(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return function(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}(t)}function Ts(t,e){var n=Math.max(Ms(t),Ms(e)),i=t+e;return n>20?i:ks(i,n)}function Is(t){var e=2*Math.PI;return(t%e+e)%e}function Ds(t){return t>-1e-4&&t=10&&e++,e}function Es(t,e){var n=Ls(t),i=Math.pow(10,n),r=t/i;return t=(r<1.5?1:r<2.5?2:r<4?3:r<7?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function zs(t){var e=parseFloat(t);return e==t&&(0!==e||!En(t)||t.indexOf("x")<=0)?e:NaN}function Ns(){return Math.round(9*Math.random())}function Os(t,e){return 0===e?t:Os(e,t%e)}function Rs(t,e){return null==t?e:null==e?t:t*e/Os(t,e)}var $s="undefined"!=typeof console&&console.warn&&console.log;function Hs(t,e){!function(t,e){$s&&console[t]("[ECharts] "+e)}("error",t)}function Fs(t){throw new Error(t)}function Bs(t,e,n){return(e-t)*n+t}var Vs="series\0";function Ws(t){return t instanceof Array?t:null==t?[]:[t]}function Us(t,e,n){if(t){t[e]=t[e]||{},t.emphasis=t.emphasis||{},t.emphasis[e]=t.emphasis[e]||{};for(var i=0,r=n.length;i=0||r&&xn(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var yl=ml([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),_l=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return yl(this,t,e)},t}(),bl=new co(50);function xl(t){if("string"==typeof t){var e=bl.get(t);return e&&e.image}return t}function wl(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=bl.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!Cl(e=o.image)&&o.pending.push(a):((e=en.loadImage(t,Sl,Sl)).__zrImageSrc=t,bl.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function Sl(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;c++)l-=s;var u=ja(a,n);return u>l&&(n="",u=0),l=t-u,r.ellipsis=n,r.ellipsisWidth=u,r.contentWidth=l,r.containerWidth=t,r}function Il(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=ja(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?Dl(e,r,o):a>0?Math.floor(e.length*r/a):0;a=ja(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function Dl(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),h=!0),i.accumWidth=f}else{var g=Ol(e,u,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=Wa(u),m=0;m=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!zl[t]}function Ol(t,e,n,i,r){for(var o=[],a=[],s="",l="",c=0,u=0,h=Wa(e),d=0;dn:r+u+f>n)?u?(s||l)&&(g?(s||(s=l,l="",u=c=0),o.push(s),a.push(u-c),l+=p,s="",u=c+=f):(l&&(s+=l,l="",c=0),o.push(s),a.push(u),s=p,u=f)):g?(o.push(l),a.push(c),l=p,c=f):(o.push(p),a.push(f)):(u+=f,g?(l+=p,c+=f):(l&&(s+=l,l="",c=0),s+=p))}else l&&(s+=l,u+=c),o.push(s),a.push(u),s="",l="",c=0,u=0}return l&&(s+=l),s&&(o.push(s),a.push(u)),1===o.length&&(u+=r),{accumWidth:u,lines:o,linesWidths:a}}function Rl(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;cr.set($l,Za(n,a,r),Ka(i,s,o),a,s),cr.intersect(e,$l,null,Hl);var l=Hl.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=Za(l.x,l.width,r,!0),t.baseY=Ka(l.y,l.height,o,!0)}}var $l=new cr(0,0,0,0),Hl={outIntersectRect:{},clamp:!0};function Fl(t){return null!=t?t+="":t=""}function Bl(t,e,n,i){var r=new cr(Za(t.x||0,e,t.textAlign),Ka(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:Vl(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function Vl(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var Wl="__zr_style_"+Math.round(10*Math.random()),Ul={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},Gl={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};Ul[Wl]=!0;var ql=["z","z2","invisible"],jl=["invisible"],Xl=function(t){function e(e){return t.call(this,e)||this}var n;return y(e,t),e.prototype._init=function(e){for(var n=In(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(nc[0]=tc(r)*n+t,nc[1]=Jl(r)*i+e,ic[0]=tc(o)*n+t,ic[1]=Jl(o)*i+e,c(s,nc,ic),u(l,nc,ic),(r%=ec)<0&&(r+=ec),(o%=ec)<0&&(o+=ec),r>o&&!a?o+=ec:rr&&(rc[0]=tc(p)*n+t,rc[1]=Jl(p)*i+e,c(s,rc,s),u(l,rc,l))}var hc={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},dc=[],pc=[],fc=[],gc=[],vc=[],mc=[],yc=Math.min,_c=Math.max,bc=Math.cos,xc=Math.sin,wc=Math.abs,Sc=Math.PI,Cc=2*Sc,kc="undefined"!=typeof Float32Array,Mc=[];function Tc(t){return Math.round(t/Sc*1e8)/1e8%2*Sc}var Ic=function(){function t(t){this.dpr=1,this._xi=0,this._yi=0,this._x0=0,this._y0=0,this._len=0,t&&(this._saveData=!1),this._saveData&&(this.data=[])}var e;return t.prototype.increaseVersion=function(){this._version++},t.prototype.getVersion=function(){return this._version},t.prototype.setScale=function(t,e,n){(n=n||0)>0&&(this._ux=wc(n/Da/t)||0,this._uy=wc(n/Da/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(hc.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=wc(t-this._xi),i=wc(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(hc.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(hc.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(hc.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),Mc[0]=i,Mc[1]=r,function(t,e){var n=Tc(t[0]);n<0&&(n+=Cc);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=Cc?r=n+Cc:e&&n-r>=Cc?r=n-Cc:!e&&n>r?r=n+(Cc-Tc(n-r)):e&&n0&&o))for(var a=0;ac.length&&(this._expandData(),c=this.data);for(var u=0;u0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){fc[0]=fc[1]=vc[0]=vc[1]=Number.MAX_VALUE,gc[0]=gc[1]=mc[0]=mc[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||wc(v)>i||h===e-1)&&(f=Math.sqrt(D*D+v*v),r=g,o=_);break;case hc.C:var m=t[h++],y=t[h++],_=(g=t[h++],t[h++]),b=t[h++],x=t[h++];f=Qr(r,o,m,y,g,_,b,x,10),r=b,o=x;break;case hc.Q:f=io(r,o,m=t[h++],y=t[h++],g=t[h++],_=t[h++],10),r=g,o=_;break;case hc.A:var w=t[h++],S=t[h++],C=t[h++],k=t[h++],M=t[h++],T=t[h++],I=T+M;h+=1,p&&(a=bc(M)*C+w,s=xc(M)*k+S),f=_c(C,k)*yc(Cc,Math.abs(T)),r=bc(I)*C+w,o=xc(I)*k+S;break;case hc.R:a=r=t[h++],s=o=t[h++],f=2*t[h++]+2*t[h++];break;case hc.Z:var D=a-r;v=s-o;f=Math.sqrt(D*D+v*v),r=a,o=s}f>=0&&(l[u++]=f,c+=f)}return this._pathLen=c,c},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,c,u,h,d=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,m=0,y=0,_=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,c=e*this._pathLen))t:for(var b=0;b0&&(t.lineTo(u,h),_=0),x){case hc.M:n=r=d[b++],i=o=d[b++],t.moveTo(r,o);break;case hc.L:a=d[b++],s=d[b++];var S=wc(a-r),C=wc(s-o);if(S>p||C>f){if(v){if(m+(X=l[y++])>c){var k=(c-m)/X;t.lineTo(r*(1-k)+a*k,o*(1-k)+s*k);break t}m+=X}t.lineTo(a,s),r=a,o=s,_=0}else{var M=S*S+C*C;M>_&&(u=a,h=s,_=M)}break;case hc.C:var T=d[b++],I=d[b++],D=d[b++],A=d[b++],P=d[b++],L=d[b++];if(v){if(m+(X=l[y++])>c){Kr(r,T,D,P,k=(c-m)/X,dc),Kr(o,I,A,L,k,pc),t.bezierCurveTo(dc[1],pc[1],dc[2],pc[2],dc[3],pc[3]);break t}m+=X}t.bezierCurveTo(T,I,D,A,P,L),r=P,o=L;break;case hc.Q:T=d[b++],I=d[b++],D=d[b++],A=d[b++];if(v){if(m+(X=l[y++])>c){no(r,T,D,k=(c-m)/X,dc),no(o,I,A,k,pc),t.quadraticCurveTo(dc[1],pc[1],dc[2],pc[2]);break t}m+=X}t.quadraticCurveTo(T,I,D,A),r=D,o=A;break;case hc.A:var E=d[b++],z=d[b++],N=d[b++],O=d[b++],R=d[b++],$=d[b++],H=d[b++],F=!d[b++],B=N>O?N:O,V=wc(N-O)>.001,W=R+$,U=!1;if(v)m+(X=l[y++])>c&&(W=R+$*(c-m)/X,U=!0),m+=X;if(V&&t.ellipse?t.ellipse(E,z,N,O,H,R,W,F):t.arc(E,z,B,R,W,F),U)break t;w&&(n=bc(R)*N+E,i=xc(R)*O+z),r=bc(W)*N+E,o=xc(W)*O+z;break;case hc.R:n=r=d[b],i=o=d[b+1],a=d[b++],s=d[b++];var G=d[b++],q=d[b++];if(v){if(m+(X=l[y++])>c){var j=c-m;t.moveTo(a,s),t.lineTo(a+yc(j,G),s),(j-=G)>0&&t.lineTo(a+G,s+yc(j,q)),(j-=q)>0&&t.lineTo(a+_c(G-j,0),s+q),(j-=G)>0&&t.lineTo(a,s+_c(q-j,0));break t}m+=X}t.rect(a,s,G,q);break;case hc.Z:if(v){var X;if(m+(X=l[y++])>c){k=(c-m)/X;t.lineTo(r*(1-k)+n*k,o*(1-k)+i*k);break t}m+=X}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=hc,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function Dc(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+h&&u>i+h&&u>o+h&&u>s+h||ut+h&&c>n+h&&c>r+h&&c>a+h||c=0&&pe+c&&l>i+c&&l>o+c||lt+c&&s>n+c&&s>r+c||s=0&&gn||u+cr&&(r+=zc);var d=Math.atan2(l,s);return d<0&&(d+=zc),d>=i&&d<=r||d+zc>=i&&d+zc<=r}function Oc(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var Rc=Ic.CMD,$c=2*Math.PI;var Hc=[-1,-1,-1],Fc=[-1,-1];function Bc(){var t=Fc[0];Fc[0]=Fc[1],Fc[1]=t}function Vc(t,e,n,i,r,o,a,s,l,c){if(c>e&&c>i&&c>o&&c>s||c1&&Bc(),p=jr(e,i,o,s,Fc[0]),d>1&&(f=jr(e,i,o,s,Fc[1]))),2===d?ve&&s>i&&s>o||s=0&&u<=1&&(r[l++]=u);else{var c=a*a-4*o*s;if(Gr(c))(u=-a/(2*o))>=0&&u<=1&&(r[l++]=u);else if(c>0){var u,h=Rr(c),d=(-a-h)/(2*o);(u=(-a+h)/(2*o))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}(e,i,o,s,Hc);if(0===l)return 0;var c=eo(e,i,o);if(c>=0&&c<=1){for(var u=0,h=Jr(e,i,o,c),d=0;dn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);Hc[0]=-l,Hc[1]=l;var c=Math.abs(i-r);if(c<1e-4)return 0;if(c>=$c-1e-4){i=0,r=$c;var u=o?1:-1;return a>=Hc[0]+t&&a<=Hc[1]+t?u:0}if(i>r){var h=i;i=r,r=h}i<0&&(i+=$c,r+=$c);for(var d=0,p=0;p<2;p++){var f=Hc[p];if(f+t>a){var g=Math.atan2(s,f);u=o?1:-1;g<0&&(g=$c+g),(g>=i&&g<=r||g+$c>=i&&g+$c<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(u=-u),d+=u)}}return d}function Gc(t,e,n,i,r){for(var o,a,s=t.data,l=t.len(),c=0,u=0,h=0,d=0,p=0,f=0;f1&&(n||(c+=Oc(u,h,d,p,i,r))),v&&(d=u=s[f],p=h=s[f+1]),g){case Rc.M:u=d=s[f++],h=p=s[f++];break;case Rc.L:if(n){if(Dc(u,h,s[f],s[f+1],e,i,r))return!0}else c+=Oc(u,h,s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case Rc.C:if(n){if(Ac(u,h,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=Vc(u,h,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case Rc.Q:if(n){if(Pc(u,h,s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=Wc(u,h,s[f++],s[f++],s[f],s[f+1],i,r)||0;u=s[f++],h=s[f++];break;case Rc.A:var m=s[f++],y=s[f++],_=s[f++],b=s[f++],x=s[f++],w=s[f++];f+=1;var S=!!(1-s[f++]);o=Math.cos(x)*_+m,a=Math.sin(x)*b+y,v?(d=o,p=a):c+=Oc(u,h,o,a,i,r);var C=(i-m)*b/_+m;if(n){if(Nc(m,y,b,x,x+w,S,e,C,r))return!0}else c+=Uc(m,y,b,x,x+w,S,C,r);u=Math.cos(x+w)*_+m,h=Math.sin(x+w)*b+y;break;case Rc.R:if(d=u=s[f++],p=h=s[f++],o=d+s[f++],a=p+s[f++],n){if(Dc(d,p,o,p,e,i,r)||Dc(o,p,o,a,e,i,r)||Dc(o,a,d,a,e,i,r)||Dc(d,a,d,p,e,i,r))return!0}else c+=Oc(o,p,o,a,i,r),c+=Oc(d,a,d,p,i,r);break;case Rc.Z:if(n){if(Dc(u,h,d,p,e,i,r))return!0}else c+=Oc(u,h,d,p,i,r);u=d,h=p}}return n||function(t,e){return Math.abs(t-e)<1e-4}(h,p)||(c+=Oc(u,h,d,p,i,r)||0),0!==c}var qc=bn({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},Ul),jc={style:bn({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},Gl.style)},Xc=Ba.concat(["invisible","culling","z","z2","zlevel","parent"]),Yc=function(t){function e(e){return t.call(this,e)||this}var n;return y(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?Aa:e>.2?"#eee":Pa}if(t)return Pa}return Aa},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(En(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===Io(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new Ic(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return Gc(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return Gc(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:_n(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return ni(qc,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=_n({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=_n({},i.shape),_n(s,n.shape)):(s=_n({},r?this.shape:i.shape),_n(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=_n({},this.shape);for(var c={},u=In(s),h=0;hc&&(n*=c/(a=n+i),i*=c/a),r+o>c&&(r*=c/(a=r+o),o*=c/a),i+r>u&&(i*=u/(a=i+r),r*=u/a),n+o>u&&(n*=u/(a=n+o),o*=u/a),t.moveTo(s+n,l),t.lineTo(s+c-i,l),0!==i&&t.arc(s+c-i,l+i,i,-Math.PI/2,0),t.lineTo(s+c,l+u-r),0!==r&&t.arc(s+c-r,l+u-r,r,0,Math.PI/2),t.lineTo(s+o,l+u),0!==o&&t.arc(s+o,l+u-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(Yc);su.prototype.type="rect";var lu={fill:"#000"},cu={},uu={style:bn({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},Gl.style)},hu=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=lu,n.attr(e),n}return y(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;em&&p){var _=Math.floor(m/d);f=f||v.length>_,y=(v=v.slice(0,_)).length*d}if(r&&u&&null!=g)for(var b=Tl(g,c,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),x={},w=0;w0,k=0;kg&&El(o,a.substring(g,v),e,f),El(o,d[2],e,f,d[1]),g=kl.lastIndex}gh){var z=o.lines.length;I>0?(k.tokens=k.tokens.slice(0,I),S(k,T,M),o.lines=o.lines.slice(0,C+1)):o.lines=o.lines.slice(0,C),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(T=_[M]).align;)this._placeToken(T,t,x,f,k,"right",v),w-=T.width,k-=T.width,M--;for(C+=(s-(C-p)-(g-k)-w)/2;S<=M;)T=_[S],this._placeToken(T,t,x,f,C+T.width/2,"center",v),C+=T.width,S++;f+=x}},e.prototype._placeToken=function(t,e,n,i,r,o,a){var s=e.rich[t.styleName]||{};s.text=t.text;var l=t.verticalAlign,c=i+n/2;"top"===l?c=i+t.height/2:"bottom"===l&&(c=i+n-t.height/2),!t.isLineHolder&&Su(s)&&this._renderBackground(s,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var u=!!s.backgroundColor,h=t.textPadding;h&&(r=xu(r,o,h),c-=t.height/2-h[0]-t.innerHeight/2);var d=this._getOrCreateChild(Kc),p=d.createStyle();d.useStyle(p);var f=this._defaultStyle,g=!1,v=0,m=!1,y=bu("fill"in s?s.fill:"fill"in e?e.fill:(g=!0,f.fill)),_=_u("stroke"in s?s.stroke:"stroke"in e?e.stroke:u||a||f.autoStroke&&!g?null:(v=2,m=!0,f.stroke)),b=s.textShadowBlur>0||e.textShadowBlur>0;p.text=t.text,p.x=r,p.y=c,b&&(p.shadowBlur=s.textShadowBlur||e.textShadowBlur||0,p.shadowColor=s.textShadowColor||e.textShadowColor||"transparent",p.shadowOffsetX=s.textShadowOffsetX||e.textShadowOffsetX||0,p.shadowOffsetY=s.textShadowOffsetY||e.textShadowOffsetY||0),p.textAlign=o,p.textBaseline="middle",p.font=t.font||Ke,p.opacity=Un(s.opacity,e.opacity,1),vu(p,s),_&&(p.lineWidth=Un(s.lineWidth,e.lineWidth,v),p.lineDash=Wn(s.lineDash,e.lineDash),p.lineDashOffset=e.lineDashOffset||0,p.stroke=_),y&&(p.fill=y),d.setBoundingRect(Bl(p,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,c=t.backgroundColor,u=t.borderWidth,h=t.borderColor,d=c&&c.image,p=c&&!d,f=t.borderRadius,g=this;if(p||t.lineHeight||u&&h){(a=this._getOrCreateChild(su)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=c||null,l.fillOpacity=Wn(t.fillOpacity,1);else if(d){(s=this._getOrCreateChild(tu)).onload=function(){g.dirtyStyle()};var m=s.style;m.image=c.image,m.x=n,m.y=i,m.width=r,m.height=o}u&&h&&((l=a.style).lineWidth=u,l.stroke=h,l.strokeOpacity=Wn(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var y=(a||s).style;y.shadowBlur=t.shadowBlur||0,y.shadowColor=t.shadowColor||"transparent",y.shadowOffsetX=t.shadowOffsetX||0,y.shadowOffsetY=t.shadowOffsetY||0,y.opacity=Un(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return mu(t)&&(e=[t.fontStyle,t.fontWeight,gu(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&Xn(e)||t.textFont||t.font},e}(Xl),du={left:!0,right:1,center:1},pu={top:1,bottom:1,middle:1},fu=["fontStyle","fontWeight","fontSize","fontFamily"];function gu(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function vu(t,e){for(var n=0;n=0,o=!1;if(t instanceof Yc){var a=Tu(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(Ru(s)||Ru(l)){var c=(i=i||{}).style||{};"inherit"===c.fill?(o=!0,i=_n({},i),(c=_n({},c)).fill=s):!Ru(c.fill)&&Ru(s)?(o=!0,i=_n({},i),(c=_n({},c)).fill=Ao(s)):!Ru(c.stroke)&&Ru(l)&&(o||(i=_n({},i),c=_n({},c)),c.stroke=Ao(l)),i.style=c}}if(i&&null==i.z2){o||(i=_n({},i));var u=t.z2EmphasisLift;i.z2=t.z2+(null!=u?u:10)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=xn(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function uh(t,e,n){gh(t,!0),qu(t,Yu),function(t,e,n){var i=Cu(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}(t,e,n)}function hh(t,e,n,i){i?function(t){gh(t,!1)}(t):uh(t,e,n)}var dh=["emphasis","blur","select"],ph={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function fh(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=Sh(f),s*=Sh(f));var g=(r===o?-1:1)*Sh((a*a*(s*s)-a*a*(p*p)-s*s*(d*d))/(a*a*(p*p)+s*s*(d*d)))||0,v=g*a*p/s,m=g*-s*d/a,y=(t+n)/2+kh(h)*v-Ch(h)*m,_=(e+i)/2+Ch(h)*v+kh(h)*m,b=Dh([1,0],[(d-v)/a,(p-m)/s]),x=[(d-v)/a,(p-m)/s],w=[(-1*d-v)/a,(-1*p-m)/s],S=Dh(x,w);if(Ih(x,w)<=-1&&(S=Mh),Ih(x,w)>=1&&(S=0),S<0){var C=Math.round(S/Mh*1e6)/1e6;S=2*Mh+C%2*Mh}u.addData(c,y,_,a,s,b,S,h,o)}var Ph=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,Lh=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var Eh=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e.prototype.applyTransform=function(t){},e}(Yc);function zh(t){return null!=t.setData}function Nh(t,e){var n=function(t){var e=new Ic;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=Ic.CMD,l=t.match(Ph);if(!l)return e;for(var c=0;cA*A+P*P&&(C=M,k=T),{cx:C,cy:k,x0:-u,y0:-h,x1:C*(r/x-1),y1:k*(r/x-1)}}function Qh(t,e){var n,i=Xh(e.r,0),r=Xh(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var c=e.cx,u=e.cy,h=!!e.clockwise,d=qh(l-s),p=d>Bh&&d%Bh;if(p>Zh&&(d=p),i>Zh)if(d>Bh-Zh)t.moveTo(c+i*Wh(s),u+i*Vh(s)),t.arc(c,u,i,s,l,!h),r>Zh&&(t.moveTo(c+r*Wh(l),u+r*Vh(l)),t.arc(c,u,r,l,s,h));else{var f=void 0,g=void 0,v=void 0,m=void 0,y=void 0,_=void 0,b=void 0,x=void 0,w=void 0,S=void 0,C=void 0,k=void 0,M=void 0,T=void 0,I=void 0,D=void 0,A=i*Wh(s),P=i*Vh(s),L=r*Wh(l),E=r*Vh(l),z=d>Zh;if(z){var N=e.cornerRadius;N&&(n=function(t){var e;if(Pn(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(N),f=n[0],g=n[1],v=n[2],m=n[3]);var O=qh(i-r)/2;if(y=Yh(O,v),_=Yh(O,m),b=Yh(O,f),x=Yh(O,g),C=w=Xh(y,_),k=S=Xh(b,x),(w>Zh||S>Zh)&&(M=i*Wh(l),T=i*Vh(l),I=r*Wh(s),D=r*Vh(s),dZh){var U=Yh(v,C),G=Yh(m,C),q=Kh(I,D,A,P,i,U,h),j=Kh(M,T,L,E,i,G,h);t.moveTo(c+q.cx+q.x0,u+q.cy+q.y0),C0&&t.arc(c+q.cx,u+q.cy,U,Gh(q.y0,q.x0),Gh(q.y1,q.x1),!h),t.arc(c,u,i,Gh(q.cy+q.y1,q.cx+q.x1),Gh(j.cy+j.y1,j.cx+j.x1),!h),G>0&&t.arc(c+j.cx,u+j.cy,G,Gh(j.y1,j.x1),Gh(j.y0,j.x0),!h))}else t.moveTo(c+A,u+P),t.arc(c,u,i,s,l,!h);else t.moveTo(c+A,u+P);if(r>Zh&&z)if(k>Zh){U=Yh(f,k),q=Kh(L,E,M,T,r,-(G=Yh(g,k)),h),j=Kh(A,P,I,D,r,-U,h);t.lineTo(c+q.cx+q.x0,u+q.cy+q.y0),k0&&t.arc(c+q.cx,u+q.cy,G,Gh(q.y0,q.x0),Gh(q.y1,q.x1),!h),t.arc(c,u,r,Gh(q.cy+q.y1,q.cx+q.x1),Gh(j.cy+j.y1,j.cx+j.x1),h),U>0&&t.arc(c+j.cx,u+j.cy,U,Gh(j.y1,j.x1),Gh(j.y0,j.x0),!h))}else t.lineTo(c+L,u+E),t.arc(c,u,r,l,s,h);else t.lineTo(c+L,u+E)}else t.moveTo(c,u);t.closePath()}}}var Jh=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},td=function(t){function e(e){return t.call(this,e)||this}return y(e,t),e.prototype.getDefaultShape=function(){return new Jh},e.prototype.buildPath=function(t,e){Qh(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(Yc);td.prototype.type="sector";var ed=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},nd=function(t){function e(e){return t.call(this,e)||this}return y(e,t),e.prototype.getDefaultShape=function(){return new ed},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(Yc);function id(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],c=[],u=[],h=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var d=0,p=t.length;dkd[1]){if(r=!1,Md.negativeSize||n)return r;var s=Sd(kd[0]-Cd[1]),l=Sd(Cd[0]-kd[1]);xd(s,l)>Id.len()&&(s=l||!Md.bidirectional)&&(Yi.scale(Td,a,-l*i),Md.useDir&&Md.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var h={duration:u.duration,delay:u.delay||0,easing:u.easing,done:o,force:!!o||!!a,setToFinal:!c,scope:t,during:a};l?e.animateFrom(n,h):e.animateTo(n,h)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function zd(t,e,n,i,r,o){Ed("update",t,e,n,i,r,o)}function Nd(t,e,n,i,r,o){Ed("enter",t,e,n,i,r,o)}function Od(t){if(!t.__zr)return!0;for(var e=0;e=-1e-6)return!1;var f=t-r,g=e-o,v=np(f,g,c,u)/p;if(v<0||v>1)return!1;var m=np(f,g,h,d)/p;return!(m<0||m>1)}function np(t,e,n,i){return t*i-n*e}function ip(t,e,n,i,r){return null==e||(Nn(e)?rp[0]=rp[1]=rp[2]=rp[3]=e:(rp[0]=e[0],rp[1]=e[1],rp[2]=e[2],rp[3]=e[3]),i&&(rp[0]=bs(0,rp[0]),rp[1]=bs(0,rp[1]),rp[2]=bs(0,rp[2]),rp[3]=bs(0,rp[3])),n&&(rp[0]=-rp[0],rp[1]=-rp[1],rp[2]=-rp[2],rp[3]=-rp[3]),op(t,rp,"x","width",3,1,r&&r[0]||0),op(t,rp,"y","height",0,2,r&&r[1]||0)),t}var rp=[0,0,0,0];function op(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=bs(0,_s(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:xs(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function ap(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=En(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&Cn(In(l),function(t){ii(s,t)||(s[t]=l[t],s.$vars.push(t))});var c=Cu(t.el);c.componentMainType=o,c.componentIndex=a,c.tooltipConfig={name:i,option:bn({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function sp(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function lp(t,e){if(t)if(Pn(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}},clipPointsByRect:function(t,e){return kn(t,function(t){var n=t[0];n=bs(n,e.x),n=_s(n,e.x+e.width);var i=t[1];return i=bs(i,e.y),[n,i=_s(i,e.y+e.height)]})},clipRectByRect:function(t,e){var n=bs(t.x,e.x),i=_s(t.x+t.width,e.x+e.width),r=bs(t.y,e.y),o=_s(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}},createIcon:tp,ensureCopyRect:hp,ensureCopyTransform:dp,expandOrShrinkRect:ip,extendPath:function(t,e){return Vd(t,e)},extendShape:function(t){return Yc.extend(t)},getShapeClass:function(t){if(Hd.hasOwnProperty(t))return Hd[t]},getTransform:function(t,e){for(var n=Wi([]);t&&t!==e;)Gi(n,t.getLocalTransform(),n),t=t.parent;return n},groupTransition:Jd,initProps:Nd,isBoundingRectAxisAligned:cp,isElementRemoved:Od,lineLineIntersect:ep,linePolygonIntersect:function(t,e,n,i,r){for(var o=0,a=r[r.length-1];oxs(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"},traverseElements:lp,traverseUpdateZ:fp,updateProps:zd}),mp={};function yp(t,e,n){var i,r=t.labelFetcher,o=t.labelDataIndex,a=t.labelDimIndex,s=e.normal;r&&(i=r.getFormattedLabel(o,"normal",null,a,s&&s.get("formatter"),null!=n?{interpolatedValue:n}:null)),null==i&&(i=Ln(t.defaultText)?t.defaultText(o,t,n):t.defaultText);for(var l={normal:i},c=0;c-1?Up:qp;function Zp(t,e){t=t.toUpperCase(),Xp[t]=new Bp(e),jp[t]=e}Zp(Gp,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),Zp(Up,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});function Kp(){return null}var Qp=1e3,Jp=6e4,tf=36e5,ef=864e5,nf=31536e6,rf={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},of={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},af="{yyyy}-{MM}-{dd}",sf={year:"{yyyy}",month:"{yyyy}-{MM}",day:af,hour:af+" "+of.hour,minute:af+" "+of.minute,second:af+" "+of.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},lf=["year","month","day","hour","minute","second","millisecond"],cf=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function uf(t){return En(t)||Ln(t)?t:function(t){t=t||{};var e={},n=!0;return Cn(lf,function(e){n&&(n=null==t[e])}),Cn(lf,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=lf[s],c=On(o)&&!Pn(o)?o[l]:o,u=void 0;Pn(c)?a=(u=c.slice())[0]||"":En(c)?u=[a=c]:(null==a?a=of[i]:rf[l].test(a)||(a=e[l][l][0]+" "+a),u=[a],n&&(u[1]="{primary|"+a+"}")),e[i][l]=u}}),e}(t)}function hf(t,e){return"0000".substr(0,e-(t+="").length)+t}function df(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function pf(t){return t===df(t)}function ff(t,e,n,i){var r=Ps(t),o=r[mf(n)](),a=r[yf(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[_f(n)](),c=r["get"+(n?"UTC":"")+"Day"](),u=r[bf(n)](),h=(u-1)%12+1,d=r[xf(n)](),p=r[wf(n)](),f=r[Sf(n)](),g=u>=12?"pm":"am",v=g.toUpperCase(),m=i instanceof Bp?i:function(t){return Xp[t]}(i||Yp)||Xp[qp],y=m.getModel("time"),_=y.get("month"),b=y.get("monthAbbr"),x=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,hf(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[a-1]).replace(/{MMM}/g,b[a-1]).replace(/{MM}/g,hf(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,hf(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,x[c]).replace(/{ee}/g,w[c]).replace(/{e}/g,c+"").replace(/{HH}/g,hf(u,2)).replace(/{H}/g,u+"").replace(/{hh}/g,hf(h+"",2)).replace(/{h}/g,h+"").replace(/{mm}/g,hf(d,2)).replace(/{m}/g,d+"").replace(/{ss}/g,hf(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,hf(f,3)).replace(/{S}/g,f+"")}function gf(t,e){var n=Ps(t),i=n[yf(e)]()+1,r=n[_f(e)](),o=n[bf(e)](),a=n[xf(e)](),s=n[wf(e)](),l=0===n[Sf(e)](),c=l&&0===s,u=c&&0===a,h=u&&0===o,d=h&&1===r;return d&&1===i?"year":d?"month":h?"day":u?"hour":c?"minute":l?"second":"millisecond"}function vf(t,e,n){switch(e){case"year":t[kf(n)](0);case"month":t[Mf(n)](1);case"day":t[Tf(n)](0);case"hour":t[If(n)](0);case"minute":t[Df(n)](0);case"second":t[Af(n)](0)}return t}function mf(t){return t?"getUTCFullYear":"getFullYear"}function yf(t){return t?"getUTCMonth":"getMonth"}function _f(t){return t?"getUTCDate":"getDate"}function bf(t){return t?"getUTCHours":"getHours"}function xf(t){return t?"getUTCMinutes":"getMinutes"}function wf(t){return t?"getUTCSeconds":"getSeconds"}function Sf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function Cf(t){return t?"setUTCFullYear":"setFullYear"}function kf(t){return t?"setUTCMonth":"setMonth"}function Mf(t){return t?"setUTCDate":"setDate"}function Tf(t){return t?"setUTCHours":"setHours"}function If(t){return t?"setUTCMinutes":"setMinutes"}function Df(t){return t?"setUTCSeconds":"setSeconds"}function Af(t){return t?"setUTCMilliseconds":"setMilliseconds"}function Pf(t){if(isNaN(zs(t)))return En(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function Lf(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var Ef=qn;function zf(t,e,n){function i(t){return t&&Xn(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?Ps(t):t;if(!isNaN(+s))return ff(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return zn(t)?i(t):Nn(t)&&r(t)?t+"":"-";var l=zs(t);return r(l)?Pf(l):zn(t)?i(t):"boolean"==typeof t?t+"":"-"}var Nf=["a","b","c","d","e","f","g"],Of=function(t,e){return"{"+t+(null==e?"":e)+"}"};function Rf(t,e,n){Pn(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;oi||l.newline?(o=0,u=g,a+=s+n,s=d.height):s=Math.max(s,d.height)}else{var v=d.height+(f?-f.y+d.y:0);(h=a+v)>r||l.newline?(o+=s+n,a=0,h=v,s=d.width):s=Math.max(s,d.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=u+n:a=h+n)})}function Jf(t,e,n){n=Ef(n||0);var i=e.width,r=e.height,o=Ss(t.left,i),a=Ss(t.top,r),s=Ss(t.right,i),l=Ss(t.bottom,r),c=Ss(t.width,i),u=Ss(t.height,r),h=n[2]+n[0],d=n[1]+n[3],p=t.aspect;switch(isNaN(c)&&(c=i-s-d-o),isNaN(u)&&(u=r-l-h-a),null!=p&&(isNaN(c)&&isNaN(u)&&(p>i/r?c=.8*i:u=.8*r),isNaN(c)&&(c=p*u),isNaN(u)&&(u=c/p)),isNaN(o)&&(o=i-s-c-d),isNaN(a)&&(a=r-l-u-h),t.left||t.right){case"center":o=i/2-c/2-n[3];break;case"right":o=i-c-d}switch(t.top||t.bottom){case"middle":case"center":a=r/2-u/2-n[0];break;case"bottom":a=r-u-h}o=o||0,a=a||0,isNaN(c)&&(c=i-d-o-(s||0)),isNaN(u)&&(u=r-h-a-(l||0));var f=new cr((e.x||0)+o+n[3],(e.y||0)+a+n[0],c,u);return f.margin=n,f}An(Qf,"vertical"),An(Qf,"horizontal");var tg=1;function eg(t,e,n){var i,r,o,a,s=t.boxCoordinateSystem;if(s){var l=function(t){var e=t.getShallow("coord",!0),n=Vf;if(null==e){var i=Uf.get(t.type);i&&i.getCoord2&&(n=Wf,e=i.getCoord2(t))}return{coord:e,from:n}}(t),c=l.coord,u=l.from;if(s.dataToLayout){o=tg,a=u;var h=s.dataToLayout(c);i=h.contentRect||h.rect}}return null==o&&(o=tg),o===tg&&(i||(i={x:0,y:0,width:e.getWidth(),height:e.getHeight()}),r=[i.x+i.width/2,i.y+i.height/2]),{type:o,refContainer:i,refPoint:r,boxCoordFrom:a}}function ng(t){var e=t.layoutMode||t.constructor.layoutMode;return On(e)?e:e?{type:e}:null}function ig(t,e,n){var i=n&&n.ignoreSize;!Pn(i)&&(i=[i,i]);var r=a(Kf[0],0),o=a(Kf[1],1);function a(n,r){var o={},a=0,l={},c=0;if(Yf(n,function(e){l[e]=t[e]}),Yf(n,function(t){ii(e,t)&&(o[t]=l[t]=e[t]),s(o,t)&&a++,s(l,t)&&c++}),i[r])return s(e,n[1])?l[n[2]]=null:s(e,n[2])&&(l[n[1]]=null),l;if(2!==c&&a){if(a>=2)return o;for(var u=0;u=0;a--)o=yn(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return al(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return e=!1,{left:(t=this).getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)};var t,e},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((n=e.prototype).type="component",n.id="",n.name="",n.mainType="",n.subType="",void(n.componentIndex=0)),e}(Bp);dl(ag,Bp),vl(ag),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=ul(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=ul(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(ag),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return Cn(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return Cn(t,function(t){xn(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),Cn(s,function(t){xn(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);xn(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,c={};for(Cn(t,function(t){c[t]=!0});l.length;){var u=l.pop(),h=s[u],d=!!c[u];d&&(r.call(o,u,h.originalDeps.slice()),delete c[u]),Cn(h.successor,d?f:p)}Cn(c,function(){throw new Error("")})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){c[t]=!0,p(t)}}}(ag,function(t){var e=[];Cn(ag.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=kn(e,function(t){return ul(t).main}),"dataset"!==t&&xn(e,"dataset")<=0&&e.unshift("dataset");return e});var sg={color:{},darkColor:{},size:{}},lg=sg.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var cg in _n(lg,{primary:lg.neutral80,secondary:lg.neutral70,tertiary:lg.neutral60,quaternary:lg.neutral50,disabled:lg.neutral20,border:lg.neutral30,borderTint:lg.neutral20,borderShade:lg.neutral40,background:lg.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:lg.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:lg.neutral70,axisLineTint:lg.neutral40,axisTick:lg.neutral70,axisTickMinor:lg.neutral60,axisLabel:lg.neutral70,axisSplitLine:lg.neutral15,axisMinorSplitLine:lg.neutral05}),lg)if(lg.hasOwnProperty(cg)){var ug=lg[cg];"theme"===cg?sg.darkColor.theme=lg.theme.slice():"highlight"===cg?sg.darkColor.highlight="rgba(255,231,130,0.4)":0===cg.indexOf("accent")?sg.darkColor[cg]=Mo(ug,0,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):sg.darkColor[cg]=Mo(ug,0,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}sg.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var hg="";"undefined"!=typeof navigator&&(hg=navigator.platform||"");var dg="rgba(0, 0, 0, 0.2)",pg=sg.color.theme[0],fg=Mo(pg,0,null,.9),gg={darkMode:"auto",colorBy:"series",color:sg.color.theme,gradientColor:[fg,pg],aria:{decal:{decals:[{color:dg,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:dg,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:dg,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:dg,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:dg,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:dg,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:hg.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},vg=ei(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),mg="original",yg="arrayRows",_g="objectRows",bg="keyedColumns",xg="typedArray",wg="unknown",Sg="column",Cg="row",kg=1,Mg=2,Tg=3,Ig=el();function Dg(t,e,n){var i={},r=Ag(e);if(!r||!t)return i;var o,a,s=[],l=[],c=e.ecModel,u=Ig(c).datasetMap,h=r.uid+"_"+n.seriesLayoutBy;Cn(t=t.slice(),function(e,n){var r=On(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var d=u.get(h)||u.set(h,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if(u=u||n,!u||!u.length)return;var h=u[l];r&&(c[r]=h);return s.paletteIdx=(l+1)%u.length,h}(this,Eg,i,r,t,e,n)},t.prototype.clearColorPalette=function(){!function(t,e){e(t).paletteIdx=0,e(t).paletteNameMap={}}(this,Eg)},t}();var $g="\0_ec_inner",Hg=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new Bp(i),this._locale=new Bp(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=Vg(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,Vg(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):Og(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&Cn(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=ei(),s=e&&e.replaceMergeMainTypeMap;Ig(this).datasetMap=ei(),Cn(t,function(t,e){null!=t&&(ag.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?mn(t):yn(n[e],t,!0))}),s&&s.each(function(t,e){ag.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),ag.topologicalTravel(o,ag.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=Lg.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,Ws(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",c=Xs(a,o,l);(function(t,e,n){Cn(t,function(t){var i=t.newOption;On(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(c,e,ag),n[e]=null,i.set(e,null),r.set(e,0);var u,h=[],d=[],p=0;Cn(c,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=ag.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(u)return;u=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=_n({componentIndex:n},t.keyInfo);_n(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(h.push(i.option),d.push(i),p++):(h.push(void 0),d.push(void 0))},this),n[e]=h,i.set(e,d),r.set(e,p),"series"===e&&zg(this)},this),this._seriesIndices||zg(this)},e.prototype.getOption=function(){var t=mn(this.option);return Cn(t,function(e,n){if(ag.hasClass(n)){for(var i=Ws(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!Js(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[$g],t},e.prototype.setTheme=function(t){this._theme=new Bp(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var Xg=Cn,Yg=On,Zg=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function Kg(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=Zg.length;nc&&(c=p)}s[0]=l,s[1]=c}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return Fv(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function Wv(t){var e,n;return On(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function Uv(t){return new Gv(t)}var Gv=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=u(this._modBy),s=this._modDataCount||0,l=u(t&&t.modBy),c=t&&t.modDataCount||0;function u(t){return!(t>=1)&&(t=1),t}a===l&&s===c||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=c;var h=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var d=this._dueIndex,p=Math.min(null!=h?this._dueIndex+h:1/0,this._dueEnd);if(!i&&(o||d1&&i>0?s:a}};return o;function a(){return e=t?null:oi?-this._resultLT:0},t}(),Yv=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return jv(t,e)},t}();function Zv(t){if(!nm(t.sourceFormat)){Fs("")}return t.data}function Kv(t){var e=t.sourceFormat,n=t.data;if(!nm(e)){Fs("")}if(e===yg){for(var i=[],r=0,o=n.length;r65535?om:am}function hm(){return[1/0,-1/0]}function dm(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function pm(t,e,n,i,r){var o=cm[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),c=0;cg[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=kn(o,function(t){return t.property}),c=0;cv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=c&&_<=u||isNaN(_))&&(a[s++]=p),p++}d=!0}else if(2===r){f=h[i[0]];var v=h[i[1]],m=t[i[1]][0],y=t[i[1]][1];for(g=0;g=c&&_<=u||isNaN(_))&&(b>=m&&b<=y||isNaN(b))&&(a[s++]=p),p++}d=!0}}if(!d)if(1===r)for(g=0;g=c&&_<=u||isNaN(_))&&(a[s++]=x)}else for(g=0;gt[C][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,c=Math.floor(1/e),u=this.getRawIndex(0),h=new(um(this._rawCount))(Math.min(2*(Math.ceil(s/c)+2),s));h[l++]=u;for(var d=1;dn&&(n=i,r=k)}C>0&&Ca&&(f=a-c);for(var g=0;gp&&(p=v,d=c+g)}var m=this.getRawIndex(u),y=this.getRawIndex(d);uc-p&&(s=c-p,a.length=s);for(var f=0;fu[1]&&(u[1]=v),h[d++]=m}return r._count=d,r._indices=h,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return jv(t[i],this._dimensions[i])}im={arrayRows:t,objectRows:function(t,e,n,i){return jv(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return jv(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),gm=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(vm(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var c=i[0];c.prepareSource(),a=(l=c.getSource()).data,s=l.sourceFormat,e=[c._getVersionSign()]}else s=$n(a=o.get("data",!0))?xg:mg,e=[];var u=this._getSourceMetaRawOption()||{},h=l&&l.metaRawOption||{},d=Wn(u.seriesLayoutBy,h.seriesLayoutBy)||null,p=Wn(u.sourceHeader,h.sourceHeader),f=Wn(u.dimensions,h.dimensions);t=d!==h.seriesLayoutBy||!!p!=!!h.sourceHeader||f?[wv(a,{seriesLayoutBy:d,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[wv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){1!==t.length&&mm("")}var o,a=[],s=[];return Cn(t,function(t){t.prepareSource();var e=t.getSource(r||0);null==r||e||mm(""),a.push(e),s.push(t._getVersionSign())}),i?e=function(t,e){var n=Ws(t),i=n.length;i||Fs("");for(var r=0,o=i;r1||n>0&&!t.noHeader;return Cn(t.blocks,function(t){var n=km(t);n>=e&&(e=n+ +(i&&(!n||Sm(t)&&!t.noHeader)))}),e}return 0}function Mm(t,e,n,i){var r,o=e.noHeader,a=(r=km(e),{html:bm[r],richText:xm[r]}),s=[],l=e.blocks||[];jn(!l||Pn(l)),l=l||[];var c=t.orderMode;if(e.sortBlocks&&c){l=l.slice();var u={valueAsc:"asc",valueDesc:"desc"};if(ii(u,c)){var h=new Xv(u[c],null);l.sort(function(t,e){return h.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===c&&l.reverse()}Cn(l,function(n,r){var o=e.valueFormatter,l=Cm(n)(o?_n(_n({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var d="richText"===t.renderMode?s.join(a.richText):Dm(i,s.join(""),o?n:a.html);if(o)return d;var p=zf(e.header,"ordinal",t.useUTC),f=_m(i,t.renderMode).nameStyle,g=ym(i);return"richText"===t.renderMode?Am(t,p,f)+a.richText+d:Dm(i,'
'+Ai(p)+"
"+d,n)}function Tm(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,c=t.useUTC,u=e.valueFormatter||t.valueFormatter||function(t){return kn(t=Pn(t)?t:[t],function(t,e){return zf(t,Pn(p)?p[e]:p,c)})};if(!o||!a){var h=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||sg.color.secondary,r),d=o?"":zf(l,"ordinal",c),p=e.valueType,f=a?[]:u(e.value,e.dataIndex),g=!s||!o,v=!s&&o,m=_m(i,r),y=m.nameStyle,_=m.valueStyle;return"richText"===r?(s?"":h)+(o?"":Am(t,d,y))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(Pn(e)?e.join(" "):e,o)}(t,f,g,v,_)):Dm(i,(s?"":h)+(o?"":function(t,e,n){return''+Ai(t)+""}(d,!s,y))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=Pn(t)?t:[t],''+kn(t,function(t){return Ai(t)}).join("  ")+""}(f,g,v,_)),n)}}function Im(t,e,n,i,r,o){if(t)return Cm(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function Dm(t,e,n){return'
'+e+'
'}function Am(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function Pm(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var Lm=function(){function t(){this.richTextStyles={},this._nextStyleNameId=Ns()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=function(t,e){var n=En(t)?{color:t,extraCssText:e}:t||{},i=n.color,r=n.type;e=n.extraCssText;var o=n.renderMode||"html";return i?"html"===o?"subItem"===r?'':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}({color:e,type:t,renderMode:n,markerId:i});return En(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};Pn(e)?Cn(e,function(t){return _n(n,t)}):_n(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function Em(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),c=l.mapDimensionsAll("defaultedTooltip"),u=c.length,h=o.getRawValue(a),d=Pn(h),p=function(t,e){return $f(t.getData().getItemVisual(e,"style")[t.visualDrawType])}(o,a);if(u>1||d&&!u){var f=function(t,e,n,i,r){var o=e.getData(),a=Mn(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],c=[];function u(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?c.push(wm("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?Cn(i,function(t){u(Fv(o,n,t),t)}):Cn(t,u),{inlineValues:s,inlineValueTypes:l,blocks:c}}(h,o,a,c,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(u){var g=l.getDimensionInfo(c[0]);r=e=Fv(l,a,c[0]),n=g.type}else r=e=d?h[0]:h;var v=Qs(o),m=v&&o.name||"",y=l.getName(a),_=s?m:y;return wm("section",{header:m,noHeader:s||!v,sortParam:r,blocks:[wm("nameValue",{markerType:"item",markerColor:p,name:_,noName:!Xn(_),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var zm=el();function Nm(t,e){return t.getName(e)||t.getId(e)}var Om=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var n;return y(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=Uv({count:$m,reset:Hm}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(zm(this).sourceManager=new gm(this)).prepareSource();var i=this.getInitialData(t,n);Bm(i,this),this.dataTask.context.data=i,zm(this).dataBeforeProcessed=i,Rm(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=ng(this),i=n?rg(t):{},r=this.subType;ag.hasClass(r)&&(r+="Series"),yn(t,e.getTheme().get(this.subType)),yn(t,this.getDefaultOption()),Us(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&ig(t,i,n)},e.prototype.mergeOption=function(t,e){t=yn(this.option,t,!0),this.fillDataTextStyle(t.data);var n=ng(this);n&&ig(this.option,t,n);var i=zm(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);Bm(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,zm(this).dataBeforeProcessed=r,Rm(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!$n(t))for(var e=["show"],n=0;n=0&&u<0)&&(c=o,u=r,h=0),r===u&&(l[h++]=e))}),l.length=h,l},e.prototype.formatTooltip=function(t,e,n){return Em({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(Ye.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=Rg.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[Nm(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this.__universalTransitionEnabled)return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){On(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return ag.registerClass(t)},e.protoInitialize=((n=e.prototype).type="series.__base__",n.seriesIndex=0,n.ignoreStyleOnData=!1,n.hasSymbolVisual=!1,n.defaultSymbol="circle",n.visualStyleAccessPath="itemStyle",void(n.visualDrawType="fill")),e}(ag);function Rm(t){var e=t.name;Qs(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return Cn(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function $m(t){return t.model.getRawData().count()}function Hm(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),Fm}function Fm(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function Bm(t,e){Cn(function(t,e){for(var n=new t.constructor(t.length+e.length),i=0;i=0?h():u=setTimeout(h,-r),l=i};return d.clear=function(){u&&(clearTimeout(u),u=null)},d.debounceNextCall=function(t){s=t},d}function ry(t,e,n,i){var r=t[e];if(r){var o=r[ty]||r,a=r[ny];if(r[ey]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=iy(o,n,"debounce"===i))[ty]=o,r[ny]=i,r[ey]=n}return r}}function oy(t,e){var n=t[e];n&&n[ty]&&(n.clear&&n.clear(),t[e]=n[ty])}var ay=el(),sy={itemStyle:ml($p,!0),lineStyle:ml(Np,!0)},ly={lineStyle:"stroke",itemStyle:"fill"};function cy(t,e){var n=t.visualStyleMapper||sy[e];return n||(console.warn("Unknown style type '"+e+"'."),sy.itemStyle)}function uy(t,e){var n=t.visualDrawType||ly[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var hy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=cy(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=uy(t,i),l=o[s],c=Ln(l)?l:null,u="auto"===o.fill||"auto"===o.stroke;if(!o[s]||c||u){var h=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=h,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||Ln(o.fill)?h:o.fill,o.stroke="auto"===o.stroke||Ln(o.stroke)?h:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&c)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=_n({},o);r[s]=c(i),e.setItemVisual(n,"style",r)}}}},dy=new Bp,py={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=cy(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){dy.option=n[i];var a=r(dy);_n(t.ensureUniqueItemVisual(e,"style"),a),dy.option.decal&&(t.setItemVisual(e,"decal",dy.option.decal),dy.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},fy={performRawSeries:!0,overallReset:function(t){var e=ei();t.eachSeries(function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),ay(t).scope=r}}),t.eachSeries(function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=ay(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=uy(e,a);r.each(function(t){var e=r.getRawIndex(t);i[e]=t}),n.each(function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),c=n.getName(t)||t+"",u=n.count();l[s]=e.getColorFromPalette(c,o,u)}})}})}},gy=Math.PI;var vy=function(){function t(t,e,n,i){this._stageTaskMap=ei(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=ei();t.eachSeries(function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;Cn(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{});jn(!(i.reset&&i.overallReset),""),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}Cn(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),c=l.seriesTaskMap,u=l.overallTask;if(u){var h,d=u.agentStubMap;d.each(function(t){a(i,t)&&(t.dirty(),h=!0)}),h&&u.dirty(),o.updatePayload(u,n);var p=o.getPerformArgs(u,i.block);d.each(function(t){t.perform(p)}),u.perform(p)&&(r=!0)}else c&&c.each(function(s,l){a(i,s)&&s.dirty();var c=o.getPerformArgs(s,i.block);c.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(c)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=ei(),s=t.seriesType,l=t.getTargetSeries;function c(e){var s=e.uid,l=a.set(s,o&&o.get(s)||Uv({plan:xy,reset:wy,count:ky}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(c):s?n.eachRawSeriesByType(s,c):l&&l(n,i).each(c)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||Uv({reset:my});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=ei(),l=t.seriesType,c=t.getTargetSeries,u=!0,h=!1;function d(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(h=!0,Uv({reset:yy,onDirty:by})));n.context={model:t,overallProgress:u},n.agent=o,n.__block=u,r._pipe(t,n)}jn(!t.createOnAllSeries,""),l?n.eachRawSeriesByType(l,d):c?c(n,i).each(d):(u=!1,Cn(n.getSeries(),d)),h&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return Ln(t)&&(t={overallReset:t,seriesType:My(t)}),t.uid=Wp("stageHandler"),e&&(t.visualType=e),t},t}();function my(t){t.overallReset(t.ecModel,t.api,t.payload)}function yy(t){return t.overallProgress&&_y}function _y(){this.agent.dirty(),this.getDownstream().dirty()}function by(){this.agent&&this.agent.dirty()}function xy(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function wy(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=Ws(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?kn(e,function(t,e){return Cy(e)}):Sy}var Sy=Cy(0);function Cy(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&u===r.length-c.length){var h=r.slice(0,u);"data"!==h&&(e.mainType=h,e[c.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return c(s,o,"mainType")&&c(s,o,"subType")&&c(s,o,"index","componentIndex")&&c(s,o,"name")&&c(s,o,"id")&&c(l,r,"name")&&c(l,r,"dataIndex")&&c(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function c(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),$y=["symbol","symbolSize","symbolRotate","symbolOffset"],Hy=$y.concat(["symbolKeepAspect"]),Fy={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a<$y.length;a++){var s=$y[a],l=t.get(s);Ln(l)?(o=!0,r[s]=l):i[s]=l}if(i.symbol=i.symbol||t.defaultSymbol,n.setVisual(_n({legendIcon:t.legendIcon||i.symbol,symbolKeepAspect:t.get("symbolKeepAspect")},i)),!e.isSeriesFiltered(t)){var c=In(r);return{dataEach:o?function(e,n){for(var i=t.getRawValue(n),o=t.getDataParams(n),a=0;a=0&&i_(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=i_(i)?i:0,r=i_(r)?r:1,o=i_(o)?o:0,a=i_(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:Nn(e)?[e]:Pn(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=kn(r,function(t){return t/a}),o/=a)}return[r,o]}var l_=new Ic(!0);function c_(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function u_(t){return"string"==typeof t&&"none"!==t}function h_(t){var e=t.fill;return null!=e&&"none"!==e}function d_(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function p_(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function f_(t,e,n){var i=wl(e.image,e.__image,n);if(Cl(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*oi),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var g_=["shadowBlur","shadowOffsetX","shadowOffsetY"],v_=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function m_(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){b_(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?Ul.opacity:a}(i||e.blend!==n.blend)&&(o||(b_(t,r),o=!0),t.globalCompositeOperation=e.blend||Ul.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[N_])if(this._disposed)this.id;else{var i,r,o;if(On(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[N_]=!0,lb(this),!this._model||e){var a=new qg(this._api),s=this._theme,l=this._model=new Hg;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},yb);var c={seriesTransition:o,optionChanged:!0};if(n)this[R_]={silent:i,updateParams:c},this[N_]=!1,this.getZr().wakeUp();else{try{U_(this),j_.update.call(this,null,c)}catch(t){throw this[R_]=null,this[N_]=!1,t}this._ssr||this._zr.flush(),this[R_]=null,this[N_]=!1,K_.call(this,i),Q_.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[N_])if(this._disposed)this.id;else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[R_]&&(null==i&&(i=this[R_].silent),r=this[R_].updateParams,this[R_]=null),this[N_]=!0,lb(this);try{this._updateTheme(t),n.setTheme(this._theme),U_(this),j_.update.call(this,{type:"setTheme"},r)}catch(t){throw this[N_]=!1,t}this[N_]=!1,K_.call(this,i),Q_.call(this,i)}}},e.prototype._updateTheme=function(t){En(t)&&(t=bb[t]),t&&((t=mn(t))&&pv(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||Ye.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){return t=t||{},this._zr.painter.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){return t=t||{},this._zr.painter.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return Cn(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;Cn(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return Cn(i,function(t){t.group.ignore=!1}),o}this.id},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(Sb[n]){var a=o,s=o,l=-1/0,c=-1/0,u=[],h=t&&t.pixelRatio||this.getDevicePixelRatio();Cn(wb,function(o,h){if(o.group===n){var d=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(mn(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),c=r(p.bottom,c),u.push({dom:d,left:p.left,top:p.top})}});var d=(l*=h)-(a*=h),p=(c*=h)-(s*=h),f=en.createCanvas(),g=ms(f,{renderer:e?"svg":"canvas"});if(g.resize({width:d,height:p}),e){var v="";return Cn(u,function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""}),g.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&g.painter.setBackgroundColor(t.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}return t.connectedBackgroundColor&&g.add(new su({shape:{x:0,y:0,width:d,height:p},style:{fill:t.connectedBackgroundColor}})),Cn(u,function(t){var e=new tu({style:{x:t.left*h-a,y:t.top*h-s,image:t.dom}});g.add(e)}),g.refreshImmediately(),f.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}this.id},e.prototype.convertToPixel=function(t,e,n){return X_(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return X_(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return X_(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return Cn(il(this._model,t),function(t,i){i.indexOf("Models")>=0&&Cn(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}},this)},this),!!n;this.id},e.prototype.getVisual=function(t,e){var n=il(this._model,t,{defaultMainType:"series"}),i=n.seriesModel.getData(),r=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?i.indexOfRawIndex(n.dataIndex):null;return null!=r?function(t,e,n){switch(n){case"color":return t.getItemVisual(e,"style")[t.getVisual("drawType")];case"opacity":return t.getItemVisual(e,"style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getItemVisual(e,n)}}(i,r,e):function(t,e){switch(e){case"color":return t.getVisual("style")[t.getVisual("drawType")];case"opacity":return t.getVisual("style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getVisual(e)}}(i,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;Cn(pb,function(e){var n=function(n){var i,r=t.getModel(),o=n.target;if("globalout"===e?i={}:o&&Wy(o,function(t){var e=Cu(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=_n({},e.eventData),!0},!0),i){var a=i.componentType,s=i.componentIndex;"markLine"!==a&&"markPoint"!==a&&"markArea"!==a||(a="series",s=i.seriesIndex);var l=a&&null!=s&&r.getComponent(a,s),c=l&&t["series"===l.mainType?"_chartsMap":"_componentsMap"][l.__viewId];i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:l,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;Cn(vb,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(Vy("map","selectchanged",e,i,t),Vy("pie","selectchanged",e,i,t)):"select"===t.fromAction?(Vy("map","selected",e,i,t),Vy("pie","selected",e,i,t)):"unselect"===t.fromAction&&(Vy("map","unselected",e,i,t),Vy("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?this.id:this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)this.id;else{this._disposed=!0,this.getDom()&&sl(this.getDom(),kb,"");var t=this,e=t._api,n=t._model;Cn(t._componentsViews,function(t){t.dispose(n,e)}),Cn(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete wb[t.id]}},e.prototype.resize=function(t){if(!this[N_])if(this._disposed)this.id;else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[R_]&&(null==i&&(i=this[R_].silent),n=!0,this[R_]=null),this[N_]=!0,lb(this);try{n&&U_(this),j_.update.call(this,{type:"resize",animation:_n({duration:0},t&&t.animation)})}catch(t){throw this[N_]=!1,t}this[N_]=!1,K_.call(this,i),Q_.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)this.id;else if(On(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),xb[t]){var n=xb[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?this.id:(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=_n({},t);return e.type=gb[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)this.id;else if(On(e)||(e={silent:!!e}),fb[t.type]&&this._model)if(this[N_])this._pendingActions.push(t);else{var n=e.silent;Z_.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&Ye.browser.weChat&&this._throttledZrFlush(),K_.call(this,n),Q_.call(this,n)}},e.prototype.updateLabelLayout=function(){A_.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)this.id;else{var e=t.seriesIndex;this.getModel().getSeriesByIndex(e).appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(Od(t))return;if(t instanceof Yc&&function(t){var e=Tu(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}U_=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),G_(t,!0),G_(t,!1),e.plan()},G_=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!Ye.node&&!Ye.worker&&e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)})}})}(t,e),A_.trigger("series:afterupdate",e,o,s)},ab=function(t){t[$_]=!0,t.getZr().wakeUp()},lb=function(t){t[O_]=(t[O_]+1)%1e3},sb=function(t){t[$_]&&(t.getZr().storage.traverse(function(t){Od(t)||e(t)}),t[$_]=!1)},rb=function(t){return new(function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return y(n,e),n.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},n.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},n.prototype.enterEmphasis=function(e,n){Qu(e,n),ab(t)},n.prototype.leaveEmphasis=function(e,n){Ju(e,n),ab(t)},n.prototype.enterBlur=function(e){!function(t){qu(t,Bu)}(e),ab(t)},n.prototype.leaveBlur=function(e){th(e),ab(t)},n.prototype.enterSelect=function(e){eh(e),ab(t)},n.prototype.leaveSelect=function(e){nh(e),ab(t)},n.prototype.getModel=function(){return t.getModel()},n.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},n.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},n.prototype.getMainProcessVersion=function(){return t[O_]},n}(Ug))(t)},ob=function(t){function e(t,e){for(var n=0;n=0)){Eb.push(n);var o=vy.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function Nb(t,e){xb[t]=e}var Ob=function(t){var e=(t=mn(t)).type;e||Fs("");var n=e.split(":");2!==n.length&&Fs("");var i=!1;"echarts"===n[0]&&(e=n[1],i=!0),t.__isBuiltIn=i,tm.set(e,t)};function Rb(t,e,n,i){return{eventContent:{selected:ch(n),isFromClick:e.isFromClick||!1}}}function $b(t){return null==t?0:t.length||1}function Hb(t){return t}Lb(L_,hy),Lb(E_,py),Lb(E_,fy),Lb(L_,Fy),Lb(E_,By),Lb(7e3,function(t,e){t.eachRawSeries(function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each(function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=M_(n,e))});var r=i.getVisual("decal");if(r)i.getVisual("style").decal=M_(r,e)}})}),Ib(pv),Db(900,function(t){var e=ei();t.eachSeries(function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.push(o)}}),e.each(function(t){0!==t.length&&("seriesDesc"===(t[0].seriesModel.get("stackOrder")||"seriesAsc")&&t.reverse(),Cn(t,function(e,n){e.data.setCalculationInfo("stackedOnSeries",n>0?t[n-1].seriesModel:null)}),function(t){Cn(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,c,u){var h,d,p=a.get(e.stackedDimension,u);if(isNaN(p))return r;s?d=a.getRawIndex(u):h=a.get(e.stackedByDimension,u);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(d=v.data.rawIndexOf(v.stackedByDimension,h)),d>=0){var m=v.data.getByRawIndex(v.stackResultDimension,d);if("all"===l||"positive"===l&&m>0||"negative"===l&&m<0||"samesign"===l&&p>=0&&m>0||"samesign"===l&&p<=0&&m<0){p=Ts(p,m),f=m;break}}}return i[0]=p,i[1]=f,i})})}(t))})}),Nb("default",function(t,e){bn(e=e||{},{text:"loading",textColor:sg.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:sg.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new ds,i=new su({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new hu({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new su({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new vd({shape:{startAngle:-gy/2,endAngle:-gy/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*gy/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*gy/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),c=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:c}),a.setShape({x:l-s,y:c-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),Pb({type:Pu,event:Pu,update:Pu},ri),Pb({type:Lu,event:Lu,update:Lu},ri),Pb({type:Eu,event:Ou,update:Eu,action:ri,refineEvent:Rb,publishNonRefinedEvent:!0}),Pb({type:zu,event:Ou,update:zu,action:ri,refineEvent:Rb,publishNonRefinedEvent:!0}),Pb({type:Nu,event:Ou,update:Nu,action:ri,refineEvent:Rb,publishNonRefinedEvent:!0}),Tb("default",{}),Tb("dark",Oy);var Fb=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||Hb,this._newKeyGetter=i||Hb,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var c=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(c,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===h)this._updateManyToOne&&this._updateManyToOne(c,l),i[s]=null;else if(1===u&&h>1)this._updateOneToMany&&this._updateOneToMany(c,l),i[s]=null;else if(1===u&&1===h)this._update&&this._update(c,l),i[s]=null;else if(u>1&&h>1)this._updateManyToMany&&this._updateManyToMany(c,l),i[s]=null;else if(u>1)for(var d=0;d1)for(var a=0;a30}var Kb,Qb,Jb,tx,ex,nx,ix,rx=On,ox=kn,ax="undefined"==typeof Int32Array?Array:Int32Array,sx=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],lx=["_approximateExtent"],cx=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;jb(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},c=0;c=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===mg&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(Pn(r=this.getVisual(e))?r=r.slice():rx(r)&&(r=_n({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,rx(e)?_n(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){rx(t)?_n(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?_n(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){!function(t,e,n,i){if(i){var r=Cu(i);r.dataIndex=n,r.dataType=e,r.seriesIndex=t,r.ssrType="chart","group"===i.type&&i.traverse(function(i){var r=Cu(i);r.seriesIndex=t,r.dataIndex=n,r.dataType=e,r.ssrType="chart"})}}(this.hostModel&&this.hostModel.seriesIndex,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){Cn(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:ox(this.dimensions,this._getDimInfo,this),this.hostModel)),ex(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];Ln(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(Gn(arguments)))})},t.internalField=(Kb=function(t){var e=t._invertedIndicesMap;Cn(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new ax(o.categories.length);for(var s=0;s1&&(s+="__ec__"+c),i[e]=s}})),t}();function ux(t,e){xv(t)||(t=Sv(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=ei(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return Cn(e,function(t){var e;On(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&Zb(a),l=i===t.dimensionsDefine,c=l?Yb(t):Xb(i),u=e.encodeDefine;!u&&e.encodeDefaulter&&(u=e.encodeDefaulter(t,a));for(var h=ei(u),d=new sm(a),p=0;p0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new qb({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function hx(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var dx=function(t){this.coordSysDims=[],this.axisMap=ei(),this.categoryAxisMap=ei(),this.coordSysName=t};var px={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",ol).models[0],o=t.getReferringComponents("yAxis",ol).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),fx(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),fx(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",ol).models[0];e.coordSysDims=["single"],n.set("single",r),fx(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",ol).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),fx(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),fx(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();Cn(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),fx(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",ol).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function fx(t){return"category"===t.get("type")}function gx(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!jb(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,c,u,h,d=!(!t||!t.get("stack"));if(Cn(i,function(t,e){En(t)&&(i[e]=t={name:t}),d&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),c||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(c=t))}),!c||a||l||(a=!0),c){u="__\0ecstackresult_"+t.id,h="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var p=c.coordDim,f=c.type,g=0;Cn(i,function(t){t.coordDim===p&&g++});var v={name:u,coordDim:p,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},m={name:h,coordDim:h,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(v.storeDimIndex=o.ensureCalculationDimension(h,f),m.storeDimIndex=o.ensureCalculationDimension(u,f)),r.appendCalculationDimension(v),r.appendCalculationDimension(m)):(i.push(v),i.push(m))}return{stackedDimension:c&&c.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:h,stackResultDimension:u}}function vx(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function mx(t,e,n){n=n||{};var i,r,o=e.getSourceManager();r=(i=o.getSource()).sourceFormat===mg;var a=function(t){var e=t.get("coordinateSystem"),n=new dx(e),i=px[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=Bf.get(i);return e&&e.coordSysDims&&(n=kn(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=function(t){return"category"===t?"ordinal":"time"===t?"time":"float"}(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,c=Ln(l)?l:l?An(Dg,s,e):null,u=ux(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:c,canOmitUnusedDimensions:!r}),h=function(t,e,n){var i,r;return n&&Cn(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(u.dimensions,n.createInvertedIndices,a),d=r?null:o.getSharedDataStore(u),p=gx(e,{schema:u,store:d}),f=new cx(u,e);f.setCalculationInfo(p);var g=null!=h&&function(t){if(t.sourceFormat===mg){var e=function(t){var e=0;for(;er&&(a=o.interval=r);var s=o.intervalPrecision=xx(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),Sx(t,0,e),Sx(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(o.niceTickExtent=[ks(Math.ceil(t[0]/a)*a,s),ks(Math.floor(t[1]/a)*a,s)],t),o}function bx(t){var e=Math.pow(10,Ls(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,ks(n*e)}function xx(t){return Ms(t)+2}function Sx(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function Cx(t,e){return t>=e[0]&&t<=e[1]}var kx=function(){function t(){this.normalize=Mx,this.scale=Tx}return t.prototype.updateMethods=function(t){t.hasBreaks()?(this.normalize=Dn(t.normalize,t),this.scale=Dn(t.scale,t)):(this.normalize=Mx,this.scale=Tx)},t}();function Mx(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function Tx(t,e){return t*(e[1]-e[0])+e[0]}function Ix(t,e,n){var i=Math.log(t);return[Math.log(n?e[0]:Math.max(0,e[0]))/i,Math.log(n?e[1]:Math.max(0,e[1]))/i]}var Dx=function(){function t(t){this._calculator=new kx,this._setting=t||{},this._extent=[1/0,-1/0]}return t.prototype.getSetting=function(t){return this._setting[t]},t.prototype._innerUnionExtent=function(t){var e=this._extent;this._innerSetExtent(t[0]e[1]?t[1]:e[1])},t.prototype.unionExtentFromData=function(t,e){this._innerUnionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){this._innerSetExtent(t,e)},t.prototype._innerSetExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e),this._brkCtx&&this._brkCtx.update(n)},t.prototype.setBreaksFromOption=function(t){},t.prototype._innerSetBreak=function(t){this._brkCtx&&(this._brkCtx.setBreaks(t),this._calculator.updateMethods(this._brkCtx),this._brkCtx.update(this._extent))},t.prototype._innerGetBreaks=function(){return this._brkCtx?this._brkCtx.breaks:[]},t.prototype.hasBreaks=function(){return!!this._brkCtx&&this._brkCtx.hasBreaks()},t.prototype._getExtentSpanWithBreaks=function(){return this._brkCtx&&this._brkCtx.hasBreaks()?this._brkCtx.getExtentSpan():this._extent[1]-this._extent[0]},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();vl(Dx);var Ax=0,Px=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++Ax,this._onCollect=t.onCollect}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&kn(i,Lx);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!En(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,this._onCollect&&this._onCollect(t,e),e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e),this._onCollect&&this._onCollect(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=ei(this.categories))},t}();function Lx(t){return On(t)&&null!=t.value?t.value:t+""}var Ex=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new Px({})),Pn(i)&&(i=new Px({categories:kn(i,function(t){return On(t)?t.value:t})})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return y(e,t),e.prototype.parse=function(t){return null==t?NaN:En(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return Cx(t,this._extent)&&t>=0&&t=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(Dx);Dx.registerClass(Ex);var zx=ks,Nx=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return y(e,t),e.prototype.parse=function(t){return null==t||""===t?NaN:Number(t)},e.prototype.contain=function(t){return Cx(t,this._extent)},e.prototype.normalize=function(t){return this._calculator.normalize(t,this._extent)},e.prototype.scale=function(t){return this._calculator.scale(t,this._extent)},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=xx(t)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;t.breakTicks;n[0]=0&&(s=zx(s+l*e,r))}if(o.length>0&&s===o[o.length-1].value)break;if(o.length>1e4)return[]}var c=o.length?o[o.length-1].value:i[1];return n[1]>c&&(t.expandToNicedExtent?o.push({value:zx(c+e,r)}):o.push({value:n[1]})),t.breakTicks,o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks({expandToNicedExtent:!0}),n=[],i=this.getExtent(),r=1;ri[0]&&h0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return Cn(t,function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),c=r.scale.getExtent(),u=Math.abs(c[1]-c[0]);i=s?l/u*s:l}else{var h=t.getData();i=Math.abs(o[1]-o[0])/h.count()}var d=Ss(t.get("barWidth"),i),p=Ss(t.get("barMaxWidth"),i),f=Ss(t.get("barMinWidth")||(function(t){return t.pipelineContext&&t.pipelineContext.large}(t)?.5:1),i),g=t.get("barGap"),v=t.get("barCategoryGap"),m=t.get("defaultBarGap");n.push({bandWidth:i,barWidth:d,barMaxWidth:p,barMinWidth:f,barGap:g,barCategoryGap:v,defaultBarGap:m,axisKey:Fx(r),stackId:Hx(t)})}),function(t){var e={};Cn(t,function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:t.defaultBarGap||0,stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var c=t.barMaxWidth;c&&(a[s].maxWidth=c);var u=t.barMinWidth;u&&(a[s].minWidth=u);var h=t.barGap;null!=h&&(o.gap=h);var d=t.barCategoryGap;null!=d&&(o.categoryGap=d)});var n={};return Cn(e,function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=In(i).length;o=Math.max(35-4*a,15)+"%"}var s=Ss(o,r),l=Ss(t.gap,1),c=t.remainedWidth,u=t.autoWidthCount,h=(c-s)/(u+(u-1)*l);h=Math.max(h,0),Cn(i,function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,c-=i+l*i,u--}else{var i=h;e&&ei&&(i=n),i!==h&&(t.width=i,c-=i+l*i,u--)}}),h=(c-s)/(u+(u-1)*l),h=Math.max(h,0);var d,p=0;Cn(i,function(t,e){t.width||(t.width=h),d=t,p+=t.width*(1+l)}),d&&(p-=d.width*l);var f=-p/2;Cn(i,function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)})}),n}(n)}var Vx=function(t){function e(e){var n=t.call(this,e)||this;return n.type="time",n}return y(e,t),e.prototype.getLabel=function(t){var e=this.getSetting("useUTC");return ff(t.value,sf[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(df(this._minLevelUnit))]||sf.second,e,this.getSetting("locale"))},e.prototype.getFormattedLabel=function(t,e,n){var i=this.getSetting("useUTC");return function(t,e,n,i,r){var o=null;if(En(n))o=n;else if(Ln(n)){var a={time:t.time,level:t.time.level},s=null;s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var c=n[l.lowerTimeUnit][l.upperTimeUnit];o=c[Math.min(l.level,c.length-1)]||""}else{var u=gf(t.value,r);o=n[u][u][0]}}return ff(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=[];if(!e)return i;var r=this.getSetting("useUTC"),o=gf(n[1],r);i.push({value:n[0],time:{level:0,upperTimeUnit:o,lowerTimeUnit:o}});var a=function(t,e,n,i,r,o){var a=1e4,s=cf,l=0;function c(t,e,n,r,s,c,u){for(var h=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),d=e,p=new Date(d);da));)if(p[s](p[r]()+t),d=p.getTime(),o){var f=o.calcNiceTickMultiple(d,h);f>0&&(p[s](p[r]()+f*t),d=p.getTime())}u.push({value:d,notAdd:!0})}function u(t,r,o){var a=[],s=!r.length;if(!function(t,e,n,i){return vf(new Date(e),t,i).getTime()===vf(new Date(n),t,i).getTime()}(df(t),i[0],i[1],n)){s&&(r=[{value:Yx(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&u<=i[1]&&c(d,u,h,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-d})}}for(l=0;l=i[0]&&_<=i[1]&&p++)}var b=r/e;if(p>1.5*b&&f>b/1.5)break;if(h.push(m),p>b||t===s[g])break}d=[]}}var x=Tn(kn(h,function(t){return Tn(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=[],S=x.length-1;for(g=0;gn&&(this._approxInterval=n);var r=Wx.length,o=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function Gx(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function qx(t){return(t/=tf)>12?12:t>6?6:t>3.5?4:t>2?2:1}function jx(t,e){return(t/=e?Jp:Qp)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function Xx(t){return Es(t)}function Yx(t,e,n){var i=Math.max(0,xn(lf,e)-1);return vf(new Date(t),lf[i],n).getTime()}Dx.registerClass(Vx);var Zx=ks,Kx=Math.floor,Qx=Math.ceil,Jx=Math.pow,tw=Math.log,ew=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new Nx,e}return y(e,t),e.prototype.getTicks=function(e){e=e||{};var n=this._extent.slice(),i=this._originalScale.getExtent(),r=t.prototype.getTicks.call(this,e),o=this.base;return this._originalScale._innerGetBreaks(),kn(r,function(t){var e=t.value,r=null,a=Jx(o,e);return e===n[0]&&this._fixMin?r=i[0]:e===n[1]&&this._fixMax&&(r=i[1]),null!=r&&(a=nw(a,r)),{value:a,break:void 0}},this)},e.prototype._getNonTransBreaks=function(){return this._originalScale._innerGetBreaks()},e.prototype.setExtent=function(e,n){this._originalScale.setExtent(e,n);var i=Ix(this.base,[e,n]);t.prototype.setExtent.call(this,i[0],i[1])},e.prototype.getExtent=function(){var e=this.base,n=t.prototype.getExtent.call(this);n[0]=Jx(e,n[0]),n[1]=Jx(e,n[1]);var i=this._originalScale.getExtent();return this._fixMin&&(n[0]=nw(n[0],i[0])),this._fixMax&&(n[1]=nw(n[1],i[1])),n},e.prototype.unionExtentFromData=function(t,e){this._originalScale.unionExtentFromData(t,e);var n=Ix(this.base,t.getApproximateExtent(e),!0);this._innerUnionExtent(n)},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent.slice(),n=this._getExtentSpanWithBreaks();if(isFinite(n)&&!(n<=0)){var i,r=(i=n,Math.pow(10,Ls(i)));for(t/n*r<=.5&&(r*=10);!isNaN(r)&&Math.abs(r)<1&&Math.abs(r)>0;)r*=10;var o=[Zx(Qx(e[0]/r)*r),Zx(Kx(e[1]/r)*r)];this._interval=r,this._intervalPrecision=xx(r),this._niceExtent=o}},e.prototype.calcNiceExtent=function(e){t.prototype.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},e.prototype.contain=function(e){return e=tw(e)/tw(this.base),t.prototype.contain.call(this,e)},e.prototype.normalize=function(e){return e=tw(e)/tw(this.base),t.prototype.normalize.call(this,e)},e.prototype.scale=function(e){return e=t.prototype.scale.call(this,e),Jx(this.base,e)},e.prototype.setBreaksFromOption=function(t){},e.type="log",e}(Nx);function nw(t,e){return Zx(t,Ms(e))}Dx.registerClass(ew);var iw=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!c&&(s=0));var h=this._determinedMin,d=this._determinedMax;return null!=h&&(a=h,l=!0),null!=d&&(s=d,c=!0),{min:a,max:s,minFixed:l,maxFixed:c,isBlank:u}},t.prototype.modifyDataMinMax=function(t,e){this[ow[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){this[rw[t]]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),rw={min:"_determinedMin",max:"_determinedMax"},ow={min:"_dataMin",max:"_dataMax"};function aw(t,e){return null==e?null:Bn(e)?NaN:t.parse(e)}function sw(t,e){var n=t.type,i=function(t,e,n){var i=t.rawExtentInfo;return i||(i=new iw(t,e,n),t.rawExtentInfo=i,i)}(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=function(t,e){var n=[];return e.eachSeriesByType(t,function(t){(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type})(t)&&n.push(t)}),n}("bar",a),l=!1;if(Cn(s,function(t){l=l||t.getBaseAxis()===e.axis}),l){var c=Bx(s),u=function(t,e,n,i){var r=n.axis.getExtent(),o=Math.abs(r[1]-r[0]),a=function(t,e){if(t&&e)return t[Fx(e)]}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;Cn(a,function(t){s=Math.min(t.offset,s)});var l=-1/0;Cn(a,function(t){l=Math.max(t.offset+t.width,l)}),s=Math.abs(s),l=Math.abs(l);var c=s+l,u=e-t,h=u/(1-(s+l)/o)-u;return e+=h*(l/c),t-=h*(s/c),{min:t,max:e}}(r,o,e,c);r=u.min,o=u.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function lw(t,e){var n=e,i=sw(t,n),r=i.extent,o=n.get("splitNumber");t instanceof ew&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setBreaksFromOption(vw(n)),t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function cw(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=uf(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(En(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(Ln(e)){if("category"===t.type)return function(n,i){return e(uw(t,n),n.value-t.scale.getExtent()[0],null)};var i=null;return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(uw(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function uw(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function hw(t){var e=t.get("interval");return null==e?"auto":e}function dw(t){return"category"===t.type&&0===hw(t.getLabelModel())}function pw(t,e){var n={};return Cn(t.mapDimensionsAll(e),function(e){n[function(t,e){return vx(t,e)?t.getCalculationInfo("stackResultDimension"):e}(t,e)]=!0}),In(n)}function fw(t){return"middle"===t||"center"===t}function gw(t){return t.getShallow("show")}function vw(t){t.get("breaks",!0)}var mw=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),yw=[],_w={registerPreprocessor:Ib,registerProcessor:Db,registerPostInit:function(t){Ab("afterinit",t)},registerPostUpdate:function(t){Ab("afterupdate",t)},registerUpdateLifecycle:Ab,registerAction:Pb,registerCoordinateSystem:function(t,e){Bf.register(t,e)},registerLayout:function(t,e){zb(_b,t,e,1e3,"layout")},registerVisual:Lb,registerTransform:Ob,registerLoading:Nb,registerMap:function(t,e,n){var i=P_["registerMap"];i&&i(t,e,n)},registerImpl:function(t,e){P_[t]=e},PRIORITY:z_,ComponentModel:ag,ComponentView:Um,SeriesModel:Om,ChartView:Xm,registerComponentModel:function(t){ag.registerClass(t)},registerComponentView:function(t){Um.registerClass(t)},registerSeriesModel:function(t){Om.registerClass(t)},registerChartView:function(t){Xm.registerClass(t)},registerCustomSeries:function(t,e){},registerSubTypeDefaulter:function(t,e){ag.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){var n;n=e,ps[t]=n}};function bw(t){Pn(t)?Cn(t,function(t){bw(t)}):xn(yw,t)>=0||(yw.push(t),Ln(t)&&(t={install:t}),t.install(_w))}var xw=el(),ww=el(),Sw=1,Cw=2;function kw(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function Mw(t,e){var n=kn(e,function(e){return t.scale.parse(e)});return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function Tw(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=cw(t),r=t.scale.getExtent();return{labels:kn(Tn(Mw(t,n),function(t){return t>=r[0]&&t<=r[1]}),function(e){var n={value:e};return{formattedLabel:i(n),rawLabel:t.scale.getLabel(n),tickValue:e,time:void 0,break:void 0}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=Dw(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=cw(t);return{labels:kn(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value,time:e.time,break:e.break}})}}(t)}function Iw(t,e,n){var i=t.getTickModel().get("customValues");if(i){var r=t.scale.getExtent();return{ticks:Tn(Mw(t,i),function(t){return t>=r[0]&&t<=r[1]})}}return"category"===t.type?function(t,e){var n,i,r=Aw(t),o=hw(e),a=Ew(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(Ln(o))n=$w(t,o,!0);else if("auto"===o){var s=Dw(t,t.getLabelModel(),kw(Cw));i=s.labelCategoryInterval,n=kn(s.labels,function(t){return t.tickValue})}else n=Rw(t,i=o,!0);return zw(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:kn(t.scale.getTicks(n),function(t){return t.value})}}function Dw(t,e,n){var i,r,o=Pw(t),a=hw(e),s=n.kind===Sw;if(!s){var l=Ew(o,a);if(l)return l}Ln(a)?i=$w(t,a):(r="auto"===a?function(t,e){if(e.kind===Sw){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return ww(t).autoInterval=n,!0}),n}var i=ww(t).autoInterval;return null!=i?i:ww(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=Rw(t,r));var c={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return zw(o,a,c),!0}):zw(o,a,c),c}var Aw=Lw("axisTick"),Pw=Lw("axisLabel");function Lw(t){return function(e){return ww(e)[t]||(ww(e)[t]={list:[]})}}function Ew(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function Rw(t,e,n){var i=cw(t),r=t.scale,o=r.getExtent(),a=t.getLabelModel(),s=[],l=Math.max((e||0)+1,1),c=o[0],u=r.count();0!==c&&l>1&&u/l>2&&(c=Math.round(Math.ceil(c/l)*l));var h=dw(t),d=a.get("showMinLabel")||h,p=a.get("showMaxLabel")||h;d&&c!==o[0]&&g(o[0]);for(var f=c;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t,time:void 0,break:void 0})}return p&&f-l!==o[1]&&g(o[1]),s}function $w(t,e,n){var i=t.scale,r=cw(t),o=[];return Cn(i.getTicks(),function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s,time:void 0,break:void 0})}),o}var Hw=[0,1],Fw=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return function(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(xs(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(i.parse(t)),this.onBand&&"ordinal"===i.type&&Bw(n=n.slice(),i.count()),ws(t,Hw,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&Bw(n=n.slice(),i.count());var r=ws(t,n,Hw,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=kn(Iw(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}},this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],e[0].onBand=!0,o=e[1]={coord:s[1],tickValue:e[0].tickValue,onBand:!0};else{var l=e[r-1].tickValue-e[0].tickValue,c=(e[r-1].coord-e[0].coord)/l;Cn(e,function(t){t.coord-=c/2,t.onBand=!0});var u=t.scale.getExtent();a=1+u[1]-e[r-1].tickValue,o={coord:e[r-1].coord+c*a,tickValue:u[1]+1,onBand:!0},e.push(o)}var h=s[0]>s[1];d(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&d(s[0],e[0].coord)&&e.unshift({coord:s[0],onBand:!0});d(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&d(o.coord,s[1])&&e.push({coord:s[1],onBand:!0});function d(t,e){return t=ks(t),e=ks(e),h?t>e:t0&&t<100||(t=5),kn(this.scale.getMinorTicks(t),function(t){return kn(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return Tw(this,t=t||kw(Cw)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=cw(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var c=1;l>40&&(c=Math.max(1,Math.floor(l/40)));for(var u=s[0],h=t.dataToCoord(u+1)-t.dataToCoord(u),d=Math.abs(h*Math.cos(o)),p=Math.abs(h*Math.sin(o)),f=0,g=0;u<=s[1];u+=c){var v,m,y=Ya(r({value:u}),i.font,"center","top");v=1.3*y.width,m=1.3*y.height,f=Math.max(f,v,7),g=Math.max(g,m,7)}var _=f/d,b=g/p;isNaN(_)&&(_=1/0),isNaN(b)&&(b=1/0);var x=Math.max(0,Math.floor(Math.min(_,b)));if(n===Sw)return e.out.noPxChangeTryDetermine.push(Dn(Nw,null,t,x,l)),x;var w=Ow(t,x,l);return null!=w?w:x}(this,t=t||kw(Cw))},t}();function Bw(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var Vw=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"];function Ww(t,e,n){n=n||3,e?t.dirty|=n:t.dirty&=~n}function Uw(t,e){return e=e||3,null==t.dirty||!!(t.dirty&e)}function Gw(t){if(t)return Uw(t)&&function(t,e,n){var i=e.getComputedTransform();t.transform=dp(t.transform,i);var r=t.localRect=hp(t.localRect,e.getBoundingRect()),o=e.style,a=o.margin,s=n&&n.marginForce,l=n&&n.minMarginForce,c=n&&n.marginDefault,u=o.__marginType;null==u&&c&&(a=c,u=Ap.textMargin);for(var h=0;h<4;h++)qw[h]=u===Ap.minMargin&&l&&null!=l[h]?l[h]:s&&null!=s[h]?s[h]:a?a[h]:0;u===Ap.textMargin&&ip(r,qw,!1,!1);var d=t.rect=hp(t.rect,r);i&&d.applyTransform(i);u===Ap.minMargin&&ip(d,qw,!1,!1);t.axisAligned=cp(i),(t.label=t.label||{}).ignore=e.ignore,Ww(t,!1),Ww(t,!0,2)}(t,t.label,t),t}var qw=[0,0,0,0];function jw(t,e){for(var n=0;n-1&&(s.style.stroke=s.style.fill,s.style.fill=sg.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(Om);function Kw(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=Fv(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a0?+d:1;M.scaleX=this._sizeX*T,M.scaleY=this._sizeY*T,this.setSymbolScale(1),hh(this,l,c,u)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=Cu(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&Rd(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();Rd(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return Pn(n=t.getItemVisual(e,"symbolSize"))||(n=[+n,+n]),[n[0]||0,n[1]||0];var n},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(ds);function Jw(t,e){this.parent.drift(t,e)}function tS(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function eS(t){return null==t||On(t)||(t={isIgnore:t}),t||{}}function nS(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:bp(e),cursorStyle:e.get("cursor")}}var iS=function(){function t(t){this.group=new ds,this._SymbolCtor=t||Qw}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=eS(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=nS(t),l={disableAnimation:a},c=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=c(i);if(tS(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}}).update(function(u,h){var d=r.getItemGraphicEl(h),p=c(u);if(tS(t,p,u,e)){var f=t.getItemVisual(u,"symbol")||"circle",g=d&&d.getSymbolType&&d.getSymbolType();if(!d||g&&g!==f)n.remove(d),(d=new o(t,u,s,l)).setPosition(p);else{d.updateData(t,u,s,l);var v={x:p[0],y:p[1]};a?d.attr(v):zd(d,v,i)}n.add(d),t.setItemGraphicEl(u,d)}else n.remove(d)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=c,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()})},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=nS(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=eS(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),c=e.mapDimension(a),u="x"===s||"radius"===s?1:0,h=kn(t.dimensions,function(t){return e.mapDimension(t)}),d=!1,p=e.getCalculationInfo("stackResultDimension");return vx(e,h[0])&&(d=!0,h[0]=p),vx(e,h[1])&&(d=!0,h[1]=p),{dataDimsForPoint:h,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!d,valueDim:l,baseDim:c,baseDataOffset:u,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function oS(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var aS=Math.min,sS=Math.max;function lS(t,e){return isNaN(t)||isNaN(e)}function cS(t,e,n,i,r,o,a,s,l){for(var c,u,h,d,p,f,g=n,v=0;v=r||g<0)break;if(lS(m,y)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](m,y),h=m,d=y;else{var _=m-c,b=y-u;if(_*_+b*b<.5){g+=o;continue}if(a>0){for(var x=g+o,w=e[2*x],S=e[2*x+1];w===m&&S===y&&v=i||lS(w,S))p=m,f=y;else{M=w-c,T=S-u;var A=m-c,P=w-m,L=y-u,E=S-y,z=void 0,N=void 0;if("x"===s){var O=M>0?1:-1;p=m-O*(z=Math.abs(A))*a,f=y,I=m+O*(N=Math.abs(P))*a,D=y}else if("y"===s){var R=T>0?1:-1;p=m,f=y-R*(z=Math.abs(L))*a,I=m,D=y+R*(N=Math.abs(E))*a}else z=Math.sqrt(A*A+L*L),p=m-M*a*(1-(k=(N=Math.sqrt(P*P+E*E))/(N+z))),f=y-T*a*(1-k),D=y+T*a*k,I=aS(I=m+M*a*k,sS(w,m)),D=aS(D,sS(S,y)),I=sS(I,aS(w,m)),f=y-(T=(D=sS(D,aS(S,y)))-y)*z/N,p=aS(p=m-(M=I-m)*z/N,sS(c,m)),f=aS(f,sS(u,y)),I=m+(M=m-(p=sS(p,aS(c,m))))*N/z,D=y+(T=y-(f=sS(f,aS(u,y))))*N/z}t.bezierCurveTo(h,d,p,f,m,y),h=I,d=D}else t.lineTo(m,y)}c=m,u=y,g+=o}return v}var uS=function(){this.smooth=0,this.smoothConstraint=!0},hS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return y(e,t),e.prototype.getDefaultStyle=function(){return{stroke:sg.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new uS},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&lS(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(u-i)*g+i:(c-n)*g+n;return a?[t,v]:[v,t]}n=c,i=u;break;case o.C:c=r[l++],u=r[l++],h=r[l++],d=r[l++],p=r[l++],f=r[l++];var m=a?Yr(n,c,h,p,t,s):Yr(i,u,d,f,t,s);if(m>0)for(var y=0;y=0){v=a?jr(i,u,d,f,_):jr(n,c,h,p,_);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(Yc),dS=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e}(uS),pS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return y(e,t),e.prototype.getDefaultShape=function(){return new dS},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&lS(n[2*o-2],n[2*o-1]);o--);for(;r=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=So(e[r]),s=So(e[o]),l=i-r,c=To([ho(mo(a[0],s[0],l)),ho(mo(a[1],s[1],l)),ho(mo(a[2],s[2],l)),po(mo(a[3],s[3],l))],"rgba");return n?{color:c,leftIndex:r,rightIndex:o,value:i}:c}}((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}function bS(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return Cn(o.getViewLabels(),function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function xS(t,e){return isNaN(t)||isNaN(e)}function wS(t,e){return[t[2*e],t[2*e+1]]}function SS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),c=kn(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),u=c.length,h=o.outerColors.slice();u&&c[0].coord>c[u-1].coord&&(c.reverse(),h.reverse());var d=_S(c,"x"===r?n.getWidth():n.getHeight()),p=d.length;if(!p&&u)return c[0].coord<0?h[1]?h[1]:c[u-1].color:h[0]?h[0]:c[0].color;var f=d[0].coord-10,g=d[p-1].coord+10,v=g-f;if(v<.001)return"transparent";Cn(d,function(t){t.offset=(t.coord-f)/v}),d.push({offset:p?d[p-1].offset:.5,color:h[1]||"transparent"}),d.unshift({offset:p?d[0].offset:.5,color:h[0]||"transparent"});var m=new _d(0,0,0,0,d,!0);return m[r]=f,m[r+"2"]=g,m}}}(o,i,n)||o.getVisual("style")[o.getVisual("drawType")];if(d&&u.type===i.type&&k===this._step){v&&!p?p=this._newPolygon(l,_):p&&!v&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,$f(M));var T=f.getClipPath();if(T)Nd(T,{shape:CS(this,i,!1,t).shape},t);else f.setClipPath(CS(this,i,!0,t));b&&h.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),fS(this._stackedOnPoints,_)&&fS(this._points,l)||(g?this._doUpdateAnimation(o,_,i,n,k,m,x):(k&&(_&&(_=yS(_,l,i,k,x)),l=yS(l,null,i,k,x)),d.setShape({points:l}),p&&p.setShape({points:l,stackedOnPoints:_})))}else b&&h.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),g&&this._initSymbolLabelAnimation(o,i,C),k&&(_&&(_=yS(_,l,i,k,x)),l=yS(l,null,i,k,x)),d=this._newPolyline(l),v?p=this._newPolygon(l,_):p&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,$f(M)),f.setClipPath(CS(this,i,!0,t));var I=t.getModel("emphasis"),D=I.get("focus"),A=I.get("blurScope"),P=I.get("disabled");(d.useStyle(bn(a.getLineStyle(),{fill:"none",stroke:M,lineJoin:"bevel"})),fh(d,t,"lineStyle"),d.style.lineWidth>0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(d.getState("emphasis").style.lineWidth=+d.style.lineWidth+1);Cu(d).seriesIndex=t.seriesIndex,hh(d,D,A,P);var L=mS(t.get("smooth")),E=t.get("smoothMonotone");if(d.setShape({smooth:L,smoothMonotone:E,connectNulls:x}),p){var z=o.getCalculationInfo("stackedOnSeries"),N=0;p.useStyle(bn(s.getAreaStyle(),{fill:M,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),z&&(N=mS(z.get("smooth"))),p.setShape({smooth:L,stackedOnSmooth:N,smoothMonotone:E,connectNulls:x}),fh(p,t,"areaStyle"),Cu(p).seriesIndex=t.seriesIndex,hh(p,D,A,P)}var O=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=O)}),this._polyline.onHoverStateChange=O,this._data=o,this._coordSys=i,this._stackedOnPoints=_,this._points=l,this._step=k,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,d),p&&this.packEventData(t,p))},e.prototype.packEventData=function(t,e){Cu(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=tl(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],c=a[2*o+1];if(isNaN(l)||isNaN(c))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,c))return;var u=t.get("zlevel")||0,h=t.get("z")||0;(s=new Qw(r,o)).x=l,s.y=c,s.setZ(u,h);var d=s.getSymbolPath().getTextContent();d&&(d.zlevel=u,d.z=h,d.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else Xm.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=tl(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else Xm.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;ju(this._polyline,t),e&&ju(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new hS({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new pS({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");Ln(l)&&(l=l(null));var c=s.get("animationDelay")||0,u=Ln(c)?c(null):c;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var h=[t.x,t.y],d=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(h);i?(d=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(d=g.r0,p=g.r,f=v[0])}else{var m=n;i?(d=m.x,p=m.x+m.width,f=t.x):(d=m.y+m.height,p=m.y,f=t.y)}var y=p===d?0:(f-d)/(p-d);a&&(y=1-y);var _=Ln(c)?c(o):l*y+u,b=s.getSymbolPath(),x=b.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:_}),x&&x.animateFrom({style:{opacity:0}},{duration:300,delay:_}),b.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(SS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new hu({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&xS(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(_p(o,bp(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?function(t,e){var n=t.mapDimensionsAll("defaultedLabel");if(!Pn(e))return e+"";for(var i=[],r=0;r=0&&i.push(e[o])}return i.join(" ")}(r,n):Kw(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var c=n.getLayout("points"),u=n.hostModel,h=u.get("connectNulls"),d=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,m=e.shape,y=v?g?m.x:m.y+m.height:g?m.x+m.width:m.y,_=(g?p:0)*(v?-1:1),b=(g?0:-p)*(v?-1:1),x=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,c=0;c=e||i>=e&&r<=e){l=c;break}s=c,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(c,y,x),S=w.range,C=S[1]-S[0],k=void 0;if(C>=1){if(C>1&&!h){var M=wS(c,S[0]);s.attr({x:M[0]+_,y:M[1]+b}),r&&(k=u.getRawValue(S[0]))}else{(M=l.getPointOn(y,x))&&s.attr({x:M[0]+_,y:M[1]+b});var T=u.getRawValue(S[0]),I=u.getRawValue(S[1]);r&&(k=function(t,e,n,i,r){var o=null==e||"auto"===e;if(null==i)return i;if(Nn(i))return ks(f=Bs(n||0,i,r),o?Math.max(Ms(n||0),Ms(i)):e);if(En(i))return r<1?n:i;for(var a=[],s=n,l=i,c=Math.max(s?s.length:0,l.length),u=0;u0?S[0]:0;M=wS(c,D);r&&(k=u.getRawValue(D)),s.attr({x:M[0]+_,y:M[1]+b})}if(r){var A=Dp(s);"function"==typeof A.setLabelText&&A.setLabelText(k)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,c=t.hostModel,u=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],c=[],u=[],h=[],d=[],p=[],f=[],g=rS(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],y=0;y3e3||l&&vS(d,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=u.current,s.shape.points=h;var g={shape:{points:p}};u.current!==h&&(g.shape.__points=u.next),s.stopAnimation(),zd(s,g,c),l&&(l.setShape({points:h,stackedOnPoints:d}),l.stopAnimation(),zd(l,{shape:{stackedOnPoints:f}},c),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],m=u.status,y=0;ye&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(Fw),US="expandAxisBreak",GS=Math.PI,qS=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],jS=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],XS=el(),YS=el(),ZS=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var KS=[1,0,0,1,0,0],QS=new cr(0,0,0,0),JS=function(t,e,n,i,r,o){if(fw(t.nameLocation)){var a=o.stOccupiedRect;a&&tC(function(t,e,n){return t.transform=dp(t.transform,n),t.localRect=hp(t.localRect,e),t.rect=hp(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=cp(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else eC(o.labelInfoList,o.dirVec,i,r)};function tC(t,e,n){var i=new Yi;Yw(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&function(t,e){if(t){t.label.x+=e.x,t.label.y+=e.y,t.label.markRedraw();var n=t.transform;n&&(n[4]+=e.x,n[5]+=e.y);var i=t.rect;i&&(i.x+=e.x,i.y+=e.y);var r=t.obb;r&&r.fromBoundingRect(t.localRect,n)}}(e,i)}function eC(t,e,n,i){for(var r=Yi.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):Ds(o-GS)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),iC=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],rC={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),c=o.transform,u=[l[0],0],h=[l[1],0],d=u[0]>h[0];c&&(gi(u,u,c),gi(h,h,c));var p=_n({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&i.axis.scale.hasBreaks())null.buildAxisBreakLine(i,r,o,f);else{var g=new ud(_n({shape:{x1:u[0],y1:u[1],x2:h[0],y2:h[1]}},f));Yd(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var m=i.get(["axisLine","symbolSize"]);En(v)&&(v=[v,v]),(En(m)||Nn(m))&&(m=[m,m]);var y=n_(i.get(["axisLine","symbolOffset"])||0,m),_=m[0],b=m[1];Cn([{rotate:t.rotation+Math.PI/2,offset:y[0],r:0},{rotate:t.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((u[0]-h[0])*(u[0]-h[0])+(u[1]-h[1])*(u[1]-h[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=e_(v[n],-_/2,-b/2,_,b,p.stroke,!0),o=e.r+e.offset,a=d?h:u;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){lC(e,r,s)&&oC(t,e,n,i,r,o,a,Sw)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){lC(e,r,s)&&oC(t,e,n,i,r,o,a,Cw);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),c=sC(r.getTicksCoords(),n.transform,l,bn(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),u=0;ui[1],l="start"===e&&!s||"start"!==e&&s;Ds(a-GS/2)?(o=l?"bottom":"top",r="center"):Ds(a-1.5*GS)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*GS&&a>GS/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,u,x||0,f),null!=(b=t.raw.axisNameAvailableWidth)&&(b=Math.abs(b/Math.sin(_.rotation)),!isFinite(b)&&(b=null)));var w=d.getFont(),S=i.get("nameTruncate",!0)||{},C=S.ellipsis,k=Vn(t.raw.nameTruncateMaxWidth,S.maxWidth,b),M=s.nameMarginLevel||0,T=new hu({x:v.x,y:v.y,rotation:_.rotation,silent:nC.isLabelSilent(i),style:xp(d,{text:c,font:w,overflow:"truncate",width:k,ellipsis:C,fill:d.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:d.get("align")||_.textAlign,verticalAlign:d.get("verticalAlign")||_.textVerticalAlign}),z2:1});if(ap({el:T,componentModel:i,itemName:c}),T.__fullText=c,T.anid="name",i.get("triggerEvent")){var I=nC.makeAxisEventDataBase(i);I.targetType="axisName",I.name=c,Cu(T).eventData=I}o.add(T),T.updateTransform(),e.nameEl=T;var D=l.nameLayout=Gw({label:T,priority:T.z2,defaultAttr:{ignore:T.ignore},marginDefault:fw(u)?qS[M]:jS[M]});if(l.nameLocation=u,r.add(T),T.decomposeTransform(),t.shouldNameMoveOverlap&&D){var A=n.ensureRecord(i);n.resolveAxisNameOverlap(t,n,i,D,m,A)}}}};function oC(t,e,n,i,r,o,a,s){cC(e)||function(t,e,n,i,r,o){var a=r.axis,s=Vn(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new ds;n.add(l);var c=kw(i);if(!s||a.scale.isBlank())return void uC(e,[],l,c);var u=r.getModel("axisLabel"),h=a.getViewLabels(c),d=(Vn(t.raw.labelRotate,u.get("rotate"))||0)*GS/180,p=nC.innerTextLayout(t.rotation,d,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),m=1/0,y=-1/0;Cn(h,function(t,e){var n,i="ordinal"===a.scale.type?a.scale.getRawOrdinalNumber(t.tickValue):t.tickValue,s=t.formattedLabel,c=t.rawLabel,d=u;if(f&&f[i]){var _=f[i];On(_)&&_.textStyle&&(d=new Bp(_.textStyle,u,r.ecModel))}var b=d.getTextColor()||r.get(["axisLine","lineStyle","color"]),x=d.getShallow("align",!0)||p.textAlign,w=Wn(d.getShallow("alignMinLabel",!0),x),S=Wn(d.getShallow("alignMaxLabel",!0),x),C=d.getShallow("verticalAlign",!0)||d.getShallow("baseline",!0)||p.textVerticalAlign,k=Wn(d.getShallow("verticalAlignMinLabel",!0),C),M=Wn(d.getShallow("verticalAlignMaxLabel",!0),C),T=10+((null===(n=t.time)||void 0===n?void 0:n.level)||0);m=Math.min(m,T),y=Math.max(y,T);var I=new hu({x:0,y:0,rotation:0,silent:nC.isLabelSilent(r),z2:T,style:xp(d,{text:s,align:0===e?w:e===h.length-1?S:x,verticalAlign:0===e?k:e===h.length-1?M:C,fill:Ln(b)?b("category"===a.type?c:"value"===a.type?i+"":i,e):b})});I.anid="label_"+i;var D=XS(I);if(D.break=t.break,D.tickValue=i,D.layoutRotation=p.rotation,ap({el:I,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return I.isTruncated},value:c,tickIndex:e}}),v){var A=nC.makeAxisEventDataBase(r);A.targetType="axisLabel",A.value=c,A.tickIndex=e,t.break&&(A.break={start:t.break.parsedBreak.vmin,end:t.break.parsedBreak.vmax}),"category"===a.type&&(A.dataIndex=i),Cu(I).eventData=A,t.break&&function(t,e,n,i){n.on("click",function(n){var r={type:US,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,I,t.break)}g.push(I),l.add(I)});var _=kn(g,function(t){return{label:t,priority:XS(t).break?t.z2+(y-m+1):t.z2,defaultAttr:{ignore:t.ignore}}});uC(e,_,l,c)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);Cn(n,function(n,o){var a=Gw(n);if(a){var s=a.label,l=XS(s);a.suggestIgnore=s.ignore,s.ignore=!1,Va(hC,dC),hC.x=e.axis.dataToCoord(l.tickValue),hC.y=t.labelOffset+t.labelDirection*r,hC.rotation=l.layoutRotation,i.add(hC),hC.updateTransform(),i.remove(hC),hC.decomposeTransform(),Va(s,hC),s.markRedraw(),Ww(a,!0),Gw(a)}})}(t,i,l,o),t.rotation;var c=t.optionHideOverlap;!function(t,e,n){if(dw(t.axis))return;function i(t,i,r){var o=Gw(e[i]),a=Gw(e[r]);if(o&&a)if(!1===t||o.suggestIgnore)aC(o.label);else if(a.suggestIgnore)aC(a.label);else{var s=.1;if(!n){var l=[0,0,0,0];o=jw({marginForce:l},o),a=jw({marginForce:l},a)}Yw(o,a,null,{touchThreshold:s})&&aC(t?a.label:o.label)}}var r=t.get(["axisLabel","showMinLabel"]),o=t.get(["axisLabel","showMaxLabel"]),a=e.length;i(r,0,1),i(o,a-1,a-2)}(i,l,c),c&&function(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i.1?"x":"y",u=a.transGroup[c];if(s.sort(function(t,e){return Math.abs(t.label[c]-u)-Math.abs(e.label[c]-u)}),l&&r){var h=o.getExtent(),d=Math.min(h[0],h[1]),p=Math.max(h[0],h[1])-d;r.union(new cr(d,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function aC(t){t&&(t.ignore=!0)}function sC(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;yx(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(lw(l,s),yx(l)&&(e=a))}r.length&&(e||lw((e=r.pop()).scale,e.model),Cn(r,function(t){!function(t,e,n){var i=Nx.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,{expandToNicedExtent:!0}),a=r.length-1,s=i.getInterval.call(n),l=sw(t,e),c=l.extent,u=l.fixMin,h=l.fixMax;"log"===t.type&&(c=Ix(t.base,c,!0)),t.setBreaksFromOption(vw(e)),t.setExtent(c[0],c[1]),t.calcNiceExtent({splitNumber:a,fixMin:u,fixMax:h});var d=i.getExtent.call(t);u&&(c[0]=d[0]),h&&(c[1]=d[1]);var p=i.getInterval.call(t),f=c[0],g=c[1];if(u&&h)p=(g-f)/a;else if(u)for(g=c[0]+p*a;gc[0]&&isFinite(f)&&isFinite(c[0]);)p=bx(p),f=c[1]-p*a;else{t.getTicks().length-1>a&&(p=bx(p));var v=p*a;(f=ks((g=Math.ceil(c[1]/p)*p)-v))<0&&c[0]>=0?(f=0,g=ks(v)):g>0&&c[1]<=0&&(g=0,f=-ks(v))}var m=(r[0].value-o[0].value)/s,y=(r[a].value-o[a].value)/s;i.setExtent.call(t,f+p*m,g+p*y),i.setInterval.call(t,p),(m||y)&&i.setNiceExtent.call(t,f+p,g-p)}(t.scale,t.model,e.scale)}))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};Cn(n.x,function(t){_C(n,"y",t,r)}),Cn(n.y,function(t){_C(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=eg(t,e),r=this._rect=Jf(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(xC(o,r),!n){var l=function(t,e,n,i,r){var o=new ZS(kC);return Cn(n,function(n){return Cn(n,function(n){if(gw(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=fC(t,n),s=!1,l=!1,c=0;c0&&i>0||n<0&&i<0)}(t)}function xC(t,e){Cn(t.x,function(t){return wC(t,e.x,e.width)}),Cn(t.y,function(t){return wC(t,e.y,e.height)})}function wC(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function SC(t,e,n,i,r,o,a){CC(i,r,Sw,e,!1,a);var s=[0,0,0,0];c(0),c(1),u(i,0,NaN),u(i,1,NaN);var l=null==function(t,e,n){if(t&&e)for(var i=0,r=t.length;i0});return ip(i,s,!0,!0,n),xC(r,i),l;function c(t){Cn(r[Fd[t]],function(e){if(gw(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!Bn(e)&&e>1e-4&&(t/=e),t}}function CC(t,e,n,i,r,o){var a=n===Cw;Cn(e,function(e){return Cn(e,function(e){gw(e.model)&&(!function(t,e,n){var i=fC(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[Fd[1-e]]=t[Bd[e]]<=.5*o.refContainer[Bd[e]]?0:1-e==1?2:1}l(0),l(1),Cn(e,function(t,e){return Cn(t,function(t){gw(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var kC=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";JS(t,0,0,i,r,o),fw(t.nameLocation)||Cn(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&eC(t.labelInfoList,t.dirVec,i,r)})};function MC(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];Cn(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=AC(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var c=n.model.getModel("tooltip",i);if(Cn(n.getAxes(),An(p,!1,null)),n.getTooltipAxes&&i&&c.get("show")){var u="axis"===c.get("trigger"),h="cross"===c.get(["axisPointer","type"]),d=n.getTooltipAxes(c.get(["axisPointer","axis"]));(u||h)&&Cn(d.baseAxes,An(p,!h||"cross",u)),h&&Cn(d.otherAxes,An(p,"cross",!1))}}function p(i,s,u){var h=u.model.getModel("axisPointer",r),d=h.get("show");if(d&&("auto"!==d||i||DC(h))){null==s&&(s=h.get("triggerTooltip")),h=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};Cn(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=mn(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var c=a.get(["label","show"]);if(l.show=null==c||c,!o){var u=s.lineStyle=a.get("crossStyle");u&&bn(l,u.textStyle)}}return t.model.getModel("axisPointer",new Bp(s,n,i))}(u,c,r,e,i,s):h;var p=h.get("snap"),f=h.get("triggerEmphasis"),g=AC(u.model),v=s||p||"category"===u.type,m=t.axesInfo[g]={key:g,axis:u,coordSys:n,axisPointerModel:h,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:DC(h),seriesModels:[],linkGroup:null};l[g]=m,t.seriesInvolved=t.seriesInvolved||v;var y=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function IC(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[AC(t)]}function DC(t){return!!t.get(["handle","show"])}function AC(t){return t.type+"||"+t.id}var PC={},LC=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=IC(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=DC(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),aa)return!0;if(o){var s=IC(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=VC(t).pointerEl=new vp[r.type](WC(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=VC(t).labelEl=new hu(WC(e.label));t.add(r),XC(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=VC(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=VC(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),XC(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=tp(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){Hi(t.event)},onmousedown:UC(this._onHandleDragMove,this,0,0),drift:UC(this._onHandleDragMove,this),ondragend:UC(this._onHandleDragEnd,this)}),i.add(r)),ZC(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");Pn(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,ry(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){qC(this._axisPointerModel,!e&&this._moveAnimation,this._handle,YC(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(YC(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(YC(i)),VC(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),oy(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function qC(t,e,n,i){jC(VC(n).lastProp,i)||(VC(n).lastProp=i,e?zd(n,i,t):(n.stopAnimation(),n.attr(i)))}function jC(t,e){if(On(t)&&On(e)){var n=!0;return Cn(e,function(e,i){n=n&&jC(t[i],e)}),!!n}return t===e}function XC(t,e){t[e.get(["label","show"])?"show":"hide"]()}function YC(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function ZC(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function KC(t,e,n,i,r){var o=QC(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=Ef(a.get("padding")||0),l=a.getFont(),c=Ya(o,l),u=r.position,h=c.width+s[1]+s[3],d=c.height+s[0]+s[2],p=r.align;"right"===p&&(u[0]-=h),"center"===p&&(u[0]-=h/2);var f=r.verticalAlign;"bottom"===f&&(u[1]-=d),"middle"===f&&(u[1]-=d/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(u,h,d,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:u[0],y:u[1],style:xp(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function QC(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:uw(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};Cn(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),En(a)?o=a.replace("{value}",o):Ln(a)&&(o=a(s))}return o}function JC(t,e,n){var i=[1,0,0,1,0,0];return ji(i,i,n.rotation),qi(i,i,n.position),Kd([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}var tk=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return y(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=ek(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var u=function(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}(i),h=nk[s](o,c,l);h.style=u,t.graphicKey=h.type,t.pointer=h}!function(t,e,n,i,r,o){var a=nC.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),KC(e,i,r,o,{position:JC(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}(e,t,fC(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=fC(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=JC(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=ek(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,c=[t.x,t.y];c[l]+=e[l],c[l]=Math.min(a[1],c[l]),c[l]=Math.max(a[0],c[l]);var u=(s[1]+s[0])/2,h=[u,u];h[l]=c[l];return{x:c[0],y:c[1],rotation:t.rotation,cursorPoint:h,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(GC);function ek(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var nk={line:function(t,e,n){var i,r,o;return{type:"Line",subPixelOptimize:!0,shape:(i=[e,n[0]],r=[e,n[1]],o=ik(t),{x1:i[o=o||0],y1:i[1-o],x2:r[o],y2:r[1-o]})}},shadow:function(t,e,n){var i,r,o,a=Math.max(1,t.getBandWidth()),s=n[1]-n[0];return{type:"Rect",shape:(i=[e-a/2,n[0]],r=[a,s],o=ik(t),{x:i[o=o||0],y:i[1-o],width:r[o],height:r[1-o]})}}};function ik(t){return"x"===t.dim?0:1}var rk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:sg.color.border,width:1,type:"dashed"},shadowStyle:{color:sg.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:sg.color.neutral00,padding:[5,7,5,7],backgroundColor:sg.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:sg.color.accent40,throttle:40}},e}(ag),ok=el(),ak=Cn;function sk(t,e,n){if(!Ye.node){var i=e.getZr();ok(i).records||(ok(i).records={}),function(t,e){if(ok(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);ak(ok(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}ok(t).initialized=!0,n("click",An(ck,"click")),n("mousemove",An(ck,"mousemove")),n("globalout",lk)}(i,e),(ok(i).records[t]||(ok(i).records[t]={})).handler=n}}function lk(t,e,n){t.handler("leave",null,n)}function ck(t,e,n,i){e.handler(t,n,i)}function uk(t,e){if(!Ye.node){var n=e.getZr();(ok(n).records||{})[t]&&(ok(n).records[t]=null)}}var hk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";sk("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){uk("axisPointer",e)},e.prototype.dispose=function(t,e){uk("axisPointer",e)},e.type="axisPointer",e}(Um);function dk(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=tl(o,t);if(null==a||a<0||Pn(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var c=l.getBaseAxis(),u=l.getOtherAxis(c).dim,h=c.dim,d="x"===u||"radius"===u?1:0,p=o.mapDimension(h),f=[];f[d]=o.get(p,a),f[1-d]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(kn(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var pk=el();function fk(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||Dn(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){_k(r)&&(r=dk({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=_k(r),c=o.axesInfo,u=s.axesInfo,h="leave"===i||_k(r),d={},p={},f={list:[],map:{}},g={showPointer:An(vk,p),showTooltip:An(mk,f)};Cn(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);Cn(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(c,t);if(!h&&n&&(!c||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&gk(t,a,g,!1,d)}})});var v={};return Cn(u,function(t,e){var n=t.linkGroup;n&&!p[e]&&Cn(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,yk(e),yk(t)))),v[t.key]=o}})}),Cn(v,function(t,e){gk(u[e],t,g,!0,d)}),function(t,e,n){var i=n.axesInfo=[];Cn(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,u,d),function(t,e,n,i){if(_k(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=pk(i)[r]||{},a=pk(i)[r]={};Cn(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&Cn(n.seriesDataIndices,function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t})});var s=[],l=[];Cn(o,function(t,e){!a[e]&&l.push(t)}),Cn(a,function(t,e){!o[e]&&s.push(t)}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(u,0,n),d}}function gk(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return Cn(e.seriesModels,function(e,l){var c,u,h=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var d=e.getAxisTooltipData(h,t,n);u=d.dataIndices,c=d.nestestValue}else{if(!(u=e.indicesOfNearest(i,h[0],t,"category"===n.type?.5:null)).length)return;c=e.getData().get(h[0],u[0])}if(null!=c&&isFinite(c)){var p=t-c,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=c,o.length=0),Cn(u,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&_n(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function vk(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function mk(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,c=AC(l),u=t.map[c];u||(u=t.map[c]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(u)),u.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function yk(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function _k(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function bk(t){LC.registerAxisPointerClass("CartesianAxisPointer",tk),t.registerComponentModel(rk),t.registerComponentView(hk),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!Pn(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=MC(t,e)}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},fk)}var xk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return y(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:sg.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:sg.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:sg.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:sg.color.tertiary,fontSize:14}},e}(ag);function wk(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function Sk(t){if(Ye.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&r.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",r="",o="";return n&&(o="opacity"+(r=" "+t/2+"s "+i)+",visibility"+r),e||(r=" "+t+"s "+i,o+=(o.length?",":"")+(Ye.transformSupported?""+Tk+r:",left"+r+",top"+r)),Mk+":"+o}(o,n,i)),a&&r.push("background-color:"+a),Cn(["width","color","radius"],function(e){var n="border-"+e,i=Lf(n),o=t.get(i);null!=o&&r.push(n+":"+o+("color"===e?"":"px"))}),r.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=Wn(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),Cn(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(h)),null!=d&&r.push("padding:"+Ef(d).join("px ")+"px"),r.join(";")+";"}function Pk(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){Mi(ki,e,i,r,!0)&&Mi(t,n,ki[0],ki[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var Lk=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,Ye.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),r=e.appendTo,o=r&&(En(r)?document.querySelector(r):Hn(r)?r:Ln(r)&&r(t.getDom()));Pk(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(n),this._api=t,this._container=o;var a=this;n.onmouseenter=function(){a._enterable&&(clearTimeout(a._hideTimeout),a._show=!0),a._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!a._enterable){var e=i.handler;Ri(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){a._inContent=!1,a._enterable&&a._show&&a.hideLater(a._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?a[o]:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=Ik+Ak(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+Dk(r[0],r[1],!0)+"border-color:"+$f(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(En(r)&&"item"===n.get("trigger")&&!wk(n)&&(a=function(t,e,n){if(!En(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=$f(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),c="",u=Tk+":";xn(["left","right"],s)>-1?(c+="top:50%",u+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(c+="left:50%",u+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var h=a*Math.PI/180,d=l+r,p=d*Math.abs(Math.cos(h))+d*Math.abs(Math.sin(h)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),En(t))o.innerHTML=t+a;else if(t){o.innerHTML="",Pn(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!Ye.node&&n.getDom()){var r=Fk(i,n);this._ticket="";var o=i.dataByCoordSys,a=function(t,e,n){var i=rl(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=al(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=Cu(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(a){var s=a.el.getBoundingRect().clone();s.applyTransform(a.el.transform),this._tryShow({offsetX:s.x+s.width/2,offsetY:s.y+s.height/2,target:a.el,position:i.position,positionDefault:"bottom"},r)}else if(i.tooltip&&null!=i.x&&null!=i.y){var l=Rk;l.x=i.x,l.y=i.y,l.update(),Cu(l).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:l},r)}else if(o)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:o,tooltipOption:i.tooltipOption},r);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=dk(i,e),u=c.point[0],h=c.point[1];null!=u&&null!=h&&this._tryShow({offsetX:u,offsetY:h,target:c.el,position:i.position,positionDefault:"bottom"},r)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},r))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(Fk(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===Hk([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===Cu(n).ssrType)return;this._lastDataByCoordSys=null,Wy(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=Cu(t).dataIndex?r=t:null!=Cu(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=Dn(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=Hk([e.tooltipOption],i),a=this._renderMode,s=[],l=wm("section",{blocks:[],noHeader:!0}),c=[],u=new Lm;Cn(t,function(t){Cn(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=QC(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),h=wm("section",{header:o,noHeader:!Xn(o),sortBlocks:!0,blocks:[]});l.blocks.push(h),Cn(t.seriesDataIndices,function(l){var d=n.getSeriesByIndex(l.seriesIndex),p=l.dataIndexInside,f=d.getDataParams(p);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=uw(e.axis,{value:r}),f.axisValueLabel=o,f.marker=u.makeTooltipMarker("item",$f(f.color),a);var g=Wv(d.formatTooltip(p,!0,null)),v=g.frag;if(v){var m=Hk([d],i).get("valueFormatter");h.blocks.push(m?_n({valueFormatter:m},v):v)}g.text&&c.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),c.reverse();var h=e.position,d=o.get("order"),p=Im(l,u,a,d,n.get("useUTC"),o.get("textStyle"));p&&c.unshift(p);var f="richText"===a?"\n\n":"
",g=c.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,h,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],h,null,u)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=Cu(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,c=r.dataType,u=s.getData(c),h=this._renderMode,d=t.positionDefault,p=Hk([u.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,d?{position:d}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,c),v=new Lm;g.marker=v.makeTooltipMarker("item",$f(g.color),h);var m=Wv(s.formatTooltip(l,!1,c)),y=p.get("order"),_=p.get("valueFormatter"),b=m.frag,x=b?Im(_?_n({valueFormatter:_},b):b,v,h,y,i.get("useUTC"),p.get("textStyle")):m.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,x,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:u.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=Cu(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(En(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=mn(o)).content=Ai(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var c=t.positionDefault,u=Hk(s,this._tooltipModel,c?{position:c}:null),h=u.get("content"),d=Math.random()+"",p=new Lm;this._showOrMove(u,function(){var n=mn(u.get("formatterParams")||{});this._showTooltipContent(u,h,n,d,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var c=this._tooltipContent;c.setEnterable(t.get("enterable"));var u=t.get("formatter");a=a||t.get("position");var h=e,d=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(u)if(En(u)){var p=t.ecModel.get("useUTC"),f=Pn(n)?n[0]:n;h=u,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(h=ff(f.axisValue,h,p)),h=Rf(h,n,!0)}else if(Ln(u)){var g=Dn(function(e,i){e===this._ticket&&(c.setContent(i,l,t,d,a),this._updatePosition(t,a,r,o,c,n,s))},this);this._ticket=i,h=u(n,i,g)}else h=u;c.setContent(h,l,t,d,a),c.show(t,d),this._updatePosition(t,a,r,o,c,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||Pn(e)?{color:i||r}:Pn(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var c=r.getSize(),u=t.get("align"),h=t.get("verticalAlign"),d=a&&a.getBoundingRect().clone();if(a&&d.applyTransform(a.transform),Ln(e)&&(e=e([n,i],o,r.el,d,{viewSize:[s,l],contentSize:c.slice()})),Pn(e))n=Ss(e[0],s),i=Ss(e[1],l);else if(On(e)){var p=e;p.width=c[0],p.height=c[1];var f=Jf(p,{width:s,height:l});n=f.x,i=f.y,u=null,h=null}else if(En(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,c=e.width,u=e.height;switch(t){case"inside":s=e.x+c/2-r/2,l=e.y+u/2-o/2;break;case"top":s=e.x+c/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+c/2-r/2,l=e.y+u+a;break;case"left":s=e.x-r-a,l=e.y+u/2-o/2;break;case"right":s=e.x+c+a,l=e.y+u/2-o/2}return[s,l]}(e,d,c,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],c=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+c+a>r?e-=c+a:e+=a);return[t,e]}(n,i,r,s,l,u?null:20,h?null:20);n=g[0],i=g[1]}if(u&&(n-=Bk(u)?c[0]/2:"right"===u?c[0]:0),h&&(i-=Bk(h)?c[1]/2:"bottom"===h?c[1]:0),wk(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&Cn(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&Cn(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&Cn(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&Cn(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!Ye.node&&e.getDom()&&(oy(this,"_updatePosition"),this._tooltipContent.dispose(),uk("itemTooltip",e))},e.type="tooltip",e}(Um);function Hk(t,e,n){var i,r=e.ecModel;n?(i=new Bp(n,r,r),i=new Bp(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof Bp&&(a=a.get("tooltip",!0)),En(a)&&(a={formatter:a}),a&&(i=new Bp(a,i,r)))}return i}function Fk(t,e){return t.dispatchAction||Dn(e.dispatchAction,e)}function Bk(t){return"center"===t||"middle"===t}var Vk=Math.sin,Wk=Math.cos,Uk=Math.PI,Gk=2*Math.PI,qk=180/Uk,jk=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,c=!s,u=Math.abs(l),h=zo(u-Gk)||(c?l>=Gk:-l>=Gk),d=l>0?l%Gk:l%Gk+Gk,p=!1;p=!!h||!zo(u)&&d>=Uk==!!c;var f=t+n*Wk(o),g=e+i*Vk(o);this._start&&this._add("M",f,g);var v=Math.round(r*qk);if(h){var m=1/this._p,y=(c?1:-1)*(Gk-m);this._add("A",n,i,v,1,+c,t+n*Wk(o+y),e+i*Vk(o+y)),m>.01&&this._add("A",n,i,v,0,+c,f,g)}else{var _=t+n*Wk(a),b=e+i*Vk(a);this._add("A",n,i,v,+p,+c,_,b)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var c=[],u=this._p,h=1;h"}(r,o)+("style"!==r?Ai(a):a||"")+(i?""+n+kn(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function oM(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function aM(t,e,n,i){return iM("svg","root",{width:t,height:e,xmlns:Jk,"xmlns:xlink":tM,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var sM=0;function lM(){return sM++}var cM={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},uM="transform-origin";function hM(t,e,n){var i=_n({},t.shape);_n(i,e),t.buildPath(n,i);var r=new jk;return r.reset(Uo(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function dM(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[uM]=n+"px "+i+"px")}var pM={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function fM(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function gM(t){return En(t)?cM[t]?"cubic-bezier("+cM[t]+")":oo(t)?t:"":""}function vM(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof md){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(Cn(o,function(t){var e=oM(n.zrId);e.animation=!0,vM(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=In(o),c=l.length;if(c){var u=o[r=l[c-1]];for(var h in u){var d=u[h];a[h]=a[h]||{d:""},a[h].d+=d.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=fM(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},c=0;c0}).length)return fM(u,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var m=n.zrId+"-cls-"+lM();n.cssNodes["."+m]={animation:a.join(",")},e.class=m}}function mM(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+lM(),n.cssStyleCache[r]=o,n.cssNodes["."+o+":hover"]=t),e.class=e.class?e.class+" "+o:o}var yM=Math.round;function _M(t){return t&&En(t.src)}function bM(t){return t&&Ln(t.toDataURL)}function xM(t,e,n,i){Qk(function(r,o){var a="fill"===r||"stroke"===r;a&&Vo(o)?LM(e,t,r,i):a&&Ho(o)?EM(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],c=s[1];if(!l||!c)return;var u=i.shadowOffsetX||0,h=i.shadowOffsetY||0,d=i.shadowBlur,p=Lo(i.shadowColor),f=p.opacity,g=p.color,v=d/2/l+" "+d/2/c;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=iM("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[iM("feDropShadow","",{dx:u/l,dy:h/c,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=Wo(a)}}(n,t,i)}function wM(t,e){var n=function(t){if("function"==typeof gs)return gs(t)}(e);n&&(n.each(function(e,n){null!=e&&(t[(eM+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[eM+"silent"]="true"))}function SM(t){return zo(t[0]-1)&&zo(t[1])&&zo(t[2])&&zo(t[3]-1)}function CM(t,e,n){if(e&&(!function(t){return zo(t[4])&&zo(t[5])}(e)||!SM(e))){var i=1e4;t.transform=SM(e)?"translate("+yM(e[4]*i)/i+" "+yM(e[5]*i)/i+")":function(t){return"matrix("+No(t[0])+","+No(t[1])+","+No(t[2])+","+No(t[3])+","+Oo(t[4])+","+Oo(t[5])+")"}(e)}}function kM(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=Ao(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var c={cursor:"pointer"};r&&(c.fill=r),i.stroke&&(c.stroke=i.stroke),l&&(c["stroke-width"]=l),mM(c,e,n)}}(t,o,e),iM(s,t.id+"",o)}function PM(t,e){return t instanceof Yc?AM(t,e):t instanceof tu?function(t,e){var n=t.style,i=n.image;if(i&&!En(i)&&(_M(i)?i=i.src:bM(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),CM(a,t.transform),xM(a,n,t,e),wM(a,t),e.animation&&vM(t,a,e),iM("image",t.id+"",a)}}(t,e):t instanceof Kc?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||Ke,o=n.x||0,a=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,Qa(r),n.textBaseline),s={"dominant-baseline":"central","text-anchor":Ro[n.textAlign]||n.textAlign};if(mu(n)){var l="",c=n.fontStyle,u=gu(n.fontSize);if(!parseFloat(u))return;var h=n.fontFamily||Ze,d=n.fontWeight;l+="font-size:"+u+";font-family:"+h+";",c&&"normal"!==c&&(l+="font-style:"+c+";"),d&&"normal"!==d&&(l+="font-weight:"+d+";"),s.style=l}else s.style="font: "+r;return i.match(/\s/)&&(s["xml:space"]="preserve"),o&&(s.x=o),a&&(s.y=a),CM(s,t.transform),xM(s,n,t,e),wM(s,t),e.animation&&vM(t,s,e),iM("text",t.id+"",s,void 0,i)}}(t,e):void 0}function LM(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(Fo(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!Bo(o))return;r="radialGradient",a.cx=Wn(o.x,.5),a.cy=Wn(o.y,.5),a.r=Wn(o.r,.5)}for(var s=o.colorStops,l=[],c=0,u=s.length;cl?XM(t,null==n[h+1]?null:n[h+1].elm,n,s,h):YM(t,e,a,l))}(n,i,r):UM(r)?(UM(t.text)&&BM(n,""),XM(n,null,r,0,r.length-1)):UM(i)?YM(n,i,0,i.length-1):UM(t.text)&&BM(n,""):t.text!==e.text&&(UM(i)&&YM(n,i,0,i.length-1),BM(n,e.text)))}var QM=0,JM=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=function(){},this.configLayer=function(){},this.storage=e,this._opts=n=_n({},n),this.root=t,this._id="zr"+QM++,this._oldVNode=aM(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=nM("svg");ZM(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(qM(t,e))KM(t,e);else{var n=t.elm,i=HM(n);jM(e),null!==i&&(OM(i,e.elm,FM(n)),YM(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return PM(t,oM(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=oM(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=iM("rect","bg",{width:t,height:e,x:"0",y:"0"}),Vo(n))LM({fill:n},r.attrs,"fill",i);else if(Ho(n))EM({style:{fill:n},dirty:ri,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=Lo(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=iM("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=kn(In(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(iM("defs","defs",{},l)),t.animation){var c=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=kn(In(t),function(e){return e+r+kn(In(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=kn(In(e),function(t){return"@keyframes "+t+r+kn(In(e[t]),function(n){return n+r+kn(In(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(c){var u=iM("style","stl",{},[],c);o.push(u)}}return aM(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},rM(this.renderToVNode({animation:Wn(t.cssAnimation,!0),emphasis:Wn(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:Wn(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,c=0;c=0&&(!h||!r||h[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),c=s.getExtent(),u=n.getDevicePixelRatio(),h=Math.abs(c[1]-c[0])*(u||1),d=Math.round(a/h);if(isFinite(d)&&d>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/d)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/d));var p=void 0;En(r)?p=MS[r]:Ln(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/d,p,TS))}}}}}("line"))},function(t){bw(BC),bw(bk)},function(t){bw(bk),t.registerComponentModel(xk),t.registerComponentView($k),t.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},ri),t.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},ri)},function(t){t.registerPainter("svg",JM)}]);class tT extends Dt{constructor(){super(...arguments),this.options=null,this.data=[],this.height="120px",this._chart=null,this._resizeObserver=null}render(){return ut`
`}connectedCallback(){super.connectedCallback(),this.hasUpdated&&!this._chart&&this.options&&(this._mount(),this._observeResize())}firstUpdated(){this._mount(),this._observeResize()}updated(t){(t.has("options")||t.has("data"))&&(this._chart&&this.options?this._chart.setOption(this._mergedOptions(),!0):!this._chart&&this.options&&this._mount()),t.has("height")&&this._chart&&this._chart.resize()}disconnectedCallback(){this._destroy(),super.disconnectedCallback()}_mount(){if(!this.options)return;const t=this.shadowRoot?.querySelector(".chart-host");t&&(this._chart=Mb(t,void 0,{renderer:"svg"}),this._chart.setOption(this._mergedOptions(),!0))}_mergedOptions(){if(!this.options)throw new Error("span-chart: options not set");return{...this.options,series:this.data}}_observeResize(){if("undefined"==typeof ResizeObserver)return;this._resizeObserver=new ResizeObserver(()=>{this._chart&&this._chart.resize()});const t=this.shadowRoot?.querySelector(".chart-host");t&&this._resizeObserver.observe(t)}_destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._chart&&(this._chart.dispose(),this._chart=null)}}tT.styles=M` +var $s={},Rs={};var Hs,Fs=function(){function t(t,e,n){var i=this;this._sleepAfterStill=10,this._stillFrameAccum=0,this._needsRefresh=!0,this._needsRefreshHover=!0,this._darkMode=!1,n=n||{},this.dom=e,this.id=t;var r=new ro,o=n.renderer||"canvas";$s[o]||(o=Qn($s)[0]),n.useDirtyRect=null!=n.useDirtyRect&&n.useDirtyRect;var a=new $s[o](e,r,n,t),s=n.ssr||a.ssrOnly;this.storage=r,this.painter=a;var l,c=bn.node||bn.worker||s?null:new Ka(a.getViewportRoot(),a.root),d=n.useCoarsePointer;(null==d||"auto"===d?bn.touchEventsSupported:!!d)&&(l=pi(n.pointerSize,44)),this.handler=new Gr(r,a,c,a.root,l),this.animation=new za({stage:{update:s?null:function(){return i._flush(!0)}}}),s||this.animation.start()}return t.prototype.add=function(t){!this._disposed&&t&&(this.storage.addRoot(t),t.addSelfToZr(this),this.refresh())},t.prototype.remove=function(t){!this._disposed&&t&&(this.storage.delRoot(t),t.removeSelfFromZr(this),this.refresh())},t.prototype.configLayer=function(t,e){this._disposed||(this.painter.configLayer&&this.painter.configLayer(t,e),this.refresh())},t.prototype.setBackgroundColor=function(t){this._disposed||(this.painter.setBackgroundColor&&this.painter.setBackgroundColor(t),this.refresh(),this._backgroundColor=t,this._darkMode=function(t){if(!t)return!1;if("string"==typeof t)return Qo(t,1)<.4;if(t.colorStops){for(var e=t.colorStops,n=0,i=e.length,r=0;r0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*c+a}var js=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return Xs(t,e,n)};function Xs(t,e,n){return ii(t)?(i=t,i.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e+(n||0):parseFloat(t):null==t?NaN:+t;var i}function Ys(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function Zs(t){if(t=+t,isNaN(t))return 0;if(t>1e-14)for(var e=1,n=0;n<15;n++,e*=10)if(Math.round(t*e)/e===t)return n;return function(t){var e=t.toString().toLowerCase(),n=e.indexOf("e"),i=n>0?+e.slice(n+1):0,r=n>0?n:e.length,o=e.indexOf("."),a=o<0?0:r-1-o;return Math.max(0,a-i)}(t)}function Ks(t,e){var n=Math.max(Zs(t),Zs(e)),i=t+e;return n>20?i:Ys(i,n)}function Qs(t){var e=2*Math.PI;return(t%e+e)%e}function Js(t){return t>-1e-4&&t=10&&e++,e}function il(t,e){var n=nl(t),i=Math.pow(10,n),r=t/i;return t=(r<1.5?1:r<2.5?2:r<4?3:r<7?5:10)*i,n>=-20?+t.toFixed(n<0?-n:0):t}function rl(t){var e=parseFloat(t);return e==t&&(0!==e||!ii(t)||t.indexOf("x")<=0)?e:NaN}function ol(){return Math.round(9*Math.random())}function al(t,e){return 0===e?t:al(e,t%e)}function sl(t,e){return null==t?e:null==e?t:t*e/al(t,e)}var ll="undefined"!=typeof console&&console.warn&&console.log;function cl(t,e){!function(t,e){ll&&console[t]("[ECharts] "+e)}("error",t)}function dl(t){throw new Error(t)}function ul(t,e,n){return(e-t)*n+t}var hl="series\0";function pl(t){return t instanceof Array?t:null==t?[]:[t]}function fl(t,e,n){if(t){t[e]=t[e]||{},t.emphasis=t.emphasis||{},t.emphasis[e]=t.emphasis[e]||{};for(var i=0,r=n.length;i=0||r&&Un(r,s)<0)){var l=n.getShallow(s,e);null!=l&&(o[t[a][0]]=l)}}return o}}var Vl=Bl([["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]]),Wl=function(){function t(){}return t.prototype.getAreaStyle=function(t,e){return Vl(this,t,e)},t}(),Gl=new zo(50);function Ul(t){if("string"==typeof t){var e=Gl.get(t);return e&&e.image}return t}function ql(t,e,n,i,r){if(t){if("string"==typeof t){if(e&&e.__zrImageSrc===t||!n)return e;var o=Gl.get(t),a={hostEl:n,cb:i,cbPayload:r};return o?!Xl(e=o.image)&&o.pending.push(a):((e=Tn.loadImage(t,jl,jl)).__zrImageSrc=t,Gl.put(t,e.__cachedImgObj={image:e,pending:[a]})),e}return t}return e}function jl(){var t=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var e=0;e=s;c++)l-=s;var d=ms(a,n);return d>l&&(n="",d=0),l=t-d,r.ellipsis=n,r.ellipsisWidth=d,r.contentWidth=l,r.containerWidth=t,r}function Ql(t,e,n){var i=n.containerWidth,r=n.contentWidth,o=n.fontMeasureInfo;if(!i)return t.textLine="",void(t.isTruncated=!1);var a=ms(o,e);if(a<=i)return t.textLine=e,void(t.isTruncated=!1);for(var s=0;;s++){if(a<=r||s>=n.maxIterations){e+=n.ellipsis;break}var l=0===s?Jl(e,r,o):a>0?Math.floor(e.length*r/a):0;a=ms(o,e=e.substr(0,l))}""===e&&(e=n.placeholder),t.textLine=e,t.isTruncated=!0}function Jl(t,e,n){for(var i=0,r=0,o=t.length;r0&&f+i.accumWidth>i.width&&(o=e.split("\n"),u=!0),i.accumWidth=f}else{var g=ac(e,d,i.width,i.breakAll,i.accumWidth);i.accumWidth=g.accumWidth+p,a=g.linesWidths,o=g.lines}}o||(o=e.split("\n"));for(var v=ps(d),m=0;m=32&&e<=591||e>=880&&e<=4351||e>=4608&&e<=5119||e>=7680&&e<=8303}(t)||!!rc[t]}function ac(t,e,n,i,r){for(var o=[],a=[],s="",l="",c=0,d=0,u=ps(e),h=0;hn:r+d+f>n)?d?(s||l)&&(g?(s||(s=l,l="",d=c=0),o.push(s),a.push(d-c),l+=p,s="",d=c+=f):(l&&(s+=l,l="",c=0),o.push(s),a.push(d),s=p,d=f)):g?(o.push(l),a.push(c),l=p,c=f):(o.push(p),a.push(f)):(d+=f,g?(l+=p,c+=f):(l&&(s+=l,l="",c=0),s+=p))}else l&&(s+=l,d+=c),o.push(s),a.push(d),s="",l="",c=0,d=0}return l&&(s+=l),s&&(o.push(s),a.push(d)),1===o.length&&(d+=r),{accumWidth:d,lines:o,linesWidths:a}}function sc(t,e,n,i,r,o){if(t.baseX=n,t.baseY=i,t.outerWidth=t.outerHeight=null,e){var a=2*e.width,s=2*e.height;Er.set(lc,bs(n,a,r),xs(i,s,o),a,s),Er.intersect(e,lc,null,cc);var l=cc.outIntersectRect;t.outerWidth=l.width,t.outerHeight=l.height,t.baseX=bs(l.x,l.width,r,!0),t.baseY=xs(l.y,l.height,o,!0)}}var lc=new Er(0,0,0,0),cc={outIntersectRect:{},clamp:!0};function dc(t){return null!=t?t+="":t=""}function uc(t,e,n,i){var r=new Er(bs(t.x||0,e,t.textAlign),xs(t.y||0,n,t.textBaseline),e,n),o=null!=i?i:hc(t)?t.lineWidth:0;return o>0&&(r.x-=o/2,r.y-=o/2,r.width+=o,r.height+=o),r}function hc(t){var e=t.stroke;return null!=e&&"none"!==e&&t.lineWidth>0}var pc="__zr_style_"+Math.round(10*Math.random()),fc={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},gc={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};fc[pc]=!0;var vc=["z","z2","invisible"],mc=["invisible"],yc=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype._init=function(e){for(var n=Qn(e),i=0;i1e-4)return s[0]=t-n,s[1]=e-i,l[0]=t+n,void(l[1]=e+i);if(Tc[0]=Cc(r)*n+t,Tc[1]=Sc(r)*i+e,Mc[0]=Cc(o)*n+t,Mc[1]=Sc(o)*i+e,c(s,Tc,Mc),d(l,Tc,Mc),(r%=kc)<0&&(r+=kc),(o%=kc)<0&&(o+=kc),r>o&&!a?o+=kc:rr&&(Ic[0]=Cc(p)*n+t,Ic[1]=Sc(p)*i+e,c(s,Ic,s),d(l,Ic,l))}var Nc={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},Oc=[],$c=[],Rc=[],Hc=[],Fc=[],Bc=[],Vc=Math.min,Wc=Math.max,Gc=Math.cos,Uc=Math.sin,qc=Math.abs,jc=Math.PI,Xc=2*jc,Yc="undefined"!=typeof Float32Array,Zc=[];function Kc(t){return Math.round(t/jc*1e8)/1e8%2*jc}var Qc=function(){function t(t){this.dpr=1,this._xi=0,this._yi=0,this._x0=0,this._y0=0,this._len=0,t&&(this._saveData=!1),this._saveData&&(this.data=[])}var e;return t.prototype.increaseVersion=function(){this._version++},t.prototype.getVersion=function(){return this._version},t.prototype.setScale=function(t,e,n){(n=n||0)>0&&(this._ux=qc(n/Ja/t)||0,this._uy=qc(n/Ja/e)||0)},t.prototype.setDPR=function(t){this.dpr=t},t.prototype.setContext=function(t){this._ctx=t},t.prototype.getContext=function(){return this._ctx},t.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},t.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},t.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(Nc.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},t.prototype.lineTo=function(t,e){var n=qc(t-this._xi),i=qc(e-this._yi),r=n>this._ux||i>this._uy;if(this.addData(Nc.L,t,e),this._ctx&&r&&this._ctx.lineTo(t,e),r)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=n*n+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){return this._drawPendingPt(),this.addData(Nc.C,t,e,n,i,r,o),this._ctx&&this._ctx.bezierCurveTo(t,e,n,i,r,o),this._xi=r,this._yi=o,this},t.prototype.quadraticCurveTo=function(t,e,n,i){return this._drawPendingPt(),this.addData(Nc.Q,t,e,n,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,n,i),this._xi=n,this._yi=i,this},t.prototype.arc=function(t,e,n,i,r,o){this._drawPendingPt(),Zc[0]=i,Zc[1]=r,function(t,e){var n=Kc(t[0]);n<0&&(n+=Xc);var i=n-t[0],r=t[1];r+=i,!e&&r-n>=Xc?r=n+Xc:e&&n-r>=Xc?r=n-Xc:!e&&n>r?r=n+(Xc-Kc(n-r)):e&&n0&&o))for(var a=0;ac.length&&(this._expandData(),c=this.data);for(var d=0;d0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},t.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},t.prototype.getBoundingRect=function(){Rc[0]=Rc[1]=Fc[0]=Fc[1]=Number.MAX_VALUE,Hc[0]=Hc[1]=Bc[0]=Bc[1]=-Number.MAX_VALUE;var t,e=this.data,n=0,i=0,r=0,o=0;for(t=0;tn||qc(v)>i||u===e-1)&&(f=Math.sqrt(A*A+v*v),r=g,o=_);break;case Nc.C:var m=t[u++],y=t[u++],_=(g=t[u++],t[u++]),b=t[u++],x=t[u++];f=So(r,o,m,y,g,_,b,x,10),r=b,o=x;break;case Nc.Q:f=Io(r,o,m=t[u++],y=t[u++],g=t[u++],_=t[u++],10),r=g,o=_;break;case Nc.A:var w=t[u++],S=t[u++],C=t[u++],k=t[u++],T=t[u++],M=t[u++],I=M+T;u+=1,p&&(a=Gc(T)*C+w,s=Uc(T)*k+S),f=Wc(C,k)*Vc(Xc,Math.abs(M)),r=Gc(I)*C+w,o=Uc(I)*k+S;break;case Nc.R:a=r=t[u++],s=o=t[u++],f=2*t[u++]+2*t[u++];break;case Nc.Z:var A=a-r;v=s-o;f=Math.sqrt(A*A+v*v),r=a,o=s}f>=0&&(l[d++]=f,c+=f)}return this._pathLen=c,c},t.prototype.rebuildPath=function(t,e){var n,i,r,o,a,s,l,c,d,u,h=this.data,p=this._ux,f=this._uy,g=this._len,v=e<1,m=0,y=0,_=0;if(!v||(this._pathSegLen||this._calculateLength(),l=this._pathSegLen,c=e*this._pathLen))t:for(var b=0;b0&&(t.lineTo(d,u),_=0),x){case Nc.M:n=r=h[b++],i=o=h[b++],t.moveTo(r,o);break;case Nc.L:a=h[b++],s=h[b++];var S=qc(a-r),C=qc(s-o);if(S>p||C>f){if(v){if(m+(X=l[y++])>c){var k=(c-m)/X;t.lineTo(r*(1-k)+a*k,o*(1-k)+s*k);break t}m+=X}t.lineTo(a,s),r=a,o=s,_=0}else{var T=S*S+C*C;T>_&&(d=a,u=s,_=T)}break;case Nc.C:var M=h[b++],I=h[b++],A=h[b++],D=h[b++],P=h[b++],L=h[b++];if(v){if(m+(X=l[y++])>c){wo(r,M,A,P,k=(c-m)/X,Oc),wo(o,I,D,L,k,$c),t.bezierCurveTo(Oc[1],$c[1],Oc[2],$c[2],Oc[3],$c[3]);break t}m+=X}t.bezierCurveTo(M,I,A,D,P,L),r=P,o=L;break;case Nc.Q:M=h[b++],I=h[b++],A=h[b++],D=h[b++];if(v){if(m+(X=l[y++])>c){Mo(r,M,A,k=(c-m)/X,Oc),Mo(o,I,D,k,$c),t.quadraticCurveTo(Oc[1],$c[1],Oc[2],$c[2]);break t}m+=X}t.quadraticCurveTo(M,I,A,D),r=A,o=D;break;case Nc.A:var E=h[b++],z=h[b++],N=h[b++],O=h[b++],$=h[b++],R=h[b++],H=h[b++],F=!h[b++],B=N>O?N:O,V=qc(N-O)>.001,W=$+R,G=!1;if(v)m+(X=l[y++])>c&&(W=$+R*(c-m)/X,G=!0),m+=X;if(V&&t.ellipse?t.ellipse(E,z,N,O,H,$,W,F):t.arc(E,z,B,$,W,F),G)break t;w&&(n=Gc($)*N+E,i=Uc($)*O+z),r=Gc(W)*N+E,o=Uc(W)*O+z;break;case Nc.R:n=r=h[b],i=o=h[b+1],a=h[b++],s=h[b++];var U=h[b++],q=h[b++];if(v){if(m+(X=l[y++])>c){var j=c-m;t.moveTo(a,s),t.lineTo(a+Vc(j,U),s),(j-=U)>0&&t.lineTo(a+U,s+Vc(j,q)),(j-=q)>0&&t.lineTo(a+Wc(U-j,0),s+q),(j-=U)>0&&t.lineTo(a,s+Wc(q-j,0));break t}m+=X}t.rect(a,s,U,q);break;case Nc.Z:if(v){var X;if(m+(X=l[y++])>c){k=(c-m)/X;t.lineTo(r*(1-k)+n*k,o*(1-k)+i*k);break t}m+=X}t.closePath(),r=n,o=i}}},t.prototype.clone=function(){var e=new t,n=this.data;return e.data=n.slice?n.slice():Array.prototype.slice.call(n),e._len=this._len,e},t.prototype.canSave=function(){return!!this._saveData},t.CMD=Nc,t.initDefaultProps=((e=t.prototype)._saveData=!0,e._ux=0,e._uy=0,e._pendingPtDist=0,void(e._version=0)),t}();function Jc(t,e,n,i,r,o,a){if(0===r)return!1;var s=r,l=0;if(a>e+s&&a>i+s||at+s&&o>n+s||oe+u&&d>i+u&&d>o+u&&d>s+u||dt+u&&c>n+u&&c>r+u&&c>a+u||c=0&&pe+c&&l>i+c&&l>o+c||lt+c&&s>n+c&&s>r+c||s=0&&gn||d+cr&&(r+=rd);var h=Math.atan2(l,s);return h<0&&(h+=rd),h>=i&&h<=r||h+rd>=i&&h+rd<=r}function ad(t,e,n,i,r,o){if(o>e&&o>i||or?s:0}var sd=Qc.CMD,ld=2*Math.PI;var cd=[-1,-1,-1],dd=[-1,-1];function ud(){var t=dd[0];dd[0]=dd[1],dd[1]=t}function hd(t,e,n,i,r,o,a,s,l,c){if(c>e&&c>i&&c>o&&c>s||c1&&ud(),p=yo(e,i,o,s,dd[0]),h>1&&(f=yo(e,i,o,s,dd[1]))),2===h?ve&&s>i&&s>o||s=0&&d<=1&&(r[l++]=d);else{var c=a*a-4*o*s;if(vo(c))(d=-a/(2*o))>=0&&d<=1&&(r[l++]=d);else if(c>0){var d,u=so(c),h=(-a-u)/(2*o);(d=(-a+u)/(2*o))>=0&&d<=1&&(r[l++]=d),h>=0&&h<=1&&(r[l++]=h)}}return l}(e,i,o,s,cd);if(0===l)return 0;var c=To(e,i,o);if(c>=0&&c<=1){for(var d=0,u=Co(e,i,o,c),h=0;hn||s<-n)return 0;var l=Math.sqrt(n*n-s*s);cd[0]=-l,cd[1]=l;var c=Math.abs(i-r);if(c<1e-4)return 0;if(c>=ld-1e-4){i=0,r=ld;var d=o?1:-1;return a>=cd[0]+t&&a<=cd[1]+t?d:0}if(i>r){var u=i;i=r,r=u}i<0&&(i+=ld,r+=ld);for(var h=0,p=0;p<2;p++){var f=cd[p];if(f+t>a){var g=Math.atan2(s,f);d=o?1:-1;g<0&&(g=ld+g),(g>=i&&g<=r||g+ld>=i&&g+ld<=r)&&(g>Math.PI/2&&g<1.5*Math.PI&&(d=-d),h+=d)}}return h}function gd(t,e,n,i,r){for(var o,a,s=t.data,l=t.len(),c=0,d=0,u=0,h=0,p=0,f=0;f1&&(n||(c+=ad(d,u,h,p,i,r))),v&&(h=d=s[f],p=u=s[f+1]),g){case sd.M:d=h=s[f++],u=p=s[f++];break;case sd.L:if(n){if(Jc(d,u,s[f],s[f+1],e,i,r))return!0}else c+=ad(d,u,s[f],s[f+1],i,r)||0;d=s[f++],u=s[f++];break;case sd.C:if(n){if(td(d,u,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=hd(d,u,s[f++],s[f++],s[f++],s[f++],s[f],s[f+1],i,r)||0;d=s[f++],u=s[f++];break;case sd.Q:if(n){if(ed(d,u,s[f++],s[f++],s[f],s[f+1],e,i,r))return!0}else c+=pd(d,u,s[f++],s[f++],s[f],s[f+1],i,r)||0;d=s[f++],u=s[f++];break;case sd.A:var m=s[f++],y=s[f++],_=s[f++],b=s[f++],x=s[f++],w=s[f++];f+=1;var S=!!(1-s[f++]);o=Math.cos(x)*_+m,a=Math.sin(x)*b+y,v?(h=o,p=a):c+=ad(d,u,o,a,i,r);var C=(i-m)*b/_+m;if(n){if(od(m,y,b,x,x+w,S,e,C,r))return!0}else c+=fd(m,y,b,x,x+w,S,C,r);d=Math.cos(x+w)*_+m,u=Math.sin(x+w)*b+y;break;case sd.R:if(h=d=s[f++],p=u=s[f++],o=h+s[f++],a=p+s[f++],n){if(Jc(h,p,o,p,e,i,r)||Jc(o,p,o,a,e,i,r)||Jc(o,a,h,a,e,i,r)||Jc(h,a,h,p,e,i,r))return!0}else c+=ad(o,p,o,a,i,r),c+=ad(h,a,h,p,i,r);break;case sd.Z:if(n){if(Jc(d,u,h,p,e,i,r))return!0}else c+=ad(d,u,h,p,i,r);d=h,u=p}}return n||function(t,e){return Math.abs(t-e)<1e-4}(u,p)||(c+=ad(d,u,h,p,i,r)||0),0!==c}var vd=Gn({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},fc),md={style:Gn({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},gc.style)},yd=us.concat(["invisible","culling","z","z2","zlevel","parent"]),_d=function(t){function e(e){return t.call(this,e)||this}var n;return _(e,t),e.prototype.update=function(){var n=this;t.prototype.update.call(this);var i=this.style;if(i.decal){var r=this._decalEl=this._decalEl||new e;r.buildPath===e.prototype.buildPath&&(r.buildPath=function(t){n.buildPath(t,n.shape)}),r.silent=!0;var o=r.style;for(var a in i)o[a]!==i[a]&&(o[a]=i[a]);o.fill=i.fill?i.decal:null,o.decal=null,o.shadowColor=null,i.strokeFirst&&(o.stroke=null);for(var s=0;s.5?ts:e>.2?"#eee":es}if(t)return es}return ts},e.prototype.getInsideTextStroke=function(t){var e=this.style.fill;if(ii(e)){var n=this.__zr;if(!(!n||!n.isDarkMode())===Qo(t,0)<.4)return e}},e.prototype.buildPath=function(t,e,n){},e.prototype.pathUpdated=function(){this.__dirty&=-5},e.prototype.getUpdatedPathProxy=function(t){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,t),this.path},e.prototype.createPathProxy=function(){this.path=new Qc(!1)},e.prototype.hasStroke=function(){var t=this.style,e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))},e.prototype.hasFill=function(){var t=this.style.fill;return null!=t&&"none"!==t},e.prototype.getBoundingRect=function(){var t=this._rect,e=this.style,n=!t;if(n){var i=!1;this.path||(i=!0,this.createPathProxy());var r=this.path;(i||4&this.__dirty)&&(r.beginPath(),this.buildPath(r,this.shape,!1),this.pathUpdated()),t=r.getBoundingRect()}if(this._rect=t,this.hasStroke()&&this.path&&this.path.len()>0){var o=this._rectStroke||(this._rectStroke=t.clone());if(this.__dirty||n){o.copy(t);var a=e.strokeNoScale?this.getLineScale():1,s=e.lineWidth;if(!this.hasFill()){var l=this.strokeContainThreshold;s=Math.max(s,null==l?4:l)}a>1e-10&&(o.width+=s/a,o.height+=s/a,o.x-=s/a/2,o.y-=s/a/2)}return o}return t},e.prototype.contain=function(t,e){var n=this.transformCoordToLocal(t,e),i=this.getBoundingRect(),r=this.style;if(t=n[0],e=n[1],i.contain(t,e)){var o=this.path;if(this.hasStroke()){var a=r.lineWidth,s=r.strokeNoScale?this.getLineScale():1;if(s>1e-10&&(this.hasFill()||(a=Math.max(a,this.strokeContainThreshold)),function(t,e,n,i){return gd(t,e,!0,n,i)}(o,a/s,t,e)))return!0}if(this.hasFill())return function(t,e,n){return gd(t,0,!1,e,n)}(o,t,e)}return!1},e.prototype.dirtyShape=function(){this.__dirty|=4,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},e.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},e.prototype.animateShape=function(t){return this.animate("shape",t)},e.prototype.updateDuringAnimation=function(t){"style"===t?this.dirtyStyle():"shape"===t?this.dirtyShape():this.markRedraw()},e.prototype.attrKV=function(e,n){"shape"===e?this.setShape(n):t.prototype.attrKV.call(this,e,n)},e.prototype.setShape=function(t,e){var n=this.shape;return n||(n=this.shape={}),"string"==typeof t?n[t]=e:Wn(n,t),this.dirtyShape(),this},e.prototype.shapeChanged=function(){return!!(4&this.__dirty)},e.prototype.createStyle=function(t){return Ti(vd,t)},e.prototype._innerSaveToNormal=function(e){t.prototype._innerSaveToNormal.call(this,e);var n=this._normalState;e.shape&&!n.shape&&(n.shape=Wn({},this.shape))},e.prototype._applyStateObj=function(e,n,i,r,o,a){t.prototype._applyStateObj.call(this,e,n,i,r,o,a);var s,l=!(n&&r);if(n&&n.shape?o?r?s=n.shape:(s=Wn({},i.shape),Wn(s,n.shape)):(s=Wn({},r?this.shape:i.shape),Wn(s,n.shape)):l&&(s=i.shape),s)if(o){this.shape=Wn({},this.shape);for(var c={},d=Qn(s),u=0;uc&&(n*=c/(a=n+i),i*=c/a),r+o>c&&(r*=c/(a=r+o),o*=c/a),i+r>d&&(i*=d/(a=i+r),r*=d/a),n+o>d&&(n*=d/(a=n+o),o*=d/a),t.moveTo(s+n,l),t.lineTo(s+c-i,l),0!==i&&t.arc(s+c-i,l+i,i,-Math.PI/2,0),t.lineTo(s+c,l+d-r),0!==r&&t.arc(s+c-r,l+d-r,r,0,Math.PI/2),t.lineTo(s+o,l+d),0!==o&&t.arc(s+o,l+d-o,o,Math.PI/2,Math.PI),t.lineTo(s,l+n),0!==n&&t.arc(s+n,l+n,n,Math.PI,1.5*Math.PI)}(t,e):t.rect(n,i,r,o)},e.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},e}(_d);Pd.prototype.type="rect";var Ld={fill:"#000"},Ed={},zd={style:Gn({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},gc.style)},Nd=function(t){function e(e){var n=t.call(this)||this;return n.type="text",n._children=[],n._defaultStyle=Ld,n.attr(e),n}return _(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.update=function(){t.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;em&&p){var _=Math.floor(m/h);f=f||v.length>_,y=(v=v.slice(0,_)).length*h}if(r&&d&&null!=g)for(var b=Kl(g,c,e.ellipsis,{minChar:e.truncateMinChar,placeholder:e.placeholder}),x={},w=0;w0,k=0;kg&&ic(o,a.substring(g,v),e,f),ic(o,h[2],e,f,h[1]),g=Yl.lastIndex}gu){var z=o.lines.length;I>0?(k.tokens=k.tokens.slice(0,I),S(k,M,T),o.lines=o.lines.slice(0,C+1)):o.lines=o.lines.slice(0,C),o.isTruncated=o.isTruncated||o.lines.length=0&&"right"===(M=_[T]).align;)this._placeToken(M,t,x,f,k,"right",v),w-=M.width,k-=M.width,T--;for(C+=(s-(C-p)-(g-k)-w)/2;S<=T;)M=_[S],this._placeToken(M,t,x,f,C+M.width/2,"center",v),C+=M.width,S++;f+=x}},e.prototype._placeToken=function(t,e,n,i,r,o,a){var s=e.rich[t.styleName]||{};s.text=t.text;var l=t.verticalAlign,c=i+n/2;"top"===l?c=i+t.height/2:"bottom"===l&&(c=i+n-t.height/2),!t.isLineHolder&&jd(s)&&this._renderBackground(s,e,"right"===o?r-t.width:"center"===o?r-t.width/2:r,c-t.height/2,t.width,t.height);var d=!!s.backgroundColor,u=t.textPadding;u&&(r=Ud(r,o,u),c-=t.height/2-u[0]-t.innerHeight/2);var h=this._getOrCreateChild(xd),p=h.createStyle();h.useStyle(p);var f=this._defaultStyle,g=!1,v=0,m=!1,y=Gd("fill"in s?s.fill:"fill"in e?e.fill:(g=!0,f.fill)),_=Wd("stroke"in s?s.stroke:"stroke"in e?e.stroke:d||a||f.autoStroke&&!g?null:(v=2,m=!0,f.stroke)),b=s.textShadowBlur>0||e.textShadowBlur>0;p.text=t.text,p.x=r,p.y=c,b&&(p.shadowBlur=s.textShadowBlur||e.textShadowBlur||0,p.shadowColor=s.textShadowColor||e.textShadowColor||"transparent",p.shadowOffsetX=s.textShadowOffsetX||e.textShadowOffsetX||0,p.shadowOffsetY=s.textShadowOffsetY||e.textShadowOffsetY||0),p.textAlign=o,p.textBaseline="middle",p.font=t.font||wn,p.opacity=fi(s.opacity,e.opacity,1),Fd(p,s),_&&(p.lineWidth=fi(s.lineWidth,e.lineWidth,v),p.lineDash=pi(s.lineDash,e.lineDash),p.lineDashOffset=e.lineDashOffset||0,p.stroke=_),y&&(p.fill=y),h.setBoundingRect(uc(p,t.contentWidth,t.contentHeight,m?0:null))},e.prototype._renderBackground=function(t,e,n,i,r,o){var a,s,l,c=t.backgroundColor,d=t.borderWidth,u=t.borderColor,h=c&&c.image,p=c&&!h,f=t.borderRadius,g=this;if(p||t.lineHeight||d&&u){(a=this._getOrCreateChild(Pd)).useStyle(a.createStyle()),a.style.fill=null;var v=a.shape;v.x=n,v.y=i,v.width=r,v.height=o,v.r=f,a.dirtyShape()}if(p)(l=a.style).fill=c||null,l.fillOpacity=pi(t.fillOpacity,1);else if(h){(s=this._getOrCreateChild(Cd)).onload=function(){g.dirtyStyle()};var m=s.style;m.image=c.image,m.x=n,m.y=i,m.width=r,m.height=o}d&&u&&((l=a.style).lineWidth=d,l.stroke=u,l.strokeOpacity=pi(t.strokeOpacity,1),l.lineDash=t.borderDash,l.lineDashOffset=t.borderDashOffset||0,a.strokeContainThreshold=0,a.hasFill()&&a.hasStroke()&&(l.strokeFirst=!0,l.lineWidth*=2));var y=(a||s).style;y.shadowBlur=t.shadowBlur||0,y.shadowColor=t.shadowColor||"transparent",y.shadowOffsetX=t.shadowOffsetX||0,y.shadowOffsetY=t.shadowOffsetY||0,y.opacity=fi(t.opacity,e.opacity,1)},e.makeFont=function(t){var e="";return Bd(t)&&(e=[t.fontStyle,t.fontWeight,Hd(t.fontSize),t.fontFamily||"sans-serif"].join(" ")),e&&yi(e)||t.textFont||t.font},e}(yc),Od={left:!0,right:1,center:1},$d={top:1,bottom:1,middle:1},Rd=["fontStyle","fontWeight","fontSize","fontFamily"];function Hd(t){return"string"!=typeof t||-1===t.indexOf("px")&&-1===t.indexOf("rem")&&-1===t.indexOf("em")?isNaN(+t)?"12px":t+"px":t}function Fd(t,e){for(var n=0;n=0,o=!1;if(t instanceof _d){var a=Kd(t),s=r&&a.selectFill||a.normalFill,l=r&&a.selectStroke||a.normalStroke;if(su(s)||su(l)){var c=(i=i||{}).style||{};"inherit"===c.fill?(o=!0,i=Wn({},i),(c=Wn({},c)).fill=s):!su(c.fill)&&su(s)?(o=!0,i=Wn({},i),(c=Wn({},c)).fill=ta(s)):!su(c.stroke)&&su(l)&&(o||(i=Wn({},i),c=Wn({},c)),c.stroke=ta(l)),i.style=c}}if(i&&null==i.z2){o||(i=Wn({},i));var d=t.z2EmphasisLift;i.z2=t.z2+(null!=d?d:10)}return i}(this,0,e,n);if("blur"===t)return function(t,e,n){var i=Un(t.currentStates,e)>=0,r=t.style.opacity,o=i?null:function(t,e,n,i){for(var r=t.style,o={},a=0;a0){var o={dataIndex:r,seriesIndex:t.seriesIndex};null!=i&&(o.dataType=i),e.push(o)}})}),e}function zu(t,e,n){Hu(t,!0),vu(t,_u),function(t,e,n){var i=Xd(t);null!=e?(i.focus=e,i.blurScope=n):i.focus&&(i.focus=null)}(t,e,n)}function Nu(t,e,n,i){i?function(t){Hu(t,!1)}(t):zu(t,e,n)}var Ou=["emphasis","blur","select"],$u={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function Ru(t,e,n,i){n=n||"itemStyle";for(var r=0;r1&&(a*=ju(f),s*=ju(f));var g=(r===o?-1:1)*ju((a*a*(s*s)-a*a*(p*p)-s*s*(h*h))/(a*a*(p*p)+s*s*(h*h)))||0,v=g*a*p/s,m=g*-s*h/a,y=(t+n)/2+Yu(u)*v-Xu(u)*m,_=(e+i)/2+Xu(u)*v+Yu(u)*m,b=Ju([1,0],[(h-v)/a,(p-m)/s]),x=[(h-v)/a,(p-m)/s],w=[(-1*h-v)/a,(-1*p-m)/s],S=Ju(x,w);if(Qu(x,w)<=-1&&(S=Zu),Qu(x,w)>=1&&(S=0),S<0){var C=Math.round(S/Zu*1e6)/1e6;S=2*Zu+C%2*Zu}d.addData(c,y,_,a,s,b,S,u,o)}var eh=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/gi,nh=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;var ih=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.applyTransform=function(t){},e}(_d);function rh(t){return null!=t.setData}function oh(t,e){var n=function(t){var e=new Qc;if(!t)return e;var n,i=0,r=0,o=i,a=r,s=Qc.CMD,l=t.match(eh);if(!l)return e;for(var c=0;cD*D+P*P&&(C=T,k=M),{cx:C,cy:k,x0:-d,y0:-u,x1:C*(r/x-1),y1:k*(r/x-1)}}function wh(t,e){var n,i=yh(e.r,0),r=yh(e.r0||0,0),o=i>0;if(o||r>0){if(o||(i=r,r=0),r>i){var a=i;i=r,r=a}var s=e.startAngle,l=e.endAngle;if(!isNaN(s)&&!isNaN(l)){var c=e.cx,d=e.cy,u=!!e.clockwise,h=vh(l-s),p=h>uh&&h%uh;if(p>bh&&(h=p),i>bh)if(h>uh-bh)t.moveTo(c+i*ph(s),d+i*hh(s)),t.arc(c,d,i,s,l,!u),r>bh&&(t.moveTo(c+r*ph(l),d+r*hh(l)),t.arc(c,d,r,l,s,u));else{var f=void 0,g=void 0,v=void 0,m=void 0,y=void 0,_=void 0,b=void 0,x=void 0,w=void 0,S=void 0,C=void 0,k=void 0,T=void 0,M=void 0,I=void 0,A=void 0,D=i*ph(s),P=i*hh(s),L=r*ph(l),E=r*hh(l),z=h>bh;if(z){var N=e.cornerRadius;N&&(n=function(t){var e;if(ei(t)){var n=t.length;if(!n)return t;e=1===n?[t[0],t[0],0,0]:2===n?[t[0],t[0],t[1],t[1]]:3===n?t.concat(t[2]):t}else e=[t,t,t,t];return e}(N),f=n[0],g=n[1],v=n[2],m=n[3]);var O=vh(i-r)/2;if(y=_h(O,v),_=_h(O,m),b=_h(O,f),x=_h(O,g),C=w=yh(y,_),k=S=yh(b,x),(w>bh||S>bh)&&(T=i*ph(l),M=i*hh(l),I=r*ph(s),A=r*hh(s),hbh){var G=_h(v,C),U=_h(m,C),q=xh(I,A,D,P,i,G,u),j=xh(T,M,L,E,i,U,u);t.moveTo(c+q.cx+q.x0,d+q.cy+q.y0),C0&&t.arc(c+q.cx,d+q.cy,G,gh(q.y0,q.x0),gh(q.y1,q.x1),!u),t.arc(c,d,i,gh(q.cy+q.y1,q.cx+q.x1),gh(j.cy+j.y1,j.cx+j.x1),!u),U>0&&t.arc(c+j.cx,d+j.cy,U,gh(j.y1,j.x1),gh(j.y0,j.x0),!u))}else t.moveTo(c+D,d+P),t.arc(c,d,i,s,l,!u);else t.moveTo(c+D,d+P);if(r>bh&&z)if(k>bh){G=_h(f,k),q=xh(L,E,T,M,r,-(U=_h(g,k)),u),j=xh(D,P,I,A,r,-G,u);t.lineTo(c+q.cx+q.x0,d+q.cy+q.y0),k0&&t.arc(c+q.cx,d+q.cy,U,gh(q.y0,q.x0),gh(q.y1,q.x1),!u),t.arc(c,d,r,gh(q.cy+q.y1,q.cx+q.x1),gh(j.cy+j.y1,j.cx+j.x1),u),G>0&&t.arc(c+j.cx,d+j.cy,G,gh(j.y1,j.x1),gh(j.y0,j.x0),!u))}else t.lineTo(c+L,d+E),t.arc(c,d,r,l,s,u);else t.lineTo(c+L,d+E)}else t.moveTo(c,d);t.closePath()}}}var Sh=function(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=2*Math.PI,this.clockwise=!0,this.cornerRadius=0},Ch=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new Sh},e.prototype.buildPath=function(t,e){wh(t,e)},e.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},e}(_d);Ch.prototype.type="sector";var kh=function(){this.cx=0,this.cy=0,this.r=0,this.r0=0},Th=function(t){function e(e){return t.call(this,e)||this}return _(e,t),e.prototype.getDefaultShape=function(){return new kh},e.prototype.buildPath=function(t,e){var n=e.cx,i=e.cy,r=2*Math.PI;t.moveTo(n+e.r,i),t.arc(n,i,e.r,0,r,!1),t.moveTo(n+e.r0,i),t.arc(n,i,e.r0,0,r,!0)},e}(_d);function Mh(t,e,n){var i=e.smooth,r=e.points;if(r&&r.length>=2){if(i){var o=function(t,e,n,i){var r,o,a,s,l=[],c=[],d=[],u=[];if(i){a=[1/0,1/0],s=[-1/0,-1/0];for(var h=0,p=t.length;hYh[1]){if(r=!1,Zh.negativeSize||n)return r;var s=jh(Yh[0]-Xh[1]),l=jh(Xh[0]-Yh[1]);Uh(s,l)>Qh.len()&&(s=l||!Zh.bidirectional)&&(_r.scale(Kh,a,-l*i),Zh.useDir&&Zh.calcDirMTV()))}}return r},t.prototype._getProjMinMaxOnAxis=function(t,e,n){for(var i=this._axes[t],r=this._origin,o=e[0].dot(i)+r[t],a=o,s=o,l=1;l0){var u={duration:d.duration,delay:d.delay||0,easing:d.easing,done:o,force:!!o||!!a,setToFinal:!c,scope:t,during:a};l?e.animateFrom(n,u):e.animateTo(n,u)}else e.stopAnimation(),!l&&e.attr(n),a&&a(1),o&&o()}function rp(t,e,n,i,r,o){ip("update",t,e,n,i,r,o)}function op(t,e,n,i,r,o){ip("enter",t,e,n,i,r,o)}function ap(t){if(!t.__zr)return!0;for(var e=0;e=-1e-6)return!1;var f=t-r,g=e-o,v=Tp(f,g,c,d)/p;if(v<0||v>1)return!1;var m=Tp(f,g,u,h)/p;return!(m<0||m>1)}function Tp(t,e,n,i){return t*i-n*e}function Mp(t,e,n,i,r){return null==e||(oi(e)?Ip[0]=Ip[1]=Ip[2]=Ip[3]=e:(Ip[0]=e[0],Ip[1]=e[1],Ip[2]=e[2],Ip[3]=e[3]),i&&(Ip[0]=Gs(0,Ip[0]),Ip[1]=Gs(0,Ip[1]),Ip[2]=Gs(0,Ip[2]),Ip[3]=Gs(0,Ip[3])),n&&(Ip[0]=-Ip[0],Ip[1]=-Ip[1],Ip[2]=-Ip[2],Ip[3]=-Ip[3]),Ap(t,Ip,"x","width",3,1,r&&r[0]||0),Ap(t,Ip,"y","height",0,2,r&&r[1]||0)),t}var Ip=[0,0,0,0];function Ap(t,e,n,i,r,o,a){var s=e[o]+e[r],l=t[i];t[i]+=s,a=Gs(0,Ws(a,l)),t[i]=0?-e[r]:e[o]>=0?l+e[o]:Us(s)>1e-8?(l-a)*e[r]/s:0):t[n]-=e[r]}function Dp(t){var e=t.itemTooltipOption,n=t.componentModel,i=t.itemName,r=ii(e)?{formatter:e}:e,o=n.mainType,a=n.componentIndex,s={componentType:o,name:i,$vars:["name"]};s[o+"Index"]=a;var l=t.formatterParamsExtra;l&&Xn(Qn(l),function(t){Mi(s,t)||(s[t]=l[t],s.$vars.push(t))});var c=Xd(t.el);c.componentMainType=o,c.componentIndex=a,c.tooltipConfig={name:i,option:Gn({content:i,encodeHTMLContent:!0,formatterParams:s},r)}}function Pp(t,e){var n;t.isGroup&&(n=e(t)),n||t.traverse(e)}function Lp(t,e){if(t)if(ei(t))for(var n=0;ne&&(e=i),ie&&(n=e=0),{min:n,max:e}},clipPointsByRect:function(t,e){return Yn(t,function(t){var n=t[0];n=Gs(n,e.x),n=Ws(n,e.x+e.width);var i=t[1];return i=Gs(i,e.y),[n,i=Ws(i,e.y+e.height)]})},clipRectByRect:function(t,e){var n=Gs(t.x,e.x),i=Ws(t.x+t.width,e.x+e.width),r=Gs(t.y,e.y),o=Ws(t.y+t.height,e.y+e.height);if(i>=n&&o>=r)return{x:n,y:r,width:i-n,height:o-r}},createIcon:Cp,ensureCopyRect:Np,ensureCopyTransform:Op,expandOrShrinkRect:Mp,extendPath:function(t,e){return hp(t,e)},extendShape:function(t){return _d.extend(t)},getShapeClass:function(t){if(cp.hasOwnProperty(t))return cp[t]},getTransform:function(t,e){for(var n=pr([]);t&&t!==e;)gr(n,t.getLocalTransform(),n),t=t.parent;return n},groupTransition:Sp,initProps:op,isBoundingRectAxisAligned:Ep,isElementRemoved:ap,lineLineIntersect:kp,linePolygonIntersect:function(t,e,n,i,r){for(var o=0,a=r[r.length-1];oUs(o[1])?o[0]>0?"right":"left":o[1]>0?"bottom":"top"},traverseElements:Lp,traverseUpdateZ:Rp,updateProps:rp}),Bp={};function Vp(t,e,n){var i,r=t.labelFetcher,o=t.labelDataIndex,a=t.labelDimIndex,s=e.normal;r&&(i=r.getFormattedLabel(o,"normal",null,a,s&&s.get("formatter"),null!=n?{interpolatedValue:n}:null)),null==i&&(i=ni(t.defaultText)?t.defaultText(o,t,n):t.defaultText);for(var l={normal:i},c=0;c-1?gf:mf;function xf(t,e){t=t.toUpperCase(),_f[t]=new hf(e),yf[t]=e}xf(vf,{time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}}),xf(gf,{time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}});function wf(){return null}var Sf=1e3,Cf=6e4,kf=36e5,Tf=864e5,Mf=31536e6,If={year:/({yyyy}|{yy})/,month:/({MMMM}|{MMM}|{MM}|{M})/,day:/({dd}|{d})/,hour:/({HH}|{H}|{hh}|{h})/,minute:/({mm}|{m})/,second:/({ss}|{s})/,millisecond:/({SSS}|{S})/},Af={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}"},Df="{yyyy}-{MM}-{dd}",Pf={year:"{yyyy}",month:"{yyyy}-{MM}",day:Df,hour:Df+" "+Af.hour,minute:Df+" "+Af.minute,second:Df+" "+Af.second,millisecond:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},Lf=["year","month","day","hour","minute","second","millisecond"],Ef=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function zf(t){return ii(t)||ni(t)?t:function(t){t=t||{};var e={},n=!0;return Xn(Lf,function(e){n&&(n=null==t[e])}),Xn(Lf,function(i,r){var o=t[i];e[i]={};for(var a=null,s=r;s>=0;s--){var l=Lf[s],c=ai(o)&&!ei(o)?o[l]:o,d=void 0;ei(c)?a=(d=c.slice())[0]||"":ii(c)?d=[a=c]:(null==a?a=Af[i]:If[l].test(a)||(a=e[l][l][0]+" "+a),d=[a],n&&(d[1]="{primary|"+a+"}")),e[i][l]=d}}),e}(t)}function Nf(t,e){return"0000".substr(0,e-(t+="").length)+t}function Of(t){switch(t){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return t}}function $f(t){return t===Of(t)}function Rf(t,e,n,i){var r=el(t),o=r[Bf(n)](),a=r[Vf(n)]()+1,s=Math.floor((a-1)/3)+1,l=r[Wf(n)](),c=r["get"+(n?"UTC":"")+"Day"](),d=r[Gf(n)](),u=(d-1)%12+1,h=r[Uf(n)](),p=r[qf(n)](),f=r[jf(n)](),g=d>=12?"pm":"am",v=g.toUpperCase(),m=i instanceof hf?i:function(t){return _f[t]}(i||bf)||_f[mf],y=m.getModel("time"),_=y.get("month"),b=y.get("monthAbbr"),x=y.get("dayOfWeek"),w=y.get("dayOfWeekAbbr");return(e||"").replace(/{a}/g,g+"").replace(/{A}/g,v+"").replace(/{yyyy}/g,o+"").replace(/{yy}/g,Nf(o%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,_[a-1]).replace(/{MMM}/g,b[a-1]).replace(/{MM}/g,Nf(a,2)).replace(/{M}/g,a+"").replace(/{dd}/g,Nf(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,x[c]).replace(/{ee}/g,w[c]).replace(/{e}/g,c+"").replace(/{HH}/g,Nf(d,2)).replace(/{H}/g,d+"").replace(/{hh}/g,Nf(u+"",2)).replace(/{h}/g,u+"").replace(/{mm}/g,Nf(h,2)).replace(/{m}/g,h+"").replace(/{ss}/g,Nf(p,2)).replace(/{s}/g,p+"").replace(/{SSS}/g,Nf(f,3)).replace(/{S}/g,f+"")}function Hf(t,e){var n=el(t),i=n[Vf(e)]()+1,r=n[Wf(e)](),o=n[Gf(e)](),a=n[Uf(e)](),s=n[qf(e)](),l=0===n[jf(e)](),c=l&&0===s,d=c&&0===a,u=d&&0===o,h=u&&1===r;return h&&1===i?"year":h?"month":u?"day":d?"hour":c?"minute":l?"second":"millisecond"}function Ff(t,e,n){switch(e){case"year":t[Yf(n)](0);case"month":t[Zf(n)](1);case"day":t[Kf(n)](0);case"hour":t[Qf(n)](0);case"minute":t[Jf(n)](0);case"second":t[tg(n)](0)}return t}function Bf(t){return t?"getUTCFullYear":"getFullYear"}function Vf(t){return t?"getUTCMonth":"getMonth"}function Wf(t){return t?"getUTCDate":"getDate"}function Gf(t){return t?"getUTCHours":"getHours"}function Uf(t){return t?"getUTCMinutes":"getMinutes"}function qf(t){return t?"getUTCSeconds":"getSeconds"}function jf(t){return t?"getUTCMilliseconds":"getMilliseconds"}function Xf(t){return t?"setUTCFullYear":"setFullYear"}function Yf(t){return t?"setUTCMonth":"setMonth"}function Zf(t){return t?"setUTCDate":"setDate"}function Kf(t){return t?"setUTCHours":"setHours"}function Qf(t){return t?"setUTCMinutes":"setMinutes"}function Jf(t){return t?"setUTCSeconds":"setSeconds"}function tg(t){return t?"setUTCMilliseconds":"setMilliseconds"}function eg(t){if(isNaN(rl(t)))return ii(t)?t:"-";var e=(t+"").split(".");return e[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(e.length>1?"."+e[1]:"")}function ng(t,e){return t=(t||"").toLowerCase().replace(/-(.)/g,function(t,e){return e.toUpperCase()}),e&&t&&(t=t.charAt(0).toUpperCase()+t.slice(1)),t}var ig=vi;function rg(t,e,n){function i(t){return t&&yi(t)?t:"-"}function r(t){return!(null==t||isNaN(t)||!isFinite(t))}var o="time"===e,a=t instanceof Date;if(o||a){var s=o?el(t):t;if(!isNaN(+s))return Rf(s,"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}",n);if(a)return"-"}if("ordinal"===e)return ri(t)?i(t):oi(t)&&r(t)?t+"":"-";var l=rl(t);return r(l)?eg(l):ri(t)?i(t):"boolean"==typeof t?t+"":"-"}var og=["a","b","c","d","e","f","g"],ag=function(t,e){return"{"+t+(null==e?"":e)+"}"};function sg(t,e,n){ei(e)||(e=[e]);var i=e.length;if(!i)return"";for(var r=e[0].$vars||[],o=0;oi||l.newline?(o=0,d=g,a+=s+n,s=h.height):s=Math.max(s,h.height)}else{var v=h.height+(f?-f.y+h.y:0);(u=a+v)>r||l.newline?(o+=s+n,a=0,u=v,s=h.width):s=Math.max(s,h.width)}l.newline||(l.x=o,l.y=a,l.markRedraw(),"horizontal"===t?o=d+n:a=u+n)})}function Sg(t,e,n){n=ig(n||0);var i=e.width,r=e.height,o=js(t.left,i),a=js(t.top,r),s=js(t.right,i),l=js(t.bottom,r),c=js(t.width,i),d=js(t.height,r),u=n[2]+n[0],h=n[1]+n[3],p=t.aspect;switch(isNaN(c)&&(c=i-s-h-o),isNaN(d)&&(d=r-l-u-a),null!=p&&(isNaN(c)&&isNaN(d)&&(p>i/r?c=.8*i:d=.8*r),isNaN(c)&&(c=p*d),isNaN(d)&&(d=c/p)),isNaN(o)&&(o=i-s-c-h),isNaN(a)&&(a=r-l-d-u),t.left||t.right){case"center":o=i/2-c/2-n[3];break;case"right":o=i-c-h}switch(t.top||t.bottom){case"middle":case"center":a=r/2-d/2-n[0];break;case"bottom":a=r-d-u}o=o||0,a=a||0,isNaN(c)&&(c=i-h-o-(s||0)),isNaN(d)&&(d=r-u-a-(l||0));var f=new Er((e.x||0)+o+n[3],(e.y||0)+a+n[0],c,d);return f.margin=n,f}ti(wg,"vertical"),ti(wg,"horizontal");var Cg=1;function kg(t,e,n){var i,r,o,a,s=t.boxCoordinateSystem;if(s){var l=function(t){var e=t.getShallow("coord",!0),n=hg;if(null==e){var i=fg.get(t.type);i&&i.getCoord2&&(n=pg,e=i.getCoord2(t))}return{coord:e,from:n}}(t),c=l.coord,d=l.from;if(s.dataToLayout){o=Cg,a=d;var u=s.dataToLayout(c);i=u.contentRect||u.rect}}return null==o&&(o=Cg),o===Cg&&(i||(i={x:0,y:0,width:e.getWidth(),height:e.getHeight()}),r=[i.x+i.width/2,i.y+i.height/2]),{type:o,refContainer:i,refPoint:r,boxCoordFrom:a}}function Tg(t){var e=t.layoutMode||t.constructor.layoutMode;return ai(e)?e:e?{type:e}:null}function Mg(t,e,n){var i=n&&n.ignoreSize;!ei(i)&&(i=[i,i]);var r=a(xg[0],0),o=a(xg[1],1);function a(n,r){var o={},a=0,l={},c=0;if(_g(n,function(e){l[e]=t[e]}),_g(n,function(t){Mi(e,t)&&(o[t]=l[t]=e[t]),s(o,t)&&a++,s(l,t)&&c++}),i[r])return s(e,n[1])?l[n[2]]=null:s(e,n[2])&&(l[n[1]]=null),l;if(2!==c&&a){if(a>=2)return o;for(var d=0;d=0;a--)o=Vn(o,n[a],!0);e.defaultOption=o}return e.defaultOption},e.prototype.getReferringComponents=function(t,e){var n=t+"Index",i=t+"Id";return Dl(this.ecModel,t,{index:this.get(n,!0),id:this.get(i,!0)},e)},e.prototype.getBoxLayoutParams=function(){return e=!1,{left:(t=this).getShallow("left",e),top:t.getShallow("top",e),right:t.getShallow("right",e),bottom:t.getShallow("bottom",e),width:t.getShallow("width",e),height:t.getShallow("height",e)};var t,e},e.prototype.getZLevelKey=function(){return""},e.prototype.setZLevel=function(t){this.option.zlevel=t},e.protoInitialize=((n=e.prototype).type="component",n.id="",n.name="",n.mainType="",n.subType="",void(n.componentIndex=0)),e}(hf);Ol(Dg,hf),Fl(Dg),function(t){var e={};t.registerSubTypeDefaulter=function(t,n){var i=zl(t);e[i.main]=n},t.determineSubType=function(n,i){var r=i.type;if(!r){var o=zl(n).main;t.hasSubTypes(n)&&e[o]&&(r=e[o](i))}return r}}(Dg),function(t,e){function n(t,e){return t[e]||(t[e]={predecessor:[],successor:[]}),t[e]}t.topologicalTravel=function(t,i,r,o){if(t.length){var a=function(t){var i={},r=[];return Xn(t,function(o){var a=n(i,o),s=function(t,e){var n=[];return Xn(t,function(t){Un(e,t)>=0&&n.push(t)}),n}(a.originalDeps=e(o),t);a.entryCount=s.length,0===a.entryCount&&r.push(o),Xn(s,function(t){Un(a.predecessor,t)<0&&a.predecessor.push(t);var e=n(i,t);Un(e.successor,t)<0&&e.successor.push(o)})}),{graph:i,noEntryList:r}}(i),s=a.graph,l=a.noEntryList,c={};for(Xn(t,function(t){c[t]=!0});l.length;){var d=l.pop(),u=s[d],h=!!c[d];h&&(r.call(o,d,u.originalDeps.slice()),delete c[d]),Xn(u.successor,h?f:p)}Xn(c,function(){throw new Error("")})}function p(t){s[t].entryCount--,0===s[t].entryCount&&l.push(t)}function f(t){c[t]=!0,p(t)}}}(Dg,function(t){var e=[];Xn(Dg.getClassesByMainType(t),function(t){e=e.concat(t.dependencies||t.prototype.dependencies||[])}),e=Yn(e,function(t){return zl(t).main}),"dataset"!==t&&Un(e,"dataset")<=0&&e.unshift("dataset");return e});var Pg={color:{},darkColor:{},size:{}},Lg=Pg.color={theme:["#5070dd","#b6d634","#505372","#ff994d","#0ca8df","#ffd10a","#fb628b","#785db0","#3fbe95"],neutral00:"#fff",neutral05:"#f4f7fd",neutral10:"#e8ebf0",neutral15:"#dbdee4",neutral20:"#cfd2d7",neutral25:"#c3c5cb",neutral30:"#b7b9be",neutral35:"#aaacb2",neutral40:"#9ea0a5",neutral45:"#929399",neutral50:"#86878c",neutral55:"#797b7f",neutral60:"#6d6e73",neutral65:"#616266",neutral70:"#54555a",neutral75:"#48494d",neutral80:"#3c3c41",neutral85:"#303034",neutral90:"#232328",neutral95:"#17171b",neutral99:"#000",accent05:"#eff1f9",accent10:"#e0e4f2",accent15:"#d0d6ec",accent20:"#c0c9e6",accent25:"#b1bbdf",accent30:"#a1aed9",accent35:"#91a0d3",accent40:"#8292cc",accent45:"#7285c6",accent50:"#6578ba",accent55:"#5c6da9",accent60:"#536298",accent65:"#4a5787",accent70:"#404c76",accent75:"#374165",accent80:"#2e3654",accent85:"#252b43",accent90:"#1b2032",accent95:"#121521",transparent:"rgba(0,0,0,0)",highlight:"rgba(255,231,130,0.8)"};for(var Eg in Wn(Lg,{primary:Lg.neutral80,secondary:Lg.neutral70,tertiary:Lg.neutral60,quaternary:Lg.neutral50,disabled:Lg.neutral20,border:Lg.neutral30,borderTint:Lg.neutral20,borderShade:Lg.neutral40,background:Lg.neutral05,backgroundTint:"rgba(234,237,245,0.5)",backgroundTransparent:"rgba(255,255,255,0)",backgroundShade:Lg.neutral10,shadow:"rgba(0,0,0,0.2)",shadowTint:"rgba(129,130,136,0.2)",axisLine:Lg.neutral70,axisLineTint:Lg.neutral40,axisTick:Lg.neutral70,axisTickMinor:Lg.neutral60,axisLabel:Lg.neutral70,axisSplitLine:Lg.neutral15,axisMinorSplitLine:Lg.neutral05}),Lg)if(Lg.hasOwnProperty(Eg)){var zg=Lg[Eg];"theme"===Eg?Pg.darkColor.theme=Lg.theme.slice():"highlight"===Eg?Pg.darkColor.highlight="rgba(255,231,130,0.4)":0===Eg.indexOf("accent")?Pg.darkColor[Eg]=Zo(zg,0,function(t){return.5*t},function(t){return Math.min(1,1.3-t)}):Pg.darkColor[Eg]=Zo(zg,0,function(t){return.9*t},function(t){return 1-Math.pow(t,1.5)})}Pg.size={xxs:2,xs:5,s:10,m:15,l:20,xl:30,xxl:40,xxxl:50};var Ng="";"undefined"!=typeof navigator&&(Ng=navigator.platform||"");var Og="rgba(0, 0, 0, 0.2)",$g=Pg.color.theme[0],Rg=Zo($g,0,null,.9),Hg={darkMode:"auto",colorBy:"series",color:Pg.color.theme,gradientColor:[Rg,$g],aria:{decal:{decals:[{color:Og,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:Og,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:Og,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:Og,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:Og,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:Og,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:Ng.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1},Fg=ki(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),Bg="original",Vg="arrayRows",Wg="objectRows",Gg="keyedColumns",Ug="typedArray",qg="unknown",jg="column",Xg="row",Yg=1,Zg=2,Kg=3,Qg=kl();function Jg(t,e,n){var i={},r=tv(e);if(!r||!t)return i;var o,a,s=[],l=[],c=e.ecModel,d=Qg(c).datasetMap,u=r.uid+"_"+n.seriesLayoutBy;Xn(t=t.slice(),function(e,n){var r=ai(e)?e:t[n]={name:e};"ordinal"===r.type&&null==o&&(o=n,a=f(r)),i[r.name]=[]});var h=d.get(u)||d.set(u,{categoryWayDim:a,valueWayDim:0});function p(t,e,n){for(var i=0;ie)return t[i];return t[n-1]}(i,a):n;if(d=d||n,!d||!d.length)return;var u=d[l];r&&(c[r]=u);return s.paletteIdx=(l+1)%d.length,u}(this,iv,i,r,t,e,n)},t.prototype.clearColorPalette=function(){!function(t,e){e(t).paletteIdx=0,e(t).paletteNameMap={}}(this,iv)},t}();var lv="\0_ec_inner",cv=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.init=function(t,e,n,i,r,o){i=i||{},this.option=null,this._theme=new hf(i),this._locale=new hf(r),this._optionManager=o},e.prototype.setOption=function(t,e,n){var i=hv(e);this._optionManager.setOption(t,n,i),this._resetOption(null,i)},e.prototype.resetOption=function(t,e){return this._resetOption(t,hv(e))},e.prototype._resetOption=function(t,e){var n=!1,i=this._optionManager;if(!t||"recreate"===t){var r=i.mountOption("recreate"===t);this.option&&"recreate"!==t?(this.restoreData(),this._mergeOption(r,e)):av(this,r),n=!0}if("timeline"!==t&&"media"!==t||this.restoreData(),!t||"recreate"===t||"timeline"===t){var o=i.getTimelineOption(this);o&&(n=!0,this._mergeOption(o,e))}if(!t||"recreate"===t||"media"===t){var a=i.getMediaOption(this);a.length&&Xn(a,function(t){n=!0,this._mergeOption(t,e)},this)}return n},e.prototype.mergeOption=function(t){this._mergeOption(t,null)},e.prototype._mergeOption=function(t,e){var n=this.option,i=this._componentsMap,r=this._componentsCount,o=[],a=ki(),s=e&&e.replaceMergeMainTypeMap;Qg(this).datasetMap=ki(),Xn(t,function(t,e){null!=t&&(Dg.hasClass(e)?e&&(o.push(e),a.set(e,!0)):n[e]=null==n[e]?Bn(t):Vn(n[e],t,!0))}),s&&s.each(function(t,e){Dg.hasClass(e)&&!a.get(e)&&(o.push(e),a.set(e,!0))}),Dg.topologicalTravel(o,Dg.getAllClassMainTypes(),function(e){var o=function(t,e,n){var i=nv.get(e);if(!i)return n;var r=i(t);return r?n.concat(r):n}(this,e,pl(t[e])),a=i.get(e),l=a?s&&s.get(e)?"replaceMerge":"normalMerge":"replaceAll",c=yl(a,o,l);(function(t,e,n){Xn(t,function(t){var i=t.newOption;ai(i)&&(t.keyInfo.mainType=e,t.keyInfo.subType=function(t,e,n,i){return e.type?e.type:n?n.subType:i.determineSubType(t,e)}(e,i,t.existing,n))})})(c,e,Dg),n[e]=null,i.set(e,null),r.set(e,0);var d,u=[],h=[],p=0;Xn(c,function(t,n){var i=t.existing,r=t.newOption;if(r){var o="series"===e,a=Dg.getClass(e,t.keyInfo.subType,!o);if(!a)return;if("tooltip"===e){if(d)return;d=!0}if(i&&i.constructor===a)i.name=t.keyInfo.name,i.mergeOption(r,this),i.optionUpdated(r,!1);else{var s=Wn({componentIndex:n},t.keyInfo);Wn(i=new a(r,this,this,s),s),t.brandNew&&(i.__requireNewView=!0),i.init(r,this,this),i.optionUpdated(null,!0)}}else i&&(i.mergeOption({},this),i.optionUpdated({},!1));i?(u.push(i.option),h.push(i),p++):(u.push(void 0),h.push(void 0))},this),n[e]=u,i.set(e,h),r.set(e,p),"series"===e&&rv(this)},this),this._seriesIndices||rv(this)},e.prototype.getOption=function(){var t=Bn(this.option);return Xn(t,function(e,n){if(Dg.hasClass(n)){for(var i=pl(e),r=i.length,o=!1,a=r-1;a>=0;a--)i[a]&&!Sl(i[a])?o=!0:(i[a]=null,!o&&r--);i.length=r,t[n]=i}}),delete t[lv],t},e.prototype.setTheme=function(t){this._theme=new hf(t),this._resetOption("recreate",null)},e.prototype.getTheme=function(){return this._theme},e.prototype.getLocaleModel=function(){return this._locale},e.prototype.setUpdatePayload=function(t){this._payload=t},e.prototype.getUpdatePayload=function(){return this._payload},e.prototype.getComponent=function(t,e){var n=this._componentsMap.get(t);if(n){var i=n[e||0];if(i)return i;if(null==e)for(var r=0;r=e:"max"===n?t<=e:t===e})(i[a],t,o)||(r=!1)}}),r}var yv=Xn,_v=ai,bv=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function xv(t){var e=t&&t.itemStyle;if(e)for(var n=0,i=bv.length;nc&&(c=p)}s[0]=l,s[1]=c}},i=function(){return this._data?this._data.length/this._dimSize:0};function r(t){for(var e=0;e=0&&(s=o.interpolatedValue[l])}return null!=s?s+"":""}):void 0},t.prototype.getRawValue=function(t,e){return dm(this.getData(e),t)},t.prototype.formatTooltip=function(t,e,n){},t}();function pm(t){var e,n;return ai(t)?t.type&&(n=t):e=t,{text:e,frag:n}}function fm(t){return new gm(t)}var gm=function(){function t(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return t.prototype.perform=function(t){var e,n=this._upstream,i=t&&t.skip;if(this._dirty&&n){var r=this.context;r.data=r.outputData=n.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this),this._plan&&!i&&(e=this._plan(this.context));var o,a=d(this._modBy),s=this._modDataCount||0,l=d(t&&t.modBy),c=t&&t.modDataCount||0;function d(t){return!(t>=1)&&(t=1),t}a===l&&s===c||(e="reset"),(this._dirty||"reset"===e)&&(this._dirty=!1,o=this._doReset(i)),this._modBy=l,this._modDataCount=c;var u=t&&t.step;if(this._dueEnd=n?n._outputDueEnd:this._count?this._count(this.context):1/0,this._progress){var h=this._dueIndex,p=Math.min(null!=u?this._dueIndex+u:1/0,this._dueEnd);if(!i&&(o||h1&&i>0?s:a}};return o;function a(){return e=t?null:oi?-this._resultLT:0},t}(),_m=function(){function t(){}return t.prototype.getRawData=function(){throw new Error("not supported")},t.prototype.getRawDataItem=function(t){throw new Error("not supported")},t.prototype.cloneRawData=function(){},t.prototype.getDimensionInfo=function(t){},t.prototype.cloneAllDimensionInfo=function(){},t.prototype.count=function(){},t.prototype.retrieveValue=function(t,e){},t.prototype.retrieveValueFromItem=function(t,e){},t.prototype.convertValue=function(t,e){return mm(t,e)},t}();function bm(t){if(!Tm(t.sourceFormat)){dl("")}return t.data}function xm(t){var e=t.sourceFormat,n=t.data;if(!Tm(e)){dl("")}if(e===Vg){for(var i=[],r=0,o=n.length;r65535?Am:Dm}function Nm(){return[1/0,-1/0]}function Om(t){var e=t.constructor;return e===Array?t.slice():new e(t)}function $m(t,e,n,i,r){var o=Em[n||"float"];if(r){var a=t[e],s=a&&a.length;if(s!==i){for(var l=new o(i),c=0;cg[1]&&(g[1]=f)}return this._rawCount=this._count=s,{start:a,end:s}},t.prototype._initDataFromProvider=function(t,e,n){for(var i=this._provider,r=this._chunks,o=this._dimensions,a=o.length,s=this._rawExtent,l=Yn(o,function(t){return t.property}),c=0;cv[1]&&(v[1]=g)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},t.prototype.count=function(){return this._count},t.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,n=e[t];if(null!=n&&nt))return o;r=o-1}}return-1},t.prototype.getIndices=function(){var t,e=this._indices;if(e){var n=e.constructor,i=this._count;if(n===Array){t=new n(i);for(var r=0;r=c&&_<=d||isNaN(_))&&(a[s++]=p),p++}h=!0}else if(2===r){f=u[i[0]];var v=u[i[1]],m=t[i[1]][0],y=t[i[1]][1];for(g=0;g=c&&_<=d||isNaN(_))&&(b>=m&&b<=y||isNaN(b))&&(a[s++]=p),p++}h=!0}}if(!h)if(1===r)for(g=0;g=c&&_<=d||isNaN(_))&&(a[s++]=x)}else for(g=0;gt[C][1])&&(w=!1)}w&&(a[s++]=e.getRawIndex(g))}return sv[1]&&(v[1]=g)}}}},t.prototype.lttbDownSample=function(t,e){var n,i,r,o=this.clone([t],!0),a=o._chunks[t],s=this.count(),l=0,c=Math.floor(1/e),d=this.getRawIndex(0),u=new(zm(this._rawCount))(Math.min(2*(Math.ceil(s/c)+2),s));u[l++]=d;for(var h=1;hn&&(n=i,r=k)}C>0&&Ca&&(f=a-c);for(var g=0;gp&&(p=v,h=c+g)}var m=this.getRawIndex(d),y=this.getRawIndex(h);dc-p&&(s=c-p,a.length=s);for(var f=0;fd[1]&&(d[1]=v),u[h++]=m}return r._count=h,r._indices=u,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();ra&&(a=l)}return i=[o,a],this._extent[t]=i,i},t.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var n=[],i=this._chunks,r=0;r=0?this._indices[t]:-1},t.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},t.internalField=function(){function t(t,e,n,i){return mm(t[i],this._dimensions[i])}Mm={arrayRows:t,objectRows:function(t,e,n,i){return mm(t[e],this._dimensions[i])},keyedColumns:t,original:function(t,e,n,i){var r=t&&(null==t.value?t:t.value);return mm(r instanceof Array?r[i]:r,this._dimensions[i])},typedArray:function(t,e,n,i){return t[i]}}}(),t}(),Hm=function(){function t(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return t.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},t.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},t.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},t.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},t.prototype._createSource=function(){this._setLocalSource([],[]);var t,e,n=this._sourceHost,i=this._getUpstreamSourceManagers(),r=!!i.length;if(Fm(n)){var o=n,a=void 0,s=void 0,l=void 0;if(r){var c=i[0];c.prepareSource(),a=(l=c.getSource()).data,s=l.sourceFormat,e=[c._getVersionSign()]}else s=li(a=o.get("data",!0))?Ug:Bg,e=[];var d=this._getSourceMetaRawOption()||{},u=l&&l.metaRawOption||{},h=pi(d.seriesLayoutBy,u.seriesLayoutBy)||null,p=pi(d.sourceHeader,u.sourceHeader),f=pi(d.dimensions,u.dimensions);t=h!==u.seriesLayoutBy||!!p!=!!u.sourceHeader||f?[qv(a,{seriesLayoutBy:h,sourceHeader:p,dimensions:f},s)]:[]}else{var g=n;if(r){var v=this._applyTransform(i);t=v.sourceList,e=v.upstreamSignList}else{t=[qv(g.get("source",!0),this._getSourceMetaRawOption(),null)],e=[]}}this._setLocalSource(t,e)},t.prototype._applyTransform=function(t){var e,n=this._sourceHost,i=n.get("transform",!0),r=n.get("fromTransformResult",!0);if(null!=r){1!==t.length&&Bm("")}var o,a=[],s=[];return Xn(t,function(t){t.prepareSource();var e=t.getSource(r||0);null==r||e||Bm(""),a.push(e),s.push(t._getVersionSign())}),i?e=function(t,e){var n=pl(t),i=n.length;i||dl("");for(var r=0,o=i;r1||n>0&&!t.noHeader;return Xn(t.blocks,function(t){var n=Ym(t);n>=e&&(e=n+ +(i&&(!n||jm(t)&&!t.noHeader)))}),e}return 0}function Zm(t,e,n,i){var r,o=e.noHeader,a=(r=Ym(e),{html:Gm[r],richText:Um[r]}),s=[],l=e.blocks||[];mi(!l||ei(l)),l=l||[];var c=t.orderMode;if(e.sortBlocks&&c){l=l.slice();var d={valueAsc:"asc",valueDesc:"desc"};if(Mi(d,c)){var u=new ym(d[c],null);l.sort(function(t,e){return u.evaluate(t.sortParam,e.sortParam)})}else"seriesDesc"===c&&l.reverse()}Xn(l,function(n,r){var o=e.valueFormatter,l=Xm(n)(o?Wn(Wn({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)});var h="richText"===t.renderMode?s.join(a.richText):Jm(i,s.join(""),o?n:a.html);if(o)return h;var p=rg(e.header,"ordinal",t.useUTC),f=Wm(i,t.renderMode).nameStyle,g=Vm(i);return"richText"===t.renderMode?ty(t,p,f)+a.richText+h:Jm(i,'
'+tr(p)+"
"+h,n)}function Km(t,e,n,i){var r=t.renderMode,o=e.noName,a=e.noValue,s=!e.markerType,l=e.name,c=t.useUTC,d=e.valueFormatter||t.valueFormatter||function(t){return Yn(t=ei(t)?t:[t],function(t,e){return rg(t,ei(p)?p[e]:p,c)})};if(!o||!a){var u=s?"":t.markupStyleCreator.makeTooltipMarker(e.markerType,e.markerColor||Pg.color.secondary,r),h=o?"":rg(l,"ordinal",c),p=e.valueType,f=a?[]:d(e.value,e.dataIndex),g=!s||!o,v=!s&&o,m=Wm(i,r),y=m.nameStyle,_=m.valueStyle;return"richText"===r?(s?"":u)+(o?"":ty(t,h,y))+(a?"":function(t,e,n,i,r){var o=[r],a=i?10:20;return n&&o.push({padding:[0,0,0,a],align:"right"}),t.markupStyleCreator.wrapRichTextStyle(ei(e)?e.join(" "):e,o)}(t,f,g,v,_)):Jm(i,(s?"":u)+(o?"":function(t,e,n){return''+tr(t)+""}(h,!s,y))+(a?"":function(t,e,n,i){var r=n?"10px":"20px",o=e?"float:right;margin-left:"+r:"";return t=ei(t)?t:[t],''+Yn(t,function(t){return tr(t)}).join("  ")+""}(f,g,v,_)),n)}}function Qm(t,e,n,i,r,o){if(t)return Xm(t)({useUTC:r,renderMode:n,orderMode:i,markupStyleCreator:e,valueFormatter:t.valueFormatter},t,0,o)}function Jm(t,e,n){return'
'+e+'
'}function ty(t,e,n){return t.markupStyleCreator.wrapRichTextStyle(e,n)}function ey(t,e){var n=t.get("padding");return null!=n?n:"richText"===e?[8,10]:10}var ny=function(){function t(){this.richTextStyles={},this._nextStyleNameId=ol()}return t.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},t.prototype.makeTooltipMarker=function(t,e,n){var i="richText"===n?this._generateStyleName():null,r=function(t,e){var n=ii(t)?{color:t,extraCssText:e}:t||{},i=n.color,r=n.type;e=n.extraCssText;var o=n.renderMode||"html";return i?"html"===o?"subItem"===r?'':'':{renderMode:o,content:"{"+(n.markerId||"markerX")+"|} ",style:"subItem"===r?{width:4,height:4,borderRadius:2,backgroundColor:i}:{width:10,height:10,borderRadius:5,backgroundColor:i}}:""}({color:e,type:t,renderMode:n,markerId:i});return ii(r)?r:(this.richTextStyles[i]=r.style,r.content)},t.prototype.wrapRichTextStyle=function(t,e){var n={};ei(e)?Xn(e,function(t){return Wn(n,t)}):Wn(n,e);var i=this._generateStyleName();return this.richTextStyles[i]=n,"{"+i+"|"+t+"}"},t}();function iy(t){var e,n,i,r,o=t.series,a=t.dataIndex,s=t.multipleSeries,l=o.getData(),c=l.mapDimensionsAll("defaultedTooltip"),d=c.length,u=o.getRawValue(a),h=ei(u),p=function(t,e){return lg(t.getData().getItemVisual(e,"style")[t.visualDrawType])}(o,a);if(d>1||h&&!d){var f=function(t,e,n,i,r){var o=e.getData(),a=Zn(t,function(t,e,n){var i=o.getDimensionInfo(n);return t||i&&!1!==i.tooltip&&null!=i.displayName},!1),s=[],l=[],c=[];function d(t,e){var n=o.getDimensionInfo(e);n&&!1!==n.otherDims.tooltip&&(a?c.push(qm("nameValue",{markerType:"subItem",markerColor:r,name:n.displayName,value:t,valueType:n.type})):(s.push(t),l.push(n.type)))}return i.length?Xn(i,function(t){d(dm(o,n,t),t)}):Xn(t,d),{inlineValues:s,inlineValueTypes:l,blocks:c}}(u,o,a,c,p);e=f.inlineValues,n=f.inlineValueTypes,i=f.blocks,r=f.inlineValues[0]}else if(d){var g=l.getDimensionInfo(c[0]);r=e=dm(l,a,c[0]),n=g.type}else r=e=h?u[0]:u;var v=wl(o),m=v&&o.name||"",y=l.getName(a),_=s?m:y;return qm("section",{header:m,noHeader:s||!v,sortParam:r,blocks:[qm("nameValue",{markerType:"item",markerColor:p,name:_,noName:!yi(_),value:e,valueType:n,dataIndex:a})].concat(i||[])})}var ry=kl();function oy(t,e){return t.getName(e)||t.getId(e)}var ay=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}var n;return _(e,t),e.prototype.init=function(t,e,n){this.seriesIndex=this.componentIndex,this.dataTask=fm({count:ly,reset:cy}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(t,n),(ry(this).sourceManager=new Hm(this)).prepareSource();var i=this.getInitialData(t,n);uy(i,this),this.dataTask.context.data=i,ry(this).dataBeforeProcessed=i,sy(this),this._initSelectedMapFromData(i)},e.prototype.mergeDefaultAndTheme=function(t,e){var n=Tg(this),i=n?Ig(t):{},r=this.subType;Dg.hasClass(r)&&(r+="Series"),Vn(t,e.getTheme().get(this.subType)),Vn(t,this.getDefaultOption()),fl(t,"label",["show"]),this.fillDataTextStyle(t.data),n&&Mg(t,i,n)},e.prototype.mergeOption=function(t,e){t=Vn(this.option,t,!0),this.fillDataTextStyle(t.data);var n=Tg(this);n&&Mg(this.option,t,n);var i=ry(this).sourceManager;i.dirty(),i.prepareSource();var r=this.getInitialData(t,e);uy(r,this),this.dataTask.dirty(),this.dataTask.context.data=r,ry(this).dataBeforeProcessed=r,sy(this),this._initSelectedMapFromData(r)},e.prototype.fillDataTextStyle=function(t){if(t&&!li(t))for(var e=["show"],n=0;n=0&&d<0)&&(c=o,d=r,u=0),r===d&&(l[u++]=e))}),l.length=u,l},e.prototype.formatTooltip=function(t,e,n){return iy({series:this,dataIndex:t,multipleSeries:e})},e.prototype.isAnimationEnabled=function(){var t=this.ecModel;if(bn.node&&(!t||!t.ssr))return!1;var e=this.getShallow("animation");return e&&this.getData().count()>this.getShallow("animationThreshold")&&(e=!1),!!e},e.prototype.restoreData=function(){this.dataTask.dirty()},e.prototype.getColorFromPalette=function(t,e,n){var i=this.ecModel,r=sv.prototype.getColorFromPalette.call(this,t,e,n);return r||(r=i.getColorFromPalette(t,e,n)),r},e.prototype.coordDimToDataDim=function(t){return this.getRawData().mapDimensionsAll(t)},e.prototype.getProgressive=function(){return this.get("progressive")},e.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},e.prototype.select=function(t,e){this._innerSelect(this.getData(e),t)},e.prototype.unselect=function(t,e){var n=this.option.selectedMap;if(n){var i=this.option.selectedMode,r=this.getData(e);if("series"===i||"all"===n)return this.option.selectedMap={},void(this._selectedDataIndicesMap={});for(var o=0;o=0&&n.push(r)}return n},e.prototype.isSelected=function(t,e){var n=this.option.selectedMap;if(!n)return!1;var i=this.getData(e);return("all"===n||n[oy(i,t)])&&!i.getItemModel(t).get(["select","disabled"])},e.prototype.isUniversalTransitionEnabled=function(){if(this.__universalTransitionEnabled)return!0;var t=this.option.universalTransition;return!!t&&(!0===t||t&&t.enabled)},e.prototype._innerSelect=function(t,e){var n,i,r=this.option,o=r.selectedMode,a=e.length;if(o&&a)if("series"===o)r.selectedMap="all";else if("multiple"===o){ai(r.selectedMap)||(r.selectedMap={});for(var s=r.selectedMap,l=0;l0&&this._innerSelect(t,e)}},e.registerClass=function(t){return Dg.registerClass(t)},e.protoInitialize=((n=e.prototype).type="series.__base__",n.seriesIndex=0,n.ignoreStyleOnData=!1,n.hasSymbolVisual=!1,n.defaultSymbol="circle",n.visualStyleAccessPath="itemStyle",void(n.visualDrawType="fill")),e}(Dg);function sy(t){var e=t.name;wl(t)||(t.name=function(t){var e=t.getRawData(),n=e.mapDimensionsAll("seriesName"),i=[];return Xn(n,function(t){var n=e.getDimensionInfo(t);n.displayName&&i.push(n.displayName)}),i.join(" ")}(t)||e)}function ly(t){return t.model.getRawData().count()}function cy(t){var e=t.model;return e.setData(e.getRawData().cloneShallow()),dy}function dy(t,e){e.outputData&&t.end>e.outputData.count()&&e.model.getRawData().cloneShallow(e.outputData)}function uy(t,e){Xn(function(t,e){for(var n=new t.constructor(t.length+e.length),i=0;i=0?u():d=setTimeout(u,-r),l=i};return h.clear=function(){d&&(clearTimeout(d),d=null)},h.debounceNextCall=function(t){s=t},h}function Iy(t,e,n,i){var r=t[e];if(r){var o=r[Cy]||r,a=r[Ty];if(r[ky]!==n||a!==i){if(null==n||!i)return t[e]=o;(r=t[e]=My(o,n,"debounce"===i))[Cy]=o,r[Ty]=i,r[ky]=n}return r}}function Ay(t,e){var n=t[e];n&&n[Cy]&&(n.clear&&n.clear(),t[e]=n[Cy])}var Dy=kl(),Py={itemStyle:Bl(cf,!0),lineStyle:Bl(af,!0)},Ly={lineStyle:"stroke",itemStyle:"fill"};function Ey(t,e){var n=t.visualStyleMapper||Py[e];return n||(console.warn("Unknown style type '"+e+"'."),Py.itemStyle)}function zy(t,e){var n=t.visualDrawType||Ly[e];return n||(console.warn("Unknown style type '"+e+"'."),"fill")}var Ny={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=t.getModel(i),o=Ey(t,i)(r),a=r.getShallow("decal");a&&(n.setVisual("decal",a),a.dirty=!0);var s=zy(t,i),l=o[s],c=ni(l)?l:null,d="auto"===o.fill||"auto"===o.stroke;if(!o[s]||c||d){var u=t.getColorFromPalette(t.name,null,e.getSeriesCount());o[s]||(o[s]=u,n.setVisual("colorFromPalette",!0)),o.fill="auto"===o.fill||ni(o.fill)?u:o.fill,o.stroke="auto"===o.stroke||ni(o.stroke)?u:o.stroke}if(n.setVisual("style",o),n.setVisual("drawType",s),!e.isSeriesFiltered(t)&&c)return n.setVisual("colorFromPalette",!1),{dataEach:function(e,n){var i=t.getDataParams(n),r=Wn({},o);r[s]=c(i),e.setItemVisual(n,"style",r)}}}},Oy=new hf,$y={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){if(!t.ignoreStyleOnData&&!e.isSeriesFiltered(t)){var n=t.getData(),i=t.visualStyleAccessPath||"itemStyle",r=Ey(t,i),o=n.getVisual("drawType");return{dataEach:n.hasItemOption?function(t,e){var n=t.getRawDataItem(e);if(n&&n[i]){Oy.option=n[i];var a=r(Oy);Wn(t.ensureUniqueItemVisual(e,"style"),a),Oy.option.decal&&(t.setItemVisual(e,"decal",Oy.option.decal),Oy.option.decal.dirty=!0),o in a&&t.setItemVisual(e,"colorFromPalette",!1)}}:null}}}},Ry={performRawSeries:!0,overallReset:function(t){var e=ki();t.eachSeries(function(t){var n=t.getColorBy();if(!t.isColorBySeries()){var i=t.type+"-"+n,r=e.get(i);r||(r={},e.set(i,r)),Dy(t).scope=r}}),t.eachSeries(function(e){if(!e.isColorBySeries()&&!t.isSeriesFiltered(e)){var n=e.getRawData(),i={},r=e.getData(),o=Dy(e).scope,a=e.visualStyleAccessPath||"itemStyle",s=zy(e,a);r.each(function(t){var e=r.getRawIndex(t);i[e]=t}),n.each(function(t){var a=i[t];if(r.getItemVisual(a,"colorFromPalette")){var l=r.ensureUniqueItemVisual(a,"style"),c=n.getName(t)||t+"",d=n.count();l[s]=e.getColorFromPalette(c,o,d)}})}})}},Hy=Math.PI;var Fy=function(){function t(t,e,n,i){this._stageTaskMap=ki(),this.ecInstance=t,this.api=e,n=this._dataProcessorHandlers=n.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=n.concat(i)}return t.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(t){var e=t.overallTask;e&&e.dirty()})},t.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var n=this._pipelineMap.get(t.__pipeline.id),i=n.context,r=!e&&n.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>n.blockIndex?n.step:null,o=i&&i.modDataCount;return{step:r,modBy:null!=o?Math.ceil(o/r):null,modDataCount:o}}},t.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},t.prototype.updateStreamModes=function(t,e){var n=this._pipelineMap.get(t.uid),i=t.getData().count(),r=n.progressiveEnabled&&e.incrementalPrepareRender&&i>=n.threshold,o=t.get("large")&&i>=t.get("largeThreshold"),a="mod"===t.get("progressiveChunkMode")?i:null;t.pipelineContext=n.context={progressiveRender:r,modDataCount:a,large:o}},t.prototype.restorePipelines=function(t){var e=this,n=e._pipelineMap=ki();t.eachSeries(function(t){var i=t.getProgressive(),r=t.uid;n.set(r,{id:r,head:null,tail:null,threshold:t.getProgressiveThreshold(),progressiveEnabled:i&&!(t.preventIncremental&&t.preventIncremental()),blockIndex:-1,step:Math.round(i||700),count:0}),e._pipe(t,t.dataTask)})},t.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),n=this.api;Xn(this._allHandlers,function(i){var r=t.get(i.uid)||t.set(i.uid,{});mi(!(i.reset&&i.overallReset),""),i.reset&&this._createSeriesStageTask(i,r,e,n),i.overallReset&&this._createOverallStageTask(i,r,e,n)},this)},t.prototype.prepareView=function(t,e,n,i){var r=t.renderTask,o=r.context;o.model=e,o.ecModel=n,o.api=i,r.__block=!t.incrementalPrepareRender,this._pipe(e,r)},t.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},t.prototype.performVisualTasks=function(t,e,n){this._performStageTasks(this._visualHandlers,t,e,n)},t.prototype._performStageTasks=function(t,e,n,i){i=i||{};var r=!1,o=this;function a(t,e){return t.setDirty&&(!t.dirtyMap||t.dirtyMap.get(e.__pipeline.id))}Xn(t,function(t,s){if(!i.visualType||i.visualType===t.visualType){var l=o._stageTaskMap.get(t.uid),c=l.seriesTaskMap,d=l.overallTask;if(d){var u,h=d.agentStubMap;h.each(function(t){a(i,t)&&(t.dirty(),u=!0)}),u&&d.dirty(),o.updatePayload(d,n);var p=o.getPerformArgs(d,i.block);h.each(function(t){t.perform(p)}),d.perform(p)&&(r=!0)}else c&&c.each(function(s,l){a(i,s)&&s.dirty();var c=o.getPerformArgs(s,i.block);c.skip=!t.performRawSeries&&e.isSeriesFiltered(s.context.model),o.updatePayload(s,n),s.perform(c)&&(r=!0)})}}),this.unfinished=r||this.unfinished},t.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(t){e=t.dataTask.perform()||e}),this.unfinished=e||this.unfinished},t.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},t.prototype.updatePayload=function(t,e){"remain"!==e&&(t.context.payload=e)},t.prototype._createSeriesStageTask=function(t,e,n,i){var r=this,o=e.seriesTaskMap,a=e.seriesTaskMap=ki(),s=t.seriesType,l=t.getTargetSeries;function c(e){var s=e.uid,l=a.set(s,o&&o.get(s)||fm({plan:Uy,reset:qy,count:Yy}));l.context={model:e,ecModel:n,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:r},r._pipe(e,l)}t.createOnAllSeries?n.eachRawSeries(c):s?n.eachRawSeriesByType(s,c):l&&l(n,i).each(c)},t.prototype._createOverallStageTask=function(t,e,n,i){var r=this,o=e.overallTask=e.overallTask||fm({reset:By});o.context={ecModel:n,api:i,overallReset:t.overallReset,scheduler:r};var a=o.agentStubMap,s=o.agentStubMap=ki(),l=t.seriesType,c=t.getTargetSeries,d=!0,u=!1;function h(t){var e=t.uid,n=s.set(e,a&&a.get(e)||(u=!0,fm({reset:Vy,onDirty:Gy})));n.context={model:t,overallProgress:d},n.agent=o,n.__block=d,r._pipe(t,n)}mi(!t.createOnAllSeries,""),l?n.eachRawSeriesByType(l,h):c?c(n,i).each(h):(d=!1,Xn(n.getSeries(),h)),u&&o.dirty()},t.prototype._pipe=function(t,e){var n=t.uid,i=this._pipelineMap.get(n);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},t.wrapStageHandler=function(t,e){return ni(t)&&(t={overallReset:t,seriesType:Zy(t)}),t.uid=ff("stageHandler"),e&&(t.visualType=e),t},t}();function By(t){t.overallReset(t.ecModel,t.api,t.payload)}function Vy(t){return t.overallProgress&&Wy}function Wy(){this.agent.dirty(),this.getDownstream().dirty()}function Gy(){this.agent&&this.agent.dirty()}function Uy(t){return t.plan?t.plan(t.model,t.ecModel,t.api,t.payload):null}function qy(t){t.useClearVisual&&t.data.clearAllVisual();var e=t.resetDefines=pl(t.reset(t.model,t.ecModel,t.api,t.payload));return e.length>1?Yn(e,function(t,e){return Xy(e)}):jy}var jy=Xy(0);function Xy(t){return function(e,n){var i=n.data,r=n.resetDefines[t];if(r&&r.dataEach)for(var o=e.start;o0&&d===r.length-c.length){var u=r.slice(0,d);"data"!==u&&(e.mainType=u,e[c.toLowerCase()]=t,s=!0)}}a.hasOwnProperty(r)&&(n[r]=t,s=!0),s||(i[r]=t)})}return{cptQuery:e,dataQuery:n,otherQuery:i}},t.prototype.filter=function(t,e){var n=this.eventInfo;if(!n)return!0;var i=n.targetEl,r=n.packedEvent,o=n.model,a=n.view;if(!o||!a)return!0;var s=e.cptQuery,l=e.dataQuery;return c(s,o,"mainType")&&c(s,o,"subType")&&c(s,o,"index","componentIndex")&&c(s,o,"name")&&c(s,o,"id")&&c(l,r,"name")&&c(l,r,"dataIndex")&&c(l,r,"dataType")&&(!a.filterForExposedEvent||a.filterForExposedEvent(t,e.otherQuery,i,r));function c(t,e,n,i){return null==t[n]||e[i||n]===t[n]}},t.prototype.afterTrigger=function(){this.eventInfo=null},t}(),l_=["symbol","symbolSize","symbolRotate","symbolOffset"],c_=l_.concat(["symbolKeepAspect"]),d_={createOnAllSeries:!0,performRawSeries:!0,reset:function(t,e){var n=t.getData();if(t.legendIcon&&n.setVisual("legendIcon",t.legendIcon),t.hasSymbolVisual){for(var i={},r={},o=!1,a=0;a=0&&M_(l)?l:.5,t.createRadialGradient(a,s,0,a,s,l)}(t,e,n):function(t,e,n){var i=null==e.x?0:e.x,r=null==e.x2?1:e.x2,o=null==e.y?0:e.y,a=null==e.y2?0:e.y2;return e.global||(i=i*n.width+n.x,r=r*n.width+n.x,o=o*n.height+n.y,a=a*n.height+n.y),i=M_(i)?i:0,r=M_(r)?r:1,o=M_(o)?o:0,a=M_(a)?a:0,t.createLinearGradient(i,o,r,a)}(t,e,n),r=e.colorStops,o=0;o0&&(e=i.lineDash,n=i.lineWidth,e&&"solid"!==e&&n>0?"dashed"===e?[4*n,2*n]:"dotted"===e?[n]:oi(e)?[e]:ei(e)?e:null:null),o=i.lineDashOffset;if(r){var a=i.strokeNoScale&&t.getLineScale?t.getLineScale():1;a&&1!==a&&(r=Yn(r,function(t){return t/a}),o/=a)}return[r,o]}var L_=new Qc(!0);function E_(t){var e=t.stroke;return!(null==e||"none"===e||!(t.lineWidth>0))}function z_(t){return"string"==typeof t&&"none"!==t}function N_(t){var e=t.fill;return null!=e&&"none"!==e}function O_(t,e){if(null!=e.fillOpacity&&1!==e.fillOpacity){var n=t.globalAlpha;t.globalAlpha=e.fillOpacity*e.opacity,t.fill(),t.globalAlpha=n}else t.fill()}function $_(t,e){if(null!=e.strokeOpacity&&1!==e.strokeOpacity){var n=t.globalAlpha;t.globalAlpha=e.strokeOpacity*e.opacity,t.stroke(),t.globalAlpha=n}else t.stroke()}function R_(t,e,n){var i=ql(e.image,e.__image,n);if(Xl(i)){var r=t.createPattern(i,e.repeat||"repeat");if("function"==typeof DOMMatrix&&r&&r.setTransform){var o=new DOMMatrix;o.translateSelf(e.x||0,e.y||0),o.rotateSelf(0,0,(e.rotation||0)*Ai),o.scaleSelf(e.scaleX||1,e.scaleY||1),r.setTransform(o)}return r}}var H_=["shadowBlur","shadowOffsetX","shadowOffsetY"],F_=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function B_(t,e,n,i,r){var o=!1;if(!i&&e===(n=n||{}))return!1;if(i||e.opacity!==n.opacity){G_(t,r),o=!0;var a=Math.max(Math.min(e.opacity,1),0);t.globalAlpha=isNaN(a)?fc.opacity:a}(i||e.blend!==n.blend)&&(o||(G_(t,r),o=!0),t.globalCompositeOperation=e.blend||fc.blend);for(var s=0;s0&&t.unfinished);t.unfinished||this._zr.flush()}}},e.prototype.getDom=function(){return this._dom},e.prototype.getId=function(){return this.id},e.prototype.getZr=function(){return this._zr},e.prototype.isSSR=function(){return this._ssr},e.prototype.setOption=function(t,e,n){if(!this[ob])if(this._disposed)this.id;else{var i,r,o;if(ai(e)&&(n=e.lazyUpdate,i=e.silent,r=e.replaceMerge,o=e.transition,e=e.notMerge),this[ob]=!0,Lb(this),!this._model||e){var a=new vv(this._api),s=this._theme,l=this._model=new cv;l.scheduler=this._scheduler,l.ssr=this._ssr,l.init(null,null,null,s,this._locale,a)}this._model.setOption(t,{replaceMerge:r},Vb);var c={seriesTransition:o,optionChanged:!0};if(n)this[sb]={silent:i,updateParams:c},this[ob]=!1,this.getZr().wakeUp();else{try{fb(this),mb.update.call(this,null,c)}catch(t){throw this[sb]=null,this[ob]=!1,t}this._ssr||this._zr.flush(),this[sb]=null,this[ob]=!1,xb.call(this,i),wb.call(this,i)}}},e.prototype.setTheme=function(t,e){if(!this[ob])if(this._disposed)this.id;else{var n=this._model;if(n){var i=e&&e.silent,r=null;this[sb]&&(null==i&&(i=this[sb].silent),r=this[sb].updateParams,this[sb]=null),this[ob]=!0,Lb(this);try{this._updateTheme(t),n.setTheme(this._theme),fb(this),mb.update.call(this,{type:"setTheme"},r)}catch(t){throw this[ob]=!1,t}this[ob]=!1,xb.call(this,i),wb.call(this,i)}}},e.prototype._updateTheme=function(t){ii(t)&&(t=Gb[t]),t&&((t=Bn(t))&&$v(t,!0),this._theme=t)},e.prototype.getModel=function(){return this._model},e.prototype.getOption=function(){return this._model&&this._model.getOption()},e.prototype.getWidth=function(){return this._zr.getWidth()},e.prototype.getHeight=function(){return this._zr.getHeight()},e.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||bn.hasGlobalWindow&&window.devicePixelRatio||1},e.prototype.getRenderedCanvas=function(t){return this.renderToCanvas(t)},e.prototype.renderToCanvas=function(t){return t=t||{},this._zr.painter.getRenderedCanvas({backgroundColor:t.backgroundColor||this._model.get("backgroundColor"),pixelRatio:t.pixelRatio||this.getDevicePixelRatio()})},e.prototype.renderToSVGString=function(t){return t=t||{},this._zr.painter.renderToString({useViewBox:t.useViewBox})},e.prototype.getSvgDataURL=function(){var t=this._zr;return Xn(t.storage.getDisplayList(),function(t){t.stopAnimation(null,!0)}),t.painter.toDataURL()},e.prototype.getDataURL=function(t){if(!this._disposed){var e=(t=t||{}).excludeComponents,n=this._model,i=[],r=this;Xn(e,function(t){n.eachComponent({mainType:t},function(t){var e=r._componentsMap[t.__viewId];e.group.ignore||(i.push(e),e.group.ignore=!0)})});var o="svg"===this._zr.painter.getType()?this.getSvgDataURL():this.renderToCanvas(t).toDataURL("image/"+(t&&t.type||"png"));return Xn(i,function(t){t.group.ignore=!1}),o}this.id},e.prototype.getConnectedDataURL=function(t){if(!this._disposed){var e="svg"===t.type,n=this.group,i=Math.min,r=Math.max,o=1/0;if(jb[n]){var a=o,s=o,l=-1/0,c=-1/0,d=[],u=t&&t.pixelRatio||this.getDevicePixelRatio();Xn(qb,function(o,u){if(o.group===n){var h=e?o.getZr().painter.getSvgDom().innerHTML:o.renderToCanvas(Bn(t)),p=o.getDom().getBoundingClientRect();a=i(p.left,a),s=i(p.top,s),l=r(p.right,l),c=r(p.bottom,c),d.push({dom:h,left:p.left,top:p.top})}});var h=(l*=u)-(a*=u),p=(c*=u)-(s*=u),f=Tn.createCanvas(),g=Bs(f,{renderer:e?"svg":"canvas"});if(g.resize({width:h,height:p}),e){var v="";return Xn(d,function(t){var e=t.left-a,n=t.top-s;v+=''+t.dom+""}),g.painter.getSvgRoot().innerHTML=v,t.connectedBackgroundColor&&g.painter.setBackgroundColor(t.connectedBackgroundColor),g.refreshImmediately(),g.painter.toDataURL()}return t.connectedBackgroundColor&&g.add(new Pd({shape:{x:0,y:0,width:h,height:p},style:{fill:t.connectedBackgroundColor}})),Xn(d,function(t){var e=new Cd({style:{x:t.left*u-a,y:t.top*u-s,image:t.dom}});g.add(e)}),g.refreshImmediately(),f.toDataURL("image/"+(t&&t.type||"png"))}return this.getDataURL(t)}this.id},e.prototype.convertToPixel=function(t,e,n){return yb(this,"convertToPixel",t,e,n)},e.prototype.convertToLayout=function(t,e,n){return yb(this,"convertToLayout",t,e,n)},e.prototype.convertFromPixel=function(t,e,n){return yb(this,"convertFromPixel",t,e,n)},e.prototype.containPixel=function(t,e){var n;if(!this._disposed)return Xn(Ml(this._model,t),function(t,i){i.indexOf("Models")>=0&&Xn(t,function(t){var r=t.coordinateSystem;if(r&&r.containPoint)n=n||!!r.containPoint(e);else if("seriesModels"===i){var o=this._chartsMap[t.__viewId];o&&o.containPoint&&(n=n||o.containPoint(e,t))}},this)},this),!!n;this.id},e.prototype.getVisual=function(t,e){var n=Ml(this._model,t,{defaultMainType:"series"}),i=n.seriesModel.getData(),r=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?i.indexOfRawIndex(n.dataIndex):null;return null!=r?function(t,e,n){switch(n){case"color":return t.getItemVisual(e,"style")[t.getVisual("drawType")];case"opacity":return t.getItemVisual(e,"style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getItemVisual(e,n)}}(i,r,e):function(t,e){switch(e){case"color":return t.getVisual("style")[t.getVisual("drawType")];case"opacity":return t.getVisual("style").opacity;case"symbol":case"symbolSize":case"liftZ":return t.getVisual(e)}}(i,e)},e.prototype.getViewOfComponentModel=function(t){return this._componentsMap[t.__viewId]},e.prototype.getViewOfSeriesModel=function(t){return this._chartsMap[t.__viewId]},e.prototype._initEvents=function(){var t=this;Xn($b,function(e){var n=function(n){var i,r=t.getModel(),o=n.target;if("globalout"===e?i={}:o&&p_(o,function(t){var e=Xd(t);if(e&&null!=e.dataIndex){var n=e.dataModel||r.getSeriesByIndex(e.seriesIndex);return i=n&&n.getDataParams(e.dataIndex,e.dataType,o)||{},!0}if(e.eventData)return i=Wn({},e.eventData),!0},!0),i){var a=i.componentType,s=i.componentIndex;"markLine"!==a&&"markPoint"!==a&&"markArea"!==a||(a="series",s=i.seriesIndex);var l=a&&null!=s&&r.getComponent(a,s),c=l&&t["series"===l.mainType?"_chartsMap":"_componentsMap"][l.__viewId];i.event=n,i.type=e,t._$eventProcessor.eventInfo={targetEl:o,packedEvent:i,model:l,view:c},t.trigger(e,i)}};n.zrEventfulCallAtLast=!0,t._zr.on(e,n,t)});var e=this._messageCenter;Xn(Fb,function(n,i){e.on(i,function(e){t.trigger(i,e)})}),function(t,e,n){t.on("selectchanged",function(t){var i=n.getModel();t.isFromClick?(h_("map","selectchanged",e,i,t),h_("pie","selectchanged",e,i,t)):"select"===t.fromAction?(h_("map","selected",e,i,t),h_("pie","selected",e,i,t)):"unselect"===t.fromAction&&(h_("map","unselected",e,i,t),h_("pie","unselected",e,i,t))})}(e,this,this._api)},e.prototype.isDisposed=function(){return this._disposed},e.prototype.clear=function(){this._disposed?this.id:this.setOption({series:[]},!0)},e.prototype.dispose=function(){if(this._disposed)this.id;else{this._disposed=!0,this.getDom()&&Pl(this.getDom(),Yb,"");var t=this,e=t._api,n=t._model;Xn(t._componentsViews,function(t){t.dispose(n,e)}),Xn(t._chartsViews,function(t){t.dispose(n,e)}),t._zr.dispose(),t._dom=t._model=t._chartsMap=t._componentsMap=t._chartsViews=t._componentsViews=t._scheduler=t._api=t._zr=t._throttledZrFlush=t._theme=t._coordSysMgr=t._messageCenter=null,delete qb[t.id]}},e.prototype.resize=function(t){if(!this[ob])if(this._disposed)this.id;else{this._zr.resize(t);var e=this._model;if(this._loadingFX&&this._loadingFX.resize(),e){var n=e.resetOption("media"),i=t&&t.silent;this[sb]&&(null==i&&(i=this[sb].silent),n=!0,this[sb]=null),this[ob]=!0,Lb(this);try{n&&fb(this),mb.update.call(this,{type:"resize",animation:Wn({duration:0},t&&t.animation)})}catch(t){throw this[ob]=!1,t}this[ob]=!1,xb.call(this,i),wb.call(this,i)}}},e.prototype.showLoading=function(t,e){if(this._disposed)this.id;else if(ai(t)&&(e=t,t=""),t=t||"default",this.hideLoading(),Ub[t]){var n=Ub[t](this._api,e),i=this._zr;this._loadingFX=n,i.add(n)}},e.prototype.hideLoading=function(){this._disposed?this.id:(this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null)},e.prototype.makeActionFromEvent=function(t){var e=Wn({},t);return e.type=Hb[t.type],e},e.prototype.dispatchAction=function(t,e){if(this._disposed)this.id;else if(ai(e)||(e={silent:!!e}),Rb[t.type]&&this._model)if(this[ob])this._pendingActions.push(t);else{var n=e.silent;bb.call(this,t,n);var i=e.flush;i?this._zr.flush():!1!==i&&bn.browser.weChat&&this._throttledZrFlush(),xb.call(this,n),wb.call(this,n)}},e.prototype.updateLabelLayout=function(){tb.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},e.prototype.appendData=function(t){if(this._disposed)this.id;else{var e=t.seriesIndex;this.getModel().getSeriesByIndex(e).appendData(t),this._scheduler.unfinished=!0,this.getZr().wakeUp()}},e.internalField=function(){function t(t){t.clearColorPalette(),t.eachSeries(function(t){t.clearColorPalette()})}function e(t){for(var e=[],n=t.currentStates,i=0;i0?{duration:o,delay:i.get("delay"),easing:i.get("easing")}:null;n.eachRendered(function(t){if(t.states&&t.states.emphasis){if(ap(t))return;if(t instanceof _d&&function(t){var e=Kd(t);e.normalFill=t.style.fill,e.normalStroke=t.style.stroke;var n=t.states.select||{};e.selectFill=n.style&&n.style.fill||null,e.selectStroke=n.style&&n.style.stroke||null}(t),t.__dirty){var n=t.prevStates;n&&t.useStates(n)}if(r){t.stateTransition=a;var i=t.getTextContent(),o=t.getTextGuideLine();i&&(i.stateTransition=a),o&&(o.stateTransition=a)}t.__dirty&&e(t)}})}fb=function(t){var e=t._scheduler;e.restorePipelines(t._model),e.prepareStageTasks(),gb(t,!0),gb(t,!1),e.plan()},gb=function(t,e){for(var n=t._model,i=t._scheduler,r=e?t._componentsViews:t._chartsViews,o=e?t._componentsMap:t._chartsMap,a=t._zr,s=t._api,l=0;le.get("hoverLayerThreshold")&&!bn.node&&!bn.worker&&e.eachSeries(function(e){if(!e.preventUsingHoverLayer){var n=t._chartsMap[e.__viewId];n.__alive&&n.eachRendered(function(t){t.states.emphasis&&(t.states.emphasis.hoverLayer=!0)})}})}(t,e),tb.trigger("series:afterupdate",e,o,s)},Db=function(t){t[lb]=!0,t.getZr().wakeUp()},Lb=function(t){t[ab]=(t[ab]+1)%1e3},Pb=function(t){t[lb]&&(t.getZr().storage.traverse(function(t){ap(t)||e(t)}),t[lb]=!1)},Ib=function(t){return new(function(e){function n(){return null!==e&&e.apply(this,arguments)||this}return _(n,e),n.prototype.getCoordinateSystems=function(){return t._coordSysMgr.getCoordinateSystems()},n.prototype.getComponentByElement=function(e){for(;e;){var n=e.__ecComponentInfo;if(null!=n)return t._model.getComponent(n.mainType,n.index);e=e.parent}},n.prototype.enterEmphasis=function(e,n){wu(e,n),Db(t)},n.prototype.leaveEmphasis=function(e,n){Su(e,n),Db(t)},n.prototype.enterBlur=function(e){!function(t){vu(t,uu)}(e),Db(t)},n.prototype.leaveBlur=function(e){Cu(e),Db(t)},n.prototype.enterSelect=function(e){ku(e),Db(t)},n.prototype.leaveSelect=function(e){Tu(e),Db(t)},n.prototype.getModel=function(){return t.getModel()},n.prototype.getViewOfComponentModel=function(e){return t.getViewOfComponentModel(e)},n.prototype.getViewOfSeriesModel=function(e){return t.getViewOfSeriesModel(e)},n.prototype.getMainProcessVersion=function(){return t[ab]},n}(fv))(t)},Ab=function(t){function e(t,e){for(var n=0;n=0)){ix.push(n);var o=Fy.wrapStageHandler(n,r);o.__prio=e,o.__raw=n,t.push(o)}}function ox(t,e){Ub[t]=e}var ax=function(t){var e=(t=Bn(t)).type;e||dl("");var n=e.split(":");2!==n.length&&dl("");var i=!1;"echarts"===n[0]&&(e=n[1],i=!0),t.__isBuiltIn=i,Cm.set(e,t)};function sx(t,e,n,i){return{eventContent:{selected:Eu(n),isFromClick:e.isFromClick||!1}}}function lx(t){return null==t?0:t.length||1}function cx(t){return t}nx(nb,Ny),nx(ib,$y),nx(ib,Ry),nx(nb,d_),nx(ib,u_),nx(7e3,function(t,e){t.eachRawSeries(function(n){if(!t.isSeriesFiltered(n)){var i=n.getData();i.hasItemVisual()&&i.each(function(t){var n=i.getItemVisual(t,"decal");n&&(i.ensureUniqueItemVisual(t,"style").decal=Z_(n,e))});var r=i.getVisual("decal");if(r)i.getVisual("style").decal=Z_(r,e)}})}),Qb($v),Jb(900,function(t){var e=ki();t.eachSeries(function(t){var n=t.get("stack");if(n){var i=e.get(n)||e.set(n,[]),r=t.getData(),o={stackResultDimension:r.getCalculationInfo("stackResultDimension"),stackedOverDimension:r.getCalculationInfo("stackedOverDimension"),stackedDimension:r.getCalculationInfo("stackedDimension"),stackedByDimension:r.getCalculationInfo("stackedByDimension"),isStackedByIndex:r.getCalculationInfo("isStackedByIndex"),data:r,seriesModel:t};if(!o.stackedDimension||!o.isStackedByIndex&&!o.stackedByDimension)return;i.push(o)}}),e.each(function(t){0!==t.length&&("seriesDesc"===(t[0].seriesModel.get("stackOrder")||"seriesAsc")&&t.reverse(),Xn(t,function(e,n){e.data.setCalculationInfo("stackedOnSeries",n>0?t[n-1].seriesModel:null)}),function(t){Xn(t,function(e,n){var i=[],r=[NaN,NaN],o=[e.stackResultDimension,e.stackedOverDimension],a=e.data,s=e.isStackedByIndex,l=e.seriesModel.get("stackStrategy")||"samesign";a.modify(o,function(o,c,d){var u,h,p=a.get(e.stackedDimension,d);if(isNaN(p))return r;s?h=a.getRawIndex(d):u=a.get(e.stackedByDimension,d);for(var f=NaN,g=n-1;g>=0;g--){var v=t[g];if(s||(h=v.data.rawIndexOf(v.stackedByDimension,u)),h>=0){var m=v.data.getByRawIndex(v.stackResultDimension,h);if("all"===l||"positive"===l&&m>0||"negative"===l&&m<0||"samesign"===l&&p>=0&&m>0||"samesign"===l&&p<=0&&m<0){p=Ks(p,m),f=m;break}}}return i[0]=p,i[1]=f,i})})}(t))})}),ox("default",function(t,e){Gn(e=e||{},{text:"loading",textColor:Pg.color.primary,fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255,255,255,0.8)",showSpinner:!0,color:Pg.color.theme[0],spinnerRadius:10,lineWidth:5,zlevel:0});var n=new Os,i=new Pd({style:{fill:e.maskColor},zlevel:e.zlevel,z:1e4});n.add(i);var r,o=new Nd({style:{text:e.text,fill:e.textColor,fontSize:e.fontSize,fontWeight:e.fontWeight,fontStyle:e.fontStyle,fontFamily:e.fontFamily},zlevel:e.zlevel,z:10001}),a=new Pd({style:{fill:"none"},textContent:o,textConfig:{position:"right",distance:10},zlevel:e.zlevel,z:10001});return n.add(a),e.showSpinner&&((r=new Fh({shape:{startAngle:-Hy/2,endAngle:-Hy/2+.1,r:e.spinnerRadius},style:{stroke:e.color,lineCap:"round",lineWidth:e.lineWidth},zlevel:e.zlevel,z:10001})).animateShape(!0).when(1e3,{endAngle:3*Hy/2}).start("circularInOut"),r.animateShape(!0).when(1e3,{startAngle:3*Hy/2}).delay(300).start("circularInOut"),n.add(r)),n.resize=function(){var n=o.getBoundingRect().width,s=e.showSpinner?e.spinnerRadius:0,l=(t.getWidth()-2*s-(e.showSpinner&&n?10:0)-n)/2-(e.showSpinner&&n?0:5+n/2)+(e.showSpinner?0:n/2)+(n?0:s),c=t.getHeight()/2;e.showSpinner&&r.setShape({cx:l,cy:c}),a.setShape({x:l-s,y:c-s,width:2*s,height:2*s}),i.setShape({x:0,y:0,width:t.getWidth(),height:t.getHeight()})},n.resize(),n}),ex({type:eu,event:eu,update:eu},Ii),ex({type:nu,event:nu,update:nu},Ii),ex({type:iu,event:au,update:iu,action:Ii,refineEvent:sx,publishNonRefinedEvent:!0}),ex({type:ru,event:au,update:ru,action:Ii,refineEvent:sx,publishNonRefinedEvent:!0}),ex({type:ou,event:au,update:ou,action:Ii,refineEvent:sx,publishNonRefinedEvent:!0}),Kb("default",{}),Kb("dark",a_);var dx=function(){function t(t,e,n,i,r,o){this._old=t,this._new=e,this._oldKeyGetter=n||cx,this._newKeyGetter=i||cx,this.context=r,this._diffModeMultiple="multiple"===o}return t.prototype.add=function(t){return this._add=t,this},t.prototype.update=function(t){return this._update=t,this},t.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},t.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},t.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},t.prototype.remove=function(t){return this._remove=t,this},t.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},t.prototype._executeOneToOne=function(){var t=this._old,e=this._new,n={},i=new Array(t.length),r=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,n,r,"_newKeyGetter");for(var o=0;o1){var c=s.shift();1===s.length&&(n[a]=s[0]),this._update&&this._update(c,o)}else 1===l?(n[a]=null,this._update&&this._update(s,o)):this._remove&&this._remove(o)}this._performRestAdd(r,n)},t.prototype._executeMultiple=function(){var t=this._old,e=this._new,n={},i={},r=[],o=[];this._initIndexMap(t,n,r,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var a=0;a1&&1===u)this._updateManyToOne&&this._updateManyToOne(c,l),i[s]=null;else if(1===d&&u>1)this._updateOneToMany&&this._updateOneToMany(c,l),i[s]=null;else if(1===d&&1===u)this._update&&this._update(c,l),i[s]=null;else if(d>1&&u>1)this._updateManyToMany&&this._updateManyToMany(c,l),i[s]=null;else if(d>1)for(var h=0;h1)for(var a=0;a30}var xx,Sx,Cx,kx,Tx,Mx,Ix,Ax=ai,Dx=Yn,Px="undefined"==typeof Int32Array?Array:Int32Array,Lx=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],Ex=["_approximateExtent"],zx=function(){function t(t,e){var n;this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var i=!1;mx(t)?(n=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,n=t),n=n||["x","y"];for(var r={},o=[],a={},s=!1,l={},c=0;c=e)){var n=this._store.getProvider();this._updateOrdinalMeta();var i=this._nameList,r=this._idList;if(n.getSource().sourceFormat===Bg&&!n.pure)for(var o=[],a=t;a0},t.prototype.ensureUniqueItemVisual=function(t,e){var n=this._itemVisuals,i=n[t];i||(i=n[t]={});var r=i[e];return null==r&&(ei(r=this.getVisual(e))?r=r.slice():Ax(r)&&(r=Wn({},r)),i[e]=r),r},t.prototype.setItemVisual=function(t,e,n){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,Ax(e)?Wn(i,e):i[e]=n},t.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},t.prototype.setLayout=function(t,e){Ax(t)?Wn(this._layout,t):this._layout[t]=e},t.prototype.getLayout=function(t){return this._layout[t]},t.prototype.getItemLayout=function(t){return this._itemLayouts[t]},t.prototype.setItemLayout=function(t,e,n){this._itemLayouts[t]=n?Wn(this._itemLayouts[t]||{},e):e},t.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},t.prototype.setItemGraphicEl=function(t,e){!function(t,e,n,i){if(i){var r=Xd(i);r.dataIndex=n,r.dataType=e,r.seriesIndex=t,r.ssrType="chart","group"===i.type&&i.traverse(function(i){var r=Xd(i);r.seriesIndex=t,r.dataIndex=n,r.dataType=e,r.ssrType="chart"})}}(this.hostModel&&this.hostModel.seriesIndex,this.dataType,t,e),this._graphicEls[t]=e},t.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},t.prototype.eachItemGraphicEl=function(t,e){Xn(this._graphicEls,function(n,i){n&&t&&t.call(e,n,i)})},t.prototype.cloneShallow=function(e){return e||(e=new t(this._schema?this._schema:Dx(this.dimensions,this._getDimInfo,this),this.hostModel)),Tx(e,this),e._store=this._store,e},t.prototype.wrapMethod=function(t,e){var n=this[t];ni(n)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var t=n.apply(this,arguments);return e.apply(this,[t].concat(gi(arguments)))})},t.internalField=(xx=function(t){var e=t._invertedIndicesMap;Xn(e,function(n,i){var r=t._dimInfos[i],o=r.ordinalMeta,a=t._store;if(o){n=e[i]=new Px(o.categories.length);for(var s=0;s1&&(s+="__ec__"+c),i[e]=s}})),t}();function Nx(t,e){Uv(t)||(t=jv(t));var n=(e=e||{}).coordDimensions||[],i=e.dimensionsDefine||t.dimensionsDefine||[],r=ki(),o=[],a=function(t,e,n,i){var r=Math.max(t.dimensionsDetectedCount||1,e.length,n.length,i||0);return Xn(e,function(t){var e;ai(t)&&(e=t.dimsDef)&&(r=Math.max(r,e.length))}),r}(t,n,i,e.dimensionsCount),s=e.canOmitUnusedDimensions&&bx(a),l=i===t.dimensionsDefine,c=l?_x(t):yx(i),d=e.encodeDefine;!d&&e.encodeDefaulter&&(d=e.encodeDefaulter(t,a));for(var u=ki(d),h=new Pm(a),p=0;p0&&(i.name=r+(o-1)),o++,e.set(r,o)}}(o),new vx({source:t,dimensions:o,fullDimensionCount:a,dimensionOmitted:s})}function Ox(t,e,n){if(n||e.hasKey(t)){for(var i=0;e.hasKey(t+i);)i++;t+=i}return e.set(t,!0),t}var $x=function(t){this.coordSysDims=[],this.axisMap=ki(),this.categoryAxisMap=ki(),this.coordSysName=t};var Rx={cartesian2d:function(t,e,n,i){var r=t.getReferringComponents("xAxis",Al).models[0],o=t.getReferringComponents("yAxis",Al).models[0];e.coordSysDims=["x","y"],n.set("x",r),n.set("y",o),Hx(r)&&(i.set("x",r),e.firstCategoryDimIndex=0),Hx(o)&&(i.set("y",o),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},singleAxis:function(t,e,n,i){var r=t.getReferringComponents("singleAxis",Al).models[0];e.coordSysDims=["single"],n.set("single",r),Hx(r)&&(i.set("single",r),e.firstCategoryDimIndex=0)},polar:function(t,e,n,i){var r=t.getReferringComponents("polar",Al).models[0],o=r.findAxisModel("radiusAxis"),a=r.findAxisModel("angleAxis");e.coordSysDims=["radius","angle"],n.set("radius",o),n.set("angle",a),Hx(o)&&(i.set("radius",o),e.firstCategoryDimIndex=0),Hx(a)&&(i.set("angle",a),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=1))},geo:function(t,e,n,i){e.coordSysDims=["lng","lat"]},parallel:function(t,e,n,i){var r=t.ecModel,o=r.getComponent("parallel",t.get("parallelIndex")),a=e.coordSysDims=o.dimensions.slice();Xn(o.parallelAxisIndex,function(t,o){var s=r.getComponent("parallelAxis",t),l=a[o];n.set(l,s),Hx(s)&&(i.set(l,s),null==e.firstCategoryDimIndex&&(e.firstCategoryDimIndex=o))})},matrix:function(t,e,n,i){var r=t.getReferringComponents("matrix",Al).models[0];e.coordSysDims=["x","y"];var o=r.getDimensionModel("x"),a=r.getDimensionModel("y");n.set("x",o),n.set("y",a),i.set("x",o),i.set("y",a)}};function Hx(t){return"category"===t.get("type")}function Fx(t,e,n){var i,r,o,a=(n=n||{}).byIndex,s=n.stackedCoordDimension;!function(t){return!mx(t.schema)}(e)?(r=e.schema,i=r.dimensions,o=e.store):i=e;var l,c,d,u,h=!(!t||!t.get("stack"));if(Xn(i,function(t,e){ii(t)&&(i[e]=t={name:t}),h&&!t.isExtraCoord&&(a||l||!t.ordinalMeta||(l=t),c||"ordinal"===t.type||"time"===t.type||s&&s!==t.coordDim||(c=t))}),!c||a||l||(a=!0),c){d="__\0ecstackresult_"+t.id,u="__\0ecstackedover_"+t.id,l&&(l.createInvertedIndices=!0);var p=c.coordDim,f=c.type,g=0;Xn(i,function(t){t.coordDim===p&&g++});var v={name:d,coordDim:p,coordDimIndex:g,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length},m={name:u,coordDim:u,coordDimIndex:g+1,type:f,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:i.length+1};r?(o&&(v.storeDimIndex=o.ensureCalculationDimension(u,f),m.storeDimIndex=o.ensureCalculationDimension(d,f)),r.appendCalculationDimension(v),r.appendCalculationDimension(m)):(i.push(v),i.push(m))}return{stackedDimension:c&&c.name,stackedByDimension:l&&l.name,isStackedByIndex:a,stackedOverDimension:u,stackResultDimension:d}}function Bx(t,e){return!!e&&e===t.getCalculationInfo("stackedDimension")}function Vx(t,e,n){n=n||{};var i,r,o=e.getSourceManager();r=(i=o.getSource()).sourceFormat===Bg;var a=function(t){var e=t.get("coordinateSystem"),n=new $x(e),i=Rx[e];if(i)return i(t,n,n.axisMap,n.categoryAxisMap),n}(e),s=function(t,e){var n,i=t.get("coordinateSystem"),r=ug.get(i);return e&&e.coordSysDims&&(n=Yn(e.coordSysDims,function(t){var n={name:t},i=e.axisMap.get(t);if(i){var r=i.get("type");n.type=function(t){return"category"===t?"ordinal":"time"===t?"time":"float"}(r)}return n})),n||(n=r&&(r.getDimensionsInfo?r.getDimensionsInfo():r.dimensions.slice())||["x","y"]),n}(e,a),l=n.useEncodeDefaulter,c=ni(l)?l:l?ti(Jg,s,e):null,d=Nx(i,{coordDimensions:s,generateCoord:n.generateCoord,encodeDefine:e.getEncode(),encodeDefaulter:c,canOmitUnusedDimensions:!r}),u=function(t,e,n){var i,r;return n&&Xn(t,function(t,o){var a=t.coordDim,s=n.categoryAxisMap.get(a);s&&(null==i&&(i=o),t.ordinalMeta=s.getOrdinalMeta(),e&&(t.createInvertedIndices=!0)),null!=t.otherDims.itemName&&(r=!0)}),r||null==i||(t[i].otherDims.itemName=0),i}(d.dimensions,n.createInvertedIndices,a),h=r?null:o.getSharedDataStore(d),p=Fx(e,{schema:d,store:h}),f=new zx(d,e);f.setCalculationInfo(p);var g=null!=u&&function(t){if(t.sourceFormat===Bg){var e=function(t){var e=0;for(;er&&(a=o.interval=r);var s=o.intervalPrecision=qx(a);return function(t,e){!isFinite(t[0])&&(t[0]=e[0]),!isFinite(t[1])&&(t[1]=e[1]),jx(t,0,e),jx(t,1,e),t[0]>t[1]&&(t[0]=t[1])}(o.niceTickExtent=[Ys(Math.ceil(t[0]/a)*a,s),Ys(Math.floor(t[1]/a)*a,s)],t),o}function Ux(t){var e=Math.pow(10,nl(t)),n=t/e;return n?2===n?n=3:3===n?n=5:n*=2:n=1,Ys(n*e)}function qx(t){return Zs(t)+2}function jx(t,e,n){t[e]=Math.max(Math.min(t[e],n[1]),n[0])}function Xx(t,e){return t>=e[0]&&t<=e[1]}var Yx=function(){function t(){this.normalize=Zx,this.scale=Kx}return t.prototype.updateMethods=function(t){t.hasBreaks()?(this.normalize=Jn(t.normalize,t),this.scale=Jn(t.scale,t)):(this.normalize=Zx,this.scale=Kx)},t}();function Zx(t,e){return e[1]===e[0]?.5:(t-e[0])/(e[1]-e[0])}function Kx(t,e){return t*(e[1]-e[0])+e[0]}function Qx(t,e,n){var i=Math.log(t);return[Math.log(n?e[0]:Math.max(0,e[0]))/i,Math.log(n?e[1]:Math.max(0,e[1]))/i]}var Jx=function(){function t(t){this._calculator=new Yx,this._setting=t||{},this._extent=[1/0,-1/0]}return t.prototype.getSetting=function(t){return this._setting[t]},t.prototype._innerUnionExtent=function(t){var e=this._extent;this._innerSetExtent(t[0]e[1]?t[1]:e[1])},t.prototype.unionExtentFromData=function(t,e){this._innerUnionExtent(t.getApproximateExtent(e))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.setExtent=function(t,e){this._innerSetExtent(t,e)},t.prototype._innerSetExtent=function(t,e){var n=this._extent;isNaN(t)||(n[0]=t),isNaN(e)||(n[1]=e),this._brkCtx&&this._brkCtx.update(n)},t.prototype.setBreaksFromOption=function(t){},t.prototype._innerSetBreak=function(t){this._brkCtx&&(this._brkCtx.setBreaks(t),this._calculator.updateMethods(this._brkCtx),this._brkCtx.update(this._extent))},t.prototype._innerGetBreaks=function(){return this._brkCtx?this._brkCtx.breaks:[]},t.prototype.hasBreaks=function(){return!!this._brkCtx&&this._brkCtx.hasBreaks()},t.prototype._getExtentSpanWithBreaks=function(){return this._brkCtx&&this._brkCtx.hasBreaks()?this._brkCtx.getExtentSpan():this._extent[1]-this._extent[0]},t.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},t.prototype.isBlank=function(){return this._isBlank},t.prototype.setBlank=function(t){this._isBlank=t},t}();Fl(Jx);var tw=0,ew=function(){function t(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++tw,this._onCollect=t.onCollect}return t.createByAxisModel=function(e){var n=e.option,i=n.data,r=i&&Yn(i,nw);return new t({categories:r,needCollect:!r,deduplication:!1!==n.dedplication})},t.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},t.prototype.parseAndCollect=function(t){var e,n=this._needCollect;if(!ii(t)&&!n)return t;if(n&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,this._onCollect&&this._onCollect(t,e),e;var i=this._getOrCreateMap();return null==(e=i.get(t))&&(n?(e=this.categories.length,this.categories[e]=t,i.set(t,e),this._onCollect&&this._onCollect(t,e)):e=NaN),e},t.prototype._getOrCreateMap=function(){return this._map||(this._map=ki(this.categories))},t}();function nw(t){return ai(t)&&null!=t.value?t.value:t+""}var iw=function(t){function e(e){var n=t.call(this,e)||this;n.type="ordinal";var i=n.getSetting("ordinalMeta");return i||(i=new ew({})),ei(i)&&(i=new ew({categories:Yn(i,function(t){return ai(t)?t.value:t})})),n._ordinalMeta=i,n._extent=n.getSetting("extent")||[0,i.categories.length-1],n}return _(e,t),e.prototype.parse=function(t){return null==t?NaN:ii(t)?this._ordinalMeta.getOrdinal(t):Math.round(t)},e.prototype.contain=function(t){return Xx(t,this._extent)&&t>=0&&t=0&&t=0&&t=t},e.prototype.getOrdinalMeta=function(){return this._ordinalMeta},e.prototype.calcNiceTicks=function(){},e.prototype.calcNiceExtent=function(){},e.type="ordinal",e}(Jx);Jx.registerClass(iw);var rw=Ys,ow=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return _(e,t),e.prototype.parse=function(t){return null==t||""===t?NaN:Number(t)},e.prototype.contain=function(t){return Xx(t,this._extent)},e.prototype.normalize=function(t){return this._calculator.normalize(t,this._extent)},e.prototype.scale=function(t){return this._calculator.scale(t,this._extent)},e.prototype.getInterval=function(){return this._interval},e.prototype.setInterval=function(t){this._interval=t,this._niceExtent=this._extent.slice(),this._intervalPrecision=qx(t)},e.prototype.getTicks=function(t){t=t||{};var e=this._interval,n=this._extent,i=this._niceExtent,r=this._intervalPrecision,o=[];if(!e)return o;t.breakTicks;n[0]=0&&(s=rw(s+l*e,r))}if(o.length>0&&s===o[o.length-1].value)break;if(o.length>1e4)return[]}var c=o.length?o[o.length-1].value:i[1];return n[1]>c&&(t.expandToNicedExtent?o.push({value:rw(c+e,r)}):o.push({value:n[1]})),t.breakTicks,o},e.prototype.getMinorTicks=function(t){for(var e=this.getTicks({expandToNicedExtent:!0}),n=[],i=this.getExtent(),r=1;ri[0]&&u0&&(o=null===o?s:Math.min(o,s))}n[i]=o}}return n}(t),n=[];return Xn(t,function(t){var i,r=t.coordinateSystem.getBaseAxis(),o=r.getExtent();if("category"===r.type)i=r.getBandWidth();else if("value"===r.type||"time"===r.type){var a=r.dim+"_"+r.index,s=e[a],l=Math.abs(o[1]-o[0]),c=r.scale.getExtent(),d=Math.abs(c[1]-c[0]);i=s?l/d*s:l}else{var u=t.getData();i=Math.abs(o[1]-o[0])/u.count()}var h=js(t.get("barWidth"),i),p=js(t.get("barMaxWidth"),i),f=js(t.get("barMinWidth")||(function(t){return t.pipelineContext&&t.pipelineContext.large}(t)?.5:1),i),g=t.get("barGap"),v=t.get("barCategoryGap"),m=t.get("defaultBarGap");n.push({bandWidth:i,barWidth:h,barMaxWidth:p,barMinWidth:f,barGap:g,barCategoryGap:v,defaultBarGap:m,axisKey:dw(r),stackId:cw(t)})}),function(t){var e={};Xn(t,function(t,n){var i=t.axisKey,r=t.bandWidth,o=e[i]||{bandWidth:r,remainedWidth:r,autoWidthCount:0,categoryGap:null,gap:t.defaultBarGap||0,stacks:{}},a=o.stacks;e[i]=o;var s=t.stackId;a[s]||o.autoWidthCount++,a[s]=a[s]||{width:0,maxWidth:0};var l=t.barWidth;l&&!a[s].width&&(a[s].width=l,l=Math.min(o.remainedWidth,l),o.remainedWidth-=l);var c=t.barMaxWidth;c&&(a[s].maxWidth=c);var d=t.barMinWidth;d&&(a[s].minWidth=d);var u=t.barGap;null!=u&&(o.gap=u);var h=t.barCategoryGap;null!=h&&(o.categoryGap=h)});var n={};return Xn(e,function(t,e){n[e]={};var i=t.stacks,r=t.bandWidth,o=t.categoryGap;if(null==o){var a=Qn(i).length;o=Math.max(35-4*a,15)+"%"}var s=js(o,r),l=js(t.gap,1),c=t.remainedWidth,d=t.autoWidthCount,u=(c-s)/(d+(d-1)*l);u=Math.max(u,0),Xn(i,function(t){var e=t.maxWidth,n=t.minWidth;if(t.width){i=t.width;e&&(i=Math.min(i,e)),n&&(i=Math.max(i,n)),t.width=i,c-=i+l*i,d--}else{var i=u;e&&ei&&(i=n),i!==u&&(t.width=i,c-=i+l*i,d--)}}),u=(c-s)/(d+(d-1)*l),u=Math.max(u,0);var h,p=0;Xn(i,function(t,e){t.width||(t.width=u),h=t,p+=t.width*(1+l)}),h&&(p-=h.width*l);var f=-p/2;Xn(i,function(t,i){n[e][i]=n[e][i]||{bandWidth:r,offset:f,width:t.width},f+=t.width*(1+l)})}),n}(n)}var hw=function(t){function e(e){var n=t.call(this,e)||this;return n.type="time",n}return _(e,t),e.prototype.getLabel=function(t){var e=this.getSetting("useUTC");return Rf(t.value,Pf[function(t){switch(t){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}(Of(this._minLevelUnit))]||Pf.second,e,this.getSetting("locale"))},e.prototype.getFormattedLabel=function(t,e,n){var i=this.getSetting("useUTC");return function(t,e,n,i,r){var o=null;if(ii(n))o=n;else if(ni(n)){var a={time:t.time,level:t.time.level},s=null;s&&s.makeAxisLabelFormatterParamBreak(a,t.break),o=n(t.value,e,a)}else{var l=t.time;if(l){var c=n[l.lowerTimeUnit][l.upperTimeUnit];o=c[Math.min(l.level,c.length-1)]||""}else{var d=Hf(t.value,r);o=n[d][d][0]}}return Rf(new Date(t.value),o,r,i)}(t,e,n,this.getSetting("locale"),i)},e.prototype.getTicks=function(t){var e=this._interval,n=this._extent,i=[];if(!e)return i;var r=this.getSetting("useUTC"),o=Hf(n[1],r);i.push({value:n[0],time:{level:0,upperTimeUnit:o,lowerTimeUnit:o}});var a=function(t,e,n,i,r,o){var a=1e4,s=Ef,l=0;function c(t,e,n,r,s,c,d){for(var u=function(t,e){var n=new Date(0);n[t](1);var i=n.getTime();n[t](1+e);var r=n.getTime()-i;return function(t,e){return Math.max(0,Math.round((e-t)/r))}}(s,t),h=e,p=new Date(h);ha));)if(p[s](p[r]()+t),h=p.getTime(),o){var f=o.calcNiceTickMultiple(h,u);f>0&&(p[s](p[r]()+f*t),h=p.getTime())}d.push({value:h,notAdd:!0})}function d(t,r,o){var a=[],s=!r.length;if(!function(t,e,n,i){return Ff(new Date(e),t,i).getTime()===Ff(new Date(n),t,i).getTime()}(Of(t),i[0],i[1],n)){s&&(r=[{value:_w(i[0],t,n)},{value:i[1]}]);for(var l=0;l=i[0]&&d<=i[1]&&c(h,d,u,p,f,g,a),"year"===t&&o.length>1&&0===l&&o.unshift({value:o[0].value-h})}}for(l=0;l=i[0]&&_<=i[1]&&p++)}var b=r/e;if(p>1.5*b&&f>b/1.5)break;if(u.push(m),p>b||t===s[g])break}h=[]}}var x=Kn(Yn(u,function(t){return Kn(t,function(t){return t.value>=i[0]&&t.value<=i[1]&&!t.notAdd})}),function(t){return t.length>0}),w=[],S=x.length-1;for(g=0;gn&&(this._approxInterval=n);var r=pw.length,o=Math.min(function(t,e,n,i){for(;n>>1;t[r][1]16?16:t>7.5?7:t>3.5?4:t>1.5?2:1}function gw(t){return(t/=2592e6)>6?6:t>3?3:t>2?2:1}function vw(t){return(t/=kf)>12?12:t>6?6:t>3.5?4:t>2?2:1}function mw(t,e){return(t/=e?Cf:Sf)>30?30:t>20?20:t>15?15:t>10?10:t>5?5:t>2?2:1}function yw(t){return il(t)}function _w(t,e,n){var i=Math.max(0,Un(Lf,e)-1);return Ff(new Date(t),Lf[i],n).getTime()}Jx.registerClass(hw);var bw=Ys,xw=Math.floor,ww=Math.ceil,Sw=Math.pow,Cw=Math.log,kw=function(t){function e(){var e=null!==t&&t.apply(this,arguments)||this;return e.type="log",e.base=10,e._originalScale=new ow,e}return _(e,t),e.prototype.getTicks=function(e){e=e||{};var n=this._extent.slice(),i=this._originalScale.getExtent(),r=t.prototype.getTicks.call(this,e),o=this.base;return this._originalScale._innerGetBreaks(),Yn(r,function(t){var e=t.value,r=null,a=Sw(o,e);return e===n[0]&&this._fixMin?r=i[0]:e===n[1]&&this._fixMax&&(r=i[1]),null!=r&&(a=Tw(a,r)),{value:a,break:void 0}},this)},e.prototype._getNonTransBreaks=function(){return this._originalScale._innerGetBreaks()},e.prototype.setExtent=function(e,n){this._originalScale.setExtent(e,n);var i=Qx(this.base,[e,n]);t.prototype.setExtent.call(this,i[0],i[1])},e.prototype.getExtent=function(){var e=this.base,n=t.prototype.getExtent.call(this);n[0]=Sw(e,n[0]),n[1]=Sw(e,n[1]);var i=this._originalScale.getExtent();return this._fixMin&&(n[0]=Tw(n[0],i[0])),this._fixMax&&(n[1]=Tw(n[1],i[1])),n},e.prototype.unionExtentFromData=function(t,e){this._originalScale.unionExtentFromData(t,e);var n=Qx(this.base,t.getApproximateExtent(e),!0);this._innerUnionExtent(n)},e.prototype.calcNiceTicks=function(t){t=t||10;var e=this._extent.slice(),n=this._getExtentSpanWithBreaks();if(isFinite(n)&&!(n<=0)){var i,r=(i=n,Math.pow(10,nl(i)));for(t/n*r<=.5&&(r*=10);!isNaN(r)&&Math.abs(r)<1&&Math.abs(r)>0;)r*=10;var o=[bw(ww(e[0]/r)*r),bw(xw(e[1]/r)*r)];this._interval=r,this._intervalPrecision=qx(r),this._niceExtent=o}},e.prototype.calcNiceExtent=function(e){t.prototype.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},e.prototype.contain=function(e){return e=Cw(e)/Cw(this.base),t.prototype.contain.call(this,e)},e.prototype.normalize=function(e){return e=Cw(e)/Cw(this.base),t.prototype.normalize.call(this,e)},e.prototype.scale=function(e){return e=t.prototype.scale.call(this,e),Sw(this.base,e)},e.prototype.setBreaksFromOption=function(t){},e.type="log",e}(ow);function Tw(t,e){return bw(t,Zs(e))}Jx.registerClass(kw);var Mw=function(){function t(t,e,n){this._prepareParams(t,e,n)}return t.prototype._prepareParams=function(t,e,n){n[1]0&&s>0&&!l&&(a=0),a<0&&s<0&&!c&&(s=0));var u=this._determinedMin,h=this._determinedMax;return null!=u&&(a=u,l=!0),null!=h&&(s=h,c=!0),{min:a,max:s,minFixed:l,maxFixed:c,isBlank:d}},t.prototype.modifyDataMinMax=function(t,e){this[Aw[t]]=e},t.prototype.setDeterminedMinMax=function(t,e){this[Iw[t]]=e},t.prototype.freeze=function(){this.frozen=!0},t}(),Iw={min:"_determinedMin",max:"_determinedMax"},Aw={min:"_dataMin",max:"_dataMax"};function Dw(t,e){return null==e?null:ui(e)?NaN:t.parse(e)}function Pw(t,e){var n=t.type,i=function(t,e,n){var i=t.rawExtentInfo;return i||(i=new Mw(t,e,n),t.rawExtentInfo=i,i)}(t,e,t.getExtent()).calculate();t.setBlank(i.isBlank);var r=i.min,o=i.max,a=e.ecModel;if(a&&"time"===n){var s=function(t,e){var n=[];return e.eachSeriesByType(t,function(t){(function(t){return t.coordinateSystem&&"cartesian2d"===t.coordinateSystem.type})(t)&&n.push(t)}),n}("bar",a),l=!1;if(Xn(s,function(t){l=l||t.getBaseAxis()===e.axis}),l){var c=uw(s),d=function(t,e,n,i){var r=n.axis.getExtent(),o=Math.abs(r[1]-r[0]),a=function(t,e){if(t&&e)return t[dw(e)]}(i,n.axis);if(void 0===a)return{min:t,max:e};var s=1/0;Xn(a,function(t){s=Math.min(t.offset,s)});var l=-1/0;Xn(a,function(t){l=Math.max(t.offset+t.width,l)}),s=Math.abs(s),l=Math.abs(l);var c=s+l,d=e-t,u=d/(1-(s+l)/o)-d;return e+=u*(l/c),t-=u*(s/c),{min:t,max:e}}(r,o,e,c);r=d.min,o=d.max}}return{extent:[r,o],fixMin:i.minFixed,fixMax:i.maxFixed}}function Lw(t,e){var n=e,i=Pw(t,n),r=i.extent,o=n.get("splitNumber");t instanceof kw&&(t.base=n.get("logBase"));var a=t.type,s=n.get("interval"),l="interval"===a||"time"===a;t.setBreaksFromOption(Fw(n)),t.setExtent(r[0],r[1]),t.calcNiceExtent({splitNumber:o,fixMin:i.fixMin,fixMax:i.fixMax,minInterval:l?n.get("minInterval"):null,maxInterval:l?n.get("maxInterval"):null}),null!=s&&t.setInterval&&t.setInterval(s)}function Ew(t){var e=t.getLabelModel().get("formatter");if("time"===t.type){var n=zf(e);return function(e,i){return t.scale.getFormattedLabel(e,i,n)}}if(ii(e))return function(n){var i=t.scale.getLabel(n);return e.replace("{value}",null!=i?i:"")};if(ni(e)){if("category"===t.type)return function(n,i){return e(zw(t,n),n.value-t.scale.getExtent()[0],null)};var i=null;return function(n,r){var o=null;return i&&(o=i.makeAxisLabelFormatterParamBreak(o,n.break)),e(zw(t,n),r,o)}}return function(e){return t.scale.getLabel(e)}}function zw(t,e){return"category"===t.type?t.scale.getLabel(e):e.value}function Nw(t){var e=t.get("interval");return null==e?"auto":e}function Ow(t){return"category"===t.type&&0===Nw(t.getLabelModel())}function $w(t,e){var n={};return Xn(t.mapDimensionsAll(e),function(e){n[function(t,e){return Bx(t,e)?t.getCalculationInfo("stackResultDimension"):e}(t,e)]=!0}),Qn(n)}function Rw(t){return"middle"===t||"center"===t}function Hw(t){return t.getShallow("show")}function Fw(t){t.get("breaks",!0)}var Bw=function(){function t(){}return t.prototype.getNeedCrossZero=function(){return!this.option.scale},t.prototype.getCoordSysModel=function(){},t}(),Vw=[],Ww={registerPreprocessor:Qb,registerProcessor:Jb,registerPostInit:function(t){tx("afterinit",t)},registerPostUpdate:function(t){tx("afterupdate",t)},registerUpdateLifecycle:tx,registerAction:ex,registerCoordinateSystem:function(t,e){ug.register(t,e)},registerLayout:function(t,e){rx(Wb,t,e,1e3,"layout")},registerVisual:nx,registerTransform:ax,registerLoading:ox,registerMap:function(t,e,n){var i=eb["registerMap"];i&&i(t,e,n)},registerImpl:function(t,e){eb[t]=e},PRIORITY:rb,ComponentModel:Dg,ComponentView:fy,SeriesModel:ay,ChartView:yy,registerComponentModel:function(t){Dg.registerClass(t)},registerComponentView:function(t){fy.registerClass(t)},registerSeriesModel:function(t){ay.registerClass(t)},registerChartView:function(t){yy.registerClass(t)},registerCustomSeries:function(t,e){},registerSubTypeDefaulter:function(t,e){Dg.registerSubTypeDefaulter(t,e)},registerPainter:function(t,e){var n;n=e,$s[t]=n}};function Gw(t){ei(t)?Xn(t,function(t){Gw(t)}):Un(Vw,t)>=0||(Vw.push(t),ni(t)&&(t={install:t}),t.install(Ww))}var Uw=kl(),qw=kl(),jw=1,Xw=2;function Yw(t){return{out:{noPxChangeTryDetermine:[]},kind:t}}function Zw(t,e){var n=Yn(e,function(e){return t.scale.parse(e)});return"time"===t.type&&n.length>0&&(n.sort(),n.unshift(n[0]),n.push(n[n.length-1])),n}function Kw(t,e){var n=t.getLabelModel().get("customValues");if(n){var i=Ew(t),r=t.scale.getExtent();return{labels:Yn(Kn(Zw(t,n),function(t){return t>=r[0]&&t<=r[1]}),function(e){var n={value:e};return{formattedLabel:i(n),rawLabel:t.scale.getLabel(n),tickValue:e,time:void 0,break:void 0}})}}return"category"===t.type?function(t,e){var n=t.getLabelModel(),i=Jw(t,n,e);return!n.get("show")||t.scale.isBlank()?{labels:[]}:i}(t,e):function(t){var e=t.scale.getTicks(),n=Ew(t);return{labels:Yn(e,function(e,i){return{formattedLabel:n(e,i),rawLabel:t.scale.getLabel(e),tickValue:e.value,time:e.time,break:e.break}})}}(t)}function Qw(t,e,n){var i=t.getTickModel().get("customValues");if(i){var r=t.scale.getExtent();return{ticks:Kn(Zw(t,i),function(t){return t>=r[0]&&t<=r[1]})}}return"category"===t.type?function(t,e){var n,i,r=tS(t),o=Nw(e),a=iS(r,o);if(a)return a;e.get("show")&&!t.scale.isBlank()||(n=[]);if(ni(o))n=lS(t,o,!0);else if("auto"===o){var s=Jw(t,t.getLabelModel(),Yw(Xw));i=s.labelCategoryInterval,n=Yn(s.labels,function(t){return t.tickValue})}else n=sS(t,i=o,!0);return rS(r,o,{ticks:n,tickCategoryInterval:i})}(t,e):{ticks:Yn(t.scale.getTicks(n),function(t){return t.value})}}function Jw(t,e,n){var i,r,o=eS(t),a=Nw(e),s=n.kind===jw;if(!s){var l=iS(o,a);if(l)return l}ni(a)?i=lS(t,a):(r="auto"===a?function(t,e){if(e.kind===jw){var n=t.calculateCategoryInterval(e);return e.out.noPxChangeTryDetermine.push(function(){return qw(t).autoInterval=n,!0}),n}var i=qw(t).autoInterval;return null!=i?i:qw(t).autoInterval=t.calculateCategoryInterval(e)}(t,n):a,i=sS(t,r));var c={labels:i,labelCategoryInterval:r};return s?n.out.noPxChangeTryDetermine.push(function(){return rS(o,a,c),!0}):rS(o,a,c),c}var tS=nS("axisTick"),eS=nS("axisLabel");function nS(t){return function(e){return qw(e)[t]||(qw(e)[t]={list:[]})}}function iS(t,e){for(var n=0;ne&&i.axisExtent0===r[0]&&i.axisExtent1===r[1])return o;i.lastTickCount=n,i.lastAutoInterval=e,i.axisExtent0=r[0],i.axisExtent1=r[1]}function sS(t,e,n){var i=Ew(t),r=t.scale,o=r.getExtent(),a=t.getLabelModel(),s=[],l=Math.max((e||0)+1,1),c=o[0],d=r.count();0!==c&&l>1&&d/l>2&&(c=Math.round(Math.ceil(c/l)*l));var u=Ow(t),h=a.get("showMinLabel")||u,p=a.get("showMaxLabel")||u;h&&c!==o[0]&&g(o[0]);for(var f=c;f<=o[1];f+=l)g(f);function g(t){var e={value:t};s.push(n?t:{formattedLabel:i(e),rawLabel:r.getLabel(e),tickValue:t,time:void 0,break:void 0})}return p&&f-l!==o[1]&&g(o[1]),s}function lS(t,e,n){var i=t.scale,r=Ew(t),o=[];return Xn(i.getTicks(),function(t){var a=i.getLabel(t),s=t.value;e(t.value,a)&&o.push(n?s:{formattedLabel:r(t),rawLabel:a,tickValue:s,time:void 0,break:void 0})}),o}var cS=[0,1],dS=function(){function t(t,e,n){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=n||[0,0]}return t.prototype.contain=function(t){var e=this._extent,n=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=n&&t<=i},t.prototype.containData=function(t){return this.scale.contain(this.scale.parse(t))},t.prototype.getExtent=function(){return this._extent.slice()},t.prototype.getPixelPrecision=function(t){return function(t,e){var n=Math.log,i=Math.LN10,r=Math.floor(n(t[1]-t[0])/i),o=Math.round(n(Us(e[1]-e[0]))/i),a=Math.min(Math.max(-r+o,0),20);return isFinite(a)?a:20}(t||this.scale.getExtent(),this._extent)},t.prototype.setExtent=function(t,e){var n=this._extent;n[0]=t,n[1]=e},t.prototype.dataToCoord=function(t,e){var n=this._extent,i=this.scale;return t=i.normalize(i.parse(t)),this.onBand&&"ordinal"===i.type&&uS(n=n.slice(),i.count()),qs(t,cS,n,e)},t.prototype.coordToData=function(t,e){var n=this._extent,i=this.scale;this.onBand&&"ordinal"===i.type&&uS(n=n.slice(),i.count());var r=qs(t,n,cS,e);return this.scale.scale(r)},t.prototype.pointToData=function(t,e){},t.prototype.getTicksCoords=function(t){var e=(t=t||{}).tickModel||this.getTickModel(),n=Yn(Qw(this,e,{breakTicks:t.breakTicks,pruneByBreak:t.pruneByBreak}).ticks,function(t){return{coord:this.dataToCoord("ordinal"===this.scale.type?this.scale.getRawOrdinalNumber(t):t),tickValue:t}},this);return function(t,e,n,i){var r=e.length;if(!t.onBand||n||!r)return;var o,a,s=t.getExtent();if(1===r)e[0].coord=s[0],e[0].onBand=!0,o=e[1]={coord:s[1],tickValue:e[0].tickValue,onBand:!0};else{var l=e[r-1].tickValue-e[0].tickValue,c=(e[r-1].coord-e[0].coord)/l;Xn(e,function(t){t.coord-=c/2,t.onBand=!0});var d=t.scale.getExtent();a=1+d[1]-e[r-1].tickValue,o={coord:e[r-1].coord+c*a,tickValue:d[1]+1,onBand:!0},e.push(o)}var u=s[0]>s[1];h(e[0].coord,s[0])&&(i?e[0].coord=s[0]:e.shift());i&&h(s[0],e[0].coord)&&e.unshift({coord:s[0],onBand:!0});h(s[1],o.coord)&&(i?o.coord=s[1]:e.pop());i&&h(o.coord,s[1])&&e.push({coord:s[1],onBand:!0});function h(t,e){return t=Ys(t),e=Ys(e),u?t>e:t0&&t<100||(t=5),Yn(this.scale.getMinorTicks(t),function(t){return Yn(t,function(t){return{coord:this.dataToCoord(t),tickValue:t}},this)},this)},t.prototype.getViewLabels=function(t){return Kw(this,t=t||Yw(Xw)).labels},t.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},t.prototype.getTickModel=function(){return this.model.getModel("axisTick")},t.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),n=e[1]-e[0]+(this.onBand?1:0);0===n&&(n=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/n},t.prototype.calculateCategoryInterval=function(t){return function(t,e){var n=e.kind,i=function(t){var e=t.getLabelModel();return{axisRotate:t.getRotate?t.getRotate():t.isHorizontal&&!t.isHorizontal()?90:0,labelRotate:e.get("rotate")||0,font:e.getFont()}}(t),r=Ew(t),o=(i.axisRotate-i.labelRotate)/180*Math.PI,a=t.scale,s=a.getExtent(),l=a.count();if(s[1]-s[0]<1)return 0;var c=1;l>40&&(c=Math.max(1,Math.floor(l/40)));for(var d=s[0],u=t.dataToCoord(d+1)-t.dataToCoord(d),h=Math.abs(u*Math.cos(o)),p=Math.abs(u*Math.sin(o)),f=0,g=0;d<=s[1];d+=c){var v,m,y=_s(r({value:d}),i.font,"center","top");v=1.3*y.width,m=1.3*y.height,f=Math.max(f,v,7),g=Math.max(g,m,7)}var _=f/h,b=g/p;isNaN(_)&&(_=1/0),isNaN(b)&&(b=1/0);var x=Math.max(0,Math.floor(Math.min(_,b)));if(n===jw)return e.out.noPxChangeTryDetermine.push(Jn(oS,null,t,x,l)),x;var w=aS(t,x,l);return null!=w?w:x}(this,t=t||Yw(Xw))},t}();function uS(t,e){var n=(t[1]-t[0])/e/2;t[0]+=n,t[1]-=n}var hS=["label","labelLine","layoutOption","priority","defaultAttr","marginForce","minMarginForce","marginDefault","suggestIgnore"];function pS(t,e,n){n=n||3,e?t.dirty|=n:t.dirty&=~n}function fS(t,e){return e=e||3,null==t.dirty||!!(t.dirty&e)}function gS(t){if(t)return fS(t)&&function(t,e,n){var i=e.getComputedTransform();t.transform=Op(t.transform,i);var r=t.localRect=Np(t.localRect,e.getBoundingRect()),o=e.style,a=o.margin,s=n&&n.marginForce,l=n&&n.minMarginForce,c=n&&n.marginDefault,d=o.__marginType;null==d&&c&&(a=c,d=tf.textMargin);for(var u=0;u<4;u++)vS[u]=d===tf.minMargin&&l&&null!=l[u]?l[u]:s&&null!=s[u]?s[u]:a?a[u]:0;d===tf.textMargin&&Mp(r,vS,!1,!1);var h=t.rect=Np(t.rect,r);i&&h.applyTransform(i);d===tf.minMargin&&Mp(h,vS,!1,!1);t.axisAligned=Ep(i),(t.label=t.label||{}).ignore=e.ignore,pS(t,!1),pS(t,!0,2)}(t,t.label,t),t}var vS=[0,0,0,0];function mS(t,e){for(var n=0;n-1&&(s.style.stroke=s.style.fill,s.style.fill=Pg.color.neutral00,s.style.lineWidth=2),e},e.type="series.line",e.dependencies=["grid","polar"],e.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:6,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},e}(ay);function xS(t,e){var n=t.mapDimensionsAll("defaultedLabel"),i=n.length;if(1===i){var r=dm(t,e,n[0]);return null!=r?r+"":null}if(i){for(var o=[],a=0;a0?+h:1;T.scaleX=this._sizeX*M,T.scaleY=this._sizeY*M,this.setSymbolScale(1),Nu(this,l,c,d)},e.prototype.setSymbolScale=function(t){this.scaleX=this.scaleY=t},e.prototype.fadeOut=function(t,e,n){var i=this.childAt(0),r=Xd(this).dataIndex,o=n&&n.animation;if(this.silent=i.silent=!0,n&&n.fadeLabel){var a=i.getTextContent();a&&sp(a,{style:{opacity:0}},e,{dataIndex:r,removeOpt:o,cb:function(){i.removeTextContent()}})}else i.removeTextContent();sp(i,{style:{opacity:0},scaleX:0,scaleY:0},e,{dataIndex:r,cb:t,removeOpt:o})},e.getSymbolSize=function(t,e){return ei(n=t.getItemVisual(e,"symbolSize"))||(n=[+n,+n]),[n[0]||0,n[1]||0];var n},e.getSymbolZ2=function(t,e){return t.getItemVisual(e,"z2")},e}(Os);function SS(t,e){this.parent.drift(t,e)}function CS(t,e,n,i){return e&&!isNaN(e[0])&&!isNaN(e[1])&&!(i.isIgnore&&i.isIgnore(n))&&!(i.clipShape&&!i.clipShape.contain(e[0],e[1]))&&"none"!==t.getItemVisual(n,"symbol")}function kS(t){return null==t||ai(t)||(t={isIgnore:t}),t||{}}function TS(t){var e=t.hostModel,n=e.getModel("emphasis");return{emphasisItemStyle:n.getModel("itemStyle").getItemStyle(),blurItemStyle:e.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:e.getModel(["select","itemStyle"]).getItemStyle(),focus:n.get("focus"),blurScope:n.get("blurScope"),emphasisDisabled:n.get("disabled"),hoverScale:n.get("scale"),labelStatesModels:Gp(e),cursorStyle:e.get("cursor")}}var MS=function(){function t(t){this.group=new Os,this._SymbolCtor=t||wS}return t.prototype.updateData=function(t,e){this._progressiveEls=null,e=kS(e);var n=this.group,i=t.hostModel,r=this._data,o=this._SymbolCtor,a=e.disableAnimation,s=TS(t),l={disableAnimation:a},c=e.getSymbolPoint||function(e){return t.getItemLayout(e)};r||n.removeAll(),t.diff(r).add(function(i){var r=c(i);if(CS(t,r,i,e)){var a=new o(t,i,s,l);a.setPosition(r),t.setItemGraphicEl(i,a),n.add(a)}}).update(function(d,u){var h=r.getItemGraphicEl(u),p=c(d);if(CS(t,p,d,e)){var f=t.getItemVisual(d,"symbol")||"circle",g=h&&h.getSymbolType&&h.getSymbolType();if(!h||g&&g!==f)n.remove(h),(h=new o(t,d,s,l)).setPosition(p);else{h.updateData(t,d,s,l);var v={x:p[0],y:p[1]};a?h.attr(v):rp(h,v,i)}n.add(h),t.setItemGraphicEl(d,h)}else n.remove(h)}).remove(function(t){var e=r.getItemGraphicEl(t);e&&e.fadeOut(function(){n.remove(e)},i)}).execute(),this._getSymbolPoint=c,this._data=t},t.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(e,n){var i=t._getSymbolPoint(n);e.setPosition(i),e.markRedraw()})},t.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=TS(t),this._data=null,this.group.removeAll()},t.prototype.incrementalUpdate=function(t,e,n){function i(t){t.isGroup||(t.incremental=!0,t.ensureState("emphasis").hoverLayer=!0)}this._progressiveEls=[],n=kS(n);for(var r=t.start;r0?n=i[0]:i[1]<0&&(n=i[1]);return n}(r,n),a=i.dim,s=r.dim,l=e.mapDimension(s),c=e.mapDimension(a),d="x"===s||"radius"===s?1:0,u=Yn(t.dimensions,function(t){return e.mapDimension(t)}),h=!1,p=e.getCalculationInfo("stackResultDimension");return Bx(e,u[0])&&(h=!0,u[0]=p),Bx(e,u[1])&&(h=!0,u[1]=p),{dataDimsForPoint:u,valueStart:o,valueAxisDim:s,baseAxisDim:a,stacked:!!h,valueDim:l,baseDim:c,baseDataOffset:d,stackedOverDimension:e.getCalculationInfo("stackedOverDimension")}}function AS(t,e,n,i){var r=NaN;t.stacked&&(r=n.get(n.getCalculationInfo("stackedOverDimension"),i)),isNaN(r)&&(r=t.valueStart);var o=t.baseDataOffset,a=[];return a[o]=n.get(t.baseDim,i),a[1-o]=r,e.dataToPoint(a)}var DS=Math.min,PS=Math.max;function LS(t,e){return isNaN(t)||isNaN(e)}function ES(t,e,n,i,r,o,a,s,l){for(var c,d,u,h,p,f,g=n,v=0;v=r||g<0)break;if(LS(m,y)){if(l){g+=o;continue}break}if(g===n)t[o>0?"moveTo":"lineTo"](m,y),u=m,h=y;else{var _=m-c,b=y-d;if(_*_+b*b<.5){g+=o;continue}if(a>0){for(var x=g+o,w=e[2*x],S=e[2*x+1];w===m&&S===y&&v=i||LS(w,S))p=m,f=y;else{T=w-c,M=S-d;var D=m-c,P=w-m,L=y-d,E=S-y,z=void 0,N=void 0;if("x"===s){var O=T>0?1:-1;p=m-O*(z=Math.abs(D))*a,f=y,I=m+O*(N=Math.abs(P))*a,A=y}else if("y"===s){var $=M>0?1:-1;p=m,f=y-$*(z=Math.abs(L))*a,I=m,A=y+$*(N=Math.abs(E))*a}else z=Math.sqrt(D*D+L*L),p=m-T*a*(1-(k=(N=Math.sqrt(P*P+E*E))/(N+z))),f=y-M*a*(1-k),A=y+M*a*k,I=DS(I=m+T*a*k,PS(w,m)),A=DS(A,PS(S,y)),I=PS(I,DS(w,m)),f=y-(M=(A=PS(A,DS(S,y)))-y)*z/N,p=DS(p=m-(T=I-m)*z/N,PS(c,m)),f=DS(f,PS(d,y)),I=m+(T=m-(p=PS(p,DS(c,m))))*N/z,A=y+(M=y-(f=PS(f,DS(d,y))))*N/z}t.bezierCurveTo(u,h,p,f,m,y),u=I,h=A}else t.lineTo(m,y)}c=m,d=y,g+=o}return v}var zS=function(){this.smooth=0,this.smoothConstraint=!0},NS=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polyline",n}return _(e,t),e.prototype.getDefaultStyle=function(){return{stroke:Pg.color.neutral99,fill:null}},e.prototype.getDefaultShape=function(){return new zS},e.prototype.buildPath=function(t,e){var n=e.points,i=0,r=n.length/2;if(e.connectNulls){for(;r>0&&LS(n[2*r-2],n[2*r-1]);r--);for(;i=0){var v=a?(d-i)*g+i:(c-n)*g+n;return a?[t,v]:[v,t]}n=c,i=d;break;case o.C:c=r[l++],d=r[l++],u=r[l++],h=r[l++],p=r[l++],f=r[l++];var m=a?bo(n,c,u,p,t,s):bo(i,d,h,f,t,s);if(m>0)for(var y=0;y=0){v=a?yo(i,d,h,f,_):yo(n,c,u,p,_);return a?[t,v]:[v,t]}}n=p,i=f}}},e}(_d),OS=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e}(zS),$S=function(t){function e(e){var n=t.call(this,e)||this;return n.type="ec-polygon",n}return _(e,t),e.prototype.getDefaultShape=function(){return new OS},e.prototype.buildPath=function(t,e){var n=e.points,i=e.stackedOnPoints,r=0,o=n.length/2,a=e.smoothMonotone;if(e.connectNulls){for(;o>0&&LS(n[2*o-2],n[2*o-1]);o--);for(;r=0&&t<=1){var i=t*(e.length-1),r=Math.floor(i),o=Math.ceil(i),a=jo(e[r]),s=jo(e[o]),l=i-r,c=Ko([Oo(Bo(a[0],s[0],l)),Oo(Bo(a[1],s[1],l)),Oo(Bo(a[2],s[2],l)),$o(Bo(a[3],s[3],l))],"rgba");return n?{color:c,leftIndex:r,rightIndex:o,value:i}:c}}((n-i)/(e.coord-i),[t.color,e.color])}}for(var s=0;se){i?r.push(a(i,l,e)):n&&r.push(a(n,l,0),a(n,l,e));break}n&&(r.push(a(n,l,0)),n=null),r.push(l),i=l}}return r}function GS(t,e,n){var i=t.get("showAllSymbol"),r="auto"===i;if(!i||r){var o=n.getAxesByScale("ordinal")[0];if(o&&(!r||!function(t,e){var n=t.getExtent(),i=Math.abs(n[1]-n[0])/t.scale.count();isNaN(i)&&(i=0);for(var r=e.count(),o=Math.max(1,Math.round(r/5)),a=0;ai)return!1;return!0}(o,e))){var a=e.mapDimension(o.dim),s={};return Xn(o.getViewLabels(),function(t){var e=o.scale.getRawOrdinalNumber(t.tickValue);s[e]=1}),function(t){return!s.hasOwnProperty(e.get(a,t))}}}}function US(t,e){return isNaN(t)||isNaN(e)}function qS(t,e){return[t[2*e],t[2*e+1]]}function jS(t){if(t.get(["endLabel","show"]))return!0;for(var e=0;e=0;a--){var s=t.getDimensionInfo(i[a].dimension);if("x"===(r=s&&s.coordDim)||"y"===r){o=i[a];break}}if(o){var l=e.getAxis(r),c=Yn(o.stops,function(t){return{coord:l.toGlobalCoord(l.dataToCoord(t.value)),color:t.color}}),d=c.length,u=o.outerColors.slice();d&&c[0].coord>c[d-1].coord&&(c.reverse(),u.reverse());var h=WS(c,"x"===r?n.getWidth():n.getHeight()),p=h.length;if(!p&&d)return c[0].coord<0?u[1]?u[1]:c[d-1].color:u[0]?u[0]:c[0].color;var f=h[0].coord-10,g=h[p-1].coord+10,v=g-f;if(v<.001)return"transparent";Xn(h,function(t){t.offset=(t.coord-f)/v}),h.push({offset:p?h[p-1].offset:.5,color:u[1]||"transparent"}),h.unshift({offset:p?h[0].offset:.5,color:u[0]||"transparent"});var m=new Wh(0,0,0,0,h,!0);return m[r]=f,m[r+"2"]=g,m}}}(o,i,n)||o.getVisual("style")[o.getVisual("drawType")];if(h&&d.type===i.type&&k===this._step){v&&!p?p=this._newPolygon(l,_):p&&!v&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,lg(T));var M=f.getClipPath();if(M)op(M,{shape:XS(this,i,!1,t).shape},t);else f.setClipPath(XS(this,i,!0,t));b&&u.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),RS(this._stackedOnPoints,_)&&RS(this._points,l)||(g?this._doUpdateAnimation(o,_,i,n,k,m,x):(k&&(_&&(_=VS(_,l,i,k,x)),l=VS(l,null,i,k,x)),h.setShape({points:l}),p&&p.setShape({points:l,stackedOnPoints:_})))}else b&&u.updateData(o,{isIgnore:w,clipShape:C,disableAnimation:!0,getSymbolPoint:function(t){return[l[2*t],l[2*t+1]]}}),g&&this._initSymbolLabelAnimation(o,i,C),k&&(_&&(_=VS(_,l,i,k,x)),l=VS(l,null,i,k,x)),h=this._newPolyline(l),v?p=this._newPolygon(l,_):p&&(f.remove(p),p=this._polygon=null),c||this._initOrUpdateEndLabel(t,i,lg(T)),f.setClipPath(XS(this,i,!0,t));var I=t.getModel("emphasis"),A=I.get("focus"),D=I.get("blurScope"),P=I.get("disabled");(h.useStyle(Gn(a.getLineStyle(),{fill:"none",stroke:T,lineJoin:"bevel"})),Ru(h,t,"lineStyle"),h.style.lineWidth>0&&"bolder"===t.get(["emphasis","lineStyle","width"]))&&(h.getState("emphasis").style.lineWidth=+h.style.lineWidth+1);Xd(h).seriesIndex=t.seriesIndex,Nu(h,A,D,P);var L=BS(t.get("smooth")),E=t.get("smoothMonotone");if(h.setShape({smooth:L,smoothMonotone:E,connectNulls:x}),p){var z=o.getCalculationInfo("stackedOnSeries"),N=0;p.useStyle(Gn(s.getAreaStyle(),{fill:T,opacity:.7,lineJoin:"bevel",decal:o.getVisual("style").decal})),z&&(N=BS(z.get("smooth"))),p.setShape({smooth:L,stackedOnSmooth:N,smoothMonotone:E,connectNulls:x}),Ru(p,t,"areaStyle"),Xd(p).seriesIndex=t.seriesIndex,Nu(p,A,D,P)}var O=this._changePolyState;o.eachItemGraphicEl(function(t){t&&(t.onHoverStateChange=O)}),this._polyline.onHoverStateChange=O,this._data=o,this._coordSys=i,this._stackedOnPoints=_,this._points=l,this._step=k,this._valueOrigin=m,t.get("triggerLineEvent")&&(this.packEventData(t,h),p&&this.packEventData(t,p))},e.prototype.packEventData=function(t,e){Xd(e).eventData={componentType:"series",componentSubType:"line",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"line"}},e.prototype.highlight=function(t,e,n,i){var r=t.getData(),o=Cl(r,i);if(this._changePolyState("emphasis"),!(o instanceof Array)&&null!=o&&o>=0){var a=r.getLayout("points"),s=r.getItemGraphicEl(o);if(!s){var l=a[2*o],c=a[2*o+1];if(isNaN(l)||isNaN(c))return;if(this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(l,c))return;var d=t.get("zlevel")||0,u=t.get("z")||0;(s=new wS(r,o)).x=l,s.y=c,s.setZ(d,u);var h=s.getSymbolPath().getTextContent();h&&(h.zlevel=d,h.z=u,h.z2=this._polyline.z2+1),s.__temp=!0,r.setItemGraphicEl(o,s),s.stopSymbolAnimation(!0),this.group.add(s)}s.highlight()}else yy.prototype.highlight.call(this,t,e,n,i)},e.prototype.downplay=function(t,e,n,i){var r=t.getData(),o=Cl(r,i);if(this._changePolyState("normal"),null!=o&&o>=0){var a=r.getItemGraphicEl(o);a&&(a.__temp?(r.setItemGraphicEl(o,null),this.group.remove(a)):a.downplay())}else yy.prototype.downplay.call(this,t,e,n,i)},e.prototype._changePolyState=function(t){var e=this._polygon;mu(this._polyline,t),e&&mu(e,t)},e.prototype._newPolyline=function(t){var e=this._polyline;return e&&this._lineGroup.remove(e),e=new NS({shape:{points:t},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(e),this._polyline=e,e},e.prototype._newPolygon=function(t,e){var n=this._polygon;return n&&this._lineGroup.remove(n),n=new $S({shape:{points:t,stackedOnPoints:e},segmentIgnoreThreshold:2}),this._lineGroup.add(n),this._polygon=n,n},e.prototype._initSymbolLabelAnimation=function(t,e,n){var i,r,o=e.getBaseAxis(),a=o.inverse;"cartesian2d"===e.type?(i=o.isHorizontal(),r=!1):"polar"===e.type&&(i="angle"===o.dim,r=!0);var s=t.hostModel,l=s.get("animationDuration");ni(l)&&(l=l(null));var c=s.get("animationDelay")||0,d=ni(c)?c(null):c;t.eachItemGraphicEl(function(t,o){var s=t;if(s){var u=[t.x,t.y],h=void 0,p=void 0,f=void 0;if(n)if(r){var g=n,v=e.pointToCoord(u);i?(h=g.startAngle,p=g.endAngle,f=-v[1]/180*Math.PI):(h=g.r0,p=g.r,f=v[0])}else{var m=n;i?(h=m.x,p=m.x+m.width,f=t.x):(h=m.y+m.height,p=m.y,f=t.y)}var y=p===h?0:(f-h)/(p-h);a&&(y=1-y);var _=ni(c)?c(o):l*y+d,b=s.getSymbolPath(),x=b.getTextContent();s.attr({scaleX:0,scaleY:0}),s.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:_}),x&&x.animateFrom({style:{opacity:0}},{duration:300,delay:_}),b.disableLabelAnimation=!0}})},e.prototype._initOrUpdateEndLabel=function(t,e,n){var i=t.getModel("endLabel");if(jS(t)){var r=t.getData(),o=this._polyline,a=r.getLayout("points");if(!a)return o.removeTextContent(),void(this._endLabel=null);var s=this._endLabel;s||((s=this._endLabel=new Nd({z2:200})).ignoreClip=!0,o.setTextContent(this._endLabel),o.disableLabelAnimation=!0);var l=function(t){for(var e=t.length/2;e>0&&US(t[2*e-2],t[2*e-1]);e--);return e-1}(a);l>=0&&(Wp(o,Gp(t,"endLabel"),{inheritColor:n,labelFetcher:t,labelDataIndex:l,defaultText:function(t,e,n){return null!=n?function(t,e){var n=t.mapDimensionsAll("defaultedLabel");if(!ei(e))return e+"";for(var i=[],r=0;r=0&&i.push(e[o])}return i.join(" ")}(r,n):xS(r,t)},enableTextSetter:!0},function(t,e){var n=e.getBaseAxis(),i=n.isHorizontal(),r=n.inverse,o=i?r?"right":"left":"center",a=i?"middle":r?"top":"bottom";return{normal:{align:t.get("align")||o,verticalAlign:t.get("verticalAlign")||a}}}(i,e)),o.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},e.prototype._endLabelOnDuring=function(t,e,n,i,r,o,a){var s=this._endLabel,l=this._polyline;if(s){t<1&&null==i.originalX&&(i.originalX=s.x,i.originalY=s.y);var c=n.getLayout("points"),d=n.hostModel,u=d.get("connectNulls"),h=o.get("precision"),p=o.get("distance")||0,f=a.getBaseAxis(),g=f.isHorizontal(),v=f.inverse,m=e.shape,y=v?g?m.x:m.y+m.height:g?m.x+m.width:m.y,_=(g?p:0)*(v?-1:1),b=(g?0:-p)*(v?-1:1),x=g?"x":"y",w=function(t,e,n){for(var i,r,o=t.length/2,a="x"===n?0:1,s=0,l=-1,c=0;c=e||i>=e&&r<=e){l=c;break}s=c,i=r}else i=r;return{range:[s,l],t:(e-i)/(r-i)}}(c,y,x),S=w.range,C=S[1]-S[0],k=void 0;if(C>=1){if(C>1&&!u){var T=qS(c,S[0]);s.attr({x:T[0]+_,y:T[1]+b}),r&&(k=d.getRawValue(S[0]))}else{(T=l.getPointOn(y,x))&&s.attr({x:T[0]+_,y:T[1]+b});var M=d.getRawValue(S[0]),I=d.getRawValue(S[1]);r&&(k=function(t,e,n,i,r){var o=null==e||"auto"===e;if(null==i)return i;if(oi(i))return Ys(f=ul(n||0,i,r),o?Math.max(Zs(n||0),Zs(i)):e);if(ii(i))return r<1?n:i;for(var a=[],s=n,l=i,c=Math.max(s?s.length:0,l.length),d=0;d0?S[0]:0;T=qS(c,A);r&&(k=d.getRawValue(A)),s.attr({x:T[0]+_,y:T[1]+b})}if(r){var D=Jp(s);"function"==typeof D.setLabelText&&D.setLabelText(k)}}},e.prototype._doUpdateAnimation=function(t,e,n,i,r,o,a){var s=this._polyline,l=this._polygon,c=t.hostModel,d=function(t,e,n,i,r,o,a){for(var s=function(t,e){var n=[];return e.diff(t).add(function(t){n.push({cmd:"+",idx:t})}).update(function(t,e){n.push({cmd:"=",idx:e,idx1:t})}).remove(function(t){n.push({cmd:"-",idx:t})}).execute(),n}(t,e),l=[],c=[],d=[],u=[],h=[],p=[],f=[],g=IS(r,e,a),v=t.getLayout("points")||[],m=e.getLayout("points")||[],y=0;y3e3||l&&FS(h,f)>3e3)return s.stopAnimation(),s.setShape({points:p}),void(l&&(l.stopAnimation(),l.setShape({points:p,stackedOnPoints:f})));s.shape.__points=d.current,s.shape.points=u;var g={shape:{points:p}};d.current!==u&&(g.shape.__points=d.next),s.stopAnimation(),rp(s,g,c),l&&(l.setShape({points:u,stackedOnPoints:h}),l.stopAnimation(),rp(l,{shape:{stackedOnPoints:f}},c),s.shape.points!==l.shape.points&&(l.shape.points=s.shape.points));for(var v=[],m=d.status,y=0;ye&&(e=t[n]);return isFinite(e)?e:NaN},min:function(t){for(var e=1/0,n=0;ne[1]&&e.reverse(),e},e.prototype.pointToData=function(t,e){return this.coordToData(this.toLocalCoord(t["x"===this.dim?0:1]),e)},e.prototype.setCategorySortInfo=function(t){if("category"!==this.type)return!1;this.model.option.categorySortInfo=t,this.scale.setSortInfo(t)},e}(dS),fC="expandAxisBreak",gC=Math.PI,vC=[[1,2,1,2],[5,3,5,3],[8,3,8,3]],mC=[[0,1,0,1],[0,3,0,3],[0,3,0,3]],yC=kl(),_C=kl(),bC=function(){function t(t){this.recordMap={},this.resolveAxisNameOverlap=t}return t.prototype.ensureRecord=function(t){var e=t.axis.dim,n=t.componentIndex,i=this.recordMap,r=i[e]||(i[e]=[]);return r[n]||(r[n]={ready:{}})},t}();var xC=[1,0,0,1,0,0],wC=new Er(0,0,0,0),SC=function(t,e,n,i,r,o){if(Rw(t.nameLocation)){var a=o.stOccupiedRect;a&&CC(function(t,e,n){return t.transform=Op(t.transform,n),t.localRect=Np(t.localRect,e),t.rect=Np(t.rect,e),n&&t.rect.applyTransform(n),t.axisAligned=Ep(n),t.obb=void 0,(t.label=t.label||{}).ignore=!1,t}({},a,o.transGroup.transform),i,r)}else kC(o.labelInfoList,o.dirVec,i,r)};function CC(t,e,n){var i=new _r;_S(t,e,i,{direction:Math.atan2(n.y,n.x),bidirectional:!1,touchThreshold:.05})&&function(t,e){if(t){t.label.x+=e.x,t.label.y+=e.y,t.label.markRedraw();var n=t.transform;n&&(n[4]+=e.x,n[5]+=e.y);var i=t.rect;i&&(i.x+=e.x,i.y+=e.y);var r=t.obb;r&&r.fromBoundingRect(t.localRect,n)}}(e,i)}function kC(t,e,n,i){for(var r=_r.dot(i,e)>=0,o=0,a=t.length;o0?"top":"bottom",i="center"):Js(o-gC)?(r=n>0?"bottom":"top",i="center"):(r="middle",i=o>0&&o0?"right":"left":n>0?"left":"right"),{rotation:o,textAlign:i,textVerticalAlign:r}},t.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},t.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},t}(),MC=["axisLine","axisTickLabelEstimate","axisTickLabelDetermine","axisName"],IC={axisLine:function(t,e,n,i,r,o,a){var s=i.get(["axisLine","show"]);if("auto"===s&&(s=!0,null!=t.raw.axisLineAutoShow&&(s=!!t.raw.axisLineAutoShow)),s){var l=i.axis.getExtent(),c=o.transform,d=[l[0],0],u=[l[1],0],h=d[0]>u[0];c&&(Hi(d,d,c),Hi(u,u,c));var p=Wn({lineCap:"round"},i.getModel(["axisLine","lineStyle"]).getLineStyle()),f={strokeContainThreshold:t.raw.strokeContainThreshold||5,silent:!0,z2:1,style:p};if(i.get(["axisLine","breakLine"])&&i.axis.scale.hasBreaks())null.buildAxisBreakLine(i,r,o,f);else{var g=new zh(Wn({shape:{x1:d[0],y1:d[1],x2:u[0],y2:u[1]}},f));_p(g.shape,g.style.lineWidth),g.anid="line",r.add(g)}var v=i.get(["axisLine","symbol"]);if(null!=v){var m=i.get(["axisLine","symbolSize"]);ii(v)&&(v=[v,v]),(ii(m)||oi(m))&&(m=[m,m]);var y=T_(i.get(["axisLine","symbolOffset"])||0,m),_=m[0],b=m[1];Xn([{rotate:t.rotation+Math.PI/2,offset:y[0],r:0},{rotate:t.rotation-Math.PI/2,offset:y[1],r:Math.sqrt((d[0]-u[0])*(d[0]-u[0])+(d[1]-u[1])*(d[1]-u[1]))}],function(e,n){if("none"!==v[n]&&null!=v[n]){var i=k_(v[n],-_/2,-b/2,_,b,p.stroke,!0),o=e.r+e.offset,a=h?u:d;i.attr({rotation:e.rotate,x:a[0]+o*Math.cos(t.rotation),y:a[1]-o*Math.sin(t.rotation),silent:!0,z2:11}),r.add(i)}})}}},axisTickLabelEstimate:function(t,e,n,i,r,o,a,s){LC(e,r,s)&&AC(t,e,n,i,r,o,a,jw)},axisTickLabelDetermine:function(t,e,n,i,r,o,a,s){LC(e,r,s)&&AC(t,e,n,i,r,o,a,Xw);var l=function(t,e,n,i){var r=i.axis,o=i.getModel("axisTick"),a=o.get("show");"auto"===a&&(a=!0,null!=t.raw.axisTickAutoShow&&(a=!!t.raw.axisTickAutoShow));if(!a||r.scale.isBlank())return[];for(var s=o.getModel("lineStyle"),l=t.tickDirection*o.get("length"),c=PC(r.getTicksCoords(),n.transform,l,Gn(s.getLineStyle(),{stroke:i.get(["axisLine","lineStyle","color"])}),"ticks"),d=0;di[1],l="start"===e&&!s||"start"!==e&&s;Js(a-gC/2)?(o=l?"bottom":"top",r="center"):Js(a-1.5*gC)?(o=l?"top":"bottom",r="center"):(o="middle",r=a<1.5*gC&&a>gC/2?l?"left":"right":l?"right":"left");return{rotation:a,textAlign:r,textVerticalAlign:o}}(t.rotation,d,x||0,f),null!=(b=t.raw.axisNameAvailableWidth)&&(b=Math.abs(b/Math.sin(_.rotation)),!isFinite(b)&&(b=null)));var w=h.getFont(),S=i.get("nameTruncate",!0)||{},C=S.ellipsis,k=hi(t.raw.nameTruncateMaxWidth,S.maxWidth,b),T=s.nameMarginLevel||0,M=new Nd({x:v.x,y:v.y,rotation:_.rotation,silent:TC.isLabelSilent(i),style:Up(h,{text:c,font:w,overflow:"truncate",width:k,ellipsis:C,fill:h.getTextColor()||i.get(["axisLine","lineStyle","color"]),align:h.get("align")||_.textAlign,verticalAlign:h.get("verticalAlign")||_.textVerticalAlign}),z2:1});if(Dp({el:M,componentModel:i,itemName:c}),M.__fullText=c,M.anid="name",i.get("triggerEvent")){var I=TC.makeAxisEventDataBase(i);I.targetType="axisName",I.name=c,Xd(M).eventData=I}o.add(M),M.updateTransform(),e.nameEl=M;var A=l.nameLayout=gS({label:M,priority:M.z2,defaultAttr:{ignore:M.ignore},marginDefault:Rw(d)?vC[T]:mC[T]});if(l.nameLocation=d,r.add(M),M.decomposeTransform(),t.shouldNameMoveOverlap&&A){var D=n.ensureRecord(i);n.resolveAxisNameOverlap(t,n,i,A,m,D)}}}};function AC(t,e,n,i,r,o,a,s){EC(e)||function(t,e,n,i,r,o){var a=r.axis,s=hi(t.raw.axisLabelShow,r.get(["axisLabel","show"])),l=new Os;n.add(l);var c=Yw(i);if(!s||a.scale.isBlank())return void zC(e,[],l,c);var d=r.getModel("axisLabel"),u=a.getViewLabels(c),h=(hi(t.raw.labelRotate,d.get("rotate"))||0)*gC/180,p=TC.innerTextLayout(t.rotation,h,t.labelDirection),f=r.getCategories&&r.getCategories(!0),g=[],v=r.get("triggerEvent"),m=1/0,y=-1/0;Xn(u,function(t,e){var n,i="ordinal"===a.scale.type?a.scale.getRawOrdinalNumber(t.tickValue):t.tickValue,s=t.formattedLabel,c=t.rawLabel,h=d;if(f&&f[i]){var _=f[i];ai(_)&&_.textStyle&&(h=new hf(_.textStyle,d,r.ecModel))}var b=h.getTextColor()||r.get(["axisLine","lineStyle","color"]),x=h.getShallow("align",!0)||p.textAlign,w=pi(h.getShallow("alignMinLabel",!0),x),S=pi(h.getShallow("alignMaxLabel",!0),x),C=h.getShallow("verticalAlign",!0)||h.getShallow("baseline",!0)||p.textVerticalAlign,k=pi(h.getShallow("verticalAlignMinLabel",!0),C),T=pi(h.getShallow("verticalAlignMaxLabel",!0),C),M=10+((null===(n=t.time)||void 0===n?void 0:n.level)||0);m=Math.min(m,M),y=Math.max(y,M);var I=new Nd({x:0,y:0,rotation:0,silent:TC.isLabelSilent(r),z2:M,style:Up(h,{text:s,align:0===e?w:e===u.length-1?S:x,verticalAlign:0===e?k:e===u.length-1?T:C,fill:ni(b)?b("category"===a.type?c:"value"===a.type?i+"":i,e):b})});I.anid="label_"+i;var A=yC(I);if(A.break=t.break,A.tickValue=i,A.layoutRotation=p.rotation,Dp({el:I,componentModel:r,itemName:s,formatterParamsExtra:{isTruncated:function(){return I.isTruncated},value:c,tickIndex:e}}),v){var D=TC.makeAxisEventDataBase(r);D.targetType="axisLabel",D.value=c,D.tickIndex=e,t.break&&(D.break={start:t.break.parsedBreak.vmin,end:t.break.parsedBreak.vmax}),"category"===a.type&&(D.dataIndex=i),Xd(I).eventData=D,t.break&&function(t,e,n,i){n.on("click",function(n){var r={type:fC,breaks:[{start:i.parsedBreak.breakOption.start,end:i.parsedBreak.breakOption.end}]};r[t.axis.dim+"AxisIndex"]=t.componentIndex,e.dispatchAction(r)})}(r,o,I,t.break)}g.push(I),l.add(I)});var _=Yn(g,function(t){return{label:t,priority:yC(t).break?t.z2+(y-m+1):t.z2,defaultAttr:{ignore:t.ignore}}});zC(e,_,l,c)}(t,e,r,s,i,a);var l=e.labelLayoutList;!function(t,e,n,i){var r=e.get(["axisLabel","margin"]);Xn(n,function(n,o){var a=gS(n);if(a){var s=a.label,l=yC(s);a.suggestIgnore=s.ignore,s.ignore=!1,hs(NC,OC),NC.x=e.axis.dataToCoord(l.tickValue),NC.y=t.labelOffset+t.labelDirection*r,NC.rotation=l.layoutRotation,i.add(NC),NC.updateTransform(),i.remove(NC),NC.decomposeTransform(),hs(s,NC),s.markRedraw(),pS(a,!0),gS(a)}})}(t,i,l,o),t.rotation;var c=t.optionHideOverlap;!function(t,e,n){if(Ow(t.axis))return;function i(t,i,r){var o=gS(e[i]),a=gS(e[r]);if(o&&a)if(!1===t||o.suggestIgnore)DC(o.label);else if(a.suggestIgnore)DC(a.label);else{var s=.1;if(!n){var l=[0,0,0,0];o=mS({marginForce:l},o),a=mS({marginForce:l},a)}_S(o,a,null,{touchThreshold:s})&&DC(t?a.label:o.label)}}var r=t.get(["axisLabel","showMinLabel"]),o=t.get(["axisLabel","showMaxLabel"]),a=e.length;i(r,0,1),i(o,a-1,a-2)}(i,l,c),c&&function(t){var e=[];function n(t){if(!t.ignore){var e=t.ensureState("emphasis");null==e.ignore&&(e.ignore=!1)}t.ignore=!0}t.sort(function(t,e){return(e.suggestIgnore?1:0)-(t.suggestIgnore?1:0)||e.priority-t.priority});for(var i=0;i.1?"x":"y",d=a.transGroup[c];if(s.sort(function(t,e){return Math.abs(t.label[c]-d)-Math.abs(e.label[c]-d)}),l&&r){var u=o.getExtent(),h=Math.min(u[0],u[1]),p=Math.max(u[0],u[1])-h;r.union(new Er(h,0,p,1))}a.stOccupiedRect=r,a.labelInfoList=s}(t,n,i,l)}function DC(t){t&&(t.ignore=!0)}function PC(t,e,n,i,r){for(var o=[],a=[],s=[],l=0;l=0;o--){var a=t[+n[o]],s=a.model,l=a.scale;Wx(l)&&s.get("alignTicks")&&null==s.get("interval")?r.push(a):(Lw(l,s),Wx(l)&&(e=a))}r.length&&(e||Lw((e=r.pop()).scale,e.model),Xn(r,function(t){!function(t,e,n){var i=ow.prototype,r=i.getTicks.call(n),o=i.getTicks.call(n,{expandToNicedExtent:!0}),a=r.length-1,s=i.getInterval.call(n),l=Pw(t,e),c=l.extent,d=l.fixMin,u=l.fixMax;"log"===t.type&&(c=Qx(t.base,c,!0)),t.setBreaksFromOption(Fw(e)),t.setExtent(c[0],c[1]),t.calcNiceExtent({splitNumber:a,fixMin:d,fixMax:u});var h=i.getExtent.call(t);d&&(c[0]=h[0]),u&&(c[1]=h[1]);var p=i.getInterval.call(t),f=c[0],g=c[1];if(d&&u)p=(g-f)/a;else if(d)for(g=c[0]+p*a;gc[0]&&isFinite(f)&&isFinite(c[0]);)p=Ux(p),f=c[1]-p*a;else{t.getTicks().length-1>a&&(p=Ux(p));var v=p*a;(f=Ys((g=Math.ceil(c[1]/p)*p)-v))<0&&c[0]>=0?(f=0,g=Ys(v)):g>0&&c[1]<=0&&(g=0,f=-Ys(v))}var m=(r[0].value-o[0].value)/s,y=(r[a].value-o[a].value)/s;i.setExtent.call(t,f+p*m,g+p*y),i.setInterval.call(t,p),(m||y)&&i.setNiceExtent.call(t,f+p,g-p)}(t.scale,t.model,e.scale)}))}}this._updateScale(t,this.model),i(n.x),i(n.y);var r={};Xn(n.x,function(t){WC(n,"y",t,r)}),Xn(n.y,function(t){WC(n,"x",t,r)}),this.resize(this.model,e)},t.prototype.resize=function(t,e,n){var i=kg(t,e),r=this._rect=Sg(t.getBoxLayoutParams(),i.refContainer),o=this._axesMap,a=this._coordsList,s=t.get("containLabel");if(UC(o,r),!n){var l=function(t,e,n,i,r){var o=new bC(YC);return Xn(n,function(n){return Xn(n,function(n){if(Hw(n.model)){var a=!i;n.axisBuilder=function(t,e,n,i,r,o){for(var a=RC(t,n),s=!1,l=!1,c=0;c0&&i>0||n<0&&i<0)}(t)}function UC(t,e){Xn(t.x,function(t){return qC(t,e.x,e.width)}),Xn(t.y,function(t){return qC(t,e.y,e.height)})}function qC(t,e,n){var i=[0,n],r=t.inverse?1:0;t.setExtent(i[r],i[1-r]),function(t,e){var n=t.getExtent(),i=n[0]+n[1];t.toGlobalCoord="x"===t.dim?function(t){return t+e}:function(t){return i-t+e},t.toLocalCoord="x"===t.dim?function(t){return t-e}:function(t){return i-t+e}}(t,e)}function jC(t,e,n,i,r,o,a){XC(i,r,jw,e,!1,a);var s=[0,0,0,0];c(0),c(1),d(i,0,NaN),d(i,1,NaN);var l=null==function(t,e,n){if(t&&e)for(var i=0,r=t.length;i0});return Mp(i,s,!0,!0,n),UC(r,i),l;function c(t){Xn(r[dp[t]],function(e){if(Hw(e.model)){var n=o.ensureRecord(e.model),i=n.labelInfoList;if(i)for(var r=0;r0&&!ui(e)&&e>1e-4&&(t/=e),t}}function XC(t,e,n,i,r,o){var a=n===Xw;Xn(e,function(e){return Xn(e,function(e){Hw(e.model)&&(!function(t,e,n){var i=RC(e,n);t.updateCfg(i)}(e.axisBuilder,t,e.model),e.axisBuilder.build(a?{axisTickLabelDetermine:!0}:{axisTickLabelEstimate:!0},{noPxChange:r}))})});var s={x:0,y:0};function l(e){s[dp[1-e]]=t[up[e]]<=.5*o.refContainer[up[e]]?0:1-e==1?2:1}l(0),l(1),Xn(e,function(t,e){return Xn(t,function(t){Hw(t.model)&&(("all"===i||a)&&t.axisBuilder.build({axisName:!0},{nameMarginLevel:s[e]}),a&&t.axisBuilder.build({axisLine:!0}))})})}var YC=function(t,e,n,i,r,o){var a="x"===n.axis.dim?"y":"x";SC(t,0,0,i,r,o),Rw(t.nameLocation)||Xn(e.recordMap[a],function(t){t&&t.labelInfoList&&t.dirVec&&kC(t.labelInfoList,t.dirVec,i,r)})};function ZC(t,e){var n={axesInfo:{},seriesInvolved:!1,coordSysAxesInfo:{},coordSysMap:{}};return function(t,e,n){var i=e.getComponent("tooltip"),r=e.getComponent("axisPointer"),o=r.get("link",!0)||[],a=[];Xn(n.getCoordinateSystems(),function(n){if(n.axisPointerEnabled){var s=tk(n.model),l=t.coordSysAxesInfo[s]={};t.coordSysMap[s]=n;var c=n.model.getModel("tooltip",i);if(Xn(n.getAxes(),ti(p,!1,null)),n.getTooltipAxes&&i&&c.get("show")){var d="axis"===c.get("trigger"),u="cross"===c.get(["axisPointer","type"]),h=n.getTooltipAxes(c.get(["axisPointer","axis"]));(d||u)&&Xn(h.baseAxes,ti(p,!u||"cross",d)),u&&Xn(h.otherAxes,ti(p,"cross",!1))}}function p(i,s,d){var u=d.model.getModel("axisPointer",r),h=u.get("show");if(h&&("auto"!==h||i||JC(u))){null==s&&(s=u.get("triggerTooltip")),u=i?function(t,e,n,i,r,o){var a=e.getModel("axisPointer"),s={};Xn(["type","snap","lineStyle","shadowStyle","label","animation","animationDurationUpdate","animationEasingUpdate","z"],function(t){s[t]=Bn(a.get(t))}),s.snap="category"!==t.type&&!!o,"cross"===a.get("type")&&(s.type="line");var l=s.label||(s.label={});if(null==l.show&&(l.show=!1),"cross"===r){var c=a.get(["label","show"]);if(l.show=null==c||c,!o){var d=s.lineStyle=a.get("crossStyle");d&&Gn(l,d.textStyle)}}return t.model.getModel("axisPointer",new hf(s,n,i))}(d,c,r,e,i,s):u;var p=u.get("snap"),f=u.get("triggerEmphasis"),g=tk(d.model),v=s||p||"category"===d.type,m=t.axesInfo[g]={key:g,axis:d,coordSys:n,axisPointerModel:u,triggerTooltip:s,triggerEmphasis:f,involveSeries:v,snap:p,useHandle:JC(u),seriesModels:[],linkGroup:null};l[g]=m,t.seriesInvolved=t.seriesInvolved||v;var y=function(t,e){for(var n=e.model,i=e.dim,r=0;r=0||t===e}function QC(t){var e=(t.ecModel.getComponent("axisPointer")||{}).coordSysAxesInfo;return e&&e.axesInfo[tk(t)]}function JC(t){return!!t.get(["handle","show"])}function tk(t){return t.type+"||"+t.id}var ek={},nk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(e,n,i,r){this.axisPointerClass&&function(t){var e=QC(t);if(e){var n=e.axisPointerModel,i=e.axis.scale,r=n.option,o=n.get("status"),a=n.get("value");null!=a&&(a=i.parse(a));var s=JC(n);null==o&&(r.status=s?"show":"hide");var l=i.getExtent().slice();l[0]>l[1]&&l.reverse(),(null==a||a>l[1])&&(a=l[1]),aa)return!0;if(o){var s=QC(t).seriesDataCount,l=i.getExtent();return Math.abs(l[0]-l[1])/s>a}return!1}return!0===n},t.prototype.makeElOption=function(t,e,n,i,r){},t.prototype.createPointerEl=function(t,e,n,i){var r=e.pointer;if(r){var o=hk(t).pointerEl=new Fp[r.type](pk(e.pointer));t.add(o)}},t.prototype.createLabelEl=function(t,e,n,i){if(e.label){var r=hk(t).labelEl=new Nd(pk(e.label));t.add(r),yk(r,i)}},t.prototype.updatePointerEl=function(t,e,n){var i=hk(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),n(i,{shape:e.pointer.shape}))},t.prototype.updateLabelEl=function(t,e,n,i){var r=hk(t).labelEl;r&&(r.setStyle(e.label.style),n(r,{x:e.label.x,y:e.label.y}),yk(r,i))},t.prototype._renderHandle=function(t){if(!this._dragging&&this.updateHandleTransform){var e,n=this._axisPointerModel,i=this._api.getZr(),r=this._handle,o=n.getModel("handle"),a=n.get("status");if(!o.get("show")||!a||"hide"===a)return r&&i.remove(r),void(this._handle=null);this._handle||(e=!0,r=this._handle=Cp(o.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(t){cr(t.event)},onmousedown:fk(this._onHandleDragMove,this,0,0),drift:fk(this._onHandleDragMove,this),ondragend:fk(this._onHandleDragEnd,this)}),i.add(r)),bk(r,n,!1),r.setStyle(o.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var s=o.get("size");ei(s)||(s=[s,s]),r.scaleX=s[0]/2,r.scaleY=s[1]/2,Iy(this,"_doDispatchAxisPointer",o.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,e)}},t.prototype._moveHandleToValue=function(t,e){vk(this._axisPointerModel,!e&&this._moveAnimation,this._handle,_k(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},t.prototype._onHandleDragMove=function(t,e){var n=this._handle;if(n){this._dragging=!0;var i=this.updateHandleTransform(_k(n),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,n.stopAnimation(),n.attr(_k(i)),hk(n).lastProp=null,this._doDispatchAxisPointer()}},t.prototype._doDispatchAxisPointer=function(){if(this._handle){var t=this._payloadInfo,e=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:t.cursorPoint[0],y:t.cursorPoint[1],tooltipOption:t.tooltipOption,axesInfo:[{axisDim:e.axis.dim,axisIndex:e.componentIndex}]})}},t.prototype._onHandleDragEnd=function(){if(this._dragging=!1,this._handle){var t=this._axisPointerModel.get("value");this._moveHandleToValue(t),this._api.dispatchAction({type:"hideTip"})}},t.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),n=this._group,i=this._handle;e&&n&&(this._lastGraphicKey=null,n&&e.remove(n),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),Ay(this,"_doDispatchAxisPointer")},t.prototype.doClear=function(){},t.prototype.buildLabel=function(t,e,n){return{x:t[n=n||0],y:t[1-n],width:e[n],height:e[1-n]}},t}();function vk(t,e,n,i){mk(hk(n).lastProp,i)||(hk(n).lastProp=i,e?rp(n,i,t):(n.stopAnimation(),n.attr(i)))}function mk(t,e){if(ai(t)&&ai(e)){var n=!0;return Xn(e,function(e,i){n=n&&mk(t[i],e)}),!!n}return t===e}function yk(t,e){t[e.get(["label","show"])?"show":"hide"]()}function _k(t){return{x:t.x||0,y:t.y||0,rotation:t.rotation||0}}function bk(t,e,n){var i=e.get("z"),r=e.get("zlevel");t&&t.traverse(function(t){"group"!==t.type&&(null!=i&&(t.z=i),null!=r&&(t.zlevel=r),t.silent=n)})}function xk(t,e,n,i,r){var o=wk(n.get("value"),e.axis,e.ecModel,n.get("seriesDataIndices"),{precision:n.get(["label","precision"]),formatter:n.get(["label","formatter"])}),a=n.getModel("label"),s=ig(a.get("padding")||0),l=a.getFont(),c=_s(o,l),d=r.position,u=c.width+s[1]+s[3],h=c.height+s[0]+s[2],p=r.align;"right"===p&&(d[0]-=u),"center"===p&&(d[0]-=u/2);var f=r.verticalAlign;"bottom"===f&&(d[1]-=h),"middle"===f&&(d[1]-=h/2),function(t,e,n,i){var r=i.getWidth(),o=i.getHeight();t[0]=Math.min(t[0]+e,r)-e,t[1]=Math.min(t[1]+n,o)-n,t[0]=Math.max(t[0],0),t[1]=Math.max(t[1],0)}(d,u,h,i);var g=a.get("backgroundColor");g&&"auto"!==g||(g=e.get(["axisLine","lineStyle","color"])),t.label={x:d[0],y:d[1],style:Up(a,{text:o,font:l,fill:a.getTextColor(),padding:s,backgroundColor:g}),z2:10}}function wk(t,e,n,i,r){t=e.scale.parse(t);var o=e.scale.getLabel({value:t},{precision:r.precision}),a=r.formatter;if(a){var s={value:zw(e,{value:t}),axisDimension:e.dim,axisIndex:e.index,seriesData:[]};Xn(i,function(t){var e=n.getSeriesByIndex(t.seriesIndex),i=t.dataIndexInside,r=e&&e.getDataParams(i);r&&s.seriesData.push(r)}),ii(a)?o=a.replace("{value}",o):ni(a)&&(o=a(s))}return o}function Sk(t,e,n){var i=[1,0,0,1,0,0];return mr(i,i,n.rotation),vr(i,i,n.position),xp([t.dataToCoord(e),(n.labelOffset||0)+(n.labelDirection||1)*(n.labelMargin||0)],i)}var Ck=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return _(e,t),e.prototype.makeElOption=function(t,e,n,i,r){var o=n.axis,a=o.grid,s=i.get("type"),l=kk(a,o).getOtherAxis(o).getGlobalExtent(),c=o.toGlobalCoord(o.dataToCoord(e,!0));if(s&&"none"!==s){var d=function(t){var e,n=t.get("type"),i=t.getModel(n+"Style");return"line"===n?(e=i.getLineStyle()).fill=null:"shadow"===n&&((e=i.getAreaStyle()).stroke=null),e}(i),u=Tk[s](o,c,l);u.style=d,t.graphicKey=u.type,t.pointer=u}!function(t,e,n,i,r,o){var a=TC.innerTextLayout(n.rotation,0,n.labelDirection);n.labelMargin=r.get(["label","margin"]),xk(e,i,r,o,{position:Sk(i.axis,t,n),align:a.textAlign,verticalAlign:a.textVerticalAlign})}(e,t,RC(a.getRect(),n),n,i,r)},e.prototype.getHandleTransform=function(t,e,n){var i=RC(e.axis.grid.getRect(),e,{labelInside:!1});i.labelMargin=n.get(["handle","margin"]);var r=Sk(e.axis,t,i);return{x:r[0],y:r[1],rotation:i.rotation+(i.labelDirection<0?Math.PI:0)}},e.prototype.updateHandleTransform=function(t,e,n,i){var r=n.axis,o=r.grid,a=r.getGlobalExtent(!0),s=kk(o,r).getOtherAxis(r).getGlobalExtent(),l="x"===r.dim?0:1,c=[t.x,t.y];c[l]+=e[l],c[l]=Math.min(a[1],c[l]),c[l]=Math.max(a[0],c[l]);var d=(s[1]+s[0])/2,u=[d,d];u[l]=c[l];return{x:c[0],y:c[1],rotation:t.rotation,cursorPoint:u,tooltipOption:[{verticalAlign:"middle"},{align:"center"}][l]}},e}(gk);function kk(t,e){var n={};return n[e.dim+"AxisIndex"]=e.index,t.getCartesian(n)}var Tk={line:function(t,e,n){var i,r,o;return{type:"Line",subPixelOptimize:!0,shape:(i=[e,n[0]],r=[e,n[1]],o=Mk(t),{x1:i[o=o||0],y1:i[1-o],x2:r[o],y2:r[1-o]})}},shadow:function(t,e,n){var i,r,o,a=Math.max(1,t.getBandWidth()),s=n[1]-n[0];return{type:"Rect",shape:(i=[e-a/2,n[0]],r=[a,s],o=Mk(t),{x:i[o=o||0],y:i[1-o],width:r[o],height:r[1-o]})}}};function Mk(t){return"x"===t.dim?0:1}var Ik=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="axisPointer",e.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:Pg.color.border,width:1,type:"dashed"},shadowStyle:{color:Pg.color.shadowTint},label:{show:!0,formatter:null,precision:"auto",margin:3,color:Pg.color.neutral00,padding:[5,7,5,7],backgroundColor:Pg.color.accent60,borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:Pg.color.accent40,throttle:40}},e}(Dg),Ak=kl(),Dk=Xn;function Pk(t,e,n){if(!bn.node){var i=e.getZr();Ak(i).records||(Ak(i).records={}),function(t,e){if(Ak(t).initialized)return;function n(n,i){t.on(n,function(n){var r=function(t){var e={showTip:[],hideTip:[]},n=function(i){var r=e[i.type];r?r.push(i):(i.dispatchAction=n,t.dispatchAction(i))};return{dispatchAction:n,pendings:e}}(e);Dk(Ak(t).records,function(t){t&&i(t,n,r.dispatchAction)}),function(t,e){var n,i=t.showTip.length,r=t.hideTip.length;i?n=t.showTip[i-1]:r&&(n=t.hideTip[r-1]);n&&(n.dispatchAction=null,e.dispatchAction(n))}(r.pendings,e)})}Ak(t).initialized=!0,n("click",ti(Ek,"click")),n("mousemove",ti(Ek,"mousemove")),n("globalout",Lk)}(i,e),(Ak(i).records[t]||(Ak(i).records[t]={})).handler=n}}function Lk(t,e,n){t.handler("leave",null,n)}function Ek(t,e,n,i){e.handler(t,n,i)}function zk(t,e){if(!bn.node){var n=e.getZr();(Ak(n).records||{})[t]&&(Ak(n).records[t]=null)}}var Nk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.prototype.render=function(t,e,n){var i=e.getComponent("tooltip"),r=t.get("triggerOn")||i&&i.get("triggerOn")||"mousemove|click";Pk("axisPointer",n,function(t,e,n){"none"!==r&&("leave"===t||r.indexOf(t)>=0)&&n({type:"updateAxisPointer",currTrigger:t,x:e&&e.offsetX,y:e&&e.offsetY})})},e.prototype.remove=function(t,e){zk("axisPointer",e)},e.prototype.dispose=function(t,e){zk("axisPointer",e)},e.type="axisPointer",e}(fy);function Ok(t,e){var n,i=[],r=t.seriesIndex;if(null==r||!(n=e.getSeriesByIndex(r)))return{point:[]};var o=n.getData(),a=Cl(o,t);if(null==a||a<0||ei(a))return{point:[]};var s=o.getItemGraphicEl(a),l=n.coordinateSystem;if(n.getTooltipPosition)i=n.getTooltipPosition(a)||[];else if(l&&l.dataToPoint)if(t.isStacked){var c=l.getBaseAxis(),d=l.getOtherAxis(c).dim,u=c.dim,h="x"===d||"radius"===d?1:0,p=o.mapDimension(u),f=[];f[h]=o.get(p,a),f[1-h]=o.get(o.getCalculationInfo("stackResultDimension"),a),i=l.dataToPoint(f)||[]}else i=l.dataToPoint(o.getValues(Yn(l.dimensions,function(t){return o.mapDimension(t)}),a))||[];else if(s){var g=s.getBoundingRect().clone();g.applyTransform(s.transform),i=[g.x+g.width/2,g.y+g.height/2]}return{point:i,el:s}}var $k=kl();function Rk(t,e,n){var i=t.currTrigger,r=[t.x,t.y],o=t,a=t.dispatchAction||Jn(n.dispatchAction,n),s=e.getComponent("axisPointer").coordSysAxesInfo;if(s){Wk(r)&&(r=Ok({seriesIndex:o.seriesIndex,dataIndex:o.dataIndex},e).point);var l=Wk(r),c=o.axesInfo,d=s.axesInfo,u="leave"===i||Wk(r),h={},p={},f={list:[],map:{}},g={showPointer:ti(Fk,p),showTooltip:ti(Bk,f)};Xn(s.coordSysMap,function(t,e){var n=l||t.containPoint(r);Xn(s.coordSysAxesInfo[e],function(t,e){var i=t.axis,o=function(t,e){for(var n=0;n<(t||[]).length;n++){var i=t[n];if(e.axis.dim===i.axisDim&&e.axis.model.componentIndex===i.axisIndex)return i}}(c,t);if(!u&&n&&(!c||o)){var a=o&&o.value;null!=a||l||(a=i.pointToData(r)),null!=a&&Hk(t,a,g,!1,h)}})});var v={};return Xn(d,function(t,e){var n=t.linkGroup;n&&!p[e]&&Xn(n.axesInfo,function(e,i){var r=p[i];if(e!==t&&r){var o=r.value;n.mapper&&(o=t.axis.scale.parse(n.mapper(o,Vk(e),Vk(t)))),v[t.key]=o}})}),Xn(v,function(t,e){Hk(d[e],t,g,!0,h)}),function(t,e,n){var i=n.axesInfo=[];Xn(e,function(e,n){var r=e.axisPointerModel.option,o=t[n];o?(!e.useHandle&&(r.status="show"),r.value=o.value,r.seriesDataIndices=(o.payloadBatch||[]).slice()):!e.useHandle&&(r.status="hide"),"show"===r.status&&i.push({axisDim:e.axis.dim,axisIndex:e.axis.model.componentIndex,value:r.value})})}(p,d,h),function(t,e,n,i){if(Wk(e)||!t.list.length)return void i({type:"hideTip"});var r=((t.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};i({type:"showTip",escapeConnect:!0,x:e[0],y:e[1],tooltipOption:n.tooltipOption,position:n.position,dataIndexInside:r.dataIndexInside,dataIndex:r.dataIndex,seriesIndex:r.seriesIndex,dataByCoordSys:t.list})}(f,r,t,a),function(t,e,n){var i=n.getZr(),r="axisPointerLastHighlights",o=$k(i)[r]||{},a=$k(i)[r]={};Xn(t,function(t,e){var n=t.axisPointerModel.option;"show"===n.status&&t.triggerEmphasis&&Xn(n.seriesDataIndices,function(t){var e=t.seriesIndex+" | "+t.dataIndex;a[e]=t})});var s=[],l=[];Xn(o,function(t,e){!a[e]&&l.push(t)}),Xn(a,function(t,e){!o[e]&&s.push(t)}),l.length&&n.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&n.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}(d,0,n),h}}function Hk(t,e,n,i,r){var o=t.axis;if(!o.scale.isBlank()&&o.containData(e))if(t.involveSeries){var a=function(t,e){var n=e.axis,i=n.dim,r=t,o=[],a=Number.MAX_VALUE,s=-1;return Xn(e.seriesModels,function(e,l){var c,d,u=e.getData().mapDimensionsAll(i);if(e.getAxisTooltipData){var h=e.getAxisTooltipData(u,t,n);d=h.dataIndices,c=h.nestestValue}else{if(!(d=e.indicesOfNearest(i,u[0],t,"category"===n.type?.5:null)).length)return;c=e.getData().get(u[0],d[0])}if(null!=c&&isFinite(c)){var p=t-c,f=Math.abs(p);f<=a&&((f=0&&s<0)&&(a=f,s=p,r=c,o.length=0),Xn(d,function(t){o.push({seriesIndex:e.seriesIndex,dataIndexInside:t,dataIndex:e.getData().getRawIndex(t)})}))}}),{payloadBatch:o,snapToValue:r}}(e,t),s=a.payloadBatch,l=a.snapToValue;s[0]&&null==r.seriesIndex&&Wn(r,s[0]),!i&&t.snap&&o.containData(l)&&null!=l&&(e=l),n.showPointer(t,e,s),n.showTooltip(t,a,l)}else n.showPointer(t,e)}function Fk(t,e,n,i){t[e.key]={value:n,payloadBatch:i}}function Bk(t,e,n,i){var r=n.payloadBatch,o=e.axis,a=o.model,s=e.axisPointerModel;if(e.triggerTooltip&&r.length){var l=e.coordSys.model,c=tk(l),d=t.map[c];d||(d=t.map[c]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},t.list.push(d)),d.dataByAxis.push({axisDim:o.dim,axisIndex:a.componentIndex,axisType:a.type,axisId:a.id,value:i,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:r.slice()})}}function Vk(t){var e=t.axis.model,n={},i=n.axisDim=t.axis.dim;return n.axisIndex=n[i+"AxisIndex"]=e.componentIndex,n.axisName=n[i+"AxisName"]=e.name,n.axisId=n[i+"AxisId"]=e.id,n}function Wk(t){return!t||null==t[0]||isNaN(t[0])||null==t[1]||isNaN(t[1])}function Gk(t){nk.registerAxisPointerClass("CartesianAxisPointer",Ck),t.registerComponentModel(Ik),t.registerComponentView(Nk),t.registerPreprocessor(function(t){if(t){(!t.axisPointer||0===t.axisPointer.length)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!ei(e)&&(t.axisPointer.link=[e])}}),t.registerProcessor(t.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=ZC(t,e)}),t.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},Rk)}var Uk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return _(e,t),e.type="tooltip",e.dependencies=["axisPointer"],e.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,displayTransition:!0,enterable:!1,backgroundColor:Pg.color.neutral00,shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,defaultBorderColor:Pg.color.border,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:Pg.color.borderShade,width:1,type:"dashed",textStyle:{}}},textStyle:{color:Pg.color.tertiary,fontSize:14}},e}(Dg);function qk(t){var e=t.get("confine");return null!=e?!!e:"richText"===t.get("renderMode")}function jk(t){if(bn.domSupported)for(var e=document.documentElement.style,n=0,i=t.length;n0&&r.push(function(t,e,n){var i="cubic-bezier(0.23,1,0.32,1)",r="",o="";return n&&(o="opacity"+(r=" "+t/2+"s "+i)+",visibility"+r),e||(r=" "+t+"s "+i,o+=(o.length?",":"")+(bn.transformSupported?""+Kk+r:",left"+r+",top"+r)),Zk+":"+o}(o,n,i)),a&&r.push("background-color:"+a),Xn(["width","color","radius"],function(e){var n="border-"+e,i=ng(n),o=t.get(i);null!=o&&r.push(n+":"+o+("color"===e?"":"px"))}),r.push(function(t){var e=[],n=t.get("fontSize"),i=t.getTextColor();i&&e.push("color:"+i),e.push("font:"+t.getFont());var r=pi(t.get("lineHeight"),Math.round(3*n/2));n&&e.push("line-height:"+r+"px");var o=t.get("textShadowColor"),a=t.get("textShadowBlur")||0,s=t.get("textShadowOffsetX")||0,l=t.get("textShadowOffsetY")||0;return o&&a&&e.push("text-shadow:"+s+"px "+l+"px "+a+"px "+o),Xn(["decoration","align"],function(n){var i=t.get(n);i&&e.push("text-"+n+":"+i)}),e.join(";")}(u)),null!=h&&r.push("padding:"+ig(h).join("px ")+"px"),r.join(";")+";"}function eT(t,e,n,i,r){var o=e&&e.painter;if(n){var a=o&&o.getViewportRoot();a&&function(t,e,n,i,r){Zi(Yi,e,i,r,!0)&&Zi(t,n,Yi[0],Yi[1])}(t,a,n,i,r)}else{t[0]=i,t[1]=r;var s=o&&o.getViewportRootOffset();s&&(t[0]+=s.offsetLeft,t[1]+=s.offsetTop)}t[2]=t[0]/e.getWidth(),t[3]=t[1]/e.getHeight()}var nT=function(){function t(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,bn.wxa)return null;var n=document.createElement("div");n.domBelongToZr=!0,this.el=n;var i=this._zr=t.getZr(),r=e.appendTo,o=r&&(ii(r)?document.querySelector(r):ci(r)?r:ni(r)&&r(t.getDom()));eT(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(n),this._api=t,this._container=o;var a=this;n.onmouseenter=function(){a._enterable&&(clearTimeout(a._hideTimeout),a._show=!0),a._inContent=!0},n.onmousemove=function(t){if(t=t||window.event,!a._enterable){var e=i.handler;sr(i.painter.getViewportRoot(),t,!0),e.dispatch("mousemove",t)}},n.onmouseleave=function(){a._inContent=!1,a._enterable&&a._show&&a.hideLater(a._hideDelay)}}return t.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),n=(o="position",(a=(r=e).currentStyle||document.defaultView&&document.defaultView.getComputedStyle(r))?a[o]:null),i=e.style;"absolute"!==i.position&&"absolute"!==n&&(i.position="relative")}var r,o,a,s=t.get("alwaysShowContent");s&&this._moveIfResized(),this._alwaysShowContent=s,this._enableDisplayTransition=t.get("displayTransition")&&t.get("transitionDuration")>0,this.el.className=t.get("className")||""},t.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var n=this.el,i=n.style,r=this._styleCoord;n.innerHTML?i.cssText=Qk+tT(t,!this._firstShow,this._longHide,this._enableDisplayTransition)+Jk(r[0],r[1],!0)+"border-color:"+lg(e)+";"+(t.get("extraCssText")||"")+";pointer-events:"+(this._enterable?"auto":"none"):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},t.prototype.setContent=function(t,e,n,i,r){var o=this.el;if(null!=t){var a="";if(ii(r)&&"item"===n.get("trigger")&&!qk(n)&&(a=function(t,e,n){if(!ii(n)||"inside"===n)return"";var i=t.get("backgroundColor"),r=t.get("borderWidth");e=lg(e);var o,a,s="left"===(o=n)?"right":"right"===o?"left":"top"===o?"bottom":"top",l=Math.max(1.5*Math.round(r),6),c="",d=Kk+":";Un(["left","right"],s)>-1?(c+="top:50%",d+="translateY(-50%) rotate("+(a="left"===s?-225:-45)+"deg)"):(c+="left:50%",d+="translateX(-50%) rotate("+(a="top"===s?225:45)+"deg)");var u=a*Math.PI/180,h=l+r,p=h*Math.abs(Math.cos(u))+h*Math.abs(Math.sin(u)),f=e+" solid "+r+"px;";return'
'}(n,i,r)),ii(t))o.innerHTML=t+a;else if(t){o.innerHTML="",ei(t)||(t=[t]);for(var s=0;s=0?this._tryShow(n,i):"leave"===e&&this._hide(i))},this))},e.prototype._keepShow=function(){var t=this._tooltipModel,e=this._ecModel,n=this._api,i=t.get("triggerOn");if(null!=this._lastX&&null!=this._lastY&&"none"!==i&&"click"!==i){var r=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!n.isDisposed()&&r.manuallyShowTip(t,e,n,{x:r._lastX,y:r._lastY,dataByCoordSys:r._lastDataByCoordSys})})}},e.prototype.manuallyShowTip=function(t,e,n,i){if(i.from!==this.uid&&!bn.node&&n.getDom()){var r=dT(i,n);this._ticket="";var o=i.dataByCoordSys,a=function(t,e,n){var i=Il(t).queryOptionMap,r=i.keys()[0];if(!r||"series"===r)return;var o=Dl(e,r,i.get(r),{useDefault:!1,enableAll:!1,enableNone:!1}),a=o.models[0];if(!a)return;var s,l=n.getViewOfComponentModel(a);if(l.group.traverse(function(e){var n=Xd(e).tooltipConfig;if(n&&n.name===t.name)return s=e,!0}),s)return{componentMainType:r,componentIndex:a.componentIndex,el:s}}(i,e,n);if(a){var s=a.el.getBoundingRect().clone();s.applyTransform(a.el.transform),this._tryShow({offsetX:s.x+s.width/2,offsetY:s.y+s.height/2,target:a.el,position:i.position,positionDefault:"bottom"},r)}else if(i.tooltip&&null!=i.x&&null!=i.y){var l=sT;l.x=i.x,l.y=i.y,l.update(),Xd(l).tooltipConfig={name:null,option:i.tooltip},this._tryShow({offsetX:i.x,offsetY:i.y,target:l},r)}else if(o)this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,dataByCoordSys:o,tooltipOption:i.tooltipOption},r);else if(null!=i.seriesIndex){if(this._manuallyAxisShowTip(t,e,n,i))return;var c=Ok(i,e),d=c.point[0],u=c.point[1];null!=d&&null!=u&&this._tryShow({offsetX:d,offsetY:u,target:c.el,position:i.position,positionDefault:"bottom"},r)}else null!=i.x&&null!=i.y&&(n.dispatchAction({type:"updateAxisPointer",x:i.x,y:i.y}),this._tryShow({offsetX:i.x,offsetY:i.y,position:i.position,target:n.getZr().findHover(i.x,i.y).target},r))}},e.prototype.manuallyHideTip=function(t,e,n,i){var r=this._tooltipContent;this._tooltipModel&&r.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,i.from!==this.uid&&this._hide(dT(i,n))},e.prototype._manuallyAxisShowTip=function(t,e,n,i){var r=i.seriesIndex,o=i.dataIndex,a=e.getComponent("axisPointer").coordSysAxesInfo;if(null!=r&&null!=o&&null!=a){var s=e.getSeriesByIndex(r);if(s)if("axis"===cT([s.getData().getItemModel(o),s,(s.coordinateSystem||{}).model],this._tooltipModel).get("trigger"))return n.dispatchAction({type:"updateAxisPointer",seriesIndex:r,dataIndex:o,position:i.position}),!0}},e.prototype._tryShow=function(t,e){var n=t.target;if(this._tooltipModel){this._lastX=t.offsetX,this._lastY=t.offsetY;var i=t.dataByCoordSys;if(i&&i.length)this._showAxisTooltip(i,t);else if(n){var r,o;if("legend"===Xd(n).ssrType)return;this._lastDataByCoordSys=null,p_(n,function(t){if(t.tooltipDisabled)return r=o=null,!0;r||o||(null!=Xd(t).dataIndex?r=t:null!=Xd(t).tooltipConfig&&(o=t))},!0),r?this._showSeriesItemTooltip(t,r,e):o?this._showComponentItemTooltip(t,o,e):this._hide(e)}else this._lastDataByCoordSys=null,this._hide(e)}},e.prototype._showOrMove=function(t,e){var n=t.get("showDelay");e=Jn(e,this),clearTimeout(this._showTimout),n>0?this._showTimout=setTimeout(e,n):e()},e.prototype._showAxisTooltip=function(t,e){var n=this._ecModel,i=this._tooltipModel,r=[e.offsetX,e.offsetY],o=cT([e.tooltipOption],i),a=this._renderMode,s=[],l=qm("section",{blocks:[],noHeader:!0}),c=[],d=new ny;Xn(t,function(t){Xn(t.dataByAxis,function(t){var e=n.getComponent(t.axisDim+"Axis",t.axisIndex),r=t.value;if(e&&null!=r){var o=wk(r,e.axis,n,t.seriesDataIndices,t.valueLabelOpt),u=qm("section",{header:o,noHeader:!yi(o),sortBlocks:!0,blocks:[]});l.blocks.push(u),Xn(t.seriesDataIndices,function(l){var h=n.getSeriesByIndex(l.seriesIndex),p=l.dataIndexInside,f=h.getDataParams(p);if(!(f.dataIndex<0)){f.axisDim=t.axisDim,f.axisIndex=t.axisIndex,f.axisType=t.axisType,f.axisId=t.axisId,f.axisValue=zw(e.axis,{value:r}),f.axisValueLabel=o,f.marker=d.makeTooltipMarker("item",lg(f.color),a);var g=pm(h.formatTooltip(p,!0,null)),v=g.frag;if(v){var m=cT([h],i).get("valueFormatter");u.blocks.push(m?Wn({valueFormatter:m},v):v)}g.text&&c.push(g.text),s.push(f)}})}})}),l.blocks.reverse(),c.reverse();var u=e.position,h=o.get("order"),p=Qm(l,d,a,h,n.get("useUTC"),o.get("textStyle"));p&&c.unshift(p);var f="richText"===a?"\n\n":"
",g=c.join(f);this._showOrMove(o,function(){this._updateContentNotChangedOnAxis(t,s)?this._updatePosition(o,u,r[0],r[1],this._tooltipContent,s):this._showTooltipContent(o,g,s,Math.random()+"",r[0],r[1],u,null,d)})},e.prototype._showSeriesItemTooltip=function(t,e,n){var i=this._ecModel,r=Xd(e),o=r.seriesIndex,a=i.getSeriesByIndex(o),s=r.dataModel||a,l=r.dataIndex,c=r.dataType,d=s.getData(c),u=this._renderMode,h=t.positionDefault,p=cT([d.getItemModel(l),s,a&&(a.coordinateSystem||{}).model],this._tooltipModel,h?{position:h}:null),f=p.get("trigger");if(null==f||"item"===f){var g=s.getDataParams(l,c),v=new ny;g.marker=v.makeTooltipMarker("item",lg(g.color),u);var m=pm(s.formatTooltip(l,!1,c)),y=p.get("order"),_=p.get("valueFormatter"),b=m.frag,x=b?Qm(_?Wn({valueFormatter:_},b):b,v,u,y,i.get("useUTC"),p.get("textStyle")):m.text,w="item_"+s.name+"_"+l;this._showOrMove(p,function(){this._showTooltipContent(p,x,g,w,t.offsetX,t.offsetY,t.position,t.target,v)}),n({type:"showTip",dataIndexInside:l,dataIndex:d.getRawIndex(l),seriesIndex:o,from:this.uid})}},e.prototype._showComponentItemTooltip=function(t,e,n){var i="html"===this._renderMode,r=Xd(e),o=r.tooltipConfig.option||{},a=o.encodeHTMLContent;if(ii(o)){o={content:o,formatter:o},a=!0}a&&i&&o.content&&((o=Bn(o)).content=tr(o.content));var s=[o],l=this._ecModel.getComponent(r.componentMainType,r.componentIndex);l&&s.push(l),s.push({formatter:o.content});var c=t.positionDefault,d=cT(s,this._tooltipModel,c?{position:c}:null),u=d.get("content"),h=Math.random()+"",p=new ny;this._showOrMove(d,function(){var n=Bn(d.get("formatterParams")||{});this._showTooltipContent(d,u,n,h,t.offsetX,t.offsetY,t.position,e,p)}),n({type:"showTip",from:this.uid})},e.prototype._showTooltipContent=function(t,e,n,i,r,o,a,s,l){if(this._ticket="",t.get("showContent")&&t.get("show")){var c=this._tooltipContent;c.setEnterable(t.get("enterable"));var d=t.get("formatter");a=a||t.get("position");var u=e,h=this._getNearestPoint([r,o],n,t.get("trigger"),t.get("borderColor"),t.get("defaultBorderColor",!0)).color;if(d)if(ii(d)){var p=t.ecModel.get("useUTC"),f=ei(n)?n[0]:n;u=d,f&&f.axisType&&f.axisType.indexOf("time")>=0&&(u=Rf(f.axisValue,u,p)),u=sg(u,n,!0)}else if(ni(d)){var g=Jn(function(e,i){e===this._ticket&&(c.setContent(i,l,t,h,a),this._updatePosition(t,a,r,o,c,n,s))},this);this._ticket=i,u=d(n,i,g)}else u=d;c.setContent(u,l,t,h,a),c.show(t,h),this._updatePosition(t,a,r,o,c,n,s)}},e.prototype._getNearestPoint=function(t,e,n,i,r){return"axis"===n||ei(e)?{color:i||r}:ei(e)?void 0:{color:i||e.color||e.borderColor}},e.prototype._updatePosition=function(t,e,n,i,r,o,a){var s=this._api.getWidth(),l=this._api.getHeight();e=e||t.get("position");var c=r.getSize(),d=t.get("align"),u=t.get("verticalAlign"),h=a&&a.getBoundingRect().clone();if(a&&h.applyTransform(a.transform),ni(e)&&(e=e([n,i],o,r.el,h,{viewSize:[s,l],contentSize:c.slice()})),ei(e))n=js(e[0],s),i=js(e[1],l);else if(ai(e)){var p=e;p.width=c[0],p.height=c[1];var f=Sg(p,{width:s,height:l});n=f.x,i=f.y,d=null,u=null}else if(ii(e)&&a){var g=function(t,e,n,i){var r=n[0],o=n[1],a=Math.ceil(Math.SQRT2*i)+8,s=0,l=0,c=e.width,d=e.height;switch(t){case"inside":s=e.x+c/2-r/2,l=e.y+d/2-o/2;break;case"top":s=e.x+c/2-r/2,l=e.y-o-a;break;case"bottom":s=e.x+c/2-r/2,l=e.y+d+a;break;case"left":s=e.x-r-a,l=e.y+d/2-o/2;break;case"right":s=e.x+c+a,l=e.y+d/2-o/2}return[s,l]}(e,h,c,t.get("borderWidth"));n=g[0],i=g[1]}else{g=function(t,e,n,i,r,o,a){var s=n.getSize(),l=s[0],c=s[1];null!=o&&(t+l+o+2>i?t-=l+o:t+=o);null!=a&&(e+c+a>r?e-=c+a:e+=a);return[t,e]}(n,i,r,s,l,d?null:20,u?null:20);n=g[0],i=g[1]}if(d&&(n-=uT(d)?c[0]/2:"right"===d?c[0]:0),u&&(i-=uT(u)?c[1]/2:"bottom"===u?c[1]:0),qk(t)){g=function(t,e,n,i,r){var o=n.getSize(),a=o[0],s=o[1];return t=Math.min(t+a,i)-a,e=Math.min(e+s,r)-s,t=Math.max(t,0),e=Math.max(e,0),[t,e]}(n,i,r,s,l);n=g[0],i=g[1]}r.moveTo(n,i)},e.prototype._updateContentNotChangedOnAxis=function(t,e){var n=this._lastDataByCoordSys,i=this._cbParamsList,r=!!n&&n.length===t.length;return r&&Xn(n,function(n,o){var a=n.dataByAxis||[],s=(t[o]||{}).dataByAxis||[];(r=r&&a.length===s.length)&&Xn(a,function(t,n){var o=s[n]||{},a=t.seriesDataIndices||[],l=o.seriesDataIndices||[];(r=r&&t.value===o.value&&t.axisType===o.axisType&&t.axisId===o.axisId&&a.length===l.length)&&Xn(a,function(t,e){var n=l[e];r=r&&t.seriesIndex===n.seriesIndex&&t.dataIndex===n.dataIndex}),i&&Xn(t.seriesDataIndices,function(t){var n=t.seriesIndex,o=e[n],a=i[n];o&&a&&a.data!==o.data&&(r=!1)})})}),this._lastDataByCoordSys=t,this._cbParamsList=e,!!r},e.prototype._hide=function(t){this._lastDataByCoordSys=null,t({type:"hideTip",from:this.uid})},e.prototype.dispose=function(t,e){!bn.node&&e.getDom()&&(Ay(this,"_updatePosition"),this._tooltipContent.dispose(),zk("itemTooltip",e))},e.type="tooltip",e}(fy);function cT(t,e,n){var i,r=e.ecModel;n?(i=new hf(n,r,r),i=new hf(e.option,i,r)):i=e;for(var o=t.length-1;o>=0;o--){var a=t[o];a&&(a instanceof hf&&(a=a.get("tooltip",!0)),ii(a)&&(a={formatter:a}),a&&(i=new hf(a,i,r)))}return i}function dT(t,e){return t.dispatchAction||Jn(e.dispatchAction,e)}function uT(t){return"center"===t||"middle"===t}var hT=Math.sin,pT=Math.cos,fT=Math.PI,gT=2*Math.PI,vT=180/fT,mT=function(){function t(){}return t.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},t.prototype.moveTo=function(t,e){this._add("M",t,e)},t.prototype.lineTo=function(t,e){this._add("L",t,e)},t.prototype.bezierCurveTo=function(t,e,n,i,r,o){this._add("C",t,e,n,i,r,o)},t.prototype.quadraticCurveTo=function(t,e,n,i){this._add("Q",t,e,n,i)},t.prototype.arc=function(t,e,n,i,r,o){this.ellipse(t,e,n,n,0,i,r,o)},t.prototype.ellipse=function(t,e,n,i,r,o,a,s){var l=a-o,c=!s,d=Math.abs(l),u=ra(d-gT)||(c?l>=gT:-l>=gT),h=l>0?l%gT:l%gT+gT,p=!1;p=!!u||!ra(d)&&h>=fT==!!c;var f=t+n*pT(o),g=e+i*hT(o);this._start&&this._add("M",f,g);var v=Math.round(r*vT);if(u){var m=1/this._p,y=(c?1:-1)*(gT-m);this._add("A",n,i,v,1,+c,t+n*pT(o+y),e+i*hT(o+y)),m>.01&&this._add("A",n,i,v,0,+c,f,g)}else{var _=t+n*pT(a),b=e+i*hT(a);this._add("A",n,i,v,+p,+c,_,b)}},t.prototype.rect=function(t,e,n,i){this._add("M",t,e),this._add("l",n,0),this._add("l",0,i),this._add("l",-n,0),this._add("Z")},t.prototype.closePath=function(){this._d.length>0&&this._add("Z")},t.prototype._add=function(t,e,n,i,r,o,a,s,l){for(var c=[],d=this._p,u=1;u"}(r,o)+("style"!==r?tr(a):a||"")+(i?""+n+Yn(i,function(e){return t(e)}).join(n)+n:"")+("")}(t)}function AT(t){return{zrId:t,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function DT(t,e,n,i){return MT("svg","root",{width:t,height:e,xmlns:ST,"xmlns:xlink":CT,version:"1.1",baseProfile:"full",viewBox:!!i&&"0 0 "+t+" "+e},n)}var PT=0;function LT(){return PT++}var ET={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},zT="transform-origin";function NT(t,e,n){var i=Wn({},t.shape);Wn(i,e),t.buildPath(n,i);var r=new mT;return r.reset(fa(t)),n.rebuildPath(r,1),r.generateStr(),r.getStr()}function OT(t,e){var n=e.originX,i=e.originY;(n||i)&&(t[zT]=n+"px "+i+"px")}var $T={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function RT(t,e){var n=e.zrId+"-ani-"+e.cssAnimIdx++;return e.cssAnims[n]=t,n}function HT(t){return ii(t)?ET[t]?"cubic-bezier("+ET[t]+")":Do(t)?t:"":""}function FT(t,e,n,i){var r=t.animators,o=r.length,a=[];if(t instanceof Bh){var s=function(t,e,n){var i,r,o=t.shape.paths,a={};if(Xn(o,function(t){var e=AT(n.zrId);e.animation=!0,FT(t,{},e,!0);var o=e.cssAnims,s=e.cssNodes,l=Qn(o),c=l.length;if(c){var d=o[r=l[c-1]];for(var u in d){var h=d[u];a[u]=a[u]||{d:""},a[u].d+=h.d||""}for(var p in s){var f=s[p].animation;f.indexOf(r)>=0&&(i=f)}}}),i){e.d=!1;var s=RT(a,n);return i.replace(r,s)}}(t,e,n);if(s)a.push(s);else if(!o)return}else if(!o)return;for(var l={},c=0;c0}).length)return RT(d,n)+" "+r[0]+" both"}for(var v in l){(s=g(l[v]))&&a.push(s)}if(a.length){var m=n.zrId+"-cls-"+LT();n.cssNodes["."+m]={animation:a.join(",")},e.class=m}}function BT(t,e,n,i){var r=JSON.stringify(t),o=n.cssStyleCache[r];o||(o=n.zrId+"-cls-"+LT(),n.cssStyleCache[r]=o,n.cssNodes["."+o+":hover"]=t),e.class=e.class?e.class+" "+o:o}var VT=Math.round;function WT(t){return t&&ii(t.src)}function GT(t){return t&&ni(t.toDataURL)}function UT(t,e,n,i){wT(function(r,o){var a="fill"===r||"stroke"===r;a&&ha(o)?nM(e,t,r,i):a&&ca(o)?iM(n,t,r,i):t[r]=o,a&&i.ssr&&"none"===o&&(t["pointer-events"]="visible")},e,n,!1),function(t,e,n){var i=t.style;if(function(t){return t&&(t.shadowBlur||t.shadowOffsetX||t.shadowOffsetY)}(i)){var r=function(t){var e=t.style,n=t.getGlobalScale();return[e.shadowColor,(e.shadowBlur||0).toFixed(2),(e.shadowOffsetX||0).toFixed(2),(e.shadowOffsetY||0).toFixed(2),n[0],n[1]].join(",")}(t),o=n.shadowCache,a=o[r];if(!a){var s=t.getGlobalScale(),l=s[0],c=s[1];if(!l||!c)return;var d=i.shadowOffsetX||0,u=i.shadowOffsetY||0,h=i.shadowBlur,p=na(i.shadowColor),f=p.opacity,g=p.color,v=h/2/l+" "+h/2/c;a=n.zrId+"-s"+n.shadowIdx++,n.defs[a]=MT("filter",a,{id:a,x:"-100%",y:"-100%",width:"300%",height:"300%"},[MT("feDropShadow","",{dx:d/l,dy:u/c,stdDeviation:v,"flood-color":g,"flood-opacity":f})]),o[r]=a}e.filter=pa(a)}}(n,t,i)}function qT(t,e){var n=function(t){if("function"==typeof Hs)return Hs(t)}(e);n&&(n.each(function(e,n){null!=e&&(t[(kT+n).toLowerCase()]=e+"")}),e.isSilent()&&(t[kT+"silent"]="true"))}function jT(t){return ra(t[0]-1)&&ra(t[1])&&ra(t[2])&&ra(t[3]-1)}function XT(t,e,n){if(e&&(!function(t){return ra(t[4])&&ra(t[5])}(e)||!jT(e))){var i=1e4;t.transform=jT(e)?"translate("+VT(e[4]*i)/i+" "+VT(e[5]*i)/i+")":function(t){return"matrix("+oa(t[0])+","+oa(t[1])+","+oa(t[2])+","+oa(t[3])+","+aa(t[4])+","+aa(t[5])+")"}(e)}}function YT(t,e,n){for(var i=t.points,r=[],o=0;o=0&&a||o;s&&(r=ta(s))}var l=i.lineWidth;l&&(l/=!i.strokeNoScale&&t.transform?t.transform[0]:1);var c={cursor:"pointer"};r&&(c.fill=r),i.stroke&&(c.stroke=i.stroke),l&&(c["stroke-width"]=l),BT(c,e,n)}}(t,o,e),MT(s,t.id+"",o)}function eM(t,e){return t instanceof _d?tM(t,e):t instanceof Cd?function(t,e){var n=t.style,i=n.image;if(i&&!ii(i)&&(WT(i)?i=i.src:GT(i)&&(i=i.toDataURL())),i){var r=n.x||0,o=n.y||0,a={href:i,width:n.width,height:n.height};return r&&(a.x=r),o&&(a.y=o),XT(a,t.transform),UT(a,n,t,e),qT(a,t),e.animation&&FT(t,a,e),MT("image",t.id+"",a)}}(t,e):t instanceof xd?function(t,e){var n=t.style,i=n.text;if(null!=i&&(i+=""),i&&!isNaN(n.x)&&!isNaN(n.y)){var r=n.font||wn,o=n.x||0,a=function(t,e,n){return"top"===n?t+=e/2:"bottom"===n&&(t-=e/2),t}(n.y||0,ws(r),n.textBaseline),s={"dominant-baseline":"central","text-anchor":sa[n.textAlign]||n.textAlign};if(Bd(n)){var l="",c=n.fontStyle,d=Hd(n.fontSize);if(!parseFloat(d))return;var u=n.fontFamily||xn,h=n.fontWeight;l+="font-size:"+d+";font-family:"+u+";",c&&"normal"!==c&&(l+="font-style:"+c+";"),h&&"normal"!==h&&(l+="font-weight:"+h+";"),s.style=l}else s.style="font: "+r;return i.match(/\s/)&&(s["xml:space"]="preserve"),o&&(s.x=o),a&&(s.y=a),XT(s,t.transform),UT(s,n,t,e),qT(s,t),e.animation&&FT(t,s,e),MT("text",t.id+"",s,void 0,i)}}(t,e):void 0}function nM(t,e,n,i){var r,o=t[n],a={gradientUnits:o.global?"userSpaceOnUse":"objectBoundingBox"};if(da(o))r="linearGradient",a.x1=o.x,a.y1=o.y,a.x2=o.x2,a.y2=o.y2;else{if(!ua(o))return;r="radialGradient",a.cx=pi(o.x,.5),a.cy=pi(o.y,.5),a.r=pi(o.r,.5)}for(var s=o.colorStops,l=[],c=0,d=s.length;cl?yM(t,null==n[u+1]?null:n[u+1].elm,n,s,u):_M(t,e,a,l))}(n,i,r):fM(r)?(fM(t.text)&&uM(n,""),yM(n,null,r,0,r.length-1)):fM(i)?_M(n,i,0,i.length-1):fM(t.text)&&uM(n,""):t.text!==e.text&&(fM(i)&&_M(n,i,0,i.length-1),uM(n,e.text)))}var wM=0,SM=function(){function t(t,e,n){if(this.type="svg",this.refreshHover=function(){},this.configLayer=function(){},this.storage=e,this._opts=n=Wn({},n),this.root=t,this._id="zr"+wM++,this._oldVNode=DT(n.width,n.height),t&&!n.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var r=this._svgDom=this._oldVNode.elm=TT("svg");bM(null,this._oldVNode),i.appendChild(r),t.appendChild(i)}this.resize(n.width,n.height)}return t.prototype.getType=function(){return this.type},t.prototype.getViewportRoot=function(){return this._viewport},t.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},t.prototype.getSvgDom=function(){return this._svgDom},t.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",function(t,e){if(vM(t,e))xM(t,e);else{var n=t.elm,i=cM(n);mM(e),null!==i&&(aM(i,e.elm,dM(n)),_M(i,[t],0,0))}}(this._oldVNode,t),this._oldVNode=t}},t.prototype.renderOneToVNode=function(t){return eM(t,AT(this._id))},t.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),n=this._width,i=this._height,r=AT(this._id);r.animation=t.animation,r.willUpdate=t.willUpdate,r.compress=t.compress,r.emphasis=t.emphasis,r.ssr=this._opts.ssr;var o=[],a=this._bgVNode=function(t,e,n,i){var r;if(n&&"none"!==n)if(r=MT("rect","bg",{width:t,height:e,x:"0",y:"0"}),ha(n))nM({fill:n},r.attrs,"fill",i);else if(ca(n))iM({style:{fill:n},dirty:Ii,getBoundingRect:function(){return{width:t,height:e}}},r.attrs,"fill",i);else{var o=na(n),a=o.color,s=o.opacity;r.attrs.fill=a,s<1&&(r.attrs["fill-opacity"]=s)}return r}(n,i,this._backgroundColor,r);a&&o.push(a);var s=t.compress?null:this._mainVNode=MT("g","main",{},[]);this._paintList(e,r,s?s.children:o),s&&o.push(s);var l=Yn(Qn(r.defs),function(t){return r.defs[t]});if(l.length&&o.push(MT("defs","defs",{},l)),t.animation){var c=function(t,e,n){var i=(n=n||{}).newline?"\n":"",r=" {"+i,o=i+"}",a=Yn(Qn(t),function(e){return e+r+Yn(Qn(t[e]),function(n){return n+":"+t[e][n]+";"}).join(i)+o}).join(i),s=Yn(Qn(e),function(t){return"@keyframes "+t+r+Yn(Qn(e[t]),function(n){return n+r+Yn(Qn(e[t][n]),function(i){var r=e[t][n][i];return"d"===i&&(r='path("'+r+'")'),i+":"+r+";"}).join(i)+o}).join(i)+o}).join(i);return a||s?[""].join(i):""}(r.cssNodes,r.cssAnims,{newline:!0});if(c){var d=MT("style","stl",{},[],c);o.push(d)}}return DT(n,i,o,t.useViewBox)},t.prototype.renderToString=function(t){return t=t||{},IT(this.renderToVNode({animation:pi(t.cssAnimation,!0),emphasis:pi(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:pi(t.useViewBox,!0)}),{newline:!0})},t.prototype.setBackgroundColor=function(t){this._backgroundColor=t},t.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},t.prototype._paintList=function(t,e,n){for(var i,r,o=t.length,a=[],s=0,l=0,c=0;c=0&&(!u||!r||u[f]!==r[f]);f--);for(var g=p-1;g>f;g--)i=a[--s-1];for(var v=f+1;v10&&"cartesian2d"===o.type&&r){var s=o.getBaseAxis(),l=o.getOtherAxis(s),c=s.getExtent(),d=n.getDevicePixelRatio(),u=Math.abs(c[1]-c[0])*(d||1),h=Math.round(a/u);if(isFinite(h)&&h>1){"lttb"===r?t.setData(i.lttbDownSample(i.mapDimension(l.dim),1/h)):"minmax"===r&&t.setData(i.minmaxDownSample(i.mapDimension(l.dim),1/h));var p=void 0;ii(r)?p=ZS[r]:ni(r)&&(p=r),p&&t.setData(i.downSample(i.mapDimension(l.dim),1/h,p,KS))}}}}}("line"))},function(t){Gw(uk),Gw(Gk)},function(t){Gw(Gk),t.registerComponentModel(Uk),t.registerComponentView(lT),t.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},Ii),t.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},Ii)},function(t){t.registerPainter("svg",SM)}]);class CM extends Dt{constructor(){super(...arguments),this.options=null,this.data=[],this.height="120px",this._chart=null,this._resizeObserver=null}render(){return ut`
`}connectedCallback(){super.connectedCallback(),this.hasUpdated&&!this._chart&&this.options&&(this._mount(),this._observeResize())}firstUpdated(){this._mount(),this._observeResize()}updated(t){(t.has("options")||t.has("data"))&&(this._chart&&this.options?this._chart.setOption(this._mergedOptions(),!0):!this._chart&&this.options&&this._mount()),t.has("height")&&this._chart&&this._chart.resize()}disconnectedCallback(){this._destroy(),super.disconnectedCallback()}_mount(){if(!this.options)return;const t=this.shadowRoot?.querySelector(".chart-host");t&&(this._chart=Zb(t,void 0,{renderer:"svg"}),this._chart.setOption(this._mergedOptions(),!0))}_mergedOptions(){if(!this.options)throw new Error("span-chart: options not set");return{...this.options,series:this.data}}_observeResize(){if("undefined"==typeof ResizeObserver)return;this._resizeObserver=new ResizeObserver(()=>{this._chart&&this._chart.resize()});const t=this.shadowRoot?.querySelector(".chart-host");t&&this._resizeObserver.observe(t)}_destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._chart&&(this._chart.dispose(),this._chart=null)}}CM.styles=M` :host { display: block; width: 100%; @@ -233,7 +233,7 @@ var ps={},fs={};var gs,vs=function(){function t(t,e,n){var i=this;this._sleepAft width: 100%; height: 100%; } - `,_([Et({attribute:!1})],tT.prototype,"options",void 0),_([Et({attribute:!1})],tT.prototype,"data",void 0),_([Et({type:String})],tT.prototype,"height",void 0);try{customElements.get("span-chart")||customElements.define("span-chart",tT)}catch{}function eT(t,e,n,i,o,a,s,l,c){const{options:u,series:h}=function(t,e,n,i,o,a=!1){n||(n=f[r]);const s=i?"140, 160, 220":"77, 217, 175",l=`rgb(${s})`,c=Date.now(),u=c-e,h=void 0!==n.fixedMin&&void 0!==n.fixedMax,d=(t??[]).filter(t=>t.time>=u).map(t=>[t.time,Math.abs(t.value)]),p=[{type:"line",data:d,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${s}, 0.18)`},{offset:1,color:`rgba(${s}, 0.18)`}]}},itemStyle:{color:l}}],g=d.length>0?function(t){let e=0;for(const n of t)n[1]>e&&(e=n[1]);return e}(d):0,v={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:g<10?t=>0===t?"0":t.toFixed(1):t=>n.format(t)},splitLine:{lineStyle:{opacity:.15}}};h?(v.min=n.fixedMin,v.max=n.fixedMax):g<1&&(v.min=0,v.max=1),o&&"current"===n.entityRole&&(v.min=0,v.max=Math.ceil(1.25*o),p.push({type:"line",data:[[u,.8*o],[c,.8*o]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),p.push({type:"line",data:[[u,o],[c,o]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:u,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:v,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:t=>{if(!t||0===t.length)return"";const e=t[0],i=new Date(e.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),r=parseFloat(e.value[1].toFixed(2));return`
${i}
${n.format(r)} ${n.unit(r)}
`}},animation:!1};return{options:m,series:p}}(n,i,o,a,l,c),d=s??120;t.style.minHeight=d+"px";let p=t.querySelector("span-chart");p||(p=document.createElement("span-chart"),p.style.display="block",p.style.width="100%",t.innerHTML="",t.appendChild(p));const g=t.clientHeight;p.height=(g>0?g:d)+"px",p.options=u,p.data=h}function nT(t){return"function"==typeof globalThis.CSS?.escape?CSS.escape(t):t.replace(/["\\]/g,"\\$&")}function iT(t,e,n,i,r){const o="current"===(i.chart_metric||"power"),a=t.querySelector(".stat-consumption .stat-value"),s=t.querySelector(".stat-consumption .stat-unit");if(o){const t=n.panel_entities?.site_power,i=t?e.states[t]:null,r=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(r)?Math.abs(r).toFixed(1):"--"),s&&(s.textContent="A")}else{let t=r;const i=n.panel_entities?.site_power;if(i){const n=e.states[i];n&&(t=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=fe(t)),s&&(s.textContent="kW")}const l=t.querySelector(".stat-upstream .stat-value"),c=t.querySelector(".stat-upstream .stat-unit");if(l){const t=n.panel_entities?.current_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",c&&(c.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=fe(t),c&&(c.textContent="kW")}}const u=t.querySelector(".stat-downstream .stat-value"),h=t.querySelector(".stat-downstream .stat-unit");if(u){const t=n.panel_entities?.feedthrough_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;u.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",h&&(h.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;u.textContent=fe(t),h&&(h.textContent="kW")}}const d=t.querySelector(".stat-solar .stat-value"),p=t.querySelector(".stat-solar .stat-unit");if(d){const t=n.panel_entities?.pv_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;d.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",p&&(p.textContent="A")}else{if(i){const t=Math.abs(parseFloat(i.state)||0);d.textContent=fe(t)}else d.textContent="--";p&&(p.textContent="kW")}}const f=t.querySelector(".stat-battery .stat-value");if(f){const t=n.panel_entities?.battery_level,i=t?e.states[t]:null;i&&(f.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const g=t.querySelector(".stat-grid-state .stat-value");if(g){const t=n.panel_entities?.dsm_state,i=t?e.states[t]:null;g.textContent=i?e.formatEntityState?.(i)||i.state:"--"}}function rT(t,e,i,r,o,a){if(!t||!i||!e)return;const s=$e(r);let u=0;for(const[,t]of Object.entries(i.circuits)){const n=t.entities?.power;if(!n)continue;const i=e.states[n],r=i&&parseFloat(i.state)||0;t.device_type!==c&&(u+=Math.abs(r))}!function(t,e,n,i,r){const o=t.querySelector(".panel-stats");o&&iT(o,e,n,i,r)}(t,e,i,r,u);const h=ye(r),d="current"===h.entityRole;for(const[r,u]of Object.entries(i.circuits)){const i=t.querySelector(`.circuit-slot[data-uuid="${nT(r)}"]`);if(!i)continue;const p=u.entities?.power,f=p?e.states[p]:null,g=f&&parseFloat(f.state)||0,m=u.device_type===c||g<0,y=u.entities?.switch,_=y?e.states[y]:null,b=_?"on"===_.state:(f?.attributes?.relay_state||u.relay_state)===l,x=i.querySelector(".power-value");if(x)if(d){const t=u.entities?.current,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;x.innerHTML=`${h.format(i)}A`}else x.innerHTML=`${pe(g)}${de(g)}`;const w=i.querySelector(".toggle-pill");if(w){w.className="toggle-pill "+(b?"toggle-on":"toggle-off");const t=w.querySelector(".toggle-label");t&&(t.textContent=n(b?"grid.on":"grid.off"))}let S;if(i.classList.toggle("circuit-off",!b),i.classList.toggle("circuit-producer",m),u.always_on)S="always_on";else{const t=u.entities?.select,n=t?e.states[t]:null;S=n?n.state:"unknown"}const C=v[S]??v.unknown,k=i.querySelector(".shedding-icon");k&&(k.setAttribute("icon",C.icon),k.style.color=C.color,k.title=C.label());const M=i.querySelector(".shedding-icon-secondary");M&&(C.icon2?(M.setAttribute("icon",C.icon2),M.style.color=C.color,M.style.display=""):M.style.display="none");const T=i.querySelector(".shedding-label");T&&(C.textLabel?(T.textContent=C.textLabel,T.style.color=C.color,T.style.display=""):T.style.display="none");const I=i.querySelector(".chart-container");if(I){const t=o.get(r)||[],e=i.classList.contains("circuit-col-span")?200:100,n=a?.has(r)?He(a.get(r)):s,l=u.device_type===c;eT(I,0,t,n,h,m,e,u.breaker_rating_a??void 0,l)}}}class oT{get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new qt(t):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(t,e){const i=Date.now();if(this._fetching)return this._settings;if(this._settings&&i-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const i={};e&&(i.config_entry_id=e);const r={type:"call_service",domain:s,service:"get_graph_settings",service_data:i,return_response:!0},o=this._retry?await this._retry.callWS(t,r,{errorId:"fetch:graph_settings",errorMessage:n("error.graph_settings_failed")}):await t.callWS(r);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:n("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function aT(t,e){if(!t)return o;const n=t.circuits?.[e];return n?.has_override?n.horizon:t.global_horizon??o}function sT(t,e){if(!t)return o;const n=t.sub_devices?.[e];return n?.has_override?n.horizon:t.global_horizon??o}class lT{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new be,this.monitoringMultiCache=new xe,this.graphSettingsCache=new oT,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this.monitoringCache.errorStore=t,this.graphSettingsCache.errorStore=t,this.monitoringMultiCache.errorStore=t}get hass(){return this._hass}set hass(t){this._hass=t}get topology(){return this._topology}get config(){return this._config}set showMonitoring(t){this._showMonitoring=t}init(t,e,n,i){this._topology=t,this._config=e,this._hass=n,this._configEntryId=i}setFavoriteRefs(t){this._favRefs=t}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(t){this._panelFavorites=t}setFavoritesPerPanelInfo(t){this._perPanelInfo=t??new Map}get _inFavoritesView(){return null!==this._favRefs}setConfig(t){this._config=t}buildHorizonMaps(t){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),t&&this._topology?.circuits)for(const e of Object.keys(this._topology.circuits))this.horizonMap.set(e,aT(t,e));if(t&&this._topology?.sub_devices)for(const e of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(e,sT(t,e))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:n("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(t){if(!this._hass||0===t.length)return null;const e=this._hass;return function(t){let e=!1;const n={},i={};for(const r of t)r&&(e=!0,r.circuits&&Object.assign(n,r.circuits),r.mains&&Object.assign(i,r.mains));return e?{circuits:n,mains:i}:null}(await Promise.all(t.map(t=>this.monitoringMultiCache.fetchOne(e,t))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const t=new Set;for(const e of Object.values(this._favRefs))e.configEntryId&&t.add(e.configEntryId);const e=new Map;await Promise.all(Array.from(t).map(async t=>{e.set(t,await this._fetchGraphSettingsFresh(t))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const t of Object.keys(this._topology.circuits)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.horizonMap.set(t,aT(i,r))}if(this._topology.sub_devices)for(const t of Object.keys(this._topology.sub_devices)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.subDeviceHorizonMap.set(t,sT(i,r))}}async loadHistory(){await je(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const t=Date.now();for(const[e,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(e)??o;if(!a[i]?.useRealtime)continue;const r=_e(n,this._config);if(!r)continue;const s=this._hass.states[r];if(!s)continue;const l=parseFloat(s.state);if(isNaN(l))continue;const c=He(i),u=Fe(c),h=Be(c),d=t-c,p=this.powerHistory.get(e)??[];p.length>0&&t-p[p.length-1].time0&&t-p[p.length-1].time0&&this._topology)for(const{key:t,devId:e}of qe(this._topology))i.has(e)&&r.add(t);const o=new Map;try{await je(this._hass,this._topology,this._config,o,e,i);for(const t of e.keys()){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}for(const t of r){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}this.updateDOM(t)}catch(t){console.warn("SPAN Panel: history refresh failed",t),this._errorStore?.add({key:"fetch:history",level:"warning",message:n("error.history_failed"),persistent:!1})}}updateDOM(t){this._hass&&this._topology&&this._config&&(rT(t,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(t,e,n,i,r,o){if(!n.sub_devices)return;const a=$e(i);for(const[i,s]of Object.entries(n.sub_devices)){const n=t.querySelector(`[data-subdev="${nT(i)}"]`);if(!n)continue;const l=Pe(s);if(l){const t=e.states[l],i=t&&parseFloat(t.state)||0,r=n.querySelector(".sub-power-value");r&&(r.innerHTML=`${pe(i)} ${de(i)}`)}const c=n.querySelectorAll("[data-chart-key]");for(const t of c){const e=t.dataset.chartKey;if(!e)continue;const n=r.get(e)||[];let s=g.power;e.endsWith("_soc")?s=g.soc:e.endsWith("_soe")&&(s=g.soe);const l=!!t.closest(".bess-chart-col");eT(t,0,n,o?.has(i)?He(o.get(i)):a,s,!1,l?120:150,void 0,e.endsWith("_soc")||e.endsWith("_soe"))}for(const t of Object.keys(s.entities||{})){const i=n.querySelector(`[data-eid="${nT(t)}"]`);if(!i)continue;const r=e.states[t];if(r){let t;if(e.formatEntityState)t=e.formatEntityState(r);else{t=r.state;const e=r.attributes.unit_of_measurement||"";e&&(t+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(t=(e/1e3).toFixed(1)+" kWh")}i.textContent=t}}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(t){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(t)}}onToggleClick(t,e){const i=t.target,r=i?.closest(".toggle-pill");if(!r)return;const o=e.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;t.stopPropagation(),t.preventDefault();const a=r.closest("[data-uuid]");if(!a||!this._topology||!this._hass)return;const s=a.dataset.uuid;if(!s)return;const l=this._topology.circuits[s];if(!l)return;const c=l.entities?.switch;if(!c)return;const u=this._hass.states[c];if(!u)return void console.warn("SPAN Panel: switch entity not found:",c);const h="on"===u.state?"turn_off":"turn_on";this._hass.callService("switch",h,{},{entity_id:c}).catch(t=>{console.warn("SPAN Panel: switch service call failed",t),this._errorStore?.add({key:"service:relay",level:"error",message:n("error.relay_failed"),persistent:!1})})}async onGearClick(t,e){const n=t.target,i=n?.closest(".gear-icon");if(!i)return;const r=e.querySelector("span-side-panel");if(!r||!this._hass)return;if(r.hass=this._hass,r.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const t=await this._buildFavoritesSections();if(0===t.length)return;return void r.open({favoritesMode:!0,perPanelSections:t})}return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void r.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const a=i.dataset.uuid;if(a&&this._topology){const t=this._topology.circuits[a];if(t){const e=this._favRefs?.[a]??null,n=e&&"circuit"===e.kind?e.targetId:a,i=e?.configEntryId??this._configEntryId;let s,l;e?[s,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),s=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=t.entities?.current??t.entities?.power,u=c?l?.circuits?.[c]??null:null,h=s?.global_horizon??o,d=s?.circuits?.[n],p=d?{...d,globalHorizon:h}:{horizon:h,has_override:!1,globalHorizon:h},f=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,g=null!==e||(this._panelFavorites?.circuitUuids.has(n)??!1),v=this._inFavoritesView||null!==this._panelFavorites;return void r.open({...t,uuid:n,monitoringInfo:u,showMonitoring:this._showMonitoring,graphHorizonInfo:p,showFavorites:v,favoritePanelDeviceId:f,isFavorite:g,configEntryId:i})}}const s=i.dataset.subdevId;if(s&&this._topology?.sub_devices?.[s]){const t=this._topology.sub_devices[s],e=this._favRefs?.[s]??null,n=e&&"sub_device"===e.kind?e.targetId:s,i=e?.configEntryId??this._configEntryId;let a;e?a=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),a=this.graphSettingsCache.settings);const l=a?.global_horizon??o,c=a?.sub_devices?.[n],u=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},h=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,d=null!==e||(this._panelFavorites?.subDeviceIds.has(n)??!1),p=this._inFavoritesView||null!==this._panelFavorites;r.open({subDeviceMode:!0,subDeviceId:n,name:t.name??n,deviceType:t.type??"",entities:t.entities,graphHorizonInfo:u,showFavorites:p,favoritePanelDeviceId:h,isFavorite:d,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const t=function(t,e){const n=new Map;for(const i of Object.values(t)){if("circuit"!==i.kind)continue;const t=e.get(i.panelDeviceId);if(void 0===t)continue;let r=n.get(i.panelDeviceId);void 0===r&&(r={panelDeviceId:i.panelDeviceId,panelName:t.panelName,topology:t.topology,configEntryId:t.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,r)),r.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((t,e)=>t.panelName.localeCompare(e.panelName))}(this._favRefs,this._perPanelInfo);if(0===t.length)return[];return await Promise.all(t.map(async t=>({panelDeviceId:t.panelDeviceId,panelName:t.panelName,topology:t.topology,graphSettings:await this._fetchGraphSettingsFresh(t.configEntryId),favoriteCircuitUuids:t.favoriteCircuitUuids,configEntryId:t.configEntryId})))}async _fetchGraphSettingsFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const i={type:"call_service",domain:s,service:"get_graph_settings",service_data:e,return_response:!0},r=this._errorStore?new qt(this._errorStore):null,o=r?await r.callWS(this._hass,i,{errorId:"fetch:graph_settings",errorMessage:n("error.graph_settings_failed")}):await this._hass.callWS(i);return o?.response??null}catch(t){return console.warn("SPAN Panel: fresh graph settings fetch failed",t),null}}async _fetchMonitoringStatusFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const i={type:"call_service",domain:s,service:"get_monitoring_status",service_data:e,return_response:!0},r=this._errorStore?new qt(this._errorStore):null,o=r?await r.callWS(this._hass,i,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await this._hass.callWS(i),a=o?.response;return a?{circuits:a.circuits,mains:a.mains}:null}catch(t){return console.warn("SPAN Panel: fresh monitoring status fetch failed",t),null}}bindSlideConfirm(t,e){const n=t.querySelector(".slide-confirm-knob"),i=t.querySelector(".slide-confirm-text");if(!n||!i)return;let r=!1,o=0,a=0;const s=e=>{t.classList.contains("confirmed")||(r=!0,o=e-n.offsetLeft,a=t.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=t=>{if(!r)return;const e=Math.max(2,Math.min(t-o,a));n.style.left=e+"px"},c=()=>{if(!r)return;r=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",t.classList.add("confirmed"),n.querySelector("span-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=t.dataset.textOn??"",e&&e.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",t=>{t.preventDefault(),s(t.clientX)}),t.addEventListener("mousemove",t=>l(t.clientX)),t.addEventListener("mouseup",c),t.addEventListener("mouseleave",c),n.addEventListener("touchstart",t=>{t.preventDefault(),s(t.touches[0].clientX)},{passive:!1}),t.addEventListener("touchmove",t=>l(t.touches[0].clientX),{passive:!0}),t.addEventListener("touchend",c),t.addEventListener("touchcancel",c),t.addEventListener("click",()=>{t.classList.contains("confirmed")&&(t.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("span-icon")?.setAttribute("icon","mdi:lock"),i.textContent=t.dataset.textOff??"",e&&e.classList.add("switches-disabled"))})}startIntervals(t,e){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(t),e&&e()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(t)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(t,e){this.cleanupResizeObserver(),e&&(this._lastWidth=e.clientWidth,this._resizeObserver=new ResizeObserver(e=>{const n=e[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const e of t.querySelectorAll(".chart-container")){const t=e.querySelector("span-chart");t&&t.remove()}this.updateDOM(t)},150))}),this._resizeObserver.observe(e))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function cT(t,e){const n=e.hysteresisPx??48,i=new WeakSet;let r=null;const o=t=>{const n=t.querySelector(e.nameSelector);return!!n&&(0!==t.clientWidth&&(n.clientWidth<1||n.scrollWidth>n.clientWidth+1))},a=()=>{const i=t.querySelectorAll(e.rowSelector);if(0===i.length)return;const a=i[0].clientWidth;if(0===a)return;if(null!==r){if(a>r+n){for(const t of i)t.classList.remove(e.foldClass);r=null}else for(const t of i)t.classList.add(e.foldClass);return}let s=!1;for(const t of i)if(o(t)){s=!0;break}if(s){for(const t of i)t.classList.add(e.foldClass);r=a}},s=new ResizeObserver(()=>a()),l=()=>{const n=t.querySelectorAll(e.rowSelector);for(const t of n)i.has(t)||(i.add(t),s.observe(t));requestAnimationFrame(()=>a())};l();const c=new MutationObserver(t=>{(t=>{const n=t=>{for(const n of t)if(n instanceof Element){if(n.matches(e.rowSelector))return!0;if(n.querySelector(e.rowSelector))return!0}return!1};for(const e of t)if(n(e.addedNodes)||n(e.removedNodes))return!0;return!1})(t)&&l()});return c.observe(t,{childList:!0,subtree:!0}),()=>{s.disconnect(),c.disconnect()}}const uT='\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n /* Card shell — replaces . Theme variables (--ha-card-*) are\n stable HA contracts (not the deprecated component APIs flagged by the\n 2026.4 frontend blog), so they stay in place to keep visual parity\n with the rest of HA\'s dashboards. */\n .span-card {\n display: block;\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */\n .favorites-summary {\n padding: 8px 24px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n align-items: center;\n gap: 12px;\n }\n /* Override the generic .gear-icon { margin-left: auto } rule so the\n favorites gear stays flush-left instead of floating to the right of\n the flex row (same idea as .panel-identity .panel-gear does for\n real-panel headers). */\n .favorites-summary .favorites-gear {\n margin-left: 0;\n }\n /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle.\n margin-left:auto moved here from .favorites-summary-unit-toggle so the\n legend and toggle cluster together, matching the real-panel header\n layout. */\n .favorites-summary-right {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .favorites-subdevices-section {\n padding: 8px 16px 0;\n }\n\n /* Favorites view: responsive grid of per-contributing-panel status cards. */\n .favorites-panel-stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n gap: 12px;\n padding: 12px 24px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .favorites-panel-card {\n background: var(--secondary-background-color, rgba(255, 255, 255, 0.04));\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n padding: 10px 14px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .favorites-panel-card-title {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--primary-text-color);\n opacity: 0.85;\n }\n .favorites-panel-card .panel-stats {\n gap: 10px 20px;\n }\n .favorites-panel-card .stat-value {\n font-size: 1.15em;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item span-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob span-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n /* Five columns: left tab label, left cell, explicit 8px spacer,\n right cell, right tab label. Spacer is in-band rather than a\n column-gap so we can keep inter-cell space without paying an\n equal gap between each cell and its tab label. The tab columns\n are sized to fit a 2-digit breaker number (the font is 0.85em\n of the panel body ≈ 14px glyph width). */\n grid-template-columns: 14px 1fr 8px 1fr 14px;\n column-gap: 0;\n row-gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 3px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n /* Truncation-driven fold for By Panel breaker cells. The .is-folded\n class is added/removed by the JS observer in\n src/core/truncation-fold.ts when the .circuit-name actually\n ellipsizes. Pixel thresholds can\'t get this right because name\n length varies wildly per circuit (e.g. "Spa" vs\n "Commissioned PV System") — only measuring the live name vs its\n container catches the exact moment of truncation.\n\n When folded the nested flex wrappers (.circuit-header,\n .circuit-info, .circuit-controls, .circuit-status) collapse via\n \'display: contents\' so the leaf elements participate directly in\n the outer grid: name gets the whole first row, readings/controls/\n gear drop to a second row, chart stays as the full-width third. */\n .circuit-slot.is-folded {\n display: grid;\n /* Columns: badges + relay-toggle pack tight on the left, slack\n absorbed by the 1fr column between the relay and the power\n reading, keeping power + gear pinned to the right edge. The\n previous layout placed the slack between the shedding icon and\n the relay, which read as wasted padding the user pointed out. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n /* Rows: name and controls sized to content; chart absorbs any\n extra cell height. Without the explicit 1fr on row 3, a tall\n cell (e.g. .circuit-col-span\'s 280px min-height for 240V\n double-pole breakers) distributes excess space equally across\n all three rows via the default align-content:stretch, which\n pushes the chart down and vertically inflates the badge and\n relay toggle to fill the controls row. */\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n "name name name name name name name"\n "badge util shed status . power gear"\n "chart chart chart chart chart chart chart";\n row-gap: 6px;\n column-gap: 8px;\n }\n .circuit-slot.is-folded > .circuit-header,\n .circuit-slot.is-folded > .circuit-status,\n .circuit-slot.is-folded > .circuit-header > .circuit-info,\n .circuit-slot.is-folded > .circuit-header > .circuit-controls {\n display: contents;\n }\n .circuit-slot.is-folded .circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .circuit-slot.is-folded .breaker-badge {\n grid-area: badge;\n }\n .circuit-slot.is-folded .utilization {\n grid-area: util;\n }\n .circuit-slot.is-folded .shedding-icon,\n .circuit-slot.is-folded .shedding-composite {\n grid-area: shed;\n }\n .circuit-slot.is-folded .toggle-pill {\n grid-area: status;\n justify-self: end;\n }\n .circuit-slot.is-folded .power-value {\n grid-area: power;\n justify-self: end;\n }\n .circuit-slot.is-folded .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n .circuit-slot.is-folded > .chart-container {\n grid-area: chart;\n }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n /* Each circuit is wrapped in a .list-cell so the row + its optional\n expanded chart stay together. In single-column flex mode the cell\n just stacks naturally. In multi-column grid mode the cell becomes\n one grid item, so the chart is always in the same column as its\n row. Area headers (rendered as siblings, not inside a cell) span\n all columns via their inline "grid-column: 1 / -1". */\n .list-cell {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: grid;\n grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr));\n gap: 6px 8px;\n flex-direction: initial;\n }\n /* On narrow viewports a 2/3-column list would squeeze rows into an\n unreadable shape, so force stacking regardless of user preference. */\n @media (max-width: 599px) {\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: flex;\n flex-direction: column;\n }\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n /* min-width: 0 lets the row shrink below the sum of its\n non-shrinking children when its parent .list-cell is in a\n narrow CSS-grid track (multi-column list mode). Without this\n the row would maintain its intrinsic min-content width and\n overflow the cell, leaving the name unshrunk and the\n truncation-fold observer with no signal to react to. */\n min-width: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n flex-shrink: 0;\n /* No min-width / text-align:right: the old 70px right-aligned\n cell left a visible blank column for short readings (e.g.\n "1.3A" in a 70px slot), which robbed horizontal space from\n .list-circuit-name on narrow rows. Let the value hug the\n preceding relay control and size to its content so the freed\n width flows back into the flex:1 name column. */\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n .list-row .gear-icon {\n background: transparent;\n border: none;\n padding: 2px;\n cursor: pointer;\n color: #555;\n display: inline-flex;\n align-items: center;\n }\n .list-row .gear-icon:hover {\n color: var(--primary-text-color);\n }\n\n /* Truncation-driven fold for list rows. The .is-folded class is\n added/removed by the JS observer in src/core/truncation-fold.ts\n when the .list-circuit-name actually ellipsizes — pixel breakpoints\n can\'t track this because name length varies wildly per circuit\n ("Spa" vs "Commissioned PV System") and any single threshold\n misfires for the other end of the range. Switch to a two-row grid\n so the name gets the full width (paired only with the expand\n chevron) and the badges/controls/reading/gear drop to a secondary\n row underneath. Named areas keep the CSS readable despite the flat\n HTML child order. */\n .list-row.is-folded {\n display: grid;\n /* Row 1: name spans the row up to the chevron at the trailing\n column. Row 2: badge + util + shed + relay-toggle pack left,\n the 1fr column absorbs slack between the relay and the power\n reading, power + gear stay pinned to the right edge. The\n earlier layout placed the slack between the shedding icon and\n the relay, which the user flagged as wasted padding. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n grid-template-areas:\n "name name name name name name chevron"\n "badge util shed status . power gear";\n row-gap: 6px;\n column-gap: 8px;\n }\n .list-row.is-folded > .list-circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .list-row.is-folded > .list-expand-toggle {\n grid-area: chevron;\n }\n .list-row.is-folded > .breaker-badge {\n grid-area: badge;\n }\n .list-row.is-folded > .utilization {\n grid-area: util;\n }\n .list-row.is-folded > .shedding-icon,\n .list-row.is-folded > .shedding-composite {\n grid-area: shed;\n }\n .list-row.is-folded > .toggle-pill,\n .list-row.is-folded > .list-status-badge {\n grid-area: status;\n }\n .list-row.is-folded > .list-power-value {\n grid-area: power;\n justify-self: end;\n }\n .list-row.is-folded > .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .circuit-slot.circuit-chart-only {\n border: none;\n margin: 0;\n background: none;\n padding: 8px 12px;\n min-height: 0;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n';class hT{constructor(){this._ctrl=new lT,this._container=null,this._onGearClick=null,this._onToggleClick=null,this._onSidePanelClosed=null,this._onGraphSettingsChanged=null,this._foldUnobserve=null}get hass(){return this._ctrl.hass}set hass(t){this._ctrl.hass=t}set errorStore(t){this._ctrl.errorStore=t}setPanelFavorites(t){this._ctrl.setPanelFavorites(t)}async render(t,e,i,r,o){let a,s;this.stop(),this._ctrl.reset(),this._ctrl.showMonitoring=!0,this._container=t,this._ctrl.hass=e;try{const t=await se(e,i);a=t.topology,s=t.panelSize}catch(e){return void(t.innerHTML=`

${Bt(e.message)}

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

${n("monitoring.heading")}

\n\n
\n
\n

${n("monitoring.global_settings")}

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

${n("notification.heading")}

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

${n("monitoring.monitored_points")}

\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n ${M}\n ${k}\n \n
${n("monitoring.col.name")}${n("monitoring.col.continuous")}${n("monitoring.col.spike")}${n("monitoring.col.window")}${n("monitoring.col.cooldown")}
\n \n
\n
\n `;const T=t.querySelector("#toggle-all-circuits");T&&!S&&C&&(T.indeterminate=!0);const I=t.querySelector("#notify-all-targets");if(I&&p.length>0){const t=g.length>0;!v&&t&&(I.indeterminate=!0)}this._bindGlobalControls(t,e),this._bindNotifyTargetSelect(t,e),this._bindNotificationSettings(t,e),this._bindToggleAll(t,e,c,u),this._bindCircuitToggles(t,e),this._bindMainsToggles(t,e),this._bindThresholdInputs(t,e),this._bindResetButtons(t,e)}_serviceData(t){return this._configEntryId&&(t.config_entry_id=this._configEntryId),t}_callSetGlobal(t,e){return t.callWS({type:"call_service",domain:s,service:"set_global_monitoring",service_data:this._serviceData({...e})})}_bindGlobalControls(t,e){const i=t.querySelector("#monitoring-enabled"),r=t.querySelector("#global-fields"),o=t.querySelector("#global-status"),a=()=>{const e=[["continuous_threshold_pct","#g-continuous"],["spike_threshold_pct","#g-spike"],["window_duration_m","#g-window"],["cooldown_duration_m","#g-cooldown"]],n={};for(const[i,r]of e){const e=t.querySelector(r);if(!e)return null;const o=parseInt(e.value,10);if(Number.isNaN(o))return null;n[i]=o}return n},s=(t,e,i)=>{if(!t)return;const r=e instanceof Error?e.message:i;t.textContent=`${n("error.prefix")} ${r}`,t.style.color="var(--error-color, #f44336)"},l=()=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{const i=a();if(i)try{await this._callSetGlobal(e,i),await this.render(t,e)}catch(t){s(o,t,n("error.failed_save"))}else s(o,null,n("error.failed_save"))},p)};i&&i.addEventListener("change",async()=>{const o=i.checked;r&&(r.style.opacity=o?"":"0.4",r.style.pointerEvents=o?"":"none");const l=t.querySelector("#global-status");try{if(o){const t=a();if(!t)return void s(l,null,n("error.failed"));await this._callSetGlobal(e,t)}else await this._callSetGlobal(e,{enabled:!1})}catch(t){return void s(l,t,n("error.failed"))}await this.render(t,e)});for(const e of t.querySelectorAll("#global-fields input[type=number]"))e.addEventListener("input",l)}_bindNotifyTargetSelect(t,e){const i=t.querySelector("#notify-target-btn"),r=t.querySelector("#notify-target-dropdown"),o=t.querySelector("#notify-target-label");if(!i||!r)return;i.addEventListener("click",t=>{t.stopPropagation();const e="none"!==r.style.display;r.style.display=e?"none":"block"});const a=e=>{const n=t.querySelector("#notify-target-select");n&&!n.contains(e.target)&&(r.style.display="none")};document.addEventListener("click",a),this._notifyCloseHandler=a;const s=()=>{const i=[...t.querySelectorAll(".notify-target-cb:checked")].map(t=>t.value);if(o){const t=i.map(t=>"event_bus"===t?n("notification.event_bus_target"):t);o.textContent=t.length?t.join(", "):n("notification.none_selected")}const r=t.querySelector("#notify-all-targets");if(r){const e=[...t.querySelectorAll(".notify-target-cb")];r.checked=e.length>0&&e.every(t=>t.checked),r.indeterminate=!r.checked&&e.some(t=>t.checked)}this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(e,{notify_targets:i.join(", ")})}catch(t){console.warn("SPAN Panel: notification targets save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}},p)},l=t.querySelector("#notify-all-targets");l&&l.addEventListener("change",()=>{for(const e of t.querySelectorAll(".notify-target-cb"))e.checked=l.checked;const e=t.querySelector("#notify-target-btn");e&&(e.style.opacity=l.checked?"0.4":"",e.style.pointerEvents=l.checked?"none":""),l.checked&&(r.style.display="none"),s()});for(const e of t.querySelectorAll(".notify-target-cb"))e.addEventListener("change",()=>{s()})}_bindNotificationSettings(t,e){const i=t.querySelector("#g-priority"),r=t.querySelector("#g-title-template"),o=t.querySelector("#g-message-template"),a=(t,i)=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(e,{[t]:i})}catch(t){console.warn("SPAN Panel: notification settings save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}},p)};i&&i.addEventListener("change",async()=>{try{await this._callSetGlobal(e,{notification_priority:i.value}),await this.render(t,e)}catch(t){console.warn("SPAN Panel: notification priority change failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}}),r&&r.addEventListener("input",()=>{a("notification_title_template",r.value)}),o&&o.addEventListener("input",()=>{a("notification_message_template",o.value)});const l=t.querySelector("#test-notification-btn"),c=t.querySelector("#test-notification-status");l&&l.addEventListener("click",async()=>{l.disabled=!0,c&&(c.textContent=n("notification.test_sending"),c.style.color="var(--secondary-text-color)");try{this._debounceTimer&&(clearTimeout(this._debounceTimer),this._debounceTimer=null);const i=[...t.querySelectorAll(".notify-target-cb:checked")].map(t=>t.value).join(", ");await this._callSetGlobal(e,{notify_targets:i});const r={};this._configEntryId&&(r.config_entry_id=this._configEntryId),await e.callWS({type:"call_service",domain:s,service:"test_notification",service_data:r}),c&&(c.textContent=n("notification.test_sent"),c.style.color="var(--success-color, #4caf50)")}catch(t){if(c){const e=t instanceof Error?t.message:n("error.failed");c.textContent=`${n("error.prefix")} ${e}`,c.style.color="var(--error-color, #f44336)"}}finally{l.disabled=!1}})}_bindToggleAll(t,e,i,r){const o=t.querySelector("#toggle-all-circuits");o&&o.addEventListener("change",async()=>{const a=o.checked,l=[...Object.keys(i).map(t=>e.callWS({type:"call_service",domain:s,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:t,monitoring_enabled:a})}).catch(t=>{console.warn("SPAN Panel: circuit monitoring toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})),...Object.keys(r).map(t=>e.callWS({type:"call_service",domain:s,service:"set_mains_threshold",service_data:this._serviceData({leg:t,monitoring_enabled:a})}).catch(t=>{console.warn("SPAN Panel: mains monitoring toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}))];await Promise.all(l),await this.render(t,e)})}_bindMainsToggles(t,e){for(const i of t.querySelectorAll(".mains-toggle"))i.addEventListener("change",async()=>{const r=i.dataset.entity,o=i.checked;try{await e.callWS({type:"call_service",domain:s,service:"set_mains_threshold",service_data:this._serviceData({leg:r,monitoring_enabled:o})})}catch(t){return console.warn("SPAN Panel: mains threshold toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),void(i.checked=!o)}await this.render(t,e)})}_bindCircuitToggles(t,e){for(const i of t.querySelectorAll(".circuit-toggle"))i.addEventListener("change",async()=>{const r=i.dataset.entity,o=i.checked;try{await e.callWS({type:"call_service",domain:s,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:r,monitoring_enabled:o})})}catch(t){return console.warn("SPAN Panel: circuit threshold toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),void(i.checked=!o)}await this.render(t,e)})}_bindThresholdInputs(t,e){const i=new Map;for(const r of t.querySelectorAll(".threshold-input"))r.addEventListener("input",()=>{const o=`${r.dataset.entity}-${r.dataset.field}`,a=i.get(o);a&&clearTimeout(a),i.set(o,setTimeout(async()=>{const i=parseInt(r.value,10);if(!i||i<1)return;const o=r.dataset.entity,a=r.dataset.field,l=r.dataset.type,c="mains"===l?"set_mains_threshold":"set_circuit_threshold",u="mains"===l?"leg":"circuit_id";try{await e.callWS({type:"call_service",domain:s,service:c,service_data:this._serviceData({[u]:o,[a]:i})}),await this.render(t,e)}catch(t){console.warn("SPAN Panel: threshold input save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),r.style.borderColor="var(--error-color, #f44336)"}},800))})}_bindResetButtons(t,e){for(const n of t.querySelectorAll(".reset-btn"))n.addEventListener("click",async()=>{const i=n.dataset.entity;if(!i)return;const r=n.dataset.type,o="mains"===r?"clear_mains_threshold":"clear_circuit_threshold",a=this._serviceData("mains"===r?{leg:i}:{circuit_id:i});await e.callService(s,o,a),await this.render(t,e)})}}function _T(t=""){const e=t?` value="${Bt(t)}"`:"",i=t?"":"display:none;";return`\n
\n \n \n
\n `}function bT(t,e,i,r,o,a,s){const c=e.entities?.power,u=c?i.states[c]:null,h=u&&parseFloat(u.state)||0,d=e.entities?.switch,p=d?i.states[d]:null,f=p?"on"===p.state:(u?.attributes?.relay_state||e.relay_state)===l,g=e.breaker_rating_a,m=g?`${Math.round(g)}A`:"",y=Bt(e.name||n("grid.unknown")),_=ye(r),b="current"===_.entityRole;let x;if(f)if(b){const t=e.entities?.current,n=t?i.states[t]:null,r=n&&parseFloat(n.state)||0;x=`${_.format(r)}A`}else x=`${pe(h)}${de(h)}`;else x="";const w=a||"unknown";let S="";if("unknown"!==w){const t=v[w]??v.unknown??{icon:"mdi:help",color:"#999",label:()=>"Unknown"};S=t.icon2?`\n \n \n `:t.textLabel?`\n \n ${t.textLabel}\n `:``}let C="",k=o?.utilization_pct??null;if(null==k&&e.breaker_rating_a){const t=e.entities?.current,n=t?i.states[t]:null,r=n?Math.abs(parseFloat(n.state)||0):0;k=Math.round(r/e.breaker_rating_a*1e3)/10}if(null!=k){C=`=80?"utilization-warning":"utilization-normal"}">${Math.round(k)}%`}const M=``,T=!1!==e.is_user_controllable&&!!e.entities?.switch?`
\n ${n(f?"grid.on":"grid.off")}\n \n
`:`${f?"ON":"OFF"}`;return`\n
\n ${m?`${m}`:""}\n ${C}\n ${y}\n ${S}\n ${T}\n \n ${x}\n \n ${M}\n \n
\n `}function xT(t,e,n,i,r){const o=e.entities?.power,a=o?n.states[o]:null,s=a&&parseFloat(a.state)||0,u=e.device_type===c||s<0,h=e.entities?.switch,d=h?n.states[h]:null,p=Se(0,r,d?"on"===d.state:(a?.attributes?.relay_state||e.relay_state)===l,u),f=Bt(t);return`\n
\n
\n
\n
\n
\n `}function wT(t){return`
${Bt(t)}
`}function ST(t,e,n){const i=t.entities?.switch,r=i?e.states[i]:null,o=t.entities?.power,a=o?e.states[o]:null,s=r?"on"===r.state:(a?.attributes?.relay_state||t.relay_state)===l;let c;if("current"===(n.chart_metric||"power")){const n=t.entities?.current,i=n?e.states[n]:null;c=i?Math.abs(parseFloat(i.state)||0):0}else c=a?Math.abs(parseFloat(a.state)||0):0;return{isOn:s,value:c}}function CT(t,e){if(t.always_on)return"always_on";const n=t.entities?.select,i=n?e.states[n]:null;return i?i.state:"unknown"}function kT(t,e,n,i){const r=ST(t,n,i),o=ST(e,n,i);return r.isOn&&!o.isOn?-1:!r.isOn&&o.isOn?1:o.value-r.value}function MT(t,e,n){return t.sort((t,i)=>kT(t[1],i[1],e,n))}function TT(t){return t.entities?.current??t.entities?.power??""}class IT{constructor(t){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._foldUnobserve=null,this._ctrl=t}setColumns(t){const e=Math.max(1,Math.min(3,Math.floor(t)));this._columns=e}setInitialExpansion(t){this._expandedUuids=new Set(t)}setInitialSearchQuery(t){this._searchQuery=t}setViewName(t){this._viewName=t}renderActivityView(t,e,n,i,r,o){this._unbindEvents(),this._hass=e,this._topology=n,this._config=i,this._monitoringStatus=r;const a=MT(Object.entries(n.circuits),e,i);let s=o+_T(this._searchQuery);s+=`
`;for(const[t,n]of a){const o=we(r,TT(n)),a=CT(n,e),l=this._expandedUuids.has(t);s+=`
`,s+=bT(t,n,e,i,o,a,l),l&&(s+=xT(t,n,e,0,o)),s+="
"}s+="
",s+="",t.innerHTML=s;const l=t.querySelector("span-side-panel");l&&(l.hass=e,l.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}renderAreaView(t,e,i,r,o,a){this._unbindEvents(),this._hass=e,this._topology=i,this._config=r,this._monitoringStatus=o;const s=n("list.unassigned_area"),l=new Map;for(const[t,e]of Object.entries(i.circuits)){const n=e.area??s,i=l.get(n);i?i.push([t,e]):l.set(n,[[t,e]])}const c=[...l.keys()].sort((t,e)=>t===s?1:e===s?-1:t.localeCompare(e));let u=a+_T(this._searchQuery);u+=`
`;for(const t of c){const n=l.get(t);if(!n)continue;const i=MT(n,e,r);u+=wT(t);for(const[t,n]of i){const i=we(o,TT(n)),a=CT(n,e),s=this._expandedUuids.has(t);u+=`
`,u+=bT(t,n,e,r,i,a,s),s&&(u+=xT(t,n,e,0,i)),u+="
"}}u+="
",u+="",t.innerHTML=u;const h=t.querySelector("span-side-panel");h&&(h.hass=e,h.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}updateCollapsedRows(t,e,i,r){const o=ye(r),a="current"===o.entityRole,s=t.querySelectorAll(".list-row[data-row-uuid]");for(const t of s){const s=t.dataset.rowUuid;if(!s)continue;const l=i.circuits[s];if(!l)continue;const{isOn:c,value:u}=ST(l,e,r),h=t.querySelector(".list-power-value");if(h)if(c)if(a)h.innerHTML=`${o.format(u)}A`;else{const t=l.entities?.power,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;h.innerHTML=`${pe(i)}${de(i)}`}else h.innerHTML="";const d=t.querySelector(".toggle-pill");if(d){d.classList.toggle("toggle-on",c),d.classList.toggle("toggle-off",!c);const t=d.querySelector(".toggle-label");t&&(t.textContent=n(c?"grid.on":"grid.off"))}const p=t.querySelector(".list-status-badge");p&&(p.textContent=c?"ON":"OFF",p.classList.toggle("list-status-on",c),p.classList.toggle("list-status-off",!c)),t.classList.toggle("circuit-off",!c)}!function(t,e,n,i){const r=t.querySelector(".list-view");if(r)for(const t of function(t,e){let n={anchor:null,units:[]};const i=[n];for(const r of[...t.children])if(r.classList.contains("area-header"))n={anchor:r,units:[]},i.push(n);else if(r.classList.contains("list-cell")){const t=r.dataset.cellUuid,i=t?e.circuits[t]:void 0;t&&i&&n.units.push({cell:r,uuid:t,circuit:i})}return i}(r,n)){if(t.units.length<2)continue;const n=[...t.units].sort((t,n)=>kT(t.circuit,n.circuit,e,i));if(!n.some((e,n)=>e.uuid!==t.units[n].uuid))continue;let o=t.anchor;for(const t of n)o?o.after(t.cell):r.prepend(t.cell),o=t.cell}}(t,e,i,r)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const t={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:t,bubbles:!0,composed:!0}))}_bindEvents(t){this._container=t,this._clickHandler=e=>{const n=e.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const t=i.dataset.expandUuid;return void(t&&this._toggleExpand(t))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(e,t);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,t);if(n.closest(".list-search-clear")){const e=t.querySelector(".list-search");return void(e&&(e.value="",e.dispatchEvent(new Event("input",{bubbles:!0}))))}const r=n.closest(".unit-btn");if(r){const e=r.dataset.unit;e&&t.dispatchEvent(new CustomEvent("unit-changed",{detail:e,bubbles:!0,composed:!0}))}},this._inputHandler=e=>{const n=e.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(t),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(t).then(()=>{this._ctrl.updateDOM(t)}).catch(()=>{})},t.addEventListener("click",this._clickHandler),t.addEventListener("input",this._inputHandler),t.addEventListener("graph-settings-changed",this._graphSettingsHandler);const e=t.querySelector(".slide-confirm");e&&(this._ctrl.bindSlideConfirm(e,t),t.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_attachFoldObserver(t){this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._foldUnobserve=cT(t,{rowSelector:".list-row",nameSelector:".list-circuit-name",foldClass:"is-folded"})}_applyFilter(t){const e=t.querySelector(".list-search-clear");e&&(e.style.display=this._searchQuery?"":"none");const n=t.querySelectorAll(".list-cell[data-cell-uuid]");for(const t of n){const e=t.querySelector(".list-circuit-name"),n=(e?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=n?"":"none"}const i=t.querySelectorAll(".area-header");for(const t of i){let e=!1,n=t.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){e=!0;break}n=n.nextElementSibling}t.style.display=e?"":"none"}}_toggleExpand(t){if(!(this._container&&this._hass&&this._topology&&this._config))return;const e=nT(t),n=this._container.querySelector(`.list-cell[data-cell-uuid="${e}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${e}"]`),r=n.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(i){if(this._expandedUuids.has(t)){this._expandedUuids.delete(t);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);o&&o.remove(),r&&r.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(t);const e=this._topology.circuits[t];if(!e)return;const n=we(this._monitoringStatus,TT(e)),o=xT(t,e,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),r&&r.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}function DT(t,e){return`${t}|${e}`}class AT{async build(t,e,n,i){const r=new Map;for(const t of n)r.set(t.id,t);const o=i?new qt(i):null,a=[];for(const[n,i]of Object.entries(e)){if(!((i?.circuits?.length??0)>0||(i?.sub_devices?.length??0)>0))continue;const e=r.get(n);e&&a.push((async()=>{try{const i=await se(t,n,o);return{panelDeviceId:n,panel:e,topology:i.topology}}catch(t){return console.warn("SPAN Panel: favorites topology fetch failed",n,t),{panelDeviceId:n,panel:e,topology:null}}})())}const s=(await Promise.all(a)).filter(t=>null!==t.topology),l=s.length>1,c={},u={},h={},d=new Set,p=[];for(const{panelDeviceId:t,panel:n,topology:i}of s){if(!i)continue;const r=n.config_entries?.[0]??null;r&&d.add(r);const o=n.name_by_user??n.name??i.device_name??"";p.push({panelDeviceId:t,panelName:o,topology:i});const a=e[t],s=a?.circuits??[],f=a?.sub_devices??[];for(const e of s){const n=i.circuits?.[e];if(!n)continue;const a=DT(t,e),s=l&&o?`${o} · ${n.name}`:n.name;c[a]={...n,name:s},h[a]={panelDeviceId:t,kind:"circuit",targetId:e,configEntryId:r}}for(const e of f){const n=i.sub_devices?.[e];if(!n)continue;const a=DT(t,e),s=l&&o&&n.name?`${o} · ${n.name}`:n.name??e;u[a]={...n,name:s},h[a]={panelDeviceId:t,kind:"sub_device",targetId:e,configEntryId:r}}}return{topology:{circuits:c,sub_devices:u,panel_entities:{},device_name:"",_favoriteRefs:h},entryIds:Array.from(d),perPanelStats:p}}}const PT="span_panel_favorites_view_state";function LT(t){try{localStorage.setItem(PT,JSON.stringify(t))}catch{}}var ET;const zT="favorites";let NT=ET=class extends Dt{constructor(){super(...arguments),this.narrow=!1,this._panels=[],this._selectedPanelId=null,this._activeTab="dashboard",this._discovered=!1,this._listColumns=Wt(),this._favorites={},this._favoritesViewState={expanded:{activity:[],area:[]}},this._favoritesPanelStats=[],this._dashboardTab=new hT,this._monitoringTab=new yT,this._listDashCtrl=new lT,this._listCtrl=new IT(this._listDashCtrl),this._favCache=new Yt,this._favCtrl=new AT,this._favoritesMonitoringTabs=new Map,this._errorStore=new Ht,this._watchedPanelId=null,this._discovering=!1,this._refreshSeq=0,this._areaUnsub=null,this._areaSubscribing=!1,this._onFavoritesChanged=null,this._deviceRegistryUnsub=null,this._pendingTabRender=!1,this._persistFavoritesViewStateTimer=null,this._tabRenderScheduler=function(t){let e=null,n=!1;return async function i(){if(e)return n=!0,void await e.catch(()=>{});const r=(async()=>{try{await t()}finally{e=null,n&&(n=!1,await i())}})();e=r,await r}}(async()=>this._renderTab()),this._beginRender=function(){let t=0;return()=>{t+=1;const e=t;return()=>t!==e}}()}get _root(){const t=this.shadowRoot;if(!t)throw new Error("span-panel: shadow root is not available");return t}connectedCallback(){super.connectedCallback(),this._dashboardTab.errorStore=this._errorStore,this._listDashCtrl.errorStore=this._errorStore,this._favCache.errorStore=this._errorStore,this._monitoringTab.errorStore=this._errorStore,this._onFavoritesChanged=()=>{this._refreshFavorites()},document.addEventListener(jt,this._onFavoritesChanged),this._subscribeDeviceRegistry()}disconnectedCallback(){this._dashboardTab.stop(),this._monitoringTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();for(const t of this._favoritesMonitoringTabs.values())t.stop();this._favoritesMonitoringTabs.clear(),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null),this._onFavoritesChanged&&(document.removeEventListener(jt,this._onFavoritesChanged),this._onFavoritesChanged=null),this._unsubscribeDeviceRegistry(),this._persistFavoritesViewStateTimer&&(clearTimeout(this._persistFavoritesViewStateTimer),this._persistFavoritesViewStateTimer=null),this._errorStore.dispose(),super.disconnectedCallback()}firstUpdated(){this.hass&&!this._discovered&&this._discoverPanels()}updated(t){if(t.has("hass")){const e=t.get("hass");this._dashboardTab.hass=this.hass,this._listDashCtrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._discovered?this._root.getElementById("tab-content")||this._scheduleTabRender():this._discoverPanels(),!e&&this.hass&&this._subscribeDeviceRegistry()}if(this._discovered&&(t.has("_discovered")||t.has("_activeTab")||t.has("_selectedPanelId")||t.has("_chartMetric")||t.has("_listColumns"))){if(this._isFavoritesView&&"dashboard"===this._activeTab)return void(this._activeTab="activity");this._scheduleTabRender()}if(t.has("_selectedPanelId")&&(this._selectedPanelId!==zT&&this._selectedPanelId?(this._updatePanelStatusWatch(),this._listDashCtrl.setFavoritesPerPanelInfo(null)):(this._errorStore.clearPanelStatusWatch(),this._watchedPanelId=null)),this._discovered&&(t.has("_panels")||t.has("_selectedPanelId"))){const t=this.shadowRoot?.getElementById("panel-select");t&&null!==this._selectedPanelId&&t.value!==this._selectedPanelId&&(t.value=this._selectedPanelId)}if(t.has("hass")&&this._discovered&&("activity"===this._activeTab||"area"===this._activeTab)){const t=this._root.getElementById("tab-content"),e=this._listDashCtrl.topology;if(t&&e){this._listCtrl.updateCollapsedRows(t,this.hass,e,this._buildDashboardConfig());const n=t.querySelector("span-side-panel");n&&(n.hass=this.hass,n.errorStore=this._errorStore)}}}setConfig(t){}render(){var i,r,o;if(i=this.hass?.language,t=i&&e[i]?i:"en",!this.hass)return ut` + `,b([zt({attribute:!1})],CM.prototype,"options",void 0),b([zt({attribute:!1})],CM.prototype,"data",void 0),b([zt({type:String})],CM.prototype,"height",void 0);try{customElements.get("span-chart")||customElements.define("span-chart",CM)}catch{}function kM(t,e,n,i,o,a,s,l,c){const{options:d,series:u}=function(t,e,n,i,o,a=!1){n||(n=f[r]);const s=i?"140, 160, 220":"77, 217, 175",l=`rgb(${s})`,c=Date.now(),d=c-e,u=void 0!==n.fixedMin&&void 0!==n.fixedMax,h=(t??[]).filter(t=>t.time>=d).map(t=>[t.time,Math.abs(t.value)]),p=[{type:"line",data:h,showSymbol:!1,smooth:!1,...a?{}:{step:"end"},lineStyle:{width:1.5,color:l},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:`rgba(${s}, 0.18)`},{offset:1,color:`rgba(${s}, 0.18)`}]}},itemStyle:{color:l}}],g=h.length>0?function(t){let e=0;for(const n of t)n[1]>e&&(e=n[1]);return e}(h):0,v={type:"value",splitNumber:4,axisLabel:{fontSize:10,formatter:g<10?t=>0===t?"0":t.toFixed(1):t=>n.format(t)},splitLine:{lineStyle:{opacity:.15}}};u?(v.min=n.fixedMin,v.max=n.fixedMax):g<1&&(v.min=0,v.max=1),o&&"current"===n.entityRole&&(v.min=0,v.max=Math.ceil(1.25*o),p.push({type:"line",data:[[d,.8*o],[c,.8*o]],showSymbol:!1,lineStyle:{width:1,color:"rgba(255, 200, 40, 0.6)",type:"dashed"},itemStyle:{color:"transparent"},tooltip:{show:!1}}),p.push({type:"line",data:[[d,o],[c,o]],showSymbol:!1,lineStyle:{width:1.5,color:"rgba(255, 60, 60, 0.7)",type:"solid"},itemStyle:{color:"transparent"},tooltip:{show:!1}}));const m={xAxis:{type:"time",min:d,max:c,axisLabel:{fontSize:10},splitLine:{show:!1}},yAxis:v,grid:{top:8,right:4,bottom:0,left:0,containLabel:!0},tooltip:{trigger:"axis",axisPointer:{type:"line",lineStyle:{type:"dashed"}},formatter:t=>{if(!t||0===t.length)return"";const e=t[0],i=new Date(e.value[0]).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}),r=parseFloat(e.value[1].toFixed(2));return`
${i}
${n.format(r)} ${n.unit(r)}
`}},animation:!1};return{options:m,series:p}}(n,i,o,a,l,c),h=s??120;t.style.minHeight=h+"px";let p=t.querySelector("span-chart");p||(p=document.createElement("span-chart"),p.style.display="block",p.style.width="100%",t.innerHTML="",t.appendChild(p));const g=t.clientHeight;p.height=(g>0?g:h)+"px",p.options=d,p.data=u}function TM(t){return"function"==typeof globalThis.CSS?.escape?CSS.escape(t):t.replace(/["\\]/g,"\\$&")}function MM(t,e,n,i,r){const o="current"===(i.chart_metric||"power"),a=t.querySelector(".stat-consumption .stat-value"),s=t.querySelector(".stat-consumption .stat-unit");if(o){const t=n.panel_entities?.site_power,i=t?e.states[t]:null,r=i?parseFloat(i.attributes?.amperage):NaN;a&&(a.textContent=Number.isFinite(r)?Math.abs(r).toFixed(1):"--"),s&&(s.textContent="A")}else{let t=r;const i=n.panel_entities?.site_power;if(i){const n=e.states[i];n&&(t=Math.abs(parseFloat(n.state)||0))}a&&(a.textContent=Ve(t)),s&&(s.textContent="kW")}const l=t.querySelector(".stat-upstream .stat-value"),c=t.querySelector(".stat-upstream .stat-unit");if(l){const t=n.panel_entities?.current_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;l.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",c&&(c.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;l.textContent=Ve(t),c&&(c.textContent="kW")}}const d=t.querySelector(".stat-downstream .stat-value"),u=t.querySelector(".stat-downstream .stat-unit");if(d){const t=n.panel_entities?.feedthrough_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;d.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",u&&(u.textContent="A")}else{const t=i?Math.abs(parseFloat(i.state)||0):0;d.textContent=Ve(t),u&&(u.textContent="kW")}}const h=t.querySelector(".stat-solar .stat-value"),p=t.querySelector(".stat-solar .stat-unit");if(h){const t=n.panel_entities?.pv_power,i=t?e.states[t]:null;if(o){const t=i?parseFloat(i.attributes?.amperage):NaN;h.textContent=Number.isFinite(t)?Math.abs(t).toFixed(1):"--",p&&(p.textContent="A")}else{if(i){const t=Math.abs(parseFloat(i.state)||0);h.textContent=Ve(t)}else h.textContent="--";p&&(p.textContent="kW")}}const f=t.querySelector(".stat-battery .stat-value");if(f){const t=n.panel_entities?.battery_level,i=t?e.states[t]:null;i&&(f.textContent=`${Math.round(parseFloat(i.state)||0)}`)}const g=t.querySelector(".stat-grid-state .stat-value");if(g){const t=n.panel_entities?.dsm_state,i=t?e.states[t]:null;g.textContent=i?e.formatEntityState?.(i)||i.state:"--"}}function IM(t,e,n,i,r,o){if(!t||!n||!e)return;const a=ln(i);let s=0;for(const[,t]of Object.entries(n.circuits)){const n=t.entities?.power;if(!n)continue;const i=e.states[n],r=i&&parseFloat(i.state)||0;t.device_type!==l&&(s+=Math.abs(r))}!function(t,e,n,i,r){const o=t.querySelector(".panel-stats");o&&MM(o,e,n,i,r)}(t,e,n,i,s);const c=qe(i),d="current"===c.entityRole;for(const[i,s]of Object.entries(n.circuits)){const n=t.querySelector(`.circuit-slot[data-uuid="${TM(i)}"]`);if(!n)continue;const u=s.entities?.power,h=u?e.states[u]:null,p=h&&parseFloat(h.state)||0,f=s.device_type===l||p<0,g=be(s,e),v=n.querySelector(".power-value");if(v)if(d){const t=s.entities?.current,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;v.innerHTML=`${c.format(i)}A`}else v.innerHTML=`${Fe(p)}${He(p)}`;Ye(n,g,xe(s,e)),n.classList.toggle("circuit-off",!g),n.classList.toggle("circuit-producer",f),Je(n,Ce(s,e));const m=n.querySelector(".chart-container");if(m){const t=r.get(i)||[],e=n.classList.contains("circuit-col-span")?200:100,d=o?.has(i)?cn(o.get(i)):a,u=s.device_type===l;kM(m,0,t,d,c,f,e,s.breaker_rating_a??void 0,u)}}}class AM{get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this._retry=t?new Xt(t):null}constructor(){this._errorStore=null,this._retry=null,this._settings=null,this._lastFetch=0,this._fetching=!1}async fetch(t,e){const i=Date.now();if(this._fetching)return this._settings;if(this._settings&&i-this._lastFetch<3e4)return this._settings;this._fetching=!0;try{const i={};e&&(i.config_entry_id=e);const r={type:"call_service",domain:s,service:"get_graph_settings",service_data:i,return_response:!0},o=this._retry?await this._retry.callWS(t,r,{errorId:"fetch:graph_settings",errorMessage:n("error.graph_settings_failed")}):await t.callWS(r);this._settings=o?.response??null,this._lastFetch=Date.now()}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this._settings=null,this._retry||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:n("error.graph_settings_failed"),persistent:!1})}finally{this._fetching=!1}return this._settings}invalidate(){this._lastFetch=0}get settings(){return this._settings}clear(){this._settings=null,this._lastFetch=0}}function DM(t,e){if(!t)return o;const n=t.circuits?.[e];return n?.has_override?n.horizon:t.global_horizon??o}function PM(t,e){if(!t)return o;const n=t.sub_devices?.[e];return n?.has_override?n.horizon:t.global_horizon??o}class LM{constructor(){this.powerHistory=new Map,this.horizonMap=new Map,this.subDeviceHorizonMap=new Map,this.monitoringCache=new he,this.monitoringMultiCache=new pe,this.graphSettingsCache=new AM,this._errorStore=null,this._hass=null,this._topology=null,this._config=null,this._configEntryId=null,this._favRefs=null,this._perPanelInfo=new Map,this._panelFavorites=null,this._showMonitoring=!1,this._updateInterval=null,this._recorderRefreshInterval=null,this._resizeObserver=null,this._lastWidth=0,this._resizeDebounce=null}get errorStore(){return this._errorStore}set errorStore(t){this._errorStore=t,this.monitoringCache.errorStore=t,this.graphSettingsCache.errorStore=t,this.monitoringMultiCache.errorStore=t}get hass(){return this._hass}set hass(t){this._hass=t}get topology(){return this._topology}get config(){return this._config}set showMonitoring(t){this._showMonitoring=t}init(t,e,n,i){this._topology=t,this._config=e,this._hass=n,this._configEntryId=i}setFavoriteRefs(t){this._favRefs=t}clearFavoriteRefs(){this._favRefs=null}setPanelFavorites(t){this._panelFavorites=t}setFavoritesPerPanelInfo(t){this._perPanelInfo=t??new Map}get _inFavoritesView(){return null!==this._favRefs}get showFavorites(){return this._inFavoritesView||null!==this._panelFavorites}setConfig(t){this._config=t}buildHorizonMaps(t){if(this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),t&&this._topology?.circuits)for(const e of Object.keys(this._topology.circuits))this.horizonMap.set(e,DM(t,e));if(t&&this._topology?.sub_devices)for(const e of Object.keys(this._topology.sub_devices))this.subDeviceHorizonMap.set(e,PM(t,e))}async fetchAndBuildHorizonMaps(){try{this._favRefs?await this._buildFavoritesHorizonMaps():(await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings))}catch(t){console.warn("SPAN Panel: graph settings fetch failed",t),this.graphSettingsCache.errorStore||this._errorStore?.add({key:"fetch:graph_settings",level:"warning",message:n("error.graph_settings_failed"),persistent:!1})}}async fetchMergedMonitoringStatus(t){if(!this._hass||0===t.length)return null;const e=this._hass;return function(t){let e=!1;const n={},i={};for(const r of t)r&&(e=!0,r.circuits&&Object.assign(n,r.circuits),r.mains&&Object.assign(i,r.mains));return e?{circuits:n,mains:i}:null}(await Promise.all(t.map(t=>this.monitoringMultiCache.fetchOne(e,t))))}async _buildFavoritesHorizonMaps(){if(!this._hass||!this._favRefs||!this._topology)return;const t=new Set;for(const e of Object.values(this._favRefs))e.configEntryId&&t.add(e.configEntryId);const e=new Map;await Promise.all(Array.from(t).map(async t=>{e.set(t,await this._fetchGraphSettingsFresh(t))})),this.horizonMap.clear(),this.subDeviceHorizonMap.clear();for(const t of Object.keys(this._topology.circuits)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.horizonMap.set(t,DM(i,r))}if(this._topology.sub_devices)for(const t of Object.keys(this._topology.sub_devices)){const n=this._favRefs[t],i=n?.configEntryId?e.get(n.configEntryId)??null:null,r=n?.targetId??t;this.subDeviceHorizonMap.set(t,PM(i,r))}}async loadHistory(){await yn(this._hass,this._topology,this._config,this.powerHistory,this.horizonMap,this.subDeviceHorizonMap)}recordSamples(){if(!this._topology||!this._hass||!this._config)return;const t=Date.now();for(const[e,n]of Object.entries(this._topology.circuits)){const i=this.horizonMap.get(e)??o;if(!a[i]?.useRealtime)continue;const r=je(n,this._config);if(!r)continue;const s=this._hass.states[r];if(!s)continue;const l=parseFloat(s.state);if(isNaN(l))continue;const c=cn(i),d=dn(c),u=un(c),h=t-c,p=this.powerHistory.get(e)??[];p.length>0&&t-p[p.length-1].time0&&t-p[p.length-1].time0&&this._topology)for(const{key:t,devId:e}of mn(this._topology))i.has(e)&&r.add(t);const o=new Map;try{await yn(this._hass,this._topology,this._config,o,e,i);for(const t of e.keys()){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}for(const t of r){const e=o.get(t);e?this.powerHistory.set(t,e):this.powerHistory.delete(t)}this.updateDOM(t)}catch(t){console.warn("SPAN Panel: history refresh failed",t),this._errorStore?.add({key:"fetch:history",level:"warning",message:n("error.history_failed"),persistent:!1})}}updateDOM(t){this._hass&&this._topology&&this._config&&(IM(t,this._hass,this._topology,this._config,this.powerHistory,this.horizonMap),function(t,e,n,i,r,o){if(!n.sub_devices)return;const a=ln(i);for(const[i,s]of Object.entries(n.sub_devices)){const n=t.querySelector(`[data-subdev="${TM(i)}"]`);if(!n)continue;const l=ae(s);if(l.headlineEid){const t=n.querySelector(".sub-power-value");t&&(t.innerHTML=Be(ce(e,l.headlineEid)))}if(l.siteTotalEid){const t=n.querySelector(".sub-site-total-value");t&&(t.innerHTML=Be(ce(e,l.siteTotalEid)))}const c=n.querySelectorAll("[data-chart-key]");for(const t of c){const e=t.dataset.chartKey;if(!e)continue;const n=r.get(e)||[];let s=g.power;e.endsWith("_soc")?s=g.soc:e.endsWith("_soe")&&(s=g.soe);const l=!!t.closest(".bess-chart-col");kM(t,0,n,o?.has(i)?cn(o.get(i)):a,s,!1,l?120:150,void 0,e.endsWith("_soc")||e.endsWith("_soe"))}for(const t of Object.keys(s.entities||{})){const i=n.querySelector(`[data-eid="${TM(t)}"]`);if(!i)continue;const r=e.states[t];if(r){let t;if(e.formatEntityState)t=e.formatEntityState(r);else{t=r.state;const e=r.attributes.unit_of_measurement||"";e&&(t+=" "+e)}if("Wh"===(r.attributes.unit_of_measurement||"")){const e=parseFloat(r.state);isNaN(e)||(t=(e/1e3).toFixed(1)+" kWh")}i.textContent=t}}}}(t,this._hass,this._topology,this._config,this.powerHistory,this.subDeviceHorizonMap))}async onGraphSettingsChanged(t){if(this._hass){this._favRefs?await this._buildFavoritesHorizonMaps():(this.graphSettingsCache.invalidate(),await this.graphSettingsCache.fetch(this._hass,this._configEntryId),this.buildHorizonMaps(this.graphSettingsCache.settings)),this.powerHistory.clear();try{await this.loadHistory()}catch{}this.updateDOM(t)}}onToggleClick(t,e){const i=t.target,r=i?.closest(".toggle-pill");if(!r)return;const o=e.querySelector(".slide-confirm");if(!o||!o.classList.contains("confirmed"))return;t.stopPropagation(),t.preventDefault();const a=r.closest("[data-uuid]");if(!a||!this._topology||!this._hass)return;const s=a.dataset.uuid;if(!s)return;const l=this._topology.circuits[s];if(!l)return;const c=l.entities?.switch;if(!c)return;if("operable"!==xe(l,this._hass))return;const d="on"===this._hass.states[c]?.state?"turn_off":"turn_on";this._hass.callService("switch",d,{},{entity_id:c}).catch(t=>{console.warn("SPAN Panel: switch service call failed",t),this._errorStore?.add({key:"service:relay",level:"error",message:n("error.relay_failed"),persistent:!1})})}async onGearClick(t,e){const n=t.target,i=n?.closest(".gear-icon");if(!i)return;const r=e.querySelector("span-side-panel");if(!r||!this._hass)return;if(r.hass=this._hass,r.errorStore=this.errorStore,i.classList.contains("panel-gear")){if(this._inFavoritesView){const t=await this._buildFavoritesSections();if(0===t.length)return;return void r.open({favoritesMode:!0,perPanelSections:t})}if(!this._topology)return;return await this.graphSettingsCache.fetch(this._hass,this._configEntryId),void r.open({panelMode:!0,topology:this._topology,graphSettings:this.graphSettingsCache.settings,showFavorites:null!==this._panelFavorites,favoritePanelDeviceId:this._panelFavorites?.panelDeviceId,favoriteCircuitUuids:this._panelFavorites?.circuitUuids,favoriteSubDeviceIds:this._panelFavorites?.subDeviceIds,configEntryId:this._configEntryId})}const a=i.dataset.uuid;if(a&&this._topology){const t=this._topology.circuits[a];if(t){const e=this._favRefs?.[a]??null,n=e&&"circuit"===e.kind?e.targetId:a,i=e?.configEntryId??this._configEntryId;let s,l;e?[s,l]=await Promise.all([this._fetchGraphSettingsFresh(i),this._fetchMonitoringStatusFresh(i)]):(await Promise.all([this.graphSettingsCache.fetch(this._hass,i),this.monitoringCache.fetch(this._hass,i)]),s=this.graphSettingsCache.settings,l=this.monitoringCache.status);const c=t.entities?.current??t.entities?.power,d=c?l?.circuits?.[c]??null:null,u=s?.global_horizon??o,h=s?.circuits?.[n],p=h?{...h,globalHorizon:u}:{horizon:u,has_override:!1,globalHorizon:u},f=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,g=null!==e||(this._panelFavorites?.circuitUuids.has(n)??!1),v={uuid:n,name:t.name,tabs:t.tabs,entities:t.entities,breaker_rating_a:t.breaker_rating_a??void 0,voltage:t.voltage,is_user_controllable:t.is_user_controllable,always_on:t.always_on,relay_state:t.relay_state,priority:t.priority,device_type:t.device_type,monitoringInfo:d,showMonitoring:this._showMonitoring,graphHorizonInfo:p,showFavorites:this.showFavorites,favoritePanelDeviceId:f,isFavorite:g,configEntryId:i};return void r.open(v)}}const s=i.dataset.subdevId;if(s&&this._topology?.sub_devices?.[s]){const t=this._topology.sub_devices[s],e=this._favRefs?.[s]??null,n=e&&"sub_device"===e.kind?e.targetId:s,i=e?.configEntryId??this._configEntryId;let a;e?a=await this._fetchGraphSettingsFresh(i):(await this.graphSettingsCache.fetch(this._hass,i),a=this.graphSettingsCache.settings);const l=a?.global_horizon??o,c=a?.sub_devices?.[n],d=c?{...c,globalHorizon:l}:{horizon:l,has_override:!1,globalHorizon:l},u=e?.panelDeviceId??this._panelFavorites?.panelDeviceId,h=null!==e||(this._panelFavorites?.subDeviceIds.has(n)??!1);r.open({subDeviceMode:!0,subDeviceId:n,name:t.name??n,deviceType:t.type??"",entities:t.entities,hasChart:le(t).length>0,graphHorizonInfo:d,showFavorites:this.showFavorites,favoritePanelDeviceId:u,isFavorite:h,configEntryId:i})}}async _buildFavoritesSections(){if(!this._hass||!this._favRefs)return[];const t=function(t,e){const n=new Map;for(const i of Object.values(t)){if("circuit"!==i.kind)continue;const t=e.get(i.panelDeviceId);if(void 0===t)continue;let r=n.get(i.panelDeviceId);void 0===r&&(r={panelDeviceId:i.panelDeviceId,panelName:t.panelName,topology:t.topology,configEntryId:t.configEntryId,favoriteCircuitUuids:new Set},n.set(i.panelDeviceId,r)),r.favoriteCircuitUuids.add(i.targetId)}return Array.from(n.values()).sort((t,e)=>t.panelName.localeCompare(e.panelName))}(this._favRefs,this._perPanelInfo);if(0===t.length)return[];return await Promise.all(t.map(async t=>({panelDeviceId:t.panelDeviceId,panelName:t.panelName,topology:t.topology,graphSettings:await this._fetchGraphSettingsFresh(t.configEntryId),favoriteCircuitUuids:t.favoriteCircuitUuids,configEntryId:t.configEntryId})))}async _fetchGraphSettingsFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const i={type:"call_service",domain:s,service:"get_graph_settings",service_data:e,return_response:!0},r=this._errorStore?new Xt(this._errorStore):null,o=r?await r.callWS(this._hass,i,{errorId:"fetch:graph_settings",errorMessage:n("error.graph_settings_failed")}):await this._hass.callWS(i);return o?.response??null}catch(t){return console.warn("SPAN Panel: fresh graph settings fetch failed",t),null}}async _fetchMonitoringStatusFresh(t){if(!this._hass)return null;try{const e={};t&&(e.config_entry_id=t);const i={type:"call_service",domain:s,service:"get_monitoring_status",service_data:e,return_response:!0},r=this._errorStore?new Xt(this._errorStore):null,o=r?await r.callWS(this._hass,i,{errorId:"fetch:monitoring",errorMessage:n("error.monitoring_failed")}):await this._hass.callWS(i),a=o?.response;return a?{circuits:a.circuits,mains:a.mains}:null}catch(t){return console.warn("SPAN Panel: fresh monitoring status fetch failed",t),null}}bindSlideConfirm(t,e){const n=t.querySelector(".slide-confirm-knob"),i=t.querySelector(".slide-confirm-text");if(!n||!i)return;let r=!1,o=0,a=0;const s=e=>{t.classList.contains("confirmed")||(r=!0,o=e-n.offsetLeft,a=t.offsetWidth-n.offsetWidth-4,n.classList.remove("snapping"))},l=t=>{if(!r)return;const e=Math.max(2,Math.min(t-o,a));n.style.left=e+"px"},c=()=>{if(!r)return;r=!1;(n.offsetLeft-2)/a>=.9?(n.style.left=a+"px",t.classList.add("confirmed"),n.querySelector("span-icon")?.setAttribute("icon","mdi:lock-open"),i.textContent=t.dataset.textOn??"",e&&e.classList.remove("switches-disabled")):(n.classList.add("snapping"),n.style.left="2px")};n.addEventListener("mousedown",t=>{t.preventDefault(),s(t.clientX)}),t.addEventListener("mousemove",t=>l(t.clientX)),t.addEventListener("mouseup",c),t.addEventListener("mouseleave",c),n.addEventListener("touchstart",t=>{t.preventDefault(),s(t.touches[0].clientX)},{passive:!1}),t.addEventListener("touchmove",t=>l(t.touches[0].clientX),{passive:!0}),t.addEventListener("touchend",c),t.addEventListener("touchcancel",c),t.addEventListener("click",()=>{t.classList.contains("confirmed")&&(t.classList.remove("confirmed"),n.classList.add("snapping"),n.style.left="2px",n.querySelector("span-icon")?.setAttribute("icon","mdi:lock"),i.textContent=t.dataset.textOff??"",e&&e.classList.add("switches-disabled"))})}startIntervals(t,e){this._updateInterval=setInterval(()=>{this.recordSamples(),this.updateDOM(t),e&&e()},1e3),this._recorderRefreshInterval=setInterval(()=>{this.refreshRecorderData(t)},3e4)}stopIntervals(){this._updateInterval&&(clearInterval(this._updateInterval),this._updateInterval=null),this._recorderRefreshInterval&&(clearInterval(this._recorderRefreshInterval),this._recorderRefreshInterval=null),this.cleanupResizeObserver()}setupResizeObserver(t,e){this.cleanupResizeObserver(),e&&(this._lastWidth=e.clientWidth,this._resizeObserver=new ResizeObserver(e=>{const n=e[0];if(!n)return;const i=n.contentRect.width;Math.abs(i-this._lastWidth)<5||(this._lastWidth=i,this._resizeDebounce&&clearTimeout(this._resizeDebounce),this._resizeDebounce=setTimeout(()=>{for(const e of t.querySelectorAll(".chart-container")){const t=e.querySelector("span-chart");t&&t.remove()}this.updateDOM(t)},150))}),this._resizeObserver.observe(e))}cleanupResizeObserver(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._resizeDebounce&&(clearTimeout(this._resizeDebounce),this._resizeDebounce=null)}reset(){this.powerHistory.clear(),this.horizonMap.clear(),this.subDeviceHorizonMap.clear(),this.monitoringCache.clear(),this.monitoringMultiCache.clear(),this.graphSettingsCache.clear()}}function EM(t,e){const n=e.hysteresisPx??48,i=new WeakSet;let r=null;const o=t=>{const n=t.querySelector(e.nameSelector);return!!n&&(0!==t.clientWidth&&(n.clientWidth<1||n.scrollWidth>n.clientWidth+1))},a=()=>{const i=t.querySelectorAll(e.rowSelector);if(0===i.length)return;const a=i[0].clientWidth;if(0===a)return;if(null!==r){if(a>r+n){for(const t of i)t.classList.remove(e.foldClass);r=null}else for(const t of i)t.classList.add(e.foldClass);return}let s=!1;for(const t of i)if(o(t)){s=!0;break}if(s){for(const t of i)t.classList.add(e.foldClass);r=a}},s=new ResizeObserver(()=>a()),l=()=>{const n=t.querySelectorAll(e.rowSelector);for(const t of n)i.has(t)||(i.add(t),s.observe(t));requestAnimationFrame(()=>a())};l();const c=new MutationObserver(t=>{(t=>{const n=t=>{for(const n of t)if(n instanceof Element){if(n.matches(e.rowSelector))return!0;if(n.querySelector(e.rowSelector))return!0}return!1};for(const e of t)if(n(e.addedNodes)||n(e.removedNodes))return!0;return!1})(t)&&l()});return c.observe(t,{childList:!0,subtree:!0}),()=>{s.disconnect(),c.disconnect()}}const zM='\n :host {\n --span-accent: var(--primary-color, #4dd9af);\n }\n\n /* Card shell — replaces . Theme variables (--ha-card-*) are\n stable HA contracts (not the deprecated component APIs flagged by the\n 2026.4 frontend blog), so they stay in place to keep visual parity\n with the rest of HA\'s dashboards. */\n .span-card {\n display: block;\n padding: 24px;\n background: var(--card-background-color, #1c1c1c);\n color: var(--primary-text-color, #e0e0e0);\n border-radius: var(--ha-card-border-radius, 12px);\n border: var(--ha-card-border-width, 1px) solid var(--ha-card-border-color, var(--divider-color, #333));\n box-shadow: var(--ha-card-box-shadow, none);\n }\n\n .panel-header {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-between;\n align-items: flex-start;\n gap: 8px 16px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .header-left { flex: 1 1 300px; min-width: 0; }\n .header-center { flex: 0 0 auto; }\n .header-right { flex: 0 1 auto; min-width: 0; }\n\n .panel-identity {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 8px 12px;\n margin-bottom: 12px;\n }\n\n .panel-title {\n font-size: 1.8em;\n font-weight: 700;\n margin: 0;\n color: var(--primary-text-color, #fff);\n }\n\n .panel-serial {\n font-size: 0.85em;\n color: var(--secondary-text-color, #999);\n font-family: monospace;\n }\n\n .panel-stats {\n display: flex;\n flex-wrap: wrap;\n gap: 16px 32px;\n }\n\n /* Favorites view header: gear + slide-to-arm + right-anchored legend/W-A cluster. */\n .favorites-summary {\n padding: 8px 24px;\n border-bottom: 1px solid var(--divider-color, #e0e0e0);\n display: flex;\n align-items: center;\n gap: 12px;\n }\n /* Override the generic .gear-icon { margin-left: auto } rule so the\n favorites gear stays flush-left instead of floating to the right of\n the flex row (same idea as .panel-identity .panel-gear does for\n real-panel headers). */\n .favorites-summary .favorites-gear {\n margin-left: 0;\n }\n /* Right-anchored cluster wrapping the shedding legend + W/A unit toggle.\n margin-left:auto moved here from .favorites-summary-unit-toggle so the\n legend and toggle cluster together, matching the real-panel header\n layout. */\n .favorites-summary-right {\n margin-left: auto;\n display: flex;\n align-items: center;\n gap: 16px;\n }\n .favorites-subdevices-section {\n padding: 8px 16px 0;\n }\n\n /* Favorites view: responsive grid of per-contributing-panel status cards. */\n .favorites-panel-stats-grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));\n gap: 12px;\n padding: 12px 24px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n .favorites-panel-card {\n background: var(--secondary-background-color, rgba(255, 255, 255, 0.04));\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n padding: 10px 14px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n .favorites-panel-card-title {\n font-size: 0.85em;\n font-weight: 600;\n color: var(--primary-text-color);\n opacity: 0.85;\n }\n .favorites-panel-card .panel-stats {\n gap: 10px 20px;\n }\n .favorites-panel-card .stat-value {\n font-size: 1.15em;\n }\n\n .stat { display: flex; flex-direction: column; }\n .stat-label { font-size: 0.8em; color: var(--secondary-text-color, #999); margin-bottom: 2px; }\n .stat-row { display: flex; align-items: baseline; gap: 2px; }\n .stat-value { font-size: 1.5em; font-weight: 700; color: var(--primary-text-color, #fff); }\n .stat-unit { font-size: 0.7em; font-weight: 400; color: var(--secondary-text-color, #999); }\n\n .header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; padding-top: 8px; }\n .header-right-top { display: flex; gap: 20px; align-items: center; }\n .meta-item { font-size: 0.8em; color: var(--secondary-text-color, #999); }\n\n .shedding-legend { display: flex; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }\n .shedding-legend-item { display: inline-flex; align-items: center; gap: 3px; }\n .shedding-legend-item span-icon { --mdc-icon-size: 16px; }\n .shedding-legend-secondary { --mdc-icon-size: 12px; opacity: 0.8; }\n .shedding-legend-text { font-size: 9px; font-weight: 600; }\n .shedding-legend-label { font-size: 0.7em; color: var(--secondary-text-color, #999); }\n\n .panel-gear {\n background: none;\n border: none;\n cursor: pointer;\n color: var(--secondary-text-color);\n opacity: 0.6;\n padding: 4px;\n margin-left: 8px;\n vertical-align: middle;\n }\n .panel-gear:hover { opacity: 1; }\n .header-center {\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 8px;\n }\n .panel-identity .panel-gear {\n margin-left: 0;\n }\n .slide-confirm {\n position: relative;\n display: inline-flex;\n align-items: center;\n width: 160px;\n height: 28px;\n border-radius: 14px;\n background: color-mix(in srgb, var(--primary-color, #4dd9af) 20%, var(--secondary-background-color, #333));\n vertical-align: middle;\n overflow: hidden;\n user-select: none;\n touch-action: none;\n }\n .slide-confirm-text {\n position: absolute;\n width: 100%;\n text-align: center;\n font-size: 0.65em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n pointer-events: none;\n z-index: 0;\n }\n .slide-confirm-knob {\n position: absolute;\n left: 2px;\n top: 2px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: var(--secondary-text-color, #666);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: grab;\n z-index: 1;\n transition: none;\n }\n .slide-confirm-knob span-icon {\n --mdc-icon-size: 14px;\n color: var(--card-background-color, #1c1c1c);\n }\n .slide-confirm-knob.snapping {\n transition: left 0.25s ease;\n }\n .slide-confirm.confirmed {\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n }\n .slide-confirm.confirmed .slide-confirm-text {\n color: var(--state-active-color, var(--span-accent));\n }\n .slide-confirm.confirmed .slide-confirm-knob {\n background: var(--state-active-color, var(--span-accent));\n }\n .switches-disabled .toggle-pill {\n opacity: 0.3;\n pointer-events: none;\n }\n .unit-toggle {\n display: inline-flex;\n background: var(--secondary-background-color, #333);\n border-radius: 6px;\n overflow: hidden;\n margin-left: 8px;\n }\n .unit-btn {\n padding: 4px 10px;\n border: none;\n background: none;\n color: var(--secondary-text-color);\n font-size: 0.75em;\n font-weight: 600;\n cursor: pointer;\n }\n .unit-btn.unit-active {\n background: var(--primary-color, #4dd9af);\n color: var(--text-primary-color, #000);\n }\n\n .monitoring-summary {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 6px 16px;\n font-size: 0.8em;\n background: rgba(76, 175, 80, 0.1);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n }\n .monitoring-active { color: #4caf50; }\n .monitoring-counts { display: flex; gap: 12px; }\n .count-warning { color: #ff9800; }\n .count-alert { color: #f44336; }\n .count-overrides { color: var(--secondary-text-color); }\n\n .panel-grid {\n display: grid;\n /* Five columns: left tab label, left cell, explicit 8px spacer,\n right cell, right tab label. Spacer is in-band rather than a\n column-gap so we can keep inter-cell space without paying an\n equal gap between each cell and its tab label. The tab columns\n are sized to fit a 2-digit breaker number (the font is 0.85em\n of the panel body ≈ 14px glyph width). */\n grid-template-columns: 14px 1fr 8px 1fr 14px;\n column-gap: 0;\n row-gap: 8px;\n align-items: stretch;\n }\n\n .tab-label {\n display: flex;\n align-items: center;\n font-size: 0.85em;\n font-weight: 600;\n color: var(--secondary-text-color, #999);\n user-select: none;\n }\n .tab-left { justify-content: flex-start; }\n .tab-right { justify-content: flex-end; }\n\n .circuit-slot {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px 20px;\n min-height: 140px;\n transition: opacity 0.3s;\n position: relative;\n overflow: hidden;\n }\n\n .circuit-col-span { min-height: 280px; }\n .circuit-row-span { border-left: 3px solid var(--span-accent); }\n .circuit-off .circuit-name,\n .circuit-off .breaker-badge,\n .circuit-off .power-value,\n .circuit-off .chart-container { opacity: 0.35; }\n .circuit-off .toggle-pill,\n .circuit-off .gear-icon { opacity: 1; }\n\n .circuit-empty {\n opacity: 0.2;\n min-height: 60px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-style: dashed;\n }\n .empty-label { color: var(--secondary-text-color, #999); font-size: 0.85em; }\n\n .circuit-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n margin-bottom: 6px;\n gap: 8px;\n }\n\n .circuit-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }\n\n .breaker-badge {\n background: color-mix(in srgb, var(--span-accent) 15%, transparent);\n color: var(--span-accent);\n font-size: 0.7em;\n font-weight: 700;\n padding: 2px 3px;\n border-radius: 4px;\n white-space: nowrap;\n border: 1px solid color-mix(in srgb, var(--span-accent) 25%, transparent);\n flex-shrink: 0;\n }\n\n .circuit-name {\n font-size: 0.9em;\n font-weight: 500;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--primary-text-color, #e0e0e0);\n }\n\n .circuit-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }\n\n /* Truncation-driven fold for By Panel breaker cells. The .is-folded\n class is added/removed by the JS observer in\n src/core/truncation-fold.ts when the .circuit-name actually\n ellipsizes. Pixel thresholds can\'t get this right because name\n length varies wildly per circuit (e.g. "Spa" vs\n "Commissioned PV System") — only measuring the live name vs its\n container catches the exact moment of truncation.\n\n When folded the nested flex wrappers (.circuit-header,\n .circuit-info, .circuit-controls, .circuit-status) collapse via\n \'display: contents\' so the leaf elements participate directly in\n the outer grid: name gets the whole first row, readings/controls/\n gear drop to a second row, chart stays as the full-width third. */\n .circuit-slot.is-folded {\n display: grid;\n /* Columns: badges + relay-toggle pack tight on the left, slack\n absorbed by the 1fr column between the relay and the power\n reading, keeping power + gear pinned to the right edge. The\n previous layout placed the slack between the shedding icon and\n the relay, which read as wasted padding the user pointed out. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n /* Rows: name and controls sized to content; chart absorbs any\n extra cell height. Without the explicit 1fr on row 3, a tall\n cell (e.g. .circuit-col-span\'s 280px min-height for 240V\n double-pole breakers) distributes excess space equally across\n all three rows via the default align-content:stretch, which\n pushes the chart down and vertically inflates the badge and\n relay toggle to fill the controls row. */\n grid-template-rows: auto auto 1fr;\n grid-template-areas:\n "name name name name name name name"\n "badge util shed status . power gear"\n "chart chart chart chart chart chart chart";\n row-gap: 6px;\n column-gap: 8px;\n }\n .circuit-slot.is-folded > .circuit-header,\n .circuit-slot.is-folded > .circuit-status,\n .circuit-slot.is-folded > .circuit-header > .circuit-info,\n .circuit-slot.is-folded > .circuit-header > .circuit-controls {\n display: contents;\n }\n .circuit-slot.is-folded .circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .circuit-slot.is-folded .breaker-badge {\n grid-area: badge;\n }\n .circuit-slot.is-folded .utilization {\n grid-area: util;\n }\n .circuit-slot.is-folded .shedding-icon,\n .circuit-slot.is-folded .shedding-composite {\n grid-area: shed;\n }\n .circuit-slot.is-folded .toggle-pill {\n grid-area: status;\n justify-self: end;\n }\n .circuit-slot.is-folded .power-value {\n grid-area: power;\n justify-self: end;\n }\n .circuit-slot.is-folded .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n .circuit-slot.is-folded > .chart-container {\n grid-area: chart;\n }\n\n .power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .power-value strong { font-weight: 700; font-size: 1.1em; }\n .power-unit { font-size: 0.8em; font-weight: 400; color: var(--secondary-text-color, #999); margin-left: 1px; }\n .circuit-producer .power-value strong { color: var(--info-color, #4fc3f7); }\n\n .toggle-pill {\n display: flex;\n align-items: center;\n gap: 3px;\n padding: 2px 4px;\n border-radius: 10px;\n cursor: pointer;\n font-size: 0.65em;\n font-weight: 600;\n transition: background 0.2s;\n user-select: none;\n min-width: 40px;\n }\n .toggle-on {\n padding-left: 6px;\n background: color-mix(in srgb, var(--state-active-color, var(--span-accent)) 25%, transparent);\n color: var(--state-active-color, var(--span-accent));\n }\n .toggle-off {\n padding-right: 6px;\n background: color-mix(in srgb, var(--secondary-text-color) 15%, transparent);\n color: var(--secondary-text-color, #999);\n }\n .toggle-knob {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n transition: background 0.2s, margin 0.2s;\n }\n .toggle-on .toggle-knob {\n background: var(--state-active-color, var(--span-accent));\n margin-left: auto;\n }\n .toggle-off .toggle-knob {\n background: var(--secondary-text-color, #999);\n margin-right: auto;\n order: -1;\n }\n /* A switch Home Assistant reports unavailable: the relay state is still\n shown, but the pill cannot be operated. */\n .toggle-pill.toggle-unavailable {\n opacity: 0.4;\n pointer-events: none;\n cursor: default;\n }\n /* Locked switches dim every pill to 0.3; an inert one stays dimmer still. */\n .switches-disabled .toggle-pill.toggle-unavailable {\n opacity: 0.2;\n }\n\n .circuit-status {\n display: flex;\n align-items: center;\n gap: 4px;\n margin-top: 4px;\n padding: 0 4px;\n }\n .shedding-icon { opacity: 0.8; cursor: default; }\n .shedding-composite {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n }\n .shedding-icon-secondary { opacity: 0.8; }\n .shedding-label {\n font-size: 10px;\n font-weight: 600;\n opacity: 0.8;\n }\n .gear-icon {\n background: none;\n border: none;\n cursor: pointer;\n padding: 2px;\n opacity: 0.6;\n transition: opacity 0.2s;\n margin-left: auto;\n }\n .gear-icon:hover { opacity: 1; }\n .utilization {\n font-size: 0.75em;\n font-weight: 600;\n }\n .utilization-normal { color: #4caf50; }\n .utilization-warning { color: #ff9800; }\n .utilization-alert { color: #f44336; }\n .circuit-alert {\n border-color: #f44336 !important;\n box-shadow: 0 0 8px rgba(244, 67, 54, 0.3);\n }\n .chart-container {\n width: 100%;\n aspect-ratio: 4 / 1;\n margin-top: 4px;\n overflow: hidden;\n min-width: 0;\n }\n\n .sub-devices {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 12px;\n margin-bottom: 20px;\n padding-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .sub-device {\n background: var(--secondary-background-color, var(--card-background-color, #2a2a2a));\n border: 1px solid var(--divider-color, #333);\n border-radius: 12px;\n padding: 14px 16px;\n }\n .sub-device-bess,\n .sub-device-full {\n grid-column: 1 / -1;\n }\n\n .sub-device-header { display: flex; gap: 10px; align-items: baseline; margin-bottom: 8px; }\n .sub-device-type { font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--span-accent); }\n .sub-device-name { font-size: 0.85em; color: var(--secondary-text-color, #999); flex: 1; }\n .sub-power-value { font-size: 0.9em; color: var(--primary-text-color, #fff); white-space: nowrap; }\n .sub-power-value strong { font-weight: 700; font-size: 1.1em; }\n .sub-device .chart-container { margin-bottom: 8px; aspect-ratio: auto; }\n\n .bess-charts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n gap: 12px;\n margin-bottom: 10px;\n }\n .bess-chart-col { min-width: 0; }\n .bess-chart-title {\n font-size: 0.75em;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--secondary-text-color, #999);\n margin-bottom: 4px;\n }\n .bess-chart-col .chart-container { aspect-ratio: auto; }\n .sub-entity { display: flex; gap: 6px; padding: 3px 0; font-size: 0.85em; }\n .sub-entity-name { color: var(--secondary-text-color, #999); }\n .sub-entity-value { font-weight: 500; color: var(--primary-text-color, #e0e0e0); }\n .sub-identity { font-size: 0.85em; color: var(--secondary-text-color, #999); margin: 2px 0 6px; }\n .sub-note { font-size: 0.85em; color: var(--secondary-text-color, #999); font-style: italic; margin-top: 4px; }\n .sub-power-caption { font-size: 0.75em; color: var(--secondary-text-color, #999); margin-right: 6px; text-transform: uppercase; }\n\n /* ── Shared tab bar ────────────────────────────────────── */\n\n .shared-tab-bar {\n display: flex;\n gap: 0;\n margin-bottom: 16px;\n border-bottom: 1px solid var(--divider-color, #333);\n }\n\n .shared-tab {\n padding: 8px 16px;\n cursor: pointer;\n font-size: 0.9em;\n font-weight: 500;\n color: var(--primary-text-color);\n opacity: 0.6;\n border: none;\n border-bottom: 2px solid transparent;\n background: none;\n transition: opacity 0.15s;\n }\n\n .shared-tab:hover {\n opacity: 0.85;\n }\n\n .shared-tab.active {\n opacity: 1;\n border-bottom-color: var(--span-accent);\n }\n\n /* ── List view search ──────────────────────────────────── */\n\n .list-search-container {\n margin-bottom: 12px;\n position: relative;\n }\n\n .list-search {\n width: 100%;\n padding: 8px 36px 8px 12px;\n border-radius: 8px;\n border: 1px solid var(--divider-color, #333);\n background: var(--secondary-background-color, #2a2a2a);\n color: var(--primary-text-color);\n font-size: 0.9em;\n box-sizing: border-box;\n outline: none;\n }\n\n .list-search:focus {\n border-color: var(--span-accent);\n }\n\n .list-search-clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 2px;\n display: flex;\n align-items: center;\n opacity: 0.7;\n }\n\n .list-search-clear:hover {\n opacity: 1;\n }\n\n .list-unit-toggle {\n display: inline-flex;\n margin-bottom: 12px;\n }\n\n /* ── List rows ─────────────────────────────────────────── */\n\n .list-view {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n /* Each circuit is wrapped in a .list-cell so the row + its optional\n expanded chart stay together. In single-column flex mode the cell\n just stacks naturally. In multi-column grid mode the cell becomes\n one grid item, so the chart is always in the same column as its\n row. Area headers (rendered as siblings, not inside a cell) span\n all columns via their inline "grid-column: 1 / -1". */\n .list-cell {\n display: flex;\n flex-direction: column;\n min-width: 0;\n }\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: grid;\n grid-template-columns: repeat(var(--list-cols), minmax(0, 1fr));\n gap: 6px 8px;\n flex-direction: initial;\n }\n /* On narrow viewports a 2/3-column list would squeeze rows into an\n unreadable shape, so force stacking regardless of user preference. */\n @media (max-width: 599px) {\n .list-view[data-columns="2"],\n .list-view[data-columns="3"] {\n display: flex;\n flex-direction: column;\n }\n }\n\n .list-row {\n display: flex;\n align-items: center;\n padding: 12px 16px;\n gap: 10px;\n /* min-width: 0 lets the row shrink below the sum of its\n non-shrinking children when its parent .list-cell is in a\n narrow CSS-grid track (multi-column list mode). Without this\n the row would maintain its intrinsic min-content width and\n overflow the cell, leaving the name unshrunk and the\n truncation-fold observer with no signal to react to. */\n min-width: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-radius: 8px;\n cursor: pointer;\n transition: background 0.15s;\n }\n\n .list-row:hover {\n background: var(--secondary-background-color, #2a2a2a);\n }\n\n .list-row.circuit-off {\n opacity: 0.5;\n }\n\n .list-row.list-row-expanded {\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-bottom-color: transparent;\n }\n\n .list-circuit-name {\n flex: 1;\n color: var(--primary-text-color);\n font-size: 0.9em;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .list-status-badge {\n font-size: 0.75em;\n font-weight: 600;\n padding: 2px 8px;\n border-radius: 4px;\n flex-shrink: 0;\n }\n\n .list-status-on {\n color: #4dd9af;\n }\n\n .list-status-off {\n color: #f44336;\n }\n\n .list-power-value {\n font-size: 0.9em;\n font-weight: 600;\n flex-shrink: 0;\n /* No min-width / text-align:right: the old 70px right-aligned\n cell left a visible blank column for short readings (e.g.\n "1.3A" in a 70px slot), which robbed horizontal space from\n .list-circuit-name on narrow rows. Let the value hug the\n preceding relay control and size to its content so the freed\n width flows back into the flex:1 name column. */\n }\n\n .list-expand-toggle {\n background: none;\n border: none;\n color: var(--secondary-text-color);\n cursor: pointer;\n padding: 4px;\n transition: transform 0.2s;\n display: flex;\n align-items: center;\n flex-shrink: 0;\n }\n\n .list-expand-toggle.expanded {\n transform: rotate(180deg);\n }\n\n .list-row .gear-icon {\n background: transparent;\n border: none;\n padding: 2px;\n cursor: pointer;\n color: #555;\n display: inline-flex;\n align-items: center;\n }\n .list-row .gear-icon:hover {\n color: var(--primary-text-color);\n }\n\n /* Truncation-driven fold for list rows. The .is-folded class is\n added/removed by the JS observer in src/core/truncation-fold.ts\n when the .list-circuit-name actually ellipsizes — pixel breakpoints\n can\'t track this because name length varies wildly per circuit\n ("Spa" vs "Commissioned PV System") and any single threshold\n misfires for the other end of the range. Switch to a two-row grid\n so the name gets the full width (paired only with the expand\n chevron) and the badges/controls/reading/gear drop to a secondary\n row underneath. Named areas keep the CSS readable despite the flat\n HTML child order. */\n .list-row.is-folded {\n display: grid;\n /* Row 1: name spans the row up to the chevron at the trailing\n column. Row 2: badge + util + shed + relay-toggle pack left,\n the 1fr column absorbs slack between the relay and the power\n reading, power + gear stay pinned to the right edge. The\n earlier layout placed the slack between the shedding icon and\n the relay, which the user flagged as wasted padding. */\n grid-template-columns: auto auto auto auto 1fr auto auto;\n grid-template-areas:\n "name name name name name name chevron"\n "badge util shed status . power gear";\n row-gap: 6px;\n column-gap: 8px;\n }\n .list-row.is-folded > .list-circuit-name {\n grid-area: name;\n justify-self: start;\n }\n .list-row.is-folded > .list-expand-toggle {\n grid-area: chevron;\n }\n .list-row.is-folded > .breaker-badge {\n grid-area: badge;\n }\n .list-row.is-folded > .utilization {\n grid-area: util;\n }\n .list-row.is-folded > .shedding-icon,\n .list-row.is-folded > .shedding-composite {\n grid-area: shed;\n }\n .list-row.is-folded > .toggle-pill,\n .list-row.is-folded > .list-status-badge {\n grid-area: status;\n }\n .list-row.is-folded > .list-power-value {\n grid-area: power;\n justify-self: end;\n }\n .list-row.is-folded > .gear-icon.circuit-gear {\n grid-area: gear;\n justify-self: end;\n }\n\n /* ── Expanded circuit content ──────────────────────────── */\n\n .list-expanded-content {\n padding: 0;\n background: var(--card-background-color, #1c1c1c);\n border: 1px solid var(--divider-color, #333);\n border-top: none;\n border-radius: 0 0 8px 8px;\n margin-top: -6px;\n margin-bottom: 2px;\n }\n\n .circuit-slot.circuit-chart-only {\n border: none;\n margin: 0;\n background: none;\n padding: 8px 12px;\n min-height: 0;\n }\n\n /* ── Area headers ──────────────────────────────────────── */\n\n .area-header {\n padding: 16px 12px 6px;\n font-weight: 600;\n font-size: 0.85em;\n color: var(--secondary-text-color);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n\n /* ── No results ────────────────────────────────────────── */\n\n .list-no-results {\n padding: 24px;\n text-align: center;\n color: var(--secondary-text-color);\n }\n\n';class NM{constructor(){this._ctrl=new LM,this._container=null,this._onGearClick=null,this._onToggleClick=null,this._onSidePanelClosed=null,this._onGraphSettingsChanged=null,this._foldUnobserve=null}get hass(){return this._ctrl.hass}set hass(t){this._ctrl.hass=t}set errorStore(t){this._ctrl.errorStore=t}setPanelFavorites(t){this._ctrl.setPanelFavorites(t)}async render(t,e,i,r,o){let a,s;this.stop(),this._ctrl.reset(),this._ctrl.showMonitoring=!0,this._container=t,this._ctrl.hass=e;try{const t=await ze(e,i);a=t.topology,s=t.panelSize}catch(e){return t.innerHTML=`

${Vt(qt(e))}

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

${n("monitoring.heading")}

\n\n
\n
\n

${n("monitoring.global_settings")}

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

${n("notification.heading")}

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

${n("monitoring.monitored_points")}

\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n ${T}\n ${k}\n \n
${n("monitoring.col.name")}${n("monitoring.col.continuous")}${n("monitoring.col.spike")}${n("monitoring.col.window")}${n("monitoring.col.cooldown")}
\n \n
\n
\n `;const M=t.querySelector("#toggle-all-circuits");M&&!S&&C&&(M.indeterminate=!0);const I=t.querySelector("#notify-all-targets");if(I&&p.length>0){const t=g.length>0;!v&&t&&(I.indeterminate=!0)}this._bindGlobalControls(t,e),this._bindNotifyTargetSelect(t,e),this._bindNotificationSettings(t,e),this._bindToggleAll(t,e,c,d),this._bindCircuitToggles(t,e),this._bindMainsToggles(t,e),this._bindThresholdInputs(t,e),this._bindResetButtons(t,e)}_serviceData(t){return this._configEntryId&&(t.config_entry_id=this._configEntryId),t}_callSetGlobal(t,e){return t.callWS({type:"call_service",domain:s,service:"set_global_monitoring",service_data:this._serviceData({...e})})}_bindGlobalControls(t,e){const i=t.querySelector("#monitoring-enabled"),r=t.querySelector("#global-fields"),o=t.querySelector("#global-status"),a=()=>{const e=[["continuous_threshold_pct","#g-continuous"],["spike_threshold_pct","#g-spike"],["window_duration_m","#g-window"],["cooldown_duration_m","#g-cooldown"]],n={};for(const[i,r]of e){const e=t.querySelector(r);if(!e)return null;const o=parseInt(e.value,10);if(Number.isNaN(o))return null;n[i]=o}return n},s=(t,e,i)=>{if(!t)return;const r=null===e?i:qt(e);t.textContent=`${n("error.prefix")} ${r}`,t.style.color="var(--error-color, #f44336)"},l=()=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{const i=a();if(i)try{await this._callSetGlobal(e,i),await this.render(t,e)}catch(t){s(o,t,n("error.failed_save"))}else s(o,null,n("error.failed_save"))},p)};i&&i.addEventListener("change",async()=>{const o=i.checked;r&&(r.style.opacity=o?"":"0.4",r.style.pointerEvents=o?"":"none");const l=t.querySelector("#global-status");try{if(o){const t=a();if(!t)return void s(l,null,n("error.failed"));await this._callSetGlobal(e,t)}else await this._callSetGlobal(e,{enabled:!1})}catch(t){return void s(l,t,n("error.failed"))}await this.render(t,e)});for(const e of t.querySelectorAll("#global-fields input[type=number]"))e.addEventListener("input",l)}_bindNotifyTargetSelect(t,e){const i=t.querySelector("#notify-target-btn"),r=t.querySelector("#notify-target-dropdown"),o=t.querySelector("#notify-target-label");if(!i||!r)return;i.addEventListener("click",t=>{t.stopPropagation();const e="none"!==r.style.display;r.style.display=e?"none":"block"});const a=e=>{const n=t.querySelector("#notify-target-select");n&&!n.contains(e.target)&&(r.style.display="none")};document.addEventListener("click",a),this._notifyCloseHandler=a;const s=()=>{const i=[...t.querySelectorAll(".notify-target-cb:checked")].map(t=>t.value);if(o){const t=i.map(t=>"event_bus"===t?n("notification.event_bus_target"):t);o.textContent=t.length?t.join(", "):n("notification.none_selected")}const r=t.querySelector("#notify-all-targets");if(r){const e=[...t.querySelectorAll(".notify-target-cb")];r.checked=e.length>0&&e.every(t=>t.checked),r.indeterminate=!r.checked&&e.some(t=>t.checked)}this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(e,{notify_targets:i.join(", ")})}catch(t){console.warn("SPAN Panel: notification targets save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}},p)},l=t.querySelector("#notify-all-targets");l&&l.addEventListener("change",()=>{for(const e of t.querySelectorAll(".notify-target-cb"))e.checked=l.checked;const e=t.querySelector("#notify-target-btn");e&&(e.style.opacity=l.checked?"0.4":"",e.style.pointerEvents=l.checked?"none":""),l.checked&&(r.style.display="none"),s()});for(const e of t.querySelectorAll(".notify-target-cb"))e.addEventListener("change",()=>{s()})}_bindNotificationSettings(t,e){const i=t.querySelector("#g-priority"),r=t.querySelector("#g-title-template"),o=t.querySelector("#g-message-template"),a=(t,i)=>{this._debounceTimer&&clearTimeout(this._debounceTimer),this._debounceTimer=setTimeout(async()=>{try{await this._callSetGlobal(e,{[t]:i})}catch(t){console.warn("SPAN Panel: notification settings save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}},p)};i&&i.addEventListener("change",async()=>{try{await this._callSetGlobal(e,{notification_priority:i.value}),await this.render(t,e)}catch(t){console.warn("SPAN Panel: notification priority change failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}}),r&&r.addEventListener("input",()=>{a("notification_title_template",r.value)}),o&&o.addEventListener("input",()=>{a("notification_message_template",o.value)});const l=t.querySelector("#test-notification-btn"),c=t.querySelector("#test-notification-status");l&&l.addEventListener("click",async()=>{l.disabled=!0,c&&(c.textContent=n("notification.test_sending"),c.style.color="var(--secondary-text-color)");try{this._debounceTimer&&(clearTimeout(this._debounceTimer),this._debounceTimer=null);const i=[...t.querySelectorAll(".notify-target-cb:checked")].map(t=>t.value).join(", ");await this._callSetGlobal(e,{notify_targets:i});const r={};this._configEntryId&&(r.config_entry_id=this._configEntryId),await e.callWS({type:"call_service",domain:s,service:"test_notification",service_data:r}),c&&(c.textContent=n("notification.test_sent"),c.style.color="var(--success-color, #4caf50)")}catch(t){if(c){const e=qt(t);c.textContent=`${n("error.prefix")} ${e}`,c.style.color="var(--error-color, #f44336)"}}finally{l.disabled=!1}})}_bindToggleAll(t,e,i,r){const o=t.querySelector("#toggle-all-circuits");o&&o.addEventListener("change",async()=>{const a=o.checked,l=[...Object.keys(i).map(t=>e.callWS({type:"call_service",domain:s,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:t,monitoring_enabled:a})}).catch(t=>{console.warn("SPAN Panel: circuit monitoring toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})})),...Object.keys(r).map(t=>e.callWS({type:"call_service",domain:s,service:"set_mains_threshold",service_data:this._serviceData({leg:t,monitoring_enabled:a})}).catch(t=>{console.warn("SPAN Panel: mains monitoring toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1})}))];await Promise.all(l),await this.render(t,e)})}_bindMainsToggles(t,e){for(const i of t.querySelectorAll(".mains-toggle"))i.addEventListener("change",async()=>{const r=i.dataset.entity,o=i.checked;try{await e.callWS({type:"call_service",domain:s,service:"set_mains_threshold",service_data:this._serviceData({leg:r,monitoring_enabled:o})})}catch(t){return console.warn("SPAN Panel: mains threshold toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),void(i.checked=!o)}await this.render(t,e)})}_bindCircuitToggles(t,e){for(const i of t.querySelectorAll(".circuit-toggle"))i.addEventListener("change",async()=>{const r=i.dataset.entity,o=i.checked;try{await e.callWS({type:"call_service",domain:s,service:"set_circuit_threshold",service_data:this._serviceData({circuit_id:r,monitoring_enabled:o})})}catch(t){return console.warn("SPAN Panel: circuit threshold toggle failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),void(i.checked=!o)}await this.render(t,e)})}_bindThresholdInputs(t,e){const i=new Map;for(const r of t.querySelectorAll(".threshold-input"))r.addEventListener("input",()=>{const o=`${r.dataset.entity}-${r.dataset.field}`,a=i.get(o);a&&clearTimeout(a),i.set(o,setTimeout(async()=>{const i=parseInt(r.value,10);if(!i||i<1)return;const o=r.dataset.entity,a=r.dataset.field,l=r.dataset.type,c="mains"===l?"set_mains_threshold":"set_circuit_threshold",d="mains"===l?"leg":"circuit_id";try{await e.callWS({type:"call_service",domain:s,service:c,service_data:this._serviceData({[d]:o,[a]:i})}),await this.render(t,e)}catch(t){console.warn("SPAN Panel: threshold input save failed",t),this.errorStore?.add({key:"service:monitoring",level:"error",message:n("error.threshold_failed"),persistent:!1}),r.style.borderColor="var(--error-color, #f44336)"}},800))})}_bindResetButtons(t,e){for(const n of t.querySelectorAll(".reset-btn"))n.addEventListener("click",async()=>{const i=n.dataset.entity;if(!i)return;const r=n.dataset.type,o="mains"===r?"clear_mains_threshold":"clear_circuit_threshold",a=this._serviceData("mains"===r?{leg:i}:{circuit_id:i});await e.callService(s,o,a),await this.render(t,e)})}}function WM(t){return"object"==typeof t&&null!==t&&!Array.isArray(t)}function GM(t,e){if(Object.is(t,e))return!0;if(Array.isArray(t)||Array.isArray(e))return Array.isArray(t)&&Array.isArray(e)&&t.length===e.length&&t.every((t,n)=>GM(t,e[n]));if(!WM(t)||!WM(e))return!1;const n=Object.keys(t).filter(e=>void 0!==t[e]),i=Object.keys(e).filter(t=>void 0!==e[t]);return n.length===i.length&&n.every(n=>GM(t[n],e[n]))}function UM(t,e){return null!==t&&t.toLowerCase().includes(e)}function qM(t,e){const n=t[e];return"string"==typeof n?n:""}function jM(t,e){return{enabled:null!==e&&null===e.disabledBy,name:null===e?"":e.name,deviceClass:t.curation.device_class??"",stateClass:t.curation.state_class??"",promote:"none"===t.curation.entity_category}}function XM(t,e){const n=Number(e);return{unit_of_measurement:t||null,display_precision:""!==e&&Number.isFinite(n)?n:null}}function YM(t,e,n){return""!==t&&e.includes(t)&&n.length>0}function ZM(t){if(!t)return"";const e=t.replace(/_/g," ");return e.charAt(0).toUpperCase()+e.slice(1)}const KM=["0","1","2","3","4","5","6"],QM="var(--secondary-text-color,#999)",JM="var(--primary-color,#4dd9af)",tI="var(--warning-color,#ff9800)",eI="var(--card-background-color,#1c1c1c)",nI="var(--secondary-background-color,#232323)",iI="var(--divider-color,#333)",rI=`background:${nI};border:1px solid ${iI};color:var(--primary-text-color);border-radius:4px;padding:4px 8px;font-size:0.85em;`,oI=`${rI}min-width:0;`,aI="display:flex;align-items:center;gap:12px;flex-wrap:wrap;",sI=`font-size:0.85em;color:${QM};min-width:130px;`,lI=`color:${QM};font-size:0.75em;`,cI=`background:none;border:1px solid ${iI};color:var(--primary-text-color);border-radius:6px;padding:6px 12px;font-size:0.8em;cursor:pointer;`;function dI(t,e){const n=e?JM:QM;return`${Vt(t)}`}function uI(t,e,n){const i=e.map(t=>``).join("");return``}function hI(t){return"total_increasing"===t?n("adopted.warn_total_increasing"):"statistics_removed"===t?n("adopted.warn_statistics_removed"):t}class pI{constructor(){this._container=null,this._hass=null,this._deviceId=null,this._groups=[],this._loadError=null,this._query="",this._expandedKey=null,this._editor=null,this._unitsCache=new Map,this._refreshTimer=null,this._onClick=t=>{this._handleClick(t)},this._onInput=t=>{this._handleInput(t)},this._onChange=t=>{this._handleChange(t)}}stop(){this._refreshTimer&&(clearTimeout(this._refreshTimer),this._refreshTimer=null),this._unbind()}async render(t,e,n){const i=void 0===n?this._deviceId:n||null;i!==this._deviceId&&(this._deviceId=i,this._expandedKey=null,this._editor=null,this._query=""),this._unbind(),this._container=t,this._hass=e,"stale"!==this._adoptList(await this._fetchList(e),!1)&&this._paint()}async refresh(){const t=this._hass;t&&this._container&&"changed"===this._adoptList(await this._fetchList(t),!0)&&this._paintGroups()}async _fetchList(t){const e=this._deviceId;if(!e)return{deviceId:e,groups:[],error:null};try{const n=await t.callWS({type:`${s}/adopted/list`,device_id:e});return{deviceId:e,groups:Array.isArray(n?.devices)?n.devices:[],error:null}}catch(t){return{deviceId:e,groups:[],error:qt(t)}}}_adoptList(t,e){if(t.deviceId!==this._deviceId)return"stale";if(null!==t.error&&e)return"unchanged";const n=t.error!==this._loadError||!GM(t.groups,this._groups);return this._groups=t.groups,this._loadError=t.error,n?"changed":"unchanged"}async _fetchSeed(t,e){try{return function(t){if(!t||"object"!=typeof t)return null;const e=t;if("string"!=typeof e.entity_id)return null;const n=e.options&&"object"==typeof e.options?e.options:{},i=n.sensor&&"object"==typeof n.sensor?n.sensor:{},r=i.display_precision;return{disabledBy:"string"==typeof e.disabled_by?e.disabled_by:null,name:qM(e,"name"),unit:qM(i,"unit_of_measurement"),precision:"number"==typeof r?String(r):""}}(await t.callWS({type:"config/entity_registry/get",entity_id:e}))}catch{return null}}async _fetchUnits(t,e){if(!e)return[];const n=this._unitsCache.get(e);if(n)return n;try{const n=await t.callWS({type:"sensor/device_class_convertible_units",device_class:e}),i=Array.isArray(n?.units)?n.units.filter(t=>"string"==typeof t):[];return this._unitsCache.set(e,i),i}catch{return[]}}_findRow(t){for(const e of this._groups)for(const n of e.rows)if(n.key===t)return n;return null}_paint(){const t=this._container;t&&(t.innerHTML=`\n
\n

${n("adopted.heading")}

\n

${n("adopted.description")}

\n\n
\n \n \n
\n\n
${this._groupsHTML()}
\n
\n `,this._bind())}_paintGroups(){const t=this._container?.querySelector("#adopted-groups");t&&(t.innerHTML=this._groupsHTML())}_groupsHTML(){if(null!==this._loadError)return`

${Vt(n("adopted.load_failed"))} — ${Vt(this._loadError)}

`;if(0===this._groups.length)return`

${n("adopted.none")}

`;const t=function(t,e){const n=e.trim().toLowerCase();if(!n)return t;const i=[];for(const e of t){if(UM(e.name,n)){i.push(e);continue}const t=e.rows.filter(t=>UM(t.name,n));t.length>0&&i.push({...e,rows:t})}return i}(this._groups,this._query);return 0===t.length?`

${n("adopted.no_results")}

`:t.map(t=>this._groupHTML(t)).join("")}_groupHTML(t){const e=t.adopted_device?dI(n("adopted.adopted_device"),!1):dI(n("adopted.vendor_readings"),!0),r=i("adopted.count",{count:String(t.rows.length)}),o=t.adopted_device?`${n("adopted.via_panel")} · ${r}`:r;return`\n
\n ${Vt(t.name??"")}\n ${e}\n ${Vt(o)}\n
\n
\n ${t.rows.map(t=>this._rowHTML(t)).join("")}\n
\n `}_rowHTML(t){const e=this._expandedKey===t.key,i=e&&this._editor?this._editorHTML(t,this._editor):"",r=e?`display:flex;align-items:center;padding:12px 16px;gap:10px;background:${nI};border:1px solid ${iI};border-bottom-color:transparent;border-radius:8px 8px 0 0;cursor:pointer;`:`display:flex;align-items:center;padding:12px 16px;gap:10px;background:${eI};border:1px solid ${iI};border-radius:8px;cursor:pointer;`;return`\n
\n
\n ${Vt(t.name)}\n ${Vt(function(t){return t.unit?`${t.platform} · ${t.unit}`:t.platform}(t))}\n ${Object.keys(t.curation).length>0?dI(n("adopted.curated"),!0):""}\n ${t.stale_fields.length>0?dI(n("adopted.stale"),!1):""}\n ${function(t){return``}(e)}\n
\n ${i}\n
\n `}_editorHTML(t,e){const r=[];null!==e.seed?(r.push(this._enableHTML(e)),r.push(this._identityHTML(e))):r.push(`
${n("adopted.registry_unavailable")}
`),t.allowed_device_classes.length>0&&r.push(this._deviceClassHTML(t,e)),t.allowed_state_classes.length>0&&r.push(this._stateClassHTML(t,e)),r.push(this._prominenceHTML(e)),null!==e.seed&&YM(e.form.deviceClass,t.allowed_device_classes,e.units)&&r.push(this._displayHTML(t,e)),t.stale_fields.length>0&&r.push(`
${Vt(i("adopted.stale_note",{fields:t.stale_fields.join(", ")}))}
`),r.push(this._actionsHTML(e)),null!==e.confirm&&r.push(this._confirmHTML(t,e)),null!==e.error&&r.push(`
${Vt(e.error)}
`);for(const t of e.notices)r.push(`
${Vt(t)}
`);return r.push(`
${Vt(function(t){const e=[t.path,t.datatype];return t.unit&&e.push(t.unit),e.push(t.settable?n("adopted.settable"):n("adopted.read_only")),e.join(" · ")}(t))}
`),`\n
\n ${r.join("")}\n
\n `}_enableHTML(t){const e=t.form.enabled,i=e?JM:QM;return`\n
\n ${n("adopted.enable_entity")}\n \n ${n("adopted.enable_note")}\n
\n `}_identityHTML(t){return`\n
\n ${n("adopted.name")}\n \n
\n `}_deviceClassHTML(t,e){const r=[{value:"",label:n("adopted.no_device_class")},...t.allowed_device_classes.map(t=>({value:t,label:ZM(t)}))],o=t.unit?i("adopted.device_class_note",{unit:t.unit}):n("adopted.device_class_note_unitless");return`\n
\n ${n("adopted.device_class")}\n ${uI("device_class",r,e.form.deviceClass)}\n ${Vt(o)}\n
\n `}_stateClassHTML(t,e){const i=[{value:"",label:n("adopted.no_statistics")},...t.allowed_state_classes.map(t=>({value:t,label:ZM(t)}))];return`\n
\n ${n("adopted.statistics_class")}\n ${uI("state_class",i,e.form.stateClass)}\n ${n("adopted.statistics_note")}\n
\n `}_prominenceHTML(t){const e=(t,e,n)=>``;return`\n
\n ${n("adopted.prominence")}\n \n ${e("diagnostic",n("adopted.diagnostic"),!t.form.promote)}\n ${e("standard",n("adopted.standard"),t.form.promote)}\n \n
\n `}_displayHTML(t,e){const i=[{value:"",label:t.unit?`${t.unit} — ${n("adopted.unit_as_published")}`:n("adopted.unit_as_published")},...e.units.map(t=>({value:t,label:t}))],r=[{value:"",label:n("adopted.precision_default")},...KM.map(t=>({value:t,label:t}))];return`\n
\n ${n("adopted.display_unit")}\n ${uI("unit",i,e.unit)}\n ${n("adopted.precision")}\n ${uI("precision",r,e.precision)}\n
\n `}_actionsHTML(t){const e=t.busy?" disabled":"";return`\n
\n \n \n ${n("adopted.reload_note")}\n
\n `}_confirmHTML(t,e){const r="clearing"===e.confirm,o=r?i("adopted.confirm_clearing_subject",{name:t.name}):i("adopted.confirm_setting",{name:t.name,value:ZM(e.form.stateClass)}),a=n(r?"adopted.confirm_clearing":"adopted.confirm_total_increasing");return`\n
\n
\n \n ${n("adopted.confirm_heading")}\n
\n

${Vt(o)}

\n

${a}

\n
\n \n \n
\n
\n `}_bind(){const t=this._container;t&&(t.addEventListener("click",this._onClick),t.addEventListener("input",this._onInput),t.addEventListener("change",this._onChange))}_unbind(){const t=this._container;t&&(t.removeEventListener("click",this._onClick),t.removeEventListener("input",this._onInput),t.removeEventListener("change",this._onChange))}async _handleClick(t){const e=t.target;if(!e)return;const n=e.closest("[data-action]");if(n)return void await this._handleAction(n);const i=e.closest(".adopted-row-header"),r=i?.dataset.key;return r?this._expandedKey===r?(this._expandedKey=null,this._editor=null,void this._paintGroups()):void await this._openEditor(r):void 0}async _handleAction(t){const e=this._editor;if(e)switch(t.dataset.action){case"toggle-enable":return e.form={...e.form,enabled:!e.form.enabled},void this._paintGroups();case"prominence":return e.form={...e.form,promote:"standard"===t.dataset.value},void this._paintGroups();case"save":return void(e.busy||await this._save(!1));case"clear":return void(e.busy||await this._save(!0));case"confirm-cancel":return e.confirm=null,void this._paintGroups();case"confirm-save":{const t=e.confirmClears;return e.confirm=null,void await this._commit(t)}default:return}}_handleInput(t){const e=t.target;if(!e)return;if("adopted-filter"===e.id)return this._query=e.value,void this._paintGroups();const n=this._editor,i=e.dataset.field;if(!n||!i)return;const r=e.value;"name"===i&&(n.form={...n.form,name:r})}async _handleChange(t){const e=t.target,n=this._editor,i=e?.dataset.field;if(e&&n&&i)switch(i){case"device_class":return n.form={...n.form,deviceClass:e.value},n.units=this._hass?await this._fetchUnits(this._hass,e.value):[],void this._paintGroups();case"state_class":return void(n.form={...n.form,stateClass:e.value});case"unit":return void(n.unit=e.value);case"precision":return void(n.precision=e.value);default:return}}async _openEditor(t){const e=this._findRow(t),n=this._hass;if(!e||!n)return;this._expandedKey=t,this._editor={form:jM(e,null),seed:null,units:[],unit:"",precision:"",confirm:null,confirmClears:!1,busy:!0,error:null,notices:[]},this._paintGroups();const i=null===e.entity_id?null:await this._fetchSeed(n,e.entity_id);if(this._expandedKey!==t)return;const r=jM(e,i),o=await this._fetchUnits(n,r.deviceClass);this._expandedKey===t&&(this._editor={form:r,seed:i,units:o,unit:i?.unit??"",precision:i?.precision??"",confirm:null,confirmClears:!1,busy:!1,error:null,notices:[]},this._paintGroups())}async _save(t){const e=this._editor,n=null===this._expandedKey?null:this._findRow(this._expandedKey);if(!e||!n)return;const i=function(t,e){return"total_increasing"===e.stateClass?"total_increasing":t.curation.state_class&&""===e.stateClass?"clearing":null}(n,this._formFor(e,t));if(null!==i)return e.confirm=i,e.confirmClears=t,e.error=null,e.notices=[],void this._paintGroups();await this._commit(t)}_formFor(t,e){return e?{...t.form,deviceClass:"",stateClass:"",promote:!1}:t.form}async _commit(t){const e=this._editor,i=this._hass,r=null===this._expandedKey?null:this._findRow(this._expandedKey);if(!(e&&i&&r&&this._deviceId))return;const o=this._formFor(e,t),a=function(t,e){const n={};return e.deviceClass&&(n.device_class=e.deviceClass),e.stateClass&&(n.state_class=e.stateClass),e.promote&&(n.entity_category="none"),{registryUpdate:null===t.entity_id?null:{type:"config/entity_registry/update",entity_id:t.entity_id,name:e.name||null,disabled_by:e.enabled?null:"user"},curate:{key:t.key,record:n}}}(r,o);e.busy=!0,e.error=null,e.notices=[],this._paintGroups();let l=e.seed;try{const c=t||null===l?null:function(t,e,n){if(null===t.registryUpdate||null===e||!function(t,e){return null!==t&&(e.enabled!==(null===t.disabledBy)||e.name!==t.name)}(e,n))return null;const i={...t.registryUpdate};return function(t,e){return e.enabled===(null===t.disabledBy)}(e,n)&&delete i.disabled_by,i}(a,l,o);null!==c&&null!==l&&(await i.callWS(c),l=function(t,e){return{...t,disabledBy:e.enabled?null:t.disabledBy??"user",name:e.name}}(l,o),e.seed=l),!t&&null!==l&&null!==r.entity_id&&YM(o.deviceClass,r.allowed_device_classes,e.units)&&function(t,e,n){return null!==t&&(e!==t.unit||n!==t.precision)}(l,e.unit,e.precision)&&(await i.callWS({type:"config/entity_registry/update",entity_id:r.entity_id,options_domain:"sensor",options:XM(e.unit,e.precision)}),l={...l,unit:e.unit,precision:e.precision},e.seed=l);const d=await i.callWS({type:`${s}/adopted/curate`,device_id:this._deviceId,key:a.curate.key,record:a.curate.record});r.curation=d?.record??{},e.form=o,e.notices=[n("adopted.saved"),...Array.isArray(d?.warnings)?d.warnings.map(hI):[]]}catch(t){return e.error=qt(t),e.busy=!1,void this._paintGroups()}e.busy=!1,this._paintGroups(),this._scheduleRefresh()}_scheduleRefresh(){this._refreshTimer&&clearTimeout(this._refreshTimer),this._refreshTimer=setTimeout(()=>{this._refreshTimer=null;const t=this._hass;t&&this._fetchList(t).then(t=>{"changed"===this._adoptList(t,!0)&&this._paintGroups()})},2e3)}}function fI(t=""){const e=t?` value="${Vt(t)}"`:"",i=t?"":"display:none;";return`\n
\n \n \n
\n `}function gI(t,e,i,r,o,a,s){const l=e.entities?.power,c=l?i.states[l]:null,d=c&&parseFloat(c.state)||0,u=be(e,i),h=xe(e,i),p=e.breaker_rating_a,f=p?`${Math.round(p)}A`:"",g=Vt(e.name||n("grid.unknown")),v=qe(r),m="current"===v.entityRole;let y;if(u)if(m){const t=e.entities?.current,n=t?i.states[t]:null,r=n&&parseFloat(n.state)||0;y=`${v.format(r)}A`}else y=`${Fe(d)}${He(d)}`;else y="";const _=Qe(a||"unknown");let b="",x=o?.utilization_pct??null;if(null==x&&e.breaker_rating_a){const t=e.entities?.current,n=t?i.states[t]:null,r=n?Math.abs(parseFloat(n.state)||0):0;x=Math.round(r/e.breaker_rating_a*1e3)/10}if(null!=x){b=`=80?"utilization-warning":"utilization-normal"}">${Math.round(x)}%`}const w=``,S="none"===h?`${u?"ON":"OFF"}`:Xe(u,h);return`\n
\n ${f?`${f}`:""}\n ${b}\n ${g}\n ${_}\n ${S}\n \n ${y}\n \n ${w}\n \n
\n `}function vI(t,e,n,i,r){const o=e.entities?.power,a=o?n.states[o]:null,s=a&&parseFloat(a.state)||0,c=e.device_type===l||s<0,d=ge(0,r,be(e,n),c),u=Vt(t);return`\n
\n
\n
\n
\n
\n `}function mI(t){return`
${Vt(t)}
`}function yI(t,e,n){const i=t.entities?.power,r=i?e.states[i]:null,o=be(t,e);let a;if("current"===(n.chart_metric||"power")){const n=t.entities?.current,i=n?e.states[n]:null;a=i?Math.abs(parseFloat(i.state)||0):0}else a=r?Math.abs(parseFloat(r.state)||0):0;return{isOn:o,value:a}}function _I(t,e,n,i){const r=yI(t,n,i),o=yI(e,n,i);return r.isOn&&!o.isOn?-1:!r.isOn&&o.isOn?1:o.value-r.value}function bI(t,e,n){return t.sort((t,i)=>_I(t[1],i[1],e,n))}function xI(t){return t.entities?.current??t.entities?.power??""}class wI{constructor(t){this._expandedUuids=new Set,this._searchQuery="",this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null,this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null,this._viewName=null,this._columns=1,this._foldUnobserve=null,this._ctrl=t}setColumns(t){const e=Math.max(1,Math.min(3,Math.floor(t)));this._columns=e}setInitialExpansion(t){this._expandedUuids=new Set(t)}setInitialSearchQuery(t){this._searchQuery=t}setViewName(t){this._viewName=t}renderActivityView(t,e,n,i,r,o){this._unbindEvents(),this._hass=e,this._topology=n,this._config=i,this._monitoringStatus=r;const a=bI(Object.entries(n.circuits),e,i);let s=o+fI(this._searchQuery);s+=`
`;for(const[t,n]of a){const o=fe(r,xI(n)),a=Ce(n,e),l=this._expandedUuids.has(t);s+=`
`,s+=gI(t,n,e,i,o,a,l),l&&(s+=vI(t,n,e,0,o)),s+="
"}s+="
",s+="",t.innerHTML=s;const l=t.querySelector("span-side-panel");l&&(l.hass=e,l.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}renderAreaView(t,e,i,r,o,a){this._unbindEvents(),this._hass=e,this._topology=i,this._config=r,this._monitoringStatus=o;const s=n("list.unassigned_area"),l=new Map;for(const[t,e]of Object.entries(i.circuits)){const n=e.area??s,i=l.get(n);i?i.push([t,e]):l.set(n,[[t,e]])}const c=[...l.keys()].sort((t,e)=>t===s?1:e===s?-1:t.localeCompare(e));let d=a+fI(this._searchQuery);d+=`
`;for(const t of c){const n=l.get(t);if(!n)continue;const i=bI(n,e,r);d+=mI(t);for(const[t,n]of i){const i=fe(o,xI(n)),a=Ce(n,e),s=this._expandedUuids.has(t);d+=`
`,d+=gI(t,n,e,r,i,a,s),s&&(d+=vI(t,n,e,0,i)),d+="
"}}d+="
",d+="",t.innerHTML=d;const u=t.querySelector("span-side-panel");u&&(u.hass=e,u.errorStore=this._ctrl.errorStore),this._bindEvents(t),this._searchQuery&&this._applyFilter(t),this._ctrl.updateDOM(t),this._attachFoldObserver(t)}updateCollapsedRows(t,e,n,i){const r=qe(i),o="current"===r.entityRole,a=t.querySelectorAll(".list-row[data-row-uuid]");for(const t of a){const a=t.dataset.rowUuid;if(!a)continue;const s=n.circuits[a];if(!s)continue;const{isOn:l,value:c}=yI(s,e,i),d=t.querySelector(".list-power-value");if(d)if(l)if(o)d.innerHTML=`${r.format(c)}A`;else{const t=s.entities?.power,n=t?e.states[t]:null,i=n&&parseFloat(n.state)||0;d.innerHTML=`${Fe(i)}${He(i)}`}else d.innerHTML="";Ye(t,l,xe(s,e)),Je(t,Ce(s,e));const u=t.querySelector(".list-status-badge");u&&(u.textContent=l?"ON":"OFF",u.classList.toggle("list-status-on",l),u.classList.toggle("list-status-off",!l)),t.classList.toggle("circuit-off",!l)}!function(t,e,n,i){const r=t.querySelector(".list-view");if(r)for(const t of function(t,e){let n={anchor:null,units:[]};const i=[n];for(const r of[...t.children])if(r.classList.contains("area-header"))n={anchor:r,units:[]},i.push(n);else if(r.classList.contains("list-cell")){const t=r.dataset.cellUuid,i=t?e.circuits[t]:void 0;t&&i&&n.units.push({cell:r,uuid:t,circuit:i})}return i}(r,n)){if(t.units.length<2)continue;const n=[...t.units].sort((t,n)=>_I(t.circuit,n.circuit,e,i));if(!n.some((e,n)=>e.uuid!==t.units[n].uuid))continue;let o=t.anchor;for(const t of n)o?o.after(t.cell):r.prepend(t.cell),o=t.cell}}(t,e,n,i)}stop(){this._unbindEvents(),null===this._viewName&&(this._expandedUuids.clear(),this._searchQuery=""),this._hass=null,this._topology=null,this._config=null,this._monitoringStatus=null}_dispatchFavoritesViewState(){if(!this._viewName||!this._container)return;const t={view:this._viewName,expanded:[...this._expandedUuids],searchQuery:this._searchQuery};this._container.dispatchEvent(new CustomEvent("favorites-view-state-changed",{detail:t,bubbles:!0,composed:!0}))}_bindEvents(t){this._container=t,this._clickHandler=e=>{const n=e.target;if(!n)return;const i=n.closest(".list-expand-toggle");if(i){const t=i.dataset.expandUuid;return void(t&&this._toggleExpand(t))}if(n.closest(".gear-icon"))return void this._ctrl.onGearClick(e,t);if(n.closest(".toggle-pill"))return void this._ctrl.onToggleClick(e,t);if(n.closest(".list-search-clear")){const e=t.querySelector(".list-search");return void(e&&(e.value="",e.dispatchEvent(new Event("input",{bubbles:!0}))))}const r=n.closest(".unit-btn");if(r){const e=r.dataset.unit;e&&t.dispatchEvent(new CustomEvent("unit-changed",{detail:e,bubbles:!0,composed:!0}))}},this._inputHandler=e=>{const n=e.target;n&&n.classList.contains("list-search")&&(this._searchQuery=n.value.toLowerCase(),this._applyFilter(t),this._dispatchFavoritesViewState())},this._graphSettingsHandler=()=>{this._ctrl.onGraphSettingsChanged(t).then(()=>{this._ctrl.updateDOM(t)}).catch(()=>{})},t.addEventListener("click",this._clickHandler),t.addEventListener("input",this._inputHandler),t.addEventListener("graph-settings-changed",this._graphSettingsHandler);const e=t.querySelector(".slide-confirm");e&&(this._ctrl.bindSlideConfirm(e,t),t.classList.add("switches-disabled"))}_unbindEvents(){this._container&&(this._clickHandler&&this._container.removeEventListener("click",this._clickHandler),this._inputHandler&&this._container.removeEventListener("input",this._inputHandler),this._graphSettingsHandler&&this._container.removeEventListener("graph-settings-changed",this._graphSettingsHandler)),this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._container=null,this._clickHandler=null,this._inputHandler=null,this._graphSettingsHandler=null}_attachFoldObserver(t){this._foldUnobserve&&(this._foldUnobserve(),this._foldUnobserve=null),this._foldUnobserve=EM(t,{rowSelector:".list-row",nameSelector:".list-circuit-name",foldClass:"is-folded"})}_applyFilter(t){const e=t.querySelector(".list-search-clear");e&&(e.style.display=this._searchQuery?"":"none");const n=t.querySelectorAll(".list-cell[data-cell-uuid]");for(const t of n){const e=t.querySelector(".list-circuit-name"),n=(e?.textContent?.toLowerCase()??"").includes(this._searchQuery);t.style.display=n?"":"none"}const i=t.querySelectorAll(".area-header");for(const t of i){let e=!1,n=t.nextElementSibling;for(;n&&!n.classList.contains("area-header");){if(n.classList.contains("list-cell")&&"none"!==n.style.display){e=!0;break}n=n.nextElementSibling}t.style.display=e?"":"none"}}_toggleExpand(t){if(!(this._container&&this._hass&&this._topology&&this._config))return;const e=TM(t),n=this._container.querySelector(`.list-cell[data-cell-uuid="${e}"]`);if(!n)return;const i=n.querySelector(`.list-row[data-row-uuid="${e}"]`),r=n.querySelector(`.list-expand-toggle[data-expand-uuid="${e}"]`);if(i){if(this._expandedUuids.has(t)){this._expandedUuids.delete(t);const o=n.querySelector(`.list-expanded-content[data-expanded-uuid="${e}"]`);o&&o.remove(),r&&r.classList.remove("expanded"),i.classList.remove("list-row-expanded")}else{this._expandedUuids.add(t);const e=this._topology.circuits[t];if(!e)return;const n=fe(this._monitoringStatus,xI(e)),o=vI(t,e,this._hass,this._config,n);i.insertAdjacentHTML("afterend",o),r&&r.classList.add("expanded"),i.classList.add("list-row-expanded"),this._ctrl.updateDOM(this._container)}this._dispatchFavoritesViewState()}}}const SI="loaded";const CI=["relay_state","relay_state_target","priority","priority_target"];function kI(t){const e={...t};for(const t of CI)delete e[t];return e}function TI(t){const e=Object.fromEntries(Object.entries(t.circuits).map(([t,e])=>[t,kI(e)]));return{...t,circuits:e}}function MI(t,e){return`${t}|${e}`}class II{async build(t,e,n,i){const r=new Map;for(const t of n)r.set(t.id,t);const o=i?new Xt(i):null,a=[];for(const[n,i]of Object.entries(e)){if(!((i?.circuits?.length??0)>0||(i?.sub_devices?.length??0)>0))continue;const e=r.get(n);e&&a.push((async()=>{try{const i=await ze(t,n,o);return{panelDeviceId:n,panel:e,topology:i.topology}}catch(t){return console.warn("SPAN Panel: favorites topology fetch failed",n,t),{panelDeviceId:n,panel:e,topology:null}}})())}const s=(await Promise.all(a)).filter(t=>null!==t.topology),l=s.length>1,c={},d={},u={},h=new Set,p=[];for(const{panelDeviceId:t,panel:n,topology:i}of s){if(!i)continue;const r=n.config_entry_id??null;r&&h.add(r);const o=n.name_by_user??n.name??i.device_name??"";p.push({panelDeviceId:t,panelName:o,topology:i});const a=e[t],s=a?.circuits??[],f=a?.sub_devices??[];for(const e of s){const n=i.circuits?.[e];if(!n)continue;const a=MI(t,e),s=l&&o?`${o} · ${n.name}`:n.name;c[a]={...n,name:s},u[a]={panelDeviceId:t,kind:"circuit",targetId:e,configEntryId:r}}for(const e of f){const n=i.sub_devices?.[e];if(!n)continue;const a=MI(t,e),s=l&&o&&n.name?`${o} · ${n.name}`:n.name??e;d[a]={...n,name:s},u[a]={panelDeviceId:t,kind:"sub_device",targetId:e,configEntryId:r}}}return{topology:{circuits:c,sub_devices:d,panel_entities:{},device_name:"",_favoriteRefs:u},entryIds:Array.from(h),perPanelStats:p}}}const AI="span_panel_favorites_view_state";function DI(t){try{localStorage.setItem(AI,JSON.stringify(t))}catch{}}function PI(t){let e=null,n=!1;return async function i(){if(e)return n=!0,void await e.catch(()=>{});const r=(async()=>{try{await t()}finally{e=null,n&&(n=!1,await i())}})();e=r,await r}}function LI(t){let e=!1;return()=>{e||(e=!0,Promise.resolve().then(()=>{e=!1,t()}))}}var EI;const zI="favorites";function NI(t){return"dashboard"===t||"adopted"===t}let OI=EI=class extends Dt{constructor(){super(...arguments),this.narrow=!1,this._panels=[],this._selectedPanelId=null,this._activeTab="dashboard",this._discovered=!1,this._listColumns=Gt(),this._favorites={},this._favoritesViewState={expanded:{activity:[],area:[]}},this._favoritesPanelStats=[],this._dashboardTab=new NM,this._monitoringTab=new VM,this._adoptedTab=new pI,this._listDashCtrl=new LM,this._listCtrl=new wI(this._listDashCtrl),this._favCache=new Kt,this._favCtrl=new II,this._favoritesMonitoringTabs=new Map,this._errorStore=new Ft,this._watchedPanelId=null,this._discovering=!1,this._refreshSeq=0,this._areaSub=null,this._areaTopology=null,this._reloadSub=null,this._renderedTopologies=new Map,this._favoritesWatchEntries=[],this._panelsRefresh=PI(()=>this._refreshPanels()),this._requestPanelsRefresh=LI(()=>{this._panelsRefresh()}),this._requestTabRender=LI(()=>{this._scheduleTabRender()}),this._onFavoritesChanged=null,this._deviceRegistryUnsub=null,this._pendingTabRender=!1,this._persistFavoritesViewStateTimer=null,this._tabRenderScheduler=PI(async()=>this._renderTab()),this._beginRender=function(){let t=0;return()=>{t+=1;const e=t;return()=>t!==e}}()}get _root(){const t=this.shadowRoot;if(!t)throw new Error("span-panel: shadow root is not available");return t}connectedCallback(){super.connectedCallback(),this._dashboardTab.errorStore=this._errorStore,this._listDashCtrl.errorStore=this._errorStore,this._favCache.errorStore=this._errorStore,this._monitoringTab.errorStore=this._errorStore,this._onFavoritesChanged=()=>{this._refreshFavorites()},document.addEventListener(Yt,this._onFavoritesChanged),this._subscribeDeviceRegistry(),this._subscribeEntryReloads(),this._discovered&&(this._reapplyPanelStatusWatch(),"area"!==this._activeTab||this._isFavoritesView||this._subscribeAreaTab())}disconnectedCallback(){this._dashboardTab.stop(),this._monitoringTab.stop(),this._adoptedTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();for(const t of this._favoritesMonitoringTabs.values())t.stop();this._favoritesMonitoringTabs.clear(),this._unsubscribeAreaTab(),this._onFavoritesChanged&&(document.removeEventListener(Yt,this._onFavoritesChanged),this._onFavoritesChanged=null),this._unsubscribeDeviceRegistry(),this._unsubscribeEntryReloads(),this._watchedPanelId=null,this._persistFavoritesViewStateTimer&&(clearTimeout(this._persistFavoritesViewStateTimer),this._persistFavoritesViewStateTimer=null),this._errorStore.dispose(),super.disconnectedCallback()}firstUpdated(){this.hass&&!this._discovered&&this._discoverPanels()}updated(t){if(t.has("hass")){const e=t.get("hass");this._dashboardTab.hass=this.hass,this._listDashCtrl.hass=this.hass,this._errorStore.updateHass(this.hass),this._discovered?this._root.getElementById("tab-content")||this._scheduleTabRender():this._discoverPanels(),!e&&this.hass&&(this._subscribeDeviceRegistry(),this._subscribeEntryReloads())}if(this._discovered&&(t.has("_discovered")||t.has("_activeTab")||t.has("_selectedPanelId")||t.has("_chartMetric")||t.has("_listColumns"))){if(this._isFavoritesView&&NI(this._activeTab))return void(this._activeTab="activity");this._scheduleTabRender()}if(t.has("_selectedPanelId")&&(this._selectedPanelId!==zI&&this._selectedPanelId?(this._updatePanelStatusWatch(),this._listDashCtrl.setFavoritesPerPanelInfo(null)):(this._errorStore.clearPanelStatusWatch(),this._watchedPanelId=null)),this._discovered&&(t.has("_panels")||t.has("_selectedPanelId"))){const t=this.shadowRoot?.getElementById("panel-select");t&&null!==this._selectedPanelId&&t.value!==this._selectedPanelId&&(t.value=this._selectedPanelId)}if(t.has("hass")&&this._discovered&&("activity"===this._activeTab||"area"===this._activeTab)){const t=this._root.getElementById("tab-content"),e=this._listDashCtrl.topology;if(t&&e){this._listCtrl.updateCollapsedRows(t,this.hass,e,this._buildDashboardConfig());const n=t.querySelector("span-side-panel");n&&(n.hass=this.hass,n.errorStore=this._errorStore)}}}setConfig(t){}render(){var i,r,o;if(i=this.hass?.language,t=i&&e[i]?i:"en",!this.hass)return ut`
Span Panel
@@ -251,7 +251,7 @@ var ps={},fs={};var gs,vs=function(){function t(t,e,n){var i=this;this._sleepAft
- ${t?pt:ut`
${n("card.connecting")}
`} + ${t?ft:ut`
${n("card.connecting")}
`}
`}return ut`
@@ -263,7 +263,7 @@ var ps={},fs={};var gs,vs=function(){function t(t,e,n){var i=this;this._sleepAft ${this._panels.map(t=>ut` `)} -
${$t((r=this._buildTabList(),o=this._activeTab,`
${r.map(t=>``).join("")}
`))}
+
${Ht((r=this._buildTabList(),o=this._activeTab,`
${r.map(t=>``).join("")}
`))}
@@ -284,7 +284,7 @@ var ps={},fs={};var gs,vs=function(){function t(t,e,n){var i=this;this._sleepAft > - `}_onPanelChange(t){const e=t.target;this._selectedPanelId=e.value,localStorage.setItem("span_panel_selected",e.value),this._isFavoritesView&&"dashboard"===this._activeTab&&(this._activeTab="activity"),this._areaSubscribing=!1,this._areaUnsub&&(this._areaUnsub(),this._areaUnsub=null)}get _isFavoritesView(){return this._selectedPanelId===zT}_onTabClick(t){const e=t.target.closest(".shared-tab");if(!e)return;const n=e.dataset.tab;n&&n!==this._activeTab&&(this._activeTab=n,this._isFavoritesView&&"dashboard"!==n&&(this._favoritesViewState.activeTab=n,LT(this._favoritesViewState)))}_onTabContentClick(t){const e=t.target.closest(".unit-btn");if(e){const t=e.dataset.unit;if(!t||t===this._chartMetric)return;return this._chartMetric=t,void localStorage.setItem("span_panel_metric",t)}}_onSidePanelClosed(){if("dashboard"===this._activeTab){const t=this._dashboardTab._ctrl;t.monitoringCache.invalidate(),t.graphSettingsCache.invalidate()}this._listDashCtrl.monitoringMultiCache.invalidate(),this._pendingTabRender&&(this._pendingTabRender=!1,this._scheduleTabRender())}_onUnitChanged(t){const e=t.detail;e&&e!==this._chartMetric&&(this._chartMetric=e,localStorage.setItem("span_panel_metric",e))}_onListColumnsChanged(t){const e=t.detail;"number"!=typeof e||1!==e&&2!==e&&3!==e||e===this._listColumns||(this._listColumns=e,Ut(e))}_onGraphSettingsChanged(){if("dashboard"===this._activeTab){const t=this._root.getElementById("tab-content");if(t){this._dashboardTab._ctrl.onGraphSettingsChanged(t)}}}_onNavigateTab(t){const e=t.detail;e&&(this._activeTab=e)}_onFavoritesViewStateChangedEvent(t){if(!this._isFavoritesView)return;const e=t.detail;if(!e)return;const n=this._favoritesViewState;n.activeTab=e.view;const i=this._listDashCtrl.topology,r=i?.circuits;r&&Object.keys(r).length>0?n.expanded[e.view]=e.expanded.filter(t=>t in r):n.expanded[e.view]=e.expanded,n.searchQuery=e.searchQuery,this._persistFavoritesViewStateTimer&&clearTimeout(this._persistFavoritesViewStateTimer),this._persistFavoritesViewStateTimer=setTimeout(()=>{this._persistFavoritesViewStateTimer=null,LT(n)},250)}_subscribeDeviceRegistry(){!this._deviceRegistryUnsub&&this.hass?.connection&&(this._deviceRegistryUnsub=this.hass.connection.subscribeEvents(()=>this._refreshPanels(),"device_registry_updated"))}_unsubscribeDeviceRegistry(){this._deviceRegistryUnsub&&(this._deviceRegistryUnsub.then(t=>t()),this._deviceRegistryUnsub=null)}async _refreshPanels(){if(!this.hass||!this._discovered)return;const t=(await this.hass.callWS({type:"config/device_registry/list"})).filter(t=>t.identifiers?.some(t=>t[0]===s)&&!t.via_device_id),e=this._panels.filter(t=>t.id!==zT),n=new Map(e.map(t=>[t.id,t])),i=new Set(t.map(t=>t.id)),r=n.size!==i.size||[...n.keys()].some(t=>!i.has(t)),o=!r&&t.some(t=>{const e=n.get(t.id);return!!e&&(e.name!==t.name||e.name_by_user!==t.name_by_user)});if((r||o)&&(this._panels=this._buildPanelList(t,this._favorites),!this._panels.some(t=>t.id===this._selectedPanelId)&&this._panels.length>0)){const e=t[0];e&&(this._selectedPanelId=e.id,localStorage.setItem("span_panel_selected",this._selectedPanelId))}}async _updatePanelStatusWatch(){if(!this.hass||!this._selectedPanelId)return;if(this._selectedPanelId===zT)return;if(this._watchedPanelId===this._selectedPanelId)return;const t=this._selectedPanelId;this._watchedPanelId=t;try{const e=new qt(this._errorStore),n=await se(this.hass,t,e);if(this._selectedPanelId!==t)return;const i=n.topology?.panel_entities?.panel_status;i&&(this._errorStore.watchPanelStatus(i),this._errorStore.updateHass(this.hass))}catch(e){console.warn("SPAN Panel: unable to fetch topology for panel status watching",e),this._watchedPanelId===t&&(this._watchedPanelId=null)}}async _discoverPanels(){if(!this._discovering&&this.hass){this._discovering=!0;try{let t;try{const e=new qt(this._errorStore);t=(await e.callWS(this.hass,{type:"config/device_registry/list"},{errorId:"fetch:topology"})).filter(t=>t.identifiers?.some(t=>t[0]===s)&&!t.via_device_id)}catch(t){return console.error("SPAN Panel: device discovery failed",t),void this._errorStore.add({key:"discovery-failed",level:"error",message:n("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._discoverPanels()}})}this._favorites=await this._loadFavorites(),this._panels=this._buildPanelList(t,this._favorites),this._favoritesViewState=function(){try{const t=localStorage.getItem(PT);if(!t)return{expanded:{activity:[],area:[]}};const e=JSON.parse(t);if(!e||"object"!=typeof e)return{expanded:{activity:[],area:[]}};const n=e.expanded??{activity:[],area:[]};return{activeTab:e.activeTab,expanded:{activity:Array.isArray(n.activity)?n.activity:[],area:Array.isArray(n.area)?n.area:[]},searchQuery:"string"==typeof e.searchQuery?e.searchQuery:void 0}}catch{return{expanded:{activity:[],area:[]}}}}(),this._discovered=!0;const e=localStorage.getItem("span_panel_selected");if(e&&this._panels.some(t=>t.id===e)?this._selectedPanelId=e:t.length>0&&(this._selectedPanelId=t[0].id),this._selectedPanelId===zT){const t=this._favoritesViewState.activeTab;"activity"===t||"area"===t||"monitoring"===t?this._activeTab=t:"dashboard"===this._activeTab&&(this._activeTab="activity")}this._chartMetric=localStorage.getItem("span_panel_metric")||"power"}finally{this._discovering=!1}}}_buildPanelList(t,e){if(!Zt(e))return t;return[{id:zT,name:n("panel.favorites"),model:"__favorites__"},...t]}async _loadFavorites(){return this.hass?this._favCache.fetch(this.hass):{}}async _refreshFavorites(){const t=++this._refreshSeq;this._favCache.invalidate();const e=await this._loadFavorites();if(t!==this._refreshSeq)return;const n=this._selectedPanelId===zT;this._favorites=e;const i=this._panels.filter(t=>t.id!==zT);if(this._panels=this._buildPanelList(i,e),n&&!Zt(e)){!function(){try{localStorage.removeItem(PT)}catch{}}(),this._favoritesViewState={expanded:{activity:[],area:[]}};const t=i[0];t?(this._selectedPanelId=t.id,localStorage.setItem("span_panel_selected",t.id)):this._selectedPanelId=null}else this._isFavoritesView?this._scheduleTabRender():this._applyPanelFavorites()}_buildTabList(){const t=[];return this._isFavoritesView||t.push({id:"dashboard",label:n("tab.by_panel"),icon:"mdi:view-dashboard"}),t.push({id:"activity",label:n("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:n("tab.by_area"),icon:"mdi:home-group"},{id:"monitoring",label:n("tab.monitoring"),icon:"mdi:monitor-eye"}),t}_buildFavoritesSummaryHTML(){return function(t){return`\n
\n \n
\n ${Bt(n("header.enable_switches"))}\n
\n \n
\n
\n
\n ${le()}\n
\n \n \n
\n
\n
\n `}("current"===(this._chartMetric||"power"))}_buildFavoritesPanelStatsGridHTML(t,e){if(0===t.length)return"";return`
${t.map(t=>`\n
\n
${Bt(t.panelName||t.topology.device_name||"")}
\n ${ce(t.topology,e,t.panelDeviceId)}\n
\n `).join("")}
`}_updateFavoritesPanelStats(t,e){if(this.hass&&0!==this._favoritesPanelStats.length)for(const n of this._favoritesPanelStats){const i=t.querySelector(`.panel-stats[data-stats-panel-id="${nT(n.panelDeviceId)}"]`);i&&iT(i,this.hass,n.topology,e,0)}}_buildDashboardConfig(){return{chart_metric:this._chartMetric,history_minutes:5,show_panel:!0,show_battery:!0,show_evse:!0}}async _scheduleTabRender(){await this.updateComplete,this._sidePanelOpen()?this._pendingTabRender=!0:await this._tabRenderScheduler()}_sidePanelOpen(){const t=this.shadowRoot?.getElementById("tab-content");return!!t?.querySelector("span-side-panel[open]")}async _renderTab(){const t=this._beginRender();this._dashboardTab.stop(),this._monitoringTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();for(const t of this._favoritesMonitoringTabs.values())t.stop();this._favoritesMonitoringTabs.clear(),this._favoritesPanelStats=[];const e=this._root.getElementById("tab-content");if(e)if(this._isFavoritesView)await this._renderFavoritesTab(e,t);else switch(this._listDashCtrl.clearFavoriteRefs(),this._listCtrl.setViewName(null),this._applyPanelFavorites(),this._activeTab){case"dashboard":{e.innerHTML="";const t=this._buildDashboardConfig(),n=this._panels.find(t=>t.id===this._selectedPanelId),i=n?.config_entries?.[0]??null;await this._dashboardTab.render(e,this.hass,this._selectedPanelId??"",t,i);break}case"activity":{e.innerHTML="";const n=this._panels.find(t=>t.id===this._selectedPanelId),i=n?.config_entries?.[0]??null;try{const n=new qt(this._errorStore),r=await se(this.hass,this._selectedPanelId??void 0,n);if(t())return;const o=this._buildDashboardConfig();if(this._listDashCtrl.init(r.topology,o,this.hass,i),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.monitoringCache.fetch(this.hass,i),t())return;if(await this._listDashCtrl.fetchAndBuildHorizonMaps(),t())return;const a=r.topology?ue(r.topology,o):"";if(this._listCtrl.setColumns(this._listColumns),this._listCtrl.renderActivityView(e,this.hass,r.topology,o,this._listDashCtrl.monitoringCache.status,a),await this._listDashCtrl.loadHistory(),t())return;this._listDashCtrl.updateDOM(e),this._listDashCtrl.startIntervals(e)}catch(n){if(t())return;const i=document.createElement("p");i.style.color="var(--error-color)",i.textContent=n.message,e.appendChild(i)}break}case"area":{e.innerHTML="";const i=this._panels.find(t=>t.id===this._selectedPanelId),r=i?.config_entries?.[0]??null;try{const i=new qt(this._errorStore),o=await se(this.hass,this._selectedPanelId??void 0,i);if(t())return;const a=this._buildDashboardConfig();if(this._listDashCtrl.init(o.topology,a,this.hass,r),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.monitoringCache.fetch(this.hass,r),t())return;if(await this._listDashCtrl.fetchAndBuildHorizonMaps(),t())return;const s=o.topology?ue(o.topology,a):"";if(this._listCtrl.setColumns(this._listColumns),this._listCtrl.renderAreaView(e,this.hass,o.topology,a,this._listDashCtrl.monitoringCache.status,s),await this._listDashCtrl.loadHistory(),t())return;this._listDashCtrl.updateDOM(e),this._listDashCtrl.startIntervals(e),this._areaUnsub||this._areaSubscribing||(this._areaSubscribing=!0,async function(t,e,i,r){if(!t.connection)return()=>{};const o=async()=>{try{const n=new Map;for(const[t,i]of Object.entries(e.circuits))n.set(t,i.area);await ae(t,e);for(const[t,r]of Object.entries(e.circuits))if(r.area!==n.get(t))return void i()}catch(t){console.warn("[span-panel] area registry update failed:",t),r?.add({key:"fetch:areas",level:"warning",message:n("error.areas_failed"),persistent:!1})}},[a,s]=await Promise.all([t.connection.subscribeEvents(o,"entity_registry_updated"),t.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{a(),s()}}(this.hass,o.topology,()=>{"area"===this._activeTab&&this._scheduleTabRender()},this._errorStore).then(t=>{this._areaSubscribing?this._areaUnsub=t:t()}).catch(t=>{this._areaSubscribing=!1,console.warn("SPAN Panel: area subscription failed",t),this._errorStore.add({key:"subscribe:area",level:"warning",message:n("error.areas_failed"),persistent:!1})}))}catch(t){const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=t instanceof Error?t.message:String(t),e.appendChild(n)}break}case"monitoring":{e.innerHTML="";const t=this._panels.find(t=>t.id===this._selectedPanelId),n=t?.config_entries?.[0]??null;await this._monitoringTab.render(e,this.hass,n??void 0);break}}}async _renderFavoritesTab(t,e){if(t.innerHTML="",!this.hass)return;const i=this._panels.filter(t=>t.id!==zT),r=await this._favCtrl.build(this.hass,this._favorites,i,this._errorStore);if(e())return;const o=r.perPanelStats.map(t=>{const e=t.topology.panel_entities?.panel_status;return"string"==typeof e?{entityId:e,panelName:t.panelName}:null}).filter(t=>null!==t);this._errorStore.watchPanelStatuses(o),this._errorStore.updateHass(this.hass);const a=new Map;for(const t of r.perPanelStats){const e=i.find(e=>e.id===t.panelDeviceId);a.set(t.panelDeviceId,{panelName:t.panelName,topology:t.topology,configEntryId:e?.config_entries?.[0]??null})}this._listDashCtrl.setFavoritesPerPanelInfo(a);const s=r.topology,l=r.entryIds[0]??null,c=Object.keys(s.circuits).length>0,u=Object.keys(s.sub_devices??{}).length>0;if(!c&&!u){const e=document.createElement("p");return e.style.color="var(--secondary-text-color)",e.style.padding="24px",e.textContent=n("list.no_results"),void t.appendChild(e)}if(this._listDashCtrl.setFavoriteRefs(s._favoriteRefs),this._listDashCtrl.setPanelFavorites(null),"monitoring"===this._activeTab)return this._listCtrl.setViewName(null),void await this._renderFavoritesMonitoring(t,r.entryIds,i);const h=this._activeTab,d=new Set(Object.keys(s.circuits)),p=this._favoritesViewState.expanded[h].filter(t=>d.has(t));this._listCtrl.setViewName(h),this._listCtrl.setInitialExpansion(p),this._listCtrl.setInitialSearchQuery(this._favoritesViewState.searchQuery??""),this._listCtrl.setColumns(this._listColumns);const f=this._buildDashboardConfig();if(this._listDashCtrl.init(s,f,this.hass,l),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.fetchAndBuildHorizonMaps(),e())return;const g=await this._listDashCtrl.fetchMergedMonitoringStatus(r.entryIds);if(!e()){this._favoritesPanelStats=r.perPanelStats;try{if(await this._listDashCtrl.loadHistory(),e())return;const n=this._buildFavoritesSummaryHTML(),i=this._buildFavoritesPanelStatsGridHTML(r.perPanelStats,f),o=n+i+(u?`
\n
${Ne(s,this.hass,f)}
\n
`:"");"activity"===h?this._listCtrl.renderActivityView(t,this.hass,s,f,g,o):this._listCtrl.renderAreaView(t,this.hass,s,f,g,o),this._updateFavoritesPanelStats(t,f),this._listDashCtrl.setupResizeObserver(t,t),this._listDashCtrl.startIntervals(t,()=>{this._updateFavoritesPanelStats(t,f)})}catch(n){if(e())return;const i=document.createElement("p");i.style.color="var(--error-color)",i.textContent=n.message,t.appendChild(i)}}}async _renderFavoritesMonitoring(t,e,n){if(!this.hass)return;const i=document.createElement("div");i.className="favorites-monitoring-stack",t.appendChild(i);const r=new Map;for(const t of n){const e=t.config_entries?.[0];e&&r.set(e,t)}const o=new Map;for(const t of e){const e=r.get(t),n=document.createElement("div");n.className="favorites-monitoring-block",n.style.marginBottom="24px";const a=document.createElement("h2");a.style.margin="8px 0 12px",a.style.fontSize="1em",a.textContent=e?.name_by_user??e?.name??t,n.appendChild(a);const s=document.createElement("div");n.appendChild(s),i.appendChild(n);const l=new yT;l.errorStore=this._errorStore,o.set(t,l);try{await l.render(s,this.hass,t)}catch(e){console.warn("SPAN Panel: favorites monitoring render failed",t,e);const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=e.message??String(e),s.appendChild(n)}}this._favoritesMonitoringTabs=o}_applyPanelFavorites(){if(!this._selectedPanelId||this._isFavoritesView)return this._listDashCtrl.setPanelFavorites(null),void this._dashboardTab.setPanelFavorites(null);const t=this._favorites[this._selectedPanelId],e={panelDeviceId:this._selectedPanelId,circuitUuids:new Set(t?.circuits??[]),subDeviceIds:new Set(t?.sub_devices??[])};this._listDashCtrl.setPanelFavorites(e),this._dashboardTab.setPanelFavorites(e)}};NT._shellStyles=M` + `}_onPanelChange(t){const e=t.target;this._selectedPanelId=e.value,localStorage.setItem("span_panel_selected",e.value),this._isFavoritesView&&NI(this._activeTab)&&(this._activeTab="activity"),this._unsubscribeAreaTab(),this._areaTopology=null}get _isFavoritesView(){return this._selectedPanelId===zI}_onTabClick(t){const e=t.target.closest(".shared-tab");if(!e)return;const n=e.dataset.tab;n&&n!==this._activeTab&&(this._activeTab=n,this._isFavoritesView&&!NI(n)&&(this._favoritesViewState.activeTab=n,DI(this._favoritesViewState)))}_onTabContentClick(t){const e=t.target.closest(".unit-btn");if(e){const t=e.dataset.unit;if(!t||t===this._chartMetric)return;return this._chartMetric=t,void localStorage.setItem("span_panel_metric",t)}}_onSidePanelClosed(){if("dashboard"===this._activeTab){const t=this._dashboardTab._ctrl;t.monitoringCache.invalidate(),t.graphSettingsCache.invalidate()}this._listDashCtrl.monitoringMultiCache.invalidate(),this._pendingTabRender&&(this._pendingTabRender=!1,this._scheduleTabRender())}_onUnitChanged(t){const e=t.detail;e&&e!==this._chartMetric&&(this._chartMetric=e,localStorage.setItem("span_panel_metric",e))}_onListColumnsChanged(t){const e=t.detail;"number"!=typeof e||1!==e&&2!==e&&3!==e||e===this._listColumns||(this._listColumns=e,Ut(e))}_onGraphSettingsChanged(){if("dashboard"===this._activeTab){const t=this._root.getElementById("tab-content");if(t){this._dashboardTab._ctrl.onGraphSettingsChanged(t)}}}_onNavigateTab(t){const e=t.detail;e&&(this._activeTab=e)}_onFavoritesViewStateChangedEvent(t){if(!this._isFavoritesView)return;const e=t.detail;if(!e)return;const n=this._favoritesViewState;n.activeTab=e.view;const i=this._listDashCtrl.topology,r=i?.circuits;r&&Object.keys(r).length>0?n.expanded[e.view]=e.expanded.filter(t=>t in r):n.expanded[e.view]=e.expanded,n.searchQuery=e.searchQuery,this._persistFavoritesViewStateTimer&&clearTimeout(this._persistFavoritesViewStateTimer),this._persistFavoritesViewStateTimer=setTimeout(()=>{this._persistFavoritesViewStateTimer=null,DI(n)},250)}_subscribeDeviceRegistry(){!this._deviceRegistryUnsub&&this.hass?.connection&&(this._deviceRegistryUnsub=this.hass.connection.subscribeEvents(()=>this._refreshPanels(),"device_registry_updated"))}_unsubscribeDeviceRegistry(){this._deviceRegistryUnsub&&(this._deviceRegistryUnsub.then(t=>t()),this._deviceRegistryUnsub=null)}_subscribeEntryReloads(){!this._reloadSub&&this.hass?.connection&&(this._reloadSub=async function(t,e,n,{initialIsReseed:i}){const r=t.connection;if(!r)return async()=>{};const o=new Map;let a=!1;return r.subscribeMessage(t=>{if(t.every(t=>null===t.type)){const r=a||i;a=!0,o.clear();for(const{entry:i}of t)e(i)&&(o.set(i.entry_id,i.state),r&&i.state===SI&&n(i.entry_id));return}for(const{type:i,entry:r}of t){if(!e(r))continue;if("removed"===i){o.delete(r.entry_id);continue}const t=o.get(r.entry_id);o.set(r.entry_id,r.state),"updated"===i&&r.state===SI&&t!==SI&&n(r.entry_id)}},{type:"config_entries/subscribe",type_filter:["device"]})}(this.hass,t=>t.domain===s,t=>this._onEntryLoaded(t),{initialIsReseed:this._discovered}).catch(t=>(console.warn("SPAN Panel: config entry subscription failed",t),async()=>{})))}_unsubscribeEntryReloads(){const t=this._reloadSub;this._reloadSub=null,t?.then(t=>t())}_onEntryLoaded(t){const e=this._panels.find(e=>e.id!==zI&&e.config_entry_id===t);this._isFavoritesView||e&&e.id===this._selectedPanelId?e?(!this._isFavoritesView||this._renderedTopologies.has(e.id)||Jt(this._favorites,e.id))&&(this._isFavoritesView||"adopted"!==this._activeTab?this._renderIfRestructured(e.id):this._adoptedTab.refresh()):this._requestTabRender():this._requestPanelsRefresh()}async _renderIfRestructured(t){const e=this._renderedTopologies.get(t);if(!e||!this.hass)return void this._requestTabRender();let n;try{({topology:n}=await ze(this.hass,t,null))}catch(t){return void console.warn("SPAN Panel: topology refresh failed; keeping the current view:",qt(t))}var i,r;this._renderedTopologies.get(t)===e&&(n&&(i=n,null!==(r=e)&&GM(TI(i),TI(r)))||this._requestTabRender())}async _fetchBaselineTopology(){if(!this.hass||!this._selectedPanelId)return null;try{return(await ze(this.hass,this._selectedPanelId,null)).topology}catch(t){return console.warn("SPAN Panel: no topology baseline for this view:",qt(t)),null}}_recordRenderedTopology(t){t&&this._selectedPanelId&&this._renderedTopologies.set(this._selectedPanelId,t)}_reapplyPanelStatusWatch(){if(this._isFavoritesView)return this._errorStore.watchPanelStatuses(this._favoritesWatchEntries),void(this.hass&&this._errorStore.updateHass(this.hass));this._updatePanelStatusWatch()}_subscribeAreaTab(){!this._areaSub&&this.hass&&(this._areaSub=async function(t,e,i,r){if(!t.connection)return()=>{};const o=async()=>{const o=e();if(o)try{const e=new Map;for(const[t,n]of Object.entries(o.circuits))e.set(t,n.area);await Ee(t,o);for(const[t,n]of Object.entries(o.circuits))if(n.area!==e.get(t))return void i()}catch(t){console.warn("[span-panel] area registry update failed:",t),r?.add({key:"fetch:areas",level:"warning",message:n("error.areas_failed"),persistent:!1})}},[a,s]=await Promise.all([t.connection.subscribeEvents(o,"entity_registry_updated"),t.connection.subscribeEvents(o,"area_registry_updated")]);return()=>{a(),s()}}(this.hass,()=>this._areaTopology,()=>{"area"===this._activeTab&&this._scheduleTabRender()},this._errorStore).catch(t=>(console.warn("SPAN Panel: area subscription failed",t),this._errorStore.add({key:"subscribe:area",level:"warning",message:n("error.areas_failed"),persistent:!1}),()=>{})))}_unsubscribeAreaTab(){const t=this._areaSub;this._areaSub=null,t?.then(t=>t())}async _refreshPanels(){if(!this.hass||!this._discovered)return;const t=(await this.hass.callWS({type:"config/device_registry/list"})).filter(t=>t.identifiers?.some(t=>t[0]===s)&&!t.via_device_id),e=this._panels.filter(t=>t.id!==zI),n=new Map(e.map(t=>[t.id,t])),i=new Set(t.map(t=>t.id)),r=n.size!==i.size||[...n.keys()].some(t=>!i.has(t)),o=!r&&t.some(t=>{const e=n.get(t.id);return!!e&&(e.name!==t.name||e.name_by_user!==t.name_by_user)});if((r||o)&&(this._panels=this._buildPanelList(t,this._favorites),!this._panels.some(t=>t.id===this._selectedPanelId)&&this._panels.length>0)){const e=t[0];e&&(this._selectedPanelId=e.id,localStorage.setItem("span_panel_selected",this._selectedPanelId))}}async _updatePanelStatusWatch(){if(!this.hass||!this._selectedPanelId)return;if(this._selectedPanelId===zI)return;if(this._watchedPanelId===this._selectedPanelId)return;const t=this._selectedPanelId;this._watchedPanelId=t;try{const e=new Xt(this._errorStore),n=await ze(this.hass,t,e);if(this._selectedPanelId!==t)return;const i=n.topology?.panel_entities?.panel_status;i&&(this._errorStore.watchPanelStatus(i),this._errorStore.updateHass(this.hass))}catch(e){console.warn("SPAN Panel: unable to fetch topology for panel status watching",e),this._watchedPanelId===t&&(this._watchedPanelId=null)}}async _discoverPanels(){if(!this._discovering&&this.hass){this._discovering=!0;try{let t;try{const e=new Xt(this._errorStore);t=(await e.callWS(this.hass,{type:"config/device_registry/list"},{errorId:"fetch:topology"})).filter(t=>t.identifiers?.some(t=>t[0]===s)&&!t.via_device_id)}catch(t){return console.error("SPAN Panel: device discovery failed",t),void this._errorStore.add({key:"discovery-failed",level:"error",message:n("error.discovery_failed"),persistent:!0,retryFn:()=>{this._errorStore.remove("discovery-failed"),this._discoverPanels()}})}this._favorites=await this._loadFavorites(),this._panels=this._buildPanelList(t,this._favorites),this._favoritesViewState=function(){try{const t=localStorage.getItem(AI);if(!t)return{expanded:{activity:[],area:[]}};const e=JSON.parse(t);if(!e||"object"!=typeof e)return{expanded:{activity:[],area:[]}};const n=e.expanded??{activity:[],area:[]};return{activeTab:e.activeTab,expanded:{activity:Array.isArray(n.activity)?n.activity:[],area:Array.isArray(n.area)?n.area:[]},searchQuery:"string"==typeof e.searchQuery?e.searchQuery:void 0}}catch{return{expanded:{activity:[],area:[]}}}}(),this._discovered=!0;const e=localStorage.getItem("span_panel_selected");if(e&&this._panels.some(t=>t.id===e)?this._selectedPanelId=e:t.length>0&&(this._selectedPanelId=t[0].id),this._selectedPanelId===zI){const t=this._favoritesViewState.activeTab;"activity"===t||"area"===t||"monitoring"===t?this._activeTab=t:NI(this._activeTab)&&(this._activeTab="activity")}this._chartMetric=localStorage.getItem("span_panel_metric")||"power"}finally{this._discovering=!1}}}_buildPanelList(t,e){if(!Qt(e))return t;return[{id:zI,name:n("panel.favorites"),model:"__favorites__"},...t]}async _loadFavorites(){return this.hass?this._favCache.fetch(this.hass):{}}async _refreshFavorites(){const t=++this._refreshSeq;this._favCache.invalidate();const e=await this._loadFavorites();if(t!==this._refreshSeq)return;const n=this._selectedPanelId===zI;this._favorites=e;const i=this._panels.filter(t=>t.id!==zI);if(this._panels=this._buildPanelList(i,e),n&&!Qt(e)){!function(){try{localStorage.removeItem(AI)}catch{}}(),this._favoritesViewState={expanded:{activity:[],area:[]}};const t=i[0];t?(this._selectedPanelId=t.id,localStorage.setItem("span_panel_selected",t.id)):this._selectedPanelId=null}else this._isFavoritesView?this._scheduleTabRender():this._applyPanelFavorites()}_buildTabList(){const t=[];return this._isFavoritesView||t.push({id:"dashboard",label:n("tab.by_panel"),icon:"mdi:view-dashboard"}),t.push({id:"activity",label:n("tab.by_activity"),icon:"mdi:sort-descending"},{id:"area",label:n("tab.by_area"),icon:"mdi:home-group"},{id:"monitoring",label:n("tab.monitoring"),icon:"mdi:monitor-eye"}),this._isFavoritesView||!0!==this.hass?.user?.is_admin||t.push({id:"adopted",label:n("tab.adopted"),icon:"mdi:tune"}),t}_buildFavoritesSummaryHTML(){return function(t){return`\n
\n \n
\n ${Vt(n("header.enable_switches"))}\n
\n \n
\n
\n
\n ${Ne()}\n
\n \n \n
\n
\n
\n `}("current"===(this._chartMetric||"power"))}_buildFavoritesPanelStatsGridHTML(t,e){if(0===t.length)return"";return`
${t.map(t=>`\n
\n
${Vt(t.panelName||t.topology.device_name||"")}
\n ${Oe(t.topology,e,t.panelDeviceId)}\n
\n `).join("")}
`}_updateFavoritesPanelStats(t,e){if(this.hass&&0!==this._favoritesPanelStats.length)for(const n of this._favoritesPanelStats){const i=t.querySelector(`.panel-stats[data-stats-panel-id="${TM(n.panelDeviceId)}"]`);i&&MM(i,this.hass,n.topology,e,0)}}_buildDashboardConfig(){return{chart_metric:this._chartMetric,history_minutes:5,show_panel:!0,show_battery:!0,show_evse:!0,show_solar:!0}}async _scheduleTabRender(){await this.updateComplete,this._sidePanelOpen()?this._pendingTabRender=!0:await this._tabRenderScheduler()}_sidePanelOpen(){const t=this.shadowRoot?.getElementById("tab-content");return!!t?.querySelector("span-side-panel[open]")}async _renderTab(){const t=this._beginRender();this._dashboardTab.stop(),this._monitoringTab.stop(),this._adoptedTab.stop(),this._listCtrl.stop(),this._listDashCtrl.stopIntervals();for(const t of this._favoritesMonitoringTabs.values())t.stop();this._favoritesMonitoringTabs.clear(),this._favoritesPanelStats=[],this._renderedTopologies.clear();const e=this._root.getElementById("tab-content");if(e)if(this._isFavoritesView)await this._renderFavoritesTab(e,t);else switch(this._listDashCtrl.clearFavoriteRefs(),this._listCtrl.setViewName(null),this._applyPanelFavorites(),this._activeTab){case"dashboard":{e.innerHTML="";const n=this._buildDashboardConfig(),i=this._panels.find(t=>t.id===this._selectedPanelId),r=i?.config_entry_id??null,o=await this._dashboardTab.render(e,this.hass,this._selectedPanelId??"",n,r);t()||this._recordRenderedTopology(o);break}case"activity":{e.innerHTML="";const n=this._panels.find(t=>t.id===this._selectedPanelId),i=n?.config_entry_id??null;try{const n=new Xt(this._errorStore),r=await ze(this.hass,this._selectedPanelId??void 0,n);if(t())return;const o=this._buildDashboardConfig();if(this._listDashCtrl.init(r.topology,o,this.hass,i),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.monitoringCache.fetch(this.hass,i),t())return;if(await this._listDashCtrl.fetchAndBuildHorizonMaps(),t())return;const a=r.topology?$e(r.topology,o):"";if(this._listCtrl.setColumns(this._listColumns),this._listCtrl.renderActivityView(e,this.hass,r.topology,o,this._listDashCtrl.monitoringCache.status,a),await this._listDashCtrl.loadHistory(),t())return;this._listDashCtrl.updateDOM(e),this._listDashCtrl.startIntervals(e),this._recordRenderedTopology(r.topology)}catch(n){if(t())return;const i=document.createElement("p");i.style.color="var(--error-color)",i.textContent=qt(n),e.appendChild(i)}break}case"area":{e.innerHTML="";const n=this._panels.find(t=>t.id===this._selectedPanelId),i=n?.config_entry_id??null;try{const n=new Xt(this._errorStore),r=await ze(this.hass,this._selectedPanelId??void 0,n);if(t())return;const o=this._buildDashboardConfig();if(this._listDashCtrl.init(r.topology,o,this.hass,i),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.monitoringCache.fetch(this.hass,i),t())return;if(await this._listDashCtrl.fetchAndBuildHorizonMaps(),t())return;const a=r.topology?$e(r.topology,o):"";if(this._listCtrl.setColumns(this._listColumns),this._listCtrl.renderAreaView(e,this.hass,r.topology,o,this._listDashCtrl.monitoringCache.status,a),await this._listDashCtrl.loadHistory(),t())return;this._listDashCtrl.updateDOM(e),this._listDashCtrl.startIntervals(e),this._recordRenderedTopology(r.topology),this._areaTopology=r.topology,this.isConnected&&this._subscribeAreaTab()}catch(t){const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=qt(t),e.appendChild(n)}break}case"monitoring":{e.innerHTML="";const n=this._panels.find(t=>t.id===this._selectedPanelId),i=n?.config_entry_id??null,[,r]=await Promise.all([this._monitoringTab.render(e,this.hass,i??void 0),this._fetchBaselineTopology()]);t()||this._recordRenderedTopology(r);break}case"adopted":e.innerHTML="",await this._adoptedTab.render(e,this.hass,this._selectedPanelId??"")}}async _renderFavoritesTab(t,e){if(t.innerHTML="",!this.hass)return;const i=this._panels.filter(t=>t.id!==zI),r=await this._favCtrl.build(this.hass,this._favorites,i,this._errorStore);if(e())return;for(const t of r.perPanelStats)this._renderedTopologies.set(t.panelDeviceId,t.topology);const o=r.perPanelStats.map(t=>{const e=t.topology.panel_entities?.panel_status;return"string"==typeof e?{entityId:e,panelName:t.panelName}:null}).filter(t=>null!==t);this._favoritesWatchEntries=o,this._errorStore.watchPanelStatuses(o),this._errorStore.updateHass(this.hass);const a=new Map;for(const t of r.perPanelStats){const e=i.find(e=>e.id===t.panelDeviceId);a.set(t.panelDeviceId,{panelName:t.panelName,topology:t.topology,configEntryId:e?.config_entry_id??null})}this._listDashCtrl.setFavoritesPerPanelInfo(a);const s=r.topology,l=r.entryIds[0]??null,c=Object.keys(s.circuits).length>0,d=Object.keys(s.sub_devices??{}).length>0;if(!c&&!d){const e=document.createElement("p");return e.style.color="var(--secondary-text-color)",e.style.padding="24px",e.textContent=n("list.no_results"),void t.appendChild(e)}if(this._listDashCtrl.setFavoriteRefs(s._favoriteRefs),this._listDashCtrl.setPanelFavorites(null),"monitoring"===this._activeTab)return this._listCtrl.setViewName(null),void await this._renderFavoritesMonitoring(t,r.entryIds,i);const u=this._activeTab,h=new Set(Object.keys(s.circuits)),p=this._favoritesViewState.expanded[u].filter(t=>h.has(t));this._listCtrl.setViewName(u),this._listCtrl.setInitialExpansion(p),this._listCtrl.setInitialSearchQuery(this._favoritesViewState.searchQuery??""),this._listCtrl.setColumns(this._listColumns);const f=this._buildDashboardConfig();if(this._listDashCtrl.init(s,f,this.hass,l),this._listDashCtrl.powerHistory.clear(),await this._listDashCtrl.fetchAndBuildHorizonMaps(),e())return;const g=await this._listDashCtrl.fetchMergedMonitoringStatus(r.entryIds);if(!e()){this._favoritesPanelStats=r.perPanelStats;try{if(await this._listDashCtrl.loadHistory(),e())return;const n=this._buildFavoritesSummaryHTML(),i=this._buildFavoritesPanelStatsGridHTML(r.perPanelStats,f),o=n+i+(d?`
\n
${nn(s,this.hass,f,{showFavorites:this._listDashCtrl.showFavorites})}
\n
`:"");"activity"===u?this._listCtrl.renderActivityView(t,this.hass,s,f,g,o):this._listCtrl.renderAreaView(t,this.hass,s,f,g,o),this._updateFavoritesPanelStats(t,f),this._listDashCtrl.setupResizeObserver(t,t),this._listDashCtrl.startIntervals(t,()=>{this._updateFavoritesPanelStats(t,f)})}catch(n){if(e())return;const i=document.createElement("p");i.style.color="var(--error-color)",i.textContent=qt(n),t.appendChild(i)}}}async _renderFavoritesMonitoring(t,e,n){if(!this.hass)return;const i=document.createElement("div");i.className="favorites-monitoring-stack",t.appendChild(i);const r=new Map;for(const t of n){const e=t.config_entry_id;e&&r.set(e,t)}const o=new Map;for(const t of e){const e=r.get(t),n=document.createElement("div");n.className="favorites-monitoring-block",n.style.marginBottom="24px";const a=document.createElement("h2");a.style.margin="8px 0 12px",a.style.fontSize="1em",a.textContent=e?.name_by_user??e?.name??t,n.appendChild(a);const s=document.createElement("div");n.appendChild(s),i.appendChild(n);const l=new VM;l.errorStore=this._errorStore,o.set(t,l);try{await l.render(s,this.hass,t)}catch(e){console.warn("SPAN Panel: favorites monitoring render failed",t,e);const n=document.createElement("p");n.style.color="var(--error-color)",n.textContent=qt(e),s.appendChild(n)}}this._favoritesMonitoringTabs=o}_applyPanelFavorites(){if(!this._selectedPanelId||this._isFavoritesView)return this._listDashCtrl.setPanelFavorites(null),void this._dashboardTab.setPanelFavorites(null);const t=this._favorites[this._selectedPanelId],e={panelDeviceId:this._selectedPanelId,circuitUuids:new Set(t?.circuits??[]),subDeviceIds:new Set(t?.sub_devices??[])};this._listDashCtrl.setPanelFavorites(e),this._dashboardTab.setPanelFavorites(e)}};OI._shellStyles=M` :host { color: var(--primary-text-color); } @@ -381,4 +381,4 @@ var ps={},fs={};var gs,vs=function(){function t(t,e,n){var i=this;this._sleepAft opacity: 1; border-bottom-color: var(--app-header-text-color, white); } - `,NT.styles=[ET._shellStyles,k(uT)],_([Et({attribute:!1})],NT.prototype,"hass",void 0),_([Et({type:Boolean,reflect:!0})],NT.prototype,"narrow",void 0),_([zt()],NT.prototype,"_panels",void 0),_([zt()],NT.prototype,"_selectedPanelId",void 0),_([zt()],NT.prototype,"_activeTab",void 0),_([zt()],NT.prototype,"_discovered",void 0),_([zt()],NT.prototype,"_chartMetric",void 0),_([zt()],NT.prototype,"_listColumns",void 0),_([zt()],NT.prototype,"_favorites",void 0),NT=ET=_([(t=>(e,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)})("span-panel")],NT),console.warn("%c SPAN-PANEL %c v0.9.4 ","background: var(--primary-color, #4dd9af); color: #000; font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;"); + `,OI.styles=[EI._shellStyles,T(zM)],b([zt({attribute:!1})],OI.prototype,"hass",void 0),b([zt({type:Boolean,reflect:!0})],OI.prototype,"narrow",void 0),b([Nt()],OI.prototype,"_panels",void 0),b([Nt()],OI.prototype,"_selectedPanelId",void 0),b([Nt()],OI.prototype,"_activeTab",void 0),b([Nt()],OI.prototype,"_discovered",void 0),b([Nt()],OI.prototype,"_chartMetric",void 0),b([Nt()],OI.prototype,"_listColumns",void 0),b([Nt()],OI.prototype,"_favorites",void 0),OI=EI=b([(t=>(e,n)=>{void 0!==n?n.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)})("span-panel")],OI),console.warn("%c SPAN-PANEL %c v0.9.5 ","background: var(--primary-color, #4dd9af); color: #000; font-weight: 700; padding: 2px 6px; border-radius: 4px 0 0 4px;","background: var(--secondary-background-color, #333); color: var(--primary-text-color, #fff); padding: 2px 6px; border-radius: 0 4px 4px 0;"); diff --git a/custom_components/span_panel/grace_period.py b/custom_components/span_panel/grace_period.py index aa499174..836de298 100644 --- a/custom_components/span_panel/grace_period.py +++ b/custom_components/span_panel/grace_period.py @@ -8,9 +8,9 @@ import logging from typing import Any, Self +from homeassistant.components.sensor import SensorExtraStoredData from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import State -from homeassistant.helpers.restore_state import ExtraStoredData # Matches SensorEntity.native_value: the broadest concrete union that the # HA sensor platform can hand us. We accept it verbatim so sensor_base can @@ -49,24 +49,59 @@ def _parse_numeric_state(state: State | None) -> tuple[float | None, datetime | @dataclass -class SpanEnergyExtraStoredData(ExtraStoredData): +class SpanEnergyExtraStoredData(SensorExtraStoredData): """Extra stored data for Span energy sensors with grace period tracking. This data is persisted across Home Assistant restarts to maintain grace period state for energy sensors, preventing statistics spikes when the panel is offline at startup. + + It extends the sensor platform's own stored data rather than + `ExtraStoredData` directly, because that is what it is: `native_value` and + `native_unit_of_measurement` were restated copies of + `SensorExtraStoredData`'s two fields, which is what made + `RestoreSensor.extra_restore_state_data` -- declared to return a + `SensorExtraStoredData` -- need a `cast` to return one of these. Inheriting + them says the relationship instead of asserting it. + + The stored keys are unchanged by that: the same twelve names are written + and read as before, so records written by earlier versions restore + identically. See `as_dict` for why it does not delegate to the base. """ - native_value: float | None - native_unit_of_measurement: str | None last_valid_state: float | None last_valid_changed: str | None # ISO format datetime string energy_offset: float | None = None last_panel_reading: float | None = None last_dip_delta: float | None = None + # An unsettled dip, carried across restarts because a restart is exactly + # when one is most likely to be outstanding: the reading that would settle + # it is the one after the reading that booked it, and Home Assistant may + # stop in between. Dropping it would silently confirm the offset by + # forgetting it was ever provisional. + pending_dip_baseline: float | None = None + pending_dip_delta: float | None = None + # A corroborated dip that has not settled yet, carried for the same reason + # and with the readings left in its window, so a restart neither loses the + # ability to retract it nor buys it more readings. Stored data written + # before these existed has no such keys and restores them as None, which is + # the state those records were in. + confirmed_dip_baseline: float | None = None + confirmed_dip_delta: float | None = None + confirmed_dip_ticks_left: int | None = None def as_dict(self) -> dict[str, Any]: - """Return a dict representation of the extra data.""" + """Return a dict representation of the extra data. + + Every key is written here, the two inherited ones included, rather than + starting from `super().as_dict()`. The base encodes a `date`, `datetime` + or `Decimal` native value as a tagged dict that its own `from_dict` + decodes again, and `from_dict` below does not decode -- it reads each + key back as it was written. Since `extra_restore_state_data` coerces + this sensor's value to `float | None` before it ever gets here, writing + the value plainly keeps the pair symmetric instead of adding a decoder + for a shape these sensors cannot produce. + """ return { "native_value": self.native_value, "native_unit_of_measurement": self.native_unit_of_measurement, @@ -75,6 +110,11 @@ def as_dict(self) -> dict[str, Any]: "energy_offset": self.energy_offset, "last_panel_reading": self.last_panel_reading, "last_dip_delta": self.last_dip_delta, + "pending_dip_baseline": self.pending_dip_baseline, + "pending_dip_delta": self.pending_dip_delta, + "confirmed_dip_baseline": self.confirmed_dip_baseline, + "confirmed_dip_delta": self.confirmed_dip_delta, + "confirmed_dip_ticks_left": self.confirmed_dip_ticks_left, } @classmethod @@ -97,6 +137,11 @@ def from_dict(cls, restored: dict[str, Any]) -> Self | None: energy_offset=restored.get("energy_offset"), last_panel_reading=restored.get("last_panel_reading"), last_dip_delta=restored.get("last_dip_delta"), + pending_dip_baseline=restored.get("pending_dip_baseline"), + pending_dip_delta=restored.get("pending_dip_delta"), + confirmed_dip_baseline=restored.get("confirmed_dip_baseline"), + confirmed_dip_delta=restored.get("confirmed_dip_delta"), + confirmed_dip_ticks_left=restored.get("confirmed_dip_ticks_left"), ) except (AttributeError, KeyError, TypeError): return None diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index 2620c82b..161422a9 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -2,30 +2,28 @@ from __future__ import annotations +from collections import Counter +from collections.abc import Callable, Mapping +from hashlib import sha256 import logging -from homeassistant.components.persistent_notification import async_create -from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - entity_registry as er, # noqa: F401 — re-exported for patch compatibility -) -from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot +from homeassistant.helpers import entity_registry as er +from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot, SpanPVSnapshot from .entity_resolver import ( # noqa: F401 build_bess_unique_id_for_entry, build_binary_sensor_unique_id_for_entry, build_evse_unique_id_for_entry, + build_mid_unique_id_for_entry, + build_pv_inverter_unique_id_for_entry, build_select_unique_id_for_entry, build_switch_unique_id_for_entry, construct_circuit_unique_id_for_entry, - construct_multi_circuit_entity_id, construct_panel_unique_id_for_entry, - construct_single_circuit_entity_id, construct_synthetic_unique_id_for_entry, - construct_unmapped_entity_id, construct_unmapped_friendly_name, + entity_id_in_entry, get_device_identifier_for_entry, - get_unmapped_circuit_entity_id, resolve_evse_display_suffix, ) from .id_builder import ( # noqa: F401 @@ -37,7 +35,9 @@ build_binary_sensor_unique_id, build_circuit_unique_id, build_evse_unique_id, + build_mid_unique_id, build_panel_unique_id, + build_pv_inverter_unique_id, build_select_unique_id, build_switch_unique_id, construct_binary_sensor_unique_id, @@ -57,33 +57,37 @@ "CIRCUIT_SUFFIX_MAPPING", "PANEL_ENTITY_SUFFIX_MAPPING", "PANEL_SUFFIX_MAPPING", - "async_create_span_notification", + "adopted_capability_tokens", "build_bess_unique_id", + "build_mid_unique_id", "build_bess_unique_id_for_entry", + "build_mid_unique_id_for_entry", "build_binary_sensor_unique_id", "build_binary_sensor_unique_id_for_entry", "build_circuit_unique_id", "build_evse_unique_id", "build_evse_unique_id_for_entry", "build_panel_unique_id", + "build_pv_inverter_unique_id", + "build_pv_inverter_unique_id_for_entry", "build_select_unique_id", "build_select_unique_id_for_entry", "build_switch_unique_id", "build_switch_unique_id_for_entry", + "circuit_has_a_breaker_switch", + "circuit_has_a_priority_select", "construct_binary_sensor_unique_id", "construct_circuit_identifier_from_tabs", + "construct_circuit_label", "construct_circuit_unique_id", "construct_circuit_unique_id_for_entry", - "construct_multi_circuit_entity_id", "construct_panel_unique_id", "construct_panel_unique_id_for_entry", "construct_select_unique_id", - "construct_single_circuit_entity_id", "construct_switch_unique_id", "construct_synthetic_unique_id", "construct_synthetic_unique_id_for_entry", "construct_tabs_attribute", - "construct_unmapped_entity_id", "construct_unmapped_friendly_name", "construct_voltage_attribute", "detect_capabilities", @@ -91,50 +95,112 @@ "get_device_identifier_for_entry", "get_panel_entity_suffix", "get_suffix_from_sensor_key", - "get_unmapped_circuit_entity_id", "get_user_friendly_suffix", "has_bess", "has_evse", + "has_mid", "has_power_flows", "has_pv", + "has_shed_forecast", + "identity_digest", + "pv_inverter_capability_tokens", "is_panel_level_sensor_key", + "remove_withdrawn_controls", "resolve_evse_display_suffix", + "resolve_pv_display_suffix", + "resolve_pv_display_suffixes", ] _LOGGER = logging.getLogger(__name__) -async def async_create_span_notification( - hass: HomeAssistant, - message: str, - title: str, - notification_id: str, - level: str = "warning", -) -> None: - """Create a persistent notification for SPAN Panel issues. +def circuit_has_a_breaker_switch(circuit: SpanCircuitSnapshot) -> bool: + """Whether this circuit gets a breaker switch at all. - Args: - hass: Home Assistant instance - message: Notification message content - title: Notification title - notification_id: Unique identifier for the notification - level: Severity level (info, warning, error) + One predicate, read by `switch.async_setup_entry` when it decides what to + create and by the coordinator when it decides whether that answer has moved + since setup. A second copy would drift, and the drift would be invisible: a + switch left on the dashboard offering a control the panel refuses, or a + circuit the panel has commissioned that never grows the switch it earned. + It lives here rather than in `switch.py` because the coordinator is the only + reader that sees every circuit, and `switch.py` imports the coordinator -- + so the predicate has to sit below both of them. """ - _LOGGER.log( - getattr(logging, level.upper(), logging.WARNING), - "SPAN Panel %s: %s - %s", - level, - title, - message, - ) + if not circuit.is_user_controllable: + return False + # PV/EVSE circuits only get switches if they have a physical breaker + # (relative_position == "DOWNSTREAM" means connected at a breaker slot). + return not (circuit.device_type in ("pv", "evse") and circuit.relative_position != "DOWNSTREAM") - async_create( - hass, - message=message, - title=title, - notification_id=notification_id, - ) + +def circuit_has_a_priority_select(circuit: SpanCircuitSnapshot) -> bool: + """Whether this circuit gets a shed-priority select at all. + + The companion of `circuit_has_a_breaker_switch`, and read by the same two + callers for the same reason: `select.async_setup_entry` to decide what to + create, the coordinator to notice the answer changing under a live entry. + + Priority settability is its own commissioning flag, not the relay's. v1.0 + expresses never-backup as the absence of `$settable` on + `load-shed/priority`, which the adapter carries here. + """ + if not circuit.is_user_controllable: + return False + if circuit.is_never_backup: + return False + # PV/EVSE circuits only get selects if they have a physical breaker + # (relative_position == "DOWNSTREAM" means connected at a breaker slot). + return not (circuit.device_type in ("pv", "evse") and circuit.relative_position != "DOWNSTREAM") + + +def remove_withdrawn_controls( + registry: er.EntityRegistry, + entry_id: str, + domain: str, + circuits: Mapping[str, SpanCircuitSnapshot], + qualifies: Callable[[SpanCircuitSnapshot], bool], + unique_id_for: Callable[[str], str], +) -> list[str]: + """Remove this entry's control for every circuit that no longer qualifies for one. + + Called by each control platform's `async_setup_entry` with the predicate and + unique-id builder it creates with, so creation and removal cannot disagree. + Setup sees every path a withdrawal takes: a change while running, which + reloads; a change across a restart; and an upgrade from a release that left + the control orphaned. + + Removed rather than disabled. A disabled control could be enabled again by a + user while the panel refuses it, and would stay listed for a control that does + not exist. Core keeps the removed entry's tombstone while the config entry + exists, so a control the panel offers again returns with its entity id, name, + area and the rest of the user's customisations. + + Only circuits in `circuits` -- the setup snapshot -- are judged. A circuit + absent from it is never a withdrawal (see the coordinator's + `_check_settability_change`). An `unmapped_tab_*` pseudo-circuit is judged + like any other, but no release ever registered a switch or select for one, + so its lookup finds nothing and nothing is removed. Only this entry's + registration is removed: `entity_id_in_entry` ignores another entry holding + the same unique id. + + Returns the entity ids removed. + """ + removed: list[str] = [] + for circuit_id, circuit in circuits.items(): + if qualifies(circuit): + continue + entity_id = entity_id_in_entry(registry, entry_id, domain, unique_id_for(circuit_id)) + if entity_id is None: + continue + registry.async_remove(entity_id) + _LOGGER.info( + "Removed %s: the panel no longer offers this control on circuit %s", + entity_id, + circuit_id, + ) + removed.append(entity_id) + return removed def construct_circuit_identifier_from_tabs(tabs: list[int], circuit_id: str = "") -> str: @@ -143,75 +209,107 @@ def construct_circuit_identifier_from_tabs(tabs: list[int], circuit_id: str = "" Used as a fallback when a circuit has no panel-assigned name. Args: - tabs: List of tab numbers (1 for 120V, 2 for 240V dipole) + tabs: Every panel position the breaker occupies circuit_id: Fallback identifier when tabs are unavailable Returns: - String like "Circuit 30 32" for 240V or "Circuit 15" for 120V + String like "Circuit 30 32" for a two-pole breaker or "Circuit 15" for a + single-pole one, naming every position however many there are """ - if tabs and len(tabs) == 2: - sorted_tabs = sorted(tabs) - return f"Circuit {sorted_tabs[0]} {sorted_tabs[1]}" - if tabs and len(tabs) == 1: - return f"Circuit {tabs[0]}" + if tabs: + return "Circuit " + " ".join(str(tab) for tab in sorted(tabs)) return f"Circuit {circuit_id}" +def construct_circuit_label(circuit: SpanCircuitSnapshot | None, circuit_id: str) -> str: + """Name a circuit the way its owner knows it, for a message addressed to them. + + The panel's own name where there is one, and the tab identifier otherwise -- + which is what the entity itself was named from, so an error names the thing + the user is looking at rather than a wire UUID. `None` is accepted because a + circuit can drop out of a snapshot between a command and its failure, and an + error message is the worst possible place to raise a second error. + """ + if circuit is not None and circuit.name: + return circuit.name + tabs = circuit.tabs if circuit is not None else [] + return construct_circuit_identifier_from_tabs(tabs, circuit_id) + + def construct_tabs_attribute(circuit: SpanCircuitSnapshot) -> str | None: """Construct tabs attribute string from circuit data. - For US electrical systems, circuits can only have 1 tab (120V) or 2 tabs (240V). + Names every position the breaker occupies, however many that is. v1.0 + publishes them literally in ``info/spaces``; the flat schema published one + space plus a ``dipole`` flag and its adapter recovers the pair from that. So + this sees at most two positions on flat, and on v1.0 exactly what the panel + reported. + + **Accepting more than two is defensive, not a fix for observed hardware.** + SPAN has stated that its panels "are split-phase and publish only 1- or + 2-pole breakers", and no circuit on any panel captured so far occupies more + than two positions. The ``1:4:1`` range on ``breaker/poles`` is the generic + eBus catalog, which covers load centres that are not SPAN. What this + replaces is a hard failure: three positions used to drop the attribute + entirely and log that the hardware was "not valid for US electrical + system", which is a poor way to meet input we merely have not seen. Args: circuit: SpanCircuitSnapshot object with tabs information Returns: - Tabs attribute string like "tabs [30:32]" for 240V or "tabs [28]" for 120V, - or None if no tabs information is available + Tabs attribute string like "tabs [30:32]", or None if no tabs + information is available Examples: - Single tab (120V): "tabs [28]" - Two tabs (240V): "tabs [30:32]" + Single tab: "tabs [28]" + Two tabs: "tabs [30:32]" + Three tabs: "tabs [17:19:21]" No tabs: None """ if not circuit.tabs: return None - # Sort tabs for consistent ordering - sorted_tabs = sorted(circuit.tabs) - - if len(sorted_tabs) == 1: - # Single tab (120V) - return f"tabs [{sorted_tabs[0]}]" - if len(sorted_tabs) == 2: - # Two tabs (240V) - format as range - return f"tabs [{sorted_tabs[0]}:{sorted_tabs[1]}]" - # More than 2 tabs is not valid for US electrical system - _LOGGER.warning( - "Circuit %s has %d tabs, which is not valid for US electrical system (expected 1 or 2)", - circuit.circuit_id, - len(sorted_tabs), - ) - return None + return f"tabs [{':'.join(str(tab) for tab in sorted(circuit.tabs))}]" def construct_voltage_attribute(circuit: SpanCircuitSnapshot) -> int | None: - """Construct voltage attribute for a circuit based on tab count. - - For US electrical systems, circuits can only have 1 tab (120V) or 2 tabs (240V). + """Return the nominal voltage for a circuit, inferred from its pole count. + + **Nominal, not measured, and there is nothing better to read.** The eBus + circuit ``meter`` capability publishes current, active power and energy + only; voltage is a panel-level quantity, published as the enclosure's + ``meter/voltage-a`` / ``voltage-b``. No per-circuit voltage exists on the + wire. + + **It is derived from the pole count, not from the positions.** Those are + different claims and only the second would be unsound: the specification + defines ``spaces`` as identifying every occupied slot "without assuming a + numbering convention", so reading a leg out of a position *number* is + exactly what the property exists to make unnecessary. The count comes from + ``breaker/poles``, published outright. Given the count, SPAN supplies the + rest -- it has stated that its panels "are split-phase and publish only 1- + or 2-pole breakers" -- and on a split-phase service a two-pole breaker is + line-to-line across both legs. So 240 rests on a vendor statement about + service type, not on a layout convention. + + **Which is also why it stops at two poles.** Three or more is not a + split-phase circuit at all -- 208V line-to-line on a three-phase wye + service, 240V on a high-leg delta -- and nothing published distinguishes + them. Deriving it from ``P / I`` does not rescue it either: that yields + ``V * pf`` through a 0.1A quantiser, which on real circuits lands within 1% + once in 27 and reads 0V for any circuit drawing standby current at zero + real power. None means we do not know, and callers omit the attribute + rather than publish a guess. Args: circuit: SpanCircuitSnapshot object with tabs information Returns: - Voltage in volts (120 for single tab, 240 for double tab), or None if no tabs information - - Examples: - Single tab (120V): 120 - Two tabs (240V): 240 - No tabs: None + 120 for a single-pole circuit, 240 for a two-pole one, or None when + there is no tab information or the pole count does not determine it """ if not circuit.tabs: @@ -221,12 +319,6 @@ def construct_voltage_attribute(circuit: SpanCircuitSnapshot) -> int | None: return 120 if len(circuit.tabs) == 2: return 240 - # More than 2 tabs is not valid for US electrical system - _LOGGER.warning( - "Circuit %s has %d tabs, which is not valid for US electrical system (expected 1 or 2)", - circuit.circuit_id, - len(circuit.tabs), - ) return None @@ -246,29 +338,315 @@ def has_pv(snapshot: SpanPanelSnapshot) -> bool: ) +def resolve_pv_display_suffix( + pv: SpanPVSnapshot, + snapshot: SpanPanelSnapshot, + use_circuit_numbers: bool, +) -> str | None: + """Resolve the display suffix for one inverter's device name. + + Taken from the circuit that feeds the inverter, in the mode the user names + circuits in: the circuit's panel name in friendly-names mode, its breaker + positions in circuit-numbers mode, and the positions in either mode when + the circuit has no name. The inverter's serial is not a candidate, unlike a + charger's: it is usually unpublished, and nothing here reads it. + + Returns None for an inverter no circuit feeds. See + `resolve_pv_display_suffixes`, which keeps several inverters' names apart. + """ + circuit = snapshot.circuits.get(pv.feed_circuit_id) if pv.feed_circuit_id else None + if circuit is None: + return None + if not use_circuit_numbers and circuit.name: + name: str = circuit.name + return name + return construct_circuit_identifier_from_tabs(circuit.tabs, circuit.circuit_id) + + +def resolve_pv_display_suffixes( + snapshot: SpanPanelSnapshot, use_circuit_numbers: bool +) -> dict[str, str | None]: + """Resolve every inverter's display suffix, distinct from each other's. + + Keyed like `snapshot.pv_inverters`. Each starts from + `resolve_pv_display_suffix`; where that is missing or shared (two + unfed inverters, or two feeding circuits with the same panel name), a + circuit-fed inverter falls back to its breaker positions, and whatever is + still missing or shared then takes an ordinal, assigned in key order. + Without this, two inverters' devices would carry the same name. + """ + inverters = snapshot.pv_inverters + suffixes = { + key: resolve_pv_display_suffix(pv, snapshot, use_circuit_numbers) + for key, pv in inverters.items() + } + if len(suffixes) < 2: + return suffixes + counts = Counter(suffixes.values()) + for key, pv in inverters.items(): + if suffixes[key] is None or counts[suffixes[key]] > 1: + suffixes[key] = resolve_pv_display_suffix(pv, snapshot, use_circuit_numbers=True) + counts = Counter(suffixes.values()) + taken = {suffix for suffix, count in counts.items() if suffix is not None and count == 1} + ordinal = 0 + for key in sorted(suffixes): + suffix = suffixes[key] + if suffix is not None and suffix in taken: + continue + ordinal += 1 + while str(ordinal) in taken: + ordinal += 1 + suffixes[key] = str(ordinal) + return suffixes + + def has_power_flows(snapshot: SpanPanelSnapshot) -> bool: """Detect whether the power-flows node is publishing data.""" return snapshot.power_flow_site is not None +def has_mid(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether a Microgrid Interconnect Device is published. + + Unambiguous, unlike `has_bess`, which has to infer presence from + `soe_percentage is not None` because the battery field is always there. The library + makes `mid` optional precisely so presence needs no sentinel. + + Always false on flat firmware, which publishes no MID at all. + + DUAL-SCHEMA: this integration must serve flat and parent/child panels side by side until + every panel has hot-loaded v1.0. Grep this token to find every place that branches on + which schema a panel is publishing; when the flat path is finally retired, these are + the conditionals that become unconditional and the flat branches that get deleted. + Nothing here may *assume* parent/child before then. + """ + return snapshot.mid is not None + + +def has_shed_forecast(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether the panel publishes an `energy.ebus.capability.shed-forecast` node. + + Presence of the *capability*, from presence of any of the five fields it + fills. The library models each one as `None` when unpublished, and a panel + with no such node fills none of them, so any non-`None` field is the node — + there is no telemetry value that could be mistaken for it. That is why this + reads all five rather than only the two that back sensors: a firmware + publishing the node with a partial property set still has the capability, + and the per-sensor gate in `create_shed_forecast_sensors` is what decides + which entities that firmware can actually support. + + Always false on flat firmware, which publishes no such node at all. + + DUAL-SCHEMA: gated on what the snapshot carries rather than on a schema + version, so a panel that hot-loads parent/child mid-life gains the + capability, reloads, and the sensors appear. + """ + return any( + value is not None + for value in ( + snapshot.shed_time_to_priority_shed_min, + snapshot.shed_total_time_remaining_min, + snapshot.shed_full_charge_time_to_priority_shed_min, + snapshot.shed_full_charge_total_time_remaining_min, + snapshot.shed_forecast_confidence, + ) + ) + + +def has_bess_telemetry(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether the BESS publishes anything about itself beyond its state of charge. + + Presence of the BESS's own `meter` and `status` capability nodes, from + presence of the fields they fill. A BESS may be commissioned and publish + neither: `has_bess` reads `soc/soc`, which is a different node, and every flat + panel's BESS has no such properties at all. + + Separate from `has_bess` rather than folded into it, because the two answer + different questions and the wrong one is silently wrong. `has_bess` decides + whether the sub-device exists; this decides whether two of its sensors can be + created. Merging them would either delete the metadata sensors from a BESS + with no meter node or invent two permanently-unknown ones on it. + + DUAL-SCHEMA: gated on what the snapshot carries rather than on a schema + version, so a BESS that gains these nodes on a firmware upgrade reaches + `detect_capabilities`, the coordinator reloads, and the sensors appear. + """ + return snapshot.battery.power_w is not None or snapshot.battery.communication_state is not None + + +def has_pcs(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether the panel runs a Power Control System. + + The one capability gate here that cannot be a value test, and the library is + where that is enforced: `SpanPanelSnapshot.pcs` is `None` exactly when the + enclosure declares no `pcs` node, per the capability's own rule that + "absence of the `pcs` node means the device does not run (or participate in) + a Power Control System". + + A value test would be wrong rather than merely awkward. Every property this + capability publishes is legally zero — the reference capture is a PCS that + exists and is switched off, reporting `0.0` on every limit — so reading the + values would delete the entities of every panel whose PCS is unconfigured, + which is the state most panels are in and the state a user most wants to + see. + + Always false on flat firmware, which publishes no such node at all. + + DUAL-SCHEMA: gated on what the snapshot carries rather than on a schema + version, so a panel that gains the node reaches `detect_capabilities`, the + coordinator reloads, and the entities appear. + """ + return snapshot.pcs is not None + + def has_evse(snapshot: SpanPanelSnapshot) -> bool: - """Detect whether an EVSE (EV charger) is commissioned.""" - return len(snapshot.evse) > 0 + """Detect whether an EVSE (EV charger) is commissioned. + + A circuit typed `evse` counts even before the charger appears in + `snapshot.evse`: the panel has commissioned it and the device usually + arrives on a later snapshot. Creation still iterates `snapshot.evse`, so + the wider signal adds no entities -- it makes the coordinator ask for a + reload at the moment the panel first admits the charger exists. + """ + return len(snapshot.evse) > 0 or any( + circuit.device_type == "evse" for circuit in snapshot.circuits.values() + ) + + +def has_der_link_health(snapshot: SpanPanelSnapshot) -> bool: + """Detect whether the panel reports the link to any circuit-fed DER. + + Presence of the *record*, from presence of the field it fills. The library + models `connected` as `None` for a DER no circuit claims, and the enum a + circuit does publish is `OK,LOST,DEGRADED` with no UNKNOWN member — so an + absent property is the only way the panel can say it does not know, and + `None` is the only reading that can mean it. + + A value gate would be wrong here in a way it is not for the PCS: the + question is not what the link is doing but whether the panel says anything + about it, and `distribution-enclosure.md` makes silence the normal state for + a circuit that feeds an ordinary load rather than a DER. + + Coarse on purpose. This decides whether a *reload* is worth requesting, not + which entities exist — the per-DER gate in `binary_sensor.async_setup_entry` + does that, because two chargers can be fed by two circuits of which only one + publishes the record. + + Always false on flat firmware, which publishes this only for the BESS, and + reaches `battery.connected` rather than either field here. + + DUAL-SCHEMA: gated on what the snapshot carries rather than on a schema + version, so a panel that starts publishing the record reaches + `detect_capabilities`, the coordinator reloads, and the sensors appear. + """ + # `snapshot.pv` is the lone inverter or the inverters together, so a record + # on it is a record on some inverter here; reading the aggregate as well would + # only add a value that comes and goes with what the links read. + return any(pv.connected is not None for pv in snapshot.pv_inverters.values()) or any( + evse.connected is not None for evse in snapshot.evse.values() + ) + + +def identity_digest(identity: str) -> str: + """Return a short, stable digest of one wire identity. + + Not a security boundary and not trying to be one — this is the same rule + `diagnostics.AdoptedDeviceRow` states and applies to `parent`: a device id can + embed a serial, because producers derive a DER's id preferring a serial over a + default slug, which is why this repository holds PV's `info/serial-number` + unvalued. The tokens below are the only capability names that carry wire + identity, and the capability set is *logged at INFO* by + `_check_capability_change` whenever it expands. A serial does not belong in + a line that ends up pasted into an issue. + + Everything the reload trigger needs survives the digest: it compares tokens for + equality and never reads one, so a stable digest is the identity as far as + that comparison is concerned. What is lost is only a maintainer's ability to + read *which* device appeared out of the log line, and the diagnostics download + already answers that with the device type and counts. + """ + return sha256(identity.encode("utf-8")).hexdigest()[:12] + + +def adopted_capability_tokens(snapshot: SpanPanelSnapshot) -> frozenset[str]: + """One token per adopted device and per vendor extension property. + + Vendor extensibility is the one part of the snapshot whose vocabulary this + integration cannot enumerate in advance, so it cannot be reduced to a named + flag the way `bess` or `pcs` are. It reaches the reload trigger as its + identities, digested — see `identity_digest` for why the raw ids must not appear here. + + **One token each rather than one hash of the set.** The trigger fires on set + *expansion* (`current - known`), and a hash of a shrinking set is as "new" as + a hash of a growing one — so a single token would reload the integration when + a vendor device left the tree, and flap it on a device that came and went. + Per-identity tokens make the expansion-only rule hold for free: a device that + arrives adds a token, a device that leaves removes one, and only the first is + a reload. + + The extension token digests its **instance key** and keeps its subject kind + and wire path in the clear. The key is an identity — an EVSE's node id, a + circuit's uuid — and can carry the same serial a device id can; `battery` and + `meter/cell-temperature` are vendor vocabulary that names no install. + + Keyed on the wire identity rather than on the value: a reading changing is + every refresh, and the question here is only whether an entity that could not + be created at setup now can. + """ + return frozenset( + [f"adopted:{identity_digest(device.device_id)}" for device in snapshot.adopted_devices] + + [ + f"extension:{row.subject.kind}:" + f"{'' if row.subject.instance_key is None else identity_digest(row.subject.instance_key)}:" + f"{row.path}" + for row in snapshot.extension_properties + ] + ) + + +def pv_inverter_capability_tokens(snapshot: SpanPanelSnapshot) -> frozenset[str]: + """One token per inverter key, one inverter or several, so a key the panel starts publishing reloads. + + Digested because a key can be a device id, which carries the panel serial; see `identity_digest`. + """ + return frozenset(f"pv_inverter:{identity_digest(key)}" for key in snapshot.pv_inverters) def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]: """Derive the set of optional capabilities present in the snapshot. - Used by the coordinator to detect when new hardware (BESS, PV, EVSE) appears - and trigger a reload so new sensors are created. + Used by the coordinator to detect when new hardware (BESS, PV, EVSE, MID) or a + new published capability (shed-forecast) appears, and trigger a reload so new + sensors are created. A capability is not hardware, but it reaches this the same + way — the panel starts publishing a node it did not publish before — and the + consequence is identical: entities that could not be created at setup now can. + + Adopted devices and vendor extension properties are in the set for exactly + that reason. `adoption.py` opens by calling a device type nobody modelled an + *expected* event rather than a hypothetical one, and nothing adds those + entities dynamically — so with them excluded, a vendor device that appeared an + hour after setup produced no device, no entity and no reload, and the user saw + nothing at all until they restarted Home Assistant. See + `adopted_capability_tokens` for why they are carried as identities. """ - caps: set[str] = set() + caps: set[str] = set(adopted_capability_tokens(snapshot)) if has_bess(snapshot): caps.add("bess") + caps.update(pv_inverter_capability_tokens(snapshot)) if has_pv(snapshot): caps.add("pv") if has_power_flows(snapshot): caps.add("power_flows") if has_evse(snapshot): caps.add("evse") + if has_mid(snapshot): + caps.add("mid") + if has_shed_forecast(snapshot): + caps.add("shed_forecast") + if has_bess_telemetry(snapshot): + caps.add("bess_telemetry") + if has_pcs(snapshot): + caps.add("pcs") + if has_der_link_health(snapshot): + caps.add("der_link_health") return frozenset(caps) diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json index 4a7e9445..1272e4a6 100644 --- a/custom_components/span_panel/icons.json +++ b/custom_components/span_panel/icons.json @@ -32,6 +32,15 @@ }, "wifi_link": { "default": "mdi:wifi" + }, + "pcs_active": { + "default": "mdi:speedometer" + }, + "pv_panel_link": { + "default": "mdi:solar-power-variant" + }, + "evse_panel_link": { + "default": "mdi:lan-connect" } }, "button": { @@ -39,6 +48,11 @@ "default": "mdi:transmission-tower" } }, + "number": { + "evse_charge_current_limit": { + "default": "mdi:ev-station" + } + }, "select": { "circuit_priority": { "default": "mdi:priority-high" @@ -57,6 +71,9 @@ "bess_model": { "default": "mdi:battery-outline" }, + "bess_communication_state": { + "default": "mdi:lan-connect" + }, "bess_nameplate_capacity": { "default": "mdi:battery-high" }, @@ -180,12 +197,18 @@ "pv_vendor": { "default": "mdi:domain" }, + "shed_total_time_remaining": { + "default": "mdi:battery-clock-outline" + }, "site_power": { "default": "mdi:home-lightning-bolt" }, "software_version": { "default": "mdi:information" }, + "time_to_priority_shed": { + "default": "mdi:timer-alert-outline" + }, "upstream_l1_current": { "default": "mdi:current-ac" }, @@ -198,6 +221,15 @@ "connected": "mdi:cloud-check", "unconnected": "mdi:cloud-off-outline" } + }, + "pcs_import_limit": { + "default": "mdi:transmission-tower-import" + }, + "pcs_binding_constraint": { + "default": "mdi:scale-balance" + }, + "bess_meter_power": { + "default": "mdi:battery-charging-outline" } } }, diff --git a/custom_components/span_panel/id_builder.py b/custom_components/span_panel/id_builder.py index b790883c..0c074acc 100644 --- a/custom_components/span_panel/id_builder.py +++ b/custom_components/span_panel/id_builder.py @@ -31,6 +31,26 @@ "current": "current", "breaker_rating": "breaker_rating", } +"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style. + +Every entry here translates a legacy camelCase description key into the suffix +its entities have carried since before 2.0.8, so a changed entry moves a live +`unique_id` on every installed panel, and a moved `unique_id` costs the +statistics. + +It governs the `unique_id` only -- the wording an entity id ends with lives in +`naming.py`. + +So the rule for anything new is **verbatim**: a description key added from here on +resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`, +`build_mid_unique_id`, `build_evse_unique_id`) have always done. Their keys were +written snake_case and never needed translating. + +`tests/test_suffix_mappings_are_closed.py` holds these dictionaries to their +exact contents. It fails on an added key, a removed key and a changed value, +because all three move a live id. +""" + # Panel sensor API field mappings (used by get_user_friendly_suffix) # Includes main meter/feedthrough produced, consumed, and net energy @@ -49,6 +69,26 @@ "feedthroughNetEnergyWh": "feed_through_energy_net", # Consistent naming "batteryPercentage": "battery_percentage", } +"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style. + +Every entry here translates a legacy camelCase description key into the suffix +its entities have carried since before 2.0.8, so a changed entry moves a live +`unique_id` on every installed panel, and a moved `unique_id` costs the +statistics. + +It governs the `unique_id` only -- the wording an entity id ends with lives in +`naming.py`. + +So the rule for anything new is **verbatim**: a description key added from here on +resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`, +`build_mid_unique_id`, `build_evse_unique_id`) have always done. Their keys were +written snake_case and never needed translating. + +`tests/test_suffix_mappings_are_closed.py` holds these dictionaries to their +exact contents. It fails on an added key, a removed key and a changed value, +because all three move a live id. +""" + # Panel entity suffix mappings (used by get_panel_entity_suffix) # These are the actual entity_id/unique_id suffixes used for panel sensors @@ -68,6 +108,26 @@ "feedthroughNetEnergyWh": "feed_through_net_energy", "batteryPercentage": "battery_level", } +"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style. + +Every entry here translates a legacy camelCase description key into the suffix +its entities have carried since before 2.0.8, so a changed entry moves a live +`unique_id` on every installed panel, and a moved `unique_id` costs the +statistics. + +It governs the `unique_id` only -- the wording an entity id ends with lives in +`naming.py`. + +So the rule for anything new is **verbatim**: a description key added from here on +resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`, +`build_mid_unique_id`, `build_evse_unique_id`) have always done. Their keys were +written snake_case and never needed translating. + +`tests/test_suffix_mappings_are_closed.py` holds these dictionaries to their +exact contents. It fails on an added key, a removed key and a changed value, +because all three move a live id. +""" + # Combined mapping for general suffix lookup ALL_SUFFIX_MAPPINGS = {**CIRCUIT_SUFFIX_MAPPING, **PANEL_SUFFIX_MAPPING} @@ -314,6 +374,18 @@ def build_bess_unique_id(serial: str, description_key: str) -> str: return f"span_{serial}_bess_{description_key}" +def build_mid_unique_id(serial: str, description_key: str) -> str: + """Build unique ID for Microgrid Interconnect Device sensors (pure function). + + Returns: "span_{serial}_mid_{description_key}" + + Keyed on the panel serial rather than the MID's own, matching `build_bess_unique_id`. + The MID is one per enclosure, so the panel serial already makes it unique, and a + device-derived key would move if the BESS the MID ships with were ever replaced. + """ + return f"span_{serial}_mid_{description_key}" + + def build_evse_unique_id(serial: str, evse_id: str, description_key: str) -> str: """Build unique ID for EVSE sensor/binary_sensor entities (pure function). @@ -322,6 +394,22 @@ def build_evse_unique_id(serial: str, evse_id: str, description_key: str) -> str return f"span_{serial}_evse_{evse_id}_{description_key}" +def build_pv_inverter_unique_id(serial: str, inverter_key: str, description_key: str) -> str: + """Build unique ID for one inverter's entities on a multi-inverter panel (pure function). + + Returns: "span_{serial}_pv_{inverter_key}_{description_key}" + + `inverter_key` is the library's `pv_inverters` key: the feeding circuit's id + where a circuit feeds the inverter, the inverter's device id otherwise. Never + the inverter's serial, which is often unpublished. The serial is not + lower-cased, matching `build_evse_unique_id`, the other per-instance builder. + + A panel with one inverter does not reach this: its entities keep the + panel-scoped ids they have always had. + """ + return f"span_{serial}_pv_{inverter_key}_{description_key}" + + def construct_synthetic_unique_id(serial: str, sensor_name: str) -> str: """Build unique ID for synthetic sensors using consistent pattern (pure function). diff --git a/custom_components/span_panel/leaf_repairs.py b/custom_components/span_panel/leaf_repairs.py new file mode 100644 index 00000000..24180f19 --- /dev/null +++ b/custom_components/span_panel/leaf_repairs.py @@ -0,0 +1,138 @@ +"""The Repair raised when the panel's certificate does not name the configured host. + +The third outcome of the library's failed-handshake diagnosis, and the only one +that used to reach nobody. A pinned CA that still validates the panel's +certificate says the peer is the panel; a certificate that does not carry the +configured address says the panel is not where the entry claims it is. Most often +a new DHCP lease, sometimes an entry recorded under a name the panel does not +know itself by. + +Separate from `ca_repairs` because the two are reconciled on opposite terms, and +this one is the `schema_repairs` model rather than the `ca_repairs` one: + +- **Not persistent.** The transport is alive and still retrying. The library + re-derives this on every attempt and re-arms the signal on the next successful + connect, so there is live state to re-assert it from and a restart may sweep it + away. +- **Severity `WARNING`, not `ERROR`.** Nothing is being intercepted and nothing + has been refused -- the chain verified under the pin. The panel is simply + somewhere other than where the entry says. +- **Not fixable.** The remedy is Reconfigure, which already exists, already + refuses a host the leaf does not name, and already carries the FQDN + registration path. A Repairs fix flow cannot hand off to a config flow, so a + fixable Repair would have to re-implement a subset of Reconfigure -- host + entry, verdict, store -- and `async_panel_leaf_host` warns in as many words + that persisting a resolved address is where that subset goes wrong. One + implementation of "change the host" is the point; this Repair's job is to make + the condition visible and name the address to use. + +Two translation keys rather than one, because a certificate that names nothing +at all is a different sentence and not a different value. Home Assistant +substitutes `translation_placeholders` into the description verbatim, so a phrase +passed as a placeholder would reach every user in English no matter what language +they read the rest of the notice in; the empty case therefore gets a description +of its own, and the phrase is translated with everything else. It also reads as +what it is -- a fault in the panel rather than in the address the user chose. +""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir +from span_panel_api import LeafNameMismatch + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +LEAF_NAME_MISMATCH_ISSUE_PREFIX = "panel_leaf_name_mismatch_" + +_NAMED_TRANSLATION_KEY = "panel_leaf_name_mismatch" +_UNNAMED_TRANSLATION_KEY = "panel_leaf_name_mismatch_no_names" + + +def leaf_name_mismatch_issue_id(entry_id: str) -> str: + """Issue id for one entry's name mismatch.""" + return f"{LEAF_NAME_MISMATCH_ISSUE_PREFIX}{entry_id}" + + +@callback +def async_raise_leaf_name_mismatch( + hass: HomeAssistant, + entry: ConfigEntry, + mismatch: LeafNameMismatch, +) -> None: + """Raise the Repair, carrying the configured host and the names actually served. + + Both, because "the address is wrong" without saying which address is right + leaves the user exactly where the log line left them. The names come from the + certificate's SAN entries in certificate order, and one of them is what + Reconfigure wants. + + `is_persistent=False` because the condition is derived from a transport that + is still running: the library re-diagnoses it on every reconnect attempt and + re-arms the signal on the next successful connect, so a restart that resolves + nothing raises it again and a restart after the panel came back does not. + """ + issue_id = leaf_name_mismatch_issue_id(entry.entry_id) + if mismatch.leaf_names: + leaf_names = ", ".join(mismatch.leaf_names) + _LOGGER.warning( + "SPAN panel %s is configured as %s, but the certificate it serves names only " + "%s. The panel has probably moved. Use Reconfigure on the SPAN Panel entry to " + "point it at one of those names; the integration keeps retrying meanwhile", + entry.title, + mismatch.host, + leaf_names, + ) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=_NAMED_TRANSLATION_KEY, + translation_placeholders={ + "panel": entry.title, + "host": mismatch.host, + "leaf_names": leaf_names, + }, + ) + return + + _LOGGER.warning( + "SPAN panel %s is configured as %s, but the certificate it serves names no address " + "at all, so there is no address to re-point the entry at. The panel's certificate " + "needs regenerating; the integration keeps retrying meanwhile", + entry.title, + mismatch.host, + ) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=_UNNAMED_TRANSLATION_KEY, + translation_placeholders={ + "panel": entry.title, + "host": mismatch.host, + }, + ) + + +@callback +def async_clear_leaf_name_mismatch(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the Repair. + + Cleared on any connection that succeeded under the current configuration -- + which is exactly when the library re-arms the signal, so the two never + disagree about whether the condition still holds -- and when a CA change + takes over the same entry, because that finding supersedes this one. + """ + ir.async_delete_issue(hass, DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) diff --git a/custom_components/span_panel/logbook.py b/custom_components/span_panel/logbook.py new file mode 100644 index 00000000..9d25e2e8 --- /dev/null +++ b/custom_components/span_panel/logbook.py @@ -0,0 +1,115 @@ +"""Render control commands in the logbook. + +Discovered by name: Home Assistant looks for `logbook.async_describe_events` in +the integration and calls it once at startup. No manifest change is needed. + +The audit event carries a wire address and a `user_id`; the logbook wants a +sentence. The translation is deliberately blunt about the four outcomes, because +the whole point of `PublishState` having four members rather than two is that +"the panel did it", "the broker took it and the panel did not act", "nothing came +back" and "this will never be delivered" send an investigation in four different +directions. + +When there is no user, the originating automation or script is resolved from the +context so a contextless write is attributed to *what* rather than left blank — +which is the case the audit exists for. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.components.logbook import ( + LOGBOOK_ENTRY_ENTITY_ID, + LOGBOOK_ENTRY_MESSAGE, + LOGBOOK_ENTRY_NAME, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import Event, HomeAssistant, callback + +from .const import DOMAIN, EVENT_CONTROL_COMMAND + +_OUTCOME_PHRASES: dict[str, str] = { + "confirmed": "and the panel confirmed it", + # Not a success and not a failure. The honest message is that the panel + # accepted the command and never reported the change, which most often means + # the value was already what was asked for. + "accepted": "and the broker accepted it, but the panel reported no change", + "unconfirmed": "with no acknowledgement and no reported change", + # The one state that is a promise about the future. + "failed": "but it was never sent", +} + +_REFUSAL_PHRASES: dict[str, str] = { + "control_disabled": "but control of this panel is switched off", + "control_locked": "but the control lock is armed", + "contextless_control_refused": "but commands without a logged-in user are refused", + "admin_only_control": "but only administrators may operate this panel", + "relay_debounced": "but that circuit was operated moments ago", +} + + +def _describe_outcome(outcome: str) -> str: + """Turn one `outcome` field into a clause.""" + if outcome.startswith("refused:"): + reason = outcome.removeprefix("refused:") + return _REFUSAL_PHRASES.get(reason, f"but it was refused ({reason})") + return _OUTCOME_PHRASES.get(outcome, f"with an unrecognised outcome ({outcome})") + + +def _describe_actor(hass: HomeAssistant, user_id: str | None, parent_id: str | None) -> str: + """Name who or what issued the command. + + A `parent_id` is the context of whatever caused this call, so an automation + that operates a breaker is named rather than reduced to "no user". The + lookup is best-effort: contexts are not retained forever, and a name that + cannot be resolved is better rendered as "an automation or script" than as + a raw id nobody can act on. + """ + if user_id is not None: + # The id rather than the name: resolving a name goes through + # `hass.auth.async_get_user`, which is a coroutine, and a logbook + # describer is a synchronous callback. Home Assistant's own logbook + # renders the id here too. + return f"user {user_id}" + if parent_id is None: + return "an unattended caller" + for state in hass.states.async_all(("automation", "script")): + if state.context.id == parent_id: + return str(state.name) or state.entity_id + return "an automation or script" + + +@callback +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], +) -> None: + """Register the describer for this integration's control-command event.""" + + @callback + def describe(event: Event) -> dict[str, str]: + data = event.data + command = str(data.get("command", "a control")) + value = str(data.get("value", "")) + outcome = str(data.get("outcome", "unknown")) + user_id = data.get("user_id") + parent_id = data.get("parent_id") + actor = _describe_actor( + hass, + str(user_id) if isinstance(user_id, str) else None, + str(parent_id) if isinstance(parent_id, str) else None, + ) + entity_id = data.get(ATTR_ENTITY_ID) + + described = { + LOGBOOK_ENTRY_NAME: "SPAN Panel", + LOGBOOK_ENTRY_MESSAGE: ( + f"{command} was set to {value} by {actor} {_describe_outcome(outcome)}" + ), + } + if isinstance(entity_id, str): + described[LOGBOOK_ENTRY_ENTITY_ID] = entity_id + return described + + async_describe_event(DOMAIN, EVENT_CONTROL_COMMAND, describe) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 022832c9..1663238f 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -10,7 +10,8 @@ "frontend", "http", "panel_custom", - "persistent_notification" + "persistent_notification", + "repairs" ], "documentation": "https://github.com/SpanPanel/span", "integration_type": "device", @@ -22,9 +23,11 @@ ], "quality_scale": "gold", "requirements": [ - "span-panel-api==2.6.4" + "span-panel-api==3.6.2", + "span-panel-api-schema-0==1.2.0", + "span-panel-api-schema-1==1.2.2" ], - "version": "2.0.8", + "version": "2.1.2", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/custom_components/span_panel/migrations.py b/custom_components/span_panel/migrations.py index 2e6f61e6..d27afc52 100644 --- a/custom_components/span_panel/migrations.py +++ b/custom_components/span_panel/migrations.py @@ -8,32 +8,43 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .const import CONF_API_VERSION +from .const import CONF_API_VERSION, CONF_HOP_PASSPHRASE, PANEL_CA_PENDING +from .options import ALLOW_CONTEXTLESS_CONTROL if TYPE_CHECKING: - from . import SpanPanelConfigEntry + from .runtime import SpanPanelConfigEntry _LOGGER = logging.getLogger(__name__) # Must match the storage version produced by the latest supported entry format. -CURRENT_CONFIG_VERSION = 6 +CURRENT_CONFIG_VERSION = 7 +CURRENT_CONFIG_MINOR_VERSION = 2 async def async_migrate_entry(hass: HomeAssistant, config_entry: SpanPanelConfigEntry) -> bool: """Migrate config entry through successive versions. Supports upgrades from v1.3.1+ (config version 2) through to the - current version 6. Each step mutates only the fields relevant to + current version 7.2. Each step mutates only the fields relevant to that version boundary. + + Core also calls this for an entry a newer release has already moved past + the current minor version, which is a downgrade it allows; such an entry is + left as it is. """ - if config_entry.version >= CURRENT_CONFIG_VERSION: + if (config_entry.version, config_entry.minor_version) >= ( + CURRENT_CONFIG_VERSION, + CURRENT_CONFIG_MINOR_VERSION, + ): return True _LOGGER.debug( - "Migrating config entry %s from version %s to %s", + "Migrating config entry %s from version %s.%s to %s.%s", config_entry.entry_id, config_entry.version, + config_entry.minor_version, CURRENT_CONFIG_VERSION, + CURRENT_CONFIG_MINOR_VERSION, ) # --- v2 → v3: add api_version field --- @@ -124,4 +135,51 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: SpanPanelConfig ) _LOGGER.debug("Migrated config entry %s to version 6", config_entry.entry_id) + # --- v6 → v7: drop the stored passphrase, and queue the CA acquisition --- + if config_entry.version < 7: + updated_data = dict(config_entry.data) + # The passphrase is a registration input only — nothing at runtime reads + # it back. Holding it in `.storage` bought nothing and cost a credential + # that re-registers any client against the panel. + removed = updated_data.pop(CONF_HOP_PASSPHRASE, None) is not None + + # No I/O here, deliberately. This runs during startup, so a fetch would + # delay boot whenever the panel is unreachable and a failure would have + # nowhere to recover to. The flag defers it to the first successful + # setup and is cleared there; the same shape as solar_migration_pending + # above. Only v2 entries: v1 fails setup before it reaches a panel, and + # a simulation entry has none to fetch from. + if updated_data.get(CONF_API_VERSION) == "v2": + updated_data[PANEL_CA_PENDING] = True + + hass.config_entries.async_update_entry( + config_entry, + data=updated_data, + version=7, + ) + if removed: + _LOGGER.info( + "Removed the stored panel passphrase from config entry %s", + config_entry.entry_id, + ) + _LOGGER.debug("Migrated config entry %s to version 7", config_entry.entry_id) + + # --- v7.1 → v7.2: pin control without a logged-in user to what it was --- + if config_entry.version == 7 and config_entry.minor_version < 2: + updated_options = dict(config_entry.options) + # The default for an entry that never stored this became off. Every entry + # reaching this step was created before that, when anything but a stored + # bool resolved to on, so that is written down here rather than letting + # an upgrade start refusing a household's automations. A stored bool is + # the user's own choice and is kept. + if not isinstance(updated_options.get(ALLOW_CONTEXTLESS_CONTROL), bool): + updated_options[ALLOW_CONTEXTLESS_CONTROL] = True + + hass.config_entries.async_update_entry( + config_entry, + options=updated_options, + minor_version=2, + ) + _LOGGER.debug("Migrated config entry %s to version 7.2", config_entry.entry_id) + return True diff --git a/custom_components/span_panel/naming.py b/custom_components/span_panel/naming.py new file mode 100644 index 00000000..ebf73e7d --- /dev/null +++ b/custom_components/span_panel/naming.py @@ -0,0 +1,167 @@ +"""Identity of circuit entities: the base Home Assistant composes an id from. + +Since Home Assistant 2026.8 the user decides how an entity id is composed +(`entity_id_parts`: area, device, entity). This integration no longer presets +an `entity_id`; it supplies one *base* per circuit entity -- `Circuit 15 power` +or `Kitchen Outlets power` -- and Core assembles the rest from the user's +settings. `SpanPanelEntity.suggested_object_id` hands the base to Core. + +The base must reproduce an existing entity's id *where Home Assistant composes +it under the entity-id options that match how that install was built*: it +carries the circuit's own name and the install's own suffix wording, read back +from the id the entity already has, because this integration has shipped two +spellings (`consumed_energy` before it preset ids, `energy_consumed` after). It +never bypasses `entity_id_parts` with a hard-coded id -- where composition +yields a different device part, that is the user's configuration at work and +the offer is legitimate. A new entity gets the noun-last wording, which is what +the panel-level ids (`main_meter_consumed_energy`) and the display labels use. + +The base is *not* the display name. The two are decoupled on purpose, so a +label can be reworded without touching a single id. + +`CIRCUIT_SUFFIX_MAPPING` in `id_builder` builds unique ids and is closed; the +tables here govern entity ids only. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify + +ENTITY_ID_SUFFIX_FORMS: dict[str, frozenset[str]] = { + "power": frozenset({"power", "current_power"}), + "energy_produced": frozenset({"produced_energy", "energy_produced"}), + "energy_consumed": frozenset({"consumed_energy", "energy_consumed"}), + "energy_net": frozenset({"net_energy", "energy_net"}), + "current": frozenset({"current"}), + "breaker_rating": frozenset({"breaker_rating"}), + "breaker": frozenset({"breaker"}), + "circuit_priority": frozenset({"circuit_priority"}), +} +"""Every entity-id suffix form ever shipped, keyed by the canonical suffix. + +Historical fact: an entry is added only when another form is discovered to +have shipped, never to introduce one. +""" + +NEW_ENTITY_ID_SUFFIX_WORDS: dict[str, str] = { + "power": "power", + "energy_produced": "produced energy", + "energy_consumed": "consumed energy", + "energy_net": "net energy", + "current": "current", + "breaker_rating": "breaker rating", + "breaker": "breaker", + "circuit_priority": "circuit priority", +} +"""The wording a new entity's base ends with -- noun-last, like the panel level.""" + + +def _known_forms(suffix: str) -> list[str]: + """Return every form of `suffix`, longest first so `current_power` outranks `power`.""" + return sorted(ENTITY_ID_SUFFIX_FORMS.get(suffix, ()), key=len, reverse=True) + + +def _ends_on_segment(object_id: str, segment: str) -> bool: + """Report whether `object_id` ends on `segment` at a word boundary. + + The equality arm is the install with no device prefix, whose object id *is* + the circuit half and so has no underscore in front of it. + """ + return object_id == segment or object_id.endswith(f"_{segment}") + + +def _existing_suffix_form(existing_entity_id: str | None, suffix: str) -> str | None: + """Return the suffix form an existing id carries, or None if it carries none we know.""" + if existing_entity_id is None: + return None + object_id = existing_entity_id.split(".", 1)[-1] + for form in _known_forms(suffix): + if object_id.endswith(f"_{form}"): + return form + return None + + +def _form_after_the_identifier(object_id: str, identifier_slug: str, suffix: str) -> str | None: + """Return the form this id spells *after* naming this circuit, if it spells one.""" + for form in _known_forms(suffix): + if _ends_on_segment(object_id, f"{identifier_slug}_{form}"): + return form + return None + + +def circuit_object_id_base(identifier: str, suffix: str, existing_entity_id: str | None) -> str: + """Return the base for one circuit entity. + + `identifier` is the naming-flag half (`Circuit 15`, `Kitchen Outlets`, + `Unmapped Tab 32`); `suffix` is this entity's canonical suffix, which the + sensors derive from their description key via + `id_builder.get_user_friendly_suffix` while the switch and the select -- + which have no such key -- name theirs outright ("breaker", + "circuit_priority"). + + Where the existing id still names this circuit, what follows the name + settles both halves at once -- which form the id carries, and whether the + preset builder omitted it. They are one question: an id reading + `..._solar_power_power` carries `power` and omitted nothing, while + `..._solar_power` omitted it. Answering each half on its own -- the form by + a plain `endswith`, the omission from the identifier alone -- disagreed with + the id in both directions. A circuit named "Current" had `..._current_power` + read as the `current_power` form and was offered + `..._current_current_power`; a circuit named "Solar Power" whose id kept + both halves was offered `..._solar_power`. Both are renames the user did not + cause, which R1 forbids. + + Only where the id no longer names this circuit -- it was renamed on the + panel, issue #252 -- is the form read back by itself, from the end of the + id, and the omission decided from the *new* name. That is the whole point of + the rename: the name half follows the panel and the suffix half does not. + + The omission test carries a leading underscore because the builder's did: a + circuit named exactly "Power" is not a repetition of anything and kept both + halves, `..._power_power`. + """ + identifier_slug = slugify(identifier) + if existing_entity_id is not None: + object_id = existing_entity_id.split(".", 1)[-1] + form = _form_after_the_identifier(object_id, identifier_slug, suffix) + if form is not None: + return f"{identifier} {form.replace('_', ' ')}" + if _ends_on_segment(object_id, identifier_slug): + return identifier + + form = _existing_suffix_form(existing_entity_id, suffix) + words = ( + form.replace("_", " ") + if form + else NEW_ENTITY_ID_SUFFIX_WORDS.get(suffix, suffix.replace("_", " ")) + ) + if identifier_slug.endswith(f"_{slugify(words)}"): + return identifier + return f"{identifier} {words}" + + +def release_registry_name_written_by_older_release( + registry: er.EntityRegistry, + entity_id: str, + circuit_name: str, + description_names: Iterable[str], +) -> None: + """Hand the registry's `name` back to the user where 2.0.8 took it. + + Circuit-numbers mode used to deliver the panel's name by writing the + registry's `name` -- the *user's* field, which Home Assistant reads ahead + of everything else when generating an entity id, so occupying it made + "Recreate entity IDs" propose a friendly-name id for a circuit-numbered + entity. Only a name this integration would have written is cleared: + `"{circuit_name} {description name}"` for the description's current name + and every name it has carried before. Anything else is the user's. + """ + entry = registry.async_get(entity_id) + if entry is None or entry.name is None or not circuit_name: + return + ours = {f"{circuit_name} {description_name}" for description_name in description_names} + if entry.name in ours: + registry.async_update_entity(entity_id, name=None) diff --git a/custom_components/span_panel/notices.py b/custom_components/span_panel/notices.py new file mode 100644 index 00000000..66e08033 --- /dev/null +++ b/custom_components/span_panel/notices.py @@ -0,0 +1,460 @@ +"""Notifications that outlive a restart. + +A persistent notification lives in memory. Raise one, restart Home Assistant, +and it is gone whether or not anybody read it. That is why the things worth +saying exactly once have historically been filed as Repairs instead: a Repair is +stored, so it stands until the user dismisses it. + +That trade is a bad one. The Repairs list means "something wants your attention +because it went wrong" -- it is stamped with a severity, it offers to fix or +ignore, and everything else filed there is a defect. Putting good news in it +tells the user their panel is broken. A firmware upgrade that took nothing away +arrived looking like a warning, which is the opposite of what it was for. + +This module keeps the notification and buys back the one property the Repair had +that it lacked. A notice raised here is recorded per config entry and re-raised +at every setup until it is dismissed, and the dismissal is *observed* rather than +assumed: Home Assistant reports removals to a registered callback, so "the user +has seen it" is a fact rather than a hope. A notice therefore survives a restart, +a reload, and an owner who was on holiday when their panel upgraded. + +Which is durable and which is not is a real distinction, not a default. A notice +belongs here when it reports something that happened once and cannot be +re-derived -- a firmware upgrade, an entity that appeared. Anything re-derived +from live state on every refresh does not belong here; it belongs in +`schema_repairs`, which reconciles instead, and where being a defect is the +point. + +There is a middle case, and `async_raise_on_change` is for it: a condition that +*is* re-derived at every setup, but whose news value is spent the first time it +is reported. Raised through `async_raise` such a notice reappears after every +dismissal, which makes it un-dismissable in practice. That function remembers +what was announced, so the notice returns only when what it says changes. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +import json +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Final, TypedDict + +from homeassistant.components.persistent_notification import ( + Notification, + UpdateType, + async_create, + async_dismiss, + async_register_callback, +) +from homeassistant.core import callback +from homeassistant.helpers.storage import Store +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +_STORE_VERSION: Final = 1 + +_SAVE_DELAY: Final = 1.0 +"""Seconds to coalesce writes. + +Raising and dismissing both write, and a reload raises every standing notice in +a burst. Delayed saves collapse that into one file write, and Home Assistant +flushes anything outstanding at shutdown. +""" + + +class StandingNotice(TypedDict): + """A notice the user has been shown and has not dismissed. + + The rendered text is stored, not the arguments that produced it. A notice can + outlive the state that raised it -- the schema upgrade that raised one is over + by the time it is re-raised -- so re-deriving the wording is not possible, and + a notice that changed its story between restarts would be worse than one that + did not survive at all. + """ + + title: str + message: str + + +class StoredNotices(TypedDict): + """One config entry's undismissed notices, and what it has already been told.""" + + standing: dict[str, StandingNotice] + announced: dict[str, str] + + +@dataclass(slots=True) +class _Notices: + """One entry's live view of its notices. + + The store is held rather than rebuilt per call because `async_delay_save` + schedules on the instance: two `Store` objects over the same key would each + hold a pending write and race to be last. + + `standing` and `announced` answer different questions and are deliberately + not one dict. `standing` is "what is on screen", and dismissal empties it. + `announced` is "what the user has already been told", and *nothing* empties + it -- that is the whole point of it, because a notice whose record died with + its dismissal would be raised again by the next setup. + """ + + store: Store[StoredNotices] + standing: dict[str, StandingNotice] + announced: dict[str, str] + + +_DATA: HassKey[dict[str, _Notices]] = HassKey(f"{DOMAIN}_standing_notices") + + +def _notification_id(entry: ConfigEntry, notice_id: str) -> str: + """Namespace a notice id by domain and entry. + + Per entry, not per domain: two panels upgrading are two notices, and a shared + id would let the second overwrite the first's text and the first's dismissal + silence the second. + """ + return f"{DOMAIN}_{notice_id}_{entry.entry_id}" + + +async def async_restore(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Re-raise this entry's undismissed notices and watch for dismissals. + + Call before anything that can raise a notice. Raising needs the live view + this builds, and building it afterwards would overwrite what was on disk with + only what this run happened to raise. + + The watch is registered through `entry.async_on_unload`, so a reload replaces + it rather than accumulating one per setup -- the dispatcher fans every + notification change out to every registered callback, so a leaked + registration would be a leaked handler running against a dead entry. + + A reload keeps the live view it already had rather than re-reading the file, + because memory is the fresher of the two and the gap between them is exactly + where the interesting case lives: a schema upgrade raises a notice and + schedules a reload in the same breath, so the setup that follows can easily + arrive before the delayed write has landed. Re-reading there would drop the + notice from the standing set while it sat on the user's screen, and it would + not come back after the next restart. + """ + known = hass.data.setdefault(_DATA, {}) + notices = known.get(entry.entry_id) + if notices is None: + store: Store[StoredNotices] = Store( + hass, _STORE_VERSION, f"{DOMAIN}.notices.{entry.entry_id}" + ) + stored = await store.async_load() + notices = _Notices( + store=store, + standing=_load(stored, entry), + announced=_load_announced(stored, entry), + ) + known[entry.entry_id] = notices + + for notice_id, notice in notices.standing.items(): + _LOGGER.debug("Re-raising undismissed notice %s for %s", notice_id, entry.entry_id) + async_create( + hass, + notice["message"], + title=notice["title"], + notification_id=_notification_id(entry, notice_id), + ) + + entry.async_on_unload(async_register_callback(hass, partial(_on_change, hass, entry))) + + +def _load(stored: object, entry: ConfigEntry) -> dict[str, StandingNotice]: + """Read the standing set off disk, tolerating a file that is not what we wrote. + + `StoredNotices` and `StandingNotice` are compile-time only. On-disk data + violates them freely -- a hand edit, a partially restored backup, a file + written by a later version and read after a rollback -- and every such shape + used to raise straight out of setup. Home Assistant already handles + *undecodable* JSON by renaming the file and raising a core repair; the gap is + valid JSON of the wrong shape, which it hands back intact. + + Failing there is the wrong trade by a wide margin. This module exists to tell + the user about something that already happened; a panel that will not load + because its *notification bookkeeping* is malformed has turned a cosmetic + record into a dead integration, and one that stays dead, because setup is not + retried on a bad shape. Falling back to empty loses only the memory of which + notices were standing, and the next raise overwrites the file with a valid + one -- so it self-heals rather than needing the user to find and delete it. + """ + if stored is None: + return {} + standing = stored.get("standing") if isinstance(stored, dict) else None + if not isinstance(standing, dict): + _LOGGER.warning( + "Ignoring the notice record for %s: expected an object with a 'standing' " + "mapping, found %s. Any notice already dismissed stays dismissed; one still " + "standing may be shown again.", + entry.entry_id, + type(standing if isinstance(stored, dict) else stored).__name__, + ) + return {} + kept: dict[str, StandingNotice] = {} + for notice_id, notice in standing.items(): + if ( + isinstance(notice, dict) + and isinstance(notice.get("title"), str) + and isinstance(notice.get("message"), str) + ): + kept[str(notice_id)] = StandingNotice(title=notice["title"], message=notice["message"]) + else: + _LOGGER.warning( + "Dropping malformed notice %s for %s: a notice needs a title and a " + "message, both strings", + notice_id, + entry.entry_id, + ) + return kept + + +def _load_announced(stored: object, entry: ConfigEntry) -> dict[str, str]: + """Read the announced fingerprints off disk, tolerating anything else. + + Same trade as `_load`, and read separately rather than as part of it because + the two fall back differently in one way that matters: a lost `standing` set + shows a notice again, which is mildly annoying, while a lost `announced` set + shows a notice the user already dismissed, which is the exact defect the + record exists to prevent. Neither is worth failing setup over, so both fall + back to empty and the next raise rewrites the file. + + Absent entirely on a record written before this field existed, which is the + ordinary upgrade path rather than an error: the first setup after the upgrade + raises once more and records it. + + A row whose fingerprint is not a string is dropped without a line of its own. + Unlike a malformed *notice*, which loses text nothing can re-derive, a + dropped fingerprint costs exactly one extra raise of a notice that is + re-derived anyway. + """ + announced = stored.get("announced") if isinstance(stored, dict) else None + if announced is None: + return {} + if not isinstance(announced, dict): + _LOGGER.warning( + "Ignoring the announcement record for %s: expected a mapping, found %s. A " + "notice already dismissed may be shown once more.", + entry.entry_id, + type(announced).__name__, + ) + return {} + return { + str(notice_id): fingerprint + for notice_id, fingerprint in announced.items() + if isinstance(fingerprint, str) + } + + +@callback +def async_raise( + hass: HomeAssistant, entry: ConfigEntry, notice_id: str, *, title: str, message: str +) -> None: + """Show a notice, and keep showing it until the user dismisses it. + + A callback rather than a coroutine because the callers are: a schema-change + notice is raised from the MQTT client's own callback fan-out, which is not a + place that can await. + + Re-raising the same notice id replaces the text in place, which is what makes + the restore above a no-op when it lands on a notice already on screen. + """ + async_create(hass, message, title=title, notification_id=_notification_id(entry, notice_id)) + notices = hass.data.get(_DATA, {}).get(entry.entry_id) + if notices is None: + # The entry was removed while this notice was in flight. Showing it is + # still right -- it describes something that happened -- but there is + # nothing left to record it against, and recreating the record here would + # resurrect the file `async_forget` just deleted. + _LOGGER.debug( + "Raised %s for untracked entry %s; it will not survive a restart", + notice_id, + entry.entry_id, + ) + return + notices.standing[notice_id] = StandingNotice(title=title, message=message) + _persist(notices) + + +@callback +def async_raise_on_change( + hass: HomeAssistant, + entry: ConfigEntry, + notice_id: str, + *, + title: str, + message: str, + fingerprint: str, +) -> bool: + """Raise a notice only when what it reports differs from what was last reported. + + For the notices whose *cause* is re-derived from live state at every setup + rather than happening once. Those cannot use `async_raise` directly: it + re-raises unconditionally, so the cause being still true would put the notice + back on screen after every restart and reload, and a notice that returns + however often it is dismissed is not a notice, it is a nag with no off + switch. The module's own rule (see the top of this file) says a re-derivable + condition belongs in `schema_repairs`; this is the middle case, where the + condition is re-derivable but the *news* is not -- being told once that a + vendor device declares more readings than will be added is useful, and being + told again every Tuesday is not. + + `fingerprint` is what was said, not that something was said, so a *different* + truncation later is news again and reaches the user. The caller chooses it; + it wants to be the rendered facts rather than the rendered prose, or a + translation update would re-announce. + + Returns whether it raised, so a caller can scope its logging to the same + decision rather than duplicating it. + + Dismissal does not clear the record -- that is the point -- so a user who + wants to be told again has to remove the entry. That is the correct trade for + a condition the diagnostics download reports in full anyway. + + The record is written *before* the raise rather than after, so `async_raise`'s + own `_persist` carries both halves and this stays one queued write per + action. Two would be correct on disk and worse in every other way: a `Store` + keeps one pending write, so the second merely resets the first's timer. + """ + notices = hass.data.get(_DATA, {}).get(entry.entry_id) + if notices is not None: + if notices.announced.get(notice_id) == fingerprint: + _LOGGER.debug( + "Notice %s already announced for %s with the same content; not raised again", + notice_id, + entry.entry_id, + ) + return False + notices.announced[notice_id] = fingerprint + async_raise(hass, entry, notice_id, title=title, message=message) + return True + + +@callback +def _on_change( + hass: HomeAssistant, + entry: ConfigEntry, + change: UpdateType, + notifications: dict[str, Notification], +) -> None: + """Forget a notice once it is dismissed. + + Dismissal is the whole acknowledgement mechanism. There is no "read" signal + for a notification, and inventing one -- a restart count, an age -- would + either nag somebody who read it on day one or drop it before somebody on + holiday got back. Dismissing is the act of a user who has seen it. + + Filtered to this entry's own ids because the dispatcher delivers every + notification change in the system, most of which belong to other + integrations. + + Only the standing set is touched. The announcement record deliberately + outlives dismissal -- clearing it here would let the next setup raise the + notice the user just dismissed, which is the defect `async_raise_on_change` + exists to close. + """ + if change is not UpdateType.REMOVED: + return + notices = hass.data.get(_DATA, {}).get(entry.entry_id) + if notices is None: + return + dismissed = [ + notice_id + for notice_id in notices.standing + if _notification_id(entry, notice_id) in notifications + ] + if not dismissed: + return + for notice_id in dismissed: + _LOGGER.debug("Notice %s dismissed for %s", notice_id, entry.entry_id) + del notices.standing[notice_id] + _persist(notices) + + +@callback +def _persist(notices: _Notices) -> None: + """Queue a write of the current standing set. + + The snapshot is taken now rather than read through in the callback, because + `async_delay_save` calls its argument when the write fires rather than when it + is queued. Defensive rather than load-bearing, and worth being precise about: + a `Store` keeps only one pending write, so the newest queued function is the + one that runs, and every site that mutates the standing set calls this + immediately afterwards. There is therefore no reachable state today in which + the live dict has moved on and no newer write has superseded this one -- a + mutation test confirms the difference is unobservable through the store. + + It stays a snapshot anyway. The cost is one shallow dict copy, and the + property it buys -- what is queued is what was true when it was queued -- does + not then depend on every future mutation site remembering to re-persist. + """ + written = StoredNotices(standing=dict(notices.standing), announced=dict(notices.announced)) + notices.store.async_delay_save(lambda: written, _SAVE_DELAY) + + +async def async_forget(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop this entry's notices when the entry is removed. + + Both halves matter. The record has to go, or re-adding the same panel would + restore notices about an upgrade the new entry never saw. The notifications + themselves have to go too: nothing else clears them, so a removed panel would + leave the user reading about a device that is no longer in their system. + """ + notices = hass.data.get(_DATA, {}).pop(entry.entry_id, None) + if notices is None: + return + for notice_id in notices.standing: + async_dismiss(hass, _notification_id(entry, notice_id)) + await notices.store.async_remove() + + +def read_translations(language: str, section: str) -> dict[str, str]: + """One notification's strings for one language, or an empty mapping. + + Read from this component's `notifications/` directory rather than through + `homeassistant.helpers.translation`, because that helper filters to the + categories Home Assistant defines and a persistent notification is not one of + them -- a custom category loads as nothing at all. These are this + integration's own package files, so reading them is not reaching into + somebody else's layout. + + **A directory of its own, not `translations/`.** hassfest validates + `strings.json` and `translations/en.json` against Home Assistant's schema and + rejects any key it does not define, so a `notifications` section there fails + the check outright -- which is what a custom category being unsupported looks + like from the outside. Keeping these strings beside those files rather than + inside them is what makes both true at once: hassfest sees only what it + defines, and the notices keep per-language files. + + Falls back along the language chain -- `pt-BR`, then `pt`, then `en` -- so a + regional variant with no file of its own still gets its language rather than + English. + + Blocking file I/O. Callers run it in an executor. + """ + directory = Path(__file__).parent / "notifications" + for candidate in (f"{language}.json", f"{language.split('-')[0]}.json", "en.json"): + path = directory / candidate + if not path.is_file(): + continue + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + _LOGGER.debug("Could not read notification strings from %s", path, exc_info=True) + continue + if not isinstance(loaded, dict): + continue + strings = loaded.get(section, {}) + if isinstance(strings, dict) and strings: + return {str(key): str(value) for key, value in strings.items()} + return {} diff --git a/custom_components/span_panel/notifications/en.json b/custom_components/span_panel/notifications/en.json new file mode 100644 index 00000000..e04a307c --- /dev/null +++ b/custom_components/span_panel/notifications/en.json @@ -0,0 +1,19 @@ +{ + "panel_upgraded": { + "title": "SPAN Panel firmware upgraded", + "body": "Your SPAN Panel reported a new eBus data model (**{previous} → {current}**), which happens after a firmware upgrade. The integration reloaded so its devices and entities match what the panel now publishes.\n\nNothing you rely on has gone away, and no automation changes are required — this is a note about what changed.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. The new data model publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report. Any entities the upgrade added are listed in their own notification.\n\nEntities that were renamed or replaced by the upgrade may need to be removed manually if they remain unavailable.\n\nDismiss this once you have had a look." + }, + "new_entities": { + "title": "SPAN Panel added new entities", + "intro_one": "This update added 1 new entity to your SPAN Panel.", + "intro_many": "This update added {count} new entities to your SPAN Panel.", + "enabled_heading": "Added and ready to use", + "disabled_heading": "Added but switched off", + "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.", + "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online." + }, + "extension_overflow": { + "title": "SPAN Panel: some vendor readings were not added", + "body": "A device on your panel declares more vendor readings than this integration will add for one device ({limit}). The rest were left out: {devices}.\n\nNothing you already have is affected, and nothing is broken. The readings that were left out are still listed in this integration's diagnostics download, which is what to attach if you want them surfaced." + } +} diff --git a/custom_components/span_panel/notifications/es.json b/custom_components/span_panel/notifications/es.json new file mode 100644 index 00000000..c5e9d6d9 --- /dev/null +++ b/custom_components/span_panel/notifications/es.json @@ -0,0 +1,19 @@ +{ + "panel_upgraded": { + "title": "Firmware del Panel SPAN actualizado", + "body": "Su Panel SPAN ha comunicado un nuevo modelo de datos eBus (**{previous} → {current}**), lo que ocurre tras una actualización de firmware. La integración se ha recargado para que sus dispositivos y entidades coincidan con lo que el panel publica ahora.\n\nNada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha cambiado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. El nuevo modelo de datos no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como se determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba. Las entidades que la actualización haya añadido se enumeran en su propio aviso.\n\nLas entidades renombradas o sustituidas por la actualización pueden tener que eliminarse manualmente si siguen sin estar disponibles.\n\nPuede descartar este aviso cuando lo haya revisado." + }, + "new_entities": { + "title": "El Panel SPAN ha añadido nuevas entidades", + "intro_one": "Esta actualización añadió 1 entidad nueva a tu Panel SPAN.", + "intro_many": "Esta actualización añadió {count} entidades nuevas a tu Panel SPAN.", + "enabled_heading": "Añadidas y listas para usar", + "disabled_heading": "Añadidas pero desactivadas", + "how_to_enable": "Las entidades desactivadas no registran nada hasta que las actives. Abre la página del dispositivo Panel SPAN, muestra sus entidades desactivadas y activa las que quieras.", + "nothing_broken": "No hay ningún problema y no se requiere ninguna acción. Las entidades aparecen por varios motivos: una actualización que expone más de lo que informa tu panel, un circuito añadido al panel, o hardware como una batería, paneles solares o un cargador que se pone en línea." + }, + "extension_overflow": { + "title": "Panel SPAN: algunas lecturas del fabricante no se añadieron", + "body": "Un dispositivo de tu panel declara más lecturas del fabricante de las que esta integración añade para un solo dispositivo ({limit}). El resto quedó fuera: {devices}.\n\nNada de lo que ya tienes se ve afectado y nada está roto. Las lecturas que quedaron fuera siguen apareciendo en la descarga de diagnósticos de esta integración, que es lo que conviene adjuntar si quieres que se muestren." + } +} diff --git a/custom_components/span_panel/notifications/fr.json b/custom_components/span_panel/notifications/fr.json new file mode 100644 index 00000000..fb937c90 --- /dev/null +++ b/custom_components/span_panel/notifications/fr.json @@ -0,0 +1,19 @@ +{ + "panel_upgraded": { + "title": "Micrologiciel du Panneau SPAN mis à jour", + "body": "Votre Panneau SPAN a signalé un nouveau modèle de données eBus (**{previous} → {current}**), ce qui se produit après une mise à jour du micrologiciel. L'intégration a été rechargée afin que ses appareils et ses entités correspondent à ce que le panneau publie désormais.\n\nRien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui a changé.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. Le nouveau modèle de données ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont la capacité de secours est déterminée.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas. Les entités ajoutées par la mise à jour sont répertoriées dans un avis distinct.\n\nLes entités renommées ou remplacées par la mise à jour peuvent devoir être supprimées manuellement si elles restent indisponibles.\n\nVous pouvez ignorer cet avis après consultation." + }, + "new_entities": { + "title": "Le Panneau SPAN a ajouté de nouvelles entités", + "intro_one": "Cette mise à jour a ajouté 1 nouvelle entité à votre Panneau SPAN.", + "intro_many": "Cette mise à jour a ajouté {count} nouvelles entités à votre Panneau SPAN.", + "enabled_heading": "Ajoutées et prêtes à l'emploi", + "disabled_heading": "Ajoutées mais désactivées", + "how_to_enable": "Les entités désactivées n'enregistrent rien tant que vous ne les activez pas. Ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez.", + "nothing_broken": "Rien n'est cassé et aucune action n'est requise. Des entités apparaissent pour plusieurs raisons : une mise à jour qui expose davantage de ce que votre panneau rapporte, un circuit ajouté au panneau, ou du matériel tel qu'une batterie, du solaire ou un chargeur qui se met en ligne." + }, + "extension_overflow": { + "title": "Panneau SPAN : certaines mesures du fabricant n'ont pas été ajoutées", + "body": "Un appareil de votre panneau déclare plus de mesures du fabricant que cette intégration n'en ajoute pour un seul appareil ({limit}). Les autres ont été laissées de côté : {devices}.\n\nRien de ce que vous avez déjà n'est affecté et rien n'est cassé. Les mesures laissées de côté figurent toujours dans le téléchargement des diagnostics de cette intégration, qu'il convient de joindre si vous souhaitez les voir apparaître." + } +} diff --git a/custom_components/span_panel/notifications/ja.json b/custom_components/span_panel/notifications/ja.json new file mode 100644 index 00000000..e5c79d7f --- /dev/null +++ b/custom_components/span_panel/notifications/ja.json @@ -0,0 +1,19 @@ +{ + "panel_upgraded": { + "title": "SPAN パネルのファームウェアが更新されました", + "body": "SPAN パネルが新しい eBus データモデル (**{previous} → {current}**) を報告しました。これはファームウェアの更新後に発生します。パネルが現在公開している内容にデバイスとエンティティを合わせるため、統合を再読み込みしました。\n\nご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は変更点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。新しいデータモデルではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これがバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。今回の更新で追加されたエンティティは、別の通知に一覧表示されます。\n\n更新によって名称が変更された、または置き換えられたエンティティが利用不可のままの場合は、手動で削除する必要があることがあります。\n\n内容を確認したら、この通知は閉じて構いません。" + }, + "new_entities": { + "title": "SPAN パネルに新しいエンティティが追加されました", + "intro_one": "このアップデートで SPAN パネルに新しいエンティティが 1 個追加されました。", + "intro_many": "このアップデートで SPAN パネルに新しいエンティティが {count} 個追加されました。", + "enabled_heading": "追加され、すぐに使用できます", + "disabled_heading": "追加されましたが無効です", + "how_to_enable": "無効なエンティティは、有効にするまで何も記録しません。SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。", + "nothing_broken": "問題はなく、対応は不要です。エンティティが追加される理由はいくつかあります: パネルが報告する内容をより多く公開するアップデート、パネルに追加された回路、またはバッテリー、太陽光、充電器などのハードウェアがオンラインになった場合です。" + }, + "extension_overflow": { + "title": "SPAN パネル: 一部のベンダー測定値は追加されませんでした", + "body": "パネル上のあるデバイスが、この統合が 1 台あたりに追加する上限({limit})を超える数のベンダー測定値を宣言しています。残りは追加されませんでした: {devices}。\n\n既存のものには影響はなく、故障でもありません。追加されなかった測定値は、この統合の診断ダウンロードには引き続き記載されています。表示を希望する場合は、それを添付してください。" + } +} diff --git a/custom_components/span_panel/notifications/pt.json b/custom_components/span_panel/notifications/pt.json new file mode 100644 index 00000000..752171ba --- /dev/null +++ b/custom_components/span_panel/notifications/pt.json @@ -0,0 +1,19 @@ +{ + "panel_upgraded": { + "title": "Firmware do Painel SPAN atualizado", + "body": "O seu Painel SPAN comunicou um novo modelo de dados eBus (**{previous} → {current}**), o que acontece após uma atualização de firmware. A integração foi recarregada para que os seus dispositivos e entidades correspondam ao que o painel publica agora.\n\nNada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que mudou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. O novo modelo de dados não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a capacidade de reserva é determinada.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava. As entidades que a atualização tenha adicionado são listadas num aviso próprio.\n\nAs entidades renomeadas ou substituídas pela atualização podem ter de ser removidas manualmente se continuarem indisponíveis.\n\nPode dispensar este aviso depois de o consultar." + }, + "new_entities": { + "title": "O Painel SPAN adicionou novas entidades", + "intro_one": "Esta atualização adicionou 1 nova entidade ao seu Painel SPAN.", + "intro_many": "Esta atualização adicionou {count} novas entidades ao seu Painel SPAN.", + "enabled_heading": "Adicionadas e prontas a usar", + "disabled_heading": "Adicionadas mas desativadas", + "how_to_enable": "As entidades desativadas não registam nada até as ativar. Abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser.", + "nothing_broken": "Nada está avariado e não é necessária qualquer ação. As entidades aparecem por vários motivos: uma atualização que expõe mais do que o seu painel reporta, um circuito adicionado ao painel, ou hardware como uma bateria, solar ou um carregador a ficar online." + }, + "extension_overflow": { + "title": "Painel SPAN: algumas leituras do fabricante não foram adicionadas", + "body": "Um dispositivo do seu painel declara mais leituras do fabricante do que esta integração adiciona para um único dispositivo ({limit}). As restantes ficaram de fora: {devices}.\n\nNada do que já tem é afetado e nada está avariado. As leituras que ficaram de fora continuam listadas na transferência de diagnósticos desta integração, que é o que deve anexar se quiser que sejam apresentadas." + } +} diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py new file mode 100644 index 00000000..3b6391e7 --- /dev/null +++ b/custom_components/span_panel/number.py @@ -0,0 +1,335 @@ +"""Number entities for the Span Panel — today, one per commissioned EV charger. + +The charge-current ceiling is the only settable property v1.0 puts outside the +panel and its circuits, and the only control this integration has ever offered +that carries a physical bound: the installer commissions a maximum from the +breaker rating and J1772 derating, and nothing a user does may exceed it. + +So every number this platform builds is described by the panel rather than by +this module. The value, the maximum, and the fact that a control exists at all +come from the charger's own `$description`, resolved in the library +(`span_panel_api_schema_1.charge_limit`) because the node carrying the limit has +two spellings in circulation and the `$description` is the specification's +authority on which one a charger publishes. Nothing here names a wire property. + +**A control is offered only where the panel declares one.** `$settable` on the +limit is what creates the entity; the commissioned ceiling is what bounds it. +A charger that declares neither gets no entity, which is the honest rendering of +`charge-limit.md`'s absence semantics — "the EVSE has no adjustable +charge-current ceiling (it charges at a fixed rate)". + +**A pending write shows as an attribute, not as a state.** The panel echoes an +accepted command on the Homie `$target` topic and republishes the property when +it takes effect, and the priority select already renders that pair exactly this +way (`circuit.priority_target`). Reporting the requested value as the state +instead would show a limit the charger may never have accepted. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +import logging +from typing import Any, Final + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import UnitOfElectricCurrent +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from span_panel_api import ( + EvseControlProtocol, + PublishOutcome, + SpanEvseSnapshot, + SpanPanelSnapshot, +) + +from .adoption import create_adopted_numbers +from .const import CONF_DEVICE_NAME, USE_CIRCUIT_NUMBERS +from .control_gate import ControlMode +from .coordinator import SpanPanelCoordinator +from .entity import SpanPanelEntity +from .field_paths import DerivedReason, FieldPathDeclarationMixin +from .helpers import build_evse_unique_id_for_entry, resolve_evse_display_suffix +from .runtime import SpanPanelConfigEntry +from .util import EMPTY_EVSE, evse_device_info, evse_display_name + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True) +class SpanEvseNumberRequiredKeysMixin(FieldPathDeclarationMixin): + """Required keys mixin for EVSE number entities.""" + + value_fn: Callable[[SpanEvseSnapshot], int | None] + maximum_fn: Callable[[SpanEvseSnapshot], int | None] + target_fn: Callable[[SpanEvseSnapshot], int | None] + settable_fn: Callable[[SpanEvseSnapshot], bool] + set_fn: Callable[[EvseControlProtocol, str, int], Awaitable[PublishOutcome]] + + +@dataclass(frozen=True, kw_only=True) +class SpanEvseNumberEntityDescription(NumberEntityDescription, SpanEvseNumberRequiredKeysMixin): + """Describes an EVSE number entity.""" + + +EVSE_CHARGE_CURRENT_LIMIT: Final = SpanEvseNumberEntityDescription( + key="evse_charge_current_limit", + field_path="evse.charge_current_limit_a", + # One field, produced by schema_1 alone: flat firmware's `evse` device type + # carries `advertised-current` — what the charger is offering the vehicle, + # read-only — and no settable ceiling anywhere, so the both-adapters gate + # cannot be satisfied. schema_1 carries a metadata row for the field, which + # is what makes the path SCHEMA_1_ONLY rather than NEITHER and buys the + # entity unit validation against the charger's own `$description`. + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="evse_charge_current_limit", + device_class=NumberDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + # Zero, because a charge-current ceiling has no meaning below it: this is a + # charge-only EVSE, so lowering the ceiling can stop charging and can never + # reverse it. Not read from the wire because there is nothing on the wire to + # read — Homie expresses a bounded numeric as a `min:max` `$format`, and + # neither the capture nor the `charge-limit` catalog declares one for this + # property. See `native_max_value`, which *is* published and is read. + native_min_value=0, + # The declared datatype is `integer`, and a step is the granularity of the + # quantity rather than a policy: a charger that accepts 16 A and 17 A does + # not accept 16.5. Asserted against the declaration in + # `test_evse_charge_limit.py` rather than assumed here. + native_step=1, + mode=NumberMode.BOX, + entity_category=EntityCategory.CONFIG, + # The limit in force rather than the user's own: from r202639 a charger + # with no user limit publishes none, and the commissioned ceiling is then + # what it enforces. Showing unknown there would hide a real setting. + value_fn=lambda evse: evse.effective_charge_current_limit_a, + maximum_fn=lambda evse: evse.charge_current_ceiling_a, + target_fn=lambda evse: evse.charge_current_limit_target_a, + settable_fn=lambda evse: evse.charge_current_limit_settable, + set_fn=lambda client, node_id, amps: client.set_evse_charge_limit(node_id, amps), +) +"""The owner's charge-current ceiling. + +`mode=BOX` rather than a slider: the useful values are a handful of amperages an +installer or an owner knows by name (16, 24, 32, 40), and a slider over an +installer-set range invites dragging past the value someone meant. The range is +still enforced — Home Assistant checks the service call against `min_value` and +`max_value`, and the library refuses anything above the commissioned ceiling +before it reaches the wire. + +Deliberately `EntityCategory.CONFIG`, beside the circuit priority select: this +changes how the panel behaves rather than reporting how it is behaving. +""" + +EVSE_NUMBERS: tuple[SpanEvseNumberEntityDescription, ...] = (EVSE_CHARGE_CURRENT_LIMIT,) + + +class SpanEvseNumber(SpanPanelEntity, NumberEntity): + """One settable amperage on one commissioned EV charger.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanEvseNumberEntityDescription, + evse_id: str, + ) -> None: + """Initialize the EVSE number.""" + super().__init__(data_coordinator, context=description) + snapshot: SpanPanelSnapshot = data_coordinator.data + self._evse_id = evse_id + # The same object under two names. `entity_description` is what Home + # Assistant and `SpanPanelEntity._source_field_path` read, and its + # declared type is `NumberEntityDescription`; `_description` is the same + # instance at the type this platform actually declared, so the readers + # below stay on the description rather than being copied off it onto the + # entity. Copying is what splits a declaration from its reader, which is + # the drift `field_paths` exists to prevent. + self.entity_description = description + self._description = description + + panel_name = ( + data_coordinator.config_entry.data.get( + CONF_DEVICE_NAME, data_coordinator.config_entry.title + ) + or "Span Panel" + ) + evse = snapshot.evse.get(evse_id, EMPTY_EVSE) + use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) + + # The charger's own name, kept because both ways this control can fail + # are reported to a person. `_evse_id` is the node id the wire uses; it + # names nothing the user can see on their dashboard. + self._charger_name = evse_display_name(evse, panel_name, display_suffix) + + self._attr_device_info = evse_device_info( + snapshot.serial_number, + evse, + panel_name, + display_suffix, + panel_device_id=data_coordinator.config_entry.runtime_data.panel_device_id, + ) + self._attr_unique_id = build_evse_unique_id_for_entry( + data_coordinator, + snapshot, + evse_id, + description.key, + data_coordinator.config_entry.data.get( + CONF_DEVICE_NAME, data_coordinator.config_entry.title + ), + ) + self._apply(evse) + + def _evse(self) -> SpanEvseSnapshot: + snapshot: SpanPanelSnapshot | None = self.coordinator.data + if snapshot is None: + return EMPTY_EVSE + return snapshot.evse.get(self._evse_id, EMPTY_EVSE) + + def _apply(self, evse: SpanEvseSnapshot) -> None: + """Take the reading and the bound the panel currently publishes. + + The maximum moves with the panel because it can: an installer + recommissioning a charger republishes `installer-max`, and a control + still offering the old range would let a user ask for a current the + hardware is no longer rated for. Left at whatever was last published + when the value goes away, so the entity reports unavailable with its + last known bound rather than briefly widening. + """ + self._attr_native_value = self._description.value_fn(evse) + maximum = self._description.maximum_fn(evse) + if maximum is not None: + self._attr_native_max_value = maximum + + @property + def available(self) -> bool: + """False while the panel is offline or the bound is unknown. + + Offline follows the priority select: a control that cannot reach the + panel is not a control. + + The bound is the addition, and it is this entity's own hazard. A number + must report *some* maximum, so an unpublished ceiling would otherwise be + rendered as Home Assistant's default of 100 — a plausible-looking + amperage that no installer commissioned. Reporting unavailable says the + panel has not told us what the charger is rated for, which is the true + statement; the entity comes back when the ceiling does. + """ + if not self._transport_available: + return False + if self.coordinator.panel_offline: + return False + if self._description.maximum_fn(self._evse()) is None: + return False + return super().available + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """The pending write, while the panel is echoing one. + + The same rendering the priority select gives `priority_target`, and for + the same reason: `$target` is a command in flight, and the state stays + the value the charger is actually enforcing until it republishes. + """ + target = self._description.target_fn(self._evse()) + if target is None: + return None + return {"charge_current_limit_target": target} + + async def async_set_native_value(self, value: float) -> None: + """Ask the panel to lower (or restore) this charger's ceiling. + + Home Assistant has already rejected anything outside `min_value` / + `max_value` by the time this runs, and the library refuses anything above + the commissioned ceiling again before publishing. That is not redundancy: + the first check is against the range this entity last reported, the second + against what the panel is publishing now, and a recommissioning between + the two is exactly when they differ. + + A fractional request truncates rather than rounds. The property is + declared `integer`, so some whole number has to be chosen, and for a + ceiling the safe direction is down: asking for 16.7 A and getting 16 is + a slower charge, asking for it and getting 17 is a current the user did + not request. + + Both ways the write does not happen reach the caller, through the + `_async_control` every control in this integration shares. A refusal + never resolved an address; a `FAILED` outcome resolved one and was never + handed over, which the library promises means it will not arrive later. + """ + client = self.coordinator.client + await self._async_control( + self._description.set_fn(client, self._evse_id, int(value)), + command=f"a charge-current limit for {self._charger_name}", + failed_key="evse_charge_limit_failed", + not_delivered_key="evse_charge_limit_not_delivered", + placeholders={"charger": self._charger_name}, + ) + + await self.coordinator.async_request_refresh() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._apply(self._evse()) + super()._handle_coordinator_update() + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SpanPanelConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up number entities for Span Panel.""" + _LOGGER.debug("ASYNC SETUP ENTRY NUMBER") + + # Under `disabled` no control entity is created and no registry entry is + # removed. See `switch.async_setup_entry` for why the registry entries stay. + if config_entry.runtime_data.control_policy.mode is ControlMode.DISABLED: + return + + coordinator = config_entry.runtime_data.coordinator + snapshot: SpanPanelSnapshot = config_entry.runtime_data.setup_snapshot + + curated: list[SpanEvseNumber] = [ + SpanEvseNumber(coordinator, description, evse_id) + for evse_id, evse in snapshot.evse.items() + for description in EVSE_NUMBERS + # The declaration is the gate, never the value: a charger that + # declares the property settable and has not published one yet still + # has the control, and a charger that publishes a value it does not + # declare settable does not. + if description.settable_fn(evse) + ] + # Added before adoption is attempted, and in their own call, so that the + # controls this integration models cannot be lost to a device it does not. + # Adoption reads a vendor-extensible schema, so its inputs are the ones no + # amount of care here fully constrains -- a malformed numeric `$format` once + # raised out of `AdoptedNumber` and failed this whole coroutine, taking the + # charge-current limits of curated chargers with it. Ordering is what makes + # that structurally impossible; `classify` refusing an unreadable format is + # what makes it not happen. Both, because only one of them is a rule about + # data this integration controls. + async_add_entities(curated) + + # Settable numerics on devices this integration models nothing for, whose + # bounds come from the declaration -- which is what made them numbers rather + # than readings in the first place. + async_add_entities( + create_adopted_numbers( + coordinator, + snapshot, + dr.async_get(hass), + config_entry_id=config_entry.entry_id, + panel_device_id=config_entry.runtime_data.panel_device_id, + overlay=config_entry.runtime_data.curation, + ) + ) diff --git a/custom_components/span_panel/options.py b/custom_components/span_panel/options.py index 232afd03..b4fd3fe2 100644 --- a/custom_components/span_panel/options.py +++ b/custom_components/span_panel/options.py @@ -14,3 +14,14 @@ NOTIFICATION_TITLE_TEMPLATE = "notification_title_template" NOTIFICATION_MESSAGE_TEMPLATE = "notification_message_template" NOTIFICATION_PRIORITY = "notification_priority" + +# Control authorization and flap protection. See `control_gate`. +CONTROL_MODE = "control_mode" +ALLOW_CONTEXTLESS_CONTROL = "allow_contextless_control" +CONTROL_LOCK_TIMEOUT = "control_lock_timeout" +RELAY_DEBOUNCE_SECONDS = "relay_debounce_seconds" + + +def option_bool(value: object, default: bool) -> bool: + """Read a bool out of untyped option data: `default` unless the stored value is a bool.""" + return value if isinstance(value, bool) else default diff --git a/custom_components/span_panel/pv_binding.py b/custom_components/span_panel/pv_binding.py new file mode 100644 index 00000000..28331d83 --- /dev/null +++ b/custom_components/span_panel/pv_binding.py @@ -0,0 +1,329 @@ +"""Which inverter the Solar card's PV entities read: the one first seen, by its circuit. + +Every install has PV entities on the panel's `{serial}_pv` Solar card -- +`pv_vendor`, `pv_product`, `pv_nameplate_capacity`, `pv_panel_link` -- under +panel-scoped unique ids, beside the panel's total `pv_power`. Before firmware +r202639 a panel published one inverter, and only its feeding circuit carried a +connection record (SPAN's r202639 CHANGELOG), so that inverter was already read +by its circuit. The circuit is recorded once, at the first setup that sees an +inverter, and those entities read the inverter it feeds from then on. Every +other inverter gets a card of its own. Nothing is re-keyed or moved, and +nothing is matched by device id, serial or model. + +Where the first setup sees one inverter no circuit feeds (an upstream PV, as +behind a Tesla Gateway in SpanPanel/span#269) or several at once, nothing says +which inverter the Solar card's entities meant. The record is unbound and they +read `snapshot.pv`: the lone inverter, or the inverters together. An unbound +record is refined to a circuit exactly once -- when the lone inverter the card +already reads, holding no card of its own, is published fed by that circuit -- +because that only remembers what the card reads, before a second inverter +makes it unanswerable. + +The record is needed because the moment it matters -- a second inverter +appearing -- is the moment neither the wire nor the registry can say which +inverter came first. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import TYPE_CHECKING, Final, Literal, TypedDict + +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.storage import Store +from span_panel_api import SpanPVSnapshot + +from .const import DOMAIN, PV_PANEL_LINK_KEY +from .entity_resolver import entity_id_in_entry +from .id_builder import build_binary_sensor_unique_id, build_pv_inverter_unique_id +from .sensor_definitions import PV_METADATA_SENSORS + +if TYPE_CHECKING: + from collections.abc import Iterable + + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + from span_panel_api import SpanPanelSnapshot + +_LOGGER = logging.getLogger(__name__) + +_STORE_VERSION: Final = 1 + +_CARD_ENTITIES: Final[tuple[tuple[str, str], ...]] = ( + *(("sensor", description.key) for description in PV_METADATA_SENSORS), + ("binary_sensor", PV_PANEL_LINK_KEY), +) +"""`(domain, description key)` of the entities an inverter's own card carries.""" + + +type PvBindingMode = Literal["inverter", "unbound", "undecided"] +"""How a setup reads its inverters: bound to a circuit, unbound, or not yet decided.""" + + +class StoredPvBinding(TypedDict): + """The one shape this module writes: the bound circuit, or `None` for unbound.""" + + circuit_id: str | None + + +@dataclass(frozen=True, slots=True) +class PvBinding: + """How this setup reads its inverters. Held in `runtime_data` and nowhere else.""" + + mode: PvBindingMode + bound_key: str | None + """The circuit the Solar card's PV entities are bound to.""" + + legacy_key: str | None + """The inverter the Solar card's PV entities describe at this setup. It gets no card of its own.""" + + withheld: frozenset[str] + """Device-id keys given no card while the bound circuit's record is pending.""" + + solar_link: bool + """Whether the Solar card has its link entity at this setup. + + Decided by what does not change with a reading: the entity is already + registered, so it never vanishes, or every inverter the card reads publishes + a link record. The second is what 2.1.1 asked of its one inverter. A link a + circuit publishes reads OK, LOST or DEGRADED, so it is the record, never + its reading, that decides; the reading is what the entity shows. + """ + + inverter_links: frozenset[str] + """Keys whose own card has its link entity at this setup. + + `solar_link`'s rule applied per key, among the keys with a card of their + own: the link is already registered, or the inverter's circuit publishes a + link record. A registered link never vanishes; it reads unknown while its + inverter reports nothing. + """ + + def has_own_card(self, key: str) -> bool: + """Whether inverter `key` gets a card and entities of its own at this setup.""" + return key != self.legacy_key and key not in self.withheld + + def source(self, snapshot: SpanPanelSnapshot) -> SpanPVSnapshot: + """Return what the Solar card's PV entities read: the bound inverter, else `snapshot.pv`. + + The empty snapshot while the bound inverter is not published, as a + departed SPAN Drive reads empty. + """ + if self.bound_key is None: + return snapshot.pv + return snapshot.pv_inverters.get(self.bound_key, SpanPVSnapshot()) + + +UNDECIDED_PV_BINDING: Final = PvBinding( + "undecided", None, None, frozenset(), solar_link=False, inverter_links=frozenset() +) +"""What setup resolves while no inverter has been seen: nothing bound, nothing withheld, nothing written. + +Its link is kept only where the entity is already registered; see `resolve`. +""" + + +def read_record(stored: object) -> StoredPvBinding | None: + """Return the stored record, or `None` where the file is absent or not what this module wrote. + + This runs inside `async_setup_entry`, where an exception would leave the + entry in SETUP_ERROR (see `additions._load`). An unreadable record is decided + again from the current snapshot. + """ + if not isinstance(stored, dict) or "circuit_id" not in stored: + return None + circuit_id: object = stored["circuit_id"] + if circuit_id is None or isinstance(circuit_id, str): + return StoredPvBinding(circuit_id=circuit_id) + return None + + +def _lone_fed_circuit(snapshot: SpanPanelSnapshot) -> str | None: + """Return the lone inverter's circuit, where exactly one inverter is published and a circuit feeds it.""" + if len(snapshot.pv_inverters) != 1: + return None + key, pv = next(iter(snapshot.pv_inverters.items())) + return key if pv.feed_circuit_id is not None else None + + +def _reads_link_records(snapshot: SpanPanelSnapshot, bound: str | None) -> bool: + """Whether every inverter the Solar card reads publishes a link record. + + Bound, that is the bound inverter, which must be published to have one. + Unbound, the card reads the lone inverter or the inverters together, so + every inverter must have one: with any left unreported the aggregate is + known only while another link is down, and the entity would come and go + with a reading. + """ + if bound is not None: + pv = snapshot.pv_inverters.get(bound) + return pv is not None and pv.connected is not None + inverters = snapshot.pv_inverters.values() + return bool(inverters) and all(pv.connected is not None for pv in inverters) + + +def _inverter_links( + snapshot: SpanPanelSnapshot, + legacy: str | None, + withheld: frozenset[str], + inverter_links_held: frozenset[str], +) -> frozenset[str]: + """Return the carded keys whose link exists: registered already, or recorded by their circuit.""" + return frozenset( + key + for key, pv in snapshot.pv_inverters.items() + if key != legacy + and key not in withheld + and (key in inverter_links_held or pv.connected is not None) + ) + + +def resolve( + snapshot: SpanPanelSnapshot, + record: StoredPvBinding | None, + held: frozenset[str], + *, + link_held: bool, + inverter_links_held: frozenset[str], +) -> tuple[PvBinding, StoredPvBinding | None]: + """Decide this setup's identity, and the record to keep. + + `held` is the inverter keys that already have a card of their own, + `link_held` whether the Solar card's link entity is already registered, and + `inverter_links_held` the keys whose own link entity is. The + record is written at first sight, binding only a lone circuit-fed inverter + that holds no card; its only rewrite is refining an unbound record the same + way. A key in `held` always keeps its card, and a held link is always kept: + nothing here withdraws any of them. + """ + inverters = snapshot.pv_inverters + lone_fed = _lone_fed_circuit(snapshot) + bindable = lone_fed if lone_fed is not None and lone_fed not in held else None + if record is None: + if not inverters: + undecided = PvBinding( + "undecided", + None, + None, + frozenset(), + solar_link=link_held, + inverter_links=frozenset(), + ) + return undecided, None + record = StoredPvBinding(circuit_id=bindable) + elif record["circuit_id"] is None and bindable is not None: + record = StoredPvBinding(circuit_id=bindable) + bound = record["circuit_id"] + if bound is not None: + pending = bound not in inverters and bound in snapshot.circuits + withheld = frozenset( + key + for key, pv in inverters.items() + if pending and pv.feed_circuit_id is None and key not in held + ) + # A bound key that somehow holds a card (a store restored from an older backup) keeps it. + legacy = None if bound in held else bound + solar_link = link_held or _reads_link_records(snapshot, bound) + links = _inverter_links(snapshot, legacy, withheld, inverter_links_held) + return PvBinding("inverter", bound, legacy, withheld, solar_link, links), record + lone = next(iter(inverters)) if len(inverters) == 1 else None + legacy = lone if lone is not None and lone not in held else None + solar_link = link_held or _reads_link_records(snapshot, None) + links = _inverter_links(snapshot, legacy, frozenset(), inverter_links_held) + return PvBinding("unbound", None, legacy, frozenset(), solar_link, links), record + + +def store_for(hass: HomeAssistant, entry: ConfigEntry) -> Store[StoredPvBinding]: + """Per entry. A `Store`, not config entry data: writing entry data during setup fires the update listener.""" + return Store(hass, _STORE_VERSION, f"{DOMAIN}.pv_binding.{entry.entry_id}") + + +def _registered_in(registry: er.EntityRegistry, entry_id: str, domain: str, unique_id: str) -> bool: + """Whether `unique_id` is registered under `domain` in this entry; another entry's says nothing.""" + return entity_id_in_entry(registry, entry_id, domain, unique_id) is not None + + +def keys_holding_cards( + registry: er.EntityRegistry, entry_id: str, serial: str, keys: Iterable[str] +) -> frozenset[str]: + """Return the inverter keys among `keys` that already have an entity on a card of their own, in this entry. + + Built with `build_pv_inverter_unique_id`, as `SpanPVInverterSensor` and + `SpanPVInverterBinarySensor` build theirs; scoped to `entry_id` like every + registry lookup here. + """ + + def holds(key: str) -> bool: + return any( + _registered_in( + registry, + entry_id, + domain, + build_pv_inverter_unique_id(serial, key, description_key), + ) + for domain, description_key in _CARD_ENTITIES + ) + + return frozenset(key for key in keys if holds(key)) + + +def inverter_links_held( + registry: er.EntityRegistry, entry_id: str, serial: str, keys: Iterable[str] +) -> frozenset[str]: + """Return the keys among `keys` whose own link entity is already registered in this entry. + + Built with `build_pv_inverter_unique_id`, as `SpanPVInverterBinarySensor` builds its id. + """ + return frozenset( + key + for key in keys + if _registered_in( + registry, + entry_id, + "binary_sensor", + build_pv_inverter_unique_id(serial, key, PV_PANEL_LINK_KEY), + ) + ) + + +def solar_link_held(registry: er.EntityRegistry, entry_id: str, serial: str) -> bool: + """Return whether the Solar card's link entity is already registered in this entry. + + Built with `build_binary_sensor_unique_id`, as `SpanPVSolarLinkBinarySensor` + builds its id through the panel binary sensor it extends. + """ + return _registered_in( + registry, + entry_id, + "binary_sensor", + build_binary_sensor_unique_id(serial, PV_PANEL_LINK_KEY), + ) + + +async def async_resolve_pv_binding( + hass: HomeAssistant, entry: ConfigEntry, snapshot: SpanPanelSnapshot +) -> PvBinding: + """Resolve this setup's identity, writing the record at first sight or at its one refinement.""" + store = store_for(hass, entry) + record = read_record(await store.async_load()) + registry = er.async_get(hass) + held = keys_holding_cards( + registry, entry.entry_id, snapshot.serial_number, snapshot.pv_inverters + ) + link_held = solar_link_held(registry, entry.entry_id, snapshot.serial_number) + links_held = inverter_links_held( + registry, entry.entry_id, snapshot.serial_number, snapshot.pv_inverters + ) + identity, kept = resolve( + snapshot, record, held, link_held=link_held, inverter_links_held=links_held + ) + if kept is not None and kept != record: + await store.async_save(kept) + _LOGGER.info("Solar card's PV entities: %s", identity.mode) + return identity + + +async def async_forget_pv_binding(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Drop the record with the entry, as `curation.async_forget_curation` drops its own.""" + await store_for(hass, entry).async_remove() diff --git a/custom_components/span_panel/quality_scale.yaml b/custom_components/span_panel/quality_scale.yaml index d4e56eeb..e00ca898 100644 --- a/custom_components/span_panel/quality_scale.yaml +++ b/custom_components/span_panel/quality_scale.yaml @@ -95,9 +95,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: - status: exempt - comment: No actionable repair scenarios identified for this integration. + repair-issues: done stale-devices: done # Platinum diff --git a/custom_components/span_panel/repairs.py b/custom_components/span_panel/repairs.py new file mode 100644 index 00000000..1693d9af --- /dev/null +++ b/custom_components/span_panel/repairs.py @@ -0,0 +1,161 @@ +"""Fix flows for this integration's Repairs. + +Discovered by name: Home Assistant looks for `repairs.async_create_fix_flow` in +the integration whenever an issue it raised is marked `is_fixable`. + +One issue is fixable, and everything here exists for it. See `ca_repairs`. +""" + +from __future__ import annotations + +import logging + +from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from span_panel_api import ca_fingerprint +from span_panel_api.exceptions import ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + SpanPanelValidationError, +) +import voluptuous as vol + +from .ca_repairs import CA_CHANGED_ISSUE_PREFIX, CA_UNUSABLE_ISSUE_PREFIX +from .config_flow_validation import ( + as_port, + async_ca_signs_panel_leaf, + async_fetch_panel_ca, + pem_fingerprint_or_reason, +) +from .const import ( + CONF_EBUS_BROKER_PORT, + CONF_HTTP_PORT, + CONF_PANEL_CA_PEM, + DEFAULT_MQTTS_PORT, + DOMAIN, + PANEL_CA_PENDING, +) + +_LOGGER = logging.getLogger(__name__) + + +class PanelCAChangedRepairFlow(RepairsFlow): + """Re-pin a panel's CA, but only on an explicit human acceptance. + + The fingerprint is taken from a *fresh* fetch rather than from the issue's + stored one. The two should agree, and if they do not the panel has changed + its CA twice — at which point the value the user is being asked to accept + has to be the one that will actually be stored, not a record of an earlier + observation. Accepting a fingerprint and pinning a different certificate + would make the confirmation meaningless. + + That fresh fetch has to earn its place in the dialog: it is plaintext and + unauthenticated, so it is checked against the certificate the panel serves + before any fingerprint is shown. Everything this flow asks of the user rests + on the value being the panel's own. + """ + + def __init__(self, entry_id: str) -> None: + """Hold the entry this flow re-pins.""" + self._entry_id = entry_id + self._ca_pem: str | None = None + + async def async_step_init(self, user_input: dict[str, str] | None = None) -> RepairsFlowResult: + """Start at the confirmation.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Show the CA the panel is advertising now, and re-pin it if accepted.""" + entry = self.hass.config_entries.async_get_entry(self._entry_id) + if entry is None: + return self.async_abort(reason="entry_gone") + + if user_input is not None and self._ca_pem is not None: + data = dict(entry.data) + data[CONF_PANEL_CA_PEM] = self._ca_pem + data.pop(PANEL_CA_PENDING, None) + self.hass.config_entries.async_update_entry(entry, data=data) + _LOGGER.warning( + "Re-pinned the CA for SPAN panel %s to %s on explicit confirmation", + entry.title, + ca_fingerprint(self._ca_pem), + ) + # The issue is cleared by the fix flow completing; the reload is what + # puts the new anchor into a live transport. + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_create_entry(data={}) + + host = str(entry.data.get(CONF_HOST, "")) + # Cleared before the fetch, not after: nothing accepted on an earlier + # pass through this step may survive into a pass that refuses. + self._ca_pem = None + try: + ca_pem = await async_fetch_panel_ca( + self.hass, host, http_port=as_port(entry.data.get(CONF_HTTP_PORT), 80) + ) + fingerprint = ca_fingerprint(ca_pem) + except ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelTimeoutError, + SpanPanelValidationError, + ) as err: + _LOGGER.warning("Could not re-read the CA from panel %s: %s", host, err) + return self.async_abort(reason="ca_unreadable") + + # The fetch is plaintext and unauthenticated, so whatever answered it + # decides what this dialog would ask the user to trust. A CA that signs + # nothing the panel serves cannot be the panel's, and its fingerprint is + # not one to put in front of a person as the panel's own -- accepting it + # would replace a working pin with one that can never connect. + # + # Checked on the broker's port, because that is the connection the pin + # anchors and the one this repair exists to restore. Verifying somewhere + # else would be answering a question nobody asked. + mqtts_port = as_port(entry.data.get(CONF_EBUS_BROKER_PORT), DEFAULT_MQTTS_PORT) + if not await async_ca_signs_panel_leaf(self.hass, host, mqtts_port, ca_pem): + _LOGGER.warning( + "SPAN panel %s published a CA (SHA-256 %s) that does not sign the " + "certificate served by its broker at %s:%s; refusing to offer it. The " + "pin in place is SHA-256 %s and has not been touched", + entry.title, + fingerprint, + host, + mqtts_port, + pem_fingerprint_or_reason(entry.data.get(CONF_PANEL_CA_PEM)), + ) + return self.async_abort(reason="ca_leaf_mismatch") + + self._ca_pem = ca_pem + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders={ + "panel": entry.title, + "fingerprint": fingerprint, + }, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Return the flow that fixes `issue_id`.""" + if issue_id.startswith(CA_CHANGED_ISSUE_PREFIX): + entry_id = issue_id.removeprefix(CA_CHANGED_ISSUE_PREFIX) + return PanelCAChangedRepairFlow(entry_id) + if issue_id.startswith(CA_UNUSABLE_ISSUE_PREFIX): + # The same flow on purpose: an unreadable stored anchor and a rotated CA + # end in the same place — fetch what the panel advertises, check it + # signs the panel's own certificate, and pin it only on a person's + # explicit acceptance of the fingerprint. Only the story the issue tells + # differs, and that lives in its own translation. + entry_id = issue_id.removeprefix(CA_UNUSABLE_ISSUE_PREFIX) + return PanelCAChangedRepairFlow(entry_id) + raise ValueError(f"{DOMAIN} raised no fixable issue with id {issue_id}") diff --git a/custom_components/span_panel/runtime.py b/custom_components/span_panel/runtime.py new file mode 100644 index 00000000..1bafe331 --- /dev/null +++ b/custom_components/span_panel/runtime.py @@ -0,0 +1,118 @@ +"""The types that describe a Span Panel config entry's runtime data. + +A leaf module on purpose. `SpanPanelConfigEntry` is the annotation every +platform, the coordinator, the migrations and the services need, and it used to +live in the package root -- which imports every platform. Naming the entry type +therefore meant importing the whole integration, so the modules that needed only +the annotation reached for it from inside functions instead, and `services.py` +grew five copies of the same runtime-data guard around five deferred imports. + +Nothing here imports anything from this package at runtime except +`control_gate` and `curation`, and neither reaches back into the platforms: +`control_gate` sees `const` and `options`, `curation` sees `const` and `util`. +`SpanPanelCoordinator` is needed only as an annotation, so it is imported under +`TYPE_CHECKING`: a module that wants to say "this is a SPAN entry" should not +have to pull in the coordinator and, through it, the schema and sensor +machinery. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from homeassistant.config_entries import ConfigEntry + +from .control_gate import ControlLock, ControlPolicy +from .curation import CurationOverlay + +if TYPE_CHECKING: + from span_panel_api import SpanPanelSnapshot + + from .coordinator import SpanPanelCoordinator + from .pv_binding import PvBinding + + +def _default_control_lock() -> ControlLock: + """Return the lock that belongs to the default control policy. + + Derived from the policy rather than written down again, so the two cannot + say different things about whether the feature is on. `async_setup_entry` + builds the pair the same way (`ControlLock(armed=policy.lock_enabled)`): a + lock that exists as a user-facing thing starts armed, and one the entry has + not enabled starts disarmed because there is no switch to open it with. + """ + return ControlLock(armed=ControlPolicy.default().lock_enabled) + + +@dataclass +class SpanPanelRuntimeData: + """Runtime data for a Span Panel config entry.""" + + coordinator: SpanPanelCoordinator + # Registry id of the panel device, which every sub-device (BESS, MID, EVSE) + # hangs off with `via_device_id`. Resolved once during setup rather than + # per platform: sub-devices are only ever built after the panel device + # exists, so an id looked up here is one no caller has to handle the absence + # of. See `ensure_device_registered`. + panel_device_id: str + # The user's curation overlay for adopted entities, loaded from .storage + # before the platforms are forwarded so every adopted entity is *born* with + # its curated metadata -- a state class that arrives after the first state is + # written is a statistics reset rather than a metadata change. Required + # rather than defaulted: a setup path that forgets the load has to fail here, + # because an empty overlay is indistinguishable from a user who has curated + # nothing, and the user's records would still be sitting on disk. + curation: CurationOverlay + # Which inverter the Solar card's PV entities read, and which inverters get + # a card of their own; see `pv_binding`. Required for the reason + # `curation` is: a setup path that forgets to resolve it would silently + # give the bound inverter a duplicate card and the Solar card the wrong one. + pv_binding: PvBinding + # The snapshot this setup decided from. `pv_binding` was resolved from it, + # and every platform builds its entities from it rather than from + # `coordinator.data`, which may have moved on by the time a platform runs: + # an inverter published in between would otherwise get a card the binding + # never decided on. Whatever arrives later reaches the entities through the + # coordinator, and new hardware reloads (`_check_capability_change`), so the + # next setup decides from it. Required for the reason `pv_binding` is. + setup_snapshot: SpanPanelSnapshot + # Resolved once at setup and read by every control platform, so a single + # answer decides which entities exist and which callers may operate them. + # Defaulted rather than required because the default *is* the policy an entry + # with no control options has, and that is most of them. + control_policy: ControlPolicy = field(default_factory=ControlPolicy.default) + # Shared with the gate, and mutated by the lock entity. One object per entry: + # the gate reads it on every publish and the entity writes it. The default + # is the default policy's lock rather than a bare `ControlLock()`, whose + # disarmed state would be a promise this class is in no position to make. + control_lock: ControlLock = field(default_factory=_default_control_lock) + + +type SpanPanelConfigEntry = ConfigEntry[SpanPanelRuntimeData] + + +def loaded_runtime_data(entry: ConfigEntry) -> SpanPanelRuntimeData | None: + """Return this entry's runtime data, or None if it is not one of ours. + + Every service is registered domain-wide and walks + `async_loaded_entries(DOMAIN)`, so each of them has to answer this question + before touching an entry -- and so does anything handed an entry by core: + `async_unload_entry` after the platforms have gone, and + `async_remove_config_entry_device`. + + Both halves of the check are real. `ConfigEntry.runtime_data` is a bare + annotation that core deletes on unload (`config_entries.py:1044-1045`), so + the attribute is genuinely absent on an entry that has not finished setting + up -- hence `getattr` with a default rather than an attribute read. And what + is there is whatever the owning integration put there, so `isinstance` is + what says it is ours; a test double may put anything at all in it. + + One helper, one answer. It lives here rather than in `services` because + `services` is not a leaf and half the callers are not services: this module + already owns the type the check is against, and importing it costs nothing. + """ + runtime_data = getattr(entry, "runtime_data", None) + if isinstance(runtime_data, SpanPanelRuntimeData): + return runtime_data + return None diff --git a/custom_components/span_panel/schema_expectations.py b/custom_components/span_panel/schema_expectations.py deleted file mode 100644 index ed4d2050..00000000 --- a/custom_components/span_panel/schema_expectations.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Sensor-to-snapshot-field mapping for schema validation. - -Maps integration sensor definition keys to snapshot field paths. This is the -integration's declaration of which snapshot fields it reads, expressed in -transport-agnostic terms. - -The integration does NOT know about Homie, MQTT, node types, or property IDs. -The ``span-panel-api`` library owns that knowledge and exposes field-level -metadata keyed by snapshot field paths. This module bridges from sensor -definitions (HA side) to field paths (library side). - -Field path convention: ``{snapshot_type}.{field_name}`` - - ``panel`` — SpanPanelSnapshot fields - - ``circuit`` — SpanCircuitSnapshot fields - - ``battery`` — SpanBatterySnapshot fields - - ``pv`` — SpanPVSnapshot fields - - ``evse`` — SpanEvseSnapshot fields - -Derived sensors (net energy, dsm_state, current_run_config) that compute -values from multiple fields have no single source field and are excluded. -""" - -from __future__ import annotations - -# --------------------------------------------------------------------------- -# Sensor definition key → snapshot field path -# -# Every sensor the integration creates that reads a single snapshot field -# should appear here. The sensor definition provides the HA unit; the -# library's field metadata provides the schema-declared unit. The validation -# module compares them. -# -# Entries are grouped by snapshot type for readability. -# --------------------------------------------------------------------------- - -SENSOR_FIELD_MAP: dict[str, str] = { - # --- Panel power sensors ------------------------------------------------- - "instantGridPowerW": "panel.instant_grid_power_w", - "feedthroughPowerW": "panel.feedthrough_power_w", - "batteryPowerW": "panel.power_flow_battery", - "pvPowerW": "panel.power_flow_pv", - "gridPowerFlowW": "panel.power_flow_grid", - "sitePowerW": "panel.power_flow_site", - # --- Panel energy sensors ------------------------------------------------ - "mainMeterEnergyProducedWh": "panel.main_meter_energy_produced_wh", - "mainMeterEnergyConsumedWh": "panel.main_meter_energy_consumed_wh", - "feedthroughEnergyProducedWh": "panel.feedthrough_energy_produced_wh", - "feedthroughEnergyConsumedWh": "panel.feedthrough_energy_consumed_wh", - # --- Panel diagnostic sensors -------------------------------------------- - "l1_voltage": "panel.l1_voltage", - "l2_voltage": "panel.l2_voltage", - "upstream_l1_current": "panel.upstream_l1_current_a", - "upstream_l2_current": "panel.upstream_l2_current_a", - "downstream_l1_current": "panel.downstream_l1_current_a", - "downstream_l2_current": "panel.downstream_l2_current_a", - "main_breaker_rating": "panel.main_breaker_rating_a", - # --- Panel status sensors (enum/string — no unit, but tracked) ----------- - "main_relay_state": "panel.main_relay_state", - "grid_forming_entity": "panel.dominant_power_source", - "vendor_cloud": "panel.vendor_cloud", - "software_version": "panel.firmware_version", - # --- Circuit sensors ----------------------------------------------------- - "circuit_power": "circuit.instant_power_w", - "circuit_energy_produced": "circuit.produced_energy_wh", - "circuit_energy_consumed": "circuit.consumed_energy_wh", - "circuit_current": "circuit.current_a", - "circuit_breaker_rating": "circuit.breaker_rating_a", - # --- Unmapped circuit sensors (same fields, different sensor keys) -------- - "instantPowerW": "circuit.instant_power_w", - "producedEnergyWh": "circuit.produced_energy_wh", - "consumedEnergyWh": "circuit.consumed_energy_wh", - # --- Battery sensors ----------------------------------------------------- - "storage_battery_percentage": "battery.soe_percentage", - "nameplate_capacity": "battery.nameplate_capacity_kwh", - "soe_kwh": "battery.soe_kwh", - # --- BESS metadata sensors ----------------------------------------------- - "vendor": "battery.vendor_name", - "model": "battery.product_name", - "serial_number": "battery.serial_number", - "firmware_version": "battery.software_version", - # --- PV metadata sensors ------------------------------------------------- - "pv_vendor": "pv.vendor_name", - "pv_product": "pv.product_name", - "pv_nameplate_capacity": "pv.nameplate_capacity_w", - # --- EVSE sensors -------------------------------------------------------- - "evse_status": "evse.status", - "evse_advertised_current": "evse.advertised_current_a", - "evse_lock_state": "evse.lock_state", -} - -# Derived sensors excluded from the map (computed from multiple fields): -# dsm_state — multi-signal heuristic -# dsm_grid_state — deprecated alias for dsm_state -# current_run_config — tri-state derivation -# mainMeterNetEnergyWh — consumed_wh - produced_wh -# feedthroughNetEnergyWh — consumed_wh - produced_wh -# circuit_energy_net — consumed_wh - produced_wh (or inverse for PV) - - -def all_referenced_field_paths() -> frozenset[str]: - """Return the set of all snapshot field paths referenced by any sensor.""" - return frozenset(SENSOR_FIELD_MAP.values()) diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py new file mode 100644 index 00000000..dade2f0c --- /dev/null +++ b/custom_components/span_panel/schema_repairs.py @@ -0,0 +1,271 @@ +"""Surface schema findings as Home Assistant Repairs. + +Two conditions reach the user, both defects: a field the adapter cannot resolve +(a sensor is dead), and a unit that disagrees with the schema (a reading or its +statistics are wrong). Both are reported regardless of install age — a defect is +not a change. + +A third condition, a produced field nothing reads, is a sanctioned addition and +stays in the debug log. + +One further notice lives here and is not a defect at all: entities that this +setup registered for the first time and disabled by default. Registering a new +diagnostic disabled is what keeps an upgrade from growing everybody's entity +list uninvited, but it also makes the addition invisible — the user only finds +it by opening the device's disabled-entity list. That notice is an event, not a +condition, and is raised on different terms from the two defects; see +`async_notice_new_disabled_entities`. + +Every Repair here claims something the user owns is broken, so a finding whose +field path no enabled entity reads is not raised at all. Disabled-by-default +descriptions are registered but never added to hass, so they never self-register +with the coordinator and a finding against one has nothing to name; telling the +user "0 entity/entities are affected" only teaches them to ignore the category. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN, EVENT_SCHEMA_ISSUE +from .schema_validation import SchemaFindings + +_LOGGER = logging.getLogger(__name__) + +_MAX_EXAMPLES = 3 + +# Issue-id prefixes. `_DEFECT_PREFIXES` is what the reconcile pass owns; the +# new-entity notice is deliberately not among them. See `_scoped_issue_ids`. +_DEFECT_PREFIXES = ("unresolved_", "unit_mismatch_") +_NEW_ENTITIES_PREFIX = "new_entities_" +"""Id prefix of the retired new-entity Repair. + +Nothing raises one any more -- an addition is not a repair, and it is announced +as a notification by `additions` instead. The prefix survives so the ones already +standing on upgraded installs get cleared: they were raised `is_persistent`, so +without this they would outlive the mechanism that made them. +""" + + +_RETIRED_UPGRADE_ID = "panel_upgraded_to_ebus_v1" +"""Id stem of the retired firmware-upgrade Repair. + +Nothing raises one any more. An upgrade that took nothing away is not a defect, +and the Repairs list stamped it with a severity and offered to ignore it, which +told the user their panel was broken; `notices` carries it as a notification that +survives a restart, which is the only property being a Repair was buying. + +Unlike the other ids here this one is a whole id rather than a prefix -- it was +raised as `{stem}_{entry_id}` with nothing after -- so it is cleared by name. +""" + + +def _unresolved_id(entry_id: str, field_path: str) -> str: + return f"unresolved_{entry_id}_{field_path}" + + +def _unit_id(entry_id: str, field_path: str) -> str: + return f"unit_mismatch_{entry_id}_{field_path}" + + +def _affected(entity_ids_by_path: Mapping[str, list[str]], field_path: str) -> list[str]: + """Entities in hass that read this field, sorted. + + Sorted so an unchanged panel produces an unchanged payload: the update branch + only rewrites the entry when something actually differs. + """ + return sorted(entity_ids_by_path.get(field_path, [])) + + +def _log_suppressed(condition: str, field_path: str) -> None: + _LOGGER.debug( + "Suppressed %s Repair for %s: no enabled entity reads that field path, so " + "nothing the user owns is affected", + condition, + field_path, + ) + + +def _placeholders(affected: list[str]) -> dict[str, str]: + """Count plus a bounded sample, never the full list. + + One missing `circuit.instant_power_w` affects every circuit on the panel. + """ + return { + "count": str(len(affected)), + "examples": ", ".join(affected[:_MAX_EXAMPLES]), + } + + +@callback +def async_sync_schema_issues( + hass: HomeAssistant, + entry: ConfigEntry, + findings: SchemaFindings, + entity_ids_by_path: Mapping[str, list[str]], +) -> None: + """Reconcile Repairs against the current findings. + + One issue per (class, field path). Aggregating would be actively harmful: + `async_get_or_create`'s update branch preserves `dismissed_version`, so a + user who dismissed an aggregate would never be told when another field + joined it. + + A finding with no affected entity is suppressed rather than raised, and a + suppressed path leaves `wanted`, so an issue raised while entities existed is + deleted once the last one goes — the same path that clears a genuinely + resolved finding. + + Re-raises idempotently and deletes only on genuine resolution. Deleting is + the one thing that clears a dismissal — `dismissed_version` is never + compared against the running HA version, and neither the update branch nor + the store reload touches it — so a delete-then-recreate loop would wipe + every dismissal on every pass and turn an accepted notice into a permanent + nag. + """ + registry = ir.async_get(hass) + wanted: set[str] = set() + raised_unresolved: list[str] = [] + raised_mismatches: list[str] = [] + + for field_path in sorted(findings.unresolved): + affected = _affected(entity_ids_by_path, field_path) + if not affected: + _log_suppressed("unresolved-field", field_path) + continue + issue_id = _unresolved_id(entry.entry_id, field_path) + wanted.add(issue_id) + raised_unresolved.append(field_path) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + # Derived from live state, so it is re-asserted at every startup. A + # non-persistent issue reloads as a tombstone carrying only the + # dismissal, which is what lets re-assertion happen without + # resurrecting one the user already accepted. + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="schema_field_unresolved", + translation_placeholders={"field_path": field_path, **_placeholders(affected)}, + ) + + for mismatch in findings.unit_mismatches: + affected = _affected(entity_ids_by_path, mismatch.field_path) + if not affected: + _log_suppressed("unit-mismatch", mismatch.field_path) + continue + issue_id = _unit_id(entry.entry_id, mismatch.field_path) + wanted.add(issue_id) + raised_mismatches.append(mismatch.field_path) + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="schema_unit_mismatch", + translation_placeholders={ + "field_path": mismatch.field_path, + "ha_unit": mismatch.ha_unit, + "schema_unit": mismatch.schema_unit, + **_placeholders(affected), + }, + ) + + # Defect prefixes only: everything in this scope is re-derived on every pass, + # and the new-entity notice is not re-derivable at all. + for issue_id in _scoped_issue_ids(registry, entry.entry_id, _DEFECT_PREFIXES) - wanted: + ir.async_delete_issue(hass, DOMAIN, issue_id) + + if wanted: + # The event mirrors what the user was actually told: a suppressed finding + # is deliberately not user-facing, and an automation reacting to one + # would be reacting to a defect that took nothing down. + hass.bus.async_fire( + EVENT_SCHEMA_ISSUE, + { + "entry_id": entry.entry_id, + "unresolved": raised_unresolved, + "unit_mismatches": raised_mismatches, + }, + ) + + +@callback +def _scoped_issue_ids( + registry: ir.IssueRegistry, entry_id: str, prefixes: tuple[str, ...] +) -> set[str]: + """Return this entry's issue ids under the given id prefixes. + + Scoping by entry is not cosmetic: with a shared namespace, a healthy panel's + reconcile pass would delete a degraded panel's issues on every cycle, and + removing one panel would clear every panel's issues. + + Which prefixes is not cosmetic either, and is why this takes them rather than + answering for the whole domain. The reconcile pass deletes every id it did + not re-derive, so it may only ever see `_DEFECT_PREFIXES`; the new-entity + notice is derived exactly once and would not survive being reconciled against + a pass that cannot re-derive it. Removal, which deletes unconditionally, is + the one caller that passes every prefix. + """ + scoped = tuple(f"{prefix}{entry_id}_" for prefix in prefixes) + return { + issue_id + for (domain, issue_id) in registry.issues + if domain == DOMAIN and issue_id.startswith(scoped) + } + + +@callback +def async_clear_retired_new_entity_notices(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Delete new-entity Repairs raised before additions became notifications. + + They were raised `is_persistent=True` precisely so a restart could not sweep + them away, which now means an upgraded install keeps one standing in its + Repairs list forever with nothing left to re-derive it. Cleared at setup + rather than at removal, because the user is looking at it now. + """ + registry = ir.async_get(hass) + for issue_id in _scoped_issue_ids(registry, entry.entry_id, (_NEW_ENTITIES_PREFIX,)): + _LOGGER.debug("Clearing retired new-entity notice %s", issue_id) + ir.async_delete_issue(hass, DOMAIN, issue_id) + + +@callback +def async_clear_retired_upgrade_notice(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Delete the firmware-upgrade Repair raised before it became a notification. + + Cleared at setup rather than left to expire: it was raised non-persistent, so + a restart would sweep it away on its own, but the user is looking at it now + and has no reason to restart. The dismissal tombstone goes with it, which + costs nothing -- there is no longer an issue for it to suppress. + """ + if ir.async_get(hass).async_get_issue(DOMAIN, f"{_RETIRED_UPGRADE_ID}_{entry.entry_id}"): + _LOGGER.debug("Clearing retired upgrade notice for %s", entry.entry_id) + ir.async_delete_issue(hass, DOMAIN, f"{_RETIRED_UPGRADE_ID}_{entry.entry_id}") + + +@callback +def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove one entry's issues. Core does not do this on entry removal. + + Covers the new-entity notices too, and they are the ones that need it most: + they are persistent, so unlike the defect notices they would not even be + demoted to tombstones by a restart — a removed panel would leave them + standing forever. + """ + registry = ir.async_get(hass) + every_prefix = (*_DEFECT_PREFIXES, _NEW_ENTITIES_PREFIX) + for issue_id in _scoped_issue_ids(registry, entry.entry_id, every_prefix): + ir.async_delete_issue(hass, DOMAIN, issue_id) + # Setup normally clears this one, but an entry removed before it ever set up + # successfully never got there. + async_clear_retired_upgrade_notice(hass, entry) diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py index 34febe52..0f9d3969 100644 --- a/custom_components/span_panel/schema_validation.py +++ b/custom_components/span_panel/schema_validation.py @@ -1,176 +1,234 @@ -"""Schema validation — cross-check field metadata against sensor definitions. +"""Compare adapter field metadata against what this integration declares it reads. + +Consumes the library's three-way signal: + +- entry, ``resolved=True`` — produced; the unit is meaningful +- entry, ``resolved=False`` — a device is present but does not declare the + property. Degradation. +- **no entry** — no device of that type. Hardware absent; not a defect. + +Because the adapter classifies absence, this module needs no capability table +and never infers hardware presence from telemetry. + +**Two inventories, not one.** An adapter's metadata map carries curated rows — +snapshot field paths this integration knows about — and, under the library's +discovery namespace, rows for properties the panel declares and the adapter +reads nothing from. They answer opposite questions and must never mix: +`unread` below is "we produce this and render nothing from it", which is an +inventory of *our* backlog, while a discovered row is "the panel has something +we never modelled". Letting the second into the first would bury ten known +entries under whatever a firmware release happened to add, and would make the +conformance gate's producible set depend on the panel in front of the user. + +So the metadata is partitioned by namespace before any other question is asked +of it — see `partition`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging -Compares the ``span-panel-api`` library's field metadata (schema-derived units -and datatypes keyed by snapshot field paths) against the integration's sensor -definitions. All Homie/MQTT knowledge stays in the library; this module only -sees snapshot field paths and HA sensor metadata. +from homeassistant.components.sensor import SensorEntityDescription +from span_panel_api.models import DiscoveredMetadata, FieldMetadata, is_discovery_path -Schema drift detection (diffing schema versions between firmware updates) is -the library's responsibility. The integration only consumes the result. +from .field_paths import RESIDUAL_EXEMPT_PATHS, conditional_field_paths, declared_field_paths -All output is log-only. No entity creation or sensor behavior changes. +_LOGGER = logging.getLogger(__name__) -Phase 1 of the schema-driven changes plan. -Usage: - Called from the coordinator after the first successful data refresh. - Requires ``span-panel-api`` to expose field metadata via the client protocol. - Until that library change lands, ``validate_field_metadata()`` is a safe no-op. +KNOWN_BAD_SCHEMA_UNITS: dict[str, str] = { + # SPAN firmware declares the circuit `active-power` property as "kW" while + # publishing watts. Three things agree that the label, not the reading, is + # wrong: the sibling `lugs` device declares the same quantity as "W"; the + # library consumes the value unscaled ("active-power is in watts", + # span_panel_api_schema_0/consumer.py:244); and the independent `span-hass` + # integration documents the same defect under "Known SPAN API Issue" and + # hardcodes the same override. Our `UnitOfPower.WATT` declaration is correct. + "circuit.instant_power_w": "kW", +} +"""Schema unit declarations this integration knowingly ignores, by field path. + +Every entry is a firmware defect worked around deliberately — the panel labels a +property with a unit it does not publish — never a sensor whose unit we gave up +on checking. Without this, a panel running the affected firmware raises a +mismatch its owner cannot act on and that reflects no real defect. + +The match is exact. If firmware later declares something OTHER than the value +here for the same field, that is new information and is still reported. """ -from __future__ import annotations -import logging +@dataclass(frozen=True, slots=True) +class UnitMismatch: + """A declared unit that disagrees with the schema's.""" -from homeassistant.components.sensor import SensorEntityDescription + field_path: str + ha_unit: str + schema_unit: str -from .schema_expectations import SENSOR_FIELD_MAP, all_referenced_field_paths -from .sensor_definitions import ( - BATTERY_POWER_SENSOR, - BATTERY_SENSOR, - BESS_METADATA_SENSORS, - CIRCUIT_BREAKER_RATING_SENSOR, - CIRCUIT_CURRENT_SENSOR, - CIRCUIT_SENSORS, - DOWNSTREAM_L1_CURRENT_SENSOR, - DOWNSTREAM_L2_CURRENT_SENSOR, - EVSE_SENSORS, - GRID_POWER_FLOW_SENSOR, - L1_VOLTAGE_SENSOR, - L2_VOLTAGE_SENSOR, - MAIN_BREAKER_RATING_SENSOR, - PANEL_DATA_STATUS_SENSORS, - PANEL_ENERGY_SENSORS, - PANEL_POWER_SENSORS, - PV_METADATA_SENSORS, - PV_POWER_SENSOR, - SITE_POWER_SENSOR, - STATUS_SENSORS, - UNMAPPED_SENSORS, - UPSTREAM_L1_CURRENT_SENSOR, - UPSTREAM_L2_CURRENT_SENSOR, -) -_LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, slots=True) +class DiscoveredProperty: + """A property the panel declares that the running adapter reads nothing from. + The runtime half of `tests/test_declared_but_unread`, which asks the same + question of the adapter's reference capture and therefore cannot see a panel + that starts publishing something in the field. -def _cross_check_units( - field_metadata: dict[str, dict[str, object]], - sensor_defs: dict[str, SensorEntityDescription], -) -> None: - """Compare library-reported units against sensor definition units. + Maintainer-facing only. Nothing creates an entity, a Repair or a + notification from one of these — it is carried in diagnostics so that + triaging an issue shows what that panel declares and this integration + ignores, and so the rate can be measured across a few attachments. - For each sensor in SENSOR_FIELD_MAP that has a ``native_unit_of_measurement``, - look up the corresponding field path in the library's metadata and compare - the declared unit. + **Declarations only, deliberately.** Diagnostics leave the house into GitHub + issues and forum posts, and `diagnostics.TO_REDACT` is key-based: it knows + the config entry's keys and nothing at all about wire property names, so it + could not protect a value put here. `retained` is the only thing this says + about a value, and it says whether one exists rather than what it is. """ - for sensor_key, field_path in SENSOR_FIELD_MAP.items(): - sensor_def = sensor_defs.get(sensor_key) - if sensor_def is None: - continue - ha_unit = sensor_def.native_unit_of_measurement - if ha_unit is None: - # Sensor has no unit (enum, string) — nothing to cross-check - continue + path: str + """The library's namespaced path, ``discovered.{device type}/{node}/{property}``. - field_info = field_metadata.get(field_path) - if field_info is None: - _LOGGER.debug( - "Schema cross-check: sensor '%s' reads field '%s' but " - "library reports no metadata for it", - sensor_key, - field_path, - ) - continue - - schema_unit = field_info.get("unit") - if schema_unit is None: - _LOGGER.debug( - "Schema cross-check: field '%s' (sensor '%s') has no unit " - "in library metadata, integration expects '%s'", - field_path, - sensor_key, - ha_unit, - ) - elif str(schema_unit) != str(ha_unit): - _LOGGER.debug( - "Schema cross-check: field '%s' (sensor '%s') unit is '%s' " - "in library metadata, integration expects '%s'", - field_path, - sensor_key, - schema_unit, - ha_unit, - ) + Carried verbatim rather than trimmed to the wire path: it is the key the + adapter emitted, so a maintainer cross-referencing a capability catalog or + the unread baseline is looking at the same string the library is. + """ + datatype: str + unit: str | None + retained: bool | None + """Whether the panel has published a value, or None if the adapter did not say. -def _report_unmapped_fields( - field_metadata: dict[str, dict[str, object]], -) -> None: - """Log fields in library metadata that no sensor definition references.""" - referenced = all_referenced_field_paths() - for field_path in sorted(set(field_metadata) - referenced): - _LOGGER.debug( - "Schema: field '%s' in library metadata is not mapped to any sensor", - field_path, - ) + None is the forward-compatible case: the namespace is the contract and the + enriched row type is not, so an adapter that namespaces a row without + carrying `DiscoveredMetadata` still reports its path, datatype and unit + rather than being dropped. + """ -def validate_field_metadata( - field_metadata: dict[str, dict[str, object]] | None, - sensor_defs: dict[str, SensorEntityDescription] | None = None, -) -> None: - """Run integration-side schema validation checks. +@dataclass(frozen=True, slots=True) +class SchemaFindings: + """Outcome of one validation pass.""" - Args: - field_metadata: The library's field metadata, keyed by snapshot field - path (e.g. ``"panel.instant_grid_power_w"``). Each value is a dict - with at least ``"unit"`` and ``"datatype"`` keys. None if the - library does not yet expose metadata. - sensor_defs: Dict of sensor_key → SensorEntityDescription for unit - cross-checking. None skips the cross-check. + unresolved: frozenset[str] + unit_mismatches: tuple[UnitMismatch, ...] + unread: frozenset[str] + discovered: tuple[DiscoveredProperty, ...] = () + """Properties the panel declares and the adapter reads nothing from. + Defaulted because it is additive and because every other member is a + finding about *our* declarations, which this is not: an adapter that emits + no discovered rows — the flat one does not — leaves this empty, and that is + a fact about the adapter rather than a clean bill of health. """ - if field_metadata is None: - _LOGGER.debug("Schema validation skipped — library does not expose field metadata") - return - if sensor_defs is not None: - _cross_check_units(field_metadata, sensor_defs) - _report_unmapped_fields(field_metadata) +def partition( + field_metadata: dict[str, FieldMetadata], +) -> tuple[dict[str, FieldMetadata], tuple[DiscoveredProperty, ...]]: + """Split one adapter metadata map into the curated rows and the discovered ones. + The single place the namespace is tested, so a caller cannot half-apply it. + Everything downstream — the producible gate, the unread inventory, the unit + check, the Repairs reconciler — takes the curated half and can therefore not + be perturbed by what a panel happens to declare. + """ + curated: dict[str, FieldMetadata] = {} + discovered: list[DiscoveredProperty] = [] + for path, entry in field_metadata.items(): + if not is_discovery_path(path): + curated[path] = entry + continue + discovered.append( + DiscoveredProperty( + path=path, + datatype=entry.datatype, + unit=entry.unit, + retained=entry.retained if isinstance(entry, DiscoveredMetadata) else None, + ) + ) + return curated, tuple(sorted(discovered, key=lambda item: item.path)) -def collect_sensor_definitions() -> dict[str, SensorEntityDescription]: - """Collect all sensor definitions into a dict keyed by sensor key. - Only includes sensors that appear in SENSOR_FIELD_MAP (i.e. sensors - that read a single snapshot field and are eligible for cross-checking). +def evaluate_field_metadata( + field_metadata: dict[str, FieldMetadata], + sensor_defs: dict[str, SensorEntityDescription] | None = None, +) -> SchemaFindings: + """Classify one snapshot of adapter metadata against our declarations. + + `field_metadata` is deliberately not optional. The client returns None until + its adapter is ready, and that sentinel means "unknown", not "healthy" — + answering it with empty findings would tell a reconciler every issue is + resolved. Callers interpret the sentinel themselves; see + `SpanPanelCoordinator._run_schema_validation`. """ - all_defs: list[SensorEntityDescription] = [ - *PANEL_DATA_STATUS_SENSORS, - *STATUS_SENSORS, - *UNMAPPED_SENSORS, - BATTERY_SENSOR, - L1_VOLTAGE_SENSOR, - L2_VOLTAGE_SENSOR, - UPSTREAM_L1_CURRENT_SENSOR, - UPSTREAM_L2_CURRENT_SENSOR, - DOWNSTREAM_L1_CURRENT_SENSOR, - DOWNSTREAM_L2_CURRENT_SENSOR, - MAIN_BREAKER_RATING_SENSOR, - CIRCUIT_CURRENT_SENSOR, - CIRCUIT_BREAKER_RATING_SENSOR, - *BESS_METADATA_SENSORS, - *PV_METADATA_SENSORS, - *PANEL_POWER_SENSORS, - BATTERY_POWER_SENSOR, - PV_POWER_SENSOR, - GRID_POWER_FLOW_SENSOR, - SITE_POWER_SENSOR, - *PANEL_ENERGY_SENSORS, - *CIRCUIT_SENSORS, - *EVSE_SENSORS, - ] - mapped_keys = set(SENSOR_FIELD_MAP.keys()) - return {d.key: d for d in all_defs if d.key in mapped_keys} + # First, before anything reads the map: the discovered rows are a report + # about the panel, not an inventory of what we produce, and every question + # below is the second kind. + curated, discovered = partition(field_metadata) + declared = declared_field_paths() + # Schema-conditional entities read a real field off a real metadata row; + # what they cannot do is satisfy a gate that demands *both* adapters + # produce it. Resolution is a property of the adapter that is running, so + # asking about these paths alongside the declared ones is what gives such + # an entity its unavailability and its Repair — the apparatus every other + # entity already has. Leaving them out is how `panel.wifi_ssid` stayed + # invisible: exempt from the gate read as exempt from everything. + resolvable = declared | conditional_field_paths() + sensor_defs = sensor_defs or {} + + unresolved: set[str] = set() + mismatches: list[UnitMismatch] = [] + + for field_path in resolvable: + entry = curated.get(field_path) + if entry is None: + # Hardware not present. Not a defect, and deliberately silent. + continue + if not entry.resolved: + unresolved.add(field_path) + continue + description = sensor_defs.get(field_path) + if description is None: + continue + ha_unit = description.native_unit_of_measurement + if ha_unit is None or entry.unit is None: + continue + schema_unit = str(entry.unit) + if schema_unit == str(ha_unit): + continue + if KNOWN_BAD_SCHEMA_UNITS.get(field_path) == schema_unit: + # A firmware mislabel we already work around. See the constant. + continue + mismatches.append(UnitMismatch(field_path, str(ha_unit), schema_unit)) + + # `RESIDUAL_EXEMPT_PATHS` are read by the integration; they are exempt from + # the *producible* gate because only one adapter emits them, or neither + # does, so they are absent from `declared` without being unread. Only the + # paths matter here; each entry's `Producibility` annotation is what the + # conformance tests verify. + # + # `curated` and never `field_metadata`, which still holds both halves. A + # discovered path reaching this set would read as a produced field nothing + # renders -- a defect's shape -- and would bury ten deliberate entries under + # whatever the panel's firmware happens to declare. + unread = frozenset(set(curated) - set(declared) - RESIDUAL_EXEMPT_PATHS.keys()) + for field_path in sorted(unread): + # An addition is legal within a major version. This is an inventory for + # us, never a user-facing finding. + _LOGGER.debug("Schema: %s is produced but no platform reads it", field_path) + + for declaration in discovered: + # The panel's side of the same question, and equally not user-facing: + # the user-facing half of this would be adoption, which is not built. + _LOGGER.debug( + "Schema: %s is declared by the panel and read by nothing here (%s%s, retained=%s)", + declaration.path, + declaration.datatype, + f" in {declaration.unit}" if declaration.unit else "", + declaration.retained, + ) + + return SchemaFindings(frozenset(unresolved), tuple(mismatches), unread, discovered) diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index 30f54042..a763e50e 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -2,29 +2,34 @@ from collections.abc import Callable, Mapping import logging -from typing import Any, Final +from typing import Any, ClassVar, Final from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot -from span_panel_api.exceptions import SpanPanelServerError -from . import SpanPanelConfigEntry +from .adoption import AdoptedSelect, create_adopted_selects from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitPriority +from .control_gate import ControlMode from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .helpers import ( - async_create_span_notification, build_select_unique_id_for_entry, + circuit_has_a_priority_select, construct_circuit_identifier_from_tabs, - construct_single_circuit_entity_id, + construct_circuit_label, construct_tabs_attribute, construct_voltage_attribute, + remove_withdrawn_controls, ) +from .naming import ( + circuit_object_id_base, + release_registry_name_written_by_older_release, +) +from .runtime import SpanPanelConfigEntry # Device types that use "Solar" as the fallback identifier when unnamed. _SOLAR_DEVICE_TYPES: frozenset[str] = frozenset({"pv"}) @@ -62,6 +67,15 @@ def __init__( select_option_fn: Callable[[SpanCircuitSnapshot, str], None] | None = None, ) -> None: """Initialize the select entity description wrapper.""" + self.name: str = name + """The description's label, kept as the `str` it is. + + `SelectEntityDescription.name` is typed `str | UndefinedType | None`, + being optional for a description that declares a `translation_key` + instead. This one always declares a label, and the registry-name release + wants a `str`, so the wrapper holds on to what it was handed rather than + narrowing the frozen description's field back down at every reader. + """ self.entity_description = SelectEntityDescription( key=key, name=name, @@ -84,15 +98,29 @@ def __init__( class SpanPanelCircuitsSelect(SpanPanelEntity, SelectEntity): """Represent a select entity for Span Panel circuits.""" + # Read in entity code rather than through a description: the wrapper + # class is not a frozen dataclass description, so it cannot carry the + # declaration. `priority` is the selected option (~96); `name` and + # `tabs` build the display name and the id base (~153-171). + _residual_field_paths: ClassVar[tuple[str, ...]] = ( + "circuit.priority", + "circuit.name", + "circuit.tabs", + ) + def __init__( self, coordinator: SpanPanelCoordinator, description: SpanPanelSelectEntityDescriptionWrapper, circuit_id: str, - name: str, device_name: str, ) -> None: - """Initialize the select.""" + """Initialize the select. + + The circuit's name is not passed in: both the display name and the id + base are read from the circuit in the current snapshot, so a circuit + renamed on the panel reaches both on the next reload. + """ super().__init__(coordinator) snapshot: SpanPanelSnapshot = coordinator.data @@ -116,58 +144,40 @@ def __init__( ) use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - desc_name = description.entity_description.name - if existing_entity_id: - # Entity exists - use circuit-based name when configured, else panel name - if use_circuit_numbers: - circuit_identifier = construct_circuit_identifier_from_tabs( - circuit.tabs, circuit_id - ) - self._attr_name = f"{circuit_identifier} {desc_name}" - elif circuit.name: - self._attr_name = f"{circuit.name} {desc_name}" - else: - fallback = _unnamed_select_fallback(circuit, circuit_id) - self._attr_name = f"{fallback} {desc_name}" - - # Sync the panel friendly name to the entity registry display name - # so the UI shows e.g. "Air Conditioner Circuit Priority" while the - # entity_id stays circuit-based. - if existing_entity_id and use_circuit_numbers and circuit.name: - entity_entry = entity_registry.async_get(existing_entity_id) - if entity_entry: - expected_name = f"{circuit.name} {desc_name}" - if entity_entry.name is None or entity_entry.name == expected_name: - entity_registry.async_update_entity(existing_entity_id, name=expected_name) - - if not existing_entity_id: - # Initial install - use flag-based name for entity_id generation - if use_circuit_numbers: - circuit_identifier = construct_circuit_identifier_from_tabs( - circuit.tabs, circuit_id - ) - self._attr_name = f"{circuit_identifier} {desc_name}" - elif name: - self._attr_name = f"{name} {desc_name}" - else: - # v1 behavior: None lets HA handle default naming - self._attr_name = None - - # Explicitly set entity_id using construct_single_circuit_entity_id - # which correctly handles 240V two-tab circuits. - # Only pass unique_id for existing entities (registry lookup); - # for new entities pass None to get the constructed default. - constructed_id = construct_single_circuit_entity_id( - coordinator, - snapshot, - "select", - description.entity_description.key, - circuit, - unique_id=self._attr_unique_id if existing_entity_id else None, + desc_name = description.name + circuit_name = circuit.name or _unnamed_select_fallback(circuit, circuit_id) + + # One name path, in both naming modes: the panel's name, carried as + # `original_name`. That field ranks below the object-id base below, so + # what is displayed can no longer decide what "Recreate entity IDs" + # proposes. + self._attr_name = f"{circuit_name} {desc_name}" + + # The id itself is Home Assistant's to compose. This entity supplies only + # its base -- the naming-flag half plus the description's key -- and + # leaves `entity_id` unset so Core assembles the rest from the user's + # `entity_id_parts`. The key is used as the suffix verbatim: + # `get_user_friendly_suffix` maps `circuit_priority` to `priority`, which + # is the unique_id's spelling and not this entity's. + suffix = description.entity_description.key + identifier = ( + construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) + if use_circuit_numbers + else circuit_name ) - if constructed_id: - self.entity_id = constructed_id + self._span_object_id_base = circuit_object_id_base(identifier, suffix, existing_entity_id) + + # Circuit-numbers mode used to deliver the panel's name by writing the + # registry's `name`. That field is the user's override, and Home Assistant + # reads it ahead of `suggested_object_id` when generating an entity id, so + # occupying it made "Recreate entity IDs" propose a friendly-name id for a + # circuit-numbered entity. The name travels as `original_name` now, so all + # that is left is to let go of what the old scheme wrote -- and only that: + # any other name is the user's. + if existing_entity_id: + release_registry_name_written_by_older_release( + entity_registry, existing_entity_id, circuit.name, (desc_name,) + ) self._attr_options = description.options_fn(circuit) self._attr_current_option = description.current_option_fn(circuit) @@ -193,44 +203,33 @@ async def async_will_remove_from_hass(self) -> None: await super().async_will_remove_from_hass() async def async_select_option(self, option: str) -> None: - """Change the selected option.""" + """Change the selected option. + + A refusal is raised at the caller rather than filed as a persistent + notification. The library refuses a priority the panel declares + unsettable before anything is published, so there is nothing to correct + later and nobody to tell but the person who just made the choice -- and + a notification keyed on the circuit would outlive the failure and sit in + the sidebar until someone dismissed it by hand. + + A `FAILED` outcome is raised too, and separately worded: that command + resolved an address and was never handed to the broker, which the + library promises means it will not arrive later either. Both live in + `_async_control`, which every control in this integration shares. + """ _LOGGER.debug("Selecting option: %s", option) client = self.coordinator.client - if not hasattr(client, "set_circuit_priority"): - _LOGGER.warning("Client does not support priority control") - return - priority = CircuitPriority(option) - try: - await client.set_circuit_priority(self.id, priority.name) - await self.coordinator.async_request_refresh() - except ServiceNotFound as snf: - _LOGGER.warning( - "Service not found when setting priority: %s.%s", - snf.domain, - snf.service, - ) - await async_create_span_notification( - self.hass, - message="The requested service is not available in the SPAN API.", - title="Service Not Found", - notification_id=f"span_panel_service_not_found_{self.id}", - ) - except SpanPanelServerError: - warning_msg = ( - f"SPAN API returned a server error attempting " - f"to change the circuit priority for {self._attr_name}. " - f"This typically indicates panel firmware doesn't support " - f"this operation." - ) - _LOGGER.warning("SPAN API may not support setting priority") - await async_create_span_notification( - self.hass, - message=warning_msg, - title="SPAN API Error", - notification_id=f"span_panel_api_error_{self.id}", - ) + await self._async_control( + client.set_circuit_priority(self.id, priority.name), + command=f"a priority change for {self.entity_id}", + failed_key="circuit_priority_failed", + not_delivered_key="circuit_priority_not_delivered", + placeholders={"circuit": construct_circuit_label(self._get_circuit(), self.id)}, + ) + + await self.coordinator.async_request_refresh() def select_option(self, option: str) -> None: """Select an option synchronously.""" @@ -243,7 +242,9 @@ def available(self) -> bool: Selects become unavailable when panel is offline since they can't change settings. """ - if getattr(self.coordinator, "panel_offline", False): + if not self._transport_available: + return False + if self.coordinator.panel_offline: return False return super().available @@ -263,8 +264,9 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: if tabs_result is not None: attributes["tabs"] = tabs_result - voltage = construct_voltage_attribute(circuit) or 240 - attributes["voltage"] = voltage + voltage = construct_voltage_attribute(circuit) + if voltage is not None: + attributes["voltage"] = voltage if circuit.priority_target is not None: attributes["priority_target"] = circuit.priority_target @@ -272,75 +274,52 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: return attributes or None def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + """Handle updated data from the coordinator. + + The circuit's name is watched here: the entity carries it as + `original_name`, which can only be refreshed by being rebuilt, so a + rename earns a reload. Whether the panel still lets this circuit's shed + priority be set -- the answer that decided this entity exists -- is + watched by `SpanPanelCoordinator._check_settability_change` instead, + because the opposite edge happens on circuits that have no entity here + to see it. + """ snapshot: SpanPanelSnapshot = self.coordinator.data circuit = snapshot.circuits.get(self.id) if circuit: current_circuit_name = circuit.name - use_circuit_numbers = self.coordinator.config_entry.options.get( - USE_CIRCUIT_NUMBERS, False - ) - desc_name = self.description_wrapper.entity_description.name - - if use_circuit_numbers: - # Circuit-numbers mode: update registry display name, no reload - if self.entity_id and current_circuit_name: - entity_registry = er.async_get(self.hass) - entity_entry = entity_registry.async_get(self.entity_id) - if entity_entry: - # Compute old expected display BEFORE updating - # _previous_circuit_name - old_display = ( - f"{self._previous_circuit_name} {desc_name}" - if isinstance(self._previous_circuit_name, str) - else None - ) - new_display = f"{current_circuit_name} {desc_name}" - - # User override: registry name differs from both old - # and new expected display names - user_has_override = ( - entity_entry.name is not None - and entity_entry.name not in {old_display, new_display} - ) - - if not user_has_override and ( - self._previous_circuit_name is _NAME_UNSET - or current_circuit_name != self._previous_circuit_name - ): - entity_registry.async_update_entity(self.entity_id, name=new_display) - self._previous_circuit_name = current_circuit_name - else: - # Friendly-names mode: existing reload behavior - user_has_override = False - if self.entity_id: - entity_registry = er.async_get(self.hass) - entity_entry = entity_registry.async_get(self.entity_id) - if entity_entry and entity_entry.name: - user_has_override = True - _LOGGER.debug( - "User has customized name for %s, skipping sync", - self.entity_id, - ) - - if user_has_override: - self._previous_circuit_name = current_circuit_name - elif self._previous_circuit_name is _NAME_UNSET: - _LOGGER.info( - "First update: syncing entity name to panel name '%s' for select, requesting reload", - current_circuit_name, - ) - self._previous_circuit_name = current_circuit_name - self.coordinator.request_reload() - elif current_circuit_name != self._previous_circuit_name: - _LOGGER.info( - "Auto-sync detected circuit name change from '%s' to '%s' for select, requesting integration reload", - self._previous_circuit_name, - current_circuit_name, + # One path for both modes: the name is carried by `original_name`, + # which is written when the entity is added, so a reload is what + # refreshes it. A name in the registry is one the user set. + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + _LOGGER.debug( + "User has customized name for %s, skipping sync", + self.entity_id, ) - self._previous_circuit_name = current_circuit_name - self.coordinator.request_reload() + + if user_has_override: + self._previous_circuit_name = current_circuit_name + elif self._previous_circuit_name is _NAME_UNSET: + _LOGGER.info( + "First update: syncing entity name to panel name '%s' for select, requesting reload", + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() + elif current_circuit_name != self._previous_circuit_name: + _LOGGER.info( + "Auto-sync detected circuit name change from '%s' to '%s' for select, requesting integration reload", + self._previous_circuit_name, + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() # Update options and current option based on coordinator data circuit = self._get_circuit() @@ -373,32 +352,55 @@ async def async_setup_entry( _LOGGER.debug("ASYNC SETUP ENTRY SELECT") + # Under `disabled` no control entity is created and none is removed; see + # `switch.async_setup_entry` for why the entries stay, and for the removal + # of a control the panel withdraws. + if config_entry.runtime_data.control_policy.mode is ControlMode.DISABLED: + return + coordinator = config_entry.runtime_data.coordinator - snapshot: SpanPanelSnapshot = coordinator.data + snapshot: SpanPanelSnapshot = config_entry.runtime_data.setup_snapshot # Get device name from config entry data device_name = config_entry.data.get("device_name", config_entry.title) - entities: list[SpanPanelCircuitsSelect] = [] + remove_withdrawn_controls( + er.async_get(hass), + config_entry.entry_id, + "select", + snapshot.circuits, + circuit_has_a_priority_select, + lambda circuit_id: build_select_unique_id_for_entry( + coordinator, snapshot, circuit_id, device_name + ), + ) + + entities: list[SpanPanelCircuitsSelect | AdoptedSelect] = [] for circuit_id, circuit_data in snapshot.circuits.items(): - if not circuit_data.is_user_controllable: - continue - # PV/EVSE circuits only get selects if they have a physical breaker - # (relative_position == "DOWNSTREAM" means connected at a breaker slot) - if ( - circuit_data.device_type in ("pv", "evse") - and circuit_data.relative_position != "DOWNSTREAM" - ): + if not circuit_has_a_priority_select(circuit_data): continue entities.append( SpanPanelCircuitsSelect( coordinator, CIRCUIT_PRIORITY_DESCRIPTION, circuit_id, - circuit_data.name, device_name, ) ) + # Settable properties on devices this integration models nothing for. + # Disabled and diagnostic like every adopted entity: the panel authorises the + # write, and the user decides whether the control is one they want. + entities.extend( + create_adopted_selects( + coordinator, + coordinator.data, + dr.async_get(hass), + config_entry_id=config_entry.entry_id, + panel_device_id=config_entry.runtime_data.panel_device_id, + overlay=config_entry.runtime_data.curation, + ) + ) + async_add_entities(entities) diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index 928c6b88..6950702c 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -6,11 +6,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from span_panel_api import SpanPanelSnapshot -from . import SpanPanelConfigEntry +from .adoption import create_adopted_sensors from .const import ( CONF_DEVICE_NAME, ENABLE_CIRCUIT_NET_ENERGY_SENSORS, @@ -19,13 +20,21 @@ USE_CIRCUIT_NUMBERS, ) from .coordinator import SpanPanelCoordinator +from .energy_orientation import EnergyRole +from .extension import create_extension_sensors from .helpers import ( has_bess, + has_bess_telemetry, has_evse, + has_mid, + has_pcs, has_power_flows, has_pv, + has_shed_forecast, resolve_evse_display_suffix, + resolve_pv_display_suffixes, ) +from .runtime import SpanPanelConfigEntry from .sensor_base import SpanEnergySensorBase, SpanSensorBase from .sensor_circuit import ( SpanCircuitEnergySensor, @@ -36,6 +45,7 @@ BATTERY_POWER_SENSOR, BATTERY_SENSOR, BESS_METADATA_SENSORS, + BESS_TELEMETRY_SENSORS, CIRCUIT_BREAKER_RATING_SENSOR, CIRCUIT_CURRENT_SENSOR, CIRCUIT_SENSORS, @@ -46,11 +56,14 @@ L1_VOLTAGE_SENSOR, L2_VOLTAGE_SENSOR, MAIN_BREAKER_RATING_SENSOR, + MID_SENSORS, PANEL_DATA_STATUS_SENSORS, PANEL_ENERGY_SENSORS, PANEL_POWER_SENSORS, + PCS_SENSORS, PV_METADATA_SENSORS, PV_POWER_SENSOR, + SHED_FORECAST_SENSORS, SITE_POWER_SENSOR, STATUS_SENSORS, UNMAPPED_SENSORS, @@ -60,28 +73,42 @@ from .sensor_evse import SpanEvseSensor from .sensor_panel import ( SpanBessMetadataSensor, + SpanMidSensor, SpanPanelBattery, SpanPanelEnergySensor, SpanPanelPanelStatus, SpanPanelPowerSensor, SpanPanelStatus, + SpanPcsSensor, + SpanPVInverterSensor, SpanPVMetadataSensor, + SpanShedForecastSensor, +) +from .util import ( + bess_device_info, + evse_device_info, + mid_device_info, + pv_device_info, + pv_inverter_device_info, ) -from .util import bess_device_info, evse_device_info # Export the sensor classes for backward compatibility with tests __all__ = [ "SpanBessMetadataSensor", + "SpanMidSensor", "SpanCircuitEnergySensor", "SpanCircuitPowerSensor", "SpanEnergySensorBase", + "SpanPVInverterSensor", "SpanPVMetadataSensor", "SpanPanelBattery", "SpanPanelEnergySensor", "SpanPanelPanelStatus", "SpanPanelPowerSensor", "SpanPanelStatus", + "SpanPcsSensor", "SpanSensorBase", + "SpanShedForecastSensor", "SpanUnmappedCircuitSensor", ] @@ -98,13 +125,38 @@ async def async_setup_entry( """Set up sensor platform.""" try: coordinator = config_entry.runtime_data.coordinator - snapshot: SpanPanelSnapshot = coordinator.data + snapshot: SpanPanelSnapshot = config_entry.runtime_data.setup_snapshot # Create all native sensors (panel, circuit, and battery sensors) entities = create_native_sensors(coordinator, snapshot, config_entry) + # Readings from devices this integration models nothing for. Appended + # rather than merged into `create_native_sensors`: those are curated + # descriptions and these are declarations, and the two inventories answer + # different questions -- see `adoption`. + adopted = create_adopted_sensors( + coordinator, + snapshot, + dr.async_get(hass), + config_entry_id=config_entry.entry_id, + panel_device_id=config_entry.runtime_data.panel_device_id, + overlay=config_entry.runtime_data.curation, + ) + + # Vendor extensions on devices this integration *does* model, which + # adoption deliberately leaves alone -- see `extension`. + extensions = create_extension_sensors( + coordinator, + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=config_entry.entry_id, + overlay=config_entry.runtime_data.curation, + pv_binding=config_entry.runtime_data.pv_binding, + ) + # Add all native sensor entities - async_add_entities(entities) + async_add_entities([*entities, *adopted, *extensions]) # Force immediate coordinator refresh to ensure all sensors update right away await coordinator.async_request_refresh() @@ -140,7 +192,7 @@ def create_panel_sensors( for description in PANEL_ENERGY_SENSORS: # Skip net energy sensors if disabled - is_net_energy_sensor = "net_energy" in description.key or "NetEnergy" in description.key + is_net_energy_sensor = description.energy_role is EnergyRole.NET if not panel_net_energy_enabled and is_net_energy_sensor: continue @@ -192,7 +244,13 @@ def _build_evse_device_info_map( mapping: dict[str, DeviceInfo] = {} for evse in snapshot.evse.values(): display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) - info = evse_device_info(panel_identifier, evse, panel_name, display_suffix) + info = evse_device_info( + panel_identifier, + evse, + panel_name, + display_suffix, + panel_device_id=coordinator.config_entry.runtime_data.panel_device_id, + ) mapping[evse.feed_circuit_id] = info return mapping @@ -219,9 +277,7 @@ def create_circuit_sensors( for circuit_description in CIRCUIT_SENSORS: # Skip net energy sensors if disabled - is_net_energy_sensor = ( - "net_energy" in circuit_description.key or "energy_net" in circuit_description.key - ) + is_net_energy_sensor = circuit_description.energy_role is EnergyRole.NET if not circuit_net_energy_enabled and is_net_energy_sensor: continue @@ -301,7 +357,115 @@ def _build_bess_device_info( or "Span Panel" ) - return bess_device_info(snapshot.serial_number, snapshot.battery, panel_name) + return bess_device_info( + snapshot.serial_number, + snapshot.battery, + panel_name, + panel_device_id=coordinator.config_entry.runtime_data.panel_device_id, + ) + + +def _build_mid_device_info( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> DeviceInfo: + """DeviceInfo for the Microgrid Interconnect sub-device.""" + panel_name = ( + coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title) + or "Span Panel" + ) + mid = snapshot.mid + if mid is None: + raise ValueError("cannot build MID device info for a snapshot with no MID") + return mid_device_info( + snapshot.serial_number, + mid, + panel_name, + panel_device_id=coordinator.config_entry.runtime_data.panel_device_id, + ) + + +def create_mid_sensors( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> list[SpanMidSensor]: + """Create the Microgrid Interconnect sub-device and its sensors. + + v1.0 only, and purely additive: no flat panel publishes a MID, so `has_mid` is false + everywhere today and nothing a user has changes. Presence is the library's optional + `mid` field rather than a sentinel value, so there is nothing to infer. + + DUAL-SCHEMA: gated on what the snapshot carries, never on a version or a config flag. A + panel that hot-loads parent/child mid-life gains the MID as a capability change, the + coordinator reloads, and the device appears; one that never does simply never sees + it. When the flat path retires, this gate can go and the sensors become + unconditional. + """ + if not has_mid(snapshot): + return [] + + mid_info = _build_mid_device_info(coordinator, snapshot) + return [SpanMidSensor(coordinator, desc, snapshot, mid_info) for desc in MID_SENSORS] + + +def create_shed_forecast_sensors( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> list[SpanShedForecastSensor]: + """Create the backup-planning forecast sensors the panel can actually fill. + + Two gates, not one, because absence has two shapes here. `has_shed_forecast` + answers whether the panel publishes the capability at all — false on every + flat panel and on any v1.0 firmware that omits the node, and the reason a + reload creates these when a panel gains it mid-life. The per-description + check then answers whether *this* estimate is among what the node publishes: + the catalog marks all four times SHOULD rather than MUST, so a partial + node is legal and the half it omits must produce no entity rather than one + permanently unknown. + + The presence test is the description's own `value_fn`. The field a sensor + reads is exactly the field whose absence should suppress it, so asking the + reader is what keeps the gate from drifting away from the read. + + DUAL-SCHEMA: gated on what the snapshot carries, never on a version or a + config flag. When the flat path retires, the first gate goes and the second + stays — a v1.0 panel may still publish a partial node. + """ + if not has_shed_forecast(snapshot): + return [] + + return [ + SpanShedForecastSensor(coordinator, description, snapshot) + for description in SHED_FORECAST_SENSORS + if description.value_fn(snapshot) is not None + ] + + +def create_pcs_sensors( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> list[SpanPcsSensor]: + """Create the Power Control System sensors, where the panel runs one. + + **One gate, not two, and that is the difference from every other capability + here.** The shed forecast and the BESS telemetry gate a second time on each + description's own `value_fn`, because a property that arrives unpublished + would otherwise become a permanently-unknown entity. That reasoning does not + transfer: `pcs` publishes properties that are legally `0.0`, `false` and + `UNCONFIGURED`, and the reference capture is exactly that — a PCS which + exists and is switched off. A per-reading gate would create the entities on a + configured panel and delete them the moment somebody turned the PCS off, + which is the state a user most wants to see reported. + + So presence is the node, which `has_pcs` reads off the library's `None` + contract, and a property the node omits degrades to unknown on an entity that + stays. Both results the capability marks `SHOULD`, so a panel publishing the + node without them is unusual firmware rather than an expected shape. + + DUAL-SCHEMA: gated on what the snapshot carries, never on a version. No flat + panel publishes the capability, and a v1.0 panel that gains it reaches + `detect_capabilities` and picks the entities up on the reload. + """ + if not has_pcs(snapshot): + return [] + + return [SpanPcsSensor(coordinator, description, snapshot) for description in PCS_SENSORS] def create_battery_sensors( @@ -330,6 +494,68 @@ def create_battery_sensors( for desc in BESS_METADATA_SENSORS ) + # What the BESS reports about itself, gated per description because it comes + # from capability nodes a BESS may not have. `has_bess_telemetry` answers + # whether it publishes either node at all -- false on every flat panel, and + # the reason a reload creates these when a BESS gains them mid-life. The + # per-description check then asks whether *this* reading is among what it + # publishes: a BESS with a `meter` node and no `status` node is legal, and the + # half it omits must produce no entity rather than one permanently unknown. + # + # The presence test is the description's own `value_fn`, so the gate cannot + # drift away from the read it is gating. + if has_bess_telemetry(snapshot): + entities.extend( + SpanBessMetadataSensor(coordinator, desc, snapshot, bess_info) + for desc in BESS_TELEMETRY_SENSORS + if desc.value_fn(snapshot.battery) is not None + ) + + return entities + + +def _panel_name(coordinator: SpanPanelCoordinator) -> str: + return ( + coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title) + or "Span Panel" + ) + + +def _build_pv_device_info( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> DeviceInfo: + """DeviceInfo for the Solar card, describing what its PV entities read; see `pv_binding`.""" + return pv_device_info( + snapshot.serial_number, + coordinator.config_entry.runtime_data.pv_binding.source(snapshot), + _panel_name(coordinator), + panel_device_id=coordinator.config_entry.runtime_data.panel_device_id, + ) + + +def create_pv_inverter_sensors( + coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot +) -> list[SpanPVInverterSensor]: + """Create a card and metadata sensors for every inverter the Solar card does not read (see `pv_binding`).""" + use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + suffixes = resolve_pv_display_suffixes(snapshot, use_circuit_numbers) + identity = coordinator.config_entry.runtime_data.pv_binding + entities: list[SpanPVInverterSensor] = [] + for key, inverter in snapshot.pv_inverters.items(): + if not identity.has_own_card(key): + continue + info = pv_inverter_device_info( + snapshot.serial_number, + key, + inverter, + _panel_name(coordinator), + suffixes[key], + panel_device_id=coordinator.config_entry.runtime_data.panel_device_id, + ) + entities.extend( + SpanPVInverterSensor(coordinator, description, snapshot, key, info) + for description in PV_METADATA_SENSORS + ) return entities @@ -341,15 +567,40 @@ def create_power_flow_sensors( PV Power — only when PV is commissioned. Site Power — only when the power-flows node is publishing. PV metadata sensors — only when PV is commissioned. + + The Solar card carries PV Power, the panel's total, and the PV metadata + sensors, which read through `pv_binding`. Every other inverter's metadata is + `create_pv_inverter_sensors`'. + + The PV sensors land on the inverter's own sub-device, matching what the BESS + has done since v1.0: `battery_power` is the enclosure's reading of the + battery and it sits on the battery's card, so `pv_power` -- the enclosure's + reading of the inverter -- belongs on the inverter's. + + Nothing pins an entity_id. An installation that already has these five keeps + the ids it has, because the registry never renames an entity it already + knows; a new one gets whatever Home Assistant derives from the inverter's + device name, which is the standard assignment and what every other + sub-device entity gets. The two shapes differ by install date and that is + deliberate -- see `test_pv_device.py`. """ entities: list[SpanPanelPowerSensor | SpanPVMetadataSensor] = [] if has_pv(snapshot): - entities.append(SpanPanelPowerSensor(coordinator, PV_POWER_SENSOR, snapshot)) + pv_info = _build_pv_device_info(coordinator, snapshot) + entities.append( + SpanPanelPowerSensor( + coordinator, + PV_POWER_SENSOR, + snapshot, + device_info_override=pv_info, + ) + ) - # PV metadata sensors on the main panel device + identity = coordinator.config_entry.runtime_data.pv_binding entities.extend( - SpanPVMetadataSensor(coordinator, desc, snapshot) for desc in PV_METADATA_SENSORS + SpanPVMetadataSensor(coordinator, desc, snapshot, pv_info, identity) + for desc in PV_METADATA_SENSORS ) if has_power_flows(snapshot): @@ -388,7 +639,11 @@ def create_native_sensors( | SpanPanelBattery | SpanBessMetadataSensor | SpanPVMetadataSensor + | SpanPVInverterSensor | SpanEvseSensor + | SpanMidSensor + | SpanShedForecastSensor + | SpanPcsSensor ]: """Create all native sensors for the platform.""" entities: list[ @@ -402,7 +657,11 @@ def create_native_sensors( | SpanPanelBattery | SpanBessMetadataSensor | SpanPVMetadataSensor + | SpanPVInverterSensor | SpanEvseSensor + | SpanMidSensor + | SpanShedForecastSensor + | SpanPcsSensor ] = [] # Create different sensor types @@ -411,7 +670,11 @@ def create_native_sensors( if config_entry.options.get(ENABLE_UNMAPPED_CIRCUIT_SENSORS, False): entities.extend(create_unmapped_circuit_sensors(coordinator, snapshot)) entities.extend(create_battery_sensors(coordinator, snapshot)) + entities.extend(create_mid_sensors(coordinator, snapshot)) + entities.extend(create_shed_forecast_sensors(coordinator, snapshot)) + entities.extend(create_pcs_sensors(coordinator, snapshot)) entities.extend(create_power_flow_sensors(coordinator, snapshot)) + entities.extend(create_pv_inverter_sensors(coordinator, snapshot)) entities.extend(create_evse_sensors(coordinator, snapshot)) return entities diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py index fddec4d7..63009c3a 100644 --- a/custom_components/span_panel/sensor_base.py +++ b/custom_components/span_panel/sensor_base.py @@ -6,8 +6,9 @@ from collections.abc import Callable from datetime import UTC, date, datetime, timedelta from decimal import Decimal +from functools import partial import logging -from typing import Any, cast +from typing import Any from homeassistant.components.sensor import ( RestoreSensor, @@ -22,9 +23,16 @@ from homeassistant.helpers.typing import StateType from span_panel_api import SpanPanelSnapshot -from .const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION, USE_CIRCUIT_NUMBERS +from .const import DEFAULT_ENERGY_DIP_COMPENSATION, DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION from .coordinator import SpanPanelCoordinator -from .energy_dip import build_dip_attributes, process_energy_dip +from .energy_dip import ( + DipEvent, + DipOutcome, + PendingDip, + build_dip_attributes, + process_energy_dip, +) +from .energy_orientation import EnergyBinding from .entity import SpanPanelEntity from .grace_period import ( # noqa: F401 SpanEnergyExtraStoredData, @@ -33,7 +41,12 @@ handle_offline_grace_period, initialize_from_last_state, ) -from .options import ENERGY_REPORTING_GRACE_PERIOD +from .naming import ( + circuit_object_id_base, + release_registry_name_written_by_older_release, +) +from .options import ENERGY_REPORTING_GRACE_PERIOD, option_bool +from .sensor_definitions import SpanPanelCircuitsSensorEntityDescription _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -58,11 +71,33 @@ ) +def _description_label(description: SensorEntityDescription) -> str: + """Return the description's own label, or a neutral word where it declares none. + + A description with a `translation_key` declares no `name` -- it declares + `None` or leaves the field `UNDEFINED` -- and a sensor that reaches here with + neither is a programming error rather than a user-visible state, so "Sensor" + keeps it readable instead of rendering a sentinel. + """ + name = description.name + if isinstance(name, str) and name: + return name + return "Sensor" + + class SpanSensorBase[T: SensorEntityDescription, D](SpanPanelEntity, SensorEntity, ABC): """Abstract base class for Span Panel sensors with overridable methods.""" _attr_has_entity_name = True + _is_sub_device: bool = False + """True when this entity is shown on a sub-device's card rather than the panel's. + + Set by the circuit classes when they are handed a `device_info_override`. + Such a sensor supplies no base at all: it composes from its label on the + sub-device's card, which is the shape the sub-device's own sensors have. + """ + def __init__( self, data_coordinator: SpanPanelCoordinator, @@ -99,36 +134,31 @@ def __init__( "sensor", DOMAIN, self._attr_unique_id ) - use_circuit_numbers = data_coordinator.config_entry.options.get( - USE_CIRCUIT_NUMBERS, False - ) + # One name path, in both naming modes: the panel's name, carried + # as `original_name`. That field ranks below the object-id base + # below, so what is displayed can no longer decide what + # "Recreate entity IDs" proposes. + self._attr_name = self._generate_panel_name(snapshot, description) + + # The id itself is Home Assistant's to compose. This entity + # supplies only its base; `entity_id` is left unset so Core + # assembles the rest from the user's `entity_id_parts`. A + # sub-device sensor supplies no base either, composing from its + # label on the sub-device's card like that device's own sensors. + parts = self._object_id_parts(snapshot, description) + if parts is not None and not self._is_sub_device: + identifier, suffix = parts + self._span_object_id_base = circuit_object_id_base( + identifier, suffix, existing_entity_id + ) if existing_entity_id: - if use_circuit_numbers: - # Circuit-numbers mode: keep circuit-based name for entity_id stability - self._attr_name = self._generate_friendly_name(snapshot, description) - else: - # Friendly-names mode: use panel name for sync - self._attr_name = self._generate_panel_name(snapshot, description) - else: - # Initial install - use flag-based name - self._attr_name = self._generate_friendly_name(snapshot, description) - - # Sync panel friendly name to registry display name in - # circuit-numbers mode so the UI shows e.g. - # "Kitchen Power" while entity_id stays circuit-based. - if existing_entity_id and use_circuit_numbers: - self._sync_friendly_name_to_registry( + self._release_synced_registry_name( snapshot, description, entity_registry, existing_entity_id ) - - # Wire explicit entity_id via subclass helper - entity_id = self._construct_entity_id(snapshot, description, existing_entity_id) - if entity_id: - self.entity_id = entity_id else: # Fallback for entities without unique_id - self._attr_name = self._generate_friendly_name(snapshot, description) + self._attr_name = self._generate_panel_name(snapshot, description) # Set entity registry defaults if they exist in the description if hasattr(description, "entity_registry_enabled_default"): @@ -172,26 +202,32 @@ def _generate_unique_id(self, snapshot: SpanPanelSnapshot, description: T) -> st """ - @abstractmethod - def _generate_friendly_name(self, snapshot: SpanPanelSnapshot, description: T) -> str | None: - """Generate friendly name for the sensor. - - Subclasses must implement this to define their naming strategy. - - Args: - snapshot: The panel snapshot data - description: The sensor description - - Returns: - Friendly name string, or None to let HA use default behavior - + def _object_id_parts( + self, snapshot: SpanPanelSnapshot, description: T + ) -> tuple[str, str] | None: + """Return the identifier and canonical suffix this entity's id is built from. + + `None` -- the default, and the answer for every entity that is not a + circuit -- leaves `_span_object_id_base` unset, so Home Assistant + composes the id from the display name exactly as it always has. + + A circuit entity answers with the naming-flag half (`Circuit 15`, + `Kitchen Outlets`, `Unmapped Tab 32`) and the suffix + `naming.circuit_object_id_base` keys on. The two travel together because + neither is any use alone, and because a circuit entity's + `description.key` has been overwritten with the circuit id by then, so + there is no suffix a default could compute. """ + return None def _generate_panel_name(self, snapshot: SpanPanelSnapshot, description: T) -> str | None: - """Generate panel name for the sensor (always uses panel circuit name). + """Generate the displayed name for the sensor, in either naming mode. - This method is used for name sync - it always uses the panel circuit name - regardless of user preferences. + Circuit entities override this to prefix the panel's own circuit name, + which is what makes a circuit renamed in the SPAN app show through. Every + other sensor's name is its description's label -- and a description that + declares a `translation_key` instead is normally not asked at all, since + its name comes from `translations/en.json`. Args: snapshot: The panel snapshot data @@ -201,50 +237,59 @@ def _generate_panel_name(self, snapshot: SpanPanelSnapshot, description: T) -> s Panel name string """ - # This should be implemented by subclasses that need name sync - # For now, fall back to friendly name - return self._generate_friendly_name(snapshot, description) + return _description_label(description) - def _sync_friendly_name_to_registry( + def _release_synced_registry_name( self, snapshot: SpanPanelSnapshot, description: T, entity_registry: er.EntityRegistry, existing_entity_id: str, ) -> None: - """Sync panel circuit name to registry display name in circuit-numbers mode.""" + """Give the registry's `name` back to the user, where an older release took it. + + Circuit-numbers mode used to deliver the panel's name by writing the + registry's `name`. That field is the *user's* override, and Home Assistant + reads it ahead of `suggested_object_id` when generating an entity id -- so + occupying it made "Recreate entity IDs" propose a friendly-name id for a + circuit-numbered entity, converting the whole panel if accepted. + + The name now travels as `original_name` instead, so this only has to let + go of what the old scheme wrote. Only a name this integration would have + written is cleared; anything else is the user's and is left exactly where + it is, which is the same test the write used to gate on. + + Every label the description has carried counts, not just its current one: + a label reworded between releases left installs holding the older string, + and a write we no longer recognise is a write we would never hand back. + """ circuit = snapshot.circuits.get(getattr(self, "circuit_id", "")) if not (circuit and circuit.name): return - entity_entry = entity_registry.async_get(existing_entity_id) - if not entity_entry: - return - description_suffix = str(getattr(description, "name", None) or "Sensor") - expected_name = f"{circuit.name} {description_suffix}" - if entity_entry.name is None or entity_entry.name == expected_name: - entity_registry.async_update_entity(existing_entity_id, name=expected_name) - - def _construct_entity_id( - self, - snapshot: SpanPanelSnapshot, - description: T, - existing_entity_id: str | None = None, - ) -> str | None: - """Construct explicit entity_id for the sensor. - - Subclasses may override to use entity_id helpers from helpers.py. - Returns None to let HA auto-generate from _attr_name. + # Only the circuit descriptions declare a reworded label, and only a + # circuit entity reaches this line at all -- the guard above returns for + # anything with no circuit behind it. + legacy_names = ( + description.legacy_names + if isinstance(description, SpanPanelCircuitsSensorEntityDescription) + else () + ) + release_registry_name_written_by_older_release( + entity_registry, + existing_entity_id, + circuit.name, + (_description_label(description), *legacy_names), + ) - Args: - snapshot: The panel snapshot data - description: The sensor description - existing_entity_id: The existing entity_id from registry, or None for new entities + def _sync_circuit_name(self) -> None: + """Follow a circuit renamed on the panel, by reloading so the name is rebuilt. + One path for both modes. The name is carried by `original_name`, which is + written when the entity is added, so a reload is what refreshes it -- + circuit-numbers mode used to write the registry's `name` in place instead, + which was quicker but handed that field the last word over entity id + generation. See `_release_synced_registry_name`. """ - return None - - def _sync_circuit_name(self) -> None: - """Sync circuit name changes: registry display in circuit-numbers mode, reload in friendly-names mode.""" if not (hasattr(self, "circuit_id") and hasattr(self.coordinator.data, "circuits")): return @@ -253,61 +298,33 @@ def _sync_circuit_name(self) -> None: return current_circuit_name = circuit.name - use_circuit_numbers = self.coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) - - if use_circuit_numbers: - # Circuit-numbers mode: update registry display name, no reload - if self.entity_id: - entity_registry = er.async_get(self.hass) - entity_entry = entity_registry.async_get(self.entity_id) - if entity_entry: - description_suffix = str( - getattr(self.entity_description, "name", None) or "Sensor" - ) - old_display = ( - f"{self._previous_circuit_name} {description_suffix}" - if isinstance(self._previous_circuit_name, str) - else None - ) - new_display = f"{current_circuit_name} {description_suffix}" - - user_has_override = entity_entry.name is not None and entity_entry.name not in { - old_display, - new_display, - } - - if not user_has_override and ( - self._previous_circuit_name is _NAME_UNSET - or current_circuit_name != self._previous_circuit_name - ): - entity_registry.async_update_entity(self.entity_id, name=new_display) + + # A name in the registry is one the user set: theirs outranks the panel's, + # and reloading would not change what is displayed anyway. + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + + if user_has_override: self._previous_circuit_name = current_circuit_name - else: - # Friendly-names mode: existing reload behavior - user_has_override = False - if self.entity_id: - entity_registry = er.async_get(self.hass) - entity_entry = entity_registry.async_get(self.entity_id) - if entity_entry and entity_entry.name: - user_has_override = True - - if user_has_override: - self._previous_circuit_name = current_circuit_name - elif self._previous_circuit_name is _NAME_UNSET: - _LOGGER.info( - "First update: syncing sensor name to panel name '%s', requesting reload", - current_circuit_name, - ) - self._previous_circuit_name = current_circuit_name - self.coordinator.request_reload() - elif current_circuit_name != self._previous_circuit_name: - _LOGGER.info( - "Auto-sync detected circuit name change from '%s' to '%s' for sensor, requesting integration reload", - self._previous_circuit_name, - current_circuit_name, - ) - self._previous_circuit_name = current_circuit_name - self.coordinator.request_reload() + elif self._previous_circuit_name is _NAME_UNSET: + _LOGGER.info( + "First update: syncing sensor name to panel name '%s', requesting reload", + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() + elif current_circuit_name != self._previous_circuit_name: + _LOGGER.info( + "Auto-sync detected circuit name change from '%s' to '%s' for sensor, requesting integration reload", + self._previous_circuit_name, + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -321,16 +338,23 @@ def available(self) -> bool: Keep entities available during a panel_offline condition so sensors can show their grace period state (last_valid_state) or None when grace period expires. + + The unresolved-field probe runs first: the grace-period branch below + returns True unconditionally, so probing after it would let every + offline sensor keep reporting a field the adapter cannot resolve. + + The transport probe runs for the same reason and answers the harder + case: a grace period is a bet that the reading will be true again + shortly, and a transport that has stopped for good makes that bet + unpayable. `_handle_offline_state` gives POWER sensors 0.0 while it + runs, which is a reading nobody took. """ - try: - if getattr(self.coordinator, "panel_offline", False): - return True - except AttributeError as err: - # If coordinator is missing expected attribute, log and fall back - _LOGGER.debug("Availability check: missing coordinator attribute: %s", err) - except Exception as err: # noqa: BLE001 # pragma: no cover - defensive - # Any unexpected error shouldn't crash the availability check - _LOGGER.debug("Availability check: unexpected error: %s", err) + if self._reads_an_unresolved_field: + return False + if not self._transport_available: + return False + if self.coordinator.panel_offline: + return True return super().available @property @@ -393,7 +417,7 @@ def _handle_online_state(self) -> None: try: data_source: D = self.get_data_source(self.coordinator.data) self._log_debug_info(data_source) - raw_value: float | int | str | None = value_function(data_source) + raw_value = self._read_raw_value(value_function, data_source) self._process_raw_value(raw_value) except (AttributeError, KeyError, IndexError) as err: _LOGGER.debug( @@ -412,6 +436,19 @@ def _handle_online_state(self) -> None: ) self._attr_native_value = self._unknown_value() + def _read_raw_value( + self, + value_function: Callable[[D], float | int | str | None], + data_source: D, + ) -> float | int | str | None: + """Read this sensor's raw value from the data source its update fetched. + + A hook, so a sensor whose reading also depends on its siblings can add to it + before it is processed; see `SpanEnergySensorBase._read_raw_value`. The + data source is passed in rather than fetched again. + """ + return value_function(data_source) + def _log_debug_info(self, data_source: D) -> None: """Log debug information for circuit sensors.""" # Only do debug logging if we have valid data and the panel is online @@ -441,20 +478,36 @@ def _process_raw_value(self, raw_value: float | str | None) -> None: str_value = str(raw_value) # For enum sensors, ensure the value is in the options list before # setting it — HA raises ValueError if the state is not in options. - # Options are built dynamically from observed MQTT values. - # Values are normalized to lowercase to satisfy HA's translation - # key requirement ([a-z0-9-_]+). HA uses the state value directly - # as the translation key lookup. + # Values are normalized to lowercase to satisfy HA's translation key + # requirement ([a-z0-9-_]+); HA uses the state value directly as the + # translation key lookup. + # + # Options are declared statically on each description, from the states + # `en.json` renders. They used to be discovered here instead, which could + # not work: options would only ever list states the panel had already + # reached, so a state it had not yet visited was absent from its own + # "Possible states" — and every panel advertised a different set + # depending on what it had lived through. + # + # The append survives as a last resort so an undeclared value degrades to + # a shown state rather than a ValueError. It is a warning rather than a + # debug line because reaching it means the panel published outside the + # enum its own catalog declares, which is a producer defect and not + # something a consumer should absorb quietly. It also renders untranslated, + # as a raw key. if self._attr_device_class is SensorDeviceClass.ENUM: str_value = str_value.lower() if not hasattr(self, "_attr_options") or self._attr_options is None: self._attr_options = [] if str_value not in self._attr_options: self._attr_options.append(str_value) - _LOGGER.debug( - "Added enum option '%s' for %s", - str_value, + _LOGGER.warning( + "%s reported '%s', which is not one of its declared states %s. " + "Showing it untranslated; the panel is publishing outside the " + "enum its catalog declares.", self.entity_id or self._attr_unique_id, + str_value, + sorted(o for o in self._attr_options if o != str_value), ) self._attr_native_value = str_value @@ -478,13 +531,25 @@ class SpanEnergySensorBase[T: SensorEntityDescription, D](SpanSensorBase[T, D], _unrecorded_attributes = _ENERGY_SENSOR_UNRECORDED_ATTRIBUTES + _energy_binding: EnergyBinding[D] | None = None + """Set once, by `_bind_energy`, before `__init__` runs; read through `_energy`.""" + def __init__( self, data_coordinator: SpanPanelCoordinator, description: T, snapshot: SpanPanelSnapshot, ) -> None: - """Initialize the energy sensor with grace period tracking.""" + """Initialize the energy sensor with grace period tracking. + + Refuses a subclass that has not bound its energy: a sensor that forgot + would otherwise register as no meter's counter and give its Net no + adjustment, silently. + """ + if self._energy_binding is None: + raise TypeError( + f"{type(self).__name__} must call _bind_energy before SpanEnergySensorBase.__init__" + ) super().__init__(data_coordinator, description, snapshot) self._last_valid_state: float | None = None self._last_valid_changed: datetime | None = None @@ -498,17 +563,83 @@ def __init__( self._energy_offset: float = 0.0 self._last_panel_reading: float | None = None self._last_dip_delta: float | None = None + # A dip that has been compensated but not yet settled — see + # `process_energy_dip`. Either being non-None means part of + # `_energy_offset` is provisional and may still be taken back: the + # first is waiting on evidence, the second has it and is waiting out + # the window in which that evidence could still be an artefact. + self._pending_dip: PendingDip | None = None + self._recently_confirmed_dip: PendingDip | None = None self._is_total_increasing: bool = ( getattr(description, "state_class", None) == SensorStateClass.TOTAL_INCREASING ) - self._dip_compensation_enabled: bool = data_coordinator.config_entry.options.get( - ENABLE_ENERGY_DIP_COMPENSATION, False + self._dip_compensation_enabled: bool = option_bool( + data_coordinator.config_entry.options.get(ENABLE_ENERGY_DIP_COMPENSATION), + DEFAULT_ENERGY_DIP_COMPENSATION, ) @property def energy_offset(self) -> float: - """Return the cumulative dip compensation offset.""" - return self._energy_offset + """Return the dip offset this sensor is applying to its reading: 0.0 while it compensates nothing. + + What its meter's Net adds for it, so it answers by the same cached flag + `_process_raw_value` reads. The option is re-read on every update, while + the held offset is discarded only by the reload that follows a change, so + between the two this sensor already reports its raw reading and its Net + has to stop adding the offset on the same update. The offset itself is + kept: turning the option back on before a reload resumes it on both. + """ + return self._energy_offset if self._dip_compensation_enabled else 0.0 + + def _bind_energy(self, binding: EnergyBinding[D]) -> None: + """Record this sensor's meter, role and Net definition, once. + + Called by each subclass's `__init__` before it reaches this class's, from + its description: a subclass binds rather than passing these to `__init__` + because the circuit classes share a cooperative `__init__` with the power + sensor, which takes no such arguments. Binding twice is a programming + error, because the coordinator may already hold the first binding's key. + """ + if self._energy_binding is not None: + raise RuntimeError(f"{type(self).__name__} bound its energy twice") + self._energy_binding = binding + + @property + def _energy(self) -> EnergyBinding[D]: + """Return the sensor's binding, raising if `_bind_energy` was never called.""" + binding = self._energy_binding + if binding is None: + raise TypeError(f"{type(self).__name__} never called _bind_energy") + return binding + + def _read_raw_value( + self, + value_function: Callable[[D], float | int | str | None], + data_source: D, + ) -> float | int | str | None: + """Read the raw value; a Net also adds its siblings' dip offsets, oriented as its value is. + + Produced and Consumed compensate their own dips. Net is a `TOTAL` sensor + and compensates nothing itself, so it reads their offsets through the + coordinator and stays equal to compensated credit minus compensated debit. + The orientation is its `NetEnergy`'s, whose `value` is also its value + function, so the value and the adjustment cannot disagree. + """ + raw_value = super()._read_raw_value(value_function, data_source) + # Narrowed before the partial is built: a sensor on no meter has no + # siblings to read, so it gets no adjustment rather than a None meter. + binding = self._energy + net = binding.net + meter = binding.meter + if ( + net is None + or meter is None + or isinstance(raw_value, bool) + or not isinstance(raw_value, int | float) + ): + return raw_value + offset_of = partial(self.coordinator.dip_offset, meter) + return raw_value + net.orientation_of(data_source).adjustment(offset_of) def _process_raw_value(self, raw_value: float | str | None) -> None: """Process the raw value with energy dip compensation for TOTAL_INCREASING sensors.""" @@ -518,22 +649,66 @@ def _process_raw_value(self, raw_value: float | str | None) -> None: and isinstance(raw_value, float | int) ): raw_float = float(raw_value) - new_offset, dip_delta, compensated = process_energy_dip( - raw_float, self._last_panel_reading, self._energy_offset + outcome = process_energy_dip( + raw_float, + self._last_panel_reading, + self._energy_offset, + self._pending_dip, + self._recently_confirmed_dip, ) - if dip_delta is not None: - self._last_dip_delta = dip_delta - self.coordinator.report_energy_dip( - self.entity_id or self._attr_unique_id or "unknown", - dip_delta, - new_offset, - ) - self._energy_offset = new_offset + self._apply_dip_event(outcome) + self._energy_offset = outcome.offset + self._pending_dip = outcome.pending + self._recently_confirmed_dip = outcome.recently_confirmed self._last_panel_reading = raw_float - super()._process_raw_value(compensated) + super()._process_raw_value(outcome.compensated) else: super()._process_raw_value(raw_value) + def _apply_dip_event(self, outcome: DipOutcome) -> None: + """Record the diagnostic, and tell the coordinator once a dip is final. + + The notification waits for the dip to *settle* rather than firing when + it is first seen, or even when it is first corroborated. Reporting on + sight is what made `SpanPanel/span#259` so loud — a notification naming + essentially every circuit on the panel, for an event that had not + happened. Reporting on corroboration would be quieter but still + premature, because the reading after it can take the offset back; a + persistent notification cannot be taken back, so it is held until + nothing can undo what it describes. A dip that gets retracted produces + no notification at all, because nothing happened. + + `last_dip_delta` is set when the dip is booked, since it describes what + the counter did and the compensation is applied from that moment, and + cleared on retraction, since by then the counter turns out not to have + done it. + """ + if outcome.event is DipEvent.BOOKED: + self._last_dip_delta = outcome.delta + elif outcome.event is DipEvent.RETRACTED: + self._last_dip_delta = None + _LOGGER.debug( + "Energy dip retracted for %s: %s Wh given back, offset back to %s", + self.entity_id or self._attr_unique_id, + outcome.delta, + outcome.offset, + ) + elif outcome.event is DipEvent.CONFIRMED: + _LOGGER.debug( + "Energy dip corroborated for %s: %s Wh, reported if it settles", + self.entity_id or self._attr_unique_id, + outcome.delta, + ) + + # Checked separately from `event`: a dip can settle on the same reading + # that books or retracts another one. + if outcome.settled is not None: + self.coordinator.report_energy_dip( + self.entity_id or self._attr_unique_id or "unknown", + outcome.settled, + outcome.offset, + ) + async def async_added_to_hass(self) -> None: """Restore grace period state when entity is added to Home Assistant. @@ -566,6 +741,21 @@ async def async_added_to_hass(self) -> None: self._last_panel_reading = restored.last_panel_reading if restored.last_dip_delta is not None: self._last_dip_delta = restored.last_dip_delta + if restored.pending_dip_baseline is not None and restored.pending_dip_delta is not None: + self._pending_dip = PendingDip( + baseline=restored.pending_dip_baseline, + delta=restored.pending_dip_delta, + ) + if ( + restored.confirmed_dip_baseline is not None + and restored.confirmed_dip_delta is not None + and restored.confirmed_dip_ticks_left is not None + ): + self._recently_confirmed_dip = PendingDip( + baseline=restored.confirmed_dip_baseline, + delta=restored.confirmed_dip_delta, + confirmed_ticks_left=restored.confirmed_dip_ticks_left, + ) _LOGGER.debug( "Restored energy dip compensation for %s: offset=%s, last_reading=%s, last_dip=%s", self.entity_id or self._attr_unique_id, @@ -574,6 +764,12 @@ async def async_added_to_hass(self) -> None: self._last_dip_delta, ) + # Offer this counter's offset to its meter's Net before the coordinator + # listener exists, so a Net added after it reads it from the first update. + binding = self._energy + if binding.meter is not None and binding.counter is not None: + self.coordinator.register_energy_sensor(binding.meter, binding.counter, self) + # Register the coordinator listener (and base RestoreSensor setup). await super().async_added_to_hass() @@ -644,22 +840,32 @@ def extra_restore_state_data(self) -> SensorExtraStoredData: integration is unloaded or HA shuts down, and restored when the entity is added back to HA. """ - return cast( - SensorExtraStoredData, - SpanEnergyExtraStoredData( - native_value=( - float(self._attr_native_value) - if isinstance(self._attr_native_value, int | float) - else None - ), - native_unit_of_measurement=self.native_unit_of_measurement, - last_valid_state=self._last_valid_state, - last_valid_changed=( - self._last_valid_changed.isoformat() if self._last_valid_changed else None - ), - energy_offset=self._energy_offset or None, - last_panel_reading=self._last_panel_reading, - last_dip_delta=self._last_dip_delta, + return SpanEnergyExtraStoredData( + native_value=( + float(self._attr_native_value) + if isinstance(self._attr_native_value, int | float) + else None + ), + native_unit_of_measurement=self.native_unit_of_measurement, + last_valid_state=self._last_valid_state, + last_valid_changed=( + self._last_valid_changed.isoformat() if self._last_valid_changed else None + ), + energy_offset=self._energy_offset or None, + last_panel_reading=self._last_panel_reading, + last_dip_delta=self._last_dip_delta, + pending_dip_baseline=self._pending_dip.baseline if self._pending_dip else None, + pending_dip_delta=self._pending_dip.delta if self._pending_dip else None, + confirmed_dip_baseline=( + self._recently_confirmed_dip.baseline if self._recently_confirmed_dip else None + ), + confirmed_dip_delta=( + self._recently_confirmed_dip.delta if self._recently_confirmed_dip else None + ), + confirmed_dip_ticks_left=( + self._recently_confirmed_dip.confirmed_ticks_left + if self._recently_confirmed_dip + else None ), ) @@ -691,8 +897,9 @@ def _handle_coordinator_update(self) -> None: ) # Update dip compensation flag from options in case it changed - self._dip_compensation_enabled = self.coordinator.config_entry.options.get( - ENABLE_ENERGY_DIP_COMPENSATION, False + self._dip_compensation_enabled = option_bool( + self.coordinator.config_entry.options.get(ENABLE_ENERGY_DIP_COMPENSATION), + DEFAULT_ENERGY_DIP_COMPENSATION, ) # Use the overridden _update_native_value method which handles grace period @@ -735,7 +942,7 @@ def extra_state_attributes(self) -> dict[str, Any] | None: attributes["grace_period_remaining"] = str(remaining_minutes) # Indicate if we're currently using grace period - panel_offline = getattr(self.coordinator, "panel_offline", False) + panel_offline = self.coordinator.panel_offline if panel_offline and remaining_seconds > 0: attributes["using_grace_period"] = "True" diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index 8e12c30c..4dda1f5b 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -3,18 +3,19 @@ from __future__ import annotations from collections.abc import Mapping -import logging -from typing import Any +from dataclasses import replace +from types import MappingProxyType +from typing import Any, ClassVar from homeassistant.helpers.device_registry import DeviceInfo from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot from .const import USE_CIRCUIT_NUMBERS from .coordinator import SpanPanelCoordinator +from .energy_orientation import CircuitMeter, EnergyBinding from .helpers import ( construct_circuit_identifier_from_tabs, construct_circuit_unique_id_for_entry, - construct_single_circuit_entity_id, construct_tabs_attribute, construct_unmapped_friendly_name, construct_voltage_attribute, @@ -23,8 +24,6 @@ from .sensor_base import SpanEnergySensorBase, SpanSensorBase from .sensor_definitions import SpanPanelCircuitsSensorEntityDescription -_LOGGER: logging.Logger = logging.getLogger(__name__) - def _get_circuit_data_source(circuit_id: str, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Look up a circuit in the snapshot, raising KeyError if temporarily missing.""" @@ -48,10 +47,9 @@ def _unnamed_circuit_fallback(circuit: SpanCircuitSnapshot, circuit_id: str) -> PV circuits use "Solar" (matching v1 naming), EVSE circuits use "EV Charger", all others use tab-based naming. """ - device_type = getattr(circuit, "device_type", "circuit") - if device_type in _SOLAR_DEVICE_TYPES: + if circuit.device_type in _SOLAR_DEVICE_TYPES: return "Solar" - if device_type in _EVSE_DEVICE_TYPES: + if circuit.device_type in _EVSE_DEVICE_TYPES: return "EV Charger" return construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) @@ -60,11 +58,16 @@ def _resolve_circuit_identifier( circuit: SpanCircuitSnapshot, circuit_id: str, options: Mapping[str, Any], -) -> str | None: +) -> str: """Resolve the circuit identifier respecting user naming preference. - Returns None when the circuit has no name and user is in friendly-name mode, - matching v1 behavior where HA handles default naming. + Always answers, because this is the half of the entity id the naming flags + decide and there is nothing sensible for Home Assistant to fall back to. An + unnamed circuit in friendly-name mode used to answer `None` and let Core + compose from the description label alone, which gives every unnamed circuit + on the panel the same `sensor._power` and leaves the registry to tell + them apart with `_2`, `_3`, ... in whatever order they were added. The tab + fallback names them after the breaker position they occupy instead. """ use_circuit_numbers = options.get(USE_CIRCUIT_NUMBERS, False) @@ -75,7 +78,7 @@ def _resolve_circuit_identifier( if name: return name - return None + return _unnamed_circuit_fallback(circuit, circuit_id) def _resolve_circuit_identifier_for_sync(circuit: SpanCircuitSnapshot, circuit_id: str) -> str: @@ -86,10 +89,47 @@ def _resolve_circuit_identifier_for_sync(circuit: SpanCircuitSnapshot, circuit_i return _unnamed_circuit_fallback(circuit, circuit_id) -class SpanCircuitPowerSensor( +_API_KEY_MAP: Mapping[str, str] = MappingProxyType( + { + # Instantaneous readings. + "circuit_power": "instantPowerW", + "circuit_current": "current", + "circuit_breaker_rating": "breaker_rating", + # Totals, under the original API names migration normalized them from. + "circuit_energy_produced": "producedEnergyWh", + "circuit_energy_consumed": "consumedEnergyWh", + "circuit_energy_net": "netEnergyWh", + } +) +"""Catalog key to the wire key a circuit sensor's identity is built from. + +Both the unique id and the entity-id suffix key on this rather than on the +circuit id, because the description handed to Core is rebuilt around the circuit +id and no longer remembers which sensor it is. + +One table for both families, which are disjoint by construction: `sensor.py` +hands the first three keys only to `SpanCircuitPowerSensor` and the last three +only to `SpanCircuitEnergySensor`, so neither can shadow the other. It used to +be two class attributes of the same name, which is how they were mistaken for a +duplicate of each other. +""" + + +class SpanCircuitSensorBase( SpanSensorBase[SpanPanelCircuitsSensorEntityDescription, SpanCircuitSnapshot] ): - """Circuit power/current/breaker-rating sensor with extra state attributes.""" + """What a circuit's power and energy sensors share: how they identify themselves. + + Both are handed a catalog description and a circuit id, rebuild the + description around that id so the base class looks the reading up by it, and + key their unique id and entity-id suffix on `_API_KEY_MAP`. What genuinely + differs stays on the two subclasses: the residual field paths, the displayed + name, and the state attributes. + + Cooperative `super()` throughout, because the energy sensor mixes this with + `SpanEnergySensorBase`: the `__init__` below reaches the grace-period base on + that class and `SpanSensorBase` directly on the power one. + """ def __init__( self, @@ -99,75 +139,72 @@ def __init__( circuit_id: str, device_info_override: DeviceInfo | None = None, ) -> None: - """Initialize the enhanced circuit power sensor.""" + """Initialize a circuit sensor bound to one circuit.""" + # Set before `super().__init__`, which calls the three methods below + # while it composes the entity's identity. self.circuit_id = circuit_id self.original_key = description.key self._is_sub_device = device_info_override is not None - # Override the description key to use the circuit_id for data lookup - description_with_circuit = SpanPanelCircuitsSensorEntityDescription( - key=circuit_id, - name=description.name, - native_unit_of_measurement=description.native_unit_of_measurement, - state_class=description.state_class, - suggested_display_precision=description.suggested_display_precision, - device_class=description.device_class, - value_fn=description.value_fn, - entity_registry_enabled_default=description.entity_registry_enabled_default, - entity_registry_visible_default=description.entity_registry_visible_default, - entity_category=description.entity_category, - ) - - super().__init__(data_coordinator, description_with_circuit, snapshot) + # The key becomes the circuit id because the base class looks its data + # up by it; `original_key` keeps what the catalog called this sensor. + # + # `replace` rather than the field-by-field copy this replaced: that copy + # was restated in both classes and had already drifted -- the energy one + # omitted `entity_category` -- and any field added to the description + # later would have been dropped by both. + super().__init__(data_coordinator, replace(description, key=circuit_id), snapshot) if device_info_override is not None: self._attr_device_info = device_info_override - # Map original description keys to API keys for unique ID generation - _API_KEY_MAP: dict[str, str] = { - "circuit_power": "instantPowerW", - "circuit_current": "current", - "circuit_breaker_rating": "breaker_rating", - } + @property + def _api_key(self) -> str: + """The wire key this sensor's unique id and entity-id suffix are built from.""" + return _API_KEY_MAP.get(self.original_key, self.original_key) def _generate_unique_id( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, ) -> str: - """Generate unique ID for circuit power sensors.""" - api_key = self._API_KEY_MAP.get(self.original_key, self.original_key) + """Generate unique ID for circuit sensors.""" return construct_circuit_unique_id_for_entry( - self.coordinator, snapshot, self.circuit_id, api_key, self._device_name + self.coordinator, snapshot, self.circuit_id, self._api_key, self._device_name ) - def _generate_friendly_name( + def _object_id_parts( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, - ) -> str | None: - """Generate friendly name for circuit power sensors based on user preferences. - - Returns None when circuit has no name in friendly-name mode, - matching v1 behavior where HA handles default naming. - For sub-device sensors (EVSE), returns just the description name - since the device name already provides circuit context. - """ - if self._is_sub_device: - return str(description.name or "Sensor") - + ) -> tuple[str, str] | None: + """Return the circuit identifier and the suffix this sensor's id ends with.""" circuit = snapshot.circuits.get(self.circuit_id) if not circuit: - return construct_unmapped_friendly_name( - self.circuit_id, str(description.name or "Sensor") - ) - - circuit_identifier = _resolve_circuit_identifier( + return None + identifier = _resolve_circuit_identifier( circuit, self.circuit_id, self.coordinator.config_entry.options ) - if circuit_identifier is None: - return None - return f"{circuit_identifier} {description.name or 'Sensor'}" + return identifier, get_user_friendly_suffix(self._api_key) + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: + """Get the circuit this sensor reads.""" + return _get_circuit_data_source(self.circuit_id, snapshot) + + +class SpanCircuitPowerSensor(SpanCircuitSensorBase): + """Circuit power/current/breaker-rating sensor with extra state attributes.""" + + # Beyond the value the description declares: `name` and `tabs` build the + # entity's identity (~56-83), and `tabs`, `relay_state`, `relay_requester` + # and `priority` are republished as state attributes (~231-243). + _residual_field_paths: ClassVar[tuple[str, ...]] = ( + "circuit.name", + "circuit.tabs", + "circuit.relay_state", + "circuit.relay_requester", + "circuit.priority", + ) def _generate_panel_name( self, @@ -187,32 +224,6 @@ def _generate_panel_name( circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id) return f"{circuit_identifier} {description.name or 'Sensor'}" - def _construct_entity_id( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelCircuitsSensorEntityDescription, - existing_entity_id: str | None = None, - ) -> str | None: - """Construct explicit entity_id for circuit power sensors.""" - circuit = snapshot.circuits.get(self.circuit_id) - if not circuit: - return None - suffix = get_user_friendly_suffix( - self._API_KEY_MAP.get(self.original_key, self.original_key) - ) - return construct_single_circuit_entity_id( - self.coordinator, - snapshot, - "sensor", - suffix, - circuit, - unique_id=self._attr_unique_id if existing_entity_id else None, - ) - - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: - """Get the data source for the circuit power sensor.""" - return _get_circuit_data_source(self.circuit_id, snapshot) - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return additional state attributes.""" @@ -231,8 +242,9 @@ def extra_state_attributes(self) -> dict[str, Any] | None: attributes["tabs"] = tabs_result # Voltage derived from tab count - voltage = construct_voltage_attribute(circuit) or 240 - attributes["voltage"] = voltage + voltage = construct_voltage_attribute(circuit) + if voltage is not None: + attributes["voltage"] = voltage attributes["always_on"] = circuit.always_on attributes["relay_state"] = circuit.relay_state @@ -240,14 +252,39 @@ def extra_state_attributes(self) -> dict[str, Any] | None: attributes["shed_priority"] = circuit.priority attributes["is_sheddable"] = circuit.is_sheddable + # This circuit's participation in the enclosure's Power Control System, + # beside its load-shed participation above. Two policies on the same + # relay, and the catalog keeps them apart because they answer different + # questions — limit site import, versus preserve backup runtime — so the + # attribute names do too: `pcs_priority` is an integer shed ordering + # under an import limit, `shed_priority` the backup tier. + # + # Attributes rather than entities: a 40-space panel would otherwise gain + # eighty entities carrying two facts that change only when somebody + # reconfigures the panel. + # + # Omitted when the circuit publishes neither, which is every flat + # circuit and any v1.0 circuit outside a PCS. Both properties are `MAY`, + # so absence is conformant firmware; an attribute present and empty would + # read as a reading that failed, and `False` / `0` would each be a claim + # the panel never made. + if circuit.pcs_managed is not None: + attributes["pcs_managed"] = circuit.pcs_managed + if circuit.pcs_priority is not None: + attributes["pcs_priority"] = circuit.pcs_priority + return attributes class SpanCircuitEnergySensor( - SpanEnergySensorBase[SpanPanelCircuitsSensorEntityDescription, SpanCircuitSnapshot] + SpanCircuitSensorBase, + SpanEnergySensorBase[SpanPanelCircuitsSensorEntityDescription, SpanCircuitSnapshot], ): """Circuit energy sensor with grace period tracking.""" + # Naming only; this sensor publishes no circuit attributes. + _residual_field_paths: ClassVar[tuple[str, ...]] = ("circuit.name", "circuit.tabs") + def __init__( self, data_coordinator: SpanPanelCoordinator, @@ -256,78 +293,26 @@ def __init__( circuit_id: str, device_info_override: DeviceInfo | None = None, ) -> None: - """Initialize the circuit energy sensor.""" - self.circuit_id = circuit_id - self.original_key = description.key - self._is_sub_device = device_info_override is not None - - # Override the description key to use the circuit_id for data lookup - description_with_circuit = SpanPanelCircuitsSensorEntityDescription( - key=circuit_id, - name=description.name, - native_unit_of_measurement=description.native_unit_of_measurement, - state_class=description.state_class, - suggested_display_precision=description.suggested_display_precision, - device_class=description.device_class, - value_fn=description.value_fn, - entity_registry_enabled_default=description.entity_registry_enabled_default, - entity_registry_visible_default=description.entity_registry_visible_default, - ) - - super().__init__(data_coordinator, description_with_circuit, snapshot) - - if device_info_override is not None: - self._attr_device_info = device_info_override - - async def async_added_to_hass(self) -> None: - """Register consumed/produced sensors on the coordinator for net energy lookup.""" - await super().async_added_to_hass() - energy_type = self._ENERGY_TYPE_MAP.get(self.original_key) - if energy_type: - self.coordinator.register_circuit_energy_sensor(self.circuit_id, energy_type, self) + """Initialize a circuit energy sensor, bound to its circuit's meter. - def _generate_unique_id( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelCircuitsSensorEntityDescription, - ) -> str: - """Generate unique ID for circuit energy sensors.""" - # Map new description keys to original API keys that migration normalized from - api_key_mapping = { - "circuit_energy_produced": "producedEnergyWh", - "circuit_energy_consumed": "consumedEnergyWh", - "circuit_energy_net": "netEnergyWh", - } - api_key = api_key_mapping.get(self.original_key, self.original_key) - return construct_circuit_unique_id_for_entry( - self.coordinator, snapshot, self.circuit_id, api_key, self._device_name - ) - - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelCircuitsSensorEntityDescription, - ) -> str | None: - """Generate friendly name for circuit energy sensors based on user preferences. - - Returns None when circuit has no name in friendly-name mode, - matching v1 behavior where HA handles default naming. - For sub-device sensors (EVSE), returns just the description name - since the device name already provides circuit context. + Bound from the catalog description here rather than read off + `entity_description` later, which `SensorEntity` annotates as the base + description, as `SpanShedForecastSensor` explains. """ - if self._is_sub_device: - return str(description.name) - - circuit = snapshot.circuits.get(self.circuit_id) - if not circuit: - return f"Circuit {self.circuit_id} {description.name}" - - circuit_identifier = _resolve_circuit_identifier( - circuit, self.circuit_id, self.coordinator.config_entry.options + self._bind_energy( + EnergyBinding( + meter=CircuitMeter(circuit_id), + role=description.energy_role, + net=description.net_energy, + ) + ) + super().__init__( + data_coordinator, + description, + snapshot, + circuit_id, + device_info_override=device_info_override, ) - if circuit_identifier is None: - return None - return f"{circuit_identifier} {description.name}" def _generate_panel_name( self, @@ -345,58 +330,6 @@ def _generate_panel_name( circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id) return f"{circuit_identifier} {description.name}" - def _construct_entity_id( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelCircuitsSensorEntityDescription, - existing_entity_id: str | None = None, - ) -> str | None: - """Construct explicit entity_id for circuit energy sensors.""" - circuit = snapshot.circuits.get(self.circuit_id) - if not circuit: - return None - api_key_mapping = { - "circuit_energy_produced": "producedEnergyWh", - "circuit_energy_consumed": "consumedEnergyWh", - "circuit_energy_net": "netEnergyWh", - } - api_key = api_key_mapping.get(self.original_key, self.original_key) - suffix = get_user_friendly_suffix(api_key) - return construct_single_circuit_entity_id( - self.coordinator, - snapshot, - "sensor", - suffix, - circuit, - unique_id=self._attr_unique_id if existing_entity_id else None, - ) - - # Map original_key to the energy type used for coordinator dip offset tracking - _ENERGY_TYPE_MAP: dict[str, str] = { - "circuit_energy_consumed": "consumed", - "circuit_energy_produced": "produced", - } - - def _process_raw_value(self, raw_value: float | str | None) -> None: - """Process raw value, adjusting net energy for dip compensation consistency. - - Consumed/produced sensors apply dip offsets via the base class. The net - energy sensor reads those offsets from the registered sibling sensors - so its value stays equal to compensated_consumed - compensated_produced. - """ - super()._process_raw_value(raw_value) - - if self.original_key == "circuit_energy_net" and isinstance(self._attr_native_value, float): - consumed_offset = self.coordinator.get_circuit_dip_offset(self.circuit_id, "consumed") - produced_offset = self.coordinator.get_circuit_dip_offset(self.circuit_id, "produced") - net_adjustment = consumed_offset - produced_offset - if net_adjustment: - self._attr_native_value += net_adjustment - - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: - """Get the data source for the circuit energy sensor.""" - return _get_circuit_data_source(self.circuit_id, snapshot) - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return additional state attributes including grace period and circuit info.""" @@ -413,8 +346,9 @@ def extra_state_attributes(self) -> dict[str, Any] | None: if tabs is not None: attributes["tabs"] = tabs - voltage = construct_voltage_attribute(circuit) or 240 - attributes["voltage"] = voltage + voltage = construct_voltage_attribute(circuit) + if voltage is not None: + attributes["voltage"] = voltage return attributes or None @@ -445,8 +379,11 @@ def __init__( suggested_display_precision=description.suggested_display_precision, device_class=description.device_class, value_fn=description.value_fn, + field_path=description.field_path, + derived=description.derived, entity_registry_enabled_default=True, entity_registry_visible_default=False, + legacy_names=description.legacy_names, ) super().__init__(data_coordinator, description_with_circuit, snapshot) @@ -465,16 +402,31 @@ def _generate_unique_id( self._device_name, ) - def _generate_friendly_name( + def _generate_panel_name( self, snapshot: SpanPanelSnapshot, description: SpanPanelCircuitsSensorEntityDescription, ) -> str: - """Generate friendly name for unmapped circuit sensors.""" + """Name an unmapped tab, which has no circuit name for a mode to differ over.""" + return self._unmapped_name(description) + + def _unmapped_name(self, description: SpanPanelCircuitsSensorEntityDescription) -> str: + """Return "Unmapped Tab 32 Consumed Energy" and the like.""" tab_number = self.circuit_id.replace("unmapped_tab_", "") description_name = str(description.name) if description.name else "Sensor" return construct_unmapped_friendly_name(tab_number, description_name) + def _object_id_parts( + self, + snapshot: SpanPanelSnapshot, + description: SpanPanelCircuitsSensorEntityDescription, + ) -> tuple[str, str] | None: + """Return the tab this sensor backs; unmapped tabs carry no naming flag.""" + return ( + f"Unmapped Tab {self.circuit_id.replace('unmapped_tab_', '')}", + get_user_friendly_suffix(self.original_key), + ) + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot: """Get the data source for the unmapped circuit sensor.""" return _get_circuit_data_source(self.circuit_id, snapshot) diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index e94d3ac6..4a624aa5 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -7,12 +7,18 @@ - Circuit power and energy sensors - Unmapped circuit sensors (invisible backing data) - Battery sensor + +Disabled by default from 2.1.0, because the SPAN API's own values are unreliable +(#234): Feedthrough Power, Feedthrough Produced Energy, Feedthrough Consumed +Energy, Downstream L1 Current, Downstream L2 Current. The default applies at +first registration only, so existing installs keep them. """ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Final, NamedTuple from homeassistant.components.sensor import ( SensorDeviceClass, @@ -25,50 +31,94 @@ UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, + UnitOfTime, ) from homeassistant.helpers.entity import EntityCategory from span_panel_api import ( SpanBatterySnapshot, SpanCircuitSnapshot, SpanEvseSnapshot, + SpanMidSnapshot, SpanPanelSnapshot, + SpanPcsSnapshot, + SpanPVSnapshot, +) + +from .energy_orientation import ( + EnergyRole, + NetEnergy, + PanelMeter, + circuit_is_generation, + circuit_net_orientation, + panel_meter_net_orientation, +) +from .field_paths import ( + DerivedReason, + FieldPathDeclarationMixin, + iter_source_field_declarations, ) @dataclass(frozen=True) -class SpanPanelCircuitsRequiredKeysMixin: +class SpanPanelCircuitsRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for Span Panel circuit sensors.""" value_fn: Callable[[SpanCircuitSnapshot], float | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanPanelCircuitsSensorEntityDescription( SensorEntityDescription, SpanPanelCircuitsRequiredKeysMixin ): """Describes a Span Panel circuit sensor entity.""" + legacy_names: tuple[str, ...] = () + """Labels this description carried before, which an older release may have + written into the registry's `name`. Consulted only to hand that field back -- + never to name anything, so a label can be reworded without a migration.""" + + energy_role: EnergyRole | None = None + """The part this sensor plays in its circuit's energy: a counter, or the Net of + the two. Declared rather than matched on `key`, for the reason the shed-forecast + mixin gives: a pairing is data, and a substring match is how a rename silently + unpairs it. Its meter is the instance's circuit.""" + + net_energy: NetEnergy[SpanCircuitSnapshot] | None = None + """The Net's definition, set exactly when `energy_role` is Net. `value_fn` is + its `value`, and the sensor's dip adjustment reads its orientation.""" + @dataclass(frozen=True) -class SpanPanelDataRequiredKeysMixin: +class SpanPanelDataRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for Span Panel data sensors.""" value_fn: Callable[[SpanPanelSnapshot], float | str | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanPanelDataSensorEntityDescription(SensorEntityDescription, SpanPanelDataRequiredKeysMixin): """Describes a Span Panel data sensor entity.""" + energy_role: EnergyRole | None = None + """The part this sensor plays on `panel_meter`. Set on the six + `PANEL_ENERGY_SENSORS` and nothing else, always together with `panel_meter`.""" + + panel_meter: PanelMeter | None = None + """The panel meter whose counters this sensor reads; set with `energy_role`.""" + + net_energy: NetEnergy[SpanPanelSnapshot] | None = None + """The Net's definition, set exactly when `energy_role` is Net. `value_fn` is + its `value`, and the sensor's dip adjustment reads its orientation.""" + @dataclass(frozen=True) -class SpanPanelStatusRequiredKeysMixin: +class SpanPanelStatusRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for Span Panel status sensors.""" value_fn: Callable[[SpanPanelSnapshot], str] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanPanelStatusSensorEntityDescription( SensorEntityDescription, SpanPanelStatusRequiredKeysMixin ): @@ -76,13 +126,13 @@ class SpanPanelStatusSensorEntityDescription( @dataclass(frozen=True) -class SpanPanelBatteryRequiredKeysMixin: +class SpanPanelBatteryRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for Span Panel battery sensors.""" value_fn: Callable[[SpanBatterySnapshot], float | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanPanelBatterySensorEntityDescription( SensorEntityDescription, SpanPanelBatteryRequiredKeysMixin ): @@ -100,52 +150,59 @@ class SpanPanelBatterySensorEntityDescription( ] = ( SpanPanelDataSensorEntityDescription( key="dsm_state", + derived=DerivedReason.NO_SOURCE_FIELD, translation_key="dsm_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - options=["unknown"], + options=["dsm_off_grid", "dsm_on_grid", "unknown"], value_fn=lambda s: s.dsm_state, ), SpanPanelDataSensorEntityDescription( key="dsm_grid_state", + derived=DerivedReason.NO_SOURCE_FIELD, translation_key="dsm_grid_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, - options=["unknown"], + options=["dsm_off_grid", "dsm_on_grid", "unknown"], value_fn=lambda s: s.dsm_state, # deprecated alias — reads dsm_state ), SpanPanelDataSensorEntityDescription( key="current_run_config", + derived=DerivedReason.NO_SOURCE_FIELD, translation_key="current_run_config", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, - options=["unknown"], + options=["panel_backup", "panel_off_grid", "panel_on_grid", "unknown"], value_fn=lambda s: s.current_run_config, ), SpanPanelDataSensorEntityDescription( key="main_relay_state", + field_path="panel.main_relay_state", translation_key="main_relay_state", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, - options=["unknown"], + options=["closed", "open", "unknown"], value_fn=lambda s: s.main_relay_state, ), SpanPanelDataSensorEntityDescription( key="grid_forming_entity", + field_path="panel.dominant_power_source", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, translation_key="grid_forming_entity", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, - options=["unknown"], + options=["battery", "generator", "grid", "none", "pv", "unknown"], value_fn=lambda s: s.dominant_power_source or "unknown", ), SpanPanelDataSensorEntityDescription( key="vendor_cloud", + field_path="panel.vendor_cloud", translation_key="vendor_cloud", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - options=["unknown"], + options=["connected", "unconnected", "unknown"], value_fn=lambda s: s.vendor_cloud or "unknown", ), ) @@ -154,6 +211,7 @@ class SpanPanelBatterySensorEntityDescription( STATUS_SENSORS: tuple[SpanPanelStatusSensorEntityDescription,] = ( SpanPanelStatusSensorEntityDescription( key="software_version", + field_path="panel.firmware_version", translation_key="software_version", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda s: s.firmware_version, @@ -169,6 +227,7 @@ class SpanPanelBatterySensorEntityDescription( ] = ( SpanPanelCircuitsSensorEntityDescription( key="instantPowerW", + field_path="circuit.instant_power_w", name="Power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -180,7 +239,9 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelCircuitsSensorEntityDescription( key="producedEnergyWh", + field_path="circuit.produced_energy_wh", name="Produced Energy", + legacy_names=("Energy Produced",), native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, @@ -191,7 +252,9 @@ class SpanPanelBatterySensorEntityDescription( ), SpanPanelCircuitsSensorEntityDescription( key="consumedEnergyWh", + field_path="circuit.consumed_energy_wh", name="Consumed Energy", + legacy_names=("Energy Consumed",), native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, @@ -205,6 +268,7 @@ class SpanPanelBatterySensorEntityDescription( # Battery sensor definition (conditionally created when battery data available) BATTERY_SENSOR: SpanPanelBatterySensorEntityDescription = SpanPanelBatterySensorEntityDescription( key="storage_battery_percentage", + field_path="battery.soe_percentage", translation_key="battery_level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -213,6 +277,38 @@ class SpanPanelBatterySensorEntityDescription( value_fn=lambda b: b.soe_percentage, ) + +# --------------------------------------------------------------------------- +# Composing readings that may not have been reported +# --------------------------------------------------------------------------- +# +# `span-panel-api` reports a reading the panel has not sent as `None` rather +# than fabricating `0.0`, because on a cumulative counter the two are not +# interchangeable: a consumer compensating for firmware counter resets reads a +# fabricated zero as a reset and books the whole counter as an offset +# (`SpanPanel/span#259`). +# +# A derived sensor has to carry that distinction rather than flatten it. These +# were written as `(a or 0) - (b or 0)`, which answers `0` for a circuit that +# has reported nothing — reintroducing exactly the fabrication the library +# stopped making, one layer further out and past the point where anything can +# tell. Returning `None` instead reaches `_process_raw_value`, which renders the +# sensor unknown and, on a `TOTAL_INCREASING` sensor, skips dip compensation so +# the absent reading never becomes a baseline. + + +def _negated(value: float | None) -> float | None: + """Flip the sign of a reading, preserving `None` and avoiding `-0.0`. + + Negative zero compares equal to `0.0` and renders as "-0.0", which is the + reason the original expression ended in `or 0.0`. That guard could not also + survive `None`, because `-None` raises before it is reached. + """ + if value is None: + return None + return -value or 0.0 + + # --------------------------------------------------------------------------- # Panel diagnostic sensors (promoted from attributes) # --------------------------------------------------------------------------- @@ -220,6 +316,7 @@ class SpanPanelBatterySensorEntityDescription( # L1/L2 voltage sensors (v2 only, conditionally created) L1_VOLTAGE_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="l1_voltage", + field_path="panel.l1_voltage", translation_key="l1_voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -232,6 +329,7 @@ class SpanPanelBatterySensorEntityDescription( L2_VOLTAGE_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="l2_voltage", + field_path="panel.l2_voltage", translation_key="l2_voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -246,6 +344,7 @@ class SpanPanelBatterySensorEntityDescription( UPSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( SpanPanelDataSensorEntityDescription( key="upstream_l1_current", + field_path="panel.upstream_l1_current_a", translation_key="upstream_l1_current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -260,6 +359,7 @@ class SpanPanelBatterySensorEntityDescription( UPSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( SpanPanelDataSensorEntityDescription( key="upstream_l2_current", + field_path="panel.upstream_l2_current_a", translation_key="upstream_l2_current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -274,6 +374,9 @@ class SpanPanelBatterySensorEntityDescription( DOWNSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( SpanPanelDataSensorEntityDescription( key="downstream_l1_current", + field_path="panel.downstream_l1_current_a", + # Unreliable in the SPAN API; see the module docstring (#234). + entity_registry_enabled_default=False, translation_key="downstream_l1_current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -287,6 +390,9 @@ class SpanPanelBatterySensorEntityDescription( DOWNSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = ( SpanPanelDataSensorEntityDescription( key="downstream_l2_current", + field_path="panel.downstream_l2_current_a", + # Unreliable in the SPAN API; see the module docstring (#234). + entity_registry_enabled_default=False, translation_key="downstream_l2_current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -301,6 +407,7 @@ class SpanPanelBatterySensorEntityDescription( MAIN_BREAKER_RATING_SENSOR: SpanPanelDataSensorEntityDescription = ( SpanPanelDataSensorEntityDescription( key="main_breaker_rating", + field_path="panel.main_breaker_rating_a", translation_key="main_breaker_rating", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_category=EntityCategory.DIAGNOSTIC, @@ -309,6 +416,332 @@ class SpanPanelBatterySensorEntityDescription( ) ) +# --------------------------------------------------------------------------- +# Shed forecast (v1.0 `shed-forecast`, conditionally created) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SpanShedForecastRequiredKeysMixin(FieldPathDeclarationMixin): + """Required keys mixin for the backup-planning forecast sensors. + + Carries a second reader beside `value_fn`, which the other panel mixins do + not need. The capability publishes each live estimate with a + hypothetical-full-charge twin, and the twin refines the number on screen + rather than standing on its own — so it belongs to the sensor as an + attribute, and which twin belongs to which sensor is a fact about the + pairing rather than about the entity class. + + Declared here so that pairing is data. Reading it off `description.key` + inside the entity would put a string comparison between the two halves of + something the catalog states outright, which is how a rename silently + swaps two plausible-looking durations. + """ + + value_fn: Callable[[SpanPanelSnapshot], int | None] + + full_charge_attribute: str + """Attribute name the hypothetical twin is published under.""" + + full_charge_fn: Callable[[SpanPanelSnapshot], int | None] + """Reads the twin. `None` when the panel does not publish it.""" + + +@dataclass(frozen=True, kw_only=True) +class SpanShedForecastSensorEntityDescription( + SensorEntityDescription, SpanShedForecastRequiredKeysMixin +): + """Describes one of the two backup-planning forecast sensors.""" + + +SHED_FORECAST_SENSORS: tuple[ + SpanShedForecastSensorEntityDescription, + SpanShedForecastSensorEntityDescription, +] = ( + SpanShedForecastSensorEntityDescription( + key="time_to_priority_shed", + field_path="panel.shed_time_to_priority_shed_min", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="time_to_priority_shed", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=0, + value_fn=lambda s: s.shed_time_to_priority_shed_min, + full_charge_attribute="full_charge_time_to_priority_shed", + full_charge_fn=lambda s: s.shed_full_charge_time_to_priority_shed_min, + ), + SpanShedForecastSensorEntityDescription( + key="shed_total_time_remaining", + field_path="panel.shed_total_time_remaining_min", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="shed_total_time_remaining", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=0, + value_fn=lambda s: s.shed_total_time_remaining_min, + full_charge_attribute="full_charge_total_time_remaining", + full_charge_fn=lambda s: s.shed_full_charge_total_time_remaining_min, + ), +) +"""The two live estimates from `energy.ebus.capability.shed-forecast` 0.1. + +**Enabled by default, and not diagnostic.** These are the numbers a user plans a +backup around — how long before the panel starts shedding circuits, how long +before the battery is spent — so they belong beside the power and energy +sensors rather than under the diagnostics fold. That is the whole argument for +surfacing them ahead of the rest of the unread v1.0 surface. + +**`derived` rather than a `field_path` declaration, by the producible rule.** +The gate requires a path *both* adapters produce, and no flat panel publishes +this capability at all; `SCHEMA_CONDITIONAL_FIELD` is what that situation is +called. The paths are enumerated in `RESIDUAL_EXEMPT_PATHS` instead, annotated +`SCHEMA_1_ONLY`, and the conformance suite checks that annotation against what +the adapters actually emit — so if flat firmware ever grew the capability, the +build would fail and demand promotion rather than leaving the read ungated. + +The two hypothetical-full-charge figures ride as attributes rather than as +sensors of their own. They answer "what would this installation give me from a +full battery", which moves when the hardware does and not as the battery +drains; a separate entity would put a near-constant on a graph beside the +countdown it qualifies. +""" + + +# --------------------------------------------------------------------------- +# Power Control System (v1.0 `pcs`, conditionally created) +# --------------------------------------------------------------------------- + + +class PcsConstraintFamily(NamedTuple): + """One amps-native constraint class, and how to read its three properties. + + `energy.ebus.capability.pcs` 0.3 publishes each arbitration input as an + identical `{-import-limit, -enablement, -active}` triplet, and says + so as a rule: a vendor "MAY publish further amps-native limits using the + same triplet". So the four families are one shape repeated, and the + attribute builder is written once over this table rather than four times + over twelve field names — where a copied line would put an enablement in an + active flag and still read plausibly. + + `attribute` is the name the limit is published under, and the enablement and + active flags extend it. The names mirror the wire property ids so a user + reading the catalog and a user reading the attribute list see the same + words. + """ + + attribute: str + limit_fn: Callable[[SpanPcsSnapshot], float | None] + enablement_fn: Callable[[SpanPcsSnapshot], str | None] + active_fn: Callable[[SpanPcsSnapshot], bool | None] + + +PCS_CONSTRAINT_FAMILIES: tuple[ + PcsConstraintFamily, + PcsConstraintFamily, + PcsConstraintFamily, + PcsConstraintFamily, +] = ( + PcsConstraintFamily( + attribute="feed_import_limit", + limit_fn=lambda p: p.feed_import_limit_a, + enablement_fn=lambda p: p.feed_import_limit_enablement, + active_fn=lambda p: p.feed_import_limit_active, + ), + PcsConstraintFamily( + attribute="operator_import_limit", + limit_fn=lambda p: p.operator_import_limit_a, + enablement_fn=lambda p: p.operator_import_limit_enablement, + active_fn=lambda p: p.operator_import_limit_active, + ), + PcsConstraintFamily( + attribute="off_grid_import_limit", + limit_fn=lambda p: p.off_grid_import_limit_a, + enablement_fn=lambda p: p.off_grid_import_limit_enablement, + active_fn=lambda p: p.off_grid_import_limit_active, + ), + PcsConstraintFamily( + attribute="requested_import_limit", + limit_fn=lambda p: p.requested_import_limit_a, + enablement_fn=lambda p: p.requested_import_limit_enablement, + active_fn=lambda p: p.requested_import_limit_active, + ), +) +"""The four constraint classes the catalog names, in the order it names them. + +The FSR first because it is the only standing one: `feed_import_limit` is the +commissioned floor that "cannot be lost", and the other three are conditional — +an operator cap set over a fleet API, an islanded cap, and a limit the owner +asked for. A reader scanning the attributes meets the permanent one first. +""" + + +def _no_pcs_attributes(pcs: SpanPcsSnapshot) -> dict[str, float | str | bool]: + """Return nothing — the default for a PCS sensor with no attributes of its own.""" + return {} + + +def pcs_arbitration_attributes(pcs: SpanPcsSnapshot) -> dict[str, float | str | bool]: + """Return the arbitration inputs behind the effective import limit. + + They belong to the sensor that shows the limit itself. + + `capabilities/pcs.md` is explicit that what a PCS publishes is "the + **result**: the effective `import-limit` and the `binding-constraint`". That + is the entity; these are the working. Twelve of them, which is why they are + attributes: a dashboard with twelve near-constant amperages on it is not a + dashboard, and eleven of these move only when somebody reconfigures the + panel. + + Each is omitted when the panel does not publish it, rather than appearing as + `None`. Three of the four classes are `MAY`, so an absent family is + conformant firmware; an attribute present and empty would read as a reading + the panel failed to produce. + + `pcs_enabled` rides here rather than as its own entity because it is + subsumed: the `pcs_active` binary sensor is the fact an automation triggers + on, and a PCS that is not enabled cannot be active. + """ + attributes: dict[str, float | str | bool] = {} + + if pcs.enabled is not None: + attributes["pcs_enabled"] = pcs.enabled + + for family in PCS_CONSTRAINT_FAMILIES: + limit = family.limit_fn(pcs) + if limit is not None: + attributes[family.attribute] = limit + enablement = family.enablement_fn(pcs) + if enablement is not None: + attributes[f"{family.attribute}_enablement"] = enablement + active = family.active_fn(pcs) + if active is not None: + attributes[f"{family.attribute}_active"] = active + + return attributes + + +@dataclass(frozen=True, kw_only=True) +class SpanPcsRequiredKeysMixin(FieldPathDeclarationMixin): + """Required keys mixin for the Power Control System sensors. + + Keyword-only for the reason `FieldPathDeclarationMixin` is: a mixin's fields + flatten ahead of `EntityDescription.key`, which has no default, so a + defaulted positional field here would make every description + unconstructable. + + `attributes_fn` is carried on the description rather than decided inside the + entity, for the reason the shed-forecast pairing is: the alternative is a + comparison against `description.key` in `extra_state_attributes`, which puts + a string match between an entity and the data it publishes and silently + stops matching after a rename. + """ + + value_fn: Callable[[SpanPcsSnapshot], float | str | None] + + attributes_fn: Callable[[SpanPcsSnapshot], dict[str, float | str | bool]] = _no_pcs_attributes + """What this sensor publishes beside its state. Empty for most.""" + + +@dataclass(frozen=True, kw_only=True) +class SpanPcsSensorEntityDescription(SensorEntityDescription, SpanPcsRequiredKeysMixin): + """Describes a Power Control System sensor entity.""" + + +PCS_BINDING_CONSTRAINT_OPTIONS: tuple[str, ...] = ( + "fsr", + "doe", + "voltage", + "off_grid", + "requested", + "operator", + "none", + "unknown", +) +"""`binding-constraint`'s enum, lowercased for Home Assistant's state keys. + +The catalog's eight members, in its order. Publishers MAY extend the enum +through the property's Homie `$format`, so this is the interoperable core rather +than a closed set — a vendor value arrives as a state Home Assistant does not +recognise, which is a visible gap rather than a silent re-labelling. +""" + + +PCS_SENSORS: tuple[SpanPcsSensorEntityDescription, SpanPcsSensorEntityDescription] = ( + SpanPcsSensorEntityDescription( + key="pcs_import_limit", + field_path="pcs.import_limit_a", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="pcs_import_limit", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + # Diagnostic, which is where this integration files a panel-level + # electrical characteristic. The line is not "amps are diagnostic" -- + # `circuit_current` and `evse_advertised_current` are amps and are + # correctly primary, because they are what a circuit or a charger is doing + # right now. It is that the panel's own voltages, lug currents, breaker + # ratings and limits describe the installation rather than its activity, + # and all of those are diagnostic. `main_breaker_rating` is the closest + # analogue -- also an ampere ceiling on the panel -- and so are both of + # this sensor's own PCS siblings, which left this the only member of its + # group on the primary card. + # + # It is a ceiling the panel arbitrated, not a measurement of what is + # flowing, and it carries the four constraint limits it was arbitrated + # from as attributes -- which is what a diagnostic looks like. The + # category costs nothing operationally: it groups the entity on the device + # page and keeps it out of auto-generated dashboards, and automations, + # templates and long-term statistics are unaffected. + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, + value_fn=lambda p: p.import_limit_a, + attributes_fn=pcs_arbitration_attributes, + ), + SpanPcsSensorEntityDescription( + key="pcs_binding_constraint", + field_path="pcs.binding_constraint", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="pcs_binding_constraint", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(PCS_BINDING_CONSTRAINT_OPTIONS), + value_fn=lambda p: None if p.binding_constraint is None else p.binding_constraint.lower(), + ), +) +"""What the enclosure's Power Control System publishes as a result. + +**The effective limit is the entity, and the arbitration is its attributes.** +The capability says the PCS reconciles every active import constraint — some of +them in watts on `doe`, some in volts on `voltage-response` — to one enforced +current limit, and that "what `pcs` publishes is the **result**". So the result +is what gets an entity a user can graph and alarm on. The twelve amps-native +inputs behind it are on that entity as attributes: they explain a number rather +than being numbers anyone watches, and eleven of them move only on +reconfiguration. + +`pcs_binding_constraint` is the second half of that result and the reason the +first is interpretable — it names which constraint class won the `min()`, which +is the difference between "the panel is limiting me to 40 A" and "the panel is +limiting me to 40 A *because the utility sent an envelope*". Diagnostic and +enabled by default: it is short, it changes rarely, and it is useless filed +where nobody finds it. + +**Enabled by default and not diagnostic, for the limit.** A PCS actively +throttling import is a fact about the user's electricity supply, not about the +integration's health. + +**`derived` as well as `field_path`, by the producible rule.** No flat panel +publishes `energy.ebus.capability.pcs` at all, so the both-adapters gate cannot +be satisfied; the paths are enumerated in `RESIDUAL_EXEMPT_PATHS` as +`SCHEMA_1_ONLY`, which schema_1's metadata rows earn them and which buys +`pcs_import_limit` unit validation against the panel's own `$description`. +`field_path` still names the source, which is what gives each sensor its Repair +mention and its unavailability when the panel stops resolving the property. +""" + + # --------------------------------------------------------------------------- # Circuit diagnostic sensors (promoted from attributes) # --------------------------------------------------------------------------- @@ -317,6 +750,7 @@ class SpanPanelBatterySensorEntityDescription( CIRCUIT_CURRENT_SENSOR: SpanPanelCircuitsSensorEntityDescription = ( SpanPanelCircuitsSensorEntityDescription( key="circuit_current", + field_path="circuit.current_a", name="Current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -330,6 +764,7 @@ class SpanPanelBatterySensorEntityDescription( CIRCUIT_BREAKER_RATING_SENSOR: SpanPanelCircuitsSensorEntityDescription = ( SpanPanelCircuitsSensorEntityDescription( key="circuit_breaker_rating", + field_path="circuit.breaker_rating_a", name="Breaker Rating", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_category=EntityCategory.DIAGNOSTIC, @@ -344,19 +779,55 @@ class SpanPanelBatterySensorEntityDescription( @dataclass(frozen=True) -class SpanBessMetadataRequiredKeysMixin: +class SpanBessMetadataRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for BESS metadata sensors.""" value_fn: Callable[[SpanBatterySnapshot], float | str | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanBessMetadataSensorEntityDescription( SensorEntityDescription, SpanBessMetadataRequiredKeysMixin ): """Describes a BESS metadata sensor entity.""" +@dataclass(frozen=True) +class SpanMidRequiredKeysMixin(FieldPathDeclarationMixin): + """Required keys mixin for MID sensors.""" + + value_fn: Callable[[SpanMidSnapshot], str | None] + + +@dataclass(frozen=True, kw_only=True) +class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKeysMixin): + """Describes a sensor on the Microgrid Interconnect Device.""" + + +MID_SENSORS: tuple[SpanMidSensorEntityDescription, ...] = ( + SpanMidSensorEntityDescription( + key="mid_grid_state", + derived=DerivedReason.NO_SOURCE_FIELD, + translation_key="mid_grid_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=["up", "down", "degraded", "unknown"], + value_fn=lambda m: (m.grid_state or "unknown").lower(), + ), +) +"""Sensors on the MID device. + +Only `grid-state` — utility-supply health, genuinely new in v1.0 with no flat +equivalent, and the one non-metadata addition the MID brings. + +Islanding state and the grid-forming entity are deliberately *not* duplicated here. +Both already reach a user through entities that predate v1.0 — `dsm_state` and +`grid_forming_entity` on the panel — and those must keep their ids and their history. +Showing the same fact twice is not the benign cell of the absorb-or-surface policy; +adding a fact nobody had is. +""" + + BESS_METADATA_SENSORS: tuple[ SpanBessMetadataSensorEntityDescription, SpanBessMetadataSensorEntityDescription, @@ -364,33 +835,47 @@ class SpanBessMetadataSensorEntityDescription( SpanBessMetadataSensorEntityDescription, SpanBessMetadataSensorEntityDescription, SpanBessMetadataSensorEntityDescription, + SpanBessMetadataSensorEntityDescription, ] = ( SpanBessMetadataSensorEntityDescription( key="vendor", + field_path="battery.vendor_name", translation_key="bess_vendor", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda b: b.vendor_name, ), SpanBessMetadataSensorEntityDescription( key="model", + field_path="battery.model", translation_key="bess_model", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda b: b.product_name, + value_fn=lambda b: b.model, + ), + SpanBessMetadataSensorEntityDescription( + key="part_number", + field_path="battery.part_number", + translation_key="bess_part_number", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda b: b.part_number, ), SpanBessMetadataSensorEntityDescription( key="serial_number", + field_path="battery.serial_number", translation_key="bess_serial_number", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda b: b.serial_number, ), SpanBessMetadataSensorEntityDescription( key="firmware_version", + field_path="battery.software_version", translation_key="bess_firmware_version", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda b: b.software_version, ), SpanBessMetadataSensorEntityDescription( key="nameplate_capacity", + field_path="battery.nameplate_capacity_kwh", translation_key="bess_nameplate_capacity", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, entity_category=EntityCategory.DIAGNOSTIC, @@ -399,6 +884,7 @@ class SpanBessMetadataSensorEntityDescription( ), SpanBessMetadataSensorEntityDescription( key="soe_kwh", + field_path="battery.soe_kwh", translation_key="bess_soe_kwh", device_class=SensorDeviceClass.ENERGY_STORAGE, state_class=SensorStateClass.MEASUREMENT, @@ -409,19 +895,113 @@ class SpanBessMetadataSensorEntityDescription( ), ) +BESS_TELEMETRY_SENSORS: tuple[ + SpanBessMetadataSensorEntityDescription, + SpanBessMetadataSensorEntityDescription, +] = ( + SpanBessMetadataSensorEntityDescription( + key="meter_power", + field_path="battery.power_w", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="bess_meter_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=0, + value_fn=lambda b: b.power_w, + ), + SpanBessMetadataSensorEntityDescription( + key="communication_state", + field_path="battery.communication_state", + derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD, + translation_key="bess_communication_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=["ok", "degraded", "lost", "unknown"], + value_fn=lambda b: None if b.communication_state is None else b.communication_state.lower(), + ), +) +"""What the BESS reports about *itself*, as opposed to what the panel reports about it. + +Separate from `BESS_METADATA_SENSORS` because these are created conditionally and +those are not. Every metadata sensor exists on any commissioned BESS, filled or +empty. These two come from capability nodes a BESS may simply not have, so +absence has to mean no entity rather than a permanently unknown one, and mixing +the two rules into one tuple would mean deciding per description which applied. + +**Power is enabled by default and not diagnostic; communication state is +neither.** The battery's own charge/discharge figure is a reading a user graphs +and automates on. Its link health is a fault signal, so it lands the way the +other diagnostics do, off by default. + +**`bess_meter_power` is not `battery_power`, and the names say so.** The existing +`battery_power` sensor reads `panel.power_flow_battery`, the enclosure's own +arbitrated flow figure; this one reads the BESS's `meter/active-power`, the +battery's own meter. + +**They agree, and this is why.** Through firmware r202633 both wire properties +carry the *same* sign as each other -- a live panel capture and `ebus-panel-sim` +0.6.0 both publish `-3500.0` for the pair -- and each path applies exactly one +negation. From r202639 the BESS meter is published as the negation of the flow +figure and the library passes it through instead, so the two entities land on one +convention on either firmware. A sensor whose sign contradicted the one beside it +would be worse than no sensor. + +**The shared convention is discharge-positive, and that was measured.** This +docstring used to claim charge-positive. Driving the producer into +self-consumption with the grid at zero forces the direction -- PV 4181 W plus +battery 1917 W meeting a 6099 W load, so the battery is discharging -- and both +sensors read `+1917.49`. Positive means the battery is *discharging*. + +That is the same convention the other three power-flow sensors follow, which is +why it is right rather than merely consistent: `pv_power` is positive while +producing, `grid_power_flow` positive while importing, `battery_power` positive +while discharging. Every one is "positive means power flowing toward the house", +and each is reached by the same single negation of a wire value in the opposite +frame. The library's helper was renamed `_charge_positive` -> `_discharge_positive` +for the same reason. + +**What is settled is that nothing here regressed.** `BATTERY_POWER_SENSOR` is +behaviorally identical to the one released in 2.0.8 -- same source, same single +negation, same device and state class -- and both adapters pass +`power_flow_battery` through untouched. Whatever a live panel showed then, it +shows now. `bess_meter_power` was briefly withdrawn on the belief that it would +disagree with its neighbor on real firmware; the capture showed the two wire +properties aligned on the panel *and* on the emitter, so it cannot, and it is +restored. + +**The library picks the frame, not this integration.** It reads the release +build number from the panel's firmware version and, where that does not parse, +compares the BESS meter's sign with `power-flows/battery`. `battery.power_w` +arrives discharge-positive on every firmware, so this sensor reads it as is. + +**`derived` as well as `field_path`, by the producible rule.** The gate wants a +path both adapters produce, and flat's BESS device class declares neither +property -- so `SCHEMA_CONDITIONAL_FIELD`, with the paths enumerated in +`RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY`. `field_path` still names the source, +which is what gives each sensor its Repair mention and its unavailability when +the panel stops resolving the property. +""" + # --------------------------------------------------------------------------- -# PV metadata sensors (on main panel device) +# PV metadata sensors (on the solar sub-device, or on each inverter's) # --------------------------------------------------------------------------- @dataclass(frozen=True) -class SpanPVMetadataRequiredKeysMixin: - """Required keys mixin for PV metadata sensors.""" +class SpanPVMetadataRequiredKeysMixin(FieldPathDeclarationMixin): + """Required keys mixin for PV metadata sensors. - value_fn: Callable[[SpanPanelSnapshot], float | str | None] + The value_fn takes one inverter's snapshot, as the BESS metadata ones take + the battery's, so the same three descriptions serve the Solar card, through + `pv_binding`, and each inverter's own card. + """ + value_fn: Callable[[SpanPVSnapshot], float | str | None] -@dataclass(frozen=True) + +@dataclass(frozen=True, kw_only=True) class SpanPVMetadataSensorEntityDescription( SensorEntityDescription, SpanPVMetadataRequiredKeysMixin ): @@ -435,23 +1015,30 @@ class SpanPVMetadataSensorEntityDescription( ] = ( SpanPVMetadataSensorEntityDescription( key="pv_vendor", + field_path="pv.vendor_name", translation_key="pv_vendor", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda s: s.pv.vendor_name, + value_fn=lambda pv: pv.vendor_name, ), SpanPVMetadataSensorEntityDescription( key="pv_product", + field_path="pv.model", translation_key="pv_product", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda s: s.pv.product_name, + value_fn=lambda pv: pv.model, ), SpanPVMetadataSensorEntityDescription( key="pv_nameplate_capacity", + field_path="pv.nameplate_capacity_w", translation_key="pv_nameplate_capacity", native_unit_of_measurement=UnitOfPower.WATT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda s: s.pv.nameplate_capacity_w, + # The array's DC size as recorded at installation: informational, and + # never a limit on what `pv_power` can read. The name stays + # "Nameplate Capacity" because renaming it would change the entity id + # every new installation derives. + value_fn=lambda pv: pv.nameplate_capacity_w, ), ) @@ -463,6 +1050,7 @@ class SpanPVMetadataSensorEntityDescription( ] = ( SpanPanelDataSensorEntityDescription( key="instantGridPowerW", + field_path="panel.instant_grid_power_w", translation_key="instant_grid_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -472,6 +1060,9 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="feedthroughPowerW", + field_path="panel.feedthrough_power_w", + # Unreliable in the SPAN API; see the module docstring (#234). + entity_registry_enabled_default=False, translation_key="feedthrough_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -484,6 +1075,7 @@ class SpanPVMetadataSensorEntityDescription( # Battery power sensor (conditionally created when BESS is commissioned) BATTERY_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="batteryPowerW", + field_path="panel.power_flow_battery", translation_key="battery_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -495,6 +1087,7 @@ class SpanPVMetadataSensorEntityDescription( # PV power sensor (conditionally created when PV is commissioned) PV_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="pvPowerW", + field_path="panel.power_flow_pv", translation_key="pv_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -506,6 +1099,7 @@ class SpanPVMetadataSensorEntityDescription( # Grid power flow sensor (conditionally created when power-flows data is available) GRID_POWER_FLOW_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="gridPowerFlowW", + field_path="panel.power_flow_grid", translation_key="grid_power_flow", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -517,6 +1111,7 @@ class SpanPVMetadataSensorEntityDescription( # Site power sensor (conditionally created when power-flows data is available) SITE_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="sitePowerW", + field_path="panel.power_flow_site", translation_key="site_power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -525,7 +1120,35 @@ class SpanPVMetadataSensorEntityDescription( value_fn=lambda s: s.power_flow_site if s.power_flow_site is not None else 0.0, ) +# Each meter's Net, declared once: the description's value function and the +# sensor's dip adjustment both read the orientation here. +_MAIN_METER_NET_ENERGY: Final = NetEnergy[SpanPanelSnapshot]( + orientation_of=panel_meter_net_orientation, + consumed=lambda s: s.main_meter_energy_consumed_wh, + produced=lambda s: s.main_meter_energy_produced_wh, +) + +# Through the same path as the others. Its counters are `TOTAL` and never +# compensated, so their offsets, and with them its adjustment, are zero. +_FEEDTHROUGH_NET_ENERGY: Final = NetEnergy[SpanPanelSnapshot]( + orientation_of=panel_meter_net_orientation, + consumed=lambda s: s.feedthrough_energy_consumed_wh, + produced=lambda s: s.feedthrough_energy_produced_wh, +) + +_CIRCUIT_NET_ENERGY: Final = NetEnergy[SpanCircuitSnapshot]( + orientation_of=circuit_net_orientation, + consumed=lambda c: c.consumed_energy_wh, + produced=lambda c: c.produced_energy_wh, +) + # Panel energy sensor definitions +# Net is listed after its Produced and Consumed siblings, and the order is +# load-bearing. Net reads their dip offsets during the same coordinator +# fan-out; listeners run in the order the entities were added, and the +# platform adds them in this order. Listed earlier, Net would add the previous +# update's offsets and show a one-update spike whenever a dip is booked. +# `tests/test_net_energy_fanout.py` pins it. PANEL_ENERGY_SENSORS: tuple[ SpanPanelDataSensorEntityDescription, SpanPanelDataSensorEntityDescription, @@ -536,6 +1159,9 @@ class SpanPVMetadataSensorEntityDescription( ] = ( SpanPanelDataSensorEntityDescription( key="mainMeterEnergyProducedWh", + energy_role=EnergyRole.PRODUCED, + panel_meter=PanelMeter.MAIN_METER, + field_path="panel.main_meter_energy_produced_wh", translation_key="main_meter_produced_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -545,6 +1171,9 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="mainMeterEnergyConsumedWh", + energy_role=EnergyRole.CONSUMED, + panel_meter=PanelMeter.MAIN_METER, + field_path="panel.main_meter_energy_consumed_wh", translation_key="main_meter_consumed_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -554,6 +1183,11 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="feedthroughEnergyProducedWh", + energy_role=EnergyRole.PRODUCED, + panel_meter=PanelMeter.FEEDTHROUGH, + field_path="panel.feedthrough_energy_produced_wh", + # Unreliable in the SPAN API; see the module docstring (#234). + entity_registry_enabled_default=False, translation_key="feedthrough_produced_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -563,6 +1197,11 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="feedthroughEnergyConsumedWh", + energy_role=EnergyRole.CONSUMED, + panel_meter=PanelMeter.FEEDTHROUGH, + field_path="panel.feedthrough_energy_consumed_wh", + # Unreliable in the SPAN API; see the module docstring (#234). + entity_registry_enabled_default=False, translation_key="feedthrough_consumed_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -572,31 +1211,41 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelDataSensorEntityDescription( key="mainMeterNetEnergyWh", + energy_role=EnergyRole.NET, + panel_meter=PanelMeter.MAIN_METER, + net_energy=_MAIN_METER_NET_ENERGY, + derived=DerivedReason.MULTIPLE_FIELDS, translation_key="main_meter_net_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, entity_registry_enabled_default=False, - value_fn=lambda s: ( - (s.main_meter_energy_consumed_wh or 0) - (s.main_meter_energy_produced_wh or 0) - ), + value_fn=_MAIN_METER_NET_ENERGY.value, ), SpanPanelDataSensorEntityDescription( key="feedthroughNetEnergyWh", + energy_role=EnergyRole.NET, + panel_meter=PanelMeter.FEEDTHROUGH, + net_energy=_FEEDTHROUGH_NET_ENERGY, + derived=DerivedReason.MULTIPLE_FIELDS, translation_key="feedthrough_net_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, entity_registry_enabled_default=False, - value_fn=lambda s: ( - (s.feedthrough_energy_consumed_wh or 0) - (s.feedthrough_energy_produced_wh or 0) - ), + value_fn=_FEEDTHROUGH_NET_ENERGY.value, ), ) # Circuit sensor definitions +# Net is listed after its Produced and Consumed siblings, and the order is +# load-bearing. Net reads their dip offsets during the same coordinator +# fan-out; listeners run in the order the entities were added, and the +# platform adds them in this order. Listed earlier, Net would add the previous +# update's offsets and show a one-update spike whenever a dip is booked. +# `tests/test_net_energy_fanout.py` pins it. CIRCUIT_SENSORS: tuple[ SpanPanelCircuitsSensorEntityDescription, SpanPanelCircuitsSensorEntityDescription, @@ -605,20 +1254,24 @@ class SpanPVMetadataSensorEntityDescription( ] = ( SpanPanelCircuitsSensorEntityDescription( key="circuit_power", + field_path="circuit.instant_power_w", name="Power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, device_class=SensorDeviceClass.POWER, value_fn=lambda c: ( - (-c.instant_power_w or 0.0) if c.device_type == "pv" else c.instant_power_w + _negated(c.instant_power_w) if circuit_is_generation(c) else c.instant_power_w ), entity_registry_enabled_default=True, entity_registry_visible_default=True, ), SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_produced", + energy_role=EnergyRole.PRODUCED, + field_path="circuit.produced_energy_wh", name="Produced Energy", + legacy_names=("Energy Produced",), native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, @@ -629,7 +1282,10 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_consumed", + energy_role=EnergyRole.CONSUMED, + field_path="circuit.consumed_energy_wh", name="Consumed Energy", + legacy_names=("Energy Consumed",), native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, @@ -640,16 +1296,16 @@ class SpanPVMetadataSensorEntityDescription( ), SpanPanelCircuitsSensorEntityDescription( key="circuit_energy_net", + energy_role=EnergyRole.NET, + net_energy=_CIRCUIT_NET_ENERGY, + derived=DerivedReason.MULTIPLE_FIELDS, name="Net Energy", + legacy_names=("Energy Net",), native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda c: ( - (c.produced_energy_wh or 0) - (c.consumed_energy_wh or 0) - if c.device_type == "pv" - else (c.consumed_energy_wh or 0) - (c.produced_energy_wh or 0) - ), + value_fn=_CIRCUIT_NET_ENERGY.value, entity_registry_enabled_default=True, entity_registry_visible_default=True, ), @@ -662,13 +1318,13 @@ class SpanPVMetadataSensorEntityDescription( @dataclass(frozen=True) -class SpanEvseRequiredKeysMixin: +class SpanEvseRequiredKeysMixin(FieldPathDeclarationMixin): """Required keys mixin for EVSE sensors.""" value_fn: Callable[[SpanEvseSnapshot], float | str | None] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredKeysMixin): """Describes an EVSE sensor entity.""" @@ -677,16 +1333,30 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK SpanEvseSensorEntityDescription, SpanEvseSensorEntityDescription, SpanEvseSensorEntityDescription, + SpanEvseSensorEntityDescription, ] = ( SpanEvseSensorEntityDescription( key="evse_status", + field_path="evse.status", translation_key="evse_status", device_class=SensorDeviceClass.ENUM, - options=["unknown"], + options=[ + "available", + "charging", + "faulted", + "finishing", + "preparing", + "reserved", + "suspended_ev", + "suspended_evse", + "unavailable", + "unknown", + ], value_fn=lambda e: e.status or "unknown", ), SpanEvseSensorEntityDescription( key="evse_advertised_current", + field_path="evse.advertised_current_a", translation_key="evse_advertised_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, @@ -696,9 +1366,93 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK ), SpanEvseSensorEntityDescription( key="evse_lock_state", + field_path="evse.lock_state", translation_key="evse_lock_state", device_class=SensorDeviceClass.ENUM, - options=["unknown"], + options=["locked", "unlocked", "unknown"], value_fn=lambda e: e.lock_state or "unknown", ), + # The charger's SKU, shaped like `bess_part_number`: build metadata, so + # diagnostic and off by default, and a plain `field_path` because both + # adapters map the property (`evse/part-number` on flat, `info/part-number` + # on v1.0). It was the one unread declaration whose promotion the producible + # gate could demand, and adding the schema_1 metadata row is what demanded it. + SpanEvseSensorEntityDescription( + key="evse_part_number", + field_path="evse.part_number", + translation_key="evse_part_number", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda e: e.part_number, + ), ) + + +def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]: + """Every sensor description, without deduplication. + + A tuple rather than a dict because no one key identifies a description: + `description.key` and `field_path` are different namespaces, and neither is + unique across the whole set — several field paths are read by two + descriptions. Callers key by whichever suits them; see + `sensor_descriptions_by_field_path`. + """ + return ( + *PANEL_DATA_STATUS_SENSORS, + *STATUS_SENSORS, + *UNMAPPED_SENSORS, + *MID_SENSORS, + *BESS_METADATA_SENSORS, + *BESS_TELEMETRY_SENSORS, + *PCS_SENSORS, + *PV_METADATA_SENSORS, + *PANEL_POWER_SENSORS, + *PANEL_ENERGY_SENSORS, + *CIRCUIT_SENSORS, + *EVSE_SENSORS, + *SHED_FORECAST_SENSORS, + BATTERY_SENSOR, + BATTERY_POWER_SENSOR, + PV_POWER_SENSOR, + GRID_POWER_FLOW_SENSOR, + SITE_POWER_SENSOR, + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + MAIN_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_BREAKER_RATING_SENSOR, + ) + + +def sensor_descriptions_by_field_path() -> dict[str, SensorEntityDescription]: + """Every sensor description with a source field, keyed by that field path. + + Keyed by field path because that is how the adapter keys its metadata; + `description.key` is a different namespace and would not line up. + Descriptions that name no field are excluded — they read several fields, or + none, so no single path identifies them. + + A `SCHEMA_CONDITIONAL_FIELD` description does name one and is included. Its + exemption is from the *producible* gate, and the unit its schema declares + for the field is checkable exactly as any other's: the adapter that + produces the row publishes a unit, and this integration's sensor declares + one, and they can disagree. + + A few field paths are read by two descriptions (an unmapped-circuit raw key + and its named-circuit twin), and only the first is kept. That is safe only + while such readers agree on `native_unit_of_measurement`, which is all this + map is consulted for; `test_readers_of_the_same_field_path_agree_on_unit` + pins that rather than leaving it to chance. + + Lives here rather than at the call site so no consumer has to know how a + description declares its field; `field_paths.iter_source_field_declarations` + holds that rule. + """ + by_field_path: dict[str, SensorEntityDescription] = {} + for field_path, description in iter_source_field_declarations(all_sensor_descriptions()): + by_field_path.setdefault(field_path, description) + return by_field_path diff --git a/custom_components/span_panel/sensor_evse.py b/custom_components/span_panel/sensor_evse.py index f45d6a66..7907f552 100644 --- a/custom_components/span_panel/sensor_evse.py +++ b/custom_components/span_panel/sensor_evse.py @@ -11,13 +11,10 @@ from .helpers import build_evse_unique_id_for_entry, resolve_evse_display_suffix from .sensor_base import SpanSensorBase from .sensor_definitions import SpanEvseSensorEntityDescription -from .util import evse_device_info +from .util import EMPTY_EVSE, evse_device_info _LOGGER: logging.Logger = logging.getLogger(__name__) -# Fallback EVSE snapshot used when the EVSE disappears mid-session -_EMPTY_EVSE = SpanEvseSnapshot(node_id="", feed_circuit_id="") - class SpanEvseSensor(SpanSensorBase[SpanEvseSensorEntityDescription, SpanEvseSnapshot]): """EVSE (EV charger) sensor entity.""" @@ -42,11 +39,15 @@ def __init__( ) panel_identifier = snapshot.serial_number - evse = snapshot.evse.get(evse_id, _EMPTY_EVSE) + evse = snapshot.evse.get(evse_id, EMPTY_EVSE) use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers) self._attr_device_info = evse_device_info( - panel_identifier, evse, panel_name, display_suffix + panel_identifier, + evse, + panel_name, + display_suffix, + panel_device_id=data_coordinator.config_entry.runtime_data.panel_device_id, ) def _generate_unique_id( @@ -61,12 +62,6 @@ def _generate_unique_id( self._device_name, ) - def _generate_friendly_name( - self, snapshot: SpanPanelSnapshot, description: SpanEvseSensorEntityDescription - ) -> str: - """Generate friendly name for EVSE sensors.""" - return str(description.name) - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanEvseSnapshot: """Get the EVSE snapshot for this sensor's charger.""" - return snapshot.evse.get(self._evse_id, _EMPTY_EVSE) + return snapshot.evse.get(self._evse_id, EMPTY_EVSE) diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py index da07c49a..a00b750e 100644 --- a/custom_components/span_panel/sensor_panel.py +++ b/custom_components/span_panel/sensor_panel.py @@ -2,16 +2,23 @@ from __future__ import annotations -import logging -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.typing import UNDEFINED -from span_panel_api import SpanBatterySnapshot, SpanPanelSnapshot +from span_panel_api import ( + SpanBatterySnapshot, + SpanMidSnapshot, + SpanPanelSnapshot, + SpanPcsSnapshot, + SpanPVSnapshot, +) from .coordinator import SpanPanelCoordinator +from .energy_orientation import EnergyBinding from .helpers import ( build_bess_unique_id_for_entry, + build_mid_unique_id_for_entry, + build_pv_inverter_unique_id_for_entry, construct_panel_unique_id_for_entry, construct_synthetic_unique_id_for_entry, get_panel_entity_suffix, @@ -19,18 +26,91 @@ from .sensor_base import SpanEnergySensorBase, SpanSensorBase from .sensor_definitions import ( SpanBessMetadataSensorEntityDescription, + SpanMidSensorEntityDescription, SpanPanelBatterySensorEntityDescription, SpanPanelDataSensorEntityDescription, SpanPanelStatusSensorEntityDescription, + SpanPcsSensorEntityDescription, SpanPVMetadataSensorEntityDescription, + SpanShedForecastSensorEntityDescription, ) - -_LOGGER: logging.Logger = logging.getLogger(__name__) +from .util import EMPTY_PV + +if TYPE_CHECKING: + from .pv_binding import PvBinding + + +def _grid_forming_device_name(snapshot: SpanPanelSnapshot) -> str | None: + """Return the forming device's readable name, when the library knows it. + + The state stays the source *class* — `GRID`, `BATTERY`, `PV` — because that is the + closed enum automations compare against and it must not change. v1.0 additionally + knows *which* device, which flat never did, and that belongs here: an attribute + refines a value already on screen without adding entity-list noise, and cannot break + an automation that never referenced it. + + Deliberately the display name and not the wire id. `sim-40t-001-SIM-BESS-40T-001` is + a Homie device id, not a Home Assistant one, and an opaque string on a dashboard is + worse than none. The id stays in the snapshot for correlation and diagnostics. + + DUAL-SCHEMA: `None` on any flat panel, which publishes no MID, so the attribute simply + does not appear there. The library field is always present now that the pin is + 3.0.0b3 — the conditional is about what the *panel* publishes, not about which + library is installed, and the earlier `getattr` guarding the latter has gone. + """ + mid = snapshot.mid + if mid is None: + return None + return mid.grid_forming_device_name + + +def _shed_policy_attributes(snapshot: SpanPanelSnapshot) -> dict[str, Any]: + """Render the shed policy for a person rather than as a JSON blob. + + `shed/policy` is one `json` property carrying an algorithm name and its + parameters, and the two SoC thresholds inside it are the numbers that make + the panel's shedding behaviour predictable -- what state of charge sheds the + SOC_THRESHOLD circuits, and what state of charge brings them back. + + **The raw document survives whenever the parse did not fully succeed.** The + property's `$format` schema is versioned in its own `$id`, which is the + publisher saying a different algorithm may arrive; when one does, the + library reports its name and no thresholds, and showing the document beside + the name is strictly more than showing nothing. A user can read it; an + exception would have taken the sensor down instead. + + Absent members are omitted rather than rendered as `None`, matching the + forecast sensors: an empty attribute reads as a value the panel failed to + produce, a missing one as firmware that does not carry it. + """ + attributes: dict[str, Any] = {} + if snapshot.shed_policy_algorithm is not None: + attributes["shed_algorithm"] = snapshot.shed_policy_algorithm + if snapshot.shed_soc_threshold_shed_percent is not None: + attributes["soc_threshold_shed"] = snapshot.shed_soc_threshold_shed_percent + if snapshot.shed_soc_threshold_release_percent is not None: + attributes["soc_threshold_release"] = snapshot.shed_soc_threshold_release_percent + thresholds_complete = ( + snapshot.shed_soc_threshold_shed_percent is not None + and snapshot.shed_soc_threshold_release_percent is not None + ) + if not thresholds_complete and snapshot.shed_policy is not None: + attributes["shed_policy"] = snapshot.shed_policy + return attributes class SpanPanelPanelStatus(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelSnapshot]): """Span Panel data status sensor entity.""" + # `_residual_field_paths` stays empty on purpose. The four `panel.shed_*` + # policy fields read for `dsm_state`'s attributes are not declarable here: + # everything declared on an entity flows into `declared_field_paths()`, + # where the producible gate demands both adapters emit it, and no adapter + # carries a row for any of them -- flat has no `shed` node at all, and a + # JSON policy document has no unit surface for a schema_1 row to describe. + # They are enumerated in `RESIDUAL_EXEMPT_PATHS` as `Producibility.NEITHER` + # instead, beside the shed-forecast refinements and the PCS attributes. + def __init__( self, data_coordinator: SpanPanelCoordinator, @@ -50,24 +130,196 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: + """Get the data source for the panel data status sensor.""" + return snapshot + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """The shed policy, on the sensor that says whether shedding is in force. + + `dsm_state` is the entity a user already looks at to know whether the + panel is on grid or off it, and the policy is what says what happens + next. Attached to that one description rather than to every sensor this + class renders, the same way `SpanPanelStatus` attaches the grid-forming + device name to `grid_forming_entity` alone. + """ + if self.entity_description.key != "dsm_state": + return None + snapshot = self.coordinator.data + if snapshot is None: + return None + return _shed_policy_attributes(snapshot) or None + + +class SpanShedForecastSensor( + SpanSensorBase[SpanShedForecastSensorEntityDescription, SpanPanelSnapshot] +): + """One of the two backup-planning estimates, with its refinements attached. + + Created only where the panel publishes the estimate this sensor reads, so a + panel with no `shed-forecast` node — every flat panel, and any v1.0 panel + whose firmware omits the capability — gets no entity rather than one stuck + at unknown. See `create_shed_forecast_sensors`. + """ + + # `_residual_field_paths` stays empty on purpose. The attribute reads below + # are not declarable: neither adapter carries a metadata row for those three + # fields, so declaring them here would put them in `declared_field_paths()` + # where the producible gate rejects anything one adapter cannot emit. They + # are enumerated in `RESIDUAL_EXEMPT_PATHS` as `Producibility.NEITHER` + # instead, which is where the `mid.*` attribute reads live for the same + # reason. + + def __init__( self, + data_coordinator: SpanPanelCoordinator, + description: SpanShedForecastSensorEntityDescription, snapshot: SpanPanelSnapshot, - description: SpanPanelDataSensorEntityDescription, + ) -> None: + """Initialize the shed-forecast sensor, keeping a typed handle on its description. + + `SensorEntity.entity_description` is annotated as the base + `SensorEntityDescription`, so reading the two extra members off it would + need either a narrowing override — which mypy rejects on a mutable + attribute — or a `getattr`, which is the same thing with the check + removed. Keeping the description under a name of our own is what makes + `full_charge_fn` and `full_charge_attribute` statically checked; the same + move `SpanPanelPowerSensor` makes for `_description_key`. + """ + super().__init__(data_coordinator, description, snapshot) + self._forecast = description + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanShedForecastSensorEntityDescription, ) -> str: - """Generate friendly name for panel data sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Sensor" + """Generate unique ID for a shed-forecast sensor.""" + return construct_panel_unique_id_for_entry( + self.coordinator, snapshot, description.key, self._device_name + ) def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: - """Get the data source for the panel data status sensor.""" + """Get the data source for the shed-forecast sensor.""" return snapshot + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """The hypothetical-full-charge twin, and the estimate's confidence. + + Both are omitted when the panel does not publish them, rather than + appearing as `None`. An attribute that is present and empty reads as a + reading the panel failed to produce; an absent one reads as a firmware + that does not carry it, which is what this is. + + Which twin belongs to this sensor comes from the description, not from a + comparison against `key` — the pairing is stated once, where the two + readers sit beside each other. + """ + snapshot = self.coordinator.data + if snapshot is None: + return None + + attributes: dict[str, Any] = {} + + full_charge = self._forecast.full_charge_fn(snapshot) + if full_charge is not None: + attributes[self._forecast.full_charge_attribute] = full_charge + + confidence = snapshot.shed_forecast_confidence + if confidence is not None: + attributes["forecast_confidence"] = confidence + + return attributes or None + + +class SpanPcsSensor(SpanSensorBase[SpanPcsSensorEntityDescription, SpanPcsSnapshot]): + """A reading from the enclosure's Power Control System. + + Created only where the panel declares a `pcs` node, so a panel that runs no + PCS — every flat panel, and any v1.0 firmware without the capability — gets + no entity rather than one stuck at unknown. See `create_pcs_sensors`. + """ + + # `_residual_field_paths` stays empty on purpose. The thirteen fields + # `pcs_arbitration_attributes` reads are not declarable here: no adapter + # carries a metadata row for them, so declaring them would put them in + # `declared_field_paths()` where the producible gate rejects anything one + # adapter cannot emit. They are enumerated in `RESIDUAL_EXEMPT_PATHS` as + # `Producibility.NEITHER` instead, beside the shed-forecast refinements and + # the `mid.*` attribute reads, which are outside the gate for the same + # reason. + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPcsSensorEntityDescription, + snapshot: SpanPanelSnapshot, + ) -> None: + """Initialize a PCS sensor, keeping a typed handle on its description. + + `SensorEntity.entity_description` is annotated as the base + `SensorEntityDescription`, so reading `attributes_fn` off it would need a + narrowing override mypy rejects, or a `getattr` that removes the check. + The same move `SpanShedForecastSensor` makes for its twin readers. + """ + super().__init__(data_coordinator, description, snapshot) + self._pcs = description + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanPcsSensorEntityDescription, + ) -> str: + """Generate unique ID for a PCS sensor.""" + return construct_panel_unique_id_for_entry( + self.coordinator, snapshot, description.key, self._device_name + ) + + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPcsSnapshot: + """Get the data source for the PCS sensor. + + The PCS is optional, so a snapshot without one has no data source. + Entities are created only when `has_pcs` is true, and a panel that stops + publishing the node makes them unknown rather than reaching this — the + same contract `SpanMidSensor` has. + """ + pcs = snapshot.pcs + if pcs is None: + raise ValueError("PCS sensor asked for a data source on a snapshot with no PCS") + return pcs + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """The arbitration inputs behind this sensor's reading, where it has any. + + Which attributes belong to which sensor comes from the description, not + from a comparison against `key`: `pcs_binding_constraint` publishes none + and `pcs_import_limit` publishes twelve, and stating that as data is what + keeps a rename from silently moving them. + + Individually omitted when the panel does not publish them — three of the + four constraint classes are `MAY`, so an absent family is conformant + firmware rather than a reading that failed. + """ + snapshot = self.coordinator.data + if snapshot is None or snapshot.pcs is None: + return None + + return self._pcs.attributes_fn(snapshot.pcs) or None + class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription, SpanPanelSnapshot]): """Span Panel hardware status sensor entity.""" + # `_residual_field_paths` stays empty on purpose. `panel.wifi_ssid` was + # declared here while this sensor rendered the SSID; the read moved to + # `SpanPanelWifiLinkBinarySensor` and the declaration went with it, because + # the declaration exists to let a Repair name the entity that made the read. + # `panel.panel_size` is not declared for the older reason: no adapter + # produces it, so it is an entry in `RESIDUAL_EXEMPT_PATHS` instead. + def __init__( self, data_coordinator: SpanPanelCoordinator, @@ -87,23 +339,31 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelStatusSensorEntityDescription, - ) -> str: - """Generate friendly name for panel status sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Status" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: """Get the data source for the panel status sensor.""" return snapshot @property def extra_state_attributes(self) -> dict[str, Any] | None: - """Return additional state attributes for the software version sensor.""" + """Return additional state attributes for the software version sensor. + + **No `wifi_ssid` here. It moved to the Wi-Fi Link binary sensor and is + not coming back; do not restore it "for compatibility".** A network name + on a firmware-version sensor was incoherent — it only ever sat here + because `panel_size` was already occupying this attribute block — and the + entity that reports whether Wi-Fi is up is the one that should report + which network it is up on. + + The compatibility argument for keeping a copy does not hold up. At v2.0.8 + `STATUS_SENSORS` held four descriptions, so the attribute appeared on + four sensors; the other three have since moved to + `SpanPanelPanelStatus`, which narrowed it to this one sensor without + anybody recording that it had happened. This is that narrowing finished + and written down rather than half-done and undocumented. + `test_the_ssid_moved_off_the_software_version_sensor` pins the absence. + + `panel_size` is untouched and stays here. + """ if not self.coordinator.data: return None @@ -111,8 +371,11 @@ def extra_state_attributes(self) -> dict[str, Any] | None: attributes: dict[str, Any] = {} attributes["panel_size"] = snapshot.panel_size - if snapshot.wifi_ssid is not None: - attributes["wifi_ssid"] = snapshot.wifi_ssid + + if self.entity_description.key == "grid_forming_entity": + forming = _grid_forming_device_name(snapshot) + if forming is not None: + attributes["grid_forming_device"] = forming return attributes or None @@ -145,23 +408,97 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelBatterySensorEntityDescription, - ) -> str: - """Generate friendly name for battery sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Battery" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanBatterySnapshot: """Get the data source for the battery sensor.""" return snapshot.battery +_GRID_POWER_KEY = "instantGridPowerW" +"""The one power sensor whose name is conditional on topology. + +This class backs four sensors -- grid, feedthrough, battery and PV -- and only +the grid one reads a meter whose meaning depends on where the panel sits. +""" + +_SERVICE_ENTRANCE_ADAPTER = "schema_1" +"""The adapter key whose mapper resolves `lugs_at_service_entrance`. + +`SpanMqttClient.schema_major` reports the running adapter's key, and only the +parent/child one answers this question: it reads the upstream lugs' own +`connection/fed-by-device-id` and sets the field from whether that property is +there. The flat adapter contains no reference to the field at all. + +**Why this is asked of the adapter rather than of the field metadata**, which is +how every other "did the adapter produce this" question in this integration is +settled (`schema_validation`, `SpanPanelEntity._reads_an_unresolved_field`). +That machinery rests on the adapter classifying absence, and here it cannot: a +metadata row states a *reading's* unit and datatype, and the property behind +this field is topology rather than a reading, so schema_1 lists it in +`_CONSUMED_WITHOUT_A_ROW` and emits no row for it. Neither adapter publishes +`panel.lugs_at_service_entrance`, so the metadata map is silent for both the +panel that resolved the field and the panel that never looked -- exactly the +distinction that has to be drawn. + +**This is a workaround for a library type, and it is meant to be deleted.** +`SpanPanelSnapshot.lugs_at_service_entrance` is a plain `bool` defaulting to +True, alone among the snapshot's conditional members in having no `None` to mean +"not answered". Once span-panel-api makes it `bool | None` (wanted for 3.1.1), +the honest test is `is not None` on the value itself, this constant goes, and the +integration stops needing to know which adapter is running -- which it knows +nowhere else, deliberately. +""" + + +def _service_entrance_was_read(coordinator: SpanPanelCoordinator) -> bool: + """Whether `lugs_at_service_entrance` is a reading rather than a default. + + The attribute exists to tell a topology from a fault, so publishing it where + the value came from a dataclass default answers a question the panel was + never asked -- and answers it `True`, which is exactly the "your two grid + figures agree" reading a user would act on. A flat panel with a BESS ahead + of its lugs is the changelog's own example of the topology this attribute is + for, and it is the one install that would be told the opposite. + + Omission rather than a third value: nothing renders a missing attribute, so + a flat panel looks exactly as it did before the attribute existed, while an + `unknown` string would be a new row on a dashboard meaning "ignore me". + + The test is where the value came from and never what it is. Suppressing + `True` as "probably a default" would silence the real reading a panel at the + service entrance publishes, which is the same mistake in the other + direction. + """ + return coordinator.client.schema_major == _SERVICE_ENTRANCE_ADAPTER + + class SpanPanelPowerSensor(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelSnapshot]): - """Panel power sensor with calculated amperage attribute.""" + """Panel power sensor with calculated amperage attribute. + + **The grid sensor carries `at_service_entrance`.** It reads the upstream lugs' + meter, which is grid flow only where those lugs are the utility connection + point. A BESS wired ahead of the main lugs, or a panel fed by another panel, + leaves it metering that panel's own feed instead, and `power_flow_grid` -- + the `Grid Power Flow` sensor -- is then the site-level figure. Both readings + are correct; they simply stop being the same number. + + That disagreement is what this attribute exists for. Someone whose two grid + figures differ has no way to tell a topology from a fault, and the answer now + sits on the sensor they are already looking at. + + Published only where it is a reading -- see `_service_entrance_was_read`. An + answer to a question the panel was never asked is worse here than no answer, + because the value a consumer would get is the reassuring one. + + An attribute rather than an entity, deliberately. Topology is static -- a + panel's position in a chain does not change without an electrician -- so a + binary sensor would be a permanent row recording one unchanging boolean into + the database forever. It is also additive on an entity that already exists, + so it reaches an upgraded install without touching the registry: no + `entity_id`, no `unique_id`, no `state_class`, no statistics. + + Not a Repair, for the reason the Repairs list means something: nothing is + broken and there is nothing to act on. The panel is wired the way it is wired. + """ def __init__( self, @@ -188,16 +525,6 @@ def _generate_unique_id( self.coordinator, snapshot, entity_suffix, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelDataSensorEntityDescription, - ) -> str: - """Generate friendly name for panel power sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Power" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: """Get the data source for the panel power sensor.""" return snapshot @@ -223,6 +550,11 @@ def extra_state_attributes(self) -> dict[str, Any] | None: else: attributes["amperage"] = 0.0 + if self._description_key == _GRID_POWER_KEY and _service_entrance_was_read( + self.coordinator + ): + attributes["at_service_entrance"] = self.coordinator.data.lugs_at_service_entrance + return attributes @@ -237,7 +569,14 @@ def __init__( description: SpanPanelDataSensorEntityDescription, snapshot: SpanPanelSnapshot, ) -> None: - """Initialize the panel energy sensor.""" + """Initialize the panel energy sensor, bound to the meter its description declares.""" + self._bind_energy( + EnergyBinding( + meter=description.panel_meter, + role=description.energy_role, + net=description.net_energy, + ) + ) super().__init__(data_coordinator, description, snapshot) def _generate_unique_id( @@ -251,16 +590,6 @@ def _generate_unique_id( self.coordinator, snapshot, entity_suffix, self._device_name ) - def _generate_friendly_name( - self, - snapshot: SpanPanelSnapshot, - description: SpanPanelDataSensorEntityDescription, - ) -> str: - """Generate friendly name for panel energy sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "Energy" - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: """Get the data source for the panel energy sensor.""" return snapshot @@ -304,34 +633,83 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanBatterySnapshot: + """Get the data source for the BESS metadata sensor.""" + return snapshot.battery + + +class SpanMidSensor(SpanSensorBase[SpanMidSensorEntityDescription, SpanMidSnapshot]): + """A sensor on the Microgrid Interconnect Device sub-device.""" + + def __init__( self, + data_coordinator: SpanPanelCoordinator, + description: SpanMidSensorEntityDescription, snapshot: SpanPanelSnapshot, - description: SpanBessMetadataSensorEntityDescription, + device_info_override: DeviceInfo, + ) -> None: + """Initialize the MID sensor.""" + super().__init__(data_coordinator, description, snapshot) + self._attr_device_info = device_info_override + + def _generate_unique_id( + self, + snapshot: SpanPanelSnapshot, + description: SpanMidSensorEntityDescription, ) -> str: - """Generate friendly name for BESS metadata sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "BESS Sensor" + """Generate unique ID for MID sensors.""" + return build_mid_unique_id_for_entry( + self.coordinator, snapshot, description.key, self._device_name + ) - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanBatterySnapshot: - """Get the data source for the BESS metadata sensor.""" - return snapshot.battery + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanMidSnapshot: + """Get the data source for the MID sensor. + The MID is optional, so a snapshot without one has no data source. Entities are + only created when `has_mid` is true, and a panel that stops publishing its MID + makes them unavailable rather than reaching this. + """ + mid = snapshot.mid + if mid is None: + raise ValueError("MID sensor asked for a data source on a snapshot with no MID") + return mid -class SpanPVMetadataSensor( - SpanSensorBase[SpanPVMetadataSensorEntityDescription, SpanPanelSnapshot] -): - """PV metadata sensor entity on the main panel device.""" + +class SpanPVMetadataSensor(SpanSensorBase[SpanPVMetadataSensorEntityDescription, SpanPVSnapshot]): + """PV metadata sensor entity on the PV sub-device, for a panel with one inverter. + + On the panel's own card until the inverter got one of its own, which put the + inverter's vendor and model beside the *panel's* vendor and model on the card + whose job is saying which enclosure this is. + + The unique_id stays the panel-scoped one `construct_panel_unique_id_for_entry` + has always built, because a unique_id is an identity and these are the same + three entities they were. Only the device they hang off changes, which is a + registry update Home Assistant performs itself when the entity re-registers. + + The `entity_id` is not touched either way. An installation that already has + these three keeps the panel-scoped ids it has, because the registry never + renames an entity it already knows; a new one gets the id Home Assistant + derives from the inverter's device name. That asymmetry is intended -- see + `test_pv_device.py`. + + It reads the inverter `pv_binding` binds the Solar card to, or the + inverters together. Every other inverter gets `SpanPVInverterSensor`. + """ def __init__( self, data_coordinator: SpanPanelCoordinator, description: SpanPVMetadataSensorEntityDescription, snapshot: SpanPanelSnapshot, + device_info_override: DeviceInfo, + identity: PvBinding, ) -> None: """Initialize the PV metadata sensor.""" + # Set before the base initializer, so nothing it runs finds the entity without its source. + self._identity = identity super().__init__(data_coordinator, description, snapshot) + self._attr_device_info = device_info_override def _generate_unique_id( self, @@ -343,16 +721,45 @@ def _generate_unique_id( self.coordinator, snapshot, description.key, self._device_name ) - def _generate_friendly_name( + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPVSnapshot: + """Get the data source for the PV metadata sensor.""" + return self._identity.source(snapshot) + + +class SpanPVInverterSensor(SpanSensorBase[SpanPVMetadataSensorEntityDescription, SpanPVSnapshot]): + """One inverter's metadata sensor, for an inverter the Solar card does not read. + + The per-inverter counterpart of `SpanPVMetadataSensor`, built from the same + descriptions and shaped like `SpanEvseSensor`: the unique id carries the + inverter's `pv_inverters` key, and the entity sits on that inverter's card. + An inverter that leaves the snapshot reads as `EMPTY_PV`, as a departed + charger reads as `EMPTY_EVSE`. + """ + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPVMetadataSensorEntityDescription, + snapshot: SpanPanelSnapshot, + inverter_key: str, + device_info_override: DeviceInfo, + ) -> None: + """Initialize the inverter sensor.""" + # Before the base initializer, which builds the unique id from it. + self._inverter_key = inverter_key + super().__init__(data_coordinator, description, snapshot) + self._attr_device_info = device_info_override + + def _generate_unique_id( self, snapshot: SpanPanelSnapshot, description: SpanPVMetadataSensorEntityDescription, ) -> str: - """Generate friendly name for PV metadata sensors.""" - if description.name is not None and description.name is not UNDEFINED: - return str(description.name) - return "PV Sensor" + """Generate unique ID from the inverter's key.""" + return build_pv_inverter_unique_id_for_entry( + self.coordinator, snapshot, self._inverter_key, description.key, self._device_name + ) - def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot: - """Get the data source for the PV metadata sensor.""" - return snapshot + def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPVSnapshot: + """Get this inverter's snapshot.""" + return snapshot.pv_inverters.get(self._inverter_key, EMPTY_PV) diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py index 0500424b..2518cc90 100644 --- a/custom_components/span_panel/services.py +++ b/custom_components/span_panel/services.py @@ -2,16 +2,37 @@ from __future__ import annotations +import asyncio import logging -from typing import TYPE_CHECKING, cast -from homeassistant.const import CONF_HOST +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.hass_dict import HassKey +from homeassistant.util.json import JsonObjectType, JsonValueType +import httpx +from span_panel_api import rotate_passphrase +from span_panel_api.exceptions import ( + SpanPanelAPIError, + SpanPanelAuthError, + SpanPanelConnectionError, + SpanPanelInsufficientPrivilegeError, + SpanPanelServerError, + SpanPanelTimeoutError, + SpanPanelTLSVerificationError, +) import voluptuous as vol -from .const import DEFAULT_GRAPH_HORIZON, DOMAIN, VALID_GRAPH_HORIZONS +from .config_flow_validation import PanelCaUnusableError, panel_rest_transport +from .const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_PASSWORD, + DEFAULT_GRAPH_HORIZON, + DOMAIN, + VALID_GRAPH_HORIZONS, +) from .current_monitor import CurrentMonitor from .frontend import FavoriteKind, async_get_favorites, async_set_favorite from .graph_horizon import GraphHorizonManager @@ -22,17 +43,66 @@ SPIKE_THRESHOLD_PCT, WINDOW_DURATION_M, ) - -if TYPE_CHECKING: - from homeassistant.config_entries import ConfigEntry - - from . import SpanPanelRuntimeData +from .runtime import SpanPanelRuntimeData, loaded_runtime_data _LOGGER = logging.getLogger(__name__) # Map internal device_type values to external manifest format _DEVICE_TYPE_MAP: dict[str, str] = {"bess": "battery"} +# Waits between reload attempts after a rotation, about a minute in all. The +# broker may not accept the new password the moment the rotation returns, so a +# refusal inside this window is not yet a failure. +_ROTATION_RECONNECT_DELAYS_S: tuple[float, ...] = (1.0, 2.0, 4.0, 8.0, 15.0, 30.0) + +# Entry ids whose rotation is still reconnecting. A set suffices because +# `_ROTATION_LOCKS` lets only one rotation per entry run at a time. +_ROTATIONS_IN_PROGRESS: HassKey[set[str]] = HassKey(f"{DOMAIN}_rotations_in_progress") + +# One lock per entry id, serializing `rotate_credentials`. Kept in hass.data +# rather than runtime data, which the rotation's own reload rebuilds. +_ROTATION_LOCKS: HassKey[dict[str, asyncio.Lock]] = HassKey(f"{DOMAIN}_rotation_locks") + + +def rotation_in_progress(hass: HomeAssistant, entry_id: str) -> bool: + """Return whether `rotate_credentials` is reconnecting this entry. + + While it is, setup reports a broker credential refusal as not ready rather + than starting a reauth flow, and the options listener leaves the reload to + the service. + """ + return entry_id in hass.data.get(_ROTATIONS_IN_PROGRESS, set()) + + +def _rotation_outcome_unknown(host: str) -> HomeAssistantError: + """Return the error for a rotation whose outcome the panel did not report.""" + return HomeAssistantError( + f"The SPAN Panel at {host} did not report the outcome of the credential " + "rotation. The panel passphrase and broker password may have changed. " + "Run the rotation again to get a passphrase you know. If the panel " + "refuses that rotation, reauthenticate the integration, using proof of " + "proximity if the old passphrase is no longer accepted.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_outcome_unknown", + translation_placeholders={"host": host}, + ) + + +def _rotation_may_have_reached_panel( + err: SpanPanelConnectionError | SpanPanelTimeoutError, +) -> bool: + """Return whether a transport failure may have come after the PUT was sent. + + Only a failure to connect, or a certificate the pinned CA rejects, proves + the request never left. A read timeout or a dropped connection can follow + a rotation the panel has already applied. + """ + if isinstance(err, SpanPanelTLSVerificationError): + return False + return not isinstance( + err.__cause__, (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) + ) + def _async_register_services(hass: HomeAssistant) -> None: """Register domain-level services (called once per HA instance).""" @@ -41,8 +111,6 @@ async def async_handle_export_manifest( _call: ServiceCall, ) -> ServiceResponse: """Export circuit topology manifest for all configured SPAN panels.""" - from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel - if not hass.config_entries.async_loaded_entries(DOMAIN): raise ServiceValidationError( "No SPAN panel configuration entries are loaded. " @@ -52,19 +120,18 @@ async def async_handle_export_manifest( ) entity_reg = er.async_get(hass) - panels = [] + panels: list[JsonValueType] = [] for entry in hass.config_entries.async_loaded_entries(DOMAIN): - if not hasattr(entry, "runtime_data") or not isinstance( - entry.runtime_data, SpanPanelRuntimeData - ): + runtime_data = loaded_runtime_data(entry) + if runtime_data is None: continue - snapshot = entry.runtime_data.coordinator.data + snapshot = runtime_data.coordinator.data if snapshot is None: continue serial = snapshot.serial_number - circuits = [] + circuits: list[JsonValueType] = [] for circuit_id, circuit in snapshot.circuits.items(): if circuit_id.startswith("unmapped_tab_"): @@ -99,7 +166,7 @@ async def async_handle_export_manifest( } ) - return cast(ServiceResponse, {"panels": panels}) + return {"panels": panels} hass.services.async_register( DOMAIN, @@ -191,15 +258,12 @@ def _get_runtime_data( When config_entry_id is provided, returns that specific entry. Otherwise falls back to the first loaded entry. """ - from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel - for entry in hass.config_entries.async_loaded_entries(DOMAIN): - if not hasattr(entry, "runtime_data") or not isinstance( - entry.runtime_data, SpanPanelRuntimeData - ): + runtime_data = loaded_runtime_data(entry) + if runtime_data is None: continue if config_entry_id is None or entry.entry_id == config_entry_id: - return entry.runtime_data, entry + return runtime_data, entry return None def _get_monitor( @@ -278,15 +342,20 @@ async def async_handle_get_monitoring_status( entry_id = call.data.get("config_entry_id") result = _get_runtime_data(entry_id) if result is None: - return cast(ServiceResponse, {"enabled": False}) + return {"enabled": False} runtime_data, _entry = result monitor = runtime_data.coordinator.current_monitor if monitor is None: - return cast(ServiceResponse, {"enabled": False}) - status = monitor.get_monitoring_status() + return {"enabled": False} + status: JsonObjectType = monitor.get_monitoring_status() status["enabled"] = True - status["global_settings"] = monitor.get_global_settings() - return cast(ServiceResponse, status) + # `MonitoringSettings` is `dict[str, int | bool | str]`, which `dict` is + # invariant in, so it needs widening rather than casting to sit in a + # JSON response. `get_global_settings` builds a fresh dict per call, so + # the copy costs nothing. + global_settings: JsonObjectType = dict(monitor.get_global_settings()) + status["global_settings"] = global_settings + return status hass.services.async_register( DOMAIN, @@ -371,16 +440,13 @@ def _get_horizon_manager( call: ServiceCall, ) -> GraphHorizonManager: """Find the GraphHorizonManager for the given entry.""" - from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel - entry_id = call.data.get("config_entry_id") for entry in hass.config_entries.async_loaded_entries(DOMAIN): - if not hasattr(entry, "runtime_data") or not isinstance( - entry.runtime_data, SpanPanelRuntimeData - ): + runtime_data = loaded_runtime_data(entry) + if runtime_data is None: continue if entry_id is None or entry.entry_id == entry_id: - mgr = entry.runtime_data.coordinator.graph_horizon_manager + mgr = runtime_data.coordinator.graph_horizon_manager if mgr is not None: return mgr raise ServiceValidationError( @@ -419,19 +485,17 @@ async def async_handle_clear_subdevice_graph_horizon(call: ServiceCall) -> None: async def async_handle_get_graph_settings( call: ServiceCall, ) -> ServiceResponse: - from . import SpanPanelRuntimeData # pylint: disable=import-outside-toplevel - entry_id = call.data.get("config_entry_id") for entry in hass.config_entries.async_loaded_entries(DOMAIN): - if not hasattr(entry, "runtime_data") or not isinstance( - entry.runtime_data, SpanPanelRuntimeData - ): + runtime_data = loaded_runtime_data(entry) + if runtime_data is None: continue if entry_id is None or entry.entry_id == entry_id: - mgr = entry.runtime_data.coordinator.graph_horizon_manager + mgr = runtime_data.coordinator.graph_horizon_manager if mgr is not None: - return cast(ServiceResponse, mgr.get_all_settings()) - return cast(ServiceResponse, {"global_horizon": DEFAULT_GRAPH_HORIZON, "circuits": {}}) + settings: JsonObjectType = mgr.get_all_settings() + return settings + return {"global_horizon": DEFAULT_GRAPH_HORIZON, "circuits": {}} hass.services.async_register( DOMAIN, @@ -513,8 +577,10 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin ``kind`` is ``"circuits"`` or ``"sub_devices"``. For circuits, ``target_id`` is the panel-local circuit uuid (extracted from the - entity's unique_id). For sub-devices, ``target_id`` is the HA - device id of the BESS/EVSE; the panel id walks up via ``via_device_id``. + entity's unique_id). For sub-devices, ``target_id`` is the HA device id + of the sub-device; the panel id walks up via ``via_device_id``. Nothing + here enumerates the kinds, so a new one -- the PV inverter most recently + -- is favouritable the day its device exists. Failure paths use distinct translation keys so users see the actual reason their pick was rejected. @@ -539,7 +605,10 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin device_registry = dr.async_get(hass) device_entry = device_registry.async_get(entry.device_id) - if device_entry is None or not any( + # From 2026.9 `async_get` can answer with a child device, a part of + # another device with no `via_device_id`. SPAN registers none, so a + # child device is not a SPAN Panel device either. + if not isinstance(device_entry, dr.DeviceEntry) or not any( domain == DOMAIN for domain, _ in device_entry.identifiers ): raise ServiceValidationError( @@ -551,7 +620,7 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin # Resolve the panel device id. Sub-devices register with # via_device_id; main panels never do, so via_device_id presence is a - # reliable discriminator (BESS / EVSE today) and we walk up to the + # reliable discriminator whatever kinds exist, and we walk up to the # parent SPAN Panel here. if device_entry.via_device_id is not None: parent = device_registry.async_get(device_entry.via_device_id) @@ -603,21 +672,37 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin translation_placeholders={"entity_id": entity_id}, ) + def _favorites_response(favorites: dict[str, dict[str, list[str]]]) -> ServiceResponse: + """Wrap the favorites map in the shape all three favorites services return. + + The map is rebuilt rather than handed over as-is because `dict` and + `list` are invariant: the storage type says exactly `list[str]`, and a + JSON response value says `list[JsonValueType]`, so the two are not the + same type however identical the contents. Rebuilding also means the + response cannot alias the caller's map. + """ + payload: JsonObjectType = { + panel_device_id: {kind: list(target_ids) for kind, target_ids in kinds.items()} + for panel_device_id, kinds in favorites.items() + } + return {"favorites": payload} + async def async_handle_get_favorites(_call: ServiceCall) -> ServiceResponse: - favorites = await async_get_favorites(hass) - return cast(ServiceResponse, {"favorites": favorites}) + return _favorites_response(await async_get_favorites(hass)) async def async_handle_add_favorite(call: ServiceCall) -> ServiceResponse: entity_id = call.data["entity_id"] panel_device_id, kind, target_id = _resolve_entity_to_favorite_target(entity_id) - favorites = await async_set_favorite(hass, panel_device_id, kind, target_id, True) - return cast(ServiceResponse, {"favorites": favorites}) + return _favorites_response( + await async_set_favorite(hass, panel_device_id, kind, target_id, True) + ) async def async_handle_remove_favorite(call: ServiceCall) -> ServiceResponse: entity_id = call.data["entity_id"] panel_device_id, kind, target_id = _resolve_entity_to_favorite_target(entity_id) - favorites = await async_set_favorite(hass, panel_device_id, kind, target_id, False) - return cast(ServiceResponse, {"favorites": favorites}) + return _favorites_response( + await async_set_favorite(hass, panel_device_id, kind, target_id, False) + ) _favorite_mutation_schema = vol.Schema({vol.Required("entity_id"): str}) @@ -642,3 +727,232 @@ async def async_handle_remove_favorite(call: ServiceCall) -> ServiceResponse: schema=_favorite_mutation_schema, supports_response=SupportsResponse.OPTIONAL, ) + + +async def _async_require_admin_caller(hass: HomeAssistant, call: ServiceCall) -> None: + """Refuse a service call that does not come from a logged-in administrator. + + `verify_domain_control` is deliberately not used here. It returns early for + a call with no `user_id` — every automation, script and integration — and + otherwise checks `POLICY_CONTROL`, which Home Assistant's default user + policy grants to non-admins. Neither is an administrator check. + + A contextless call is refused outright rather than being treated as + trusted: an unattended automation has no business rotating credentials. + """ + user_id = call.context.user_id + if user_id is None: + raise ServiceValidationError( + "Credential rotation must be run by an administrator from the " + "user interface, not from an automation or script.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_requires_user", + ) + + user = await hass.auth.async_get_user(user_id) + if user is None or not user.is_admin: + raise ServiceValidationError( + "Only a Home Assistant administrator can rotate SPAN Panel credentials.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_requires_admin", + ) + + +def _async_register_credential_services(hass: HomeAssistant) -> None: + """Register credential-rotation services.""" + + def _get_v2_entry(config_entry_id: str | None) -> ConfigEntry: + """Return the v2 entry to rotate, or explain why there isn't one. + + Loaded or not. A rotation reads only the stored host, access token and CA, + and the panel neither revokes nor expires access tokens, so the stored token + still authorizes it. The entries that most need another rotation are the + ones that are not loaded: one that did not reconnect after a rotation, and + one that restarted after an outcome-unknown rotation with a broker password + the panel no longer accepts. Ignored and disabled entries are not candidates. + + With the id omitted and more than one panel configured there is no defensible + default: rotating invalidates the broker password every other local client + of that panel is using, so picking one and hoping is worse than asking. + A single v2 panel is unambiguous and the id stays optional there. + """ + candidates: list[ConfigEntry] = [] + for entry in hass.config_entries.async_entries( + DOMAIN, include_ignore=False, include_disabled=False + ): + if config_entry_id is not None and entry.entry_id != config_entry_id: + continue + if entry.data.get(CONF_API_VERSION) != "v2": + continue + candidates.append(entry) + + if not candidates: + raise ServiceValidationError( + "No SPAN panel using the v2 API was found.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_no_entry", + ) + + if config_entry_id is None and len(candidates) > 1: + raise ServiceValidationError( + "More than one SPAN panel is configured. Name the panel to rotate " + "with the config entry field.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_multiple_panels", + ) + + return candidates[0] + + async def _async_rotate(entry: ConfigEntry) -> ServiceResponse: + host = str(entry.data[CONF_HOST]) + token = str(entry.data.get(CONF_ACCESS_TOKEN, "")) + if not token: + raise ServiceValidationError( + "This SPAN panel has no stored access token. Reauthenticate before rotating.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_no_token", + ) + + # Over the pinned CA where this entry has one. A credential-rotation + # service that delivered fresh secrets over unverified HTTP would undo + # the point of pinning at the one moment it matters most, so an entry + # whose stored CA no longer parses is refused rather than downgraded. + # (An entry that was never pinned is plaintext by design; that is the + # transport it has always used, and this service does not change it.) + try: + transport = panel_rest_transport(hass, entry.data, allow_plaintext_fallback=False) + except PanelCaUnusableError as err: + raise ServiceValidationError( + "The stored certificate authority for this SPAN panel cannot be " + "read, so the rotation would have to travel unencrypted. " + "Nothing was changed. Repair the panel's certificate authority, " + "then rotate again.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_ca_unusable", + ) from err + + try: + rotation = await rotate_passphrase( + host, + token, + port=transport.port, + httpx_client=transport.httpx_client, + ssl_context=transport.ssl_context, + ) + except SpanPanelInsufficientPrivilegeError as err: + raise ServiceValidationError( + "The stored access token has reduced privileges and cannot rotate " + "credentials. Reauthenticate with the panel passphrase first.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_insufficient_privilege", + ) from err + except SpanPanelAuthError as err: + raise ServiceValidationError( + "The stored access token was rejected by the panel. Reauthenticate first.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_auth_failed", + ) from err + except SpanPanelAPIError as err: + # A 5xx, or a 200 whose body could not be read: the panel may have + # replaced its passphrase and broker password without telling us + # the new value. A 503 (its passphrase service is not running) and + # any other status are refusals that changed nothing. + if ( + isinstance(err, SpanPanelServerError) and err.status_code != 503 + ) or err.status_code == 200: + raise _rotation_outcome_unknown(host) from err + raise ServiceValidationError( + f"The SPAN panel at {host} did not complete the rotation.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_failed", + translation_placeholders={"host": host}, + ) from err + except (SpanPanelConnectionError, SpanPanelTimeoutError) as err: + if _rotation_may_have_reached_panel(err): + raise _rotation_outcome_unknown(host) from err + # The request never left, so the entry still holds the credential + # the panel still accepts. + raise ServiceValidationError( + f"The SPAN panel at {host} did not complete the rotation.", + translation_domain=DOMAIN, + translation_key="rotate_credentials_failed", + translation_placeholders={"host": host}, + ) from err + + # The hop passphrase is handed back to the caller and never stored: the + # entry keeps only what the integration needs, the broker password. + updated_data = dict(entry.data) + updated_data[CONF_EBUS_BROKER_PASSWORD] = rotation.ebus_broker_password + + # Reload with the new password only, retrying while the broker may + # still be refusing it. Marked in progress first, so that a refusal in + # this window is not taken for a revoked credential and the options + # listener does not race a reload of its own. + async def _reload() -> bool: + # A reload that raises counts as not reconnected, so the response + # carrying the new passphrase still reaches the caller. + try: + return await hass.config_entries.async_reload(entry.entry_id) + except Exception: + _LOGGER.exception( + "Reloading SPAN panel entry %s after a rotation failed", entry.entry_id + ) + return False + + in_progress = hass.data.setdefault(_ROTATIONS_IN_PROGRESS, set()) + in_progress.add(entry.entry_id) + try: + hass.config_entries.async_update_entry(entry, data=updated_data) + reconnected = await _reload() + for delay in _ROTATION_RECONNECT_DELAYS_S: + if reconnected: + break + await asyncio.sleep(delay) + reconnected = await _reload() + finally: + in_progress.discard(entry.entry_id) + + # A failure here is returned rather than raised: raising would discard + # the only copy of the new passphrase. The stored password stays, since + # it is the one the panel now holds. + if reconnected: + _LOGGER.info( + "Rotated the panel passphrase and MQTT broker credential for SPAN panel entry %s", + entry.entry_id, + ) + else: + _LOGGER.error( + "Rotated the panel passphrase for SPAN panel entry %s and stored " + "the new broker password, but the entry did not reconnect with it. " + "Save the passphrase from the response, then run the rotation " + "again to get a password the broker accepts; restart the panel " + "as a last resort", + entry.entry_id, + ) + + return {"hop_passphrase": rotation.hop_passphrase, "reconnected": reconnected} + + async def async_handle_rotate_credentials(call: ServiceCall) -> ServiceResponse: + await _async_require_admin_caller(hass, call) + + entry_id = _get_v2_entry(call.data.get("config_entry_id")).entry_id + # One rotation per entry at a time, from before the PUT through the + # reload loop. Overlapping calls would otherwise each store the + # password from their own response, so the last to arrive wins even + # if the panel no longer accepts it, and the first to finish would + # clear the in-progress marker for both. + lock = hass.data.setdefault(_ROTATION_LOCKS, {}).setdefault(entry_id, asyncio.Lock()) + async with lock: + # Looked up again: the rotation ahead of this one may have stored a + # new broker password, and this one must start from that data. + return await _async_rotate(_get_v2_entry(entry_id)) + + # Response only: the new passphrase exists nowhere else once this returns, + # so a caller that did not ask for it must not be able to rotate. + hass.services.async_register( + DOMAIN, + "rotate_credentials", + async_handle_rotate_credentials, + schema=vol.Schema({vol.Optional("config_entry_id"): str}), + supports_response=SupportsResponse.ONLY, + ) diff --git a/custom_components/span_panel/services.yaml b/custom_components/span_panel/services.yaml index 530b9fcb..03412477 100644 --- a/custom_components/span_panel/services.yaml +++ b/custom_components/span_panel/services.yaml @@ -283,3 +283,10 @@ remove_favorite: entity: domain: sensor integration: span_panel + +rotate_credentials: + fields: + config_entry_id: + selector: + config_entry: + integration: span_panel diff --git a/custom_components/span_panel/solar_topology.py b/custom_components/span_panel/solar_topology.py new file mode 100644 index 00000000..ca360878 --- /dev/null +++ b/custom_components/span_panel/solar_topology.py @@ -0,0 +1,133 @@ +"""What the dashboard card draws for each solar inverter: its identity, and the reading that is its power. + +Every inverter is represented alike. Its identity is on its device, and its +power is its feeding circuit's power; no entity duplicates a circuit reading. +The Solar device is the bound inverter's device, and also carries PV Power, the +site's total. The card cannot tell which circuit feeds which inverter and must +not guess, so the topology command says so for each PV device, as an *entity id*. +An entity id rather than a circuit id, because the favorites view merges +several panels' topologies: it re-keys and filters their circuits but copies +their sub-devices verbatim, so an entity id survives the merge and a circuit id +does not. + +Structure -- which devices carry a block, and each one's feeding circuit -- +comes from the setup snapshot and the binding resolved from it, the same inputs +the PV devices were built from, so a block always describes the registered +device graph. A structural change reloads the entry, because every inverter key +is a capability token. Identity is read from the live snapshot, as the PV Vendor +and PV Product sensors read it: a vendor or model published after setup changes +no capability, so nothing reloads for it, and the tile would otherwise lag the +sensors beside it until something else did. The Solar device's block follows +`has_pv`, the test that creates the device and PV Power. A Solar device left +from an earlier setup without PV gets none. + +On SPAN a hybrid's PV is an ordinary `pv` child of the panel: circuit-fed when +in-panel, identity-only when upstream, like span#269's inverters. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Literal, TypedDict + +from .helpers import has_pv +from .util import EMPTY_PV + +if TYPE_CHECKING: + from span_panel_api import SpanPanelSnapshot + + from .pv_binding import PvBinding + +type SolarRole = Literal["site", "inverter"] + + +class SolarTopology(TypedDict): + """The `solar` block on one PV sub-device in `span_panel/panel_topology`.""" + + role: SolarRole + vendor: str | None + """As published. `None` where nothing is, unlike the registry's "Unknown" placeholder.""" + model: str | None + feed_circuit_id: str | None + power_entity_id: str | None + """The inverter's own power reading: its feeding circuit's power entity, or `None` where no circuit feeds it.""" + site_power_entity_id: str | None + """PV Power, the site's total, on the Solar device's block only.""" + + +def solar_topology( + inverter_key: str | None, + binding: PvBinding, + setup: SpanPanelSnapshot, + live: SpanPanelSnapshot, + circuit_power: Mapping[str, str], + site_power_entity_id: str | None, +) -> SolarTopology | None: + """Return the block for the Solar device (`inverter_key` None) or one inverter's own device, or None. + + None where there is nothing to describe: PV not commissioned at `setup`, for + the Solar device; or an inverter that was not published at `setup` or has no + card of its own. Identity is read from `live`, where an inverter that has + left reads as `EMPTY_PV`, as its sensors do. + """ + if inverter_key is None: + return _site(binding, setup, live, circuit_power, site_power_entity_id) + inverter = setup.pv_inverters.get(inverter_key) + if inverter is None or not binding.has_own_card(inverter_key): + return None + identity = live.pv_inverters.get(inverter_key, EMPTY_PV) + feed = inverter.feed_circuit_id + return SolarTopology( + role="inverter", + vendor=identity.vendor_name, + model=identity.model, + feed_circuit_id=feed, + power_entity_id=_power(feed, circuit_power), + site_power_entity_id=None, + ) + + +def _site( + binding: PvBinding, + setup: SpanPanelSnapshot, + live: SpanPanelSnapshot, + circuit_power: Mapping[str, str], + site_power_entity_id: str | None, +) -> SolarTopology | None: + if not has_pv(setup): + return None + source = binding.source(live) + feed = _site_feed_circuit(binding, setup) + return SolarTopology( + role="site", + vendor=source.vendor_name, + model=source.model, + feed_circuit_id=feed, + power_entity_id=_power(feed, circuit_power), + site_power_entity_id=site_power_entity_id, + ) + + +def _site_feed_circuit(binding: PvBinding, snapshot: SpanPanelSnapshot) -> str | None: + """Return the circuit of the inverter the Solar device describes and that has no tile of its own. + + `legacy_key`, not `bound_key`: a bound key that also holds a card (a store + restored from an older backup) already has its own tile, and drawing its + circuit here too would show one reading twice. A bound inverter that is not + published while its circuit is (a late record, with or without other + inverters published, or an inverter gone from a breaker that stays) keeps + that circuit: it is still the one the Solar device is bound to. + """ + key = binding.legacy_key + if key is None: + return None + inverter = snapshot.pv_inverters.get(key) + if inverter is not None: + return inverter.feed_circuit_id + if key == binding.bound_key and key in snapshot.circuits: + return key + return None + + +def _power(feed: str | None, circuit_power: Mapping[str, str]) -> str | None: + return circuit_power.get(feed) if feed is not None else None diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index e374babf..cab612a4 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -9,13 +9,21 @@ "reauth_successful": "Authentication successful.", "reconfigure_successful": "Host updated successfully.", "unique_id_mismatch": "The panel at this address has a different serial number. You cannot switch to a different panel during reconfiguration.", - "v1_not_supported": "v1 firmware is not supported. Please upgrade your panel firmware to v2." + "v1_not_supported": "v1 firmware is not supported. Please upgrade your panel firmware to v2.", + "host_not_set": "No panel address was set; restart the setup." }, "error": { + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again.", + "ca_name_mismatch": "The panel answered at this address but its certificate does not name it, so every later connection would reject it. Reconfigure to a fully qualified domain name, which asks the panel to regenerate its certificate to include it, or to the panel's .local name, which its certificate already covers.", + "ca_unavailable": "The panel's certificate authority could not be read. Check that the panel is reachable and powered on, then try again. Setup cannot continue without it, because your passphrase would otherwise be sent in the clear.", + "ca_unusable": "The certificate authority stored for this panel cannot be read, so this change would have to be sent unencrypted. Nothing has been changed. Reauthenticate the panel to store a usable authority, then try again.", "cannot_connect": "Failed to connect to Span Panel", "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time.", + "https_port_is_plaintext": "Port 80 is the plaintext HTTP port. Enter the port the panel serves TLS on — 443 unless something moved it.", "host_required": "Host is required", "invalid_auth": "Invalid authentication", + "panel_not_ready": "The panel is not ready to register Home Assistant yet, usually because it is still starting up. Wait a moment, then try again.", + "passphrase_unavailable": "The panel cannot issue credentials because its passphrase is not available yet. This does not mean a passphrase was entered incorrectly. Wait a few minutes, then try again.", "proximity_failed": "Proximity not proven. Please open and close the panel door 3 times and try again.", "unknown": "Unexpected error" }, @@ -68,7 +76,7 @@ "title": "Connect to the Span Panel" }, "fqdn_failed": { - "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name.", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, and the panel will be set up at the address its certificate does name — the domain name is not recorded, because the panel would reject it. Try the domain name again from Reconfigure once whatever blocked the registration is fixed.", "title": "Domain Registration" }, "reauth_confirm": { @@ -81,10 +89,12 @@ }, "reconfigure": { "data": { - "host": "Host" + "host": "Host", + "https_port": "HTTPS port" }, "data_description": { - "host": "IP address or hostname of the SPAN Panel" + "host": "IP address or hostname of the SPAN Panel", + "https_port": "Shown for a panel with a pinned certificate authority. Leave at 443 unless a proxy or a port mapping moved the panel's TLS." }, "description": "Update the host address for this SPAN Panel. The panel serial number must match.", "title": "Reconfigure SPAN Panel" @@ -93,7 +103,7 @@ "title": "Reconfiguration Complete" }, "reconfigure_fqdn_failed": { - "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name.", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, and the panel will be set up at the address its certificate does name — the domain name is not recorded, because the panel would reject it. Try the domain name again from Reconfigure once whatever blocked the registration is fixed.", "title": "Domain Registration" }, "reconfigure_register_fqdn": { @@ -119,6 +129,20 @@ }, "description": "Enter the connection details for your SPAN Panel.", "title": "Connect to the Span Panel" + }, + "panel_https_port": { + "title": "Panel HTTPS port", + "description": "You set a non-standard HTTP port for this panel, so it may serve TLS on a non-standard port too — for instance through a reverse proxy. Enter the port the panel's certificate is served on.", + "data": { + "https_port": "HTTPS port" + }, + "data_description": { + "https_port": "Leave at 443 unless a proxy or a port mapping moved it." + } + }, + "panel_ca": { + "title": "Reading the panel's certificate", + "description": "Home Assistant fetches the certificate authority your panel publishes, and checks that the certificate the panel serves is signed by it. This happens before you enter your passphrase, so the passphrase and the credentials the panel returns for it travel over a verified connection rather than in the clear.\n\nSelect **Submit** to try again." } } }, @@ -147,6 +171,15 @@ }, "wifi_link": { "name": "Wi-Fi Link" + }, + "pcs_active": { + "name": "PCS Active" + }, + "pv_panel_link": { + "name": "PV Panel Link" + }, + "evse_panel_link": { + "name": "EVSE Panel Link" } }, "button": { @@ -154,6 +187,11 @@ "name": "GFE Override: Grid Connected" } }, + "number": { + "evse_charge_current_limit": { + "name": "EVSE Charge Current Limit" + } + }, "select": { "circuit_priority": { "name": "Circuit Priority", @@ -177,9 +215,21 @@ "bess_model": { "name": "Model" }, + "bess_communication_state": { + "name": "Communication State", + "state": { + "ok": "OK", + "degraded": "Degraded", + "lost": "Lost", + "unknown": "Unknown" + } + }, "bess_nameplate_capacity": { "name": "Nameplate Capacity" }, + "bess_part_number": { + "name": "Part Number" + }, "bess_serial_number": { "name": "Serial Number" }, @@ -231,6 +281,9 @@ "unlocked": "Unlocked" } }, + "evse_part_number": { + "name": "Part Number" + }, "evse_status": { "name": "Charger Status", "state": { @@ -301,6 +354,15 @@ "unknown": "Unknown" } }, + "mid_grid_state": { + "name": "Grid State", + "state": { + "up": "Up", + "down": "Down", + "degraded": "Degraded", + "unknown": "Unknown" + } + }, "pv_nameplate_capacity": { "name": "PV Nameplate Capacity" }, @@ -313,12 +375,18 @@ "pv_vendor": { "name": "PV Vendor" }, + "shed_total_time_remaining": { + "name": "Backup Time Remaining" + }, "site_power": { "name": "Site Power" }, "software_version": { "name": "Software Version" }, + "time_to_priority_shed": { + "name": "Time to Priority Shed" + }, "upstream_l1_current": { "name": "Upstream L1 Current" }, @@ -332,36 +400,99 @@ "unconnected": "Disconnected", "unknown": "Unknown" } + }, + "pcs_import_limit": { + "name": "Import Limit" + }, + "pcs_binding_constraint": { + "name": "Binding Constraint", + "state": { + "fsr": "Firm Service Rating", + "doe": "Grid Envelope", + "voltage": "Voltage Support", + "off_grid": "Off-Grid", + "requested": "Requested", + "operator": "Operator", + "none": "None", + "unknown": "Unknown" + } + }, + "bess_meter_power": { + "name": "Meter Power" + } + }, + "switch": { + "control_lock": { + "name": "Control lock" } } }, "exceptions": { + "admin_only_control": { + "message": "Only a Home Assistant administrator may operate this SPAN panel." + }, + "adopted_control_failed": { + "message": "The write to {property} on {device} was refused: {reason}. Nothing was sent to the panel, so the value is unchanged." + }, + "adopted_control_not_delivered": { + "message": "The write to {property} on {device} could not be delivered to the panel: {reason}. It was refused rather than queued, so the value is unchanged and will not change later." + }, "circuit_priority_failed": { - "message": "Failed to set circuit priority for {circuit}." + "message": "The priority change for {circuit} was refused: {reason}. Nothing was sent to the panel, so the priority is unchanged." + }, + "circuit_priority_not_delivered": { + "message": "The priority change for {circuit} could not be delivered to the panel: {reason}. It was refused rather than queued, so the priority is unchanged." }, "circuit_relay_failed": { - "message": "Failed to set circuit relay state for {circuit}." + "message": "The breaker command for {circuit} was refused: {reason}. Nothing was sent to the panel, so the breaker has not moved." + }, + "circuit_relay_not_delivered": { + "message": "The breaker command for {circuit} could not be delivered to the panel: {reason}. It was refused rather than queued, so the breaker has not moved and will not move later." + }, + "contextless_control_refused": { + "message": "This SPAN panel refuses control commands that do not come from a logged-in user. To allow them, turn on \"Allow control without a logged-in user\" in its options." + }, + "control_disabled": { + "message": "Control of this SPAN panel is switched off in its options. Nothing was sent to the panel." + }, + "control_lock_disarm_requires_admin": { + "message": "Only a Home Assistant administrator can disarm the SPAN panel control lock." + }, + "control_lock_disarm_requires_user": { + "message": "The SPAN panel control lock can only be disarmed by a logged-in administrator, not by an automation or script." + }, + "control_locked": { + "message": "The SPAN panel's control lock is armed. Disarm it before operating circuits." + }, + "evse_charge_limit_failed": { + "message": "Failed to set the charge-current limit for charger {charger}: {reason}" + }, + "evse_charge_limit_not_delivered": { + "message": "The charge-current limit for charger {charger} could not be delivered to the panel: {reason}. It was refused rather than queued, so the limit is unchanged." }, "export_manifest_no_entries": { "message": "No SPAN panel configuration entries are loaded. Add and configure a SPAN panel before calling this service." }, - "favorite_not_span_entity": { - "message": "Entity {entity_id} is not a SPAN Panel entity. Pick a circuit, BESS, or EVSE sensor." + "favorite_no_circuit_uuid": { + "message": "Could not derive a favorite target from entity {entity_id}. Pick a circuit sensor (current/power) or a sub-device sensor." }, "favorite_no_device": { "message": "Entity {entity_id} is not attached to a device. Pick a SPAN circuit or sub-device sensor." }, - "favorite_subdevice_no_span_parent": { - "message": "Sub-device {entity_id} has no SPAN Panel parent device — its via_device_id does not point at a SPAN panel." - }, "favorite_no_unique_id": { "message": "Entity {entity_id} has no unique id and cannot be resolved to a SPAN target." }, - "favorite_no_circuit_uuid": { - "message": "Could not derive a favorite target from entity {entity_id}. Pick a circuit sensor (current/power) or a sub-device sensor." + "favorite_not_span_entity": { + "message": "Entity {entity_id} is not a SPAN Panel entity. Pick a circuit, BESS, or EVSE sensor." + }, + "favorite_subdevice_no_span_parent": { + "message": "Sub-device {entity_id} has no SPAN Panel parent device — its via_device_id does not point at a SPAN panel." }, "gfe_override_failed": { - "message": "Failed to send GFE override to the panel." + "message": "The GFE override to {value} was refused: {reason}. Nothing was sent to the panel." + }, + "gfe_override_not_delivered": { + "message": "The GFE override to {value} could not be delivered to the panel: {reason}. It was refused rather than queued, so nothing will happen later." }, "graph_horizon_not_available": { "message": "Graph horizon manager is not available for the selected SPAN panel." @@ -377,30 +508,71 @@ }, "panel_not_ready": { "message": "The SPAN Panel is not ready. Please check that the panel is online." + }, + "relay_debounced": { + "message": "That circuit's relay was operated moments ago. The command was refused rather than queued." + }, + "rotate_credentials_auth_failed": { + "message": "The SPAN Panel rejected the stored access token. Reauthenticate the integration before rotating credentials." + }, + "rotate_credentials_ca_unusable": { + "message": "The certificate authority stored for this SPAN Panel cannot be read, so the rotation would have to travel unencrypted. Nothing was changed. Repair the panel's certificate authority, then rotate again." + }, + "rotate_credentials_failed": { + "message": "The SPAN Panel at {host} did not complete the credential rotation. The existing credentials are unchanged." + }, + "rotate_credentials_insufficient_privilege": { + "message": "The stored access token has reduced privileges and cannot rotate credentials. Reauthenticate the integration with the panel passphrase, then rotate again." + }, + "rotate_credentials_no_entry": { + "message": "No SPAN panel using the v2 API was found." + }, + "rotate_credentials_multiple_panels": { + "message": "More than one SPAN Panel is configured. Choose the panel to rotate in the config entry field: rotating stops the previous broker password working for every other client of that panel." + }, + "rotate_credentials_no_token": { + "message": "This SPAN panel has no stored access token. Reauthenticate the integration before rotating credentials." + }, + "rotate_credentials_outcome_unknown": { + "message": "The SPAN Panel at {host} did not report the outcome of the credential rotation. The panel passphrase and broker password may have changed. Run the rotation again to get a passphrase you know. If the panel refuses that rotation, reauthenticate the integration, using proof of proximity if the old passphrase is no longer accepted." + }, + "rotate_credentials_requires_admin": { + "message": "Only a Home Assistant administrator can rotate SPAN Panel credentials." + }, + "rotate_credentials_requires_user": { + "message": "Credential rotation must be run by an administrator from the user interface. Automations, scripts and other integrations cannot call it." } }, "options": { "step": { "init": { "data": { - "show_panel": "Show Dashboard in Sidebar", - "panel_admin_only": "Admin Users Only", + "allow_contextless_control": "Allow control without a logged-in user", + "control_lock_timeout": "Control lock auto-relock (minutes)", + "control_mode": "Who may operate the panel", "enable_circuit_net_energy_sensors": "Circuit Net Energy", "enable_energy_dip_compensation": "Auto-Compensate Energy Dips", "enable_panel_net_energy_sensors": "Panel Net Energy", "enable_unmapped_circuit_sensors": "Unmapped Circuit Sensors", "energy_reporting_grace_period": "Energy Sensor Grace Period (minutes)", + "panel_admin_only": "Admin Users Only", + "relay_debounce_seconds": "Relay debounce (seconds)", + "show_panel": "Show Dashboard in Sidebar", "snapshot_update_interval": "Snapshot Update Interval (seconds)" }, "data_description": { - "show_panel": "Display the SPAN Panel dashboard as a sidebar entry.", - "panel_admin_only": "Restrict the dashboard sidebar entry to admin users.", + "allow_contextless_control": "Automations, scripts and other integrations call services with no user attached. While this is off, those commands are refused. Turn it on if your automations operate circuits.", + "control_lock_timeout": "-1 turns the control lock off and removes its switch. 0 keeps the lock armed until someone disarms it. Any other value re-arms it that many minutes after it is disarmed. Only administrators can disarm it.", + "control_mode": "Home Assistant grants every non-admin user control of every entity by default. Restricting to administrators is the only way to change that. Disabling stops creating the control entities; their registry entries, names and areas are kept.", "enable_circuit_net_energy_sensors": "Net energy per circuit, accounting for reactive power.", "enable_energy_dip_compensation": "Compensate when panel reports lower energy readings. Disabling clears offsets.", "enable_panel_net_energy_sensors": "Net energy for main meter and feed-through, accounting for bidirectional flows.", "enable_unmapped_circuit_sensors": "Backing sensors for tabs not assigned to named breakers.", - "energy_reporting_grace_period": "Minutes sensors hold last value when panel is offline (0\u201360). Default: 15.", - "snapshot_update_interval": "Seconds between snapshot rebuilds (0\u201315). Lower = faster, more CPU. 0 = no debounce." + "energy_reporting_grace_period": "Minutes sensors hold last value when panel is offline (0–60). Default: 15.", + "panel_admin_only": "Restrict the dashboard sidebar entry to admin users.", + "relay_debounce_seconds": "Refuse a second command to the same circuit's relay within this many seconds. Refused, never queued: a relay command firing seconds late against a changed panel is worse than an error the automation author can see. 0 disables it.", + "show_panel": "Display the SPAN Panel dashboard as a sidebar entry.", + "snapshot_update_interval": "Seconds between snapshot rebuilds (0–15). Lower = faster, more CPU. 0 = no debounce." }, "description": "Configure SPAN Panel integration settings.", "title": "SPAN Panel Options" @@ -408,6 +580,13 @@ } }, "selector": { + "control_mode": { + "options": { + "admin_only": "Administrators only", + "all_users": "Any Home Assistant user", + "disabled": "Nobody — do not create control entities" + } + }, "entity_naming_pattern": { "options": { "circuit_numbers": "Circuit Numbers (e.g., span_panel_circuit_15_power)", @@ -450,7 +629,7 @@ }, "config_entry_id": { "name": "Config entry", - "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + "description": "Which panel to rotate. Optional with a single panel; required when more than one SPAN Panel is loaded." } } }, @@ -693,6 +872,70 @@ "description": "Any sensor of the circuit or sub-device to remove from favorites." } } + }, + "rotate_credentials": { + "name": "Rotate panel credentials", + "description": "Ask the SPAN panel to replace its passphrase, which is currently also its eBus MQTT broker password, store the new broker password, and reload the integration. Both the panel passphrase and the broker password change. Access tokens already issued, including the integration's, are not revoked. The new passphrase is returned in the response and is not stored by the integration, so save it before closing the response. Any other local client using the previous password, such as a second Home Assistant instance, a script, or third-party tooling, must be re-provisioned with the new one. The broker may not accept the new password as soon as the panel returns it, so the integration retries for about a minute before reporting in the response that it did not reconnect. If it did not, run the rotation again, and the integration uses the newly returned password; as a last resort, restart the panel. Only a Home Assistant administrator can run this, and it cannot be called from an automation or script.", + "fields": { + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + } + }, + "issues": { + "schema_field_unresolved": { + "title": "SPAN Panel data field unavailable", + "description": "Your panel's firmware no longer provides `{field_path}`. Entities affected: {count} (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware." + }, + "schema_unit_mismatch": { + "title": "SPAN Panel unit mismatch", + "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." + }, + "panel_ca_changed": { + "title": "SPAN Panel certificate authority changed", + "fix_flow": { + "step": { + "confirm": { + "title": "Trust the new certificate authority?", + "description": "{panel} is now advertising this certificate authority:\n\n**{fingerprint}**\n\nAccepting pins it and reconnects. Only do that if you know why it changed — a firmware upgrade or a factory reset rotates it legitimately.\n\nIf nothing should have changed, do not accept. A device on your network standing in for your panel looks exactly like this, and accepting would hand it the connection." + } + }, + "abort": { + "entry_gone": "That SPAN Panel is no longer configured.", + "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again.", + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." + } + } + }, + "panel_ca_unusable": { + "title": "SPAN Panel stored certificate authority is unreadable", + "fix_flow": { + "step": { + "confirm": { + "title": "Trust the panel's certificate authority?", + "description": "The certificate authority stored for {panel} can no longer be read, so its connection cannot be verified and the integration has stopped rather than continue unencrypted.\n\nThe panel is advertising this certificate authority:\n\n**{fingerprint}**\n\nAccepting pins it and reconnects. Compare the fingerprint against another install or the panel itself if you can — a device on your network standing in for your panel would present a fingerprint of its own." + } + }, + "abort": { + "entry_gone": "That SPAN Panel is no longer configured.", + "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again.", + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "SPAN Panel HTTPS port is serving an untrusted certificate", + "description": "Whatever answers at **{host}:{https_port}** presented a certificate that {panel}'s pinned certificate authority (SHA-256 {fingerprint}) does not currently validate — while the panel itself still advertises that same authority, so the authority has not changed.\n\nTwo things produce this. Something between Home Assistant and the panel — a reverse proxy, a port forward, a wrong HTTPS port on the entry — is terminating TLS with a certificate of its own. Or the panel's clock reset in an outage and its certificate is outside its validity window, which clears on its own once the panel has time again.\n\nThe integration keeps retrying without connecting, and never falls back to unencrypted HTTP. Check the HTTPS port with *Reconfigure* on the SPAN Panel entry, or remove whatever is intercepting the connection; if the panel just lost power, give it a little while." + }, + "panel_leaf_name_mismatch": { + "title": "SPAN Panel is not at the configured address", + "description": "{panel} is configured as **{host}**, but the certificate it serves names only: **{leaf_names}**. The panel has probably moved, or the address was stored under a name the panel does not know itself by.\n\nUse *Reconfigure* on the SPAN Panel entry to point it at one of the names above. The integration keeps retrying meanwhile and will recover on its own if the panel returns to {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "SPAN Panel is not at the configured address", + "description": "{panel} is configured as **{host}**, but the certificate it serves names no address at all, so there is no address to point the entry at. That is a fault in the panel's certificate rather than in the address you configured, and it usually clears after the panel regenerates it — a restart of the panel is the usual way.\n\nIf it persists, use *Reconfigure* on the SPAN Panel entry to re-establish the connection. The integration keeps retrying meanwhile and will recover on its own if the panel starts naming {host} again." } } } diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index 88427934..2ccd31e4 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -1,26 +1,46 @@ """Control switches.""" from collections.abc import Mapping +from datetime import UTC, datetime, timedelta import logging -from typing import Any +from typing import Any, ClassVar from homeassistant.components.switch import SwitchEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.const import STATE_OFF +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.restore_state import RestoreEntity from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot -from . import SpanPanelConfigEntry +from .adoption import AdoptedSwitch, create_adopted_switches from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitRelayState +from .control_gate import ( + ControlLock, + ControlLockExtraStoredData, + ControlMode, + ControlPolicy, +) from .coordinator import SpanPanelCoordinator from .entity import SpanPanelEntity from .helpers import ( build_switch_unique_id_for_entry, + circuit_has_a_breaker_switch, construct_circuit_identifier_from_tabs, - construct_single_circuit_entity_id, + construct_circuit_label, construct_tabs_attribute, construct_voltage_attribute, + remove_withdrawn_controls, +) +from .naming import ( + circuit_object_id_base, + release_registry_name_written_by_older_release, ) +from .runtime import SpanPanelConfigEntry +from .util import snapshot_to_device_info _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -43,14 +63,28 @@ def _unnamed_switch_fallback(circuit: SpanCircuitSnapshot, circuit_id: str) -> s class SpanPanelCircuitsSwitch(SpanPanelEntity, SwitchEntity): """Represent a switch entity.""" + # Read in entity code rather than through a description: this platform + # has no entity description at all. `relay_state` is the switch's own + # state (~258); `name` and `tabs` build its display name and its id base + # (~106-122). + _residual_field_paths: ClassVar[tuple[str, ...]] = ( + "circuit.relay_state", + "circuit.name", + "circuit.tabs", + ) + def __init__( self, coordinator: SpanPanelCoordinator, circuit_id: str, - name: str, device_name: str, ) -> None: - """Initialize the values.""" + """Initialize the values. + + The circuit's name is not passed in: both the display name and the id + base are read from the circuit in the current snapshot, so a circuit + renamed on the panel reaches both on the next reload. + """ snapshot: SpanPanelSnapshot = coordinator.data circuit = snapshot.circuits.get(circuit_id) @@ -70,60 +104,42 @@ def __init__( ) use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + circuit_name = circuit.name or _unnamed_switch_fallback(circuit, circuit_id) + + # One name path, in both naming modes: the panel's name, carried as + # `original_name`. That field ranks below the object-id base below, so + # what is displayed can no longer decide what "Recreate entity IDs" + # proposes. + self._attr_name = f"{circuit_name} Breaker" + + # The id itself is Home Assistant's to compose. This entity supplies only + # its base -- the naming-flag half plus "breaker" -- and leaves + # `entity_id` unset so Core assembles the rest from the user's + # `entity_id_parts`. The suffix is named outright rather than mapped: + # `get_user_friendly_suffix` has no entry for a switch at all. + identifier = ( + construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id) + if use_circuit_numbers + else circuit_name + ) + self._span_object_id_base = circuit_object_id_base( + identifier, "breaker", existing_entity_id + ) + # Circuit-numbers mode used to deliver the panel's name by writing the + # registry's `name`. That field is the user's override, and Home Assistant + # reads it ahead of `suggested_object_id` when generating an entity id, so + # occupying it made "Recreate entity IDs" propose a friendly-name id for a + # circuit-numbered entity. The name travels as `original_name` now, so all + # that is left is to let go of what the old scheme wrote -- and only that: + # any other name is the user's. if existing_entity_id: - # Entity exists - use circuit-based name when configured, else panel name - if use_circuit_numbers: - circuit_identifier = construct_circuit_identifier_from_tabs( - circuit.tabs, circuit_id - ) - self._attr_name = f"{circuit_identifier} Breaker" - elif circuit.name: - self._attr_name = f"{circuit.name} Breaker" - else: - fallback = _unnamed_switch_fallback(circuit, circuit_id) - self._attr_name = f"{fallback} Breaker" - - # Sync the panel friendly name to the entity registry display name - # so the UI shows e.g. "Air Conditioner Breaker" while the entity_id - # stays circuit-based (e.g. switch.span_panel_circuit_15_breaker). - if existing_entity_id and use_circuit_numbers and circuit.name: - entity_entry = entity_registry.async_get(existing_entity_id) - if entity_entry: - expected_name = f"{circuit.name} Breaker" - if entity_entry.name is None or entity_entry.name == expected_name: - entity_registry.async_update_entity(existing_entity_id, name=expected_name) - - if not existing_entity_id: - # Initial install - use flag-based name for entity_id generation - if use_circuit_numbers: - circuit_identifier = construct_circuit_identifier_from_tabs( - circuit.tabs, circuit_id - ) - self._attr_name = f"{circuit_identifier} Breaker" - elif name: - self._attr_name = f"{name} Breaker" - else: - # v1 behavior: None lets HA handle default naming - self._attr_name = None + release_registry_name_written_by_older_release( + entity_registry, existing_entity_id, circuit.name, ("Breaker",) + ) super().__init__(coordinator) - # Explicitly set entity_id using construct_single_circuit_entity_id - # which correctly handles 240V two-tab circuits. - # Only pass unique_id for existing entities (registry lookup); - # for new entities pass None to get the constructed default. - constructed_id = construct_single_circuit_entity_id( - coordinator, - snapshot, - "switch", - "breaker", - circuit, - unique_id=self._attr_unique_id if existing_entity_id else None, - ) - if constructed_id: - self.entity_id = constructed_id - self._update_is_on() # Store initial circuit name for change detection in auto-sync @@ -142,71 +158,48 @@ async def async_will_remove_from_hass(self) -> None: await super().async_will_remove_from_hass() def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + """Handle updated data from the coordinator. + + The circuit's name is watched here: the entity carries it as + `original_name`, which can only be refreshed by being rebuilt, so a + rename earns a reload. Whether the panel still lets this circuit be + operated -- the answer that decided this entity exists -- is watched by + `SpanPanelCoordinator._check_settability_change` instead, because the + opposite edge happens on circuits that have no entity here to see it. + """ snapshot: SpanPanelSnapshot = self.coordinator.data circuit = snapshot.circuits.get(self._circuit_id) if circuit: current_circuit_name = circuit.name - use_circuit_numbers = self.coordinator.config_entry.options.get( - USE_CIRCUIT_NUMBERS, False - ) - - if use_circuit_numbers: - # Circuit-numbers mode: update registry display name, no reload - if self.entity_id and current_circuit_name: - entity_registry = er.async_get(self.hass) - entity_entry = entity_registry.async_get(self.entity_id) - if entity_entry: - # Compute old expected display BEFORE updating - # _previous_circuit_name - old_display = ( - f"{self._previous_circuit_name} Breaker" - if isinstance(self._previous_circuit_name, str) - else None - ) - new_display = f"{current_circuit_name} Breaker" - - # User override: registry name differs from both old - # and new expected display names - user_has_override = ( - entity_entry.name is not None - and entity_entry.name not in {old_display, new_display} - ) - - if not user_has_override and ( - self._previous_circuit_name is _NAME_UNSET - or current_circuit_name != self._previous_circuit_name - ): - entity_registry.async_update_entity(self.entity_id, name=new_display) + # One path for both modes: the name is carried by `original_name`, + # which is written when the entity is added, so a reload is what + # refreshes it. A name in the registry is one the user set. + user_has_override = False + if self.entity_id: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get(self.entity_id) + if entity_entry and entity_entry.name: + user_has_override = True + + if user_has_override: + self._previous_circuit_name = current_circuit_name + elif self._previous_circuit_name is _NAME_UNSET: + _LOGGER.info( + "First update: syncing entity name to panel name '%s' for switch, requesting reload", + current_circuit_name, + ) + self._previous_circuit_name = current_circuit_name + self.coordinator.request_reload() + elif current_circuit_name != self._previous_circuit_name: + _LOGGER.info( + "Auto-sync detected circuit name change from '%s' to '%s' for " + "switch, requesting integration reload", + self._previous_circuit_name, + current_circuit_name, + ) self._previous_circuit_name = current_circuit_name - else: - # Friendly-names mode: existing reload behavior - user_has_override = False - if self.entity_id: - entity_registry = er.async_get(self.hass) - entity_entry = entity_registry.async_get(self.entity_id) - if entity_entry and entity_entry.name: - user_has_override = True - - if user_has_override: - self._previous_circuit_name = current_circuit_name - elif self._previous_circuit_name is _NAME_UNSET: - _LOGGER.info( - "First update: syncing entity name to panel name '%s' for switch, requesting reload", - current_circuit_name, - ) - self._previous_circuit_name = current_circuit_name - self.coordinator.request_reload() - elif current_circuit_name != self._previous_circuit_name: - _LOGGER.info( - "Auto-sync detected circuit name change from '%s' to '%s' for " - "switch, requesting integration reload", - self._previous_circuit_name, - current_circuit_name, - ) - self._previous_circuit_name = current_circuit_name - self.coordinator.request_reload() + self.coordinator.request_reload() self._update_is_on() super()._handle_coordinator_update() @@ -217,7 +210,9 @@ def available(self) -> bool: Switches become unavailable when panel is offline since they can't control circuits. """ - if getattr(self.coordinator, "panel_offline", False): + if not self._transport_available: + return False + if self.coordinator.panel_offline: return False return super().available @@ -237,8 +232,9 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: if tabs_result is not None: attributes["tabs"] = tabs_result - voltage = construct_voltage_attribute(circuit) or 240 - attributes["voltage"] = voltage + voltage = construct_voltage_attribute(circuit) + if voltage is not None: + attributes["voltage"] = voltage if circuit.relay_state_target is not None: attributes["relay_state_target"] = circuit.relay_state_target @@ -281,24 +277,47 @@ def turn_off(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - client = self.coordinator.client - if not hasattr(client, "set_circuit_relay"): - _LOGGER.warning("Client does not support relay control") - return - - await client.set_circuit_relay(self._circuit_id, "CLOSED") - self._attr_is_on = True - self.async_write_ha_state() + await self._async_set_relay("CLOSED", is_on=True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" + await self._async_set_relay("OPEN", is_on=False) + + async def _async_set_relay(self, state: str, *, is_on: bool) -> None: + """Operate the relay and show the result honestly. + + The optimistic write is deliberately *after* the publish, not before. A + gate refusal or a debounce rejection raises out of the awaited call, and + setting the state first would leave the switch showing a position the + relay never took — with no coordinator update coming to correct it, + because nothing on the panel changed. + + `FAILED` is the only outcome that means the command will never be + delivered. `ACCEPTED` and `UNCONFIRMED` were both handed to the broker + and may already have been acted on; `UNCONFIRMED` most often means the + relay was already in the requested position. Neither is a failure and + neither should discard the requested state. + + Both ways the command can fail to happen -- a refusal that never resolved an + address, and a `FAILED` outcome that resolved one and was never handed + over -- are raised by `_async_control`, which every control in this + integration shares. + """ client = self.coordinator.client - if not hasattr(client, "set_circuit_relay"): - _LOGGER.warning("Client does not support relay control") - return + await self._async_control( + client.set_circuit_relay(self._circuit_id, state), + command=f"a relay command for {self.entity_id}", + failed_key="circuit_relay_failed", + not_delivered_key="circuit_relay_not_delivered", + placeholders={ + "circuit": construct_circuit_label( + self.coordinator.data.circuits.get(self._circuit_id), + self._circuit_id, + ) + }, + ) - await client.set_circuit_relay(self._circuit_id, "OPEN") - self._attr_is_on = False + self._attr_is_on = is_on self.async_write_ha_state() def _construct_switch_unique_id( @@ -313,6 +332,202 @@ def _construct_switch_unique_id( ) +class SpanPanelControlLockSwitch(SpanPanelEntity, SwitchEntity, RestoreEntity): + """Arms and disarms this panel's control lock. + + Not a control over the panel — it never publishes anything — so it is + deliberately outside `_async_guarded_control` and is never seen by the + interceptor. It is the thing the interceptor consults. + + The asymmetry is the whole design. Arming is permitted for anyone, including + a contextless caller, because making a household safer should not require + admin. Disarming requires an administrator, and is refused for a contextless + caller regardless of `allow_contextless_control`: an automation that can + unlock the panel is not a lock. + + **It is also the lock's memory.** `ControlLock` is a plain object rebuilt by + every `async_setup_entry`, and an entry is reloaded by every options save, + every credential rotation, every circuit rename that asks for one and every + restart. The entity is the only participant Home Assistant persists, so it + restores the armed state on the way in and writes the pending auto-relock + deadline on the way out. The lock stays the single source of truth: the + restored answer is written *into* it, never kept alongside it, so the + interceptor and the switch cannot disagree. + """ + + _attr_entity_category: EntityCategory | None = EntityCategory.CONFIG + _attr_translation_key: str | None = "control_lock" + + def __init__( + self, + coordinator: SpanPanelCoordinator, + lock: ControlLock, + policy: ControlPolicy, + device_name: str, + ) -> None: + """Bind the entity to this entry's single lock object.""" + super().__init__(coordinator) + snapshot: SpanPanelSnapshot = coordinator.data + self._lock = lock + self._policy = policy + self._relock_timer: CALLBACK_TYPE | None = None + self._attr_device_info = snapshot_to_device_info(snapshot, device_name) + self._attr_unique_id = f"span_{snapshot.serial_number}_control_lock" + + async def async_added_to_hass(self) -> None: + """Reopen the lock if that is where the last run left it. + + Core writes this entity's state immediately after this returns, so the + restored answer reaches the UI without an explicit write here. + """ + await super().async_added_to_hass() + self.async_on_remove(self._async_cancel_relock_timer) + + # The lock arrives armed (`ControlLock.__init__`), so only a previous + # run that had opened it has anything to say. No previous run at all is + # the case the option text describes — "0 keeps the lock armed until + # someone disarms it" is only true if enabling the feature arms it. A + # restored `unknown`/`unavailable` says the last run never got far + # enough to be trusted, and armed is the safe reading of a lock whose + # state is in doubt. + last_state = await self.async_get_last_state() + if last_state is not None and last_state.state == STATE_OFF: + self._lock.resume_disarm(await self._async_restored_relock_seconds()) + self._async_schedule_relock_write() + + async def _async_restored_relock_seconds(self) -> float | None: + """Return what is left of the previous run's auto-relock window. + + None means no window is pending. A value at or below zero means the + window closed while Home Assistant was down; `ControlLock.resume_disarm` + turns that into an armed lock. + """ + extra = await self.async_get_last_extra_data() + stored = None if extra is None else ControlLockExtraStoredData.from_dict(extra.as_dict()) + if stored is None or stored.relock_at is None: + # Either the previous run had no deadline to record, or its record + # is unreadable — a store written before this release, say. + timeout = self._policy.lock_timeout_minutes + if timeout is None or timeout <= 0: + # The entry asks for no countdown at all, so a disarm lasts + # until someone arms it. That is what the option promises and + # the restore has nothing to add to it. + return None + # This entry does auto-relock, and how much of the window was left + # is exactly what could not be read. Granting a fresh one would hand + # back more open time than the previous run had, on evidence that + # says nothing — and a restart is not consent to operate the panel. + # Treated as closed, which arms the lock and costs a user one click. + return 0.0 + return (stored.relock_at - datetime.now(tz=UTC)).total_seconds() + + @property + def extra_restore_state_data(self) -> ControlLockExtraStoredData: + """Record the pending auto-relock as an instant rather than a duration.""" + remaining = self._lock.relock_in_seconds + return ControlLockExtraStoredData( + relock_at=( + None if remaining is None else datetime.now(tz=UTC) + timedelta(seconds=remaining) + ) + ) + + @callback + def _async_cancel_relock_timer(self) -> None: + """Drop any scheduled write, so a reload leaves no timer behind.""" + if self._relock_timer is not None: + self._relock_timer() + self._relock_timer = None + + @callback + def _async_schedule_relock_write(self) -> None: + """Show the auto-relock at the moment it falls due, not at the next read. + + `ControlLock.armed` settles a lapsed deadline lazily, which keeps the + gate correct but leaves the switch reading off until something happens + to look at it — and nothing looks at a switch on a schedule. The panel + was locked and the UI said it was not, for as long as that took. + """ + self._async_cancel_relock_timer() + remaining = self._lock.relock_in_seconds + if remaining is None: + return + self._relock_timer = async_call_later(self.hass, remaining, self._async_relock_reached) + + @callback + def _async_relock_reached(self, _now: datetime) -> None: + """Arm at the deadline and say so. + + Arms explicitly rather than leaving it to the read behind `is_on`: this + timer is the mechanism, and the lazy settle in `ControlLock.armed` is + the backstop for an event loop that was suspended past the deadline. + """ + self._relock_timer = None + self._lock.arm() + self.async_write_ha_state() + + @property + def available(self) -> bool: + """A dead transport does not take this switch with it. + + The base class turns every entity unavailable once the transport is + gone, and it is right to: those entities all report something read from + the panel, and holding a value from before the transport died is exactly + what that probe is for. This one reports nothing from the panel. Arming + is local — it writes to `ControlLock`, which the interceptor consults + before it publishes anything — so it is answerable whether or not the + panel is reachable, and it is worth answering: the moment a transport + dies is a plausible moment to want the lock shut before it comes back. + + The asymmetry only runs one way, and safely. Arming works with no panel + at all; disarming works too, and buys nothing, because every control it + would open is unavailable anyway and the gate is not the thing that is + broken. + + Unconditional, so nothing in `super().available` is consulted, and each + of the three things it would have asked is answered here: the transport + probe is the one this overrides; the unresolved-field probe never fires + on an entity that declares no `field_path`, as this one does not; and + `CoordinatorEntity`'s `last_update_success` is a fact about the last + snapshot fetch, which this entity does not read and does not need. + """ + return True + + @property + def is_on(self) -> bool: + """Armed reads as on, matching an alarm panel rather than a door.""" + return self._lock.armed + + async def async_turn_on(self, **kwargs: Any) -> None: + """Arm the lock. Anyone may do this, including an automation.""" + self._lock.arm() + self._async_cancel_relock_timer() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Disarm the lock, for an administrator only.""" + context = self._context + user_id = context.user_id if context is not None else None + if user_id is None: + raise ServiceValidationError( + "The SPAN panel control lock can only be disarmed by a logged-in " + "administrator, not by an automation or script.", + translation_domain=DOMAIN, + translation_key="control_lock_disarm_requires_user", + ) + user = await self.hass.auth.async_get_user(user_id) + if user is None or not user.is_admin: + raise ServiceValidationError( + "Only a Home Assistant administrator can disarm the SPAN panel control lock.", + translation_domain=DOMAIN, + translation_key="control_lock_disarm_requires_admin", + ) + + timeout = self._policy.lock_timeout_minutes + self._lock.disarm(timeout if timeout is not None else 0) + self._async_schedule_relock_write() + self.async_write_ha_state() + + async def async_setup_entry( hass: HomeAssistant, config_entry: SpanPanelConfigEntry, @@ -321,25 +536,63 @@ async def async_setup_entry( """Set up sensor platform.""" coordinator = config_entry.runtime_data.coordinator - snapshot: SpanPanelSnapshot = coordinator.data + policy = config_entry.runtime_data.control_policy + snapshot: SpanPanelSnapshot = config_entry.runtime_data.setup_snapshot # Get device name from config entry data _device_name = config_entry.data.get("device_name", config_entry.title) - entities: list[SpanPanelCircuitsSwitch] = [] + entities: list[SpanPanelCircuitsSwitch | AdoptedSwitch | SpanPanelControlLockSwitch] = [] + + if policy.lock_enabled: + entities.append( + SpanPanelControlLockSwitch( + coordinator, + config_entry.runtime_data.control_lock, + policy, + _device_name, + ) + ) + + # Under `disabled` the control entities are not created and their registry + # entries are kept. Choosing Nobody is the user's decision about every + # control, reversed by the user, and the README promises it deletes nothing. + # A control the panel withdraws is the panel's decision about one circuit, + # and is removed below. Either way Core restores a returning control's + # entity id, name and area, so keeping these entries is a promise, not a + # safeguard. + if policy.mode is ControlMode.DISABLED: + async_add_entities(entities) + return + + remove_withdrawn_controls( + er.async_get(hass), + config_entry.entry_id, + "switch", + snapshot.circuits, + circuit_has_a_breaker_switch, + lambda circuit_id: build_switch_unique_id_for_entry( + coordinator, snapshot, circuit_id, _device_name + ), + ) for circuit_id, circuit_data in snapshot.circuits.items(): - if not circuit_data.is_user_controllable: - continue - # PV/EVSE circuits only get switches if they have a physical breaker - # (relative_position == "DOWNSTREAM" means connected at a breaker slot) - if ( - circuit_data.device_type in ("pv", "evse") - and circuit_data.relative_position != "DOWNSTREAM" - ): + if not circuit_has_a_breaker_switch(circuit_data): continue - entities.append( - SpanPanelCircuitsSwitch(coordinator, circuit_id, circuit_data.name, _device_name) + entities.append(SpanPanelCircuitsSwitch(coordinator, circuit_id, _device_name)) + + # Settable properties on devices this integration models nothing for. + # Disabled and diagnostic like every adopted entity: the panel authorises the + # write, and the user decides whether the control is one they want. + entities.extend( + create_adopted_switches( + coordinator, + coordinator.data, + dr.async_get(hass), + config_entry_id=config_entry.entry_id, + panel_device_id=config_entry.runtime_data.panel_device_id, + overlay=config_entry.runtime_data.curation, ) + ) async_add_entities(entities) diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index 040332a9..cab612a4 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -9,13 +9,21 @@ "reauth_successful": "Authentication successful.", "reconfigure_successful": "Host updated successfully.", "unique_id_mismatch": "The panel at this address has a different serial number. You cannot switch to a different panel during reconfiguration.", - "v1_not_supported": "v1 firmware is not supported. Please upgrade your panel firmware to v2." + "v1_not_supported": "v1 firmware is not supported. Please upgrade your panel firmware to v2.", + "host_not_set": "No panel address was set; restart the setup." }, "error": { + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again.", + "ca_name_mismatch": "The panel answered at this address but its certificate does not name it, so every later connection would reject it. Reconfigure to a fully qualified domain name, which asks the panel to regenerate its certificate to include it, or to the panel's .local name, which its certificate already covers.", + "ca_unavailable": "The panel's certificate authority could not be read. Check that the panel is reachable and powered on, then try again. Setup cannot continue without it, because your passphrase would otherwise be sent in the clear.", + "ca_unusable": "The certificate authority stored for this panel cannot be read, so this change would have to be sent unencrypted. Nothing has been changed. Reauthenticate the panel to store a usable authority, then try again.", "cannot_connect": "Failed to connect to Span Panel", "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time.", + "https_port_is_plaintext": "Port 80 is the plaintext HTTP port. Enter the port the panel serves TLS on — 443 unless something moved it.", "host_required": "Host is required", "invalid_auth": "Invalid authentication", + "panel_not_ready": "The panel is not ready to register Home Assistant yet, usually because it is still starting up. Wait a moment, then try again.", + "passphrase_unavailable": "The panel cannot issue credentials because its passphrase is not available yet. This does not mean a passphrase was entered incorrectly. Wait a few minutes, then try again.", "proximity_failed": "Proximity not proven. Please open and close the panel door 3 times and try again.", "unknown": "Unexpected error" }, @@ -68,7 +76,7 @@ "title": "Connect to the Span Panel" }, "fqdn_failed": { - "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name.", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, and the panel will be set up at the address its certificate does name — the domain name is not recorded, because the panel would reject it. Try the domain name again from Reconfigure once whatever blocked the registration is fixed.", "title": "Domain Registration" }, "reauth_confirm": { @@ -81,10 +89,12 @@ }, "reconfigure": { "data": { - "host": "Host" + "host": "Host", + "https_port": "HTTPS port" }, "data_description": { - "host": "IP address or hostname of the SPAN Panel" + "host": "IP address or hostname of the SPAN Panel", + "https_port": "Shown for a panel with a pinned certificate authority. Leave at 443 unless a proxy or a port mapping moved the panel's TLS." }, "description": "Update the host address for this SPAN Panel. The panel serial number must match.", "title": "Reconfigure SPAN Panel" @@ -93,7 +103,7 @@ "title": "Reconfiguration Complete" }, "reconfigure_fqdn_failed": { - "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name.", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, and the panel will be set up at the address its certificate does name — the domain name is not recorded, because the panel would reject it. Try the domain name again from Reconfigure once whatever blocked the registration is fixed.", "title": "Domain Registration" }, "reconfigure_register_fqdn": { @@ -119,6 +129,20 @@ }, "description": "Enter the connection details for your SPAN Panel.", "title": "Connect to the Span Panel" + }, + "panel_https_port": { + "title": "Panel HTTPS port", + "description": "You set a non-standard HTTP port for this panel, so it may serve TLS on a non-standard port too — for instance through a reverse proxy. Enter the port the panel's certificate is served on.", + "data": { + "https_port": "HTTPS port" + }, + "data_description": { + "https_port": "Leave at 443 unless a proxy or a port mapping moved it." + } + }, + "panel_ca": { + "title": "Reading the panel's certificate", + "description": "Home Assistant fetches the certificate authority your panel publishes, and checks that the certificate the panel serves is signed by it. This happens before you enter your passphrase, so the passphrase and the credentials the panel returns for it travel over a verified connection rather than in the clear.\n\nSelect **Submit** to try again." } } }, @@ -147,6 +171,15 @@ }, "wifi_link": { "name": "Wi-Fi Link" + }, + "pcs_active": { + "name": "PCS Active" + }, + "pv_panel_link": { + "name": "PV Panel Link" + }, + "evse_panel_link": { + "name": "EVSE Panel Link" } }, "button": { @@ -154,6 +187,11 @@ "name": "GFE Override: Grid Connected" } }, + "number": { + "evse_charge_current_limit": { + "name": "EVSE Charge Current Limit" + } + }, "select": { "circuit_priority": { "name": "Circuit Priority", @@ -177,9 +215,21 @@ "bess_model": { "name": "Model" }, + "bess_communication_state": { + "name": "Communication State", + "state": { + "ok": "OK", + "degraded": "Degraded", + "lost": "Lost", + "unknown": "Unknown" + } + }, "bess_nameplate_capacity": { "name": "Nameplate Capacity" }, + "bess_part_number": { + "name": "Part Number" + }, "bess_serial_number": { "name": "Serial Number" }, @@ -231,6 +281,9 @@ "unlocked": "Unlocked" } }, + "evse_part_number": { + "name": "Part Number" + }, "evse_status": { "name": "Charger Status", "state": { @@ -301,6 +354,15 @@ "unknown": "Unknown" } }, + "mid_grid_state": { + "name": "Grid State", + "state": { + "up": "Up", + "down": "Down", + "degraded": "Degraded", + "unknown": "Unknown" + } + }, "pv_nameplate_capacity": { "name": "PV Nameplate Capacity" }, @@ -313,12 +375,18 @@ "pv_vendor": { "name": "PV Vendor" }, + "shed_total_time_remaining": { + "name": "Backup Time Remaining" + }, "site_power": { "name": "Site Power" }, "software_version": { "name": "Software Version" }, + "time_to_priority_shed": { + "name": "Time to Priority Shed" + }, "upstream_l1_current": { "name": "Upstream L1 Current" }, @@ -332,36 +400,99 @@ "unconnected": "Disconnected", "unknown": "Unknown" } + }, + "pcs_import_limit": { + "name": "Import Limit" + }, + "pcs_binding_constraint": { + "name": "Binding Constraint", + "state": { + "fsr": "Firm Service Rating", + "doe": "Grid Envelope", + "voltage": "Voltage Support", + "off_grid": "Off-Grid", + "requested": "Requested", + "operator": "Operator", + "none": "None", + "unknown": "Unknown" + } + }, + "bess_meter_power": { + "name": "Meter Power" + } + }, + "switch": { + "control_lock": { + "name": "Control lock" } } }, "exceptions": { + "admin_only_control": { + "message": "Only a Home Assistant administrator may operate this SPAN panel." + }, + "adopted_control_failed": { + "message": "The write to {property} on {device} was refused: {reason}. Nothing was sent to the panel, so the value is unchanged." + }, + "adopted_control_not_delivered": { + "message": "The write to {property} on {device} could not be delivered to the panel: {reason}. It was refused rather than queued, so the value is unchanged and will not change later." + }, "circuit_priority_failed": { - "message": "Failed to set circuit priority for {circuit}." + "message": "The priority change for {circuit} was refused: {reason}. Nothing was sent to the panel, so the priority is unchanged." + }, + "circuit_priority_not_delivered": { + "message": "The priority change for {circuit} could not be delivered to the panel: {reason}. It was refused rather than queued, so the priority is unchanged." }, "circuit_relay_failed": { - "message": "Failed to set circuit relay state for {circuit}." + "message": "The breaker command for {circuit} was refused: {reason}. Nothing was sent to the panel, so the breaker has not moved." + }, + "circuit_relay_not_delivered": { + "message": "The breaker command for {circuit} could not be delivered to the panel: {reason}. It was refused rather than queued, so the breaker has not moved and will not move later." + }, + "contextless_control_refused": { + "message": "This SPAN panel refuses control commands that do not come from a logged-in user. To allow them, turn on \"Allow control without a logged-in user\" in its options." + }, + "control_disabled": { + "message": "Control of this SPAN panel is switched off in its options. Nothing was sent to the panel." + }, + "control_lock_disarm_requires_admin": { + "message": "Only a Home Assistant administrator can disarm the SPAN panel control lock." + }, + "control_lock_disarm_requires_user": { + "message": "The SPAN panel control lock can only be disarmed by a logged-in administrator, not by an automation or script." + }, + "control_locked": { + "message": "The SPAN panel's control lock is armed. Disarm it before operating circuits." + }, + "evse_charge_limit_failed": { + "message": "Failed to set the charge-current limit for charger {charger}: {reason}" + }, + "evse_charge_limit_not_delivered": { + "message": "The charge-current limit for charger {charger} could not be delivered to the panel: {reason}. It was refused rather than queued, so the limit is unchanged." }, "export_manifest_no_entries": { "message": "No SPAN panel configuration entries are loaded. Add and configure a SPAN panel before calling this service." }, - "favorite_not_span_entity": { - "message": "Entity {entity_id} is not a SPAN Panel entity. Pick a circuit, BESS, or EVSE sensor." + "favorite_no_circuit_uuid": { + "message": "Could not derive a favorite target from entity {entity_id}. Pick a circuit sensor (current/power) or a sub-device sensor." }, "favorite_no_device": { "message": "Entity {entity_id} is not attached to a device. Pick a SPAN circuit or sub-device sensor." }, - "favorite_subdevice_no_span_parent": { - "message": "Sub-device {entity_id} has no SPAN Panel parent device — its via_device_id does not point at a SPAN panel." - }, "favorite_no_unique_id": { "message": "Entity {entity_id} has no unique id and cannot be resolved to a SPAN target." }, - "favorite_no_circuit_uuid": { - "message": "Could not derive a favorite target from entity {entity_id}. Pick a circuit sensor (current/power) or a sub-device sensor." + "favorite_not_span_entity": { + "message": "Entity {entity_id} is not a SPAN Panel entity. Pick a circuit, BESS, or EVSE sensor." + }, + "favorite_subdevice_no_span_parent": { + "message": "Sub-device {entity_id} has no SPAN Panel parent device — its via_device_id does not point at a SPAN panel." }, "gfe_override_failed": { - "message": "Failed to send GFE override to the panel." + "message": "The GFE override to {value} was refused: {reason}. Nothing was sent to the panel." + }, + "gfe_override_not_delivered": { + "message": "The GFE override to {value} could not be delivered to the panel: {reason}. It was refused rather than queued, so nothing will happen later." }, "graph_horizon_not_available": { "message": "Graph horizon manager is not available for the selected SPAN panel." @@ -377,29 +508,70 @@ }, "panel_not_ready": { "message": "The SPAN Panel is not ready. Please check that the panel is online." + }, + "relay_debounced": { + "message": "That circuit's relay was operated moments ago. The command was refused rather than queued." + }, + "rotate_credentials_auth_failed": { + "message": "The SPAN Panel rejected the stored access token. Reauthenticate the integration before rotating credentials." + }, + "rotate_credentials_ca_unusable": { + "message": "The certificate authority stored for this SPAN Panel cannot be read, so the rotation would have to travel unencrypted. Nothing was changed. Repair the panel's certificate authority, then rotate again." + }, + "rotate_credentials_failed": { + "message": "The SPAN Panel at {host} did not complete the credential rotation. The existing credentials are unchanged." + }, + "rotate_credentials_insufficient_privilege": { + "message": "The stored access token has reduced privileges and cannot rotate credentials. Reauthenticate the integration with the panel passphrase, then rotate again." + }, + "rotate_credentials_no_entry": { + "message": "No SPAN panel using the v2 API was found." + }, + "rotate_credentials_multiple_panels": { + "message": "More than one SPAN Panel is configured. Choose the panel to rotate in the config entry field: rotating stops the previous broker password working for every other client of that panel." + }, + "rotate_credentials_no_token": { + "message": "This SPAN panel has no stored access token. Reauthenticate the integration before rotating credentials." + }, + "rotate_credentials_outcome_unknown": { + "message": "The SPAN Panel at {host} did not report the outcome of the credential rotation. The panel passphrase and broker password may have changed. Run the rotation again to get a passphrase you know. If the panel refuses that rotation, reauthenticate the integration, using proof of proximity if the old passphrase is no longer accepted." + }, + "rotate_credentials_requires_admin": { + "message": "Only a Home Assistant administrator can rotate SPAN Panel credentials." + }, + "rotate_credentials_requires_user": { + "message": "Credential rotation must be run by an administrator from the user interface. Automations, scripts and other integrations cannot call it." } }, "options": { "step": { "init": { "data": { - "show_panel": "Show Dashboard in Sidebar", - "panel_admin_only": "Admin Users Only", + "allow_contextless_control": "Allow control without a logged-in user", + "control_lock_timeout": "Control lock auto-relock (minutes)", + "control_mode": "Who may operate the panel", "enable_circuit_net_energy_sensors": "Circuit Net Energy", "enable_energy_dip_compensation": "Auto-Compensate Energy Dips", "enable_panel_net_energy_sensors": "Panel Net Energy", "enable_unmapped_circuit_sensors": "Unmapped Circuit Sensors", "energy_reporting_grace_period": "Energy Sensor Grace Period (minutes)", + "panel_admin_only": "Admin Users Only", + "relay_debounce_seconds": "Relay debounce (seconds)", + "show_panel": "Show Dashboard in Sidebar", "snapshot_update_interval": "Snapshot Update Interval (seconds)" }, "data_description": { - "show_panel": "Display the SPAN Panel dashboard as a sidebar entry.", - "panel_admin_only": "Restrict the dashboard sidebar entry to admin users.", + "allow_contextless_control": "Automations, scripts and other integrations call services with no user attached. While this is off, those commands are refused. Turn it on if your automations operate circuits.", + "control_lock_timeout": "-1 turns the control lock off and removes its switch. 0 keeps the lock armed until someone disarms it. Any other value re-arms it that many minutes after it is disarmed. Only administrators can disarm it.", + "control_mode": "Home Assistant grants every non-admin user control of every entity by default. Restricting to administrators is the only way to change that. Disabling stops creating the control entities; their registry entries, names and areas are kept.", "enable_circuit_net_energy_sensors": "Net energy per circuit, accounting for reactive power.", "enable_energy_dip_compensation": "Compensate when panel reports lower energy readings. Disabling clears offsets.", "enable_panel_net_energy_sensors": "Net energy for main meter and feed-through, accounting for bidirectional flows.", "enable_unmapped_circuit_sensors": "Backing sensors for tabs not assigned to named breakers.", "energy_reporting_grace_period": "Minutes sensors hold last value when panel is offline (0–60). Default: 15.", + "panel_admin_only": "Restrict the dashboard sidebar entry to admin users.", + "relay_debounce_seconds": "Refuse a second command to the same circuit's relay within this many seconds. Refused, never queued: a relay command firing seconds late against a changed panel is worse than an error the automation author can see. 0 disables it.", + "show_panel": "Display the SPAN Panel dashboard as a sidebar entry.", "snapshot_update_interval": "Seconds between snapshot rebuilds (0–15). Lower = faster, more CPU. 0 = no debounce." }, "description": "Configure SPAN Panel integration settings.", @@ -408,6 +580,13 @@ } }, "selector": { + "control_mode": { + "options": { + "admin_only": "Administrators only", + "all_users": "Any Home Assistant user", + "disabled": "Nobody — do not create control entities" + } + }, "entity_naming_pattern": { "options": { "circuit_numbers": "Circuit Numbers (e.g., span_panel_circuit_15_power)", @@ -450,7 +629,7 @@ }, "config_entry_id": { "name": "Config entry", - "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + "description": "Which panel to rotate. Optional with a single panel; required when more than one SPAN Panel is loaded." } } }, @@ -693,6 +872,70 @@ "description": "Any sensor of the circuit or sub-device to remove from favorites." } } + }, + "rotate_credentials": { + "name": "Rotate panel credentials", + "description": "Ask the SPAN panel to replace its passphrase, which is currently also its eBus MQTT broker password, store the new broker password, and reload the integration. Both the panel passphrase and the broker password change. Access tokens already issued, including the integration's, are not revoked. The new passphrase is returned in the response and is not stored by the integration, so save it before closing the response. Any other local client using the previous password, such as a second Home Assistant instance, a script, or third-party tooling, must be re-provisioned with the new one. The broker may not accept the new password as soon as the panel returns it, so the integration retries for about a minute before reporting in the response that it did not reconnect. If it did not, run the rotation again, and the integration uses the newly returned password; as a last resort, restart the panel. Only a Home Assistant administrator can run this, and it cannot be called from an automation or script.", + "fields": { + "config_entry_id": { + "name": "Config entry", + "description": "Optional config entry id. Only needed when more than one SPAN panel is configured." + } + } + } + }, + "issues": { + "schema_field_unresolved": { + "title": "SPAN Panel data field unavailable", + "description": "Your panel's firmware no longer provides `{field_path}`. Entities affected: {count} (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware." + }, + "schema_unit_mismatch": { + "title": "SPAN Panel unit mismatch", + "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically." + }, + "panel_ca_changed": { + "title": "SPAN Panel certificate authority changed", + "fix_flow": { + "step": { + "confirm": { + "title": "Trust the new certificate authority?", + "description": "{panel} is now advertising this certificate authority:\n\n**{fingerprint}**\n\nAccepting pins it and reconnects. Only do that if you know why it changed — a firmware upgrade or a factory reset rotates it legitimately.\n\nIf nothing should have changed, do not accept. A device on your network standing in for your panel looks exactly like this, and accepting would hand it the connection." + } + }, + "abort": { + "entry_gone": "That SPAN Panel is no longer configured.", + "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again.", + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." + } + } + }, + "panel_ca_unusable": { + "title": "SPAN Panel stored certificate authority is unreadable", + "fix_flow": { + "step": { + "confirm": { + "title": "Trust the panel's certificate authority?", + "description": "The certificate authority stored for {panel} can no longer be read, so its connection cannot be verified and the integration has stopped rather than continue unencrypted.\n\nThe panel is advertising this certificate authority:\n\n**{fingerprint}**\n\nAccepting pins it and reconnects. Compare the fingerprint against another install or the panel itself if you can — a device on your network standing in for your panel would present a fingerprint of its own." + } + }, + "abort": { + "entry_gone": "That SPAN Panel is no longer configured.", + "ca_unreadable": "The panel's certificate authority could not be read. Check that the panel is reachable and try again.", + "ca_leaf_mismatch": "The certificate this panel serves is not signed by the authority it published, so that authority has not been offered for you to accept and nothing has been changed. Check that the HTTPS port is correct and that nothing is intercepting the connection, then try again." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "SPAN Panel HTTPS port is serving an untrusted certificate", + "description": "Whatever answers at **{host}:{https_port}** presented a certificate that {panel}'s pinned certificate authority (SHA-256 {fingerprint}) does not currently validate — while the panel itself still advertises that same authority, so the authority has not changed.\n\nTwo things produce this. Something between Home Assistant and the panel — a reverse proxy, a port forward, a wrong HTTPS port on the entry — is terminating TLS with a certificate of its own. Or the panel's clock reset in an outage and its certificate is outside its validity window, which clears on its own once the panel has time again.\n\nThe integration keeps retrying without connecting, and never falls back to unencrypted HTTP. Check the HTTPS port with *Reconfigure* on the SPAN Panel entry, or remove whatever is intercepting the connection; if the panel just lost power, give it a little while." + }, + "panel_leaf_name_mismatch": { + "title": "SPAN Panel is not at the configured address", + "description": "{panel} is configured as **{host}**, but the certificate it serves names only: **{leaf_names}**. The panel has probably moved, or the address was stored under a name the panel does not know itself by.\n\nUse *Reconfigure* on the SPAN Panel entry to point it at one of the names above. The integration keeps retrying meanwhile and will recover on its own if the panel returns to {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "SPAN Panel is not at the configured address", + "description": "{panel} is configured as **{host}**, but the certificate it serves names no address at all, so there is no address to point the entry at. That is a fault in the panel's certificate rather than in the address you configured, and it usually clears after the panel regenerates it — a restart of the panel is the usual way.\n\nIf it persists, use *Reconfigure* on the SPAN Panel entry to re-establish the connection. The integration keeps retrying meanwhile and will recover on its own if the panel starts naming {host} again." } } } diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index 3bb65e2e..27ab70c3 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Span Panel ya esta configurado. Solo una configuración es posible.", "cannot_connect": "No se logró establecer conexión con Span Panel", + "host_not_set": "No se ha definido la dirección del panel; reinicie la configuración.", "no_devices_found": "No se hallaron aparatos en la red", "no_host": "No se proporcionó una dirección de host desde el complemento.", "no_serial": "No se pudo determinar el número de serie del panel.", @@ -12,10 +13,17 @@ "v1_not_supported": "El firmware v1 no es compatible. Actualice el firmware de su panel a v2." }, "error": { + "ca_leaf_mismatch": "El certificado que sirve este panel no está firmado por la autoridad que ha publicado. Compruebe que el puerto HTTPS es correcto y que nada intercepta la conexión, y vuelva a intentarlo.", + "ca_name_mismatch": "El panel respondió en esta dirección pero su certificado no la nombra, por lo que cualquier conexión posterior la rechazaría. Reconfigure con un nombre de dominio completo, lo que pide al panel que regenere su certificado para incluirlo, o con el nombre .local del panel, que su certificado ya cubre.", + "ca_unavailable": "No se ha podido leer la autoridad de certificación del panel. Compruebe que el panel es accesible y está encendido, y vuelva a intentarlo. La configuración no puede continuar sin ella, porque de lo contrario su contraseña se enviaría en claro.", + "ca_unusable": "La autoridad de certificación almacenada para este panel no se puede leer, por lo que este cambio tendría que enviarse sin cifrar. No se ha cambiado nada. Vuelva a autenticar el panel para almacenar una autoridad utilizable y vuelva a intentarlo.", "cannot_connect": "No se logró establecer conexión con Span Panel", "fqdn_registration_failed": "No se pudo registrar el nombre de dominio en el panel o el certificado TLS no se actualizó a tiempo.", "host_required": "Se requiere host", + "https_port_is_plaintext": "El puerto 80 es el puerto HTTP sin cifrar. Introduzca el puerto en el que el panel sirve TLS — 443 salvo que algo lo haya movido.", "invalid_auth": "Autenticación invalida", + "panel_not_ready": "El panel aún no está listo para registrar Home Assistant, normalmente porque todavía se está iniciando. Espere un momento e intente de nuevo.", + "passphrase_unavailable": "El panel no puede emitir credenciales porque su frase de contraseña aún no está disponible. Esto no significa que se haya introducido una frase de contraseña incorrecta. Espere unos minutos e intente de nuevo.", "proximity_failed": "Proximidad no comprobada. Por favor abra y cierre la puerta del panel 3 veces e intente de nuevo.", "unknown": "Error inesperado" }, @@ -68,7 +76,7 @@ "title": "Establecer conexión al Span Panel" }, "fqdn_failed": { - "description": "No se pudo registrar el nombre de dominio en el panel, o el certificado TLS no se actualizó a tiempo. Puede continuar, pero la conexión MQTT puede fallar si el certificado del panel no incluye su nombre de dominio.", + "description": "No se pudo registrar el nombre de dominio en el panel, o el certificado TLS no se actualizó a tiempo. Puede continuar, y el panel se configurará en la dirección que sí figura en su certificado; el nombre de dominio no se guarda, porque el panel lo rechazaría. Vuelva a intentarlo con el nombre de dominio desde Reconfigurar cuando se resuelva lo que impidió el registro.", "title": "Registro de Dominio" }, "reauth_confirm": { @@ -81,10 +89,12 @@ }, "reconfigure": { "data": { - "host": "Host" + "host": "Host", + "https_port": "Puerto HTTPS" }, "data_description": { - "host": "Dirección IP o nombre de host del SPAN Panel" + "host": "Dirección IP o nombre de host del SPAN Panel", + "https_port": "Se muestra para un panel con autoridad de certificación fijada. Deje 443 salvo que un proxy o una redirección de puertos haya movido el TLS del panel." }, "description": "Actualice la dirección de host para este SPAN Panel. El número de serie del panel debe coincidir.", "title": "Reconfigurar SPAN Panel" @@ -93,7 +103,7 @@ "title": "Reconfiguración Completada" }, "reconfigure_fqdn_failed": { - "description": "No se pudo registrar el nombre de dominio en el panel, o el certificado TLS no se actualizó a tiempo. Puede continuar, pero la conexión MQTT puede fallar si el certificado del panel no incluye su nombre de dominio.", + "description": "No se pudo registrar el nombre de dominio en el panel, o el certificado TLS no se actualizó a tiempo. Puede continuar, y el panel se configurará en la dirección que sí figura en su certificado; el nombre de dominio no se guarda, porque el panel lo rechazaría. Vuelva a intentarlo con el nombre de dominio desde Reconfigurar cuando se resuelva lo que impidió el registro.", "title": "Registro de Dominio" }, "reconfigure_register_fqdn": { @@ -119,6 +129,20 @@ }, "description": "Ingrese los detalles de conexión para su SPAN Panel.", "title": "Establecer conexión al Span Panel" + }, + "panel_https_port": { + "title": "Puerto HTTPS del panel", + "description": "Ha configurado un puerto HTTP no estándar para este panel, por lo que también podría servir TLS en un puerto no estándar, por ejemplo a través de un proxy inverso. Introduzca el puerto en el que se sirve el certificado del panel.", + "data": { + "https_port": "Puerto HTTPS" + }, + "data_description": { + "https_port": "Déjelo en 443 salvo que un proxy o una redirección de puertos lo haya cambiado." + } + }, + "panel_ca": { + "title": "Leyendo el certificado del panel", + "description": "Home Assistant obtiene la autoridad de certificación que publica su panel y comprueba que el certificado que este sirve está firmado por ella. Esto ocurre antes de que introduzca su contraseña, de modo que la contraseña y las credenciales que el panel devuelve a cambio viajan por una conexión verificada y no en claro.\n\nSeleccione **Enviar** para volver a intentarlo." } } }, @@ -147,6 +171,15 @@ }, "wifi_link": { "name": "Enlace Wi-Fi" + }, + "pcs_active": { + "name": "PCS Activo" + }, + "pv_panel_link": { + "name": "Enlace del Panel FV" + }, + "evse_panel_link": { + "name": "Enlace del Panel EVSE" } }, "button": { @@ -154,6 +187,11 @@ "name": "Anulación GFE: Conectado a Red" } }, + "number": { + "evse_charge_current_limit": { + "name": "Límite de Corriente de Carga EVSE" + } + }, "select": { "circuit_priority": { "name": "Prioridad del Circuito", @@ -177,9 +215,21 @@ "bess_model": { "name": "Modelo" }, + "bess_communication_state": { + "name": "Estado de Comunicación", + "state": { + "ok": "Correcto", + "degraded": "Degradado", + "lost": "Perdido", + "unknown": "Desconocido" + } + }, "bess_nameplate_capacity": { "name": "Capacidad Nominal" }, + "bess_part_number": { + "name": "Número de Pieza" + }, "bess_serial_number": { "name": "Número de Serie" }, @@ -231,6 +281,9 @@ "unlocked": "Desbloqueado" } }, + "evse_part_number": { + "name": "Número de Pieza" + }, "evse_status": { "name": "Estado del Cargador", "state": { @@ -301,6 +354,15 @@ "unknown": "Desconocido" } }, + "mid_grid_state": { + "name": "Estado de la red", + "state": { + "up": "Activa", + "down": "Caída", + "degraded": "Degradada", + "unknown": "Desconocido" + } + }, "pv_nameplate_capacity": { "name": "Capacidad Nominal PV" }, @@ -313,12 +375,18 @@ "pv_vendor": { "name": "Fabricante PV" }, + "shed_total_time_remaining": { + "name": "Tiempo de Respaldo Restante" + }, "site_power": { "name": "Potencia del Sitio" }, "software_version": { "name": "Versión de Software" }, + "time_to_priority_shed": { + "name": "Tiempo hasta el Corte por Prioridad" + }, "upstream_l1_current": { "name": "Corriente Ascendente L1" }, @@ -332,15 +400,75 @@ "unconnected": "Desconectado", "unknown": "Desconocido" } + }, + "pcs_import_limit": { + "name": "Límite de Importación" + }, + "pcs_binding_constraint": { + "name": "Restricción Vinculante", + "state": { + "fsr": "Capacidad Firme del Servicio", + "doe": "Envolvente de Red", + "voltage": "Soporte de Tensión", + "off_grid": "Fuera de Red", + "requested": "Solicitado", + "operator": "Operador", + "none": "Ninguno", + "unknown": "Desconocido" + } + }, + "bess_meter_power": { + "name": "Potencia del Medidor" + } + }, + "switch": { + "control_lock": { + "name": "Bloqueo de control" } } }, "exceptions": { + "admin_only_control": { + "message": "Solo un administrador de Home Assistant puede manejar este SPAN Panel." + }, + "adopted_control_failed": { + "message": "Se rechazó la escritura de {property} en {device}: {reason}. No se envió nada al panel, por lo que el valor no ha cambiado." + }, + "adopted_control_not_delivered": { + "message": "La escritura de {property} en {device} no se pudo entregar al panel: {reason}. Se rechazó en lugar de ponerse en cola, por lo que el valor no ha cambiado ni cambiará más tarde." + }, "circuit_priority_failed": { - "message": "Error al establecer la prioridad del circuito para {circuit}." + "message": "Se rechazó el cambio de prioridad para {circuit}: {reason}. No se envió nada al panel, por lo que la prioridad no ha cambiado." + }, + "circuit_priority_not_delivered": { + "message": "El cambio de prioridad para {circuit} no se pudo entregar al panel: {reason}. Se rechazó en lugar de ponerse en cola, por lo que la prioridad no ha cambiado." }, "circuit_relay_failed": { - "message": "Error al establecer el estado del relé del circuito para {circuit}." + "message": "Se rechazó el comando del interruptor para {circuit}: {reason}. No se envió nada al panel, por lo que el interruptor no ha cambiado de posición." + }, + "circuit_relay_not_delivered": { + "message": "El comando del interruptor para {circuit} no se pudo entregar al panel: {reason}. Se rechazó en lugar de ponerse en cola, por lo que el interruptor no ha cambiado de posición ni lo hará más tarde." + }, + "contextless_control_refused": { + "message": "Este SPAN Panel rechaza las órdenes de control que no proceden de un usuario con sesión iniciada. Para permitirlas, active «Permitir el control sin un usuario con sesión iniciada» en sus opciones." + }, + "control_disabled": { + "message": "El control de este SPAN Panel está desactivado en sus opciones. No se envió nada al panel." + }, + "control_lock_disarm_requires_admin": { + "message": "Solo un administrador de Home Assistant puede desactivar el bloqueo de control del SPAN Panel." + }, + "control_lock_disarm_requires_user": { + "message": "El bloqueo de control del SPAN Panel solo puede desactivarlo un administrador con sesión iniciada, no una automatización ni un script." + }, + "control_locked": { + "message": "El bloqueo de control del SPAN Panel está activado. Desactívelo antes de manejar circuitos." + }, + "evse_charge_limit_failed": { + "message": "No se pudo establecer el límite de corriente de carga para el cargador {charger}: {reason}" + }, + "evse_charge_limit_not_delivered": { + "message": "El límite de corriente de carga del cargador {charger} no se pudo entregar al panel: {reason}. Se rechazó en lugar de ponerse en cola, por lo que el límite no ha cambiado." }, "export_manifest_no_entries": { "message": "No hay entradas de configuración de SPAN Panel cargadas. Agregue y configure un SPAN Panel antes de llamar a este servicio." @@ -361,7 +489,10 @@ "message": "El subdispositivo {entity_id} no tiene un dispositivo SPAN Panel principal — su via_device_id no apunta a un panel SPAN." }, "gfe_override_failed": { - "message": "Error al enviar la anulación GFE al panel." + "message": "Se rechazó la anulación GFE a {value}: {reason}. No se envió nada al panel." + }, + "gfe_override_not_delivered": { + "message": "La anulación GFE a {value} no se pudo entregar al panel: {reason}. Se rechazó en lugar de ponerse en cola, por lo que no ocurrirá nada más tarde." }, "graph_horizon_not_available": { "message": "El gestor del horizonte del gráfico no está disponible para el SPAN Panel seleccionado." @@ -377,29 +508,70 @@ }, "panel_not_ready": { "message": "El SPAN Panel no está listo. Por favor verifique que el panel esté en línea." + }, + "relay_debounced": { + "message": "El relé de ese circuito se manejó hace unos instantes. La orden se rechazó en lugar de encolarse." + }, + "rotate_credentials_auth_failed": { + "message": "El SPAN Panel rechazó el token de acceso almacenado. Vuelva a autenticar la integración antes de rotar las credenciales." + }, + "rotate_credentials_ca_unusable": { + "message": "No se puede leer la autoridad de certificación almacenada para este SPAN Panel, por lo que la rotación tendría que viajar sin cifrar. No se ha cambiado nada. Repare la autoridad de certificación del panel y vuelva a rotar." + }, + "rotate_credentials_failed": { + "message": "El SPAN Panel en {host} no completó la rotación de credenciales. Las credenciales existentes no han cambiado." + }, + "rotate_credentials_insufficient_privilege": { + "message": "El token de acceso almacenado tiene privilegios reducidos y no puede rotar las credenciales. Vuelva a autenticar la integración con la frase de acceso del panel y luego rote de nuevo." + }, + "rotate_credentials_multiple_panels": { + "message": "Hay más de un SPAN Panel configurado. Elija en el campo de entrada de configuración el panel que desea rotar: al rotar, la contraseña anterior del broker deja de funcionar para todos los demás clientes de ese panel." + }, + "rotate_credentials_no_entry": { + "message": "No se encontró ningún SPAN Panel que use la API v2." + }, + "rotate_credentials_no_token": { + "message": "Este SPAN Panel no tiene un token de acceso almacenado. Vuelva a autenticar la integración antes de rotar las credenciales." + }, + "rotate_credentials_outcome_unknown": { + "message": "El SPAN Panel en {host} no informó el resultado de la rotación de credenciales. La frase de acceso del panel y la contraseña del broker pueden haber cambiado. Vuelva a ejecutar la rotación para obtener una frase de acceso conocida. Si el panel rechaza esa rotación, vuelva a autenticar la integración; si la frase de acceso anterior ya no se acepta, use la prueba de proximidad." + }, + "rotate_credentials_requires_admin": { + "message": "Solo un administrador de Home Assistant puede rotar las credenciales del SPAN Panel." + }, + "rotate_credentials_requires_user": { + "message": "La rotación de credenciales debe ejecutarla un administrador desde la interfaz de usuario. Las automatizaciones, los scripts y otras integraciones no pueden llamarla." } }, "options": { "step": { "init": { "data": { - "show_panel": "Mostrar Panel en la Barra Lateral", - "panel_admin_only": "Solo Usuarios Administradores", + "allow_contextless_control": "Permitir el control sin un usuario con sesión iniciada", + "control_lock_timeout": "Rearme automático del bloqueo de control (minutos)", + "control_mode": "Quién puede manejar el panel", "enable_circuit_net_energy_sensors": "Energía Neta del Circuito", "enable_energy_dip_compensation": "Compensar Caídas de Energía Automáticamente", "enable_panel_net_energy_sensors": "Energía Neta del Panel", "enable_unmapped_circuit_sensors": "Sensores de Circuitos No Asignados", "energy_reporting_grace_period": "Período de Gracia del Sensor de Energía (minutos)", + "panel_admin_only": "Solo Usuarios Administradores", + "relay_debounce_seconds": "Antirrebote del relé (segundos)", + "show_panel": "Mostrar Panel en la Barra Lateral", "snapshot_update_interval": "Intervalo de Actualización de Snapshot (segundos)" }, "data_description": { - "show_panel": "Mostrar el panel de control de SPAN como entrada de barra lateral.", - "panel_admin_only": "Restringir la entrada de la barra lateral del panel de control solo a usuarios administradores.", + "allow_contextless_control": "Las automatizaciones, los scripts y otras integraciones llaman a los servicios sin ningún usuario asociado. Mientras esté desactivado, esas órdenes se rechazan. Actívelo si sus automatizaciones manejan circuitos.", + "control_lock_timeout": "-1 desactiva el bloqueo de control y elimina su interruptor. 0 mantiene el bloqueo activado hasta que alguien lo desactive. Cualquier otro valor lo rearma esos minutos después de desactivarlo. Solo los administradores pueden desactivarlo.", + "control_mode": "De forma predeterminada, Home Assistant concede a todos los usuarios no administradores el control de todas las entidades. Restringirlo a administradores es la única forma de cambiarlo. Al desactivarlo dejan de crearse las entidades de control; se conservan sus entradas del registro, nombres y áreas.", "enable_circuit_net_energy_sensors": "Energía neta por circuito, contabilizando potencia reactiva.", "enable_energy_dip_compensation": "Compensar cuando el panel reporta lecturas de energía más bajas. Al deshabilitar se borran las compensaciones.", "enable_panel_net_energy_sensors": "Energía neta para medidor principal y derivación, contabilizando flujos bidireccionales.", "enable_unmapped_circuit_sensors": "Sensores de respaldo para pestañas no asignadas a interruptores con nombre.", "energy_reporting_grace_period": "Minutos que los sensores mantienen el último valor cuando el panel está fuera de línea (0–60). Predeterminado: 15.", + "panel_admin_only": "Restringir la entrada de la barra lateral del panel de control solo a usuarios administradores.", + "relay_debounce_seconds": "Rechaza una segunda orden al relé del mismo circuito dentro de estos segundos. Se rechaza, nunca se encola: una orden de relé que se ejecuta segundos tarde contra un panel que ha cambiado es peor que un error que el autor de la automatización puede ver. 0 lo desactiva.", + "show_panel": "Mostrar el panel de control de SPAN como entrada de barra lateral.", "snapshot_update_interval": "Segundos entre reconstrucciones de snapshot (0–15). Menor = más rápido, más CPU. 0 = sin debounce." }, "description": "Configurar los ajustes de la integración SPAN Panel.", @@ -408,6 +580,13 @@ } }, "selector": { + "control_mode": { + "options": { + "admin_only": "Solo administradores", + "all_users": "Cualquier usuario de Home Assistant", + "disabled": "Nadie: no crear entidades de control" + } + }, "entity_naming_pattern": { "options": { "circuit_numbers": "Números de Circuito (ej., span_panel_circuit_15_power)", @@ -693,6 +872,70 @@ "description": "Cualquier sensor del circuito o subdispositivo a eliminar de favoritos." } } + }, + "rotate_credentials": { + "name": "Rotar las credenciales del panel", + "description": "Pide al SPAN Panel que reemplace su frase de acceso, que actualmente también es la contraseña del broker MQTT eBus, guarda la nueva contraseña del broker y recarga la integración. Cambian tanto la frase de acceso del panel como la contraseña del broker. Los tokens de acceso ya emitidos, incluido el de la integración, no se revocan. La nueva frase de acceso se devuelve en la respuesta y la integración no la guarda, así que guárdela antes de cerrar la respuesta. Cualquier otro cliente local que use la contraseña anterior, como una segunda instancia de Home Assistant, un script o herramientas de terceros, debe volver a aprovisionarse con la nueva. Es posible que el broker no acepte la nueva contraseña en cuanto el panel la devuelve, por lo que la integración reintenta durante aproximadamente un minuto antes de indicar en la respuesta que no se reconectó. Si no se reconectó, vuelva a ejecutar la rotación, y la integración usará la contraseña recién devuelta; como último recurso, reinicie el panel. Solo un administrador de Home Assistant puede ejecutarlo, y no se puede llamar desde una automatización o un script.", + "fields": { + "config_entry_id": { + "name": "Entrada de configuración", + "description": "Qué panel rotar. Opcional con un solo panel; obligatorio cuando hay más de un SPAN Panel cargado." + } + } + } + }, + "issues": { + "schema_field_unresolved": { + "title": "Campo de datos del Panel SPAN no disponible", + "description": "El firmware de su panel ya no proporciona `{field_path}`. Entidades afectadas: {count} (por ejemplo: {examples}). Pueden quedar no disponibles, o informar datos incompletos, hasta que una actualización de la integración añada compatibilidad con el nuevo firmware." + }, + "schema_unit_mismatch": { + "title": "Discrepancia de unidades del Panel SPAN", + "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Entidades afectadas: {count} (por ejemplo: {examples}). Sus lecturas y estadísticas a largo plazo pueden ser incorrectas. No se han modificado automáticamente." + }, + "panel_ca_changed": { + "title": "La autoridad de certificación del SPAN Panel ha cambiado", + "fix_flow": { + "step": { + "confirm": { + "title": "¿Confiar en la nueva autoridad de certificación?", + "description": "{panel} anuncia ahora esta autoridad de certificación:\n\n**{fingerprint}**\n\nAceptarla la fija y vuelve a conectar. Hágalo solo si sabe por qué ha cambiado: una actualización de firmware o un restablecimiento de fábrica la rotan de forma legítima.\n\nSi no debería haber cambiado nada, no la acepte. Un dispositivo de su red que suplante a su panel tiene exactamente este aspecto, y aceptarla le entregaría la conexión." + } + }, + "abort": { + "entry_gone": "Ese SPAN Panel ya no está configurado.", + "ca_unreadable": "No se ha podido leer la autoridad de certificación del panel. Compruebe que el panel es accesible e inténtelo de nuevo.", + "ca_leaf_mismatch": "El certificado que sirve este panel no está firmado por la autoridad que ha publicado, por lo que esa autoridad no se ha ofrecido para su aceptación y no se ha cambiado nada. Compruebe que el puerto HTTPS es correcto y que nada intercepta la conexión, y vuelva a intentarlo." + } + } + }, + "panel_ca_unusable": { + "title": "La autoridad de certificación almacenada del SPAN Panel es ilegible", + "fix_flow": { + "step": { + "confirm": { + "title": "¿Confiar en la autoridad de certificación del panel?", + "description": "La autoridad de certificación almacenada para {panel} ya no se puede leer, por lo que su conexión no puede verificarse y la integración se ha detenido en lugar de continuar sin cifrado.\n\nEl panel está anunciando esta autoridad de certificación:\n\n**{fingerprint}**\n\nAceptar la fija y reconecta. Compare la huella con otra instalación o con el propio panel si puede — un dispositivo de su red que suplante al panel presentaría una huella propia." + } + }, + "abort": { + "entry_gone": "Ese SPAN Panel ya no está configurado.", + "ca_unreadable": "No se ha podido leer la autoridad de certificación del panel. Compruebe que el panel es accesible e inténtelo de nuevo.", + "ca_leaf_mismatch": "El certificado que sirve este panel no está firmado por la autoridad que publicó, por lo que esa autoridad no se le ha ofrecido para aceptar y no se ha cambiado nada. Compruebe que el puerto HTTPS es correcto y que nada intercepta la conexión, e inténtelo de nuevo." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "El puerto HTTPS del SPAN Panel sirve un certificado no fiable", + "description": "Lo que responde en **{host}:{https_port}** presentó un certificado que la autoridad de certificación fijada de {panel} (SHA-256 {fingerprint}) no valida actualmente — mientras el propio panel sigue anunciando esa misma autoridad, así que la autoridad no ha cambiado.\n\nDos cosas producen esto. Algo entre Home Assistant y el panel — un proxy inverso, una redirección de puertos, un puerto HTTPS incorrecto en la entrada — está terminando TLS con un certificado propio. O el reloj del panel se reinició en un apagón y su certificado está fuera de su período de validez, lo que se resuelve solo cuando el panel vuelve a tener hora.\n\nLa integración sigue reintentando sin conectarse, y nunca recurre a HTTP sin cifrar. Compruebe el puerto HTTPS con *Reconfigurar* en la entrada del SPAN Panel, o retire lo que esté interceptando la conexión; si el panel acaba de perder la alimentación, dele un tiempo." + }, + "panel_leaf_name_mismatch": { + "title": "El SPAN Panel no está en la dirección configurada", + "description": "{panel} está configurado como **{host}**, pero el certificado que sirve solo nombra: **{leaf_names}**. Es probable que el panel se haya movido, o que la dirección se guardara con un nombre por el que el panel no se conoce a sí mismo.\n\nUse *Reconfigurar* en la entrada del SPAN Panel para apuntarla a uno de los nombres anteriores. Mientras tanto, la integración sigue reintentando y se recuperará por sí sola si el panel vuelve a {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "El SPAN Panel no está en la dirección configurada", + "description": "{panel} está configurado como **{host}**, pero el certificado que sirve no nombra ninguna dirección, por lo que no hay ninguna dirección a la que apuntar la entrada. Se trata de un fallo del certificado del panel y no de la dirección que usted configuró, y suele resolverse cuando el panel lo regenera: lo habitual es reiniciar el panel.\n\nSi persiste, use *Reconfigurar* en la entrada del SPAN Panel para restablecer la conexión. Mientras tanto, la integración sigue reintentando y se recuperará por sí sola si el panel vuelve a nombrar {host}." } } } diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index 712bef06..5a4cdfbe 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Span Panel déjà configuré. Une seule configuration est possible.", "cannot_connect": "Échec de la connexion au Span Panel", + "host_not_set": "Aucune adresse de panneau n'a été définie ; recommencez la configuration.", "no_devices_found": "Aucun appareil trouvé sur le réseau", "no_host": "Aucune adresse d'hôte n'a été fournie par le module complémentaire.", "no_serial": "Impossible de déterminer le numéro de série du panneau.", @@ -12,10 +13,17 @@ "v1_not_supported": "Le micrologiciel v1 n'est pas pris en charge. Veuillez mettre à jour le micrologiciel de votre panneau vers la version v2." }, "error": { + "ca_leaf_mismatch": "Le certificat présenté par ce panneau n'est pas signé par l'autorité qu'il a publiée. Vérifiez que le port HTTPS est correct et que rien n'intercepte la connexion, puis réessayez.", + "ca_name_mismatch": "Le panneau a répondu à cette adresse mais son certificat ne la nomme pas, de sorte que toute connexion ultérieure la rejetterait. Reconfigurez avec un nom de domaine pleinement qualifié, ce qui demande au panneau de régénérer son certificat pour l'inclure, ou avec le nom .local du panneau, que son certificat couvre déjà.", + "ca_unavailable": "L'autorité de certification du panneau n'a pas pu être lue. Vérifiez que le panneau est joignable et sous tension, puis réessayez. La configuration ne peut pas continuer sans elle, car votre mot de passe serait sinon envoyé en clair.", + "ca_unusable": "L'autorité de certification enregistrée pour ce panneau ne peut pas être lue, ce changement devrait donc être envoyé sans chiffrement. Rien n'a été modifié. Réauthentifiez le panneau pour enregistrer une autorité utilisable, puis réessayez.", "cannot_connect": "Échec de la connexion au Span Panel", "fqdn_registration_failed": "Impossible d'enregistrer le nom de domaine auprès du panneau ou le certificat TLS n'a pas été mis à jour à temps.", "host_required": "L'hôte est requis", + "https_port_is_plaintext": "Le port 80 est le port HTTP en clair. Saisissez le port sur lequel le panneau sert TLS — 443 sauf si quelque chose l'a déplacé.", "invalid_auth": "Authentification invalide", + "panel_not_ready": "Le panneau n'est pas encore prêt à enregistrer Home Assistant, généralement parce qu'il est encore en cours de démarrage. Patientez un instant, puis réessayez.", + "passphrase_unavailable": "Le panneau ne peut pas délivrer d'identifiants car sa phrase secrète n'est pas encore disponible. Cela ne signifie pas qu'une phrase secrète incorrecte a été saisie. Patientez quelques minutes, puis réessayez.", "proximity_failed": "Proximité non prouvée. Veuillez ouvrir et fermer la porte du panneau 3 fois et réessayer.", "unknown": "Erreur inattendue" }, @@ -68,7 +76,7 @@ "title": "Connectez-vous au Span Panel" }, "fqdn_failed": { - "description": "Le nom de domaine n'a pas pu être enregistré auprès du panneau, ou le certificat TLS n'a pas été mis à jour à temps. Vous pouvez continuer, mais la connexion MQTT peut échouer si le certificat du panneau n'inclut pas votre nom de domaine.", + "description": "Le nom de domaine n'a pas pu être enregistré auprès du panneau, ou le certificat TLS n'a pas été mis à jour à temps. Vous pouvez continuer : le panneau sera configuré à l'adresse que son certificat désigne réellement, et le nom de domaine n'est pas enregistré, car le panneau le rejetterait. Réessayez avec le nom de domaine depuis Reconfigurer une fois le problème résolu.", "title": "Enregistrement du domaine" }, "reauth_confirm": { @@ -81,10 +89,12 @@ }, "reconfigure": { "data": { - "host": "Hôte" + "host": "Hôte", + "https_port": "Port HTTPS" }, "data_description": { - "host": "Adresse IP ou nom d'hôte du SPAN Panel" + "host": "Adresse IP ou nom d'hôte du SPAN Panel", + "https_port": "Affiché pour un panneau dont l'autorité de certification est épinglée. Laissez 443 sauf si un proxy ou une redirection de port a déplacé le TLS du panneau." }, "description": "Mettez à jour l'adresse de l'hôte pour ce SPAN Panel. Le numéro de série du panneau doit correspondre.", "title": "Reconfigurer le SPAN Panel" @@ -93,7 +103,7 @@ "title": "Reconfiguration terminée" }, "reconfigure_fqdn_failed": { - "description": "Le nom de domaine n'a pas pu être enregistré auprès du panneau, ou le certificat TLS n'a pas été mis à jour à temps. Vous pouvez continuer, mais la connexion MQTT peut échouer si le certificat du panneau n'inclut pas votre nom de domaine.", + "description": "Le nom de domaine n'a pas pu être enregistré auprès du panneau, ou le certificat TLS n'a pas été mis à jour à temps. Vous pouvez continuer : le panneau sera configuré à l'adresse que son certificat désigne réellement, et le nom de domaine n'est pas enregistré, car le panneau le rejetterait. Réessayez avec le nom de domaine depuis Reconfigurer une fois le problème résolu.", "title": "Enregistrement du domaine" }, "reconfigure_register_fqdn": { @@ -119,6 +129,20 @@ }, "description": "Entrez les détails de connexion pour votre SPAN Panel.", "title": "Connectez-vous au Span Panel" + }, + "panel_https_port": { + "title": "Port HTTPS du panneau", + "description": "Vous avez défini un port HTTP non standard pour ce panneau : il sert peut-être aussi TLS sur un port non standard, par exemple derrière un proxy inverse. Indiquez le port sur lequel le certificat du panneau est servi.", + "data": { + "https_port": "Port HTTPS" + }, + "data_description": { + "https_port": "Laissez 443, sauf si un proxy ou une redirection de port l'a déplacé." + } + }, + "panel_ca": { + "title": "Lecture du certificat du panneau", + "description": "Home Assistant récupère l'autorité de certification publiée par votre panneau et vérifie que le certificat présenté par celui-ci est signé par elle. Cela se produit avant que vous ne saisissiez votre mot de passe : celui-ci, ainsi que les identifiants que le panneau renvoie en échange, transitent donc par une connexion vérifiée plutôt qu'en clair.\n\nSélectionnez **Valider** pour réessayer." } } }, @@ -147,6 +171,15 @@ }, "wifi_link": { "name": "Liaison Wi-Fi" + }, + "pcs_active": { + "name": "PCS Actif" + }, + "pv_panel_link": { + "name": "Liaison du panneau PV" + }, + "evse_panel_link": { + "name": "Liaison du panneau EVSE" } }, "button": { @@ -154,6 +187,11 @@ "name": "Forçage GFE : Connecté au Réseau" } }, + "number": { + "evse_charge_current_limit": { + "name": "Limite de Courant de Charge EVSE" + } + }, "select": { "circuit_priority": { "name": "Priorité du Circuit", @@ -177,9 +215,21 @@ "bess_model": { "name": "Modèle" }, + "bess_communication_state": { + "name": "État de Communication", + "state": { + "ok": "Correct", + "degraded": "Dégradé", + "lost": "Perdu", + "unknown": "Inconnu" + } + }, "bess_nameplate_capacity": { "name": "Capacité Nominale" }, + "bess_part_number": { + "name": "Référence" + }, "bess_serial_number": { "name": "Numéro de Série" }, @@ -231,6 +281,9 @@ "unlocked": "Déverrouillé" } }, + "evse_part_number": { + "name": "Référence" + }, "evse_status": { "name": "État du Chargeur", "state": { @@ -301,6 +354,15 @@ "unknown": "Inconnu" } }, + "mid_grid_state": { + "name": "État du réseau", + "state": { + "up": "Actif", + "down": "Coupé", + "degraded": "Dégradé", + "unknown": "Inconnu" + } + }, "pv_nameplate_capacity": { "name": "Capacité Nominale PV" }, @@ -313,12 +375,18 @@ "pv_vendor": { "name": "Fournisseur PV" }, + "shed_total_time_remaining": { + "name": "Autonomie de Secours Restante" + }, "site_power": { "name": "Puissance du Site" }, "software_version": { "name": "Version du Logiciel" }, + "time_to_priority_shed": { + "name": "Temps avant Délestage Prioritaire" + }, "upstream_l1_current": { "name": "Courant Amont L1" }, @@ -332,15 +400,75 @@ "unconnected": "Déconnecté", "unknown": "Inconnu" } + }, + "pcs_import_limit": { + "name": "Limite d'Importation" + }, + "pcs_binding_constraint": { + "name": "Contrainte Contraignante", + "state": { + "fsr": "Capacité Ferme du Service", + "doe": "Enveloppe Réseau", + "voltage": "Support de Tension", + "off_grid": "Hors Réseau", + "requested": "Demandé", + "operator": "Opérateur", + "none": "Aucun", + "unknown": "Inconnu" + } + }, + "bess_meter_power": { + "name": "Puissance du Compteur" + } + }, + "switch": { + "control_lock": { + "name": "Verrou de contrôle" } } }, "exceptions": { + "admin_only_control": { + "message": "Seul un administrateur Home Assistant peut piloter ce SPAN Panel." + }, + "adopted_control_failed": { + "message": "L'écriture de {property} sur {device} a été refusée : {reason}. Rien n'a été envoyé au panneau, la valeur est donc inchangée." + }, + "adopted_control_not_delivered": { + "message": "L'écriture de {property} sur {device} n'a pas pu être transmise au panneau : {reason}. Elle a été refusée plutôt que mise en file d'attente, la valeur est donc inchangée et ne changera pas plus tard." + }, "circuit_priority_failed": { - "message": "Échec de la définition de la priorité du circuit pour {circuit}." + "message": "Le changement de priorité pour {circuit} a été refusé : {reason}. Rien n'a été envoyé au panneau, la priorité est donc inchangée." + }, + "circuit_priority_not_delivered": { + "message": "Le changement de priorité pour {circuit} n'a pas pu être transmis au panneau : {reason}. Il a été refusé plutôt que mis en file d'attente, la priorité est donc inchangée." }, "circuit_relay_failed": { - "message": "Échec de la définition de l'état du relais du circuit pour {circuit}." + "message": "La commande du disjoncteur pour {circuit} a été refusée : {reason}. Rien n'a été envoyé au panneau, le disjoncteur n'a donc pas changé de position." + }, + "circuit_relay_not_delivered": { + "message": "La commande du disjoncteur pour {circuit} n'a pas pu être transmise au panneau : {reason}. Elle a été refusée plutôt que mise en file d'attente, le disjoncteur n'a donc pas changé de position et ne changera pas plus tard." + }, + "contextless_control_refused": { + "message": "Ce SPAN Panel refuse les commandes de contrôle qui ne proviennent pas d'un utilisateur connecté. Pour les autoriser, activez « Autoriser le contrôle sans utilisateur connecté » dans ses options." + }, + "control_disabled": { + "message": "Le contrôle de ce SPAN Panel est désactivé dans ses options. Rien n'a été envoyé au panneau." + }, + "control_lock_disarm_requires_admin": { + "message": "Seul un administrateur Home Assistant peut désarmer le verrou de contrôle du SPAN Panel." + }, + "control_lock_disarm_requires_user": { + "message": "Le verrou de contrôle du SPAN Panel ne peut être désarmé que par un administrateur connecté, pas par une automatisation ou un script." + }, + "control_locked": { + "message": "Le verrou de contrôle du SPAN Panel est armé. Désarmez-le avant de piloter des circuits." + }, + "evse_charge_limit_failed": { + "message": "Échec du réglage de la limite de courant de charge pour le chargeur {charger} : {reason}" + }, + "evse_charge_limit_not_delivered": { + "message": "La limite de courant de charge du chargeur {charger} n'a pas pu être transmise au panneau : {reason}. Elle a été refusée plutôt que mise en file d'attente, la limite est donc inchangée." }, "export_manifest_no_entries": { "message": "Aucune entrée de configuration SPAN Panel n'est chargée. Ajoutez et configurez un panneau SPAN avant d'appeler ce service." @@ -361,7 +489,10 @@ "message": "Le sous-appareil {entity_id} n'a pas de panneau SPAN parent — son via_device_id ne pointe pas vers un panneau SPAN." }, "gfe_override_failed": { - "message": "Échec de l'envoi du forçage GFE au panneau." + "message": "Le forçage GFE vers {value} a été refusé : {reason}. Rien n'a été envoyé au panneau." + }, + "gfe_override_not_delivered": { + "message": "Le forçage GFE vers {value} n'a pas pu être transmis au panneau : {reason}. Il a été refusé plutôt que mis en file d'attente, rien ne se produira donc plus tard." }, "graph_horizon_not_available": { "message": "Le gestionnaire d'horizon de graphique n'est pas disponible pour le panneau SPAN sélectionné." @@ -377,29 +508,70 @@ }, "panel_not_ready": { "message": "Le SPAN Panel n'est pas prêt. Veuillez vérifier que le panneau est en ligne." + }, + "relay_debounced": { + "message": "Le relais de ce circuit a été actionné il y a quelques instants. La commande a été refusée plutôt que mise en file d'attente." + }, + "rotate_credentials_auth_failed": { + "message": "Le SPAN Panel a rejeté le jeton d'accès enregistré. Réauthentifiez l'intégration avant de renouveler les identifiants." + }, + "rotate_credentials_ca_unusable": { + "message": "L'autorité de certification enregistrée pour ce SPAN Panel est illisible ; la rotation devrait donc circuler en clair. Rien n'a été modifié. Réparez l'autorité de certification du panneau, puis relancez la rotation." + }, + "rotate_credentials_failed": { + "message": "Le SPAN Panel à l'adresse {host} n'a pas terminé le renouvellement des identifiants. Les identifiants existants sont inchangés." + }, + "rotate_credentials_insufficient_privilege": { + "message": "Le jeton d'accès enregistré dispose de privilèges réduits et ne peut pas renouveler les identifiants. Réauthentifiez l'intégration avec la phrase secrète du panneau, puis relancez le renouvellement." + }, + "rotate_credentials_multiple_panels": { + "message": "Plusieurs SPAN Panel sont configurés. Choisissez le panneau à faire tourner dans le champ d'entrée de configuration : la rotation empêche l'ancien mot de passe du broker de fonctionner pour tous les autres clients de ce panneau." + }, + "rotate_credentials_no_entry": { + "message": "Aucun SPAN Panel utilisant l'API v2 n'a été trouvé." + }, + "rotate_credentials_no_token": { + "message": "Ce SPAN Panel n'a aucun jeton d'accès enregistré. Réauthentifiez l'intégration avant de renouveler les identifiants." + }, + "rotate_credentials_outcome_unknown": { + "message": "Le SPAN Panel à {host} n'a pas indiqué le résultat du renouvellement des identifiants. La phrase secrète du panneau et le mot de passe du broker ont peut-être changé. Relancez le renouvellement pour obtenir une phrase secrète connue. Si le panneau refuse ce renouvellement, réauthentifiez l'intégration ; si l'ancienne phrase secrète n'est plus acceptée, utilisez la preuve de proximité." + }, + "rotate_credentials_requires_admin": { + "message": "Seul un administrateur Home Assistant peut renouveler les identifiants du SPAN Panel." + }, + "rotate_credentials_requires_user": { + "message": "Le renouvellement des identifiants doit être lancé par un administrateur depuis l'interface utilisateur. Les automatisations, les scripts et les autres intégrations ne peuvent pas l'appeler." } }, "options": { "step": { "init": { "data": { - "show_panel": "Afficher le Panneau dans la Barre Latérale", - "panel_admin_only": "Utilisateurs Administrateurs Uniquement", + "allow_contextless_control": "Autoriser le contrôle sans utilisateur connecté", + "control_lock_timeout": "Réarmement automatique du verrou de contrôle (minutes)", + "control_mode": "Qui peut piloter le panneau", "enable_circuit_net_energy_sensors": "Énergie Nette du Circuit", "enable_energy_dip_compensation": "Compenser les Baisses d'Énergie Automatiquement", "enable_panel_net_energy_sensors": "Énergie Nette du Panneau", "enable_unmapped_circuit_sensors": "Capteurs de Circuits Non Assignés", "energy_reporting_grace_period": "Période de grâce du capteur d'énergie (minutes)", + "panel_admin_only": "Utilisateurs Administrateurs Uniquement", + "relay_debounce_seconds": "Anti-rebond du relais (secondes)", + "show_panel": "Afficher le Panneau dans la Barre Latérale", "snapshot_update_interval": "Intervalle de Mise à Jour du Snapshot (secondes)" }, "data_description": { - "show_panel": "Afficher le tableau de bord du panneau SPAN comme entrée de barre latérale.", - "panel_admin_only": "Restreindre l'entrée de la barre latérale du tableau de bord aux utilisateurs administrateurs.", + "allow_contextless_control": "Les automatisations, les scripts et les autres intégrations appellent les services sans utilisateur associé. Tant que cette option est désactivée, ces commandes sont refusées. Activez-la si vos automatisations pilotent des circuits.", + "control_lock_timeout": "-1 désactive le verrou de contrôle et supprime son interrupteur. 0 maintient le verrou armé jusqu'à ce que quelqu'un le désarme. Toute autre valeur le réarme ce nombre de minutes après son désarmement. Seuls les administrateurs peuvent le désarmer.", + "control_mode": "Par défaut, Home Assistant accorde à chaque utilisateur non administrateur le contrôle de toutes les entités. Le restreindre aux administrateurs est le seul moyen de changer cela. Le désactiver arrête la création des entités de contrôle ; leurs entrées de registre, noms et zones sont conservés.", "enable_circuit_net_energy_sensors": "Énergie nette par circuit, tenant compte de la puissance réactive.", "enable_energy_dip_compensation": "Compenser lorsque le panneau signale des lectures d'énergie inférieures. La désactivation efface les compensations.", "enable_panel_net_energy_sensors": "Énergie nette pour le compteur principal et la dérivation, tenant compte des flux bidirectionnels.", "enable_unmapped_circuit_sensors": "Capteurs sous-jacents pour les onglets non assignés à des disjoncteurs nommés.", "energy_reporting_grace_period": "Minutes pendant lesquelles les capteurs conservent leur dernière valeur lorsque le panneau est hors ligne (0–60). Par défaut : 15.", + "panel_admin_only": "Restreindre l'entrée de la barre latérale du tableau de bord aux utilisateurs administrateurs.", + "relay_debounce_seconds": "Refuse une seconde commande au relais du même circuit pendant ce nombre de secondes. Refusée, jamais mise en file : une commande de relais déclenchée quelques secondes plus tard sur un panneau qui a changé est pire qu'une erreur visible par l'auteur de l'automatisation. 0 la désactive.", + "show_panel": "Afficher le tableau de bord du panneau SPAN comme entrée de barre latérale.", "snapshot_update_interval": "Secondes entre les reconstructions de snapshot (0–15). Plus bas = plus rapide, plus de CPU. 0 = pas de temporisation." }, "description": "Configurer les paramètres de l'intégration SPAN Panel.", @@ -408,6 +580,13 @@ } }, "selector": { + "control_mode": { + "options": { + "admin_only": "Administrateurs uniquement", + "all_users": "Tout utilisateur Home Assistant", + "disabled": "Personne — ne pas créer d'entités de contrôle" + } + }, "entity_naming_pattern": { "options": { "circuit_numbers": "Numéros de Circuit (ex., span_panel_circuit_15_power)", @@ -693,6 +872,70 @@ "description": "N'importe quel capteur du circuit ou du sous-appareil à retirer des favoris." } } + }, + "rotate_credentials": { + "name": "Renouveler les identifiants du panneau", + "description": "Demande au SPAN Panel de remplacer sa phrase secrète, qui est actuellement aussi le mot de passe du broker MQTT eBus, enregistre le nouveau mot de passe du broker et recharge l'intégration. La phrase secrète du panneau et le mot de passe du broker changent tous les deux. Les jetons d'accès déjà émis, y compris celui de l'intégration, ne sont pas révoqués. La nouvelle phrase secrète est renvoyée dans la réponse et n'est pas enregistrée par l'intégration : conservez-la avant de fermer la réponse. Tout autre client local utilisant l'ancien mot de passe, comme une seconde instance Home Assistant, un script ou un outil tiers, doit être réapprovisionné avec le nouveau. Il se peut que le broker n'accepte pas le nouveau mot de passe dès que le panneau le renvoie ; l'intégration réessaie donc pendant environ une minute avant d'indiquer dans la réponse qu'elle ne s'est pas reconnectée. Dans ce cas, relancez la rotation, et l'intégration utilisera le mot de passe nouvellement renvoyé ; en dernier recours, redémarrez le panneau. Seul un administrateur Home Assistant peut l'exécuter, et il ne peut pas être appelé depuis une automatisation ou un script.", + "fields": { + "config_entry_id": { + "name": "Entrée de configuration", + "description": "Le panneau à faire tourner. Facultatif avec un seul panneau ; obligatoire lorsque plusieurs SPAN Panel sont chargés." + } + } + } + }, + "issues": { + "schema_field_unresolved": { + "title": "Champ de données du Panneau SPAN indisponible", + "description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. Entités concernées : {count} (par exemple : {examples}). Elles peuvent devenir indisponibles, ou renvoyer des données incomplètes, jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel." + }, + "schema_unit_mismatch": { + "title": "Incohérence d'unité du Panneau SPAN", + "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. Entités concernées : {count} (par exemple : {examples}). Leurs relevés et leurs statistiques à long terme peuvent être erronés. Elles n'ont pas été modifiées automatiquement." + }, + "panel_ca_changed": { + "title": "L'autorité de certification du SPAN Panel a changé", + "fix_flow": { + "step": { + "confirm": { + "title": "Faire confiance à la nouvelle autorité de certification ?", + "description": "{panel} annonce désormais cette autorité de certification :\n\n**{fingerprint}**\n\nL'accepter l'épingle et rétablit la connexion. Ne le faites que si vous savez pourquoi elle a changé : une mise à jour du micrologiciel ou une réinitialisation d'usine la renouvelle légitimement.\n\nSi rien n'aurait dû changer, n'acceptez pas. Un appareil de votre réseau se faisant passer pour votre panneau ressemble exactement à ceci, et accepter lui remettrait la connexion." + } + }, + "abort": { + "entry_gone": "Ce SPAN Panel n'est plus configuré.", + "ca_unreadable": "L'autorité de certification du panneau n'a pas pu être lue. Vérifiez que le panneau est joignable et réessayez.", + "ca_leaf_mismatch": "Le certificat présenté par ce panneau n'est pas signé par l'autorité qu'il a publiée ; cette autorité ne vous a donc pas été proposée et rien n'a été modifié. Vérifiez que le port HTTPS est correct et que rien n'intercepte la connexion, puis réessayez." + } + } + }, + "panel_ca_unusable": { + "title": "L'autorité de certification enregistrée du SPAN Panel est illisible", + "fix_flow": { + "step": { + "confirm": { + "title": "Faire confiance à l'autorité de certification du panneau ?", + "description": "L'autorité de certification enregistrée pour {panel} ne peut plus être lue, sa connexion ne peut donc plus être vérifiée et l'intégration s'est arrêtée plutôt que de continuer sans chiffrement.\n\nLe panneau annonce cette autorité de certification :\n\n**{fingerprint}**\n\nAccepter l'épingle et reconnecte. Comparez l'empreinte avec une autre installation ou avec le panneau lui-même si possible — un appareil de votre réseau se faisant passer pour le panneau présenterait sa propre empreinte." + } + }, + "abort": { + "entry_gone": "Ce SPAN Panel n'est plus configuré.", + "ca_unreadable": "L'autorité de certification du panneau n'a pas pu être lue. Vérifiez que le panneau est joignable et réessayez.", + "ca_leaf_mismatch": "Le certificat servi par ce panneau n'est pas signé par l'autorité qu'il a publiée ; cette autorité ne vous a donc pas été proposée et rien n'a été modifié. Vérifiez que le port HTTPS est correct et que rien n'intercepte la connexion, puis réessayez." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "Le port HTTPS du SPAN Panel sert un certificat non fiable", + "description": "Ce qui répond sur **{host}:{https_port}** a présenté un certificat que l'autorité de certification épinglée de {panel} (SHA-256 {fingerprint}) ne valide pas actuellement — alors que le panneau lui-même annonce toujours cette même autorité, qui n'a donc pas changé.\n\nDeux choses produisent cela. Quelque chose entre Home Assistant et le panneau — un proxy inverse, une redirection de port, un port HTTPS erroné sur l'entrée — termine TLS avec son propre certificat. Ou l'horloge du panneau s'est réinitialisée pendant une coupure et son certificat est hors de sa période de validité, ce qui se résout de lui-même dès que le panneau retrouve l'heure.\n\nL'intégration continue de réessayer sans se connecter, et ne revient jamais au HTTP non chiffré. Vérifiez le port HTTPS via *Reconfigurer* sur l'entrée SPAN Panel, ou retirez ce qui intercepte la connexion ; si le panneau vient de perdre l'alimentation, laissez-lui un peu de temps." + }, + "panel_leaf_name_mismatch": { + "title": "Le SPAN Panel n'est pas à l'adresse configurée", + "description": "{panel} est configuré comme **{host}**, mais le certificat qu'il présente ne nomme que : **{leaf_names}**. Le panneau a probablement changé d'adresse, ou l'adresse a été enregistrée sous un nom que le panneau ne se connaît pas.\n\nUtilisez *Reconfigurer* sur l'entrée SPAN Panel pour la pointer vers l'un des noms ci-dessus. L'intégration continue de réessayer entre-temps et se rétablira d'elle-même si le panneau revient à {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "Le SPAN Panel n'est pas à l'adresse configurée", + "description": "{panel} est configuré comme **{host}**, mais le certificat qu'il présente ne nomme aucune adresse ; il n'y a donc aucune adresse vers laquelle pointer l'entrée. C'est un défaut du certificat du panneau et non de l'adresse que vous avez configurée, et cela se résout généralement lorsque le panneau le régénère — un redémarrage du panneau est la manière habituelle.\n\nSi cela persiste, utilisez *Reconfigurer* sur l'entrée SPAN Panel pour rétablir la connexion. L'intégration continue de réessayer entre-temps et se rétablira d'elle-même si le panneau nomme à nouveau {host}." } } } diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index 8879fc0e..8e0723f5 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "スパンパネルがすでに設定されています。設定は一つのみです。", "cannot_connect": "スパンパネルへの接続に失敗しました", + "host_not_set": "パネルのアドレスが設定されていません。セットアップをやり直してください。", "no_devices_found": "ネットワーク上にデバイスが見つかりません", "no_host": "アドオンからホストアドレスが提供されませんでした。", "no_serial": "パネルのシリアル番号を特定できませんでした。", @@ -12,10 +13,17 @@ "v1_not_supported": "v1ファームウェアはサポートされていません。パネルのファームウェアをv2にアップグレードしてください。" }, "error": { + "ca_leaf_mismatch": "このパネルが提供する証明書は、パネルが公開した認証局で署名されていません。HTTPS ポートが正しいこと、接続が傍受されていないことを確認してから、もう一度お試しください。", + "ca_name_mismatch": "パネルはこのアドレスで応答しましたが、証明書がこのアドレスを名前として含んでいないため、以降の接続はすべて拒否されます。完全修飾ドメイン名で再構成すると、パネルに証明書の再生成を求めてこのアドレスを含めます。または、証明書が既に対象としているパネルの .local 名で再構成してください。", + "ca_unavailable": "パネルの認証局を読み取れませんでした。パネルに到達でき、電源が入っていることを確認してから、もう一度お試しください。これがないとセットアップは続行できません。続行するとパスフレーズが平文で送信されてしまうためです。", + "ca_unusable": "このパネルに保存されている認証局を読み取れないため、この変更は暗号化されずに送信されることになります。何も変更されていません。パネルを再認証して使用可能な認証局を保存してから、もう一度お試しください。", "cannot_connect": "スパンパネルへの接続に失敗しました", "fqdn_registration_failed": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。", "host_required": "ホストが必要です", + "https_port_is_plaintext": "ポート 80 は平文 HTTP のポートです。パネルが TLS を提供しているポートを入力してください — 移動されていなければ 443 です。", "invalid_auth": "認証が無効です", + "panel_not_ready": "パネルはまだ Home Assistant を登録する準備ができていません。通常はまだ起動中であることが原因です。しばらく待ってから、もう一度お試しください。", + "passphrase_unavailable": "パネルのパスフレーズがまだ利用できないため、パネルは認証情報を発行できません。入力したパスフレーズが間違っているという意味ではありません。数分待ってから、もう一度お試しください。", "proximity_failed": "近接が証明されませんでした。パネルのドアを3回開閉してから、もう一度お試しください。", "unknown": "予期しないエラー" }, @@ -68,7 +76,7 @@ "title": "スパンパネルに接続" }, "fqdn_failed": { - "description": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。続行できますが、パネルの証明書にドメイン名が含まれていない場合、MQTT接続が失敗する可能性があります。", + "description": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。続行できますが、パネルは証明書に記載されているアドレスで設定され、ドメイン名は保存されません。パネルがそのドメイン名を拒否するためです。登録を妨げていた問題を解決してから、「再設定」でドメイン名をもう一度お試しください。", "title": "ドメイン登録" }, "reauth_confirm": { @@ -81,10 +89,12 @@ }, "reconfigure": { "data": { - "host": "ホスト" + "host": "ホスト", + "https_port": "HTTPS ポート" }, "data_description": { - "host": "SPAN PanelのIPアドレスまたはホスト名" + "host": "SPAN PanelのIPアドレスまたはホスト名", + "https_port": "認証局が固定されているパネルで表示されます。プロキシやポート転送でパネルの TLS が移動していない限り、443 のままにしてください。" }, "description": "このSPAN Panelのホストアドレスを更新します。パネルのシリアル番号が一致する必要があります。", "title": "SPAN Panelの再設定" @@ -93,7 +103,7 @@ "title": "再設定完了" }, "reconfigure_fqdn_failed": { - "description": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。続行できますが、パネルの証明書にドメイン名が含まれていない場合、MQTT接続が失敗する可能性があります。", + "description": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。続行できますが、パネルは証明書に記載されているアドレスで設定され、ドメイン名は保存されません。パネルがそのドメイン名を拒否するためです。登録を妨げていた問題を解決してから、「再設定」でドメイン名をもう一度お試しください。", "title": "ドメイン登録" }, "reconfigure_register_fqdn": { @@ -119,6 +129,20 @@ }, "description": "SPAN Panelの接続詳細を入力してください。", "title": "スパンパネルに接続" + }, + "panel_https_port": { + "title": "パネルの HTTPS ポート", + "description": "このパネルに標準以外の HTTP ポートを設定しているため、リバースプロキシ経由などで TLS も標準以外のポートで提供されている可能性があります。パネルの証明書が提供されているポートを入力してください。", + "data": { + "https_port": "HTTPS ポート" + }, + "data_description": { + "https_port": "プロキシやポート変換で変更していない限り、443 のままにしてください。" + } + }, + "panel_ca": { + "title": "パネルの証明書を読み取っています", + "description": "Home Assistant は、パネルが公開している認証局を取得し、パネルが提供する証明書がその認証局で署名されていることを確認します。これはパスフレーズを入力する前に行われるため、パスフレーズと、その引き換えにパネルが返す認証情報は、平文ではなく検証済みの接続を通ります。\n\n**送信**を選ぶと再試行します。" } } }, @@ -147,6 +171,15 @@ }, "wifi_link": { "name": "Wi-Fiリンク" + }, + "pcs_active": { + "name": "PCS 動作中" + }, + "pv_panel_link": { + "name": "PVパネルリンク" + }, + "evse_panel_link": { + "name": "EVSEパネルリンク" } }, "button": { @@ -154,6 +187,11 @@ "name": "GFEオーバーライド:系統接続" } }, + "number": { + "evse_charge_current_limit": { + "name": "EVSE充電電流上限" + } + }, "select": { "circuit_priority": { "name": "回路優先度", @@ -177,9 +215,21 @@ "bess_model": { "name": "モデル" }, + "bess_communication_state": { + "name": "通信状態", + "state": { + "ok": "正常", + "degraded": "劣化", + "lost": "喪失", + "unknown": "不明" + } + }, "bess_nameplate_capacity": { "name": "定格容量" }, + "bess_part_number": { + "name": "型番" + }, "bess_serial_number": { "name": "シリアル番号" }, @@ -231,6 +281,9 @@ "unlocked": "アンロック" } }, + "evse_part_number": { + "name": "型番" + }, "evse_status": { "name": "充電器ステータス", "state": { @@ -301,6 +354,15 @@ "unknown": "不明" } }, + "mid_grid_state": { + "name": "系統状態", + "state": { + "up": "正常", + "down": "停電", + "degraded": "劣化", + "unknown": "不明" + } + }, "pv_nameplate_capacity": { "name": "PV定格容量" }, @@ -313,12 +375,18 @@ "pv_vendor": { "name": "PVベンダー" }, + "shed_total_time_remaining": { + "name": "バックアップ残り時間" + }, "site_power": { "name": "サイト電力" }, "software_version": { "name": "ソフトウェアバージョン" }, + "time_to_priority_shed": { + "name": "優先遮断までの時間" + }, "upstream_l1_current": { "name": "上流側L1電流" }, @@ -332,15 +400,75 @@ "unconnected": "未接続", "unknown": "不明" } + }, + "pcs_import_limit": { + "name": "インポート制限" + }, + "pcs_binding_constraint": { + "name": "拘束制約", + "state": { + "fsr": "確定サービス容量", + "doe": "系統エンベロープ", + "voltage": "電圧サポート", + "off_grid": "オフグリッド", + "requested": "要求", + "operator": "オペレーター", + "none": "なし", + "unknown": "不明" + } + }, + "bess_meter_power": { + "name": "メーター電力" + } + }, + "switch": { + "control_lock": { + "name": "コントロールロック" } } }, "exceptions": { + "admin_only_control": { + "message": "この SPAN Panel を操作できるのは Home Assistant の管理者だけです。" + }, + "adopted_control_failed": { + "message": "{device}の{property}への書き込みは拒否されました: {reason}。パネルには何も送信されていないため、値は変わっていません。" + }, + "adopted_control_not_delivered": { + "message": "{device}の{property}への書き込みをパネルに送信できませんでした: {reason}。キューに入れずに拒否したため、値は変わっておらず、後から変わることもありません。" + }, "circuit_priority_failed": { - "message": "{circuit}の回路優先度の設定に失敗しました。" + "message": "{circuit}の優先度変更は拒否されました: {reason}。パネルには何も送信されていないため、優先度は変わっていません。" + }, + "circuit_priority_not_delivered": { + "message": "{circuit}の優先度変更をパネルに送信できませんでした: {reason}。キューに入れずに拒否したため、優先度は変わっていません。" }, "circuit_relay_failed": { - "message": "{circuit}の回路リレー状態の設定に失敗しました。" + "message": "{circuit}のブレーカー操作は拒否されました: {reason}。パネルには何も送信されていないため、ブレーカーの状態は変わっていません。" + }, + "circuit_relay_not_delivered": { + "message": "{circuit}のブレーカー操作をパネルに送信できませんでした: {reason}。キューに入れずに拒否したため、ブレーカーの状態は変わっておらず、後から変わることもありません。" + }, + "contextless_control_refused": { + "message": "この SPAN Panel は、ログインユーザーからではない操作コマンドを拒否します。許可するには、オプションで「ログインユーザーなしの操作を許可する」を有効にしてください。" + }, + "control_disabled": { + "message": "この SPAN Panel の制御はオプションで無効になっています。パネルには何も送信されませんでした。" + }, + "control_lock_disarm_requires_admin": { + "message": "SPAN Panel のコントロールロックを解除できるのは Home Assistant の管理者だけです。" + }, + "control_lock_disarm_requires_user": { + "message": "SPAN Panel のコントロールロックを解除できるのは、ログインした管理者だけです。オートメーションやスクリプトからは解除できません。" + }, + "control_locked": { + "message": "SPAN Panel のコントロールロックが施錠されています。回路を操作する前に解除してください。" + }, + "evse_charge_limit_failed": { + "message": "充電器 {charger} の充電電流上限を設定できませんでした: {reason}" + }, + "evse_charge_limit_not_delivered": { + "message": "充電器 {charger} の充電電流上限をパネルに送信できませんでした: {reason}。キューに入れずに拒否したため、上限は変わっていません。" }, "export_manifest_no_entries": { "message": "SPANパネルの設定エントリが読み込まれていません。このサービスを呼び出す前に、SPANパネルを追加して設定してください。" @@ -361,7 +489,10 @@ "message": "サブデバイス {entity_id} に親のSPAN Panelデバイスがありません。via_device_id がSPANパネルを指していません。" }, "gfe_override_failed": { - "message": "パネルへのGFEオーバーライドの送信に失敗しました。" + "message": "{value}へのGFEオーバーライドは拒否されました: {reason}。パネルには何も送信されていません。" + }, + "gfe_override_not_delivered": { + "message": "{value}へのGFEオーバーライドをパネルに送信できませんでした: {reason}。キューに入れずに拒否したため、後から実行されることもありません。" }, "graph_horizon_not_available": { "message": "選択されたSPANパネルではグラフホライズンマネージャーが利用できません。" @@ -377,29 +508,70 @@ }, "panel_not_ready": { "message": "SPAN Panelの準備ができていません。パネルがオンラインであることを確認してください。" + }, + "relay_debounced": { + "message": "その回路のリレーはついさきほど操作されました。コマンドはキューに入れずに拒否しました。" + }, + "rotate_credentials_auth_failed": { + "message": "SPAN Panel が保存済みのアクセストークンを拒否しました。認証情報をローテーションする前に、統合を再認証してください。" + }, + "rotate_credentials_ca_unusable": { + "message": "この SPAN Panel に保存されている認証局を読み取れないため、認証情報のローテーションは暗号化されない通信になります。変更は行われていません。パネルの認証局を修復してから、もう一度ローテーションしてください。" + }, + "rotate_credentials_failed": { + "message": "{host} の SPAN Panel が認証情報のローテーションを完了しませんでした。既存の認証情報は変更されていません。" + }, + "rotate_credentials_insufficient_privilege": { + "message": "保存されているアクセストークンは権限が制限されているため、認証情報をローテーションできません。パネルのパスフレーズで統合を再認証してから、もう一度ローテーションしてください。" + }, + "rotate_credentials_multiple_panels": { + "message": "複数の SPAN Panel が設定されています。ローテーションするパネルを設定エントリのフィールドで選択してください。ローテーションすると、そのパネルの他のすべてのクライアントで以前のブローカーパスワードが使用できなくなります。" + }, + "rotate_credentials_no_entry": { + "message": "v2 API を使用する SPAN Panel が見つかりません。" + }, + "rotate_credentials_no_token": { + "message": "この SPAN Panel には保存済みのアクセストークンがありません。認証情報をローテーションする前に、統合を再認証してください。" + }, + "rotate_credentials_outcome_unknown": { + "message": "{host} の SPAN Panel は認証情報のローテーション結果を報告しませんでした。パネルのパスフレーズとブローカーのパスワードが変更されている可能性があります。もう一度ローテーションを実行すると、確実に把握できるパスフレーズが得られます。パネルがそのローテーションを拒否した場合は、統合を再認証してください。以前のパスフレーズが受け付けられない場合は、近接証明を使用してください。" + }, + "rotate_credentials_requires_admin": { + "message": "SPAN Panel の認証情報をローテーションできるのは Home Assistant の管理者だけです。" + }, + "rotate_credentials_requires_user": { + "message": "認証情報のローテーションは、管理者がユーザーインターフェースから実行する必要があります。オートメーション、スクリプト、他の統合からは呼び出せません。" } }, "options": { "step": { "init": { "data": { - "show_panel": "サイドバーにダッシュボードを表示", - "panel_admin_only": "管理ユーザーのみ", + "allow_contextless_control": "ログインユーザーなしの操作を許可する", + "control_lock_timeout": "コントロールロックの自動再施錠(分)", + "control_mode": "パネルを操作できるユーザー", "enable_circuit_net_energy_sensors": "回路正味エネルギー", "enable_energy_dip_compensation": "エネルギー低下を自動補正", "enable_panel_net_energy_sensors": "パネル正味エネルギー", "enable_unmapped_circuit_sensors": "未割当回路センサー", "energy_reporting_grace_period": "エネルギーセンサー猶予期間 (分)", + "panel_admin_only": "管理ユーザーのみ", + "relay_debounce_seconds": "リレーのデバウンス(秒)", + "show_panel": "サイドバーにダッシュボードを表示", "snapshot_update_interval": "スナップショット更新間隔(秒)" }, "data_description": { - "show_panel": "SPANパネルダッシュボードをサイドバーエントリとして表示します。", - "panel_admin_only": "ダッシュボードサイドバーエントリを管理ユーザーのみに制限します。", + "allow_contextless_control": "オートメーション、スクリプト、他の統合は、ユーザーが紐づかない状態でサービスを呼び出します。無効の間は、そうしたコマンドを拒否します。オートメーションで回路を操作する場合は有効にしてください。", + "control_lock_timeout": "-1 はコントロールロックを無効にし、そのスイッチを削除します。0 は誰かが解除するまで施錠したままにします。それ以外の値は、解除してからその分数後に再施錠します。解除できるのは管理者だけです。", + "control_mode": "Home Assistant は既定で、管理者以外のすべてのユーザーにすべてのエンティティの操作を許可します。管理者のみに制限することが、これを変更する唯一の方法です。無効にすると操作用エンティティが作成されなくなりますが、レジストリのエントリ、名前、エリアは保持されます。", "enable_circuit_net_energy_sensors": "無効電力を考慮した回路ごとの正味エネルギー。", "enable_energy_dip_compensation": "パネルがより低いエネルギー読み取り値を報告した場合に補正します。無効にするとオフセットがクリアされます。", "enable_panel_net_energy_sensors": "双方向フローを考慮した主幹メーターとフィードスルーの正味エネルギー。", "enable_unmapped_circuit_sensors": "名前付きブレーカーに割り当てられていないタブのバッキングセンサー。", "energy_reporting_grace_period": "パネルがオフライン時にセンサーが最後の値を保持する分数(0〜60)。デフォルト:15。", + "panel_admin_only": "ダッシュボードサイドバーエントリを管理ユーザーのみに制限します。", + "relay_debounce_seconds": "同じ回路のリレーへの 2 回目のコマンドを、この秒数のあいだ拒否します。キューに入れずに拒否します。状態の変わったパネルに対して数秒遅れで発火するリレーコマンドは、オートメーションの作成者が気づけるエラーよりも危険だからです。0 で無効になります。", + "show_panel": "SPANパネルダッシュボードをサイドバーエントリとして表示します。", "snapshot_update_interval": "スナップショット再構築の間隔(秒)(0〜15)。低い値=高速、CPU負荷増。0=デバウンスなし。" }, "description": "SPAN Panel統合の設定を構成します。", @@ -408,6 +580,13 @@ } }, "selector": { + "control_mode": { + "options": { + "admin_only": "管理者のみ", + "all_users": "すべての Home Assistant ユーザー", + "disabled": "なし — 操作用エンティティを作成しない" + } + }, "entity_naming_pattern": { "options": { "circuit_numbers": "回路番号(例:span_panel_circuit_15_power)", @@ -693,6 +872,70 @@ "description": "お気に入りから削除する回路またはサブデバイスの任意のセンサー。" } } + }, + "rotate_credentials": { + "name": "パネルの認証情報をローテーション", + "description": "SPAN Panel にパスフレーズ(現在は eBus MQTT ブローカーのパスワードを兼ねる)の置き換えを要求し、新しいブローカーのパスワードを保存して統合を再読み込みします。パネルのパスフレーズとブローカーのパスワードの両方が変わります。発行済みのアクセストークン(統合のものを含む)は失効しません。新しいパスフレーズはレスポンスで返され、統合には保存されないため、レスポンスを閉じる前に控えてください。以前のパスワードを使用している他のローカルクライアント(2 台目の Home Assistant、スクリプト、サードパーティのツールなど)は、新しいパスワードで再プロビジョニングする必要があります。パネルが新しいパスワードを返した直後は、ブローカーがそれを受け付けない場合があるため、統合は約 1 分間再試行し、それでも再接続できない場合はレスポンスでそのことを報告します。その場合は、ローテーションを再実行してください。統合は新たに返されたパスワードを使用します。最終手段として、パネルを再起動してください。実行できるのは Home Assistant の管理者だけで、オートメーションやスクリプトからは呼び出せません。", + "fields": { + "config_entry_id": { + "name": "設定エントリ", + "description": "ローテーションするパネル。パネルが 1 台の場合は任意ですが、複数の SPAN Panel が読み込まれている場合は必須です。" + } + } + } + }, + "issues": { + "schema_field_unresolved": { + "title": "SPAN パネルのデータフィールドが利用できません", + "description": "パネルのファームウェアが `{field_path}` を提供しなくなりました。{count} 個のエンティティが影響を受けます (例: {examples})。新しいファームウェアに対応する統合の更新が入るまで、これらは利用不可になるか、不完全なデータを報告する可能性があります。" + }, + "schema_unit_mismatch": { + "title": "SPAN パネルの単位の不一致", + "description": "パネルは `{field_path}` を {schema_unit} で報告していますが、この統合は {ha_unit} を想定しています。{count} 個のエンティティが影響を受けます (例: {examples})。これらの測定値と長期統計が誤っている可能性があります。エンティティは自動的には変更されていません。" + }, + "panel_ca_changed": { + "title": "SPAN Panel の認証局が変更されました", + "fix_flow": { + "step": { + "confirm": { + "title": "新しい認証局を信頼しますか?", + "description": "{panel} は現在、次の認証局を通知しています。\n\n**{fingerprint}**\n\n受け入れるとピン留めして再接続します。変更された理由が分かっている場合にのみ実行してください。ファームウェアの更新や工場出荷時リセットでは正当に更新されます。\n\n何も変わっていないはずなら、受け入れないでください。ネットワーク上の別の機器がパネルになりすましている場合もまったく同じように見え、受け入れるとその機器に接続を渡すことになります。" + } + }, + "abort": { + "entry_gone": "その SPAN Panel はすでに設定されていません。", + "ca_unreadable": "パネルの認証局を読み取れませんでした。パネルに到達できることを確認して、もう一度お試しください。", + "ca_leaf_mismatch": "このパネルが提供する証明書は、パネルが公開した認証局で署名されていません。そのため、その認証局は承認の対象として提示されず、何も変更されていません。HTTPS ポートが正しいこと、接続が傍受されていないことを確認してから、もう一度お試しください。" + } + } + }, + "panel_ca_unusable": { + "title": "SPAN Panel の保存済み認証局が読み取れません", + "fix_flow": { + "step": { + "confirm": { + "title": "パネルの認証局を信頼しますか?", + "description": "{panel} に保存されている認証局が読み取れなくなったため、接続を検証できず、暗号化なしで続行する代わりに統合を停止しました。\n\nパネルは次の認証局を公開しています:\n\n**{fingerprint}**\n\n承認するとこの認証局が固定され、再接続します。可能であれば、フィンガープリントを別のインストールまたはパネル本体と比較してください — ネットワーク上でパネルになりすます機器は、独自のフィンガープリントを提示します。" + } + }, + "abort": { + "entry_gone": "その SPAN Panel はもう設定されていません。", + "ca_unreadable": "パネルの認証局を読み取れませんでした。パネルに到達できることを確認して、もう一度お試しください。", + "ca_leaf_mismatch": "このパネルが提示する証明書は、パネルが公開した認証局によって署名されていないため、その認証局は承認対象として提示されず、何も変更されていません。HTTPS ポートが正しいこと、接続を傍受するものがないことを確認して、もう一度お試しください。" + } + } + }, + "panel_rest_tls_untrusted": { + "title": "SPAN Panel の HTTPS ポートが信頼できない証明書を提示しています", + "description": "**{host}:{https_port}** で応答しているものは、{panel} の固定された認証局(SHA-256 {fingerprint})が現在検証できない証明書を提示しました — 一方でパネル本体は同じ認証局を公開し続けているため、認証局は変更されていません。\n\n原因は二つ考えられます。Home Assistant とパネルの間の何か — リバースプロキシ、ポートフォワード、エントリの誤った HTTPS ポート — が独自の証明書で TLS を終端している場合。または、停電でパネルの時計がリセットされ、証明書が有効期間外になっている場合で、こちらはパネルが時刻を取得し直せば自然に解消します。\n\n統合は接続せずに再試行を続け、暗号化されていない HTTP には決して戻りません。SPAN Panel エントリの *再設定* で HTTPS ポートを確認するか、接続を傍受しているものを取り除いてください。パネルが停電直後の場合は、しばらく待ってください。" + }, + "panel_leaf_name_mismatch": { + "title": "SPAN Panel が設定されたアドレスにありません", + "description": "{panel} は **{host}** として設定されていますが、パネルが提供する証明書は **{leaf_names}** しか名前として持っていません。パネルのアドレスが変わったか、パネル自身が認識していない名前でアドレスが保存された可能性があります。\n\nSPAN Panel のエントリで *再構成* を使い、上記のいずれかの名前を指すように変更してください。その間も統合は再試行を続け、パネルが {host} に戻れば自動的に復旧します。" + }, + "panel_leaf_name_mismatch_no_names": { + "title": "SPAN Panel が設定されたアドレスにありません", + "description": "{panel} は **{host}** として設定されていますが、パネルが提供する証明書はアドレスをまったく名前として持っていないため、エントリを指し直す先がありません。これは設定したアドレスではなくパネルの証明書側の問題で、パネルが証明書を再生成すれば通常は解消します。パネルの再起動が一般的な方法です。\n\n解消しない場合は、SPAN Panel のエントリで *再構成* を使って接続をやり直してください。その間も統合は再試行を続け、パネルが再び {host} を名前として持つようになれば自動的に復旧します。" } } } diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index 3898d7f0..d0fd36e5 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Painel Span já configurado. Só é possível uma única configuração.", "cannot_connect": "Falha ao ligar ao Painel Span", + "host_not_set": "Não foi definido nenhum endereço do painel; reinicie a configuração.", "no_devices_found": "Não foram encontrados dispositivos na rede", "no_host": "Nenhum endereço de anfitrião foi fornecido pelo add-on.", "no_serial": "Não foi possível determinar o número de série do painel.", @@ -12,10 +13,17 @@ "v1_not_supported": "O firmware v1 não é suportado. Atualize o firmware do seu painel para v2." }, "error": { + "ca_leaf_mismatch": "O certificado que este painel serve não está assinado pela autoridade que publicou. Verifique se a porta HTTPS está correta e se nada está a intercetar a ligação, e tente novamente.", + "ca_name_mismatch": "O painel respondeu neste endereço mas o seu certificado não o nomeia, pelo que qualquer ligação posterior o rejeitaria. Reconfigure com um nome de domínio totalmente qualificado, o que pede ao painel que regenere o seu certificado para o incluir, ou com o nome .local do painel, que o seu certificado já cobre.", + "ca_unavailable": "Não foi possível ler a autoridade de certificação do painel. Verifique se o painel está acessível e ligado e tente novamente. A configuração não pode continuar sem ela, porque de outro modo a sua frase-passe seria enviada em claro.", + "ca_unusable": "A autoridade de certificação armazenada para este painel não pode ser lida, pelo que esta alteração teria de ser enviada sem cifra. Nada foi alterado. Volte a autenticar o painel para armazenar uma autoridade utilizável e tente novamente.", "cannot_connect": "Falha ao ligar ao Painel Span", "fqdn_registration_failed": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo.", "host_required": "O anfitrião é necessário", + "https_port_is_plaintext": "A porta 80 é a porta HTTP sem cifra. Introduza a porta em que o painel serve TLS — 443 a menos que algo a tenha movido.", "invalid_auth": "Autenticação inválida", + "panel_not_ready": "O painel ainda não está pronto para registar o Home Assistant, normalmente porque ainda está a arrancar. Aguarde um momento e tente novamente.", + "passphrase_unavailable": "O painel não consegue emitir credenciais porque a sua frase-passe ainda não está disponível. Isto não significa que tenha sido introduzida uma frase-passe incorreta. Aguarde alguns minutos e tente novamente.", "proximity_failed": "Proximidade não comprovada. Por favor, abra e feche a porta do painel 3 vezes e tente novamente.", "unknown": "Erro inesperado" }, @@ -68,7 +76,7 @@ "title": "Ligar ao Painel Span" }, "fqdn_failed": { - "description": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo. Pode continuar, mas a conexão MQTT pode falhar se o certificado do painel não incluir o seu nome de domínio.", + "description": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo. Pode continuar, e o painel será configurado no endereço que o certificado indica; o nome de domínio não é guardado, porque o painel iria rejeitá-lo. Tente novamente o nome de domínio em Reconfigurar assim que resolver o que impediu o registo.", "title": "Registo de Domínio" }, "reauth_confirm": { @@ -81,10 +89,12 @@ }, "reconfigure": { "data": { - "host": "Anfitrião" + "host": "Anfitrião", + "https_port": "Porta HTTPS" }, "data_description": { - "host": "Endereço IP ou nome do host do SPAN Panel" + "host": "Endereço IP ou nome do host do SPAN Panel", + "https_port": "Mostrado para um painel com autoridade de certificação fixada. Deixe 443 a menos que um proxy ou um reencaminhamento de porta tenha movido o TLS do painel." }, "description": "Atualize o endereço do anfitrião para este SPAN Panel. O número de série do painel deve coincidir.", "title": "Reconfigurar SPAN Panel" @@ -93,7 +103,7 @@ "title": "Reconfiguração Concluída" }, "reconfigure_fqdn_failed": { - "description": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo. Pode continuar, mas a conexão MQTT pode falhar se o certificado do painel não incluir o seu nome de domínio.", + "description": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo. Pode continuar, e o painel será configurado no endereço que o certificado indica; o nome de domínio não é guardado, porque o painel iria rejeitá-lo. Tente novamente o nome de domínio em Reconfigurar assim que resolver o que impediu o registo.", "title": "Registo de Domínio" }, "reconfigure_register_fqdn": { @@ -119,6 +129,20 @@ }, "description": "Insira os detalhes de conexão para seu SPAN Panel.", "title": "Ligar ao Painel Span" + }, + "panel_https_port": { + "title": "Porta HTTPS do painel", + "description": "Definiu uma porta HTTP não padrão para este painel, pelo que também poderá servir TLS numa porta não padrão — por exemplo através de um proxy inverso. Indique a porta em que o certificado do painel é servido.", + "data": { + "https_port": "Porta HTTPS" + }, + "data_description": { + "https_port": "Deixe em 443, a não ser que um proxy ou um mapeamento de portas a tenha mudado." + } + }, + "panel_ca": { + "title": "A ler o certificado do painel", + "description": "O Home Assistant obtém a autoridade de certificação que o seu painel publica e verifica que o certificado servido pelo painel está assinado por ela. Isto acontece antes de introduzir a sua frase-passe, pelo que a frase-passe e as credenciais que o painel devolve em troca viajam por uma ligação verificada e não em claro.\n\nSelecione **Submeter** para tentar novamente." } } }, @@ -147,6 +171,15 @@ }, "wifi_link": { "name": "Ligação Wi-Fi" + }, + "pcs_active": { + "name": "PCS Ativo" + }, + "pv_panel_link": { + "name": "Ligação do Painel FV" + }, + "evse_panel_link": { + "name": "Ligação do Painel EVSE" } }, "button": { @@ -154,6 +187,11 @@ "name": "Substituição GFE: Conectado à Rede" } }, + "number": { + "evse_charge_current_limit": { + "name": "Limite de Corrente de Carga EVSE" + } + }, "select": { "circuit_priority": { "name": "Prioridade do Circuito", @@ -177,9 +215,21 @@ "bess_model": { "name": "Modelo" }, + "bess_communication_state": { + "name": "Estado de Comunicação", + "state": { + "ok": "Correto", + "degraded": "Degradado", + "lost": "Perdido", + "unknown": "Desconhecido" + } + }, "bess_nameplate_capacity": { "name": "Capacidade Nominal" }, + "bess_part_number": { + "name": "Número de Peça" + }, "bess_serial_number": { "name": "Número de Série" }, @@ -231,6 +281,9 @@ "unlocked": "Desbloqueado" } }, + "evse_part_number": { + "name": "Número de Peça" + }, "evse_status": { "name": "Estado do Carregador", "state": { @@ -301,6 +354,15 @@ "unknown": "Desconhecido" } }, + "mid_grid_state": { + "name": "Estado da rede", + "state": { + "up": "Ativa", + "down": "Em falha", + "degraded": "Degradada", + "unknown": "Desconhecido" + } + }, "pv_nameplate_capacity": { "name": "Capacidade Nominal PV" }, @@ -313,12 +375,18 @@ "pv_vendor": { "name": "Fornecedor PV" }, + "shed_total_time_remaining": { + "name": "Tempo de Reserva Restante" + }, "site_power": { "name": "Potência do Local" }, "software_version": { "name": "Versão do Software" }, + "time_to_priority_shed": { + "name": "Tempo até o Corte por Prioridade" + }, "upstream_l1_current": { "name": "Corrente Upstream L1" }, @@ -332,15 +400,75 @@ "unconnected": "Desconectado", "unknown": "Desconhecido" } + }, + "pcs_import_limit": { + "name": "Limite de Importação" + }, + "pcs_binding_constraint": { + "name": "Restrição Vinculativa", + "state": { + "fsr": "Capacidade Firme do Serviço", + "doe": "Envelope da Rede", + "voltage": "Suporte de Tensão", + "off_grid": "Fora da Rede", + "requested": "Solicitado", + "operator": "Operador", + "none": "Nenhum", + "unknown": "Desconhecido" + } + }, + "bess_meter_power": { + "name": "Potência do Medidor" + } + }, + "switch": { + "control_lock": { + "name": "Bloqueio de controlo" } } }, "exceptions": { + "admin_only_control": { + "message": "Apenas um administrador do Home Assistant pode operar este SPAN Panel." + }, + "adopted_control_failed": { + "message": "A escrita de {property} em {device} foi recusada: {reason}. Nada foi enviado ao painel, portanto o valor permanece inalterado." + }, + "adopted_control_not_delivered": { + "message": "A escrita de {property} em {device} não pôde ser entregue ao painel: {reason}. Foi recusada em vez de enfileirada, portanto o valor permanece inalterado e não mudará mais tarde." + }, "circuit_priority_failed": { - "message": "Falha ao definir a prioridade do circuito para {circuit}." + "message": "A alteração de prioridade para {circuit} foi recusada: {reason}. Nada foi enviado ao painel, portanto a prioridade permanece inalterada." + }, + "circuit_priority_not_delivered": { + "message": "A alteração de prioridade para {circuit} não pôde ser entregue ao painel: {reason}. Foi recusada em vez de enfileirada, portanto a prioridade permanece inalterada." }, "circuit_relay_failed": { - "message": "Falha ao definir o estado do relé do circuito para {circuit}." + "message": "O comando do disjuntor para {circuit} foi recusado: {reason}. Nada foi enviado ao painel, portanto o disjuntor não mudou de posição." + }, + "circuit_relay_not_delivered": { + "message": "O comando do disjuntor para {circuit} não pôde ser entregue ao painel: {reason}. Foi recusado em vez de enfileirado, portanto o disjuntor não mudou de posição nem mudará mais tarde." + }, + "contextless_control_refused": { + "message": "Este SPAN Panel recusa comandos de controlo que não venham de um utilizador com sessão iniciada. Para os permitir, ative «Permitir controlo sem um utilizador com sessão iniciada» nas suas opções." + }, + "control_disabled": { + "message": "O controlo deste SPAN Panel está desativado nas suas opções. Nada foi enviado ao painel." + }, + "control_lock_disarm_requires_admin": { + "message": "Apenas um administrador do Home Assistant pode desarmar o bloqueio de controlo do SPAN Panel." + }, + "control_lock_disarm_requires_user": { + "message": "O bloqueio de controlo do SPAN Panel só pode ser desarmado por um administrador com sessão iniciada, não por uma automação ou script." + }, + "control_locked": { + "message": "O bloqueio de controlo do SPAN Panel está armado. Desarme-o antes de operar circuitos." + }, + "evse_charge_limit_failed": { + "message": "Falha ao definir o limite de corrente de carga do carregador {charger}: {reason}" + }, + "evse_charge_limit_not_delivered": { + "message": "O limite de corrente de carga do carregador {charger} não pôde ser entregue ao painel: {reason}. Foi recusado em vez de enfileirado, portanto o limite permanece inalterado." }, "export_manifest_no_entries": { "message": "Nenhuma entrada de configuração do painel SPAN está carregada. Adicione e configure um painel SPAN antes de chamar este serviço." @@ -361,7 +489,10 @@ "message": "O sub-dispositivo {entity_id} não tem um painel SPAN pai — o seu via_device_id não aponta para um painel SPAN." }, "gfe_override_failed": { - "message": "Falha ao enviar substituição GFE para o painel." + "message": "A substituição GFE para {value} foi recusada: {reason}. Nada foi enviado ao painel." + }, + "gfe_override_not_delivered": { + "message": "A substituição GFE para {value} não pôde ser entregue ao painel: {reason}. Foi recusada em vez de enfileirada, portanto nada acontecerá mais tarde." }, "graph_horizon_not_available": { "message": "O gerenciador de horizonte do gráfico não está disponível para o painel SPAN selecionado." @@ -377,29 +508,70 @@ }, "panel_not_ready": { "message": "O SPAN Panel não está pronto. Verifique se o painel está online." + }, + "relay_debounced": { + "message": "O relé desse circuito foi operado há instantes. O comando foi recusado em vez de colocado em fila." + }, + "rotate_credentials_auth_failed": { + "message": "O SPAN Panel rejeitou o token de acesso guardado. Volte a autenticar a integração antes de rodar as credenciais." + }, + "rotate_credentials_ca_unusable": { + "message": "A autoridade de certificação guardada para este SPAN Panel não pode ser lida, pelo que a rotação teria de viajar sem encriptação. Nada foi alterado. Repare a autoridade de certificação do painel e rode novamente." + }, + "rotate_credentials_failed": { + "message": "O SPAN Panel em {host} não concluiu a rotação de credenciais. As credenciais existentes mantêm-se inalteradas." + }, + "rotate_credentials_insufficient_privilege": { + "message": "O token de acesso guardado tem privilégios reduzidos e não pode rodar as credenciais. Volte a autenticar a integração com a frase-passe do painel e rode novamente." + }, + "rotate_credentials_multiple_panels": { + "message": "Está configurado mais do que um SPAN Panel. Escolha o painel a rodar no campo da entrada de configuração: a rotação faz com que a palavra-passe anterior do broker deixe de funcionar para todos os outros clientes desse painel." + }, + "rotate_credentials_no_entry": { + "message": "Não foi encontrado nenhum SPAN Panel que utilize a API v2." + }, + "rotate_credentials_no_token": { + "message": "Este SPAN Panel não tem um token de acesso guardado. Volte a autenticar a integração antes de rodar as credenciais." + }, + "rotate_credentials_outcome_unknown": { + "message": "O SPAN Panel em {host} não comunicou o resultado da rotação de credenciais. A frase-passe do painel e a palavra-passe do broker podem ter mudado. Execute a rotação novamente para obter uma frase-passe conhecida. Se o painel recusar essa rotação, volte a autenticar a integração; se a frase-passe antiga já não for aceite, use a prova de proximidade." + }, + "rotate_credentials_requires_admin": { + "message": "Apenas um administrador do Home Assistant pode rodar as credenciais do SPAN Panel." + }, + "rotate_credentials_requires_user": { + "message": "A rotação de credenciais tem de ser executada por um administrador a partir da interface de utilizador. Automações, scripts e outras integrações não a podem invocar." } }, "options": { "step": { "init": { "data": { - "show_panel": "Mostrar Painel na Barra Lateral", - "panel_admin_only": "Apenas Usuários Administradores", + "allow_contextless_control": "Permitir controlo sem um utilizador com sessão iniciada", + "control_lock_timeout": "Rearme automático do bloqueio de controlo (minutos)", + "control_mode": "Quem pode operar o painel", "enable_circuit_net_energy_sensors": "Energia Líquida do Circuito", "enable_energy_dip_compensation": "Compensar Quedas de Energia Automaticamente", "enable_panel_net_energy_sensors": "Energia Líquida do Painel", "enable_unmapped_circuit_sensors": "Sensores de Circuitos Não Mapeados", "energy_reporting_grace_period": "Período de Graça do Sensor de Energia (minutos)", + "panel_admin_only": "Apenas Usuários Administradores", + "relay_debounce_seconds": "Antirrepique do relé (segundos)", + "show_panel": "Mostrar Painel na Barra Lateral", "snapshot_update_interval": "Intervalo de Atualização do Snapshot (segundos)" }, "data_description": { - "show_panel": "Exiba o painel de controle do SPAN como uma entrada de barra lateral.", - "panel_admin_only": "Restrinja a entrada da barra lateral do painel de controle apenas aos usuários administradores.", + "allow_contextless_control": "Automações, scripts e outras integrações invocam serviços sem qualquer utilizador associado. Enquanto esta opção estiver desativada, esses comandos são recusados. Ative-a se as suas automações operarem circuitos.", + "control_lock_timeout": "-1 desativa o bloqueio de controlo e remove o respetivo interruptor. 0 mantém o bloqueio armado até alguém o desarmar. Qualquer outro valor rearma-o esse número de minutos depois de ser desarmado. Só os administradores o podem desarmar.", + "control_mode": "Por predefinição, o Home Assistant concede a todos os utilizadores não administradores o controlo de todas as entidades. Restringir a administradores é a única forma de alterar isso. Desativar deixa de criar as entidades de controlo; as respetivas entradas de registo, nomes e áreas são mantidos.", "enable_circuit_net_energy_sensors": "Energia líquida por circuito, contabilizando potência reativa.", "enable_energy_dip_compensation": "Compensar quando o painel reporta leituras de energia mais baixas. Ao desativar, as compensações são apagadas.", "enable_panel_net_energy_sensors": "Energia líquida para o medidor principal e derivação, contabilizando fluxos bidirecionais.", "enable_unmapped_circuit_sensors": "Sensores de apoio para posições não atribuídas a disjuntores nomeados.", "energy_reporting_grace_period": "Minutos que os sensores mantêm o último valor quando o painel está offline (0–60). Padrão: 15.", + "panel_admin_only": "Restrinja a entrada da barra lateral do painel de controle apenas aos usuários administradores.", + "relay_debounce_seconds": "Recusa um segundo comando ao relé do mesmo circuito dentro deste número de segundos. Recusado, nunca colocado em fila: um comando de relé disparado segundos mais tarde contra um painel que mudou é pior do que um erro que o autor da automação consegue ver. 0 desativa.", + "show_panel": "Exiba o painel de controle do SPAN como uma entrada de barra lateral.", "snapshot_update_interval": "Segundos entre reconstruções do snapshot (0–15). Menor = mais rápido, mais CPU. 0 = sem debounce." }, "description": "Configurar os ajustes da integração SPAN Panel.", @@ -408,6 +580,13 @@ } }, "selector": { + "control_mode": { + "options": { + "admin_only": "Apenas administradores", + "all_users": "Qualquer utilizador do Home Assistant", + "disabled": "Ninguém — não criar entidades de controlo" + } + }, "entity_naming_pattern": { "options": { "circuit_numbers": "Números de Circuito (ex., span_panel_circuit_15_power)", @@ -693,6 +872,70 @@ "description": "Qualquer sensor do circuito ou sub-dispositivo a remover dos favoritos." } } + }, + "rotate_credentials": { + "name": "Rodar as credenciais do painel", + "description": "Pede ao SPAN Panel que substitua a sua frase-passe, que atualmente é também a palavra-passe do broker MQTT eBus, guarda a nova palavra-passe do broker e recarrega a integração. Mudam tanto a frase-passe do painel como a palavra-passe do broker. Os tokens de acesso já emitidos, incluindo o da integração, não são revogados. A nova frase-passe é devolvida na resposta e não é guardada pela integração, pelo que deve guardá-la antes de fechar a resposta. Qualquer outro cliente local que use a palavra-passe anterior, como uma segunda instância do Home Assistant, um script ou ferramentas de terceiros, tem de ser reaprovisionado com a nova. O broker pode não aceitar a nova palavra-passe assim que o painel a devolve, pelo que a integração tenta novamente durante cerca de um minuto antes de indicar na resposta que não voltou a ligar-se. Nesse caso, execute a rotação novamente, e a integração usa a palavra-passe acabada de devolver; como último recurso, reinicie o painel. Só um administrador do Home Assistant o pode executar, e não pode ser invocado a partir de uma automação ou script.", + "fields": { + "config_entry_id": { + "name": "Entrada de configuração", + "description": "Que painel rodar. Opcional com um único painel; obrigatório quando está carregado mais do que um SPAN Panel." + } + } + } + }, + "issues": { + "schema_field_unresolved": { + "title": "Campo de dados do Painel SPAN indisponível", + "description": "O firmware do seu painel já não fornece `{field_path}`. Entidades afetadas: {count} (por exemplo: {examples}). Podem ficar indisponíveis, ou comunicar dados incompletos, até que uma atualização da integração adicione suporte ao novo firmware." + }, + "schema_unit_mismatch": { + "title": "Discrepância de unidades do Painel SPAN", + "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. Entidades afetadas: {count} (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente." + }, + "panel_ca_changed": { + "title": "A autoridade de certificação do SPAN Panel mudou", + "fix_flow": { + "step": { + "confirm": { + "title": "Confiar na nova autoridade de certificação?", + "description": "{panel} anuncia agora esta autoridade de certificação:\n\n**{fingerprint}**\n\nAceitá-la fixa-a e volta a ligar. Faça-o apenas se souber por que mudou — uma atualização de firmware ou uma reposição de fábrica rodam-na legitimamente.\n\nSe nada devia ter mudado, não aceite. Um equipamento da sua rede a fazer-se passar pelo seu painel tem exatamente este aspeto, e aceitar entregar-lhe-ia a ligação." + } + }, + "abort": { + "entry_gone": "Esse SPAN Panel já não está configurado.", + "ca_unreadable": "Não foi possível ler a autoridade de certificação do painel. Verifique se o painel está acessível e tente novamente.", + "ca_leaf_mismatch": "O certificado que este painel serve não está assinado pela autoridade que publicou, pelo que essa autoridade não foi proposta para aceitação e nada foi alterado. Verifique se a porta HTTPS está correta e se nada está a intercetar a ligação, e tente novamente." + } + } + }, + "panel_ca_unusable": { + "title": "A autoridade de certificação armazenada do SPAN Panel está ilegível", + "fix_flow": { + "step": { + "confirm": { + "title": "Confiar na autoridade de certificação do painel?", + "description": "A autoridade de certificação armazenada para {panel} já não pode ser lida, pelo que a sua ligação não pode ser verificada e a integração parou em vez de continuar sem cifragem.\n\nO painel está a anunciar esta autoridade de certificação:\n\n**{fingerprint}**\n\nAceitar fixa-a e reconecta. Compare a impressão digital com outra instalação ou com o próprio painel se puder — um dispositivo na sua rede a fazer-se passar pelo painel apresentaria uma impressão digital própria." + } + }, + "abort": { + "entry_gone": "Esse SPAN Panel já não está configurado.", + "ca_unreadable": "Não foi possível ler a autoridade de certificação do painel. Verifique se o painel está acessível e tente novamente.", + "ca_leaf_mismatch": "O certificado que este painel serve não está assinado pela autoridade que publicou, pelo que essa autoridade não lhe foi oferecida para aceitar e nada foi alterado. Verifique se a porta HTTPS está correta e se nada está a intercetar a ligação, e tente novamente." + } + } + }, + "panel_rest_tls_untrusted": { + "title": "A porta HTTPS do SPAN Panel está a servir um certificado não confiável", + "description": "O que responde em **{host}:{https_port}** apresentou um certificado que a autoridade de certificação fixada de {panel} (SHA-256 {fingerprint}) não valida atualmente — enquanto o próprio painel continua a anunciar essa mesma autoridade, pelo que a autoridade não mudou.\n\nDuas coisas produzem isto. Algo entre o Home Assistant e o painel — um proxy inverso, um reencaminhamento de porta, uma porta HTTPS errada na entrada — está a terminar TLS com um certificado próprio. Ou o relógio do painel foi reposto numa falha de energia e o seu certificado está fora do período de validade, o que se resolve sozinho assim que o painel voltar a ter horas.\n\nA integração continua a tentar sem se ligar, e nunca recorre a HTTP sem cifra. Verifique a porta HTTPS com *Reconfigurar* na entrada do SPAN Panel, ou remova o que está a intercetar a ligação; se o painel acabou de perder energia, dê-lhe algum tempo." + }, + "panel_leaf_name_mismatch": { + "title": "O SPAN Panel não está no endereço configurado", + "description": "{panel} está configurado como **{host}**, mas o certificado que serve nomeia apenas: **{leaf_names}**. É provável que o painel tenha mudado de endereço, ou que o endereço tenha sido guardado com um nome pelo qual o painel não se conhece a si próprio.\n\nUse *Reconfigurar* na entrada do SPAN Panel para a apontar para um dos nomes acima. Entretanto, a integração continua a tentar e recupera sozinha se o painel voltar a {host}." + }, + "panel_leaf_name_mismatch_no_names": { + "title": "O SPAN Panel não está no endereço configurado", + "description": "{panel} está configurado como **{host}**, mas o certificado que serve não nomeia qualquer endereço, pelo que não há endereço nenhum para onde apontar a entrada. É uma falha do certificado do painel e não do endereço que configurou, e costuma resolver-se quando o painel o regenera — reiniciar o painel é a forma habitual.\n\nSe persistir, use *Reconfigurar* na entrada do SPAN Panel para restabelecer a ligação. Entretanto, a integração continua a tentar e recupera sozinha se o painel voltar a nomear {host}." } } } diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index a42ce0de..f027b211 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -1,29 +1,186 @@ """Utility functions for the Span integration.""" +from dataclasses import dataclass import logging +from typing import Final from homeassistant.helpers.device_registry import DeviceInfo -from span_panel_api import SpanBatterySnapshot, SpanEvseSnapshot, SpanPanelSnapshot +from span_panel_api import ( + SpanBatterySnapshot, + SpanEvseSnapshot, + SpanMidSnapshot, + SpanPanelSnapshot, + SpanPVSnapshot, +) from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +# Every SPAN sub-device hangs off the panel and says so with `via_device_id` -- +# the panel's *registry* id, not its identifiers. Identifiers are unique only +# within a config entry, so linking by them is ambiguous by construction; Home +# Assistant deprecated `via_device` for that reason and stops honouring it in +# 2027.8. The id is resolved once during setup and carried on the entry's +# runtime data, because a sub-device is only ever built after the panel device +# exists -- which is what lets the builders below take a plain `str` and leaves +# no caller with an absence to handle. + +# A sub-device's registry identifier is `{panel serial}_{kind}`, with EVSE +# carrying its node id after the kind because a panel can have several. PV takes +# both shapes: `{panel serial}_pv` for the panel's solar card, and +# `{panel serial}_pv_{inverter key}` for each inverter on a panel with more than +# one. +# +# Named here, beside the builders that construct them, because the topology +# WebSocket command has to read the grammar back. It used to restate it, and the +# MID shipped classifying as "unknown" for exactly that reason: a third kind was +# added to the writing end and not to the reading one. `classify_sub_device_identifier` +# is the reading end, so the two cannot drift again. +SUB_DEVICE_BESS: Final = "bess" +SUB_DEVICE_MID: Final = "mid" +SUB_DEVICE_EVSE: Final = "evse" +SUB_DEVICE_PV: Final = "pv" + +BOOLEAN_DATATYPE: Final = "boolean" +ENUM_DATATYPE: Final = "enum" +NUMERIC_DATATYPES: Final = frozenset({"float", "integer"}) +"""The wire datatypes an eBus `$datatype` declares, as far as this integration reads them. + +Here rather than in `adoption.py`, where they were first written, because three +modules now decide something from a declared datatype and only one of them is +about adopting a device: `extension.py` picks a platform from it, and +`curation.py` refuses a state class off a row that is not numeric. `util.py` is +the module all three already import from, so this is the one home that does not +make a reader of one feature import the other. +""" + + +def declares_a_number(datatype: str, unit: str | None) -> bool: + """Say whether a declaration describes a row whose value is a number. + + Either half is enough. The declared `$datatype` is the truth of it; a + declared unit is taken as saying the same thing, because a publisher that + omits a `$datatype` still declares `W`, and a property carrying a unit is a + number whatever else it says. + + The unit alone used to answer this, as a proxy, and read a bare count -- an + `integer` with no unit -- as text. Here rather than in each caller because + two of them decide different things from the same question and must not + answer it differently: `adoption` and `extension` parse a published value + with it, and `curation` offers the device classes that go behind that value. + A row whose reading is a float and whose only offered device class is `enum` + is the incoherence this being one function prevents. + """ + return unit is not None or datatype in NUMERIC_DATATYPES + + +ADOPTED_IDENTIFIER_TOKEN: Final = "adopted" +"""The infix marking a sub-device identifier as adopted rather than curated. + +An adopted device is `{panel serial}_adopted_{anchor}`, where the anchor is +whatever the device was first seen under. Kept here beside the curated kinds +because the two namespaces have to be readable apart, and the reading end below +is what would otherwise mistake one for the other: a vendor device whose id +happens to end in `pv` would classify as the solar sub-device under a suffix +rule that had never heard of adoption. +""" + + +def classify_sub_device_identifier(identifier: str) -> str | None: + """Return the kind of sub-device an identifier names, or None if it names none. + + None rather than an "unknown" string: the caller knows whether it is looking + at something that must be a sub-device, and a sentinel that reads like a kind + is what let an unclassified device render as a device with no type. + + **Most specific first.** EVSE is the one kind whose token is an infix rather + than a suffix, and a suffix test cannot tell `..._evse_inverter_pv` from a PV + identifier. Testing the infix first makes the charger's node id opaque to the + suffix rules below, which is the only ordering that stays right whatever a + panel names its nodes. + """ + # Adopted devices are not a curated kind and must not be read as one. Tested + # before every suffix rule below, because the anchor that follows the token + # is vendor vocabulary: a device id ending in `pv` would otherwise classify + # as the panel's solar sub-device. + if f"_{ADOPTED_IDENTIFIER_TOKEN}_" in identifier: + return None + + # Infix, not suffix: the node id follows, and it is what distinguishes one + # charger from another on the same panel. + if f"_{SUB_DEVICE_EVSE}_" in identifier: + return SUB_DEVICE_EVSE + # An inverter's own card, whose key follows the kind as a charger's does. + # After the EVSE test for the same reason the suffix rules are: a charger's + # node id is opaque, whatever it contains. + if f"_{SUB_DEVICE_PV}_" in identifier: + return SUB_DEVICE_PV + if identifier.endswith(f"_{SUB_DEVICE_BESS}"): + return SUB_DEVICE_BESS + if identifier.endswith(f"_{SUB_DEVICE_MID}"): + return SUB_DEVICE_MID + if identifier.endswith(f"_{SUB_DEVICE_PV}"): + return SUB_DEVICE_PV + return None + + +@dataclass(frozen=True, slots=True) +class PvDeviceRef: + """Which PV card an identifier names: the Solar card, or one inverter's. There is no third kind.""" + + inverter_key: str | None + """The `pv_inverters` key of an inverter's own card; `None` for the Solar card.""" + + +def pv_device_ref(identifier: str, panel_identifier: str) -> PvDeviceRef | None: + """Read a PV identifier back, the reading end of `pv_device_info` and `pv_inverter_device_info`. + + Anchored on this panel's own prefix rather than tested as an infix, as + `classify_sub_device_identifier` must be for kinds it cannot anchor: here + the panel is known, so a charger whose node id contains `_pv_`, an adopted + device, or another panel's card cannot be mistaken for one of these. + """ + solar = f"{panel_identifier}_{SUB_DEVICE_PV}" + if identifier == solar: + return PvDeviceRef(None) + key = identifier.removeprefix(f"{solar}_") + if key and key != identifier: + return PvDeviceRef(key) + return None + def snapshot_to_device_info( snapshot: SpanPanelSnapshot, device_name: str | None = None, host: str | None = None, ) -> DeviceInfo: - """Convert a SpanPanelSnapshot to a Home Assistant device info object.""" + """Convert a SpanPanelSnapshot to a Home Assistant device info object. + + Manufacturer, model and hardware revision come from the enclosure's own + `info` node where it publishes them, and fall back to the strings this + integration has always shown where it does not. + + **The fallbacks are the point, not a courtesy.** Flat firmware declares none + of the three, so every existing installation lands on them; a panel that + omits one must keep the card it has rather than losing a row. `hw_version` + has no such string to fall back to and so is simply absent on flat -- + `DeviceInfo` omits a `None` field, which is the difference between "this + panel does not report a revision" and "this panel reports a blank one". + """ configuration_url = f"http://{host}" if host else None return DeviceInfo( identifiers={(DOMAIN, snapshot.serial_number)}, - manufacturer="Span", - model="SPAN Panel", + manufacturer=snapshot.vendor_name or "Span", + # The published designation is the panel's own model code (`MAIN_40`), + # which is also what `panel_size` is derived from. Showing it beats + # "SPAN Panel" on a card whose whole job is saying which hardware this + # is -- and the generic string remains for anything that publishes none. + model=snapshot.model or "SPAN Panel", name=device_name or "Span Panel", sw_version=snapshot.firmware_version, + hw_version=snapshot.hardware_version, configuration_url=configuration_url, ) @@ -32,36 +189,214 @@ def bess_device_info( panel_identifier: str, battery: SpanBatterySnapshot, panel_name: str, + *, + panel_device_id: str, ) -> DeviceInfo: - """Create DeviceInfo for a BESS sub-device linked to the parent panel.""" + """Create DeviceInfo for a BESS sub-device linked to the parent panel. + + Two panel-shaped arguments doing different jobs: `panel_identifier` is the + serial this sub-device namespaces its own identity under, `panel_device_id` + is the registry id it links to. + """ name = f"{panel_name} Battery" return DeviceInfo( - identifiers={(DOMAIN, f"{panel_identifier}_bess")}, + identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_BESS}")}, name=name, manufacturer=battery.vendor_name or "Unknown", - model=battery.product_name or "Battery Storage", + # `model` is the human designation on both schemas now: v1.0 publishes it as + # `info/model` and schema_0 translates flat's `bess/product-name` into it. + model=battery.model or "Battery Storage", serial_number=battery.serial_number, sw_version=battery.software_version, - via_device=(DOMAIN, panel_identifier), + via_device_id=panel_device_id, + ) + + +def mid_device_info( + panel_identifier: str, + mid: SpanMidSnapshot, + panel_name: str, + *, + panel_device_id: str, +) -> DeviceInfo: + """Create DeviceInfo for the Microgrid Interconnect Device. + + v1.0 publishes the MID as a device of its own and puts the `grid` capability on it + rather than on the enclosure — "the enclosure device itself does not publish them" — + so islanding decisions belong to hardware with its own identity. Registering it here + keeps the integration's model in step with the library's rather than folding a + device's properties onto the panel. + + Purely additive: no flat panel publishes a MID, so nothing a user has today changes. + + Linked to the panel, matching BESS and EVSE, even though the wire tree makes the + MID a child of the BESS. Home Assistant's device graph is about what a user navigates, + and every SPAN sub-device hangs off the panel there; mirroring the Homie parentage + would put the MID one level deeper than its siblings for no reader's benefit. + """ + return DeviceInfo( + identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_MID}")}, + name=f"{panel_name} Microgrid Interconnect", + manufacturer=mid.vendor_name or "Unknown", + model=mid.model or "Microgrid Interconnect Device", + serial_number=mid.serial_number, + # Passed through unguarded, exactly as `bess_device_info` does: `DeviceInfo` + # omits a `None` field and renders an empty string as a present-but-blank row, + # so `or ""` here would invent a row for a panel that published nothing. The + # library preserves that distinction for the same reason. + # + # Unguarded on schema, deliberately: r202633 documents both on the MID's `info` + # node, and flat publishes no MID at all, so `has_mid` keeps every caller of this + # builder off a flat panel. A conditional here would be unreachable code implying + # a case that cannot arise. + sw_version=mid.software_version, + hw_version=mid.hardware_version, + via_device_id=panel_device_id, ) +EMPTY_EVSE: Final = SpanEvseSnapshot(node_id="", feed_circuit_id="") +"""Stand-in for a charger that has left the snapshot mid-session. + +One instance rather than one per platform: the binary sensor, the number and the +sensor each carried their own, and a fallback that differs between platforms is +one charger rendering three ways. Safe to share because `SpanEvseSnapshot` is +frozen -- every field below the two ids is the `None` the library gives an +unpublished property, so a charger that has gone away reads exactly like one that +never published, which is what the builder below already handles. +""" + + +def evse_display_name( + evse: SpanEvseSnapshot, + panel_name: str, + display_suffix: str | None = None, +) -> str: + """Return what this charger is called wherever a person reads about it. + + Separate from the builder below because a charger is named in two places + that have to agree: its device card, and any message about it. The wire + calls a charger by its node id, which is what the panel needs and what + nobody reading a notification can act on -- so anything user-facing asks + here instead. + """ + base_name = evse.model or "EV Charger" + name = f"{base_name} ({display_suffix})" if display_suffix else base_name + return f"{panel_name} {name}" + + def evse_device_info( panel_identifier: str, evse: SpanEvseSnapshot, panel_name: str, display_suffix: str | None = None, + *, + panel_device_id: str, ) -> DeviceInfo: """Create DeviceInfo for an EVSE sub-device linked to the parent panel.""" - base_name = evse.product_name or "EV Charger" - name = f"{base_name} ({display_suffix})" if display_suffix else base_name - name = f"{panel_name} {name}" return DeviceInfo( - identifiers={(DOMAIN, f"{panel_identifier}_evse_{evse.node_id}")}, - name=name, + identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_EVSE}_{evse.node_id}")}, + name=evse_display_name(evse, panel_name, display_suffix), manufacturer=evse.vendor_name or "SPAN", - model=evse.product_name or "SPAN Drive", + model=evse.model or "SPAN Drive", serial_number=evse.serial_number, sw_version=evse.software_version, - via_device=(DOMAIN, panel_identifier), + via_device_id=panel_device_id, + ) + + +def pv_device_info( + panel_identifier: str, + pv: SpanPVSnapshot, + panel_name: str, + *, + panel_device_id: str, +) -> DeviceInfo: + """Create DeviceInfo for the solar inverter, linked to the parent panel. + + The last DER to get a card of its own. Its vendor, model and nameplate + capacity have been readable all along and were shown as three diagnostic + sensors on the *panel's* card, beside the panel's own manufacturer and model, + which reads as if the enclosure were an Enphase inverter. The firmware + version the library also reads reached nothing at all, because a version has + no home but a device card. + + **The identifier deliberately does not mention the inverter's serial.** + `info/serial-number` is declared by every PV `$description` and published by + no producer today, so an identifier preferring it would be `_pv` on + every panel now and `_` on the first panel whose firmware + starts publishing one -- and a device identifier is what a consumer keys its + registry on, so that day would read as the inverter being replaced rather + than as a value arriving. `{panel serial}_pv` answers the only question an + identifier has to answer, "which panel's inverter", on a panel with one. + The card describes what its PV entities read: the inverter they are bound + to, or the inverters together; see `pv_binding`. The + serial is not on the card either, for the same reason it is not in the + identifier: nothing in this integration should start depending on it before + a producer publishes one. + + **No area is seeded**, so an upgraded installation has to assign this card to + an area the way it assigned the battery's and the chargers'. Both routes were + considered and neither is clean. `DeviceInfo`'s `suggested_area` is deprecated + with `breaks_in_ha_version="2026.9"`, one release past the version pinned + here, so adopting it would be adopting a removal. An explicit + `device_registry.async_update_device(area_id=...)` after setup cannot tell an + area a user deliberately cleared from one never assigned -- the registry + records `None` for both -- so it would silently re-assign the card on every + reload. Doing nothing also keeps every sub-device the same: the BESS, the MID + and each charger seed no area either. + """ + return DeviceInfo( + identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_PV}")}, + name=f"{panel_name} Solar", + manufacturer=pv.vendor_name or "Unknown", + model=pv.model or "Solar Inverter", + # Passed through unguarded, as on the BESS and the MID: `DeviceInfo` + # omits a `None` field and renders an empty string as a present-but-blank + # row, so `or ""` would invent a version row for an inverter that + # published none. + sw_version=pv.software_version, + via_device_id=panel_device_id, + ) + + +EMPTY_PV: Final = SpanPVSnapshot() +"""Stand-in for an inverter that has left the snapshot, as `EMPTY_EVSE` is for a charger.""" + + +def pv_inverter_device_info( + panel_identifier: str, + inverter_key: str, + pv: SpanPVSnapshot, + panel_name: str, + display_suffix: str | None = None, + *, + panel_device_id: str, +) -> DeviceInfo: + """Create DeviceInfo for an inverter the Solar card does not read. + + Keyed by `inverter_key`, the library's `pv_inverters` key, for the reason + `pv_device_info` gives for leaving the serial out of its own identifier: + the feeding circuit's id is what stays put across firmware, and an inverter + no circuit feeds has only its device id. Linked to the panel rather than to + the solar card, as every SPAN sub-device is. + + Named after the circuit that feeds it, as a charger is, unless that circuit + is called exactly "Solar Inverter": the name already says so, and repeating + it as the suffix only doubles it. Only an exact match is dropped, because + `resolve_pv_display_suffixes` keeps suffixes distinct by exact comparison, + and dropping a near match could leave two inverters with one name. No + serial on the card, for the reason `pv_device_info` gives. + """ + label = "Solar Inverter" + name = f"{panel_name} {label}" + if display_suffix and display_suffix != label: + name = f"{name} ({display_suffix})" + return DeviceInfo( + identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_PV}_{inverter_key}")}, + name=name, + manufacturer=pv.vendor_name or "Unknown", + model=pv.model or "Solar Inverter", + sw_version=pv.software_version, + via_device_id=panel_device_id, ) diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py index aae65dbb..b4ed62f6 100644 --- a/custom_components/span_panel/websocket.py +++ b/custom_components/span_panel/websocket.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from homeassistant.components import websocket_api @@ -11,11 +12,23 @@ import voluptuous as vol from .const import DOMAIN -from .helpers import build_panel_unique_id +from .control_gate import ControlMode +from .entity_resolver import entity_id_in_entry +from .helpers import ( + build_panel_unique_id, + circuit_has_a_breaker_switch, + circuit_has_a_priority_select, + construct_voltage_attribute, +) from .id_builder import build_binary_sensor_unique_id +from .runtime import SpanPanelRuntimeData, loaded_runtime_data +from .solar_topology import SolarTopology, solar_topology +from .util import classify_sub_device_identifier, pv_device_ref +from .websocket_adopted import handle_adopted_curate, handle_adopted_list +from .websocket_panel import resolve_panel_device if TYPE_CHECKING: - from . import SpanPanelRuntimeData + from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot # Mapping from unique_id suffixes to semantic role names for circuit sensors. # Mapping from unique_id suffixes (as generated by build_circuit_unique_id via @@ -54,8 +67,16 @@ def async_register_commands(hass: HomeAssistant) -> None: - """Register WebSocket commands for the Span Panel integration.""" + """Register WebSocket commands for the Span Panel integration. + + Every command this integration answers is named here, including the adopted + ones defined in `websocket_adopted`. The dependency runs one way -- that + module never imports this one -- so registration stays a single list rather + than something each module does for itself. + """ websocket_api.async_register_command(hass, handle_panel_topology) + websocket_api.async_register_command(hass, handle_adopted_list) + websocket_api.async_register_command(hass, handle_adopted_curate) @websocket_api.websocket_command( @@ -74,48 +95,23 @@ async def handle_panel_topology( """Return the full panel topology with entity mappings. Admin users must pass the HA device registry ID for the **main SPAN panel** - device only (not BESS/EVSE sub-devices). Returns panel metadata, circuits - with tabs/entity mappings, and sub-devices (BESS, EVSE). + device only (not a sub-device). Returns panel metadata, circuits with + tabs/entity mappings, and sub-devices -- every kind + `classify_sub_device_identifier` names, which is BESS, MID, EVSE and PV. + A PV sub-device also carries a `solar` block, the inverter it describes and + the circuit reading that is its power; see `solar_topology`. """ - device_id = msg["device_id"] - - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device_id) - - if device_entry is None: - connection.send_error(msg["id"], "device_not_found", "Device not found") - return - - is_span_device = any(domain == DOMAIN for domain, _ in device_entry.identifiers) - if not is_span_device: - connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device") - return - - # Sub-devices (BESS, EVSE) register with via_device_id pointing at the panel. - if device_entry.via_device_id is not None: - connection.send_error( - msg["id"], - "not_panel_device", - "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.", - ) - return - - # Find the config entry for this device. - config_entry_id = _find_config_entry_id(device_entry) - if config_entry_id is None: - connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device") - return - - config_entry = hass.config_entries.async_get_entry(config_entry_id) - if config_entry is None: - connection.send_error(msg["id"], "not_loaded", "SPAN Panel config entry not found") + resolved = resolve_panel_device(hass, connection, msg) + if resolved is None: return + device_entry, config_entry = resolved + config_entry_id = config_entry.entry_id + device_registry = dr.async_get(hass) - if config_entry.state is not ConfigEntryState.LOADED: + runtime_data = loaded_runtime_data(config_entry) + if config_entry.state is not ConfigEntryState.LOADED or runtime_data is None: connection.send_error(msg["id"], "not_loaded", "SPAN Panel integration is not loaded") return - - runtime_data: SpanPanelRuntimeData = config_entry.runtime_data coordinator = runtime_data.coordinator snapshot = coordinator.data @@ -133,9 +129,13 @@ async def handle_panel_topology( entities_by_device.setdefault(entity.device_id, []).append(entity) # Resolve panel-level sensor entity_ids via unique_id registry lookup. - panel_entities = _build_panel_entity_map(snapshot.serial_number, entity_registry) + panel_entities = _build_panel_entity_map( + snapshot.serial_number, entity_registry, config_entry_id + ) - panel_status_entity = _resolve_panel_status_entity(snapshot.serial_number, entity_registry) + panel_status_entity = _resolve_panel_status_entity( + snapshot.serial_number, entity_registry, config_entry_id + ) if panel_status_entity is not None: panel_entities["panel_status"] = panel_status_entity @@ -144,6 +144,8 @@ async def handle_panel_topology( # search all entities for the config entry, not just panel-device ones. circuit_ids = {cid for cid in snapshot.circuits if not cid.startswith("unmapped_tab_")} entity_map = _build_circuit_entity_map(circuit_ids, all_entities) + # The `power` role of every circuit, for the `solar` blocks below. + circuit_power = {cid: roles["power"] for cid, roles in entity_map.items() if "power" in roles} # Build circuits section. circuits: dict[str, dict[str, Any]] = {} @@ -155,7 +157,11 @@ async def handle_panel_topology( circuits[circuit_id] = { "tabs": tabs, "name": circuit.name or None, - "voltage": 240 if len(tabs) == 2 else 120, + # Same inference as the entity attribute, from the same helper: a + # pole count answers this for one and two poles and not beyond, and + # null says so rather than the previous 120, which this branch + # returned for every circuit that was not exactly two-pole. + "voltage": construct_voltage_attribute(circuit), "device_type": circuit.device_type, "relay_state": circuit.relay_state, "relay_state_target": circuit.relay_state_target, @@ -164,22 +170,36 @@ async def handle_panel_topology( "always_on": circuit.always_on, "priority": circuit.priority, "priority_target": circuit.priority_target, - "entities": entity_map.get(circuit_id, {}), + # Whether `priority` above can be written, which is a different + # commissioning flag from `is_user_controllable`: that one governs + # the relay. A never-backup circuit has a working relay and a + # priority the panel pins, so a consumer that infers priority + # settability from relay controllability gets it wrong in both + # directions. Carried even though no entity is created for such a + # circuit, because a consumer rendering from this record has no + # other way to tell a pinned priority from an absent one. + "is_never_backup": circuit.is_never_backup, + "entities": _offered_roles( + entity_map.get(circuit_id, {}), circuit, runtime_data.control_policy.mode + ), } # Build sub-devices section from devices linked via via_device_id. sub_devices: dict[str, dict[str, Any]] = {} + # Matched against the panel device itself rather than the id the request + # carried: they differ when that id was saved before 2026.8, and every + # sub-device links to the device -- see `resolve_panel_device`. for dev in dr.async_entries_for_config_entry(device_registry, config_entry_id): - if dev.id == device_id: + if dev.id == device_entry.id: continue - if dev.via_device_id != device_id: + if dev.via_device_id != device_entry.id: continue sub_device_entities = entities_by_device.get(dev.id, []) sub_device_type = _classify_sub_device(dev) - sub_devices[dev.id] = { + record: dict[str, Any] = { "name": dev.name, "type": sub_device_type, "manufacturer": dev.manufacturer, @@ -195,6 +215,12 @@ async def handle_panel_topology( for ent in sub_device_entities }, } + solar = _solar_block( + dev, runtime_data, snapshot, circuit_power, panel_entities.get("pv_power") + ) + if solar is not None: + record["solar"] = solar + sub_devices[dev.id] = record connection.send_result( msg["id"], @@ -202,7 +228,13 @@ async def handle_panel_topology( "serial": snapshot.serial_number, "firmware": snapshot.firmware_version, "panel_size": snapshot.panel_size, - "device_id": device_id, + "device_id": msg["device_id"], + # The panel's current device and the entry that owns it. They differ + # from what the request carried only when that was an id saved before + # 2026.8, which the device list no longer holds -- so a consumer takes + # the panel's identity from here rather than looking up the id it has. + "panel_device_id": device_entry.id, + "config_entry_id": config_entry_id, "device_name": device_entry.name, "panel_entities": panel_entities, "circuits": circuits, @@ -211,28 +243,79 @@ async def handle_panel_topology( ) -def _find_config_entry_id(device_entry: dr.DeviceEntry) -> str | None: - """Find the SPAN Panel config entry ID for a device.""" - is_span_device = any(domain == DOMAIN for domain, _ in device_entry.identifiers) - if not is_span_device: - return None - - # Return the first config entry; a SPAN device only belongs to one. - for config_entry_id in device_entry.config_entries: - return config_entry_id - return None +def _classify_sub_device(device_entry: dr.DeviceEntry) -> str: + """Classify a sub-device by its identifiers, or 'unknown'. + The grammar lives with the builders that write it, in `util`, rather than + being restated here. Restating it is how the MID went out as 'unknown' for a + release — it was added to the writing end and not to this one — and how a + consumer ended up rendering a device with a name and no type. -def _classify_sub_device(device_entry: dr.DeviceEntry) -> str: - """Classify a sub-device as 'bess' or 'evse' based on its identifiers.""" + 'unknown' is still returned rather than propagated as None, because it is the + documented wire value for this field and a consumer distinguishes on it. + """ for _, identifier in device_entry.identifiers: - if "_bess" in identifier: - return "bess" - if "_evse_" in identifier: - return "evse" + if (kind := classify_sub_device_identifier(identifier)) is not None: + return kind return "unknown" +def _solar_block( + device: dr.DeviceEntry, + runtime_data: SpanPanelRuntimeData, + live: SpanPanelSnapshot, + circuit_power: Mapping[str, str], + site_power_entity_id: str | None, +) -> SolarTopology | None: + """Return the `solar` block for a PV device; see `solar_topology`. + + Structure is the setup snapshot's, from which the devices and the binding + were built; identity and `circuit_power` come from the live one. A circuit + gone from the live snapshot before the reload it causes therefore gives + `power_entity_id: None` for that moment, which the card shows as no + individual reading. That is accepted. + """ + setup = runtime_data.setup_snapshot + for domain, identifier in device.identifiers: + if domain != DOMAIN: + continue + ref = pv_device_ref(identifier, setup.serial_number) + if ref is not None: + return solar_topology( + ref.inverter_key, + runtime_data.pv_binding, + setup, + live, + circuit_power, + site_power_entity_id, + ) + return None + + +def _offered_roles( + roles: Mapping[str, str], circuit: SpanCircuitSnapshot, mode: ControlMode +) -> dict[str, str]: + """Return a circuit's roles without a `switch` or `select` the integration does not provide. + + The roles are read from the entity registry, which also holds a control kept + under Nobody and, until the reload removes it, one the panel has withdrawn. + Offering either would hand the card a switch that cannot be operated. The + circuit is the live snapshot's, which covers the moment between a + settability change and the reload it causes. `is_user_controllable`, + `priority` and `is_never_backup` stay the panel's truth in the record. + """ + offers_controls = mode is not ControlMode.DISABLED + withheld = { + role + for role, provided in ( + ("switch", offers_controls and circuit_has_a_breaker_switch(circuit)), + ("select", offers_controls and circuit_has_a_priority_select(circuit)), + ) + if not provided + } + return {role: entity_id for role, entity_id in roles.items() if role not in withheld} + + def _build_circuit_entity_map( circuit_ids: set[str], entities: list[er.RegistryEntry], @@ -288,16 +371,18 @@ def _build_circuit_entity_map( def _build_panel_entity_map( serial: str, entity_registry: er.EntityRegistry, + config_entry_id: str, ) -> dict[str, str]: """Resolve panel-level sensor unique_ids to current entity_ids. - Returns a dict of {role: entity_id} for sensors that exist in the - registry. Entries that cannot be resolved are omitted. + Returns a dict of {role: entity_id} for sensors registered in this config + entry. Entries that cannot be resolved are omitted, as is a unique id that + another entry holds. """ result: dict[str, str] = {} for role, description_key in _PANEL_SENSOR_KEYS.items(): unique_id = build_panel_unique_id(serial, description_key) - entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id) + entity_id = entity_id_in_entry(entity_registry, config_entry_id, "sensor", unique_id) if entity_id is not None: result[role] = entity_id return result @@ -306,15 +391,17 @@ def _build_panel_entity_map( def _resolve_panel_status_entity( serial: str, entity_registry: er.EntityRegistry, + config_entry_id: str, ) -> str | None: """Resolve the panel_status binary sensor entity_id for the frontend. The frontend watches this entity to detect panel online/offline state. The binary sensor is always available (see binary_sensor.py) so the frontend can rely on its state regardless of coordinator offline status. + Looked up in this config entry only, as `_build_panel_entity_map` is. """ unique_id = build_binary_sensor_unique_id(serial, "panel_status") - return entity_registry.async_get_entity_id("binary_sensor", DOMAIN, unique_id) + return entity_id_in_entry(entity_registry, config_entry_id, "binary_sensor", unique_id) def _classify_sensor_role(unique_id: str) -> str | None: diff --git a/custom_components/span_panel/websocket_adopted.py b/custom_components/span_panel/websocket_adopted.py new file mode 100644 index 00000000..ead8e516 --- /dev/null +++ b/custom_components/span_panel/websocket_adopted.py @@ -0,0 +1,495 @@ +"""WebSocket commands for curating adopted entities. + +Separate from `websocket.py` because the two answer different questions about +the same panel. That module reports the *curated* topology -- circuits, tabs, +sub-devices, the entity ids a dashboard renders from -- and its readers are +dashboards. This one reports what the panel publishes that nobody has modelled +yet, and its reader is an editor: every row carries the choices the user may +assert, computed from the wire declaration through Core's own maps, so the card +never offers an option the curate command would refuse. + +**Nothing here writes registry state, and that is a boundary rather than an +omission.** Enabling, naming, icons, areas and display units are registry acts +the user makes through Core's own websocket commands, which already ask for +admin and already carry the undo. This module owns exactly the metadata Core has +nowhere to put -- a state class, a device class and prominence for an entity +built from a vendor declaration -- and `curation.py` owns whether an assertion +is admissible. + +This module must not import `websocket.py`: registration runs the other way, so +the dependency has one direction and no cycle can appear as further commands +join the ones here. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final + +from homeassistant.components import websocket_api +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import voluptuous as vol + +from .adoption import ( + adopted_curation_key, + adopted_device_label, + adopted_unique_id, + classify, + humanised, + resolve_identifier, +) +from .const import DOMAIN +from .curation import ( + PROMOTED, + CurationError, + CurationOverlay, + CurationRecord, + RowContext, + allowed_device_classes, + allowed_state_classes, + async_save_record, + record_as_dict, + validate_record, +) +from .extension import adoptable, extension_curation_key, resolve_platform +from .runtime import SpanPanelRuntimeData, loaded_runtime_data +from .websocket_panel import resolve_panel_device + +if TYPE_CHECKING: + from span_panel_api import SpanPanelSnapshot + + from .pv_binding import PvBinding + + +@dataclass(frozen=True, slots=True) +class _AdoptableRow: + """One curatable row: what it is on the wire, and where it renders. + + One derivation of what is curatable, rather than one per command. The key a + row carries is what the store is keyed on and what the editor hands back + when the user asserts something, so a second derivation of the same set + would let the editor offer a row the store cannot resolve. + """ + + key: str + """The curation-store key -- scope-prefixed and injective, per `curation.py`.""" + + path: str + """The `{node}/{property}` wire address, as the capability catalogs spell it.""" + + context: RowContext + """The declaration, as far as validation and the allowed-choice helpers need it.""" + + unique_id: str + """The id this row's entity carries, whether or not that entity exists yet.""" + + device_identifier: str + """The registry identifier of the card this row renders on. + + The grouping key rather than `device_registry_id`, because the registry id is + absent for a device adopted since the last setup and two such devices must + not collapse into one group. + """ + + device_registry_id: str | None + """The card's registry id, or None while the card is still to be created.""" + + device_label: str + """What that card is called, for a group heading the user can recognise.""" + + name: str + """The entity's own name, in the same wire vocabulary the entity carries.""" + + settable: bool + """Whether the panel accepts a write. Declaration fact, reported for triage.""" + + adopted_device: bool + """Whether the card is one adoption minted, rather than a curated device.""" + + +_STATE_CLASS_VALUES: Final = [cls.value for cls in SensorStateClass] +"""Every state class, without regard to a row -- the schema has no row to regard. + +Which choices a *particular* row admits is `allowed_state_classes`' answer and +`validate_record`'s to enforce, because both need the declaration. This is only +the alphabet, so a value Core has never heard of never reaches either. +""" + +_DEVICE_CLASS_VALUES: Final = sorted( + {cls.value for cls in SensorDeviceClass} | {cls.value for cls in BinarySensorDeviceClass} +) +"""Both platforms' device classes, unioned for the same reason: no row here yet. + +A binary row's classes and a sensor row's are disjoint vocabularies, and which +one applies is decided from `RowContext.platform` in `curation`. Sorted so the +schema's own error message names the values in a stable order. +""" + + +@websocket_api.websocket_command( + { + vol.Required("type"): "span_panel/adopted/list", + vol.Required("device_id"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def handle_adopted_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return every curatable row on this panel, grouped by the device it renders on. + + Admin users pass the HA device registry ID for the **main SPAN panel**, the + same contract `handle_panel_topology` has: one panel is one entry, and the + rows come from that entry's snapshot and overlay together. + + Read-only. The response is the editor's whole input -- the stored record, the + admissible choices, and the names of any stored fields the current + declaration no longer supports. + """ + resolved = _resolve_panel_entry(hass, connection, msg) + if resolved is None: + return + entry, runtime_data, snapshot = resolved + + entity_registry = er.async_get(hass) + devices: list[dict[str, Any]] = [] + # Each device's row list, held here as the same object its group carries, so + # one pass both opens the group and fills it. + grouped: dict[str, list[dict[str, Any]]] = {} + for row in _rows(hass, snapshot, entry.entry_id, pv_binding=runtime_data.pv_binding): + rows = grouped.get(row.device_identifier) + if rows is None: + rows = [] + grouped[row.device_identifier] = rows + devices.append( + { + "device_id": row.device_registry_id, + "name": row.device_label, + "adopted_device": row.adopted_device, + "rows": rows, + } + ) + rows.append(_row_payload(row, runtime_data.curation, entity_registry)) + + connection.send_result(msg["id"], {"devices": devices}) + + +def _row_payload( + row: _AdoptableRow, + overlay: CurationOverlay, + entity_registry: er.EntityRegistry, +) -> dict[str, Any]: + """Return the wire record for one row: its declaration, its record, its choices. + + **The record is reported as stored, not as it would be applied.** Entity + construction reads the same record through `for_row`, which drops what the + current declaration no longer supports -- that is right for an entity and + wrong for an editor, because a silently sanitised record shows the user an + assertion they never made and hides that theirs was dropped. So the stored + fields go out verbatim, beside `stale_fields` naming the ones the wire has + outgrown. + """ + record = overlay.record_for(row.key) + return { + "key": row.key, + "path": row.path, + "platform": row.context.platform.value, + "entity_id": entity_registry.async_get_entity_id( + row.context.platform.value, DOMAIN, row.unique_id + ), + "datatype": row.context.datatype, + "unit": row.context.unit, + "settable": row.settable, + "name": row.name, + "curation": {} if record is None else record_as_dict(record), + "allowed_device_classes": allowed_device_classes(row.context), + "allowed_state_classes": allowed_state_classes(row.context), + "stale_fields": list(overlay.stale_fields(row.key, row.context)), + } + + +@websocket_api.websocket_command( + { + vol.Required("type"): "span_panel/adopted/curate", + vol.Required("device_id"): str, + vol.Required("key"): vol.All( + str, vol.Length(min=1, max=256), vol.Match(r"^[A-Za-z0-9_./-]+$") + ), + vol.Required("record"): vol.Schema( + { + vol.Optional("state_class"): vol.In(_STATE_CLASS_VALUES), + vol.Optional("device_class"): vol.In(_DEVICE_CLASS_VALUES), + vol.Optional("entity_category"): PROMOTED, + } + ), + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def handle_adopted_curate( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Store one row's asserted metadata, or clear it, and rebuild the entity that reads it. + + Admin users pass the panel's device registry id -- the same handle + `adopted/list` takes -- and a `key` that command reported. The rows are + derived again here rather than the key being trusted: the store is keyed on + wire addresses, so a key nothing publishes would be held forever, read by no + entity and shown on no list. + + An empty `record` clears the row. Anything else is validated against the + row's *current* declaration, and a refusal carries `curation`'s own code + unchanged, so the editor renders the refusal it can explain rather than a + generic one. What the schema can decide without the declaration -- enum + membership, the one storable category, the key's shape -- is decided there, + so only questions that need the wire reach this far. + + **A save's side effects are exactly three: the store, a reload, the reply.** + Nothing here writes registry state, per this module's boundary. The reload is + the half that is easy to miss: an entity description is fixed at + construction, so a record reaches its entity only by that entity being built + again -- and being built *with* it, because a state class that arrives after + the first state is written is a statistics reset rather than a metadata + change. + """ + resolved = _resolve_panel_entry(hass, connection, msg) + if resolved is None: + return + entry, runtime_data, snapshot = resolved + + key: str = msg["key"] + candidates = _rows(hass, snapshot, entry.entry_id, pv_binding=runtime_data.pv_binding) + row = {candidate.key: candidate for candidate in candidates}.get(key) + if row is None: + connection.send_error(msg["id"], "unknown_key", f"No curatable row is keyed {key!r}") + return + + previous = runtime_data.curation.record_for(key) + record: CurationRecord | None = None + if msg["record"]: + try: + record = validate_record(msg["record"], row.context) + except CurationError as err: + connection.send_error(msg["id"], err.code, str(err)) + return + + await async_save_record(hass, entry, key, record) + hass.config_entries.async_schedule_reload(entry.entry_id) + connection.send_result( + msg["id"], + { + "record": {} if record is None else record_as_dict(record), + "warnings": _warnings(record, previous), + }, + ) + + +def _warnings(record: CurationRecord | None, previous: CurationRecord | None) -> list[str]: + """Name the consequences of a save that the saved record does not show on its face. + + Advisory rather than refusals -- the write has happened and the user asked + for it -- and both are about the recorder rather than the entity, which is + exactly why the record cannot show them. + + The first fires on what a save *leaves*, not on how it was spelled. A record + narrowed to its other fields drops the state class exactly as clearing the + whole record does, so a warning scoped to the clear would let the identical + consequence go unsaid on the route a user is more likely to take. Losing a + state class stops long-term statistics being compiled for the entity, and + core raises its own `state_class_removed` repair against the ones already + collected (`sensor/recorder.py`). + + Asserting `total_increasing` reinterprets the reading rather than describing + it: the recorder reads a drop of more than a tenth as a meter reset and + starts a new cycle, so a reading that legitimately falls manufactures + consumption. It cannot co-fire with the first, which requires the save to + have left no state class at all. + """ + if record is None or record.state_class is None: + if previous is not None and previous.state_class is not None: + return ["statistics_removed"] + return [] + if record.state_class is SensorStateClass.TOTAL_INCREASING: + return ["total_increasing"] + return [] + + +def _rows( + hass: HomeAssistant, + snapshot: SpanPanelSnapshot, + config_entry_id: str, + *, + pv_binding: PvBinding, +) -> list[_AdoptableRow]: + """Every row on this panel a user may curate, in a deterministic order. + + Both halves of vendor extensibility, resolved through the same functions the + entity builders use rather than beside them: `resolve_identifier` and + `classify` for a device nobody modelled, `adoptable` and `resolve_platform` + for a vendor property on a device this integration does model. A second + derivation here would let the editor disagree with the entities it edits -- + offering a state class for a row that is really a control, or a key the + curate command cannot resolve. + + `adoptable` is what decides which extension rows exist at all, so the cap and + the wait-for-the-card deferral apply here exactly as they do to the entities: + a row it declines has no entity, no card to group under and no name to show. + + Adopted declarations are sorted by `path` for the same reason `_create` + sorts them, and the sort does the same job here: `adopted_unique_id` is + deliberately non-injective, so two wire addresses can flatten onto one id, + and the lexically first path claims it. **The other is skipped rather than + listed**, mirroring `_create`, because a listed loser is not merely a row + with no entity: `entity_id` resolves by (platform, unique_id), so it would + report the *winner's* entity beside its own curation key -- inviting a record + saved against an entity that will never read it, under a live entity_id + saying it will. The skip is silent; `_create` already warns, naming both + addresses, and a second line per list request would say nothing new. + + Claimed per platform, which is the scope the registry keys on: an entity is + unique by (domain, integration, unique_id), so the same id under `sensor` and + under `switch` is two entities and not a collision. `_create` runs once per + platform and gets that scoping for free; one pass over every platform has to + say so. + + Extension rows are sorted for a weaker version of the ordering reason: + `adoptable` returns the already-registered rows first, so an unsorted list + would reshuffle the card the moment a new row's entity appeared. They need no + claim -- `extension_unique_id` carries the wire path verbatim and is + injective by construction. + """ + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + rows: list[_AdoptableRow] = [] + claimed: set[tuple[Platform, str]] = set() + + for device in snapshot.adopted_devices: + identifier = resolve_identifier( + device_registry, snapshot.serial_number, device, config_entry_id=config_entry_id + ) + card = device_registry.async_get_device_by_identifier((DOMAIN, identifier), config_entry_id) + for declaration in sorted(device.properties, key=lambda row: row.path): + platform = classify(declaration) + unique_id = adopted_unique_id(identifier, declaration) + if (platform, unique_id) in claimed: + continue + claimed.add((platform, unique_id)) + rows.append( + _AdoptableRow( + key=adopted_curation_key(identifier, declaration), + path=declaration.path, + context=RowContext( + platform=platform, + datatype=declaration.datatype, + unit=declaration.unit, + ), + unique_id=unique_id, + device_identifier=identifier, + device_registry_id=None if card is None else card.id, + device_label=_device_label(card, adopted_device_label(device)), + name=humanised(declaration.property_id), + settable=declaration.settable, + adopted_device=True, + ) + ) + + for adopted in sorted( + adoptable( + snapshot, + device_registry, + entity_registry, + config_entry_id=config_entry_id, + pv_binding=pv_binding, + ), + key=lambda adopted: (adopted.device_identifier, adopted.row.path), + ): + extension, unique_id, identifier = adopted.row, adopted.unique_id, adopted.device_identifier + key = extension_curation_key(adopted.subject, extension.path) + card = device_registry.async_get_device_by_identifier((DOMAIN, identifier), config_entry_id) + if key is None or card is None: + # Neither happens: `adoptable` declines a subject with no scope, which + # is exactly what `extension_curation_key` declines, and it declines a + # card the registry does not hold. Both branches are the type system + # holding those contracts to one answer rather than cases to handle. + continue + rows.append( + _AdoptableRow( + key=key, + path=extension.path, + context=RowContext( + platform=resolve_platform(entity_registry, unique_id, extension.datatype), + datatype=extension.datatype, + unit=extension.unit, + ), + unique_id=unique_id, + device_identifier=identifier, + device_registry_id=card.id, + device_label=_device_label(card, identifier), + name=f"{humanised(extension.node_id)} {humanised(extension.property_id)}", + settable=extension.settable, + adopted_device=False, + ) + ) + + return rows + + +def _device_label(card: dr.DeviceEntry | None, fallback: str) -> str: + """Return what this card is called, preferring what the user renamed it to. + + A group heading has to be the name the user sees in their device list, or the + editor is grouping rows under a device they cannot find. The fallback is for + a card that does not exist yet -- an adopted device that arrived since the + last setup -- where the wire's own label is the only name there is. + """ + if card is None: + return fallback + return card.name_by_user or card.name or fallback + + +def _resolve_panel_entry( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> tuple[ConfigEntry, SpanPanelRuntimeData, SpanPanelSnapshot] | None: + """Resolve the panel device id in a request to the entry, its runtime data and its snapshot. + + Sends the refusal itself and answers None, so a handler's first line is the + whole of its validation. The device is resolved by `resolve_panel_device`, + the one function every command shares, so its refusals and their codes are + topology's by construction rather than by keeping two copies in step. + + Runtime state is reached through `loaded_runtime_data`, per AGENTS.md's + runtime-data guard: core deletes `runtime_data` on unload, and what is there + on a loaded entry is whatever the owning integration put there. + """ + resolved = resolve_panel_device(hass, connection, msg) + if resolved is None: + return None + _panel_device, entry = resolved + + if entry.state is not ConfigEntryState.LOADED: + connection.send_error(msg["id"], "not_loaded", "SPAN Panel integration is not loaded") + return None + + runtime_data = loaded_runtime_data(entry) + if runtime_data is None: + connection.send_error(msg["id"], "not_loaded", "SPAN Panel integration is not loaded") + return None + + snapshot = runtime_data.coordinator.data + if snapshot is None: + connection.send_error(msg["id"], "no_data", "SPAN Panel has not yet provided any data") + return None + + return entry, runtime_data, snapshot diff --git a/custom_components/span_panel/websocket_panel.py b/custom_components/span_panel/websocket_panel.py new file mode 100644 index 00000000..47df85fb --- /dev/null +++ b/custom_components/span_panel/websocket_panel.py @@ -0,0 +1,102 @@ +"""The SPAN panel a WebSocket request names. + +Every command that takes a panel takes it the same way -- the device registry id +of the main panel device -- and has to refuse the same things with the same +codes, because a consumer that learned one set of codes must not meet a second. +One function resolves it for all of them. Two copies had already drifted: the +topology command took an arbitrary one of a device's entries where the adopted +commands checked its domain. + +A module of its own because both `websocket.py` and `websocket_adopted.py` need +it, and `websocket_adopted.py` must not import `websocket.py`. +""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components import websocket_api +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + + +def resolve_panel_device( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> tuple[dr.DeviceEntry, ConfigEntry] | None: + """Resolve the device id in a request to the panel device and the SPAN entry owning it. + + Sends the refusal itself and answers None, so a handler goes on only with a + SPAN panel device in hand. What the handler does with the entry -- whether it + is loaded, what its runtime data holds -- stays the handler's to check. + + **A device is a SPAN panel when a SPAN entry owns it.** Since Home Assistant + 2026.8 a device belongs to exactly one config entry, its `config_entry_id`; + the `config_entries` set this used to search is a deprecated shim holding just + that one. An identifier in SPAN's domain does not settle it, since another + entry can own a device carrying one, so the owner's domain is what is checked. + + **An id saved before 2026.8 is resolved to the device it became.** 2026.8 split + every device that belonged to several config entries into one device per + entry, each with a new id -- and a SPAN panel was one whenever a helper built + on its sensors had linked itself to the panel's device, as helpers did until + that release. The dashboard card keeps the id it was configured with, so it + can send the old one. The registry answers that id with a read-only composite + until 2027.8, but not usefully here: the composite's `config_entry_id` is + whichever entry was the old device's primary, and no sub-device points at it + any more, because the migration moved every `via_device_id` onto the split its + own entry owns. So the old id is resolved to the split a SPAN entry owns, and + the caller works with that device rather than with the id it was handed. + """ + # When the minimum Home Assistant version reaches 2026.9, replace this lookup + # -- the composite splits, the `async_get` fallback and `_owned_by_span` -- + # with `dr.async_get_device_and_config_entry_for_domain(hass, device_id, + # domain=DOMAIN)`, which 2026.9 adds to do exactly this, an id from before + # 2026.8 included. Its `(None, None)` -- an unknown or child-device id -- is + # `device_not_found`, and its `(device, None)` -- a device no SPAN entry owns + # -- is `not_span_panel`. The sub-device refusal below stays: the helper + # checks ownership, not `via_device_id`. + device_registry = dr.async_get(hass) + device_id: str = msg["device_id"] + candidates = device_registry.async_get_devices_for_composite_device_id(device_id) + # From 2026.9 `async_get` can answer with a child device, a part of another + # device that is never a SPAN panel. It is refused as not found, as the 2026.9 + # helper refuses it, so the swap above changes no answer. + if not candidates and isinstance( + device_entry := device_registry.async_get(device_id), dr.DeviceEntry + ): + candidates = [device_entry] + if not candidates: + connection.send_error(msg["id"], "device_not_found", "Device not found") + return None + + owned = _owned_by_span(hass, candidates) + if owned is None: + connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device") + return None + + panel_device, entry = owned + # Every sub-device registers with via_device_id pointing at the panel. + if panel_device.via_device_id is not None: + connection.send_error( + msg["id"], + "not_panel_device", + "Use the SPAN panel device registry ID, not a sub-device.", + ) + return None + return panel_device, entry + + +def _owned_by_span( + hass: HomeAssistant, devices: list[dr.DeviceEntry] +) -> tuple[dr.DeviceEntry, ConfigEntry] | None: + """Return the first of these devices a SPAN entry owns, with that entry.""" + for device_entry in devices: + entry = hass.config_entries.async_get_entry(device_entry.config_entry_id) + if entry is not None and entry.domain == DOMAIN: + return device_entry, entry + return None diff --git a/developer.md b/developer.md index 46b96dcc..b89eefad 100644 --- a/developer.md +++ b/developer.md @@ -60,9 +60,6 @@ cp .env.example .env The defaults assume the standard workspace layout: ```dotenv -# Path to span-panel-api repo (for editable pip install) -export SPAN_PANEL_API_DIR=../span-panel-api - # Path to span-card frontend repo (for build-frontend.sh) export SPAN_CARD_DIR=../cards/span-card @@ -78,6 +75,71 @@ echo 'dotenv' > .envrc direnv allow ``` +## Working against unreleased library code + +`pyproject.toml` installs `span-panel-api` and both adapters from `../../span/span-panel-api` as editable path dependencies. That is a committed, shared file: +whatever it names is what every import in this suite resolves to, for everyone, on every machine. + +Sooner or later you need to test against library work that is not on `main` yet — an unmerged branch in a worktree beside the checkout. **Do not edit +`pyproject.toml` to point at it.** Put the worktree in your virtual environment instead: + +```bash +uv sync # canonical, from the committed file +uv pip install -e ../../span/span-panel-api- # local override, this venv only +uv sync # back to canonical +``` + +`uv sync` is the undo. It rebuilds the environment from `pyproject.toml` and `uv.lock`, which never mentioned the worktree, so the override disappears with no +record of having been there. Nothing is committed at any point. + +### What the second install actually does + +The venv holds one `.pth` file per editable distribution — `_editable_impl_span_panel_api.pth`, `_editable_impl_span_panel_api_schema_0.pth`, +`_editable_impl_span_panel_api_schema_1.pth` — each holding the `src` directory of the main checkout. `uv pip install -e ` **replaces** the one for +the distribution it installs: same filename, rewritten to the worktree, with the `dist-info` (including `direct_url.json`, which is where the redirection is +recorded as data) replaced alongside it. There is no stacking and no shadowing, so no ordering question — the old target is gone. + +It replaces only that distribution. `span-panel-api` and the two adapters are three separate distributions, and installing the bootstrap from a worktree leaves +`span_panel_api_schema_0` and `span_panel_api_schema_1` resolving to the main checkout. If the branch under test changes an adapter, or moves +`ADAPTER_CONTRACT_VERSION`, install all three: + +```bash +uv pip install -e ../../span/span-panel-api- \ + -e ../../span/span-panel-api-/packages/schema-0 \ + -e ../../span/span-panel-api-/packages/schema-1 +``` + +### What not to do: `PYTHONPATH` and `MYPYPATH` + +Prefixing a worktree onto `PYTHONPATH` or `MYPYPATH` looks like the same thing and is not. A worktree on the path has **no distribution metadata of its own**, +so `importlib.metadata` goes on describing the installed distribution: the right name, the right version, the right location — all of it about a copy of the +library that is not the one running. `span_panel_api.__file__` is in the worktree and every version-based check in this suite reads the installed distribution +and passes. + +That is the worst available state, because it is the one where everything is green. The editable install moves the code and the metadata together, so they +cannot disagree; the path prefix moves one and not the other. + +### The two guards + +| Guard | When | Catches | +| ------------------------------------------- | ----------- | ---------------------------------------------------------------------------- | +| `scripts/check-library-path.py` (prek hook) | commit time | a `span-panel-api` path in `pyproject.toml` naming anything but the checkout | +| `tests/test_library_resolution.py` | every run | the library that actually resolved not being the one `manifest.json` pins | + +The hook is why editing `pyproject.toml` is not the route: it rejects the commit and names this section. The test reads `span_panel_api.__file__` rather than +metadata, so it sees the `PYTHONPATH` case as well as a stale checkout. A deliberate editable override **skips** it locally, naming the path and version it +found, and **fails** under `CI` — where `[tool.uv.sources]` is deleted before installing and no legitimate override exists. A version behind or ahead of the pin +fails everywhere; that one is never intentional. + +### Why the version alone is not enough + +`3cbf02a` pointed both `[tool.uv.sources]` and `[tool.pyright].extraPaths` at a scratch worktree and committed it. It survived several commits and hours of +other work. While it stood, the conformance tests ran against a producer defect the library had already fixed — and every version-based check passed, because +the stale worktree declared the same version number as the corrected code. + +**A version string does not identify content. Only a filesystem location does.** That is what both guards check and why neither of them checks a version on its +own. + ## Pre-commit Hooks This project uses prek for pre-commit hooks. Hooks run automatically on `git commit` and check formatting, linting, type checking, translations, and test @@ -162,6 +224,747 @@ python -m pytest tests/test_current_monitor.py -q python -m pytest tests/ --cov=custom_components/span_panel --cov-report=term-missing ``` +## The adapter reference payloads + +The conformance tests replay two real schema-adapter inputs: a full parent/child device tree and a flat `GET /api/v2/homie/schema` response. Neither is +committed here. Both are package data of the adapter distributions — `span_panel_api_schema_1/reference/parent_child_tree.json` and +`span_panel_api_schema_0/reference/homie_schema.json` — and `tests/adapter_fixtures.py` reads them through `importlib.resources`, exactly as the library's own +suite does. + +So the payload the suite replays is whichever one the pinned wheel ships, and the pin in `custom_components/span_panel/manifest.json` is the record of which +that is. **Moving to a newer capture is a pin bump and nothing else**: bump the adapter version there, let `scripts/sync-dependencies.py` carry it into +`pyproject.toml` and `requirements_test.txt`, then `uv sync`. There is no copy to refresh and nothing to keep honest — a claim about which release the bytes +came from cannot disagree with the bytes when the bytes are the release's. + +`tests/test_library_resolution.py` is what makes the pin mean something: it checks that the `span_panel_api` actually imported is the one the pins name, rather +than a worktree an override is pointing at. + +## Knowing what the panel publishes that nothing reads + +The panel declares more than this integration surfaces, and the gap is tracked mechanically rather than by memory. + +### The gate + +`tests/test_declared_but_unread.py` asserts that **every property declared in a device's `$description`** is one of three things: mapped to a snapshot field by +an adapter, consumed by a known internal route (topology, dispatch, device_info, role resolution), or listed in +`tests/fixtures/unread_declarations_baseline.json` with a one-line reason. + +It decides "read" **by experiment**, not by inspecting a map. For each declared property it republishes a legal different value derived from the property's own +`datatype`/`format`, rebuilds the snapshot through the real adapter, and checks whether any field a consumer reads actually moved. That is why it sees +consumption the `_PROPERTY_FIELD_MAP` cannot express, and why it caught a case where a metadata row existed while nothing read the value. + +It fails in **both** directions: + +- A newly declared property that reaches nothing fails the build until somebody triages it. +- A property that becomes read fails until its baseline line is deleted. + +### Working with the baseline + +When you surface a property, delete its baseline line in the same change. The test will tell you if you deleted one you did not surface, or surfaced one whose +line you left. + +When you decide a property should _stay_ unread, add a line with an honest reason. The reasons are load-bearing — they are what stops the file becoming a list +of things nobody remembers deciding. The current entries are all permanent: deliberate skips (`status/postal-code` and `status/time-zone`, which Home Assistant +already owns or has no use for), values held for identity reasons (`pv/info/serial-number`), redundant echoes (`connection/*-device-type` dereferences to a +declared `$type`), and properties no producer publishes (`connection/count`). + +**"Unread" here means "no curated entity", not "invisible".** Since 2.1.0b7 an unread property on a _modeled_ device also surfaces through +[extension adoption](#extension-properties) as a disabled diagnostic entity, so a baseline line records a decision not to **curate** something — to give it a +designed name, a category and a place — rather than a decision to withhold it. The two skips above were written before that distinction existed and read as +though a baseline line hid a property; it does not, and the entries were reworded rather than left to mislead. A reason that turns on the cost of a _default-on_ +entity is worth re-reading in that light: `postal-code`'s original reason was that surfacing it copies the user's location into recorder history, which an +opt-in disabled entity does only if the user asks for it. + +### What it does not cover + +The gate reads the **adapter's reference capture**, so it answers "what does that capture declare that we do not read". It cannot see a property a real panel +starts publishing in the field. The runtime half below is what answers that. + +Note also that a property can be _read on one device and not another_ and the gate will not see it — it asks whether anything moved, not whether everything did. +`snapshot.pv` keeps the first `energy.ebus.device.pv` child and discards the rest, so a second inverter is invisible while the property still counts as read. + +### The runtime half: what the panel in front of the user declares + +The schema_1 adapter asks the same question of the live tree and puts the answer in **diagnostics**, under `schema_discovery`: + +```json +"schema_discovery": { + "available": true, + "count": 9, + "properties": [ + { "path": "discovered.circuit/connection/count", "datatype": "integer", "unit": null, "retained": false }, + { "path": "discovered.distribution-enclosure/status/postal-code", "datatype": "string", "unit": null, "retained": true } + ] +} +``` + +`available: false` means the adapter has not reported metadata yet — a real state on a reconnect, and not the same as an empty report. `retained` says whether +the panel has published a value for the property, which is the declared-but-never-valued signal; it never says what the value is. + +**Paths, datatypes, units and retention only — never values.** Diagnostics leave the house into issues and forum posts, and `TO_REDACT` in `diagnostics.py` is +key-based over the config entry: it knows nothing about wire property names and could not protect a value added here. `test_schema_discovery` asserts that +against the capture's own published values rather than leaving it to review. + +This block is **maintainer-facing only**: nothing creates an entity, a Repair or a notification from a `schema_discovery` row — including for a device that _is_ +adopted, whose properties are reported here as declarations exactly like any other. + +That is a statement about this block, not about the properties in it. The same properties on a _modeled_ device also arrive as `snapshot.extension_properties`, +which does carry values and does become entities — see [Extension properties](#extension-properties). Two artefacts describing one property, joined by its +`{node}/{property}` path, with opposite audiences and opposite rules about values. Adoption itself is the next section. + +### Why discovered rows cannot reach the curated inventories + +The adapter returns both kinds of row in one map, keyed by the library's `discovered.` namespace. `schema_validation.partition` splits them **before any other +question is asked**, and everything downstream — the producible gate, the `unread` inventory, the exemption annotations, the unit vocabulary — sees the curated +half only. + +That partition is load-bearing rather than tidy. Every one of those inventories reads "in an adapter's map" as "this integration could read this", which a +discovered path is not: a discovered row in `unread` would bury ten deliberate entries under whatever a firmware release added, and would make the count depend +on the panel in front of the user. `test_the_unread_inventory_is_deaf_to_discovery` proves it by mutation — a synthetic discovered row changes `unread`, +`unresolved` and the unit mismatches not at all. + +The test fixtures follow the same rule: `adapter_fixtures.schema_one_metadata()` hands out the curated half, and a test that wants the other half asks for +`schema_one_discovery()` by name. + +### Keeping the library's answer honest + +The adapter decides "read" from four enumerations of what it addresses — the metadata map, the lugs direction tables, the charge-limit resolution, and +`_CONSUMED_WITHOUT_A_ROW` for the properties it reads into the snapshot without a unit surface. A stale entry there fails _silently_, by keeping a property out +of the report. `tests/test_schema_one_discovery.py` in the library runs the same republish-and-diff experiment this gate uses and holds every entry to it in +both directions, so the report means "nothing reads this" rather than "nobody wrote it down". + +## Circuit entity ids: a base, not a preset + +Since Home Assistant 2026.8 the user owns entity id composition — `entity_id_parts` says whether the area, the device and the entity name are in an id, and Core +assembles it. This integration no longer presets `entity_id` for circuit entities. It supplies one _base_ per entity and Core does the rest, which is how every +non-circuit entity here has always worked. + +`naming.py` is the only place the wording of a circuit id is written down. `circuit_object_id_base(identifier, suffix, existing_entity_id)` joins the +naming-flag half — `Circuit 15`, `Kitchen Outlets`, `Unmapped Tab 32` — to the suffix wording, and it is the base, not the display name: the two are decoupled +on purpose so a label can be reworded without a migration. The suffix wording is read back from the id an entity already has, because two spellings have shipped +(`consumed_energy` before ids were preset, `energy_consumed` after); a new entity gets the noun-last form, matching the panel-level ids. +`ENTITY_ID_SUFFIX_FORMS` records the forms that have shipped and gains an entry only when another is _discovered_, never to introduce one. + +`SpanPanelEntity.suggested_object_id` hands Core that base. It reads `_span_object_id_base`, which circuit entities set and every other entity leaves `None`, in +which case the property falls through to Core's own answer — composition from the display name, exactly as before. Core consults `suggested_object_id` only for +an entity that has not preset `entity_id`. **The property and a preset `entity_id` are not two routes to the same place**: `_async_derive_object_ids` +(`entity_platform.py`) files what the property returns as the registry's `object_id_base`, which Core then prefixes with the device and the area per the user's +parts, and files a preset `entity_id` as `suggested_object_id`, which outranks the base and is taken verbatim with no prefix at all. Setting `entity_id` to the +base would therefore ship the base as the whole id. The three circuit platforms reach the base by the same route: the sensors through `_object_id_parts`, and +the switch and the select directly. + +### What R1 bounds, and what it does not + +R1 — "Recreate entity IDs" must never propose a change the user did not cause — bounds the _base_ and nothing else. Read exactly: the base must reproduce an +existing entity's id **where Home Assistant composes it under the entity-id options that match how that install was built**. That is why the suffix wording is +read back and why the name half is anchored on the circuit's own name: two spellings of ours, or a relabel, must never produce an offer. + +It is **not** a license to bypass `entity_id_parts` with a hard-coded id. Where composition yields a different device half — a SPAN Drive feed sensor, whose +device is the charger and not the panel; a second panel the config flow named "Span Panel 2", whose circuit ids all say `span_panel_` because the old builder +was never told the panel's name; an install with `USE_DEVICE_PREFIX` off, whose ids carry no device at all where `has_entity_name` prefixes one — that is the +user's own configuration at work, and the offer is legitimate. Recreate offers it; nothing moves until the user presses the button (R5). The "legacy preset" +exceptions written for those three shapes were withdrawn by the maintainer on 2026-08-26, and there is no `self.entity_id = …` left in any of the four +platforms. + +One consequence worth naming: a circuit sensor on a sub-device card supplies **no base at all**. Its label there is the bare description name (`Power`), so it +composes as `sensor._power` like the charger's own sensors, and a rename on the panel cannot reach its id. Every other circuit entity sets its base +whenever `_object_id_parts` answers. + +**The suffix read-back therefore does not apply to sub-device (SPAN Drive feed) sensors.** The read-back lives in `circuit_object_id_base`, and a sub-device +sensor never calls it — with no base, Core composes the entity half from the description label, which is the current one. So an existing feed energy sensor +spelled `…_energy_consumed` is offered `sensor._consumed_energy`: the device half changes (panel → charger) and so does the suffix wording +(`energy_consumed` → `consumed_energy`, the label being `Consumed Energy`). **That is by design, not a bug.** These sensors belong to the charger's card, and +the alternative — reading a base back for them — is the bypass R1 was clarified to forbid. As everywhere else, nothing moves until the user presses Recreate and +accepts (R5). + +### Releasing the registry name + +Circuit-numbers mode used to deliver the panel's circuit name by writing the entity registry's `name` — the _user's_ field, which Core reads ahead of everything +else when generating an entity id, so occupying it made Recreate propose a friendly-name id for a circuit-numbered entity. +`naming.release_registry_name_written_by_older_release` hands that field back at the first start after upgrading. It clears only a value this integration would +have written — `"{circuit_name} {description name}"`, for the description's current label and every label it has carried, which is what +`SpanPanelCircuitsSensorEntityDescription.legacy_names` exists to record. Anything else in that field is the user's and is left alone. + +The display name now travels as `original_name` via `_attr_name` for both modes, which means a rename pushed from the SPAN app is picked up by rebuilding the +entity: `_sync_circuit_name` asks the coordinator for a reload rather than writing the registry in place. It skips both the reload and the name where the +registry holds a `name`, since the user's field outranks the panel's and reloading could not change what is displayed. + +## The suffix mappings are closed + +`get_user_friendly_suffix` and `get_panel_entity_suffix` translate legacy camelCase description keys (`instantPowerW`, `instantGridPowerW`, `doorState`) into +the suffixes their entities have carried since before 2.0.8. **Do not add entries.** A new description key needs none: it resolves to itself, which is what the +sub-device builders (`build_bess_unique_id`, `build_mid_unique_id`, `build_evse_unique_id`) have always done, since their keys were written snake_case. + +The reason is that the suffix is not only in the `unique_id` — it is the segment shared with the `entity_id` (it is the `suffix` half of +`naming.circuit_object_id_base`, and `get_panel_entity_suffix`'s own docstring says so). So an edit here moves both on every installed panel: the `unique_id` +costs the long-term statistics, and the `entity_id` breaks whatever templates and automations a user wrote against it. + +`tests/test_suffix_mappings_are_closed.py` holds all three dictionaries to their exact contents and fails on an added key, a removed key or a changed value — +verified by mutation, not by inspection. + +This closes the question of whether to go verbatim everywhere. The migration mechanism exists and would not cost statistics, since those key on `statistic_id` +(the entity_id), which a `unique_id`-only migration preserves. But because the suffix is shared, verbatim-across-the-board would force either verbatim +`entity_id`s — `sensor.span_panel_kitchen_instantPowerW`, breaking every user reference — or a decoupling of the two, which throws away the consistency the +helper exists to provide. Closing the mapping gets the whole benefit for none of that. + +## Settability is re-read, and the reader is the coordinator + +Which curated control entities exist is decided in each platform's `async_setup_entry`, from what the panel declares about each circuit. `switch` gates on +`circuit_has_a_breaker_switch`; `select` gates on `circuit_has_a_priority_select`, which additionally excludes `is_never_backup` — a separate commissioning +flag, since a never-backup circuit has a working relay and a priority the panel pins. + +Both predicates live in `helpers.py` rather than in their platforms, because two callers read them and a second copy would drift invisibly: a switch left on the +dashboard offering a control the panel refuses, or a circuit the panel has commissioned that never grows the switch it earned. `helpers.py` is also the only +place that sits below all three: `switch.py` and `select.py` import the coordinator, so the predicate they share with it cannot live in either of them. + +SPAN documents that re-commissioning a circuit in place cycles that child device's `$state` and republishes its `$description` with a new `$settable`, so a +circuit's settability can change while the integration is running, in either direction. `SpanPanelCoordinator._check_settability_change` watches both from one +place, on every push: + +- **A circuit that stops being controllable** can be seen from its own entity — which is where this used to be watched, and it is only one of the two edges. +- **A circuit that becomes controllable has no entity to see it with**, so only a reader over every circuit can catch that edge at all. That is the coordinator, + and putting both edges there keeps one answer rather than two half-answers. + +`_read_settability` builds `{circuit_id: (switch?, select?)}` from the same two predicates the platforms gate on, and a difference against the previous reading +calls `request_reload()`. A reload rather than a quiet availability change, because under the new answer the entity should not exist, or should exist and does +not, and creating and removing entities is what `async_setup_entry` is for. Permanent entity IDs are not an obstacle: the registry maps `unique_id` to +`entity_id`, so a reload gives every entity back the ID it had, and both platforms already leave the registry entries of controls they do not create in place. + +Two details worth keeping: + +- **Only circuits present in _both_ readings are judged.** A circuit can drop out of a snapshot and come back — the platforms guard for exactly that — and + counting an absence as a settability change would turn a flap into a reload loop. Membership is still carried forward, so a circuit that leaves and returns + with a different answer is caught on its return. +- **The baseline is updated on the same pass that asks for the reload**, so a settled panel costs one dict comprehension per push and a changed one asks exactly + once. + +The library is the backstop under all of this: `span-panel-api` refuses a circuit relay or priority command for a circuit the panel declares non-settable, +raising rather than publishing, and each control reports that refusal as a translated error on the control the user touched. SPAN also reports a firmware defect +in which the `$settable` re-toggle on the runtime re-commissioning path is skipped until the service restarts, which is why the library refuses when _either_ +the declaration or the `relay-controllable` value says no. A stale flag therefore costs a reload rather than a wrong control. + +## Adopting a device this integration models nothing for + +The section above is about properties on devices we already read. This one is about a device type nobody modeled at all — a vendor's generator, heat pump or +second inverter, which the eBus schema explicitly permits. Such a device used to produce nothing: no device, no entity, no sign it was there. + +### The rule + +Both halves of vendor extensibility are adopted, and **the half decides the shape**: a device nobody modeled becomes a device, a property on a device we do +model becomes a reading on that device's existing card. + +| What arrives | What happens | +| ------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| A device type `MODELLED_TYPES` does not name | **Adopt as a device.** One sub-device, its properties surfaced beneath it. This section. | +| A new node or property on a device we _do_ model | **Adopt as a reading** on that device's card — never a new device. [Below](#extension-properties). | +| A new property on a device already adopted | Adopt, with its siblings. | +| A second instance of a modeled type | **Not adopted.** See below. | + +The two differ in what they can promise. An adopted _device_ is a card nothing else was ever going to describe. An adopted _property_ sits beside curated +entities on a card this integration designed, so it is deliberately the plainer thing: read-only, wire-named, and carrying no expectation that curation will one +day rename it into something better. + +Extra instances of a modeled type are **not** adopted. A second BESS is a multiplicity limitation of the snapshot model, not an unmodeled device, and adopting +it would stand a machine-named card beside the curated Battery describing the same hardware. The gap stays visible as a gap. + +### Inside an adopted device, the node decides the destination + +Keyed on the Homie node — what the eBus vocabulary defines — rather than on property names: + +- **`info/*` → device-card fields.** `model`, `serial-number`, `firmware-version`, `hardware-version`, `vendor-name`. The whole node, not just the five the card + reads: dropping only the recognized ones would surface `info/nominal-power` as a string sensor the moment a vendor declared one. +- **`connection/*` → the device link.** Topology, which is `via_device`. +- **Everything else → entities**, `EntityCategory.DIAGNOSTIC` and disabled by default. + +Why by node: the capability catalogs carry **no marker** for "this value is a device reference", so the only alternative is a hard-coded property-name list — +and such a list goes stale silently. `ebus-sdk`'s own `topology.py` covers `feeds-device-id` and `fed-by-device-id` and omits `grid-forming-entity`, which lives +on the `grid` capability. A node cannot go stale that way. + +### Nothing adopted enters long-term statistics unless its owner asserts one + +No adopted entity carries a `state_class` this integration chose. `test_no_state_class_is_set_anywhere_in_the_module` reads `adoption.py` as syntax and fails if +one ever appears there; a user-asserted one reaches the entity through the description helpers in +[`curation.py`](#the-description-helpers-are-the-only-place-state_class-is-spelled), which is the only module in the integration that spells the word. + +Three reasons the integration will not pick one itself, and they are independent: + +1. It is not declared on the wire and is not derivable from one. This integration ships `feedthroughEnergyProducedWh` as `TOTAL` beside + `mainMeterEnergyProducedWh` as `TOTAL_INCREASING` — same unit, same device class, opposite classification. +2. A wrong one writes corrupt long-term statistics, and fixing the producer afterwards does not repair them. +3. Enrolling a property nobody asked for into long-term statistics is a permanent write to every install's recorder database. + +None of the three is an argument against the _user_ choosing one, and all three are arguments for it being their choice rather than a default: they own the +vendor device, so they are not guessing, and the assertion is stored where it can be seen and undone. A user who would rather not assert one, or who wants a +different derivation, still wraps the reading in a template sensor, a Riemann-sum integration or a utility meter — either way it is their call, made on an +entity they chose to enable. + +`device_class` is enumerated in `DEVICE_CLASS_BY_UNIT` rather than inferred, and a curated record overrides whatever that map answers. A unit outside the map +gets **no** device class — `%` is deliberately absent, because its uses here are a state of charge, a confidence and a duty cycle, and no single class is right +for all of them. + +### The device exists even with no entities + +`async_register_adopted_devices` registers each adopted device explicitly, before the platforms are forwarded, rather than letting it fall out of entity +creation. The reason is a device that has no entities to fall out of: a vendor device publishing only an `info` node resolves entirely to the device card by the +node rule, creates no entity, and so had nothing to call `async_get_or_create` for it. It produced _nothing at all_ — no device, no entity, no notification. + +Running it before the platforms also makes the identity freeze single-valued: `resolve_identifier` runs once, at registration, so every entity created +afterwards resolves against a device that already exists and cannot disagree. + +### The device-level grammar is not injective, and the collision is caught + +`adopted_unique_id` spells `span_{serial}_adopted_{anchor}_{suffix}` and builds the suffix through `get_user_friendly_suffix`, which flattens the hyphens. That +is what makes it read like a curated suffix, and it is also what collapses `battery-2` + `cell-temperature` and `battery` + `2-cell-temperature` onto one id — +exactly the collision the extension grammar above avoids by carrying the path verbatim. + +The encoding does not change. An adopted `unique_id` is as permanent as a curated one, so adopting the injective form would move every id an install already +holds and strand the entities keyed on them, with nothing to migrate them back. The collision is handled where the entities are built instead: `_create` claims +one id per platform, the **lexically first wire path** keeps it, and the other is skipped with a WARNING naming both. Handing both to Home Assistant registers +one and drops the other permanently, with one core log line naming an id the user cannot map back to anything on the wire. + +**Lexically first, not first to arrive.** `_create` sorts each device's properties by wire path, so the survivor is a function of the declarations alone. +Picking by adapter emission order would have tracked the wire: a firmware update declaring the two colliding properties the other way round would then have +changed what a standing entity reads, silently, since the `unique_id` does not move and nothing here migrates it. Registry preference — how the extension cap +resolves its equivalent problem — is not available, because both properties resolve to the _same_ `unique_id` and the registry row cannot say which of them put +it there. + +The injective encoding belongs to whatever identity namespace comes next, and adopting it there is a maintainer's ruling rather than a fix. + +### A device or a property that arrives after setup + +Nothing here adds entities dynamically, so a vendor device or extension property that appears an hour after setup reaches the user through the capability reload +and nothing else. `detect_capabilities` therefore carries one token per adopted device id and one per extension subject/path alongside the named flags, and +`_check_capability_change` fires on set _expansion_ exactly as it does for `bess` or `pcs`. One token each rather than one hash of the set: a hash of a +shrinking set is as "new" as a hash of a growing one, so a single token would reload the integration when a device left the tree and flap it on one that came +and went. + +### The proxy link is recorded, and the nesting is not built + +`AdoptedDevice` carries `parent` (the device id it declares as its parent) and `proxied` (whether that parent is a peer rather than the tree root). Adoption +does not act on either: every adopted device is registered under the panel with `via_device_id`, exactly as every curated sub-device is. + +They are carried because a _proxied_ unmodeled device is a real shape we would otherwise flatten away without leaving evidence. The library's own reference tree +contains one — `bess-mid` declares `parent: bess`, which is the `{proxier-id}-{proxied-id}` naming of the specification's `devices/proxy.md`. A vendor gateway +proxying its own sub-devices arrives the same way, and the parent link is the only structural information about how they relate. + +**Diagnostics report `proxied`, never `parent`.** A device id can embed a serial — producers derive a DER's id preferring a serial over a default slug, which is +why the library holds PV's `info/serial-number` unvalued — so reporting the parent verbatim would leak the serial the block deliberately withholds. The boolean +answers a maintainer's actual question, which is whether a proxied unmodeled device has appeared at all. + +**Why the nesting waits.** [python-sdk#49](https://github.com/electrification-bus/python-sdk/issues/49#issuecomment-5359203067) settled that proxied ids differ +by design — several enclosures on a shared broker each proxying the same physical device produce different ids on purpose — and that consumers correlate by +`info/serial-number`, never by device id. It also records that `ebus-sdk` 0.21.0 shipped `DeviceSpec` and `DeviceTreeBuilder` +([python-sdk#57](https://github.com/electrification-bus/python-sdk/issues/57)), with the existing graph builder still to be reconciled against it. The tree +model is under active reshaping upstream, so the fields capture the evidence and the topology waits. + +That comment also strengthens two things already here. Its deferral mechanism — a `device_id` callable returning `None` defers the device until +`resolve_deferred()` — is the producer-side form of settling identity _before_ a device exists, which is what registering adopted devices ahead of the platforms +does from this end. And "there is deliberately no existence predicate … expressing it by not calling `add()` is right" is the rule the capability gates already +follow: presence in the tree is the signal, and there is no flag to consult. + +### Identity freezes at first sighting + +`resolve_identifier` looks up **both** candidate spellings — `{panel serial}_adopted_{wire id}` and `{panel serial}_adopted_{serial}` — before minting either, +and keeps whichever already exists. Both drift in practice: + +- a serial arriving _after_ adoption would move the device off its wire id, and +- a producer that derives its wire id from a serial moves the id itself when the serial appears, which is why this repository holds PV's `info/serial-number` + unvalued. + +Either move reads to Home Assistant as a device **replacement**, taking the entities and their history. The device registry is the memory, so this needs no new +persistence. + +`classify_sub_device_identifier` returns `None` for any identifier carrying the `adopted` token, tested before its suffix rules — the anchor is vendor +vocabulary, and a device id ending in `pv` would otherwise classify as the panel's solar sub-device. + +### Controls + +`classify` routes a declaration to a platform: + +| Declaration | Platform | +| ---------------------------------- | --------------- | +| `boolean`, settable | `SWITCH` | +| `boolean` | `BINARY_SENSOR` | +| `enum`, settable, with a `format` | `SELECT` | +| numeric, settable, with a `format` | `NUMBER` | +| anything else | `SENSOR` | + +A settable property with no `format` falls back to a reading because `format` is the value domain: a select with no option list and a number with no bounds are +broken controls, not safe ones. A settable `boolean` needs none — its datatype states the domain in full. + +All five creators share `_create`, so `classify` is the only place a property's platform is decided. `test_every_property_reaches_exactly_one_platform` asserts +that as a partition, which is what five separate predicates could not guarantee. + +Controls are disabled and diagnostic like every other adopted entity. There is deliberately no second, weaker gate — no read-only mode for settable properties. +Enabling an entity is a deliberate act, commanding it is a second one, the panel authorizes the write regardless of what we create, and this integration already +ships switches that open and close breakers. + +### The write, and why it is not a generic one + +`SpanMqttClient.set_adopted_property(device_id, node_id, property_id, value)` publishes the write. **The lookup is the authorization**: it resolves the property +against the current snapshot's `adopted_devices` and publishes to the `set_topic` that property carries. No topic is accepted from the caller. + +That matters because the obvious alternative — a `set_property_topic(device, node, property)` member on `SchemaAdapter` — would put every curated control one +argument away, and two of them do real work on the way out: + +- `set_dominant_power_source` translates `GRID` into the `ON_GRID` the v1.0 islanding assertion accepts. +- `set_evse_charge_limit` **refuses** a value above the commissioned ceiling, because publishing past it is the one write with a physical consequence. + +A generic write reachable at modeled devices routes around both. Because `set_topic` is populated only for settable properties on devices `is_modelled` +rejected, a modeled device produces no `AdoptedDevice` and cannot be addressed this way however the arguments are spelled. + +It also kept the change additive. A new `SchemaAdapter` member is required of every adapter package, so an install carrying an older adapter wheel would fail at +**discovery** — the whole integration, not one feature. + +No payload translation and no bounds check on the way out, deliberately: the library knows nothing about an adopted property beyond its declaration, and +inventing a bound would be inventing a fact about somebody's hardware. The entity constrains the value to the declared domain and the panel stays the authority +on whether to accept it. A `NUMBER` on an `integer` property publishes `45`, never `45.0`. + +Diagnostics report `adopted_devices.controls` — how many adopted properties write back rather than only reporting. + +### Telling the user what was added + +Additions are announced by `additions.async_announce_new_entities` as a **persistent notification**, not a Repair. An addition is not a repair: nothing is +broken and nothing needs fixing, and filing it under Repairs puts it in a category whose whole meaning is "something went wrong". The retired +`new_entities_disabled` issue is deleted at setup by `async_clear_retired_new_entity_notices`, because it was raised `is_persistent` and would otherwise stand +forever on an upgraded install with nothing left to re-derive it. + +Three things it does that the Repair did not: + +- **Enabled additions are announced too.** The old notice covered only `disabled_by=INTEGRATION`, reasoning that an enabled entity is already visible in the + entity list and its history. Nobody watches their entity count, so that reasoning made every enabled addition invisible. +- **It names every entity**, rather than a count plus three examples. "What exactly was added" means all of it. +- **The record is durable.** The old diff compared the registry before the platforms against the registry after, which answers correctly exactly once — on the + next startup the entity is already registered beforehand and the diff is empty by construction. `additions` records what it announced in a `Store`, so the + question is "has this been announced" rather than "was this registered in the last few seconds". + +It stays silent on a first install, and silent once more on the first run after this mechanism ships: an install predating the record has entities that were +never announced but are not new either, so the first pass adopts them as known. + +Adopted devices are collapsed to one line with a count — `Backup Generator (6 entities)` — because a vendor device declaring a dozen properties would otherwise +spend the entire notification on itself and teach the user to skip it, costing them the curated additions in the same message. + +**Extension properties collapse only above `additions.COLLAPSE_ABOVE` (five), and the asymmetry is the point.** An adopted device's line names a device that did +not exist before, which is itself the news at any count. An extension property sits on a card the user already has, so `Span Panel (2 entities)` tells them +strictly less than the two names would — which is exactly what a live b7 install produced for a postal code and a time zone. Counted per notification rather +than per device lifetime: five readings announced last month and one today is a one-line update, not a flood. The detector is `_extension_device_name`, which +tests the **unique_id** rather than the device identifier, because these live on curated cards and the card says nothing about them. + +**Translations are read from this component's own `translations/` directory**, not through `homeassistant.helpers.translation`. That helper filters to the +categories Home Assistant defines, and a persistent notification is not one of them — a custom category loads as nothing at all, which was verified rather than +assumed. The strings live under a `notifications` key in `strings.json` and all five locales, with English constants in `additions._FALLBACK` so an unreadable +file costs the translation and not the notification. + +### Diagnostics + +`adopted_devices` in the diagnostics payload carries the device type, model, property paths, datatypes, units, `settable` flags and the platform each would +take. **No values, no device name, no serial.** Same rule as `schema_discovery` and for the same reason: `TO_REDACT` is key-based over the config entry and +cannot protect a wire value put there. + +`adopted_curation` is the companion block, and it is `CurationOverlay.as_dicts()` verbatim: every stored record, keyed by its curation key, carrying its enum +values and nothing else. It withholds under the same rule for a narrower reason — the keys are wire addresses and the values are Core enum members, so there is +no wire value and no user free text in the block by construction. The free text a curated row does have (its name and its icon) lives in Core's registry rather +than in this store, so a diagnostics download cannot leak it from here at all. What the block answers is the question worth asking of a support attachment: +whether a surprising entity is surprising because a user asserted something, and which field it was. + +### Adopted entities declare no field paths + +`snapshot.adopted_devices` is outside the curated field-path vocabulary by construction — it carries no metadata row, so the producible gate has nothing to +check it against. `adoption.py` is therefore absent from `residual_field_paths()`'s import list, and its entity classes declare no `_residual_field_paths`. The +same holds for `extension.py` and `snapshot.extension_properties`, for the same reason. + +## Extension properties + +A property on a device this integration **does** model — `battery-2/cell-temperature` hung off the BESS by a battery vendor. Until 2.1.0b7 it reached the user +nowhere: it became a `DiscoveredMetadata` row and stopped at the diagnostics download. `extension.py` turns it into an entity on that device's existing card. + +### Where the value comes from + +The library carries it, in a type built for the purpose: + +| Type | Carries | Audience | +| -------------------------------------------- | ----------------------------- | --------------------------- | +| `DiscoveredMetadata` (`discovered.*` paths) | Declaration only, never value | Maintainer, via diagnostics | +| `ExtensionProperty` (`extension_properties`) | Declaration **and** value | User, via entities | + +The same wire property appears in both, joined by its `{node}/{property}` path. **`ExtensionProperty` is deliberately not a `FieldMetadata`**: `partition()` +walks `build_field_metadata()`, so a type that cannot enter that map has no path into a payload that leaves the machine. That is the diagnostics guarantee as a +shape rather than as a rule somebody has to remember, and `test_extension_property_is_not_field_metadata` asserts it. + +`schema-1`'s `addressed_rows()` is shared by `build_discovery` and `build_extension_properties` so the two cannot disagree about what "unaddressed" means. A +property counted as addressed by one and not the other would appear as an entity the diagnostics claim is ignored, or the reverse. + +### The identity, which is the irreversible part + +```text +span_{serial}_adopted_{scope}/{node}/{property} +``` + +- **Anchored on what is stable and ours** — the panel serial and the curated scope (`bess`, `mid`, `pv`, `panel`, `evse_{node}`, `circuit_{id}`). +- **Addressed by the wire path verbatim**, which is upstream's own capability-catalog spelling (`AdoptedProperty.path`, `discovery_path()`). Verbatim is what + makes it injective: the id _is_ the address. Normalizing hyphens would collapse `battery-2` + `cell-temperature` and `battery` + `2-cell-temperature` into one + id, which `test_the_pairs_a_normalising_grammar_would_collapse_stay_distinct` pins. +- **Never through `get_user_friendly_suffix`**, which de-_dots_ rather than de-hyphens and substitutes a curated suffix on a mapping hit. +- **Not the eBus proxy composition.** `{proxier-id}-{proxied-id}` is upstream's device-handle spelling, and upstream states those handles are not identities: + they differ across enclosures and are unstable across the proxy-to-native transition. An id anchored on one would rename itself when a device stopped being + proxied, and nothing here migrates, so there would be no recovery. +- An address outside the Homie charset (`[a-z0-9-]`) is **refused, not sanitized** — sanitizing is what would make the slash-split ambiguous. It stays visible + in diagnostics. + +The slash distinguishes the two adoption grammars: device-level ids contain none. + +### Terminal identity + +An adopted extension is never promoted, re-sourced, re-homed or migrated. It changes only on an **external** trigger — the publisher stops publishing it, or +better metadata arrives. Three consequences worth knowing before changing any of this: + +- **Curation is never blocked by one existing.** If a property is later curated, the curated entity is a _new_ entity with its own id and history; the adopted + one is not renamed into it. Ids are permanent, identity is not, and an earlier draft of this design built a registry take-over path to avoid that — it was + cut, because it rested on the library ceasing to emit the row in lockstep with curation, a two-repo promise whose conformance test cannot distinguish + "curation mapped it" from "the capture was regenerated without it". +- **Nothing is ever removed by this integration**, and there is no engagement test anywhere. A row the user deletes is recreated — disabled — at the next setup + while the property is still published, so deletion is not suppression. It sticks exactly when publishing has stopped, because then nothing exists to recreate + it from. That is why no suppression feature exists: the delete button already means "hide until next reload" for a live reading and "clear it out" for a dead + one, decided by the wire. +- **A property that stops being published reads unknown rather than disappearing.** Silence does not distinguish gone from not-yet-arrived. + +### What metadata may reshape, and what it may not + +| Attribute | Revisable later? | +| ------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `entity_category`, device class, unit, name | **Yes**, freely — no id change, no statistics | +| Platform (`sensor` vs `binary_sensor`) | **No.** The domain is baked into `entity_id` | +| `state_class` | **User-curated only**, through the [curation store](#curation-metadata-the-user-asserts) | + +The free half is free _because_ of the never half, and curation does not spend it: an uncurated row still carries no `state_class`, still writes no long-term +statistics, and still has nothing for a later unit or device-class change to reinterpret. What changes is who may end that: the owner of the device, explicitly, +on one row at a time — and from that point the row is in the same position as a curated entity, where changing a unit under a `state_class` is the unrepairable +case. That is why the curate command answers `incompatible_device_class` rather than storing a device class the declared unit does not admit. + +The platform is enforced in `resolve_platform`, not remembered: whatever domain the id is already registered under wins, however the declaration later changes. +`async_update_entity` raises `ValueError("New entity ID should be same domain")`, so re-deriving the platform from better metadata would not move a row — it +would strand it and mint a second entity beside it. `test_the_platform_a_row_is_born_under_is_the_one_it_keeps` is the guard. + +### Read-only, and why disabled-by-default does not gate it + +No switches, selects or number boxes, even where the panel declares the property settable — `classify_extension` is `adoption.classify` with its three control +rows deleted. The worked bypass: a vendor publishes `acme/charge-limit` beside the curated EVSE limit. The curated number goes through +`evse_charge_limit_payload()`, which **refuses** a value above the commissioned ceiling. An auto-generated number on the same device, fed by a generic set +topic, publishes whatever the user types on the same wire. The islanding assertion is the same shape — schema_1 translates `GRID` into `ON_GRID`. + +"Disabled-by-default gates the control", which `classify` argues for unmodeled devices, does not transfer: there the hazard is user intent, here it is semantic +interaction with curated logic the user cannot see. The library enforces it structurally — `ExtensionProperty` has no set-topic member to populate, and +`set_adopted_property` still resolves only against `adopted_devices`. + +### The cap + +`MAX_PER_DEVICE` (60) bounds what one **wire device** may mint — counted on `subject_key`, not on the card. The panel, every circuit and both lugs render on the +panel's card, so counting per card would pool thirty-five devices against one allowance and truncate a 32-circuit panel at two vendor properties each, with no +misbehaving publisher anywhere. Overflow raises a durable notice (`async_notice_declined_extensions`, once at setup rather than once per platform), because a +truncation the user cannot see is the one thing worse than the truncation: sixty of a device's eighty readings looks exactly like a device with sixty. + +**Told once, not once per setup.** The overflow is re-derived from the same wire at every setup, so raising it through `async_raise` put it back on screen after +every restart and reload — un-dismissable in practice, which is the opposite of what `notices.py` promises. It goes through `async_raise_on_change` instead, +which records what was announced in the notices store and raises again only when that changes. The fingerprint is the rendered device/count list rather than the +message, so another device overflowing is news and a translation update is not; dismissal does not clear the record, because clearing it there is the defect. + +**An id the registry already holds is never displaced.** The cap admits in adapter emission order, which tracks the wire, so a firmware update declaring a new +property earlier shifts everything after it. Capping on arrival order alone would let a new property evict a standing entity — whose registry row is permanent, +and for which nothing would ever build an entity again, leaving it unavailable forever with a stranger in its slot and no migration path by design. `adoptable` +therefore partitions registered from new, admits every registered row, and applies the cap only to the rest. Registry rows are permanent here and nothing +removes them, so a vendor node declaring hundreds of properties would otherwise put hundreds of rows in every entity picker on every install that met it, with +no later release able to take them back. Deliberately far above any real device — the sixteen `pcs` properties are the largest curated example — so it is a +backstop against a misbehaving publisher rather than a policy on normal ones. + +### The prominence hint + +`prominence_hint()` is advisory only: everything arrives `DIAGNOSTIC` regardless, and the hint rides along as an entity attribute for curation triage. Ranked by +confidence, and the ranking is the argument: + +1. **Identity-family naming → detail.** Highest confidence because it is purely _negative_ — a property named for a vendor, model, serial, part number or + firmware build is device description. +2. **A unit in `DEVICE_CLASS_BY_UNIT` → reading.** Moderate, and it may promote but never demote, because it fails systematically in one direction: the most + headline-worthy number a battery publishes is a `%` state of charge, and `%` is absent from that map on purpose, being equally a confidence or a duty cycle. +3. **Everything else → detail**, by fall-through rather than a third signal. The node a property hangs off is the obvious candidate for one and is deliberately + not consulted: Homie nodes are organizational, not editorial. + +The real fix is upstream: a declared `role` on the property, proposed in `SpanPanel_Docs/span/docs/dev/ebus-property-role-proposal.md`. Until then the ranking +is the shipping plan, and `entity_category` being free to revise is what makes a conservative default cheap. + +## Curation: metadata the user asserts + +Adoption's refusal was never "adopted entities may not have statistics" — it was "the integration will not guess", and those are the same rule only while nobody +who does know the answer has anywhere to say it. `curation.py` is that place. It owns three fields and no others: `state_class`, `device_class`, and promotion +out of `EntityCategory.DIAGNOSTIC`. Everything else a user might want to change about an adopted entity — its name, icon, area, display unit, precision, and +whether it is enabled at all — is registry state Core already owns, and this integration writes none of it. + +Identity is untouched in every case. A curated row keeps its `unique_id`, its `entity_id` and its platform: the overlay changes what an entity declares, never +what it is. That is what makes this safe to apply to entities whose ids are [permanent by design](#terminal-identity) — it is metadata handed to an existing +identity, not a second identity namespace. + +### The store is an overlay keyed by wire address + +`helpers.storage.Store` at `span_panel.curation.{entry_id}`, one per config entry for the same reason `additions.py` has one: two panels in one house curate +independently. The stored shape is `{"records": {key: {field: value}}}`, and a record holds only what the user asserted — a missing field means the adopted +default, and a missing key means the row was never curated at all. + +The keys are scope-prefixed wire addresses rather than `unique_id`s: + +| Half | Curation key | Built by | +| ---------------------------------- | -------------------------------- | ------------------------ | +| Vendor reading on a modeled device | `{scope}/{node}/{property}` | `extension_curation_key` | +| Entity on an adopted device | `{identifier}/{node}/{property}` | `adopted_curation_key` | + +The prefix is what makes a key injective: `path` is `{node}/{property}` on both models and is unique only within one device. The two namespaces cannot collide, +because only an adopted identifier carries the `_adopted_` token. Neither key goes through `get_user_friendly_suffix`, which is what makes `adopted_unique_id` +[deliberately non-injective](#the-device-level-grammar-is-not-injective-and-the-collision-is-caught) — keying the store on a `unique_id` would have inherited +that collision and let one record reach two wire addresses. + +**A record asserting nothing clears its key rather than being stored.** Its stored form is `{}`, which `parse_record` refuses, so writing it would leave a +record on disk that the next load reports as unreadable — the warning meant for a damaged or hand-edited store — and the save after that would delete, over a +value the signature accepts. Save may not write what load rejects. + +Records are never pruned. One whose wire path stops being published goes inert rather than being deleted, which is the same "the integration never decides a +row's fate" stance the rest of adoption takes. The whole store does go when the config entry is removed (`async_forget_curation`), and that one is deliberate: +the keys are wire addresses rather than registry ids, so a store left behind is one the next entry for the same panel would load and apply, re-asserting +metadata the user removed the panel to be rid of. + +### Validation refuses at save, and runs again at construction + +`validate_record` refuses rather than warns, because a stored record is applied unattended at every future setup — a warning would be read once, by nobody. +Everything decidable without the wire is decided in the websocket schema instead (enum membership, the one storable `entity_category`, the key's charset), so +only cross-field questions reach the validator: a `state_class` needs a sensor row with a numeric datatype, a `device_class` must belong to its platform's enum +and must fit both what the row declares it is and the unit it declares (Core's own `NON_NUMERIC_DEVICE_CLASSES` and `DEVICE_CLASS_UNITS`), and a control row +accepts prominence and nothing else. + +The datatype half of that came later than the unit half and closes a real gap: gating on the unit alone let a text row be offered `power_factor`, `aqi` and +`monetary`, which constrain no unit and so passed vacuously, each of which reads `unknown` for the life of the install. Which side of Core's partition a row +falls on is `util.declares_a_number`'s answer — a numeric `$datatype` or a declared unit — which is the same predicate `AdoptedSensor.native_value` uses to +decide whether to parse. Sharing it is the point: a row whose reading is parsed as a float and whose only offered device class was `enum` would be incoherent, +and Core refuses to render a state carrying both a unit and a non-numeric device class at all. + +`declares_a_number` is also why a unit-less numeric reading is a number. The unit used to stand in for "this is numeric", so a bare `integer` count published as +`"42"` reached the state machine as text — harmless while an uncurated row asserted nothing about itself, and not harmless once its owner could put a +`measurement` on exactly that row and hand the recorder a string under it. The union rather than the datatype alone, because a publisher that omits a +`$datatype` still declares a unit, and nothing that parsed before may stop parsing. + +The same validator runs again at construction, where it drops rather than refuses. A record can go stale between the save and a later setup — the vendor may +change a row's unit or datatype — so `sanitise` re-measures each field independently, keeps the ones that still validate, and `CurationOverlay.for_row` emits +one warning naming what it dropped. It never raises: curation must not be able to block setup. It never deletes either, because the wire may revert and the +user's other assertions are still good. + +That is also why the list command reports a record **as stored** rather than as it would be applied, beside a `stale_fields` list naming the difference. +Reporting the sanitized form would show the user an assertion they never made and hide that theirs was dropped. + +### The description helpers are the only place `state_class` is spelled + +`adoption.py` and `extension.py` each carry an AST guard asserting the token appears nowhere in them — not as a keyword, not as an `_attr_state_class` target, +not as a `SensorStateClass` name. Both stay true while their entities carry curated state classes, because neither module builds its own description: both call +`curation.sensor_description`, which takes the wire path, the declared unit, the `DEVICE_CLASS_BY_UNIT` default and the record, and returns the +`SensorEntityDescription` the entity is constructed from. `binary_sensor_device_class` and `entity_category_for` do the same job for the other two fields. + +This ends up stricter than the design asked for. The plan was to relax the adoption guard into "the keyword is permitted when its value comes from the curation +interface"; routing through a helper meant it did not have to relax at all, and the guard newly added for `extension.py` could be the same absolute form rather +than a weaker one. A guard that admits one shape of exception is a guard somebody has to re-read before trusting. + +### The two commands write no registry state + +`websocket_adopted.py` defines `span_panel/adopted/list` and `span_panel/adopted/curate`, and `websocket.py`'s `async_register_commands` registers them beside +`panel_topology` — the import runs that way and only that way, so no cycle can appear as further commands join. Both are `@require_admin` and both take the main +panel's device registry id, which every command resolves through one function, `websocket_panel.resolve_panel_device` — so all three answer the same error +codes, and a consumer that learned one set does not meet a second. [websocket-api.md](websocket-api.md) is the wire contract; what matters here is the boundary. + +**Enabling is Core's act, and so are naming, icons, areas, display units and precision.** `config/entity_registry/update` already exposes all of them, already +requires admin and already carries the undo, so duplicating any of it here would mean two writers for one field and no rule about which wins. What is left over +is exactly what Core has nowhere to put — a state class, a device class and prominence for an entity built from a vendor declaration — and that is the whole of +what `curate` stores. `entity_category` is the interesting one: it _is_ a registry column, but it is absent from that websocket's schema, which is why promotion +has to come from us. + +Both commands derive their rows through one function, `_rows`, using the same helpers the entity builders use — `resolve_identifier` and `classify` for an +adopted device, `adoptable` and `resolve_platform` for a vendor reading. A second derivation would let the editor disagree with the entities it edits: offering +a state class for a row that is really a control, or a key `curate` cannot resolve. `curate` re-derives rather than trusting the key it was handed, because the +store is keyed on wire addresses and a key nothing publishes would be held forever — read by no entity and shown on no list. + +`_rows` inherits the adopted-device collision rule as a **skip** rather than a listing. A row whose `unique_id` was claimed by a lexically earlier wire path is +left out entirely, because `entity_id` resolves by (platform, `unique_id`): listing it would report the _winner's_ entity beside the loser's curation key, +inviting a record saved against an entity that will never read it under a live `entity_id` saying it will. `_create` already warns and names both addresses, so +the skip is silent. + +### The reload is the mechanism, not a courtesy + +A save has exactly three effects: the record is written, the entry is scheduled for reload, and the result is returned. The reload is the half that reads as +politeness and is not. An entity description is fixed when the entity is constructed, so a record reaches its entity only by that entity being built again — and +being built _with_ it, because a `state_class` that first appears after states have been recorded is a statistics reset rather than a metadata change. + +The response also carries advisory `warnings`, which are consequences of a save rather than objections to it: the write has already happened, and the user asked +for it. `statistics_removed` fires on what a save _leaves_ rather than on how it was spelled — a record narrowed to its other fields drops a state class exactly +as clearing the whole record does — and names Core's answer to that, which is to raise its `state_class_removed` repair and stop compiling statistics for the +entity. Statistics already collected are not deleted. `total_increasing` is the other warning, and it reinterprets a reading rather than describing it: +`sensor/recorder.py`'s `reset_detected` reads a drop of more than a tenth as a meter reset, so a reading that legitimately falls manufactures consumption. + +## Runtime data lives in `runtime.py`, and one helper answers for it + +`SpanPanelRuntimeData` and `SpanPanelConfigEntry` used to live in `__init__.py`, which imports every platform — so naming the entry type meant importing the +whole integration. Seven modules imported them from the package root and five more reached for them from inside functions to break the cycle. They now live in +`runtime.py`, whose only runtime dependency inside this package is `control_gate` (itself a leaf); the coordinator is needed there as an annotation only. The +package root re-exports both names, so `from custom_components.span_panel import SpanPanelRuntimeData` keeps working, and every deferred import of them is a +top-level one again. + +**Use `runtime.loaded_runtime_data(entry)` rather than an inline `hasattr` / `isinstance` pair.** Every service is registered domain-wide and walks +`async_loaded_entries(DOMAIN)`, so each one has to ask the same question before touching an entry, and this used to be five copies of it — plus two more in the +package root, in `async_unload_entry` and `async_remove_config_entry_device`, which is why the helper lives in `runtime` (a leaf that already owns the type) +rather than in `services`. Both halves of the check are real, which is why the helper is written the way it is: `ConfigEntry.runtime_data` is a bare annotation +that core _deletes_ on unload, so the attribute is genuinely absent on an entry that has not finished setting up (hence `getattr` with a default), and what is +there is whatever the owning integration put there, so `isinstance` is what says it is ours. AGENTS.md service point 6 names this helper. + +## Why the CA pinning behaves as it does + +The README says what the pinning does; this is why each of those choices was made rather than the obvious alternative. All of it was written for the README and +moved here, because it answers "why is it like that" rather than "what do I do". + +**The fingerprint is not shown at first contact, on purpose.** Comparing it against another source is what closes the active-in-path case, and at first contact +there is nothing to compare against — SPAN does not publish the value, so the question could only ever be answered by pressing Submit. A dialog that asks a +question the user cannot answer trains them to dismiss it, which costs the one time it matters. So the value is put where it can actually be used instead: +diagnostics under `panel_ca`, the setup log, and any other install of this integration on the same panel. + +**Diagnostics carry the fingerprint and not the certificate.** The certificate is public, so withholding it buys no secrecy — it is omitted because it is +multi-KB and the fingerprint is the part anyone reads. + +**An unreachable panel does not stop setup.** The integration starts anyway and retries on the next startup. The exposure in the meantime is exactly the one the +entry already had before pinning existed, and refusing to start would not remove it — it would remove the integration, leaving the credential no safer while +guaranteeing an outage. Retrying closes the window at the first opportunity instead. + +**Reauth keeps the anchor it acquires.** An entry that predates pinning, or whose stored authority no longer loads, goes through the certificate-authority step +before either sign-in method is offered, and keeps the anchor afterwards rather than falling back to a plaintext fetch on every connection. Reauth is a +registration — it carries the passphrase out and the broker password back, the same exchange setup performs — so it is the one flow where acquiring an anchor +first is worth a step in front of the user. + +**Reauthenticate is the route that pins on screen; Reconfigure pins silently.** The certificate-authority step asks for the TLS port where the HTTP port has +been moved — the install most likely to be behind a proxy — and errors if the authority does not sign what the panel serves. Reconfiguring the entry to the +panel's own address reaches the same place, but the pin then happens during the reload that follows, announced only by a `WARNING` reading "Pinned the CA +advertised by SPAN panel …" with the fingerprint. + +**A registered domain is checked once the panel reports the new certificate**, not when registration is requested. The panel regenerates its certificate around +the FQDN asynchronously, so the flow polls for the name to appear in the SAN rather than assuming the call took effect. + +## The Supervisor discovery path is unguarded on ports + +A pinned entry only follows a discovered host when that host serves a certificate the entry's anchor validates — `async_step_zeroconf` and the by-hand re-add +both go through that check, because the serial they match on comes from an unauthenticated endpoint anything on the LAN can answer. + +`async_step_hassio` deliberately does not hold its **ports** to that check. A Supervisor discovery arrives over the authenticated Supervisor API from an add-on +the user installed, and add-ons legitimately reallocate their own ports across restarts, so holding the ports to the stored values would freeze the entry +against its own add-on. + +**The host is not covered by that argument, and is not taken on the add-on's word.** An add-on republishing its container hostname has not moved the panel, and +that hostname is generally not a name the panel's certificate carries — writing it over a working host broke an entry seconds after it was created. So +`_async_hassio_host_update` probes the _configured_ host on the _newly discovered_ port, which is precisely the reallocated-port case, and keeps the stored host +whenever a v2 answer there carries this panel's serial. Only a configured host that has stopped answering for this panel is replaced, and then `CONF_HOST` and +`CONF_EBUS_BROKER_HOST` move together — moving one without the other left the entry naming two different machines. + +That is a narrower check than the other two routes make, and the residual cost is stated rather than hidden: an add-on that already holds Supervisor privileges +can still move an entry whose configured host has genuinely gone away. If that trade is ever revisited, the guard is the same helper the other two routes call. + ## Linting and Type Checking ```bash diff --git a/developer_entity_id_and_name_handling.md b/developer_entity_id_and_name_handling.md new file mode 100644 index 00000000..5aca4a47 --- /dev/null +++ b/developer_entity_id_and_name_handling.md @@ -0,0 +1,256 @@ +# Entity IDs and Names — the rules + +This document is the permanent statement of how this integration names entities and composes their entity IDs. It exists because the question has been +re-derived from scratch more than once. The rules below are the maintainer's intent; they outrank any single design document, review note or older comment. A +change to naming is checked against them first, and a change that cannot satisfy them is not made. + +Every claim here is grounded in the code as of 2.1.0 (`naming.py`, `entity.py`, `sensor_base.py`, `sensor_circuit.py`, `switch.py`, `select.py`) and in Home +Assistant Core 2026.8 (`helpers/entity_registry.py`, `helpers/entity_platform.py`). When the code and this document disagree, one of them is wrong — fix it, do +not paper over it. + +## 1. The five rules + +| # | Rule | +| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **R1** | **Recreate never offers an uncaused rename.** The base this integration supplies must reproduce an existing entity's ID when Home Assistant composes it under the entity-ID options matching how that install was built. Our own doing — two suffix spellings, a reworded label, a registry `name` an older release wrote — never produces an offer. An offer that reflects the user's own configuration (an area they assigned, a device they renamed, a circuit they renamed on the panel, `entity_id_parts` they chose) is legitimate. | +| **R2** | **The integration supplies only the flag-driven base.** `Circuit 15 power` or `Kitchen Outlets power`. Device and area prefixes are Core's, by the user's global `entity_id_parts` choice, and this integration does not exempt itself from that choice. No `entity_id` is preset, ever. | +| **R3** | **Circuit-numbers mode promises the circuit-number token stays stable across circuit renames** — not the surrounding prefix text, which is Core's. | +| **R4** | **The display name is the panel's, delivered as `original_name`.** The registry `name` field is the user's and is never written by this integration. The one write it makes to that field is to _clear_ a value a pre-2.1.0 release wrote there (§6). | +| **R5** | **Existing entity IDs never move.** Nothing moves an ID without the user pressing **Recreate entity IDs** and accepting the offer. Unique IDs never change with the naming style. | + +A consequence worth stating outright: R1 does **not** mean bypassing `entity_id_parts` with a hard-coded ID. Where composition yields a different device or area +part than the entity has today, that is the user's configuration at work and the offer stands. The one-time attempt to add "legacy preset" exceptions for those +cases was withdrawn. + +## 2. Three fields, three owners + +Every entity has three naming-relevant values. Keeping them apart is the whole design. + +| Value | Owner | What it is | Where it lives | +| ------------------- | --------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| **Display name** | The panel | `"{circuit name} {label}"` — `Kitchen Outlets Power`, `Bathroom Lights Breaker` | `_attr_name` → registry `original_name`. Same string in both naming modes. | +| **Object-ID base** | The naming flag | `"{identifier} {suffix words}"` — `Kitchen Outlets power` or `Circuit 15 power` | `SpanPanelEntity._span_object_id_base` → `suggested_object_id` property → registry `object_id_base`. | +| **Registry `name`** | The user | Whatever they typed in the entity's settings. Outranks everything in both display and ID composition. | Registry `name`. Read to detect an override; never written (except the §6 release). | + +The display name and the base are decoupled **on purpose**: a label can be reworded without touching a single ID, and an ID can carry an old spelling while the +label is current. + +## 3. How Home Assistant composes an ID (Core 2026.8) + +This is Core's behavior, cited so nobody has to re-read Core to trust the rest of the document. + +### 3.1 Priority + +`EntityRegistry._async_generate_entity_id` (`helpers/entity_registry.py`): + +```text +name > suggested_object_id > object_id_base +``` + +- `name` (the user's registry name) — always prefixed with the device name. +- `suggested_object_id` — used **verbatim, not prefixed** with device or area. This is where a preset `entity_id` lands. +- `object_id_base` — prefixed with device (and area, if the user's settings include it) when `has_entity_name` is True. All of ours have it True. + +The prefixing follows `self.settings.entity_id_parts`, defaulting to `(AREA, DEVICE, ENTITY)`. Device name is `device.name_by_user or device.name`; the area is +the entity's, falling back to the device's. + +### 3.2 What the platform hands Core + +`entity_platform._async_derive_object_ids`: + +- If the entity preset `entity_id` (`internal_integration_suggested_object_id`), that becomes `suggested_object_id`. **We never do this.** +- Otherwise `entity.suggested_object_id` becomes `object_id_base`. The stock property returns the display name; ours returns the base (§4). + +### 3.3 The stored row is refreshed on every add + +`async_get_or_create` on an entity that already exists calls `_async_update_entity(..., object_id_base=..., suggested_object_id=...)`, and the update applies +any value that is not `UNDEFINED` and differs from the stored one — **including `None`**. So every reload rewrites the row's `object_id_base` from the current +base and clears a stale `suggested_object_id`. The row is never frozen at creation. + +### 3.4 Recreate entity IDs regenerates from the stored row + +`async_regenerate_entity_id(entry)` composes from `entry.name`, `entry.suggested_object_id`, `entry.object_id_base`, `entry.device_id`, `entry.area_id` — the +**stored row**, not the live entity. Two corollaries: + +- A row whose `suggested_object_id` still holds a 2.0.8 preset reproduces that preset forever, whatever the entity now wants. That is exactly why 2.0.8 could + not follow a rename (#252), and why a 2.0.8 install that _appears_ to run 2.1.0 but has not actually loaded it shows "No renamable entity IDs". +- A row whose `name` is set composes from that, device-prefixed, in both display and ID. That is why a 2.0.8 circuit-numbers install got friendly-name offers + (§6). + +## 4. What this integration does — one path, both modes + +### 4.1 The identifier (the naming flag's half) + +`sensor_circuit._resolve_circuit_identifier`, and the same branch inline in `switch.py` / `select.py`: + +| Mode | Identifier | Source | +| --------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------- | +| Circuit numbers (`use_circuit_numbers`) | `Circuit 15`, `Circuit 30 32` (every tab, sorted) | `helpers.construct_circuit_identifier_from_tabs(circuit.tabs, circuit_id)` | +| Friendly names, circuit has a name | the panel's name, verbatim | `circuit.name` | +| Friendly names, circuit unnamed | `Solar` (PV), `EV Charger` (EVSE), else `Circuit 7` (tab) | `_unnamed_circuit_fallback` — never `None`, so two unnamed circuits never collide | +| Unmapped tab (either mode) | `Unmapped Tab 32` | `SpanUnmappedCircuitSensor._object_id_parts` | + +The identifier **always answers**. A `None` identifier lets Core compose from the label alone, which gives every unnamed circuit the same ID and leaves the +registry to disambiguate with `_2`, `_3` — the pre-2.1.0 behavior that is now gone. + +### 4.2 The suffix + +Each entity names its canonical suffix: + +- Circuit sensors: `id_builder.get_user_friendly_suffix(description key)` → `power`, `energy_consumed`, `energy_produced`, `energy_net`, `current`, + `breaker_rating`. +- Switch: `"breaker"`, named outright (`get_user_friendly_suffix` has no switch entry). +- Select: the description key `"circuit_priority"`, verbatim (`get_user_friendly_suffix` would map it to `priority`, which is the unique-ID's spelling, not the + entity-ID's). + +`CIRCUIT_SUFFIX_MAPPING` in `id_builder` builds **unique IDs** and is closed. The tables in `naming.py` govern **entity IDs only**. Do not cross them. + +### 4.3 The base — `naming.circuit_object_id_base(identifier, suffix, existing_entity_id)` + +Returns `"{identifier} {suffix words}"`, where the words are decided in this order: + +1. **The existing ID still names this circuit** (`..._{identifier_slug}_{form}` or `..._{identifier_slug}`): what follows the identifier settles both the form + the ID carries and whether the preset builder omitted the suffix. They are one question, answered together — answering them separately produced + `..._current_current_power` for a circuit named "Current" and `..._solar_power` for one named "Solar Power", both uncaused renames (R1). +2. **The existing ID no longer names this circuit** (renamed on the panel, #252): the form is read back from the _end_ of the ID by itself + (`_existing_suffix_form`), and the omission is decided from the _new_ name. The name half follows the panel; the suffix half does not. +3. **No existing ID**: the noun-last wording from `NEW_ENTITY_ID_SUFFIX_WORDS` (`consumed energy`, matching the panel-level `main_meter_consumed_energy`). + +`ENTITY_ID_SUFFIX_FORMS` lists every suffix form ever shipped (`consumed_energy` before the preset era, `energy_consumed` after; `current_power` and `power`). +**An entry is added only when another form is discovered to have shipped, never to introduce one.** An existing entity is offered whichever spelling it already +carries and never the other (R1). + +Worked examples (friendly mode, `(DEVICE, ENTITY)` parts, device "Span Panel"): + +| Circuit name (panel) | Existing ID | Suffix | Base | Composed / offered | +| ----------------------- | --------------------------------------------------- | ----------------- | --------------------------------- | --------------------------------------------------------------- | +| Kitchen Outlets | none | `energy_consumed` | `Kitchen Outlets consumed energy` | `sensor.span_panel_kitchen_outlets_consumed_energy` | +| Kitchen Outlets | `sensor.span_panel_kitchen_outlets_energy_consumed` | `energy_consumed` | `Kitchen Outlets energy consumed` | its own ID — no offer | +| Bathroom Lights Renamed | `switch.span_panel_bathroom_lights_breaker` | `breaker` | `Bathroom Lights Renamed breaker` | `switch.span_panel_bathroom_lights_renamed_breaker` | +| Solar Power | `sensor.span_panel_solar_power_power` | `power` | `Solar Power power` | its own ID | +| Solar Power | `sensor.span_panel_solar_power` | `power` | `Solar Power` | its own ID | +| Power | `sensor.span_panel_power_power` | `power` | `Power power` | its own ID (a name that _is_ the suffix word keeps both halves) | + +Circuit-numbers mode is the same table with `Circuit 15` in the identifier column — and a rename on the panel changes nothing in it (R3). + +### 4.4 Delivery to Core + +`SpanPanelEntity` (`entity.py`) carries `_span_object_id_base: str | None = None` and overrides the `suggested_object_id` property to return it when set, or +`super().suggested_object_id` (the display name) when not. Core's `type.__getattribute__` guard covers the `name` property only, so this override is supported. +Circuit sensors, the switch and the select set the base at construction. Every other entity leaves it `None` and composes from its label as it always has. + +`entity_id` is **never** preset. Nothing in this integration assigns `self.entity_id` or `_attr_entity_id`. + +### 4.5 Who composes without a base + +- **Sub-device circuit sensors** (an EVSE feed circuit's sensors, `_is_sub_device`): no base. Core composes from the label on the charger's device, so the ID + names the charger alone — `sensor._power` — like the charger's other sensors. A feed sensor created before this route keeps its old ID until Recreate + is pressed, and is then offered the charger-named one (a legitimate offer: the device part is Core's). +- **Everything not a circuit entity** (panel, BESS, MID, EVSE, PV, binary sensors, adopted properties): stock behavior, label-composed. + +### 4.6 Legacy `use_device_prefix` + +Stored, preserved by the options flow, and read by `get_current_naming_pattern`, but it **cannot keep the device out of a composed ID** — Core includes the +device unless the user's own `entity_id_parts` excludes it. An install predating the option, whose circuit IDs carry no device half, is offered one on Recreate. +That is Core composing under the user's settings, and it is documented in the README and CHANGELOG rather than special-cased. + +## 5. Name sync — what "phase 1" and "phase 2" mean, and what they mean now + +The terms come from the pre-2.1.0 design and are still used in conversation, so here is what each was and what it is today. + +### 5.1 Before 2.1.0 (the two-phase scheme, for context only) + +- **Phase 1 — construction.** `_attr_name` was set from the _naming flag_ (`Circuit 15 Power` in circuit-numbers mode) so that Core would derive a flag-shaped + `object_id_base`, and `entity_id` was **preset** by a builder, which Core stored as `suggested_object_id`. A new entity displayed the flag name until phase 2. +- **Phase 2 — first coordinator update, and every rename after.** Circuit-numbers mode **wrote the registry `name`** with the panel's name to fix the display. + That put the panel name in the user's field, which outranks everything in ID generation — so Recreate proposed a friendly-name ID for every circuit-numbered + entity, and a rename in the SPAN app could never reach the preset `suggested_object_id` (#252). Friendly mode's phase 2 was idempotent (same string) and wrote + nothing. + +### 5.2 In 2.1.0 the phases collapse to one + +There is one name path, in both modes: + +1. **Construction** (`sensor_base.py`, `switch.py`, `select.py`): `_attr_name = "{circuit name} {label}"` — the _panel's_ name, unconditionally, existing entity + or not. It reaches the registry as `original_name`. The base (§4.3) is computed at the same time and handed to Core through `suggested_object_id`. If an + existing row carries a `name` a pre-2.1.0 release wrote, it is released (§6). +2. **Every coordinator update** (`_handle_coordinator_update` / `_sync_circuit_name`): the entity compares the panel's current circuit name with the one it was + built from. + - If the registry `name` is set, the user has overridden the name: the panel's rename is **noted and ignored** — theirs outranks it, and a reload would + change nothing on screen. + - If the entity was **not** in the registry at construction (`_previous_circuit_name is _NAME_UNSET`), the first update requests one reload. This is the + surviving trace of "phase 2": a freshly created entity's registry row is settled by that reload. + - If the name **changed**, the entity requests a reload (`coordinator.request_reload()`). +3. **Reload** (`coordinator.py`, end of the update cycle): the entry is torn down and rebuilt. `original_name` is only written when an entity is added, so a + rebuild is the only way to refresh it — that is why a rename costs a reload rather than a registry write. The rebuild also recomputes the base with the new + circuit name and, per §3.3, rewrites the row's `object_id_base`. + +The rename therefore reaches: + +| What | Friendly names | Circuit numbers | +| ------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Display name | new name, after the reload | new name, after the reload (`Span Panel Kitchen Outlets Power`, device-prefixed like friendly mode) | +| Entity ID | unchanged (R5) | unchanged (R5) | +| Row `object_id_base` | `Kitchen Outlets Renamed power` | `Circuit 15 power` — unchanged | +| Recreate entity IDs offer | `sensor.span_panel_kitchen_outlets_renamed_power` | its own ID — no offer (R3) | +| Registry `name` | untouched | untouched | + +Settability changes (a circuit becoming or ceasing to be controllable) are **not** watched here; `SpanPanelCoordinator._check_settability_change` does that, +because the "gains a control" edge happens on circuits that have no entity to see it. + +## 6. Releasing the registry `name` a pre-2.1.0 release wrote + +`naming.release_registry_name_written_by_older_release(registry, entity_id, circuit_name, description_names)` runs once per existing circuit entity at +construction. It clears the registry `name` **only** if it equals `"{circuit_name} {label}"` for the description's current label _or any label it has carried +before_ (`SpanPanelCircuitsSensorEntityDescription.legacy_names`: `Energy Consumed` / `Energy Produced` / `Energy Net` beside the current `Consumed Energy` +etc.). Any other value is the user's and is left exactly where it is. The switch releases `"{name} Breaker"`, the select `"{name} {description name}"`. + +This is the one write this integration ever makes to the user's field, and it is a hand-back, not a claim. Miss a release site and that platform's Recreate +stays poisoned: under composition a stale `name` composes _with the area_, so a 2.0.8 name on a panel in "Basement" would propose +`sensor.basement_span_panel_kitchen_outlets_consumed_energy`. + +## 7. Offers that are legitimate — and the one that is not fully avoidable + +Recreate offers a different ID only where the user's own configuration composes one. Each of these is documented for users in README and CHANGELOG: + +| Situation | Offer | Cause | +| ---------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------- | +| Circuit renamed on the panel, friendly mode | the new name's ID | user renamed it | +| Panel device in an area, `entity_id_parts` includes AREA (the default) | area-prefixed ID | user assigned the area | +| Panel device renamed by the user (`name_by_user`) | ID carrying that name | user renamed the device | +| Sensor on a SPAN Drive feed circuit | charger-named ID | device part is Core's | +| Second panel whose generated device name is "Span Panel 2" | `span_panel_2_…` | device part is Core's | +| Install predating `use_device_prefix`, circuit IDs with no device half | device-prefixed ID | device part is Core's | +| Unnamed circuit in friendly mode whose ID carries `single_circuit`/`power_2` | `circuit_7…` | **the one uncaused offer** — no computed identifier reproduces both eras; accepted and documented | + +Anything not in this table that Recreate offers on an unchanged install is a bug against R1. + +## 8. Things that must stay true (checklist for any change touching naming) + +- [ ] No entity presets `entity_id`. `Grep` for `entity_id =` and `_attr_entity_id` in platform code finds nothing. +- [ ] The registry `name` is never assigned a value; the only `async_update_entity(..., name=...)` call is the `None` in + `release_registry_name_written_by_older_release`. +- [ ] `_attr_name` is the panel's name in both modes; nothing branches on `use_circuit_numbers` to pick a display name. +- [ ] The base is computed from `(identifier, canonical suffix, existing_entity_id)` and nothing else — never from the display label. +- [ ] `ENTITY_ID_SUFFIX_FORMS` gains an entry only for a form found to have shipped; `NEW_ENTITY_ID_SUFFIX_WORDS` and it stay in one-to-one correspondence + (`test_every_form_table_entry_has_a_new_wording_and_vice_versa`). +- [ ] A reworded description label adds the old label to `legacy_names` so §6 still recognizes what 2.0.8 wrote. +- [ ] Unique IDs are untouched by any of this; `CIRCUIT_SUFFIX_MAPPING` is closed. +- [ ] `tests/test_naming.py` and `tests/test_recreate_entity_ids.py` pass. The latter drives a real `EntityPlatform` with `entity_id_parts` fixed, so a + composition regression fails there rather than in a unit test of a builder. +- [ ] Run the `entity-id-auditor` agent after editing `id_builder.py`, `entity_resolver.py`, `naming.py` or any circuit platform file. + +## 9. Diagnosing "Recreate offered nothing" on a live install + +1. Confirm the running code: `manifest.json` version and the presence of `custom_components/span_panel/naming.py`. A 2.0.8 install with 2.1.0 selected in HACS + but not yet loaded shows exactly this symptom, because its rows still hold the 2.0.8 preset in `suggested_object_id` (§3.4). +2. Read the entity's row in `.storage/core.entity_registry`: on 2.1.0 after at least one reload, `suggested_object_id` is `null` and `object_id_base` reads the + base (`Bathroom Lights Renamed breaker`). A non-null `suggested_object_id` means the entity has not been re-added by 2.1.0 code. +3. Check `name` on the row: a non-null value is a user override (or an unreleased 2.0.8 write — compare against §6's patterns) and outranks the base. +4. Confirm the rename-triggered reload happened: the log carries `Auto-sync detected circuit name change from '…' to '…'` followed by the entry reload. +5. Only then look at `circuit_object_id_base` with the row's exact `entity_id`, identifier and suffix — `tests/test_naming.py` shows how to call it directly. + +## 10. Where the history lives + +The design trail (issue #252 write-up, the 2026-08-22 spec and plan, the 2026-08-26 composition-route review) lives in the `SpanPanel_Docs` workspace, not in +this repository. It records how the rules were arrived at and the routes that were rejected. It is history; this document is the rule. diff --git a/frontend.md b/frontend.md index 25788d71..fa3d808d 100644 --- a/frontend.md +++ b/frontend.md @@ -93,6 +93,34 @@ values. ![Monitoring Configuration](images/monitoring.png) +## Adopted View + +The Adopted tab is where you tell the integration what an adopted entity means. Your panel's schema is vendor-extensible, so it can publish devices and readings +this integration has never modeled; those arrive carrying only what the wire says about them, and the rest is yours to assert. The tab is shown to administrator +accounts only, and on a panel rather than in the Favorites view. + +Rows are grouped by the device card they render on — one group per adopted device, one per modeled device carrying vendor readings — and each row expands into a +small form. A badge marks a row you have already curated, and another marks one whose saved values the panel has stopped supporting. + +Three of the fields are the integration's own, because Home Assistant has nowhere to keep them for an entity built from a vendor declaration: + +- **Device class** — what kind of quantity the reading is, which gives it a sensible icon and, for the classes Home Assistant knows how to convert, a choice of + display unit. +- **Statistics class** — Home Assistant's `state_class`, which is what enrolls the reading in long-term statistics and makes it usable on an Energy dashboard. +- **Prominence** — whether the entity stays filed under Diagnostics or is promoted out of it. + +The rest of the form — **name**, **display unit** and **precision**, and whether the entity is **enabled** — is Home Assistant's own entity settings, shown here +so you do not have to go elsewhere, and written straight into Home Assistant's registry exactly as if you had edited the entity there. Those fields appear once +the entity exists in the registry; until then the row offers the integration's three and says so. The icon and the area are Home Assistant's own settings too +and are not restated here — set them from the entity's settings dialog. + +You are offered only what your panel's declaration allows: a statistics class on numeric readings, and the device classes that fit both the declared datatype +and the unit the panel publishes, so a text reading is never offered a class that expects a number. Setting or clearing a statistics class asks you to confirm +first, because that choice is written into recorded history and correcting the class afterwards does not repair what was already recorded. Saving reloads the +integration, which is how the setting takes effect — the reload rebuilds the entity already carrying what you asserted. + +See [The Adopted Tab](README.md#the-adopted-tab) in the README for the fuller account of what gets adopted and why. + ## Favorites View The dashboard supports a cross-panel **Favorites** view that lets you curate a single workspace from circuits and sub-devices (BESS, EVSE) belonging to any of diff --git a/hacs.json b/hacs.json index 1d29ef7d..77764d3c 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,7 @@ { "name": "Span Panel", "content_in_root": false, - "homeassistant": "2023.3.0", + "homeassistant": "2026.8.0", "render_readme": true, "zip_release": false, "filename": "custom_components/span_panel" diff --git a/prek.toml b/prek.toml index fb65adcb..5586aed6 100644 --- a/prek.toml +++ b/prek.toml @@ -1,5 +1,23 @@ default_stages = ["pre-commit"] +# `tests/fixtures/pyproject_*.toml` are byte copies of this file's own pyproject.toml +# at the commits where the library path override went wrong, held to those commits +# by tests/test_library_path_hook.py -- they are what proves the library-path gate +# fires, so they have to stay the real files rather than approximations of them. +# +# The three hooks below that *rewrite* files -- trailing-whitespace, +# end-of-file-fixer and mixed-line-ending -- are excluded from them, because a +# formatter and a byte-identical copy cannot both be unconditional and it is the +# formatter that has to yield: the bytes are the artifact. +# +# Not hypothetical, merely not yet triggered. Those three cover tests/ today, and +# they leave the current copies alone only because both happen to be +# newline-terminated with LF endings and no trailing whitespace. The first one +# taken without a final newline would be rewritten on commit, and the guard would +# then fail against a commit the copy had genuinely matched when it was made. +# +# The read-only hooks keep covering them. check-toml in particular should: a copy +# that does not parse is worth hearing about wherever it came from. [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v5.0.0" @@ -10,9 +28,27 @@ hooks = [ { id = "check-added-large-files" }, { id = "check-merge-conflict", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, { id = "debug-statements", exclude = '^tests/.*|\.\S*_cache/.*|dist/.*|venv/.*', language_version = "python3.14" }, - { id = "trailing-whitespace", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, - { id = "end-of-file-fixer", exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, - { id = "mixed-line-ending", args = ["--fix=lf"], exclude = '\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "trailing-whitespace", exclude = '^tests/fixtures/pyproject_.*\.toml$|\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "end-of-file-fixer", exclude = '^tests/fixtures/pyproject_.*\.toml$|\.\S*_cache/.*|dist/.*|venv/.*' }, + { id = "mixed-line-ending", args = ["--fix=lf"], exclude = '^tests/fixtures/pyproject_.*\.toml$|\.\S*_cache/.*|dist/.*|venv/.*' }, +] + +# The library path override in pyproject.toml must name the checkout, not a +# worktree. `3cbf02a` committed a scratch worktree into `[tool.uv.sources]` and +# `[tool.pyright].extraPaths`; it redirected every import in the suite for +# several commits, and the conformance tests ran against a producer defect the +# library had already fixed. Runs early -- it is a read of one small file, and +# a run that resolves the wrong library has nothing useful to say afterwards. +[[repos]] +repo = "local" +hooks = [ + { + id = "library-path", + name = "span-panel-api paths name the checkout, not a worktree", + entry = "python scripts/check-library-path.py", + language = "system", + files = '^pyproject\.toml$', + }, ] # Sync translations/en.json from strings.json and validate translation files @@ -49,7 +85,7 @@ hooks = [ # Ruff for linting, import sorting, and primary formatting [[repos]] repo = "https://github.com/astral-sh/ruff-pre-commit" -rev = "v0.15.1" +rev = "v0.15.12" hooks = [ { id = "ruff-format", exclude = '^tests/.*|scripts/.*|\.\S*_cache/.*|dist/.*|venv/.*' }, { id = "ruff-check", args = ["--fix"], exclude = '^tests/.*|scripts/.*|\.\S*_cache/.*|dist/.*|venv/.*' }, diff --git a/pyproject.toml b/pyproject.toml index b3e559bc..42ea8b60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,41 +1,72 @@ [project] name = "span" -version = "2.0.7" +version = "2.1.2" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ - "homeassistant==2026.5.4", - "span-panel-api==2.6.4", + "homeassistant==2026.9.4", + "span-panel-api==3.6.2", + # Separate distributions, not dependencies of the bootstrap: adapters are found + # through the `span_panel_api.schema_adapters` entry-point group, so each one has + # to be installed for discovery to see it. manifest.json carries the same three, + # and that is the list Home Assistant actually reads. + "span-panel-api-schema-0==1.2.0", + "span-panel-api-schema-1==1.2.2", ] [dependency-groups] dev = [ - "homeassistant-stubs==2026.5.4", "types-requests", "types-PyYAML", - "mypy==1.20.2", - "pyright==1.1.409", - "ruff==0.15.12", + "mypy==2.4.0", + "pyright==1.1.414", + "ruff==0.16.10", "bandit[toml]==1.9.4", - "prek>=0.3.11", + "prek>=0.5.4", "voluptuous-stubs", "python-direnv", "prettier", "radon==6.0.1", - "pylint==4.0.5", - "pytest-homeassistant-custom-component>=0.13.333", + "pylint==4.1.2", + # Pinned, not floored: each release pins one exact Home Assistant, so this + # and the `homeassistant` pin above are a single choice rather than two. + # 0.13.357 is the release that pins 2026.8.3 — the version pinned above; a + # bump to 0.13.358 pinned 2026.9.0b0 and made the pair unresolvable, which + # CI's re-lock from PyPI catches and a stale local venv does not. + "pytest-homeassistant-custom-component==0.13.367", "isort", - "vulture>=2.14", + "vulture>=2.16", ] [tool.uv] package = false [tool.uv.sources] -span-panel-api = { path = "../span-panel-api", editable = true } +# The primary checkout, which is where the pinned releases are developed. +# +# These pointed at a `-security` worktree while that work was unmerged. That +# outlived its reason and became actively harmful: the worktree never received +# span-panel-api#161, so the adapters' reference payloads resolved to a +# parent/child capture that still recorded the emitter's `$settable` defect on a +# locked relay, and the conformance tests asserted against it. A path override is +# a redirection of what every import in the suite resolves to -- including the +# reference payloads the tests replay -- so leaving one aimed at a stale checkout +# silently tests against a producer that no longer exists. Point these at a +# checkout that tracks `main`, and move them only for as long as an unmerged +# branch is actually under test. +# +# A path that does not exist is not a soft failure: `uv run` cannot build the +# environment at all, so nothing in this repo runs until it is corrected. +# +# CI never reads this block -- it deletes it and re-locks from PyPI (.github/ +# workflows/ci.yml) -- so a stale path here breaks only local work. It does mean +# CI cannot pass until the pinned releases are on PyPI. +span-panel-api = { path = "../../span/span-panel-api", editable = true } +span-panel-api-schema-0 = { path = "../../span/span-panel-api/packages/schema-0", editable = true } +span-panel-api-schema-1 = { path = "../../span/span-panel-api/packages/schema-1", editable = true } [tool.jscpd] path = ["custom_components/span_panel", "./*.{html,md}"] @@ -81,12 +112,13 @@ exclude = [ ] # Error codes +# No "mutable-override", matching Core's mypy configuration: Core's typed coordinator +# `config_entry` pattern relies on it being off. enable_error_code = [ "deprecated", "ignore-without-code", "redundant-self", "truthy-iterable", - "mutable-override" ] disable_error_code = [ "annotation-unchecked", @@ -115,7 +147,7 @@ exclude = [ ] extraPaths = [ "./custom_components", - "../span-panel-api/src", + "../../span/span-panel-api/src", "../ha-synthetic-sensors/src" ] pythonPlatform = "Darwin" diff --git a/requirements_test.txt b/requirements_test.txt index 0733eb9d..cd72a219 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,5 +1,8 @@ -pytest>=9.0.3 -pytest-asyncio>=1.3.0 +pytest>=9.1.1 +pytest-asyncio>=1.4.0 setuptools>=65.7.0 -pytest-homeassistant-custom-component>=0.13.333 -homeassistant>=2026.5.4 +pytest-homeassistant-custom-component==0.13.367 +homeassistant==2026.9.4 +span-panel-api==3.6.2 +span-panel-api-schema-0==1.2.0 +span-panel-api-schema-1==1.2.2 diff --git a/scripts/check-library-path.py b/scripts/check-library-path.py new file mode 100644 index 00000000..9713c711 --- /dev/null +++ b/scripts/check-library-path.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Hold every `span-panel-api` path in `pyproject.toml` to the library checkout. + +`pyproject.toml` is a committed, shared file, and two blocks in it decide where +`span_panel_api` comes from: `[tool.uv.sources]`, which every import in the suite +resolves through, and `[tool.pyright].extraPaths`, which decides what the type +checker reads. Both are written as relative paths so they mean the same thing in +the primary checkout and in every worktree beside it. + +Working against unreleased library code needs those paths aimed somewhere else +for a while, and the obvious way to do that is to edit them. That is what +happened in `3cbf02a`: both blocks were pointed at a scratch worktree and +committed. It stood for several commits and a few hours of other people's work, +and while it stood the conformance tests ran against a producer defect the +library had already fixed. Every version-based check passed -- the stale worktree +declared the same version number as the corrected code. A version string does +not identify content; only a filesystem location does. + +So the redirection does not belong in a committed file at all. It belongs in the +virtual environment, where it affects one developer's next command and nothing +else, and `developer.md` ("Working against unreleased library code") says how. +This hook is what makes that the only route: it rejects a `pyproject.toml` whose +`span-panel-api` paths name anything but the checkout. + +Absence is not a failure. CI deletes the whole `[tool.uv.sources]` block before +installing, so it resolves from PyPI and re-runs these hooks against a file that +no longer has one. A block that is not there declares nothing to be wrong about. +""" + +from __future__ import annotations + +from pathlib import Path +import sys +import tomllib + +CHECKOUT = "../../span/span-panel-api" +"""The library checkout, relative to this repository's root. + +Relative rather than absolute because an absolute path would be correct on +exactly one machine. The cost is that it is depth-sensitive: it arrives from the +primary checkout and from a worktree placed beside it, and misses from a worktree +nested inside the checkout, as `.claude/worktrees/` is. `setup-hooks.sh` +closes that gap with a symlink rather than this file with a second spelling, +because what is written here is what every import in the suite resolves through. +""" + +LIBRARY = "span-panel-api" +"""The name that makes a path this hook's business. + +Matched as a substring rather than by parsing the path, because the failure being +caught is a *sibling* of the checkout -- `span-panel-api-security`, +`span-panel-api-p3` -- and a sibling shares every path component up to the last. +""" + +WORKFLOW = "developer.md, 'Working against unreleased library code'" +"""Where the supported alternative to editing this file is written down.""" + + +def _table(parent: dict[str, object], name: str) -> dict[str, object]: + """Return a sub-table of a parsed TOML document, or an empty one. + + Missing and present-but-not-a-table are the same answer deliberately: this + hook reports paths that are wrong, and `check-toml` in the same hook run + already reports a file that does not parse. Two hooks failing on one + malformed file says nothing the first one did not. + """ + child = parent.get(name) + if not isinstance(child, dict): + return {} + narrowed: dict[str, object] = {} + for key, value in child.items(): + narrowed[str(key)] = value + return narrowed + + +def declared_paths(document: dict[str, object]) -> list[tuple[str, str]]: + """Return every path in the document that names the library, with where it came from. + + Both blocks, not just `[tool.uv.sources]`. They went wrong together in + `3cbf02a` and were corrected separately -- `82a512f` moved `extraPaths` back + six hours and several commits after the sources block, because nothing was + looking at it. A hook that covers one of two places that hold the same fact + leaves the slower half of the same defect in place. + """ + tool = _table(document, "tool") + found: list[tuple[str, str]] = [] + + sources = _table(_table(tool, "uv"), "sources") + for name, source in sorted(sources.items()): + if not isinstance(source, dict): + continue + path = source.get("path") + if isinstance(path, str) and LIBRARY in path: + found.append((f"[tool.uv.sources] {name}", path)) + + extra_paths = _table(tool, "pyright").get("extraPaths") + if isinstance(extra_paths, list): + for entry in extra_paths: + if isinstance(entry, str) and LIBRARY in entry: + found.append(("[tool.pyright] extraPaths", entry)) + + return found + + +def is_within_checkout(path: str) -> bool: + """Whether a declared path is the checkout itself or something inside it. + + The trailing separator on the prefix is the whole check. + `../../span/span-panel-api-security` starts with `../../span/span-panel-api` + as a string and is a different directory, which is exactly the mistake being + caught; `../../span/span-panel-api/src` does not survive that comparison by + accident. + """ + return path == CHECKOUT or path.startswith(f"{CHECKOUT}/") + + +def offences(path: Path) -> list[str]: + """Return one line per path that names the library and is not inside the checkout.""" + document: dict[str, object] = dict(tomllib.loads(path.read_text(encoding="utf-8"))) + return [ + f"{path}: {where} = {declared!r}" + for where, declared in declared_paths(document) + if not is_within_checkout(declared) + ] + + +def main(arguments: list[str]) -> int: + """Check each file named on the command line; default to this repository's own.""" + repository = Path(__file__).resolve().parent.parent + targets = [Path(argument) for argument in arguments] or [repository / "pyproject.toml"] + + found: list[str] = [] + for target in targets: + if target.is_file(): + found.extend(offences(target)) + + if not found: + return 0 + + print("A span-panel-api path names something other than the library checkout:") + for offence in found: + print(f" {offence}") + print( + f"\nEvery path here must be {CHECKOUT!r} or a directory inside it. To test against " + f"unreleased library work, put the worktree in the virtual environment instead of in " + f"this file:\n" + f"\n uv pip install -e ../../span/span-panel-api-\n" + f"\nThat redirects your next command and nothing else, and `uv sync` puts it back. " + f"See {WORKFLOW}.\n" + f"\nThis file is shared: a worktree named here redirects every import in the suite, " + f"for everyone, for as long as it goes unnoticed -- and a stale checkout declares the " + f"same version as the current one, so nothing that compares versions can see it." + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/sync-dependencies.py b/scripts/sync-dependencies.py index f318e568..f6d92c4a 100755 --- a/scripts/sync-dependencies.py +++ b/scripts/sync-dependencies.py @@ -1,105 +1,200 @@ #!/usr/bin/env python3 -"""Synchronize dependency versions from manifest.json to pyproject.toml. - -This script reads the dependency versions from custom_components/span_panel/manifest.json -and updates the corresponding dependencies in pyproject.toml to match. - -Used as a pre-commit hook to ensure pyproject.toml stays in sync with manifest versions. +"""Hold every declaration of a library pin to the one in manifest.json. + +Home Assistant installs what `custom_components/span_panel/manifest.json` +requires, so that file is the only one whose pins reach a user. Every other +place the same library is named -- `pyproject.toml` for local development, +`requirements_test.txt` for a bare pip setup -- is a copy, and a copy that drifts +is worse than no copy: the tests pass against one version while the integration +ships another. + +Run as a pre-commit hook. Rewrites the copies and exits non-zero when it changed +something, so the commit stops and the corrected files are re-staged. + +**Why this was rewritten.** The previous version matched versions with `[0-9.]+` +and so stopped at the first letter: `3.0.0b7` was seen as `3.0.0`, which matched +nothing and silently rewrote nothing. Every version this project has ever shipped +is a pre-release, so the hook has been inert for its whole life while reporting +success on every commit. It also only ever looked for the bootstrap package -- +`span-panel-api-schema-0` starts with `span-panel-api`, so it entered the branch +and then failed its own regex -- and it never knew about `requirements_test.txt` +at all, which is how that file came to pin `b4` against a manifest requiring `b7`. + +The lesson is in the shape rather than the regex: nothing parsed, so nothing +could report that it had not matched. This version parses the requirement, looks +the name up, and verifies the result, so a miss is an error rather than a +no-change. """ +from __future__ import annotations + import json from pathlib import Path import re import sys +import tomllib +REPO = Path(__file__).resolve().parent.parent +MANIFEST = REPO / "custom_components" / "span_panel" / "manifest.json" +PYPROJECT = REPO / "pyproject.toml" +REQUIREMENTS_TEST = REPO / "requirements_test.txt" -def get_manifest_versions(): - """Extract dependency versions from manifest.json.""" - manifest_path = Path("custom_components/span_panel/manifest.json") - - if not manifest_path.exists(): - return None - - try: - with open(manifest_path) as f: - manifest = json.load(f) - - requirements = manifest.get("requirements", []) - versions = {} - - for req in requirements: - if req.startswith("span-panel-api"): - # Extract full specifier (e.g. ==2.3.0, >=2.0.0, ~=1.1.0) - match = re.search(r"span-panel-api([>~=!]+[0-9.]+)", req) - if match: - versions["span-panel-api"] = match.group(1) - elif req.startswith("ha-synthetic-sensors"): - # Extract full specifier (e.g. >=1.0.8, ~=1.0.8) - match = re.search(r"ha-synthetic-sensors([>~=!]+[0-9.]+)", req) - if match: - versions["ha-synthetic-sensors"] = match.group(1) - - return versions +# One requirement: a PEP 508 name, then a specifier that runs to the end. The +# version half is deliberately unconstrained -- `3.0.0b7`, `1.0.0rc1`, `2.6.4`, +# `1.0.0.post1` and `>=1,<2` all have to survive it, and enumerating version +# grammar is what broke the last one. +REQUIREMENT = re.compile( + r"^(?P[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\s*(?P[<>=!~].*)$" +) - except Exception: - return None +class SyncError(Exception): + """Something is wrong with the inputs, as opposed to merely out of date.""" -def update_pyproject_dependencies(versions): - """Update pyproject.toml dependencies with manifest versions.""" - pyproject_path = Path("pyproject.toml") - if not pyproject_path.exists(): - return False +def manifest_pins() -> dict[str, str]: + """Return the manifest's requirements, keyed by package name. + Raises rather than returning empty on a bad manifest. An unreadable source of + truth is not a reason to leave every copy alone and report success. + """ + if not MANIFEST.is_file(): + raise SyncError(f"no manifest at {MANIFEST.relative_to(REPO)}") try: - with open(pyproject_path) as f: - content = f.read() - - original_content = content - - # Update span-panel-api version in [project] dependencies - if "span-panel-api" in versions: - span_spec = versions["span-panel-api"] - content = re.sub( - r'"span-panel-api[><=~!]+[0-9.]+"', - f'"span-panel-api{span_spec}"', - content, + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + except ValueError as exc: + raise SyncError(f"{MANIFEST.relative_to(REPO)} is not valid JSON: {exc}") from exc + + pins: dict[str, str] = {} + for requirement in manifest.get("requirements", []): + match = REQUIREMENT.match(str(requirement).strip()) + if match is None: + raise SyncError( + f"manifest requirement {requirement!r} has no version specifier; " + "Home Assistant installs these verbatim, so an unpinned one is a bug " + "rather than something to copy" ) - - # Update ha-synthetic-sensors version in [project] dependencies - if "ha-synthetic-sensors" in versions: - ha_spec = versions["ha-synthetic-sensors"] - content = re.sub( - r'"ha-synthetic-sensors[><=~!]+[0-9.]+"', - f'"ha-synthetic-sensors{ha_spec}"', - content, + pins[match["name"]] = match.group(0) + if not pins: + raise SyncError(f"{MANIFEST.relative_to(REPO)} declares no requirements") + return pins + + +def _project_dependencies_span(text: str) -> tuple[int, int]: + """Return the character span of `[project]`'s `dependencies = [...]` array. + + Scoped rather than global on purpose. `pyproject.toml` names these same + packages twice more -- as editable path overrides under `[tool.uv.sources]`, + and inside mypy's search path -- and rewriting either would replace a + filesystem path with a version specifier. + """ + project = re.search(r"^\[project\]\s*$", text, re.MULTILINE) + if project is None: + raise SyncError("pyproject.toml has no [project] table") + next_table = re.search(r"^\[", text[project.end() :], re.MULTILINE) + end_of_project = project.end() + ( + next_table.start() if next_table else len(text) - project.end() + ) + + array = re.search( + r"^dependencies\s*=\s*\[", text[project.start() : end_of_project], re.MULTILINE + ) + if array is None: + raise SyncError("[project] declares no dependencies array") + start = project.start() + array.end() + closing = text.find("]", start) + if closing == -1: + raise SyncError("[project] dependencies array is never closed") + return start, closing + + +def sync_pyproject(pins: dict[str, str]) -> list[str]: + """Rewrite pinned requirements inside `[project] dependencies`. Return the changes.""" + text = PYPROJECT.read_text(encoding="utf-8") + start, end = _project_dependencies_span(text) + changes: list[str] = [] + + def replace(match: re.Match[str]) -> str: + requirement = REQUIREMENT.match(match["req"]) + if requirement is None or requirement["name"] not in pins: + return match.group(0) + wanted = pins[requirement["name"]] + if match["req"] == wanted: + return match.group(0) + changes.append(f"pyproject.toml: {match['req']} -> {wanted}") + return f'"{wanted}"' + + body = re.sub(r'"(?P[^"]+)"', replace, text[start:end]) + if changes: + PYPROJECT.write_text(text[:start] + body + text[end:], encoding="utf-8") + return changes + + +def sync_requirements_test(pins: dict[str, str]) -> list[str]: + """Rewrite pinned requirements in `requirements_test.txt`. Return the changes.""" + if not REQUIREMENTS_TEST.is_file(): + return [] + lines = REQUIREMENTS_TEST.read_text(encoding="utf-8").splitlines() + changes: list[str] = [] + + for index, line in enumerate(lines): + requirement = REQUIREMENT.match(line.strip()) + if requirement is None or requirement["name"] not in pins: + continue + wanted = pins[requirement["name"]] + if line.strip() != wanted: + changes.append(f"requirements_test.txt: {line.strip()} -> {wanted}") + lines[index] = wanted + + if changes: + REQUIREMENTS_TEST.write_text("\n".join(lines) + "\n", encoding="utf-8") + return changes + + +def verify(pins: dict[str, str]) -> None: + """Read the rewritten files back and confirm they say what was intended. + + The point of the whole rewrite. A regex that matches nothing produces no + changes, which is indistinguishable from a file that was already correct -- + that is exactly how the previous version stayed silently broken. Checking the + result against the manifest turns a miss into a failure. + """ + declared = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["project"]["dependencies"] + for requirement in declared: + match = REQUIREMENT.match(str(requirement).strip()) + if match and match["name"] in pins and match.group(0) != pins[match["name"]]: + raise SyncError( + f"pyproject.toml still declares {requirement!r} after syncing; " + f"the manifest pins {pins[match['name']]!r}" ) - if content != original_content: - with open(pyproject_path, "w") as f: - f.write(content) - return True - - return False + if REQUIREMENTS_TEST.is_file(): + for line in REQUIREMENTS_TEST.read_text(encoding="utf-8").splitlines(): + match = REQUIREMENT.match(line.strip()) + if match and match["name"] in pins and match.group(0) != pins[match["name"]]: + raise SyncError( + f"requirements_test.txt still declares {line.strip()!r} after syncing; " + f"the manifest pins {pins[match['name']]!r}" + ) - except Exception: - return False +def main() -> int: + """Sync, verify, and fail the commit if anything moved.""" + try: + pins = manifest_pins() + changes = sync_pyproject(pins) + sync_requirements_test(pins) + verify(pins) + except SyncError as exc: + print(f"sync-dependencies: {exc}", file=sys.stderr) + return 1 -def main(): - """Main function.""" - versions = get_manifest_versions() - if not versions: - sys.exit(1) - - changes_made = update_pyproject_dependencies(versions) - - if changes_made: - sys.exit(1) # Exit with error to fail pre-commit - else: - sys.exit(0) + if not changes: + return 0 + for change in changes: + print(f"sync-dependencies: {change}") + print("sync-dependencies: files updated to match the manifest; re-stage and commit again") + return 1 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/scripts/sync_translations.py b/scripts/sync_translations.py index 0423cb1f..47f5e0da 100644 --- a/scripts/sync_translations.py +++ b/scripts/sync_translations.py @@ -15,6 +15,15 @@ STRINGS_PATH = COMPONENT_DIR / "strings.json" TRANSLATIONS_DIR = COMPONENT_DIR / "translations" EN_PATH = TRANSLATIONS_DIR / "en.json" +NOTIFICATIONS_DIR = COMPONENT_DIR / "notifications" +NOTIFICATIONS_EN = NOTIFICATIONS_DIR / "en.json" +"""Persistent-notification strings, deliberately outside `translations/`. + +hassfest validates `strings.json` and `translations/en.json` against Home +Assistant's own schema and rejects any key it does not define, so these cannot +live there. They still need the same key-parity guarantee across languages, +which is why this script checks them too rather than leaving them unchecked. +""" def collect_leaf_keys(obj: dict | str, prefix: str = "") -> set[str]: @@ -67,17 +76,17 @@ def sync_en(source: dict) -> bool: return True -def validate_translations( - source_all_keys: set[str], source_leaf_keys: set[str] +def validate_dir( + directory: Path, source_all_keys: set[str], source_leaf_keys: set[str], source_name: str ) -> list[str]: - """Validate all non-en translation files. Return list of error messages.""" + """Validate every non-en file in `directory` against the English source.""" errors: list[str] = [] - for lang_file in sorted(TRANSLATIONS_DIR.glob("*.json")): + for lang_file in sorted(directory.glob("*.json")): if lang_file.name == "en.json": continue - lang = lang_file.stem + lang = f"{directory.name}/{lang_file.stem}" try: translation = json.loads(lang_file.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: @@ -87,14 +96,14 @@ def validate_translations( orphaned = find_orphaned_keys(source_all_keys, translation) if orphaned: errors.append( - f"{lang}: {len(orphaned)} orphaned key(s) not in strings.json:\n" + f"{lang}: {len(orphaned)} orphaned key(s) not in {source_name}:\n" + "\n".join(f" - {k}" for k in orphaned) ) missing = find_missing_keys(source_leaf_keys, translation) if missing: errors.append( - f"{lang}: {len(missing)} missing key(s) from strings.json:\n" + f"{lang}: {len(missing)} missing key(s) from {source_name}:\n" + "\n".join(f" - {k}" for k in missing) ) @@ -118,7 +127,16 @@ def main() -> int: display_path = EN_PATH print(f"Updated {display_path}") - errors = validate_translations(source_all_keys, source_leaf_keys) + errors = validate_dir(TRANSLATIONS_DIR, source_all_keys, source_leaf_keys, "strings.json") + + if NOTIFICATIONS_EN.exists(): + notifications = json.loads(NOTIFICATIONS_EN.read_text(encoding="utf-8")) + errors += validate_dir( + NOTIFICATIONS_DIR, + collect_all_keys(notifications), + collect_leaf_keys(notifications), + "notifications/en.json", + ) if errors: print("Translation validation failed:", file=sys.stderr) for error in errors: diff --git a/setup-hooks.sh b/setup-hooks.sh index 589ee2fb..6231b8cb 100755 --- a/setup-hooks.sh +++ b/setup-hooks.sh @@ -1,5 +1,57 @@ #!/bin/bash +# Make `../../span/span-panel-api` mean the library checkout from here. +# +# `[tool.uv.sources]` names the checkout by a path relative to the repository +# root, so that one spelling means the same directory on every machine. It does, +# from the primary checkout and from a worktree placed beside it -- but a +# worktree nested *inside* the checkout, as `.claude/worktrees/` is, sits +# two levels deeper, and the same relative path lands on a directory that does +# not exist. That is not a soft failure: `uv run` cannot build an environment at +# all, so every hook that runs through it -- pylint, mypy, vulture, both radon +# hooks and the test suite -- fails before it starts, and the first commit from a +# new worktree is where you find out. +# +# So bridge it: a symlink at the place the nested layout looks, pointing at the +# directory the primary checkout means. Both ends are derived from git rather +# than written down here, so this holds under any worktree layout and on any +# machine. It is a link and not an edit to `pyproject.toml` for the reason +# `scripts/check-library-path.py` exists: that file is shared, and a path in it +# redirects every import in the suite for everyone. +bridge_library_checkout() { + local git_common_dir checkout_root library_root worktree_root bridge + + git_common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || return 0 + if [[ $git_common_dir != /* ]]; then + git_common_dir="$PWD/$git_common_dir" + fi + checkout_root=$(cd "$(dirname "$git_common_dir")" && pwd) || return 0 + + worktree_root=$(git rev-parse --show-toplevel 2>/dev/null) || return 0 + bridge="$worktree_root/../../span" + + # True in the primary checkout, where the relative path already arrives, and + # in a worktree that has been bridged already. Both want nothing done. + if [[ -e $bridge ]]; then + return 0 + fi + + library_root=$(cd "$checkout_root/../../span" 2>/dev/null && pwd) + if [[ -z $library_root ]]; then + echo "No library checkout beside $checkout_root; skipping the worktree bridge." + return 0 + fi + + ln -s "$library_root" "$bridge" || return 1 + echo "Bridged $library_root into this worktree so ../../span resolves." +} + +bridge_library_checkout +if [[ $? -ne 0 ]]; then + echo "Failed to bridge the library checkout. Please check the output above." + exit 1 +fi + # Ensure dependencies are up to date (uv sync is fast and idempotent) uv sync if [[ $? -ne 0 ]]; then @@ -7,8 +59,11 @@ if [[ $? -ne 0 ]]; then exit 1 fi -# Install pre-commit hooks (only if not already installed) -if [[ ! -f ".git/hooks/pre-commit" ]]; then +# Install pre-commit hooks (only if not already installed). Asked of git rather +# than assumed to be `.git/hooks`, because a worktree's `.git` is a file and its +# hooks live in the shared common directory. +hooks_path=$(git rev-parse --git-path hooks/pre-commit) +if [[ ! -f "$hooks_path" ]]; then prek install echo "Git hooks installed successfully!" fi diff --git a/test_migration.py b/test_migration.py deleted file mode 100644 index e69de29b..00000000 diff --git a/test_migration_comprehensive.py b/test_migration_comprehensive.py deleted file mode 100644 index e69de29b..00000000 diff --git a/test_migration_simple.py b/test_migration_simple.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py new file mode 100644 index 00000000..b70997ca --- /dev/null +++ b/tests/adapter_fixtures.py @@ -0,0 +1,246 @@ +"""Build real adapter field metadata from the adapters' own reference payloads. + +The library's own harness compares wire-level deltas between schemas; it cannot +know what this integration declares it reads. These helpers give the +integration's tests real adapter output to check declarations against. + +The payloads are package data of the two adapter distributions, read through +`importlib.resources` exactly as the library's own suite reads them, so the bytes +replayed here are the bytes shipped by the wheel `manifest.json` pins. The pin is +the provenance: bumping it is what moves the capture, and nothing has to keep a +copy of it honest. + +Uses the real `ebus_sdk.DiscoveredDevice` rather than a stand-in. A +description-only stand-in is not sufficient: `_downstream_lugs_metadata` -> +`find_lugs` reads property *values* via `device.get_property(...)`, so a stand-in +either raises or silently loses the five downstream/feedthrough paths. ebus-sdk +arrives transitively with span-panel-api-schema-1, so the import is free. +""" + +from __future__ import annotations + +from importlib.resources import files +import json + +from ebus_sdk.homie import DiscoveredDevice +from span_panel_api.models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot, V2HomieSchema + +from custom_components.span_panel.schema_validation import DiscoveredProperty + +SCHEMA_ONE_TREE = files("span_panel_api_schema_1") / "reference" / "parent_child_tree.json" +"""The parent/child capture, as `span-panel-api-schema-1` ships it.""" + +SCHEMA_ZERO_SCHEMA = files("span_panel_api_schema_0") / "reference" / "homie_schema.json" +"""The `GET /api/v2/homie/schema` capture, as `span-panel-api-schema-0` ships it.""" + + +def _schema_zero_types() -> HomieSchemaTypes: + """Unwrap the flat capture's `types` map, the shape a schema_0 build takes. + + The whole `GET /api/v2/homie/schema` response is what the adapter ships; + `types` is the half both flat fixtures need, so it is unwrapped once here + rather than at each of them. Narrowed rather than annotated: the document's + other top-level values are strings, and `json.loads` would let a wrong + annotation stand. + """ + document: object = json.loads(SCHEMA_ZERO_SCHEMA.read_text(encoding="utf-8")) + if not isinstance(document, dict): + raise TypeError(f"{SCHEMA_ZERO_SCHEMA.name} is not a JSON object") + types = document["types"] + if not isinstance(types, dict): + raise TypeError(f"{SCHEMA_ZERO_SCHEMA.name} has no `types` object") + return types + + +SCHEMA_ONE_PANEL = "example-40t-001" +"""Device id of the enclosure in the adapter's published capture. + +Named rather than inlined because a test that mutates one of the panel's +published topics has to address the panel, and every such test would otherwise +carry its own copy of the simulator's device naming. +""" + + +def _device(device_id: str, topics: dict[str, str]) -> DiscoveredDevice: + """Rebuild one discovered device from its retained topics. + + Mirrors the library's own builder (test_schema_one_devices.py:26-37): + `update_description` parses the JSON string, `update_property` stores each + non-`$` topic as a property value. + """ + device = DiscoveredDevice(device_id, "ebus") + device.update_description(topics["$description"]) + device.update_state(topics.get("$state", "ready")) + for topic, value in topics.items(): + if topic.startswith("$"): + continue + node, _, prop = topic.partition("/") + if prop: + device.update_property(node, prop, value) + return device + + +def _devices_from(tree: dict[str, dict[str, str]]) -> list[DiscoveredDevice]: + """Rebuild discovered devices from a retained-topic capture.""" + return [_device(device_id, topics) for device_id, topics in tree.items()] + + +def schema_one_tree(without: str | None = None) -> dict[str, dict[str, str]]: + """A mutable copy of the parent/child capture, ready to be rewritten. + + **Read from the library's package data, not copied into this repository.** + `span-panel-api-schema-1` publishes the capture beside the adapter that parses + it, so the pinned wheel is both the payload and the record of which release it + came from; a copy here would need a guard to keep it honest, and the pin needs + none. + + Copied per call, and one level deep, which is as deep as a topic goes: a test + proves a reading came off the wire by republishing it and asserting the entity + followed, and that is impossible against a shared immutable capture. + + `without` drops one device, which is how the batteryless and PV-less variants + are made. They were separate files and are now derived, so they cannot drift + from the base by construction -- the only difference each ever had was the one + missing device. + """ + capture: dict[str, dict[str, str]] = json.loads(SCHEMA_ONE_TREE.read_text(encoding="utf-8")) + tree = {device_id: dict(topics) for device_id, topics in capture.items()} + if without is not None: + assert without in tree, f"{without!r} is not in the capture; nothing to drop" + del tree[without] + return tree + + +def schema_one_snapshot(tree: dict[str, dict[str, str]] | None = None) -> SpanPanelSnapshot: + """Build a real snapshot from the capture, through the real schema_1 mapper. + + The point of going the long way round rather than through + `SpanPanelSnapshotFactory`: a factory takes the value a test hands it, so an + assertion against it proves only that the test and the entity agree. Driving + the actual adapter over the actual capture makes the published topic the + source of truth, so republishing one is a mutation the entity has to follow. + """ + from span_panel_api_schema_1.snapshot import build_snapshot + + tree = schema_one_tree() if tree is None else tree + panel = _device(SCHEMA_ONE_PANEL, tree[SCHEMA_ONE_PANEL]) + children = [ + _device(device_id, topics) + for device_id, topics in tree.items() + if device_id != SCHEMA_ONE_PANEL + ] + return build_snapshot(panel, children) + + +def schema_zero_metadata() -> dict[str, FieldMetadata]: + """Curated field metadata as schema_0 builds it from the flat REST schema. + + Partitioned like its schema_1 counterpart even though the flat adapter emits + no discovered rows: the fixtures state the rule, not the current contents of + one adapter. + """ + from span_panel_api_schema_0.field_metadata import build_field_metadata + + return _curated(build_field_metadata(_schema_zero_types())) + + +SCHEMA_ZERO_SERIAL = "sp3-synthetic-0001" +"""Serial the flat fixture adapter is built for. Synthetic, like every id here.""" + +SCHEMA_ZERO_FIRMWARE = "r202612" +"""A release in the flat window (r202603-r202627), which is what makes it flat. + +`V2HomieSchema.data_model_version` is left at its default `None` for the same +reason: absent is the flat discriminator, and the dispatcher reads it. +""" + + +def schema_zero_snapshot() -> SpanPanelSnapshot: + """Build a snapshot as the real flat adapter builds it, before any topic arrives. + + The counterpart to `schema_one_snapshot`, and it exists for the fields the + flat adapter never writes. Retained topics only ever *add* to this: a field + the adapter does not reference at all keeps whatever value the snapshot + dataclass gives it, and no message can move it. + + `lugs_at_service_entrance` is such a field. It is a plain `bool` defaulting + to True, and `span_panel_api_schema_0` contains no reference to the name, so + on flat firmware the value a consumer reads is the library's default rather + than anything the panel said. Driving the adapter rather than the snapshot + factory is what makes that a fact about the adapter instead of a fact the + test wrote down itself. + """ + from span_panel_api_schema_0.adapter import SchemaZeroAdapter + + schema = V2HomieSchema( + firmware_version=SCHEMA_ZERO_FIRMWARE, + types_schema_hash="0" * 16, + types=_schema_zero_types(), + ) + return SchemaZeroAdapter(SCHEMA_ZERO_SERIAL, schema).build_snapshot() + + +def _curated(metadata: dict[str, FieldMetadata]) -> dict[str, FieldMetadata]: + """The half of an adapter's map that names snapshot fields we curate. + + Every fixture below hands out the curated half, through the same + `schema_validation.partition` the coordinator uses, so no test can be + perturbed by what a panel declares and nobody reads. That is not tidiness: + the producible gate, the exemption annotations, the derived-reason checks + and the unit vocabulary all treat "in an adapter's map" as "this integration + could read it", and a discovered path satisfies neither half of that. + + `schema_one_discovery` is how a test asks for the other half, and + `test_schema_discovery` is where the partition itself is checked against the + unpartitioned map. + """ + from custom_components.span_panel.schema_validation import partition + + return partition(metadata)[0] + + +def schema_one_metadata() -> dict[str, FieldMetadata]: + """Curated field metadata as schema_1 builds it from a full parent/child tree.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata + + return _curated(build_field_metadata(_devices_from(schema_one_tree()))) + + +def schema_one_metadata_raw() -> dict[str, FieldMetadata]: + """The adapter's map exactly as it returns it, both halves together. + + The one fixture that does *not* partition, because the partition is the + thing under test in `test_schema_discovery`. Everywhere else, ask for a + partitioned half by name. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata + + return build_field_metadata(_devices_from(schema_one_tree())) + + +def schema_one_discovery() -> tuple[DiscoveredProperty, ...]: + """What schema_1 declares in the reference tree that it reads nothing from. + + The other half of the same map. Held apart from `schema_one_metadata` so a + test has to ask for it by name — a discovered path arriving unannounced in a + curated inventory is the failure mode the namespace exists to prevent. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata + + from custom_components.span_panel.schema_validation import partition + + return partition(build_field_metadata(_devices_from(schema_one_tree())))[1] + + +def schema_one_metadata_batteryless() -> dict[str, FieldMetadata]: + """Build the same tree with the BESS removed — no battery hardware present.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata + + return _curated(build_field_metadata(_devices_from(schema_one_tree(without="bess")))) + + +def schema_one_metadata_no_pv() -> dict[str, FieldMetadata]: + """Build the same tree with the PV device removed, power-flows still present.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata + + return _curated(build_field_metadata(_devices_from(schema_one_tree(without="pv")))) diff --git a/tests/entity_id_renaming_README.md b/tests/entity_id_renaming_README.md deleted file mode 100644 index 4a424b3b..00000000 --- a/tests/entity_id_renaming_README.md +++ /dev/null @@ -1,64 +0,0 @@ -# Bulk Entity ID Naming Pattern Tests Summary - -## Overview - -This test suite validates bulk entity ID naming pattern changes with user customization preservation. It demonstrates how the integration handles system-wide -transformations while preserving all user modifications. - -## Test Structure - -### 1. YAML Fixture Validation (`TestYamlFixtureValidation`) - -- **`test_all_yaml_fixtures_are_valid`**: Validates all YAML fixtures have proper structure and required fields -- **`test_fixture_naming_patterns_are_consistent`**: Ensures naming patterns follow expected conventions across fixtures -- **`test_fixture_user_customizations_are_preserved`**: Verifies user customizations are identical across before/after fixtures - -### 2. Bulk Naming Pattern Tests (`TestBulkEntityIdNamingPatterns`) - -- **`test_crud_then_bulk_naming_pattern_change_preserves_customizations`**: Main workflow test demonstrating CRUD operations followed by bulk naming changes -- **`test_naming_pattern_combinations`**: Tests all four naming pattern combinations (device prefix + circuit numbers) - -### 3. Edge Case Tests (`TestBulkModificationEdgeCases`) - -- **`test_empty_sensor_set_handling`**: Validates graceful handling of empty sensor sets -- **`test_unknown_sensor_types_preserved`**: Ensures unknown sensor types are preserved during bulk modifications - -## YAML Fixtures - -### `/tests/fixtures/circuit_numbers.yaml` - -### `/tests/fixtures/device_prefix.yaml` - -### `/tests/fixtures/device_prefix.yaml` - -Demonstrates solar sensors with friendly naming pattern: - -- `entity_id: "sensor.span_panel_solar_inverter_power"` -- `entity_id: "sensor.span_panel_solar_inverter_energy_produced"` -- Contains user customizations (custom attributes, modified names) - -### `/tests/fixtures/solar_sensors_circuit_numbers.yaml` - -Shows the same sensors with circuit number naming pattern: - -- `entity_id: "sensor.span_panel_circuit_30_32_power"` -- `entity_id: "sensor.span_panel_circuit_30_32_energy_produced"` -- **All user customizations preserved** - only entity_ids change - -## Key Testing Principles - -1. **Real YAML Fixtures**: Tests use actual YAML files instead of embedded strings -2. **Fixture Validation**: Dedicated tests ensure all fixtures are valid and consistent -3. **User Customization Preservation**: Validates that bulk operations preserve all user modifications -4. **Pattern Consistency**: Tests verify naming patterns follow expected conventions -5. **Edge Case Coverage**: Handles empty sets and unknown sensor types - -## Test Benefits - -- **Comprehensive Validation**: All fixtures are validated together -- **Real-world Scenarios**: Tests actual YAML structures users would create -- **Pattern Verification**: Ensures naming patterns are consistent and predictable -- **Customization Safety**: Proves user modifications survive bulk changes -- **Maintainability**: Easy to add new fixtures and validate them automatically - -This test suite provides confidence that bulk entity ID naming pattern changes work correctly while preserving all user customizations. diff --git a/tests/factories.py b/tests/factories.py index ad469691..338eacc9 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -10,6 +10,7 @@ SpanBatterySnapshot, SpanCircuitSnapshot, SpanEvseSnapshot, + SpanMidSnapshot, SpanPanelSnapshot, SpanPVSnapshot, ) @@ -23,6 +24,7 @@ CircuitPriority, CircuitRelayState, ) +from custom_components.span_panel.pv_binding import PvBinding, resolve class SpanCircuitSnapshotFactory: @@ -33,9 +35,9 @@ def create( circuit_id: str = "1", name: str = "Test Circuit", relay_state: str = CircuitRelayState.CLOSED.name, - instant_power_w: float = 150.5, - consumed_energy_wh: float = 1500.0, - produced_energy_wh: float = 0.0, + instant_power_w: float | None = 150.5, + consumed_energy_wh: float | None = 1500.0, + produced_energy_wh: float | None = 0.0, tabs: list[int] | None = None, priority: str = CircuitPriority.SOC_THRESHOLD.name, is_user_controllable: bool = True, @@ -140,7 +142,7 @@ def create( lock_state: str = "LOCKED", advertised_current_a: float | None = 32.0, vendor_name: str | None = "SPAN", - product_name: str | None = "SPAN Drive", + model: str | None = "SPAN Drive", part_number: str | None = None, serial_number: str | None = "SN-EVSE-001", software_version: str | None = "2.1.0", @@ -153,7 +155,7 @@ def create( lock_state=lock_state, advertised_current_a=advertised_current_a, vendor_name=vendor_name, - product_name=product_name, + model=model, part_number=part_number, serial_number=serial_number, software_version=software_version, @@ -176,22 +178,26 @@ def create( soe_percentage: float | None = 85.0, soe_kwh: float | None = None, vendor_name: str | None = None, - product_name: str | None = None, + model: str | None = None, + part_number: str | None = None, serial_number: str | None = None, software_version: str | None = None, nameplate_capacity_kwh: float | None = None, connected: bool | None = None, + communication_state: str | None = None, ) -> SpanBatterySnapshot: """Create a SpanBatterySnapshot with reasonable defaults.""" return SpanBatterySnapshot( soe_percentage=soe_percentage, soe_kwh=soe_kwh, vendor_name=vendor_name, - product_name=product_name, + model=model, + part_number=part_number, serial_number=serial_number, software_version=software_version, nameplate_capacity_kwh=nameplate_capacity_kwh, connected=connected, + communication_state=communication_state, ) @@ -203,12 +209,12 @@ def create( serial_number: str = "sp3-242424-001", firmware_version: str = "1.2.3", main_relay_state: str = "CLOSED", - instant_grid_power_w: float = 2500.75, - feedthrough_power_w: float = 0.0, - main_meter_energy_consumed_wh: float = 2500.0, - main_meter_energy_produced_wh: float = 0.0, - feedthrough_energy_consumed_wh: float = 0.0, - feedthrough_energy_produced_wh: float = 0.0, + instant_grid_power_w: float | None = 2500.75, + feedthrough_power_w: float | None = 0.0, + main_meter_energy_consumed_wh: float | None = 2500.0, + main_meter_energy_produced_wh: float | None = 0.0, + feedthrough_energy_consumed_wh: float | None = 0.0, + feedthrough_energy_produced_wh: float | None = 0.0, dsm_state: str = DSM_ON_GRID, current_run_config: str = PANEL_ON_GRID, door_state: str = SYSTEM_DOOR_STATE_CLOSED, @@ -219,6 +225,8 @@ def create( wwan_link: bool = False, circuits: dict[str, SpanCircuitSnapshot] | None = None, battery: SpanBatterySnapshot | None = None, + # v1.0 only. Defaults to None so every existing fixture stays a flat panel. + mid: SpanMidSnapshot | None = None, dominant_power_source: str | None = None, grid_state: str | None = None, grid_islandable: bool | None = None, @@ -238,6 +246,11 @@ def create( downstream_l2_current_a: float | None = None, pv: SpanPVSnapshot | None = None, evse: dict[str, SpanEvseSnapshot] | None = None, + # Defaults True for the same reason the library field does: a panel at the + # service entrance is the ordinary case, and flat firmware cannot be + # anything else. A fixture opts out to model a chained panel or one behind + # an upstream DER. + lugs_at_service_entrance: bool = True, ) -> SpanPanelSnapshot: """Create a SpanPanelSnapshot with reasonable defaults.""" if circuits is None: @@ -268,6 +281,7 @@ def create( wwan_link=wwan_link, circuits=circuits, battery=battery, + mid=mid, dominant_power_source=dominant_power_source, grid_state=grid_state, grid_islandable=grid_islandable, @@ -281,6 +295,7 @@ def create( panel_size=panel_size, power_flow_pv=power_flow_pv, power_flow_grid=power_flow_grid, + lugs_at_service_entrance=lugs_at_service_entrance, upstream_l1_current_a=upstream_l1_current_a, upstream_l2_current_a=upstream_l2_current_a, downstream_l1_current_a=downstream_l1_current_a, @@ -345,3 +360,8 @@ def create_minimal(serial_number: str = "sp3-242424-001") -> SpanPanelSnapshot: serial_number=serial_number, circuits=[SpanCircuitSnapshotFactory.create_kitchen_outlet()], ) + + +def pv_binding_for(snapshot: SpanPanelSnapshot) -> PvBinding: + """The identity a first setup over `snapshot` resolves, for harnesses that build runtime data by hand.""" + return resolve(snapshot, None, frozenset(), link_held=False, inverter_links_held=frozenset())[0] diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 00000000..da8f9f0e --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,60 @@ +# Test fixtures + +The files here are committed source this repository owns, with one exception: the **historical `pyproject.toml` copies** (`pyproject_*.toml`) are this +repository's own file at the two commits where the library path override went wrong, held to those commits by a guard. + +The schema-adapter payloads the conformance tests replay are **not** here. They are package data of `span-panel-api-schema-0` and `span-panel-api-schema-1`, +read out of the installed wheels by `tests/adapter_fixtures.py`; the pin in `custom_components/span_panel/manifest.json` is what says which capture the suite +replays, and bumping the pin is what moves it. + +## The historical pyproject copies + +`pyproject_both_blocks_redirected.toml` and `pyproject_type_checker_path_left_behind.toml` are byte copies of this repository's own `pyproject.toml`, taken from +its git history. Each has a `.source` file beside it recording the commit it came from, and `tests/test_library_path_hook.py` holds the copy to that commit. + +| File | Commit | State | +| ---------------------------------------------- | ---------- | ----------------------------------------------------------------------------- | +| `pyproject_both_blocks_redirected.toml` | `3cbf02a` | `[tool.uv.sources]` **and** `[tool.pyright].extraPaths` on a scratch worktree | +| `pyproject_type_checker_path_left_behind.toml` | `82a512f^` | sources block corrected, `extraPaths` still on the worktree | + +They are the failing cases for `scripts/check-library-path.py`, the hook that rejects a `span-panel-api` path naming anything but `../../span/span-panel-api`. +The real files rather than constructed ones, because **a gate proven against a made-up example is proven against the wrong thing** — a synthetic version of a +defect is written by somebody who already knows what the rule checks, so it exercises the rule rather than the mistake. The second file matters as much as the +first: it is the interval between the two corrections, several commits during which the repository looked fixed, and a hook covering only `[tool.uv.sources]` +calls it clean. + +These cannot go stale. A commit is content-addressed, so `3cbf02a:pyproject.toml` cannot become different bytes; the only failure the comparison can report is a +copy that was wrong when it was taken. That is why its guard **skips** when the object is unreachable — a shallow clone has no history to compare against, and +failing there would report the clone rather than the fixture. A skip is safe here precisely because the target cannot move, which is not true of a guard over +anything that can. + +Re-vendor either with `git show :pyproject.toml > tests/fixtures/.toml`, and only to correct a copy — never to make a failing case pass. + +## The historical copies are exempt from formatting + +`tests/fixtures/pyproject_*.toml` is excluded from every hook that rewrites files — `trailing-whitespace`, `end-of-file-fixer` and `mixed-line-ending` in +`prek.toml`. Prettier cannot format TOML, so `.prettierignore` needs no line for them. + +The reason is the whole point of the comparison: **these are captured bytes, not source we own.** A copy held byte-identical to a commit and an unconditional +formatter cannot both exist, and it is the formatter that has to yield. A copy reindented on the way in fails against the commit it genuinely matched when it +was made, and the resulting failure names the fixture rather than the hook that broke it — so the person debugging it starts in the wrong place. + +It is not hypothetical, only untriggered so far. Those three hooks cover `tests/`, and they leave the copies alone only because both happen to be +newline-terminated with LF endings and no trailing whitespace. The first one vendored without a final newline would be rewritten on commit. + +The scope is one prefix rather than the whole directory, because only these copies have this property. `tests/fixtures/README.md` is prose this repository owns +and should keep being formatted; the migration YAMLs are hand-written source; and `unread_declarations_baseline.json`, despite being a mechanically-checked +inventory, is hand-maintained — its values are one-line human explanations. `pyproject_*` is `tests/test_library_path_hook.py`'s own vocabulary, so the next +copy taken under it is covered without anyone remembering to widen the rule. + +The read-only hooks still cover these files, `check-toml` in particular. A copy that does not parse is worth hearing about wherever it came from. + +## Derived variants of the parent/child capture + +The batteryless and PV-less trees are **derived in memory**: `adapter_fixtures.schema_one_tree(without="bess")` and `without="pv"` return the adapter's capture +with that one device dropped. They were separate files once; deriving them means they cannot drift from the base, since the only difference either ever had was +the one missing device. Each drops exactly one device (14 -> 13) and retains the panel and both lugs devices — a variant that removed more would make the +conformance tests pass for the wrong reason. Note `bess-mid` is typed `energy.ebus.device.mid` and is not the BESS. + +The batteryless tree proves a panel with no BESS produces **no** `battery.*` entries — hardware absence, not degradation. The PV-less tree proves the same for a +panel that has power-flows telemetry but no PV device, which is the case telemetry-based capability detection gets wrong. diff --git a/tests/fixtures/pyproject_both_blocks_redirected.source b/tests/fixtures/pyproject_both_blocks_redirected.source new file mode 100644 index 00000000..30d4be69 --- /dev/null +++ b/tests/fixtures/pyproject_both_blocks_redirected.source @@ -0,0 +1 @@ +3cbf02a6703b8c56944e33dab2c0cc4a68193680 diff --git a/tests/fixtures/pyproject_both_blocks_redirected.toml b/tests/fixtures/pyproject_both_blocks_redirected.toml new file mode 100644 index 00000000..6794db86 --- /dev/null +++ b/tests/fixtures/pyproject_both_blocks_redirected.toml @@ -0,0 +1,227 @@ +[project] +name = "span" +version = "2.1.0b6" +description = "Span Panel Custom Integration for Home Assistant" +authors = [{name = "SpanPanel"}] +license = {text = "MIT"} +readme = "README.md" +requires-python = ">=3.14.3,<3.15" +dependencies = [ + "homeassistant==2026.8.0", + "span-panel-api==3.1.0", + # Separate distributions, not dependencies of the bootstrap: adapters are found + # through the `span_panel_api.schema_adapters` entry-point group, so each one has + # to be installed for discovery to see it. manifest.json carries the same three, + # and that is the list Home Assistant actually reads. + "span-panel-api-schema-0==1.1.0", + "span-panel-api-schema-1==1.1.0", +] + +[dependency-groups] +dev = [ + "homeassistant-stubs==2026.8.0", + "types-requests", + "types-PyYAML", + "mypy==1.20.2", + "pyright==1.1.409", + "ruff==0.15.12", + "bandit[toml]==1.9.4", + "prek>=0.3.11", + "voluptuous-stubs", + "python-direnv", + "prettier", + "radon==6.0.1", + "pylint==4.0.5", + # Pinned, not floored: each release pins one exact Home Assistant, so this + # and the `homeassistant` pin above are a single choice rather than two. + # 0.13.354 is the release that pins 2026.8.0, the version this integration + # declares as its floor and therefore the one worth testing against. + "pytest-homeassistant-custom-component==0.13.354", + "isort", + "vulture>=2.14", +] + +[tool.uv] +package = false + +[tool.uv.sources] +# Pointed at the `-security` worktree while 3.1.0 is unreleased. The primary +# checkout is still on 3.0.1 and has none of this work, so pointing here is not a +# convenience -- against the primary checkout the security tests would resolve the +# old library and pass without exercising anything. Move these back to +# `../../span/span-panel-api` once 3.1.0 is merged and published. +# +# A path that does not exist is not a soft failure: `uv run` cannot build the +# environment at all, so nothing in this repo runs until it is corrected. +# +# CI never reads this block -- it deletes it and re-locks from PyPI (.github/ +# workflows/ci.yml) -- so a stale path here breaks only local work, and only +# noisily. It does mean CI cannot pass until 3.1.0 and both 1.1.0 adapters are on +# PyPI. +span-panel-api = { path = "../../span/span-panel-api-security", editable = true } +span-panel-api-schema-0 = { path = "../../span/span-panel-api-security/packages/schema-0", editable = true } +span-panel-api-schema-1 = { path = "../../span/span-panel-api-security/packages/schema-1", editable = true } + +[tool.jscpd] +path = ["custom_components/span_panel", "./*.{html,md}"] +format = ["python", "javascript", "json", "markup", "markdown"] +ignore = "custom_components/span_panel/translations/**|**/translations/**|.github/**|env/**|**/site-packages/**|**/.direnv/**" +reporters = ["console"] +output = "./jscpdReport" +gitignore = true + +[tool.mypy] +platform = "linux" +show_error_codes = true +follow_imports = "normal" + +# Type checking settings +strict_equality = true +no_implicit_optional = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true +local_partial_types = true +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +warn_return_any = true +strict_optional = true + +# Package handling for HA custom integration +mypy_path = "." +namespace_packages = false +explicit_package_bases = false + +# Module search paths - set this explicitly to prevent duplicate module resolution +files = ["custom_components/span_panel"] + +# Exclude patterns +exclude = [ + "venv/.*", + ".venv/.*", + "scripts/.*" +] + +# Error codes +enable_error_code = [ + "deprecated", + "ignore-without-code", + "redundant-self", + "truthy-iterable", + "mutable-override" +] +disable_error_code = [ + "annotation-unchecked", + "import-not-found", + "import-untyped", + "override", + "misc", +] + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true +warn_untyped_fields = true + +[tool.pyright] +include = ["custom_components/span_panel"] +exclude = [ + "venv", + ".venv", + "scripts", + ".git", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", "**/node_modules", "**/__pycache__", "**/.*" +] +extraPaths = [ + "./custom_components", + "../../span/span-panel-api-security/src", + "../ha-synthetic-sensors/src" +] +pythonPlatform = "Darwin" +pythonVersion = "3.14" +typeCheckingMode = "basic" +useLibraryCodeForTypes = true +autoImportCompletions = true +reportMissingImports = "warning" +reportMissingTypeStubs = false +venvPath = "." +venv = ".venv" + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler is an `except` clause that only reraises + "C", # complexity + "D", # docstrings + "E", # pycodestyle (includes E402 - module level import not at top of file) + "F", # pyflakes/autoflake + "I", # isort - import sorting and organization + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Combine multiple with statements + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of conditional assignment + "T20", # flake8-print + "TID", # flake8-tidy-imports - import organization and toplevel checking + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long + "E731", # do not assign a lambda expression, use a def +] + +# Per-file ignores for test and debug files +[tool.ruff.lint.per-file-ignores] +"test_*.py" = ["T201", "D103", "D100", "E402", "TID252"] # Allow print statements, missing docstrings, and imports inside functions in test files +"debug_*.py" = ["T201", "D103", "D100", "E402", "TID252"] # Allow print statements, missing docstrings, and imports inside functions in debug files +"tests/**/*.py" = ["T201", "D103", "D100", "E402", "TID252"] # Allow print statements, missing docstrings, and imports inside functions in test directory + +# Bandit configuration +[tool.bandit] +exclude_dirs = ["tests", "scripts"] +skips = ["B101", "B108"] # Skip assert_used and hardcoded_tmp_directory for test files + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false + +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = true + +[tool.ruff.lint.mccabe] +max-complexity = 25 + +[tool.ruff.format] +# Ruff is the primary formatter +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.ruff.lint.isort] +known-first-party = ["custom_components", "span"] +force-sort-within-sections = true +combine-as-imports = true diff --git a/tests/fixtures/pyproject_type_checker_path_left_behind.source b/tests/fixtures/pyproject_type_checker_path_left_behind.source new file mode 100644 index 00000000..3661d20e --- /dev/null +++ b/tests/fixtures/pyproject_type_checker_path_left_behind.source @@ -0,0 +1 @@ +531ad1cbfeefcb32f4b6c5fd9be415b00e401dc5 diff --git a/tests/fixtures/pyproject_type_checker_path_left_behind.toml b/tests/fixtures/pyproject_type_checker_path_left_behind.toml new file mode 100644 index 00000000..a864815a --- /dev/null +++ b/tests/fixtures/pyproject_type_checker_path_left_behind.toml @@ -0,0 +1,232 @@ +[project] +name = "span" +version = "2.1.0b6" +description = "Span Panel Custom Integration for Home Assistant" +authors = [{name = "SpanPanel"}] +license = {text = "MIT"} +readme = "README.md" +requires-python = ">=3.14.3,<3.15" +dependencies = [ + "homeassistant==2026.8.0", + "span-panel-api==3.1.0", + # Separate distributions, not dependencies of the bootstrap: adapters are found + # through the `span_panel_api.schema_adapters` entry-point group, so each one has + # to be installed for discovery to see it. manifest.json carries the same three, + # and that is the list Home Assistant actually reads. + "span-panel-api-schema-0==1.1.0", + "span-panel-api-schema-1==1.1.0", +] + +[dependency-groups] +dev = [ + "homeassistant-stubs==2026.8.0", + "types-requests", + "types-PyYAML", + "mypy==1.20.2", + "pyright==1.1.409", + "ruff==0.15.12", + "bandit[toml]==1.9.4", + "prek>=0.3.11", + "voluptuous-stubs", + "python-direnv", + "prettier", + "radon==6.0.1", + "pylint==4.0.5", + # Pinned, not floored: each release pins one exact Home Assistant, so this + # and the `homeassistant` pin above are a single choice rather than two. + # 0.13.354 is the release that pins 2026.8.0, the version this integration + # declares as its floor and therefore the one worth testing against. + "pytest-homeassistant-custom-component==0.13.354", + "isort", + "vulture>=2.14", +] + +[tool.uv] +package = false + +[tool.uv.sources] +# The primary checkout, which is where 3.1.0 and both 1.1.0 adapters now live. +# +# These pointed at a `-security` worktree while that work was unmerged. That +# outlived its reason and became actively harmful: the worktree never received +# span-panel-api#161, so `span_panel_api_schema_1.reference_payloads` resolved to +# a parent/child capture that still recorded the emitter's `$settable` defect on +# a locked relay, and `tests/fixtures/schema_one_tree.json` was vendored from it. +# A path override is a redirection of what every import in the suite resolves to; +# leaving one aimed at a stale checkout silently tests against a producer that no +# longer exists. Point these at a checkout that tracks `main`, and move them only +# for as long as an unmerged branch is actually under test. +# +# A path that does not exist is not a soft failure: `uv run` cannot build the +# environment at all, so nothing in this repo runs until it is corrected. +# +# CI never reads this block -- it deletes it and re-locks from PyPI (.github/ +# workflows/ci.yml) -- so a stale path here breaks only local work. It does mean +# CI cannot pass until 3.1.0 and both 1.1.0 adapters are on PyPI. +span-panel-api = { path = "../../span/span-panel-api", editable = true } +span-panel-api-schema-0 = { path = "../../span/span-panel-api/packages/schema-0", editable = true } +span-panel-api-schema-1 = { path = "../../span/span-panel-api/packages/schema-1", editable = true } + +[tool.jscpd] +path = ["custom_components/span_panel", "./*.{html,md}"] +format = ["python", "javascript", "json", "markup", "markdown"] +ignore = "custom_components/span_panel/translations/**|**/translations/**|.github/**|env/**|**/site-packages/**|**/.direnv/**" +reporters = ["console"] +output = "./jscpdReport" +gitignore = true + +[tool.mypy] +platform = "linux" +show_error_codes = true +follow_imports = "normal" + +# Type checking settings +strict_equality = true +no_implicit_optional = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true +local_partial_types = true +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +warn_return_any = true +strict_optional = true + +# Package handling for HA custom integration +mypy_path = "." +namespace_packages = false +explicit_package_bases = false + +# Module search paths - set this explicitly to prevent duplicate module resolution +files = ["custom_components/span_panel"] + +# Exclude patterns +exclude = [ + "venv/.*", + ".venv/.*", + "scripts/.*" +] + +# Error codes +enable_error_code = [ + "deprecated", + "ignore-without-code", + "redundant-self", + "truthy-iterable", + "mutable-override" +] +disable_error_code = [ + "annotation-unchecked", + "import-not-found", + "import-untyped", + "override", + "misc", +] + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true +warn_untyped_fields = true + +[tool.pyright] +include = ["custom_components/span_panel"] +exclude = [ + "venv", + ".venv", + "scripts", + ".git", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", "**/node_modules", "**/__pycache__", "**/.*" +] +extraPaths = [ + "./custom_components", + "../../span/span-panel-api-security/src", + "../ha-synthetic-sensors/src" +] +pythonPlatform = "Darwin" +pythonVersion = "3.14" +typeCheckingMode = "basic" +useLibraryCodeForTypes = true +autoImportCompletions = true +reportMissingImports = "warning" +reportMissingTypeStubs = false +venvPath = "." +venv = ".venv" + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler is an `except` clause that only reraises + "C", # complexity + "D", # docstrings + "E", # pycodestyle (includes E402 - module level import not at top of file) + "F", # pyflakes/autoflake + "I", # isort - import sorting and organization + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Combine multiple with statements + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of conditional assignment + "T20", # flake8-print + "TID", # flake8-tidy-imports - import organization and toplevel checking + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long + "E731", # do not assign a lambda expression, use a def +] + +# Per-file ignores for test and debug files +[tool.ruff.lint.per-file-ignores] +"test_*.py" = ["T201", "D103", "D100", "E402", "TID252"] # Allow print statements, missing docstrings, and imports inside functions in test files +"debug_*.py" = ["T201", "D103", "D100", "E402", "TID252"] # Allow print statements, missing docstrings, and imports inside functions in debug files +"tests/**/*.py" = ["T201", "D103", "D100", "E402", "TID252"] # Allow print statements, missing docstrings, and imports inside functions in test directory + +# Bandit configuration +[tool.bandit] +exclude_dirs = ["tests", "scripts"] +skips = ["B101", "B108"] # Skip assert_used and hardcoded_tmp_directory for test files + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false + +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = true + +[tool.ruff.lint.mccabe] +max-complexity = 25 + +[tool.ruff.format] +# Ruff is the primary formatter +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.ruff.lint.isort] +known-first-party = ["custom_components", "span"] +force-sort-within-sections = true +combine-as-imports = true diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json new file mode 100644 index 00000000..5af3dcef --- /dev/null +++ b/tests/fixtures/unread_declarations_baseline.json @@ -0,0 +1,10 @@ +{ + "circuit/breaker/poles": "reaches circuit.is_240v and stops there; the voltage attribute is derived from the tab count instead, so no entity or attribute reads the field", + "circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type", + "distribution-enclosure/status/postal-code": "deliberate skip: no curated entity, because location has no Home Assistant value. It does surface as a disabled vendor reading through extension adoption, where the recorder cost is opt-in rather than imposed", + "distribution-enclosure/status/time-zone": "deliberate skip: no curated entity, because Home Assistant owns the time zone and a mismatch is a Repair rather than an entity. It does surface as a disabled vendor reading through extension adoption", + "lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", + "lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it", + "lugs/connection/feeds-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes", + "pv/info/serial-number": "held, and the PV device identifier deliberately does not use it: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade. The Home Assistant sub-device is keyed _pv for the same reason, so a serial arriving on the wire moves nothing" +} diff --git a/tests/helpers.py b/tests/helpers.py index cfd8827c..14887642 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,14 +1,30 @@ """Helper functions for testing the Span Panel integration.""" +from collections.abc import Awaitable, Callable import datetime +import inspect from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow from pytest_homeassistant_custom_component.common import MockConfigEntry +type WebSocketHandler = Callable[[HomeAssistant, MagicMock, dict[str, Any]], Awaitable[None]] +"""A websocket command's own coroutine, as a test awaits it with a mock connection.""" + + +def unwrap_websocket_command(command: Callable[..., object]) -> WebSocketHandler: + """Unwrap a websocket command to the coroutine its decorators wrap. + + `@websocket_command`, `@require_admin` and `@async_response` each wrap the + handler, so a test that calls it directly with a mock connection has to get + beneath them. + """ + handler: WebSocketHandler = inspect.unwrap(command, stop=inspect.iscoroutinefunction) + return handler + def make_span_panel_entry( entry_id: str = "test_entry", diff --git a/tests/test_adoption.py b/tests/test_adoption.py new file mode 100644 index 00000000..47aa6f88 --- /dev/null +++ b/tests/test_adoption.py @@ -0,0 +1,1409 @@ +"""Devices this integration models nothing for surface without disturbing curated ones. + +Three properties carry the whole design and each has a test here that fails if it +stops holding: nothing adopted enters long-term statistics, an adopted device +keeps the identity it was first seen under, and the notice counts adopted devices +rather than listing their entities. +""" + +from __future__ import annotations + +import ast +from dataclasses import replace +import json +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, InvalidStateError +from homeassistant.helpers import device_registry as dr +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import AdoptedDevice, AdoptedProperty, PublishOutcome, PublishState +from span_panel_api.exceptions import SpanPanelServerError + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.adoption import ( + CONTROL_PLATFORMS, + DEVICE_CLASS_BY_UNIT, + MAX_STATE_LENGTH, + AdoptedNumber, + adopted_control_count, + adopted_identifier, + adopted_unique_id, + async_register_adopted_devices, + classify, + create_adopted_binary_sensors, + create_adopted_numbers, + create_adopted_selects, + create_adopted_sensors, + create_adopted_switches, + parse_number_format, + resolve_identifier, +) +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay, CurationRecord +from custom_components.span_panel.diagnostics import _adoption +from custom_components.span_panel.id_builder import build_panel_unique_id +from custom_components.span_panel.number import ( + SpanEvseNumber, + async_setup_entry as number_async_setup_entry, +) +from custom_components.span_panel.util import ( + ADOPTED_IDENTIFIER_TOKEN, + classify_sub_device_identifier, +) + +from .factories import SpanEvseSnapshotFactory, SpanPanelSnapshotFactory, pv_binding_for + +if TYPE_CHECKING: + from span_panel_api import SpanPanelSnapshot + +PANEL_SERIAL = "sp3-242424-001" +# The scope the builder tests hand `create_adopted_*`. Inert on purpose: those +# tests give the builders an empty registry, so the lookup misses under any +# scope and they exercise classification, not identity. The scope itself is +# pinned by the tests that register a card first -- the freeze through the +# builders here, and every platform in `test_platform_entry_scope.py`. +ENTRY_ID = "adopted-entry" + + +@pytest.fixture +def registered_panel(hass: HomeAssistant) -> tuple[str, str]: + """Return a config entry and a registered panel device, as setup would leave them. + + Both are required rather than convenient: the device registry refuses to link + a device to an unknown config entry, and refuses a `via_device_id` naming a + device that does not exist. An adopted device is a sub-device of the panel, so + the panel has to be there first -- which is exactly why registration runs + after `ensure_device_registered` and before the platforms. + """ + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id=PANEL_SERIAL) + mock.add_to_hass(hass) + panel = dr.async_get(hass).async_get_or_create( + config_entry_id=mock.entry_id, + identifiers={(DOMAIN, PANEL_SERIAL)}, + name="Span Panel", + ) + return str(mock.entry_id), panel.id + + +def _property( + node_id: str = "meter", + property_id: str = "active-power", + datatype: str = "float", + unit: str | None = "W", + fmt: str | None = None, + settable: bool = False, + value: str | None = "2400", +) -> AdoptedProperty: + return AdoptedProperty( + node_id=node_id, + property_id=property_id, + datatype=datatype, + unit=unit, + format=fmt, + settable=settable, + value=value, + ) + + +def _device( + device_id: str = "generator-1", + *, + serial_number: str | None = None, + properties: tuple[AdoptedProperty, ...] = (), +) -> AdoptedDevice: + return AdoptedDevice( + device_id=device_id, + device_type="energy.ebus.device.generator", + name="Backup Generator", + model="GEN-9000", + serial_number=serial_number, + properties=properties, + ) + + +def _snapshot(*devices: AdoptedDevice) -> SpanPanelSnapshot: + """Return a complete snapshot carrying the given adopted devices. + + Built through `replace` rather than by teaching the factory a keyword, so the + factory keeps describing a curated panel and adoption stays visibly additive. + """ + return replace(SpanPanelSnapshotFactory.create_complete(serial_number=PANEL_SERIAL), adopted_devices=devices) + + +# -- The platform table ------------------------------------------------------ + + +@pytest.mark.parametrize( + ("datatype", "settable", "fmt", "expected"), + [ + ("boolean", True, None, Platform.SWITCH), + ("boolean", False, None, Platform.BINARY_SENSOR), + ("enum", True, "AUTO,MANUAL,OFF", Platform.SELECT), + ("float", True, "0:100:1", Platform.NUMBER), + ("integer", True, "0:100:1", Platform.NUMBER), + ("float", False, None, Platform.SENSOR), + ("enum", False, "AUTO,MANUAL", Platform.SENSOR), + ("string", True, None, Platform.SENSOR), + ], +) +def test_the_declaration_decides_the_platform( + datatype: str, settable: bool, fmt: str | None, expected: Platform +) -> None: + """The rule in one table, including its two fallbacks.""" + assert classify(_property(datatype=datatype, fmt=fmt, settable=settable, unit=None)) is expected + + +@pytest.mark.parametrize("datatype", ["enum", "float", "integer"]) +def test_a_settable_property_with_no_value_domain_falls_back_to_a_reading(datatype: str) -> None: + """Not caution -- the absence of the thing a control is made of. + + `format` is where Homie carries the domain. A select with no option list and + a number with no bounds are not safer controls, they are broken ones, so the + property surfaces as the reading it can still be. + """ + assert classify(_property(datatype=datatype, settable=True, fmt=None, unit=None)) is Platform.SENSOR + + +def test_a_settable_boolean_needs_no_format_because_its_domain_is_the_datatype() -> None: + """The one control whose value domain the datatype already states in full.""" + assert classify(_property(datatype="boolean", settable=True, fmt=None, unit=None)) is Platform.SWITCH + + +# -- Nothing adopted enters long-term statistics ----------------------------- + + +def test_no_adopted_sensor_carries_a_state_class(hass: HomeAssistant) -> None: + """The single most important assertion in this module. + + `state_class` is not declared on the wire and is not derivable from one: this + integration ships `feedthroughEnergyProducedWh` as TOTAL beside + `mainMeterEnergyProducedWh` as TOTAL_INCREASING, same unit and same device + class. A wrong one writes corrupt long-term statistics that fixing the + producer afterwards does not repair, so adoption classifies none of them -- + and a user who wants statistics from an adopted reading wraps it themselves. + """ + declarations = tuple( + _property(property_id=name, unit=unit, datatype="float") + for name, unit in (("active-power", "W"), ("imported-energy", "Wh"), ("exported-energy", "kWh")) + ) + entities = create_adopted_sensors( + MagicMock(data=_snapshot(_device(properties=declarations))), + _snapshot(_device(properties=declarations)), + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + + assert len(entities) == 3 + assert all(entity.state_class is None for entity in entities) + + +def test_no_state_class_is_set_anywhere_in_the_module() -> None: + """The rule stated once more, against the syntax rather than an instance. + + An instance test only covers the paths a test constructs, so a future branch + that set a state class on some platform nobody instantiated here would pass + every test above. Read as syntax rather than as text because the module's own + prose has to be free to explain why the rule exists. + """ + from custom_components.span_panel import adoption + + tree = ast.parse(Path(adoption.__file__).read_text(encoding="utf-8")) + keywords = [node.arg for node in ast.walk(tree) if isinstance(node, ast.keyword)] + targets = [node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)] + assert "state_class" not in keywords + assert "_attr_state_class" not in targets + assert not [node.id for node in ast.walk(tree) if isinstance(node, ast.Name) and "StateClass" in node.id] + + +# -- Device class is enumerated, never inferred ------------------------------ + + +def test_a_declared_unit_this_integration_knows_gets_a_device_class(hass: HomeAssistant) -> None: + declarations = (_property(unit="W"),) + (entity,) = create_adopted_sensors( + MagicMock(data=_snapshot(_device(properties=declarations))), + _snapshot(_device(properties=declarations)), + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + assert entity.device_class is SensorDeviceClass.POWER + + +@pytest.mark.parametrize("unit", ["%", "ppm", "kg"]) +def test_a_unit_outside_the_map_gets_no_device_class(hass: HomeAssistant, unit: str) -> None: + """An unlabelled reading is honest; a mislabelled one is not. + + `%` is the case that makes the rule earn its keep. Its uses in this + vocabulary are not one class -- a state of charge, a confidence, a duty cycle + -- so a rule that guessed BATTERY for all of them would mislabel most. + """ + declarations = (_property(unit=unit),) + (entity,) = create_adopted_sensors( + MagicMock(data=_snapshot(_device(properties=declarations))), + _snapshot(_device(properties=declarations)), + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + assert entity.device_class is None + assert unit not in DEVICE_CLASS_BY_UNIT + + +# -- Everything adopted is disabled and diagnostic --------------------------- + + +def test_every_adopted_entity_is_disabled_and_diagnostic(hass: HomeAssistant) -> None: + """Adoption makes a device reachable; it does not put one on a dashboard.""" + declarations = (_property(), _property(node_id="relay", property_id="closed", datatype="boolean", unit=None)) + snapshot = _snapshot(_device(properties=declarations)) + coordinator = MagicMock(data=snapshot) + registry = dr.async_get(hass) + uncurated = CurationOverlay.empty() + + entities = [ + *create_adopted_sensors( + coordinator, snapshot, registry, config_entry_id=ENTRY_ID, panel_device_id="panel-device-id", overlay=uncurated + ), + *create_adopted_binary_sensors( + coordinator, snapshot, registry, config_entry_id=ENTRY_ID, panel_device_id="panel-device-id", overlay=uncurated + ), + ] + + assert len(entities) == 2 + assert all(entity.entity_registry_enabled_default is False for entity in entities) + assert all(entity.entity_category is EntityCategory.DIAGNOSTIC for entity in entities) + + +def test_a_declared_boolean_becomes_a_binary_sensor_and_not_a_sensor(hass: HomeAssistant) -> None: + """The two creators partition the properties rather than both claiming one.""" + declarations = (_property(node_id="relay", property_id="closed", datatype="boolean", unit=None, value="true"),) + snapshot = _snapshot(_device(properties=declarations)) + coordinator = MagicMock(data=snapshot) + registry = dr.async_get(hass) + uncurated = CurationOverlay.empty() + + assert create_adopted_sensors(coordinator, snapshot, registry, config_entry_id=ENTRY_ID, panel_device_id="p", overlay=uncurated) == [] + (entity,) = create_adopted_binary_sensors( + coordinator, snapshot, registry, config_entry_id=ENTRY_ID, panel_device_id="p", overlay=uncurated + ) + assert entity.is_on is True + + +# -- Identity freezes at first sighting -------------------------------------- + + +def test_a_serial_arriving_after_adoption_does_not_move_the_device( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The failure this rule exists to prevent, in its most likely form. + + A device adopted under its wire id that later publishes `info/serial-number` + would, without freezing, be re-derived onto the serial -- which the registry + reads as a device replacement and which takes the entities and their history + with it. + + Against the real registry rather than a stub, because the lookup is scoped to + this entry and a stub that ignored the scope would answer the same whether + the scope were right, wrong or absent. + """ + entry_id, panel_device_id = registered_panel + registry = dr.async_get(hass) + first_seen = adopted_identifier(PANEL_SERIAL, "generator-1") + registry.async_get_or_create( + config_entry_id=entry_id, + identifiers={(DOMAIN, first_seen)}, + name="Backup Generator", + via_device_id=panel_device_id, + ) + + later = _device("generator-1", serial_number="EX-0000-0001") + assert ( + resolve_identifier(registry, PANEL_SERIAL, later, config_entry_id=entry_id) == first_seen + ) + + +def test_a_wire_id_that_moves_keeps_a_device_adopted_under_its_serial( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The other direction, which this repository has already been bitten by. + + Producers derive a DER's id preferring a serial over a default slug, so the + wire id itself moves when a serial appears -- which is why PV's + `info/serial-number` is held unvalued. A device adopted under its serial has + to survive that. + """ + entry_id, panel_device_id = registered_panel + registry = dr.async_get(hass) + first_seen = adopted_identifier(PANEL_SERIAL, "EX-0000-0001") + registry.async_get_or_create( + config_entry_id=entry_id, + identifiers={(DOMAIN, first_seen)}, + name="Backup Generator", + via_device_id=panel_device_id, + ) + + moved = _device("panel-EX-0000-0001", serial_number="EX-0000-0001") + assert ( + resolve_identifier(registry, PANEL_SERIAL, moved, config_entry_id=entry_id) == first_seen + ) + + +def test_a_device_never_seen_before_is_adopted_under_its_serial_when_it_has_one( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The specification's own correlator, used where nothing is frozen yet. + + Device ids are opaque and a proxied id is `{proxier-id}-{proxied-id}`, so the + same hardware carries different ids under different enclosures by design. The + serial is what the specification says to correlate on. + """ + entry_id, _panel_device_id = registered_panel + + fresh = _device("generator-1", serial_number="EX-0000-0001") + assert resolve_identifier( + dr.async_get(hass), PANEL_SERIAL, fresh, config_entry_id=entry_id + ) == adopted_identifier(PANEL_SERIAL, "EX-0000-0001") + + +def test_a_device_with_no_serial_is_adopted_under_its_wire_id( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + entry_id, _panel_device_id = registered_panel + + assert resolve_identifier( + dr.async_get(hass), PANEL_SERIAL, _device("generator-1"), config_entry_id=entry_id + ) == adopted_identifier( + PANEL_SERIAL, "generator-1" + ) + + +# -- The two identifier namespaces stay readable apart ----------------------- + + +@pytest.mark.parametrize("anchor", ["generator-1", "some-pv", "an_evse_thing", "ends-in-mid", "bess"]) +def test_an_adopted_identifier_never_classifies_as_a_curated_sub_device(anchor: str) -> None: + """The anchor is vendor vocabulary and can spell anything. + + `classify_sub_device_identifier` reads the curated grammar with suffix rules, + so a vendor device id ending in `pv` would classify as the panel's solar + sub-device -- which is how a device nobody modelled would end up rendered as + one that was. + """ + identifier = adopted_identifier(PANEL_SERIAL, anchor) + assert ADOPTED_IDENTIFIER_TOKEN in identifier + assert classify_sub_device_identifier(identifier) is None + + +def test_a_curated_identifier_still_classifies(hass: HomeAssistant) -> None: + """The other direction: adding the adoption test must not blind the reader.""" + assert classify_sub_device_identifier(f"{PANEL_SERIAL}_bess") == "bess" + assert classify_sub_device_identifier(f"{PANEL_SERIAL}_pv") == "pv" + + +# -- The pending write path is counted rather than hidden -------------------- + + +def test_controls_are_counted_for_diagnostics() -> None: + """A control on a device nobody modelled is the highest-consequence thing here.""" + declarations = ( + _property(datatype="boolean", settable=True, unit=None), + _property(node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None), + _property(), + ) + snapshot = _snapshot(_device(properties=declarations)) + + assert adopted_control_count(snapshot) == 2 + assert classify(declarations[0]) in CONTROL_PLATFORMS + + +def test_a_panel_with_no_adopted_device_counts_no_controls() -> None: + assert adopted_control_count(_snapshot()) == 0 + + +# -- Controls are built, and their domain comes from the declaration --------- + + +def _built(hass: HomeAssistant, *declarations: AdoptedProperty) -> dict[Platform, list[object]]: + """Every platform's share of one adopted device, keyed by platform.""" + snapshot = _snapshot(_device(properties=declarations)) + coordinator = MagicMock(data=snapshot) + registry = dr.async_get(hass) + kwargs = { + "config_entry_id": ENTRY_ID, + "panel_device_id": "panel-device-id", + "overlay": CurationOverlay.empty(), + } + return { + Platform.SENSOR: list(create_adopted_sensors(coordinator, snapshot, registry, **kwargs)), + Platform.BINARY_SENSOR: list(create_adopted_binary_sensors(coordinator, snapshot, registry, **kwargs)), + Platform.SWITCH: list(create_adopted_switches(coordinator, snapshot, registry, **kwargs)), + Platform.SELECT: list(create_adopted_selects(coordinator, snapshot, registry, **kwargs)), + Platform.NUMBER: list(create_adopted_numbers(coordinator, snapshot, registry, **kwargs)), + } + + +def test_every_property_reaches_exactly_one_platform(hass: HomeAssistant) -> None: + """The partition `classify` defines, asserted as a partition. + + Five creators sharing one predicate is what makes this hold. Five bodies each + restating it would let a property reach two platforms or none, and neither + failure shows up in a test that only counts one platform at a time. + """ + declarations = ( + _property(), + _property(node_id="relay", property_id="closed", datatype="boolean", unit=None), + _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True), + _property(node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None), + _property(node_id="generator", property_id="setpoint", datatype="integer", fmt="0:100:5", settable=True), + ) + built = _built(hass, *declarations) + + assert [len(entities) for entities in built.values()] == [1, 1, 1, 1, 1] + assert sum(len(entities) for entities in built.values()) == len(declarations) + + +def test_a_select_takes_its_options_from_the_declaration(hass: HomeAssistant) -> None: + declaration = _property( + node_id="generator", property_id="mode", datatype="enum", fmt="AUTO, MANUAL ,OFF", settable=True, unit=None + ) + (entity,) = _built(hass, declaration)[Platform.SELECT] + assert entity.options == ["AUTO", "MANUAL", "OFF"] + + +def test_a_select_reports_no_option_when_the_panel_publishes_one_it_never_declared(hass: HomeAssistant) -> None: + """Widening the list to admit whatever arrived would hide the disagreement. + + Home Assistant rejects a `current_option` outside `options`, so the choice is + between reporting unknown and quietly rewriting the panel's own declaration. + """ + declaration = _property( + node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None, value="ECO" + ) + (entity,) = _built(hass, declaration)[Platform.SELECT] + assert entity.current_option is None + assert entity.options == ["AUTO", "OFF"] + + +def test_a_number_takes_its_bounds_from_the_declaration(hass: HomeAssistant) -> None: + """The bounds are what made this a number rather than a reading.""" + declaration = _property( + node_id="generator", property_id="setpoint", datatype="integer", fmt="10:80:5", settable=True, unit="%" + ) + (entity,) = _built(hass, declaration)[Platform.NUMBER] + assert (entity.native_min_value, entity.native_max_value, entity.native_step) == (10.0, 80.0, 5.0) + + +async def test_a_switch_publishes_the_vocabulary_homie_defines(hass: HomeAssistant) -> None: + """`true` and `false`, not Home Assistant's `on` and `off`.""" + declaration = _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True) + snapshot = _snapshot(_device(properties=(declaration,))) + coordinator = MagicMock(data=snapshot) + coordinator.client.set_adopted_property = AsyncMock() + coordinator.async_request_refresh = AsyncMock() + + (entity,) = create_adopted_switches( + coordinator, + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + await entity.async_turn_on() + + coordinator.client.set_adopted_property.assert_awaited_once_with("generator-1", "relay", "enabled", "true") + + +def _adopted_switch(hass: HomeAssistant) -> tuple[MagicMock, object]: + """One adopted switch and the coordinator whose client it writes through.""" + declaration = _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True) + snapshot = _snapshot(_device(properties=(declaration,))) + coordinator = MagicMock(data=snapshot) + coordinator.async_request_refresh = AsyncMock() + (entity,) = create_adopted_switches( + coordinator, + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + return coordinator, entity + + +async def test_an_adopted_control_raises_when_the_command_was_not_delivered(hass: HomeAssistant) -> None: + """A `FAILED` outcome is the promise that nothing will happen later. + + The library refuses such a write rather than letting paho queue it, so + returning normally would tell the person who flipped the switch that their + generator changed state when the command never left the process. + """ + coordinator, entity = _adopted_switch(hass) + coordinator.client.set_adopted_property = AsyncMock( + return_value=PublishOutcome( + state=PublishState.FAILED, + topic=None, + value="true", + detail="broker not connected; refused rather than queued", + ) + ) + + with pytest.raises(HomeAssistantError) as raised: + await entity.async_turn_on() + + assert raised.value.translation_key == "adopted_control_not_delivered" + assert raised.value.translation_placeholders is not None + assert raised.value.translation_placeholders["reason"] == "broker not connected; refused rather than queued" + coordinator.async_request_refresh.assert_not_awaited() + + +async def test_an_adopted_control_translates_the_librarys_refusal(hass: HomeAssistant) -> None: + """A refusal reaches the caller in the user's language, like a curated one.""" + coordinator, entity = _adopted_switch(hass) + coordinator.client.set_adopted_property = AsyncMock( + side_effect=SpanPanelServerError("No settable adopted property relay/enabled on device 'generator-1'") + ) + + with pytest.raises(HomeAssistantError) as raised: + await entity.async_turn_on() + + assert raised.value.translation_key == "adopted_control_failed" + assert raised.value.translation_placeholders is not None + assert raised.value.translation_placeholders["reason"] == ( + "No settable adopted property relay/enabled on device 'generator-1'" + ) + coordinator.async_request_refresh.assert_not_awaited() + + +async def test_a_number_publishes_an_integer_where_the_declaration_says_integer(hass: HomeAssistant) -> None: + """`5`, never `5.0`: a float literal is outside the declared datatype.""" + declaration = _property( + node_id="generator", property_id="setpoint", datatype="integer", fmt="0:100:5", settable=True, unit=None + ) + snapshot = _snapshot(_device(properties=(declaration,))) + coordinator = MagicMock(data=snapshot) + coordinator.client.set_adopted_property = AsyncMock() + coordinator.async_request_refresh = AsyncMock() + + (entity,) = create_adopted_numbers( + coordinator, + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + await entity.async_set_native_value(45.0) + + coordinator.client.set_adopted_property.assert_awaited_once_with("generator-1", "generator", "setpoint", "45") + + +def test_a_control_is_disabled_and_diagnostic_like_every_other_adopted_entity(hass: HomeAssistant) -> None: + """Disabled-by-default is the gate, and it is the same gate for a reading. + + A second, weaker gate for controls -- surfacing them read-only -- would be + inconsistent and would not add safety: enabling is a deliberate act, + commanding is a second one, and the panel authorises the write regardless. + """ + declarations = ( + _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True), + _property(node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None), + _property(node_id="generator", property_id="setpoint", datatype="integer", fmt="0:9:1", settable=True), + ) + built = _built(hass, *declarations) + controls = built[Platform.SWITCH] + built[Platform.SELECT] + built[Platform.NUMBER] + + assert len(controls) == 3 + assert all(entity.entity_registry_enabled_default is False for entity in controls) + assert all(entity.entity_category is EntityCategory.DIAGNOSTIC for entity in controls) + + +# -- An adopted device exists even when it has no readings ------------------- + + +def test_a_device_whose_whole_declaration_is_info_still_gets_a_card(hass: HomeAssistant, registered_panel: tuple[str, str]) -> None: + """The gap explicit registration closes. + + `info` resolves entirely to the device card by the node rule, so a vendor + device that advertises what it is before publishing any reading creates no + entity -- and devices are otherwise created as a side effect of entity + creation, so it used to produce *nothing at all*: no device, no entity, no + notification. Which is the silence adoption exists to end, reached by a + different route. + """ + entry_id, panel_device_id = registered_panel + device = AdoptedDevice( + device_id="generator-1", + device_type="energy.ebus.device.generator", + name="Backup Generator", + model="GEN-9000", + vendor_name="Example Power", + software_version="3.2.1", + properties=(), + ) + snapshot = _snapshot(device) + + async_register_adopted_devices(hass, entry_id, snapshot, panel_device_id=panel_device_id) + + registered = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, adopted_identifier(PANEL_SERIAL, "generator-1")), entry_id + ) + assert registered is not None + assert registered.name == "Backup Generator" + assert registered.model == "GEN-9000" + assert registered.manufacturer == "Example Power" + assert registered.sw_version == "3.2.1" + + +def test_registration_freezes_the_anchor_before_any_entity_resolves_it(hass: HomeAssistant, registered_panel: tuple[str, str]) -> None: + """Registering first is what makes the freeze single-valued. + + `resolve_identifier` reads the registry to decide which spelling this install + uses. Running it once at registration means every entity created afterwards + resolves against a device that already exists and cannot disagree -- including + on the run where a serial first arrives. + """ + entry_id, panel_device_id = registered_panel + without_serial = _device("generator-1") + async_register_adopted_devices(hass, entry_id, _snapshot(without_serial), panel_device_id=panel_device_id) + + with_serial = _device("generator-1", serial_number="EX-0000-0001") + async_register_adopted_devices(hass, entry_id, _snapshot(with_serial), panel_device_id=panel_device_id) + + registry = dr.async_get(hass) + assert registry.async_get_device_by_identifier( + (DOMAIN, adopted_identifier(PANEL_SERIAL, "generator-1")), entry_id + ) + assert ( + registry.async_get_device_by_identifier( + (DOMAIN, adopted_identifier(PANEL_SERIAL, "EX-0000-0001")), entry_id + ) + is None + ) + + +def test_a_panel_with_no_adopted_device_registers_nothing(hass: HomeAssistant, registered_panel: tuple[str, str]) -> None: + entry_id, panel_device_id = registered_panel + async_register_adopted_devices(hass, entry_id, _snapshot(), panel_device_id=panel_device_id) + + adopted = [ + device + for device in dr.async_entries_for_config_entry(dr.async_get(hass), entry_id) + if any(ADOPTED_IDENTIFIER_TOKEN in identifier for _domain, identifier in device.identifiers) + ] + assert adopted == [] + + +# -- The id grammar is one grammar ------------------------------------------- + + +def test_an_adopted_id_follows_the_same_grammar_as_a_curated_one() -> None: + """`span_{serial}_{scope}_{suffix}`, with the serial spelled the same way. + + An earlier version lower-cased and de-hyphenated the whole string, which + mangled the serial into `span_sp3_242424_001_...` where every other id in the + integration says `span_sp3-242424-001_...`. A reader that parses an id by + position -- `extract_circuit_uuid_from_unique_id` does -- must not meet a + second grammar. + """ + declaration = _property(node_id="meter", property_id="active-power") + identifier = adopted_identifier(PANEL_SERIAL, "generator-1") + + adopted = adopted_unique_id(identifier, declaration) + curated = build_panel_unique_id(PANEL_SERIAL, "panel.instant_grid_power_w") + + prefix = f"span_{PANEL_SERIAL}_" + assert adopted.startswith(prefix) + assert curated.startswith(prefix) + assert adopted == f"{prefix}{ADOPTED_IDENTIFIER_TOKEN}_generator-1_meter_active_power" + + +def test_the_wire_address_is_snake_cased_and_the_anchor_is_not() -> None: + """The suffix has to read like a curated suffix; the anchor is an identity. + + A serial keeps its hyphens in every curated id, and an adopted anchor is the + same kind of thing -- a name the device is known by, not a description key. + """ + declaration = _property(node_id="charge-limit", property_id="owner-limit") + identifier = adopted_identifier(PANEL_SERIAL, "EX-0000-0001") + + assert adopted_unique_id(identifier, declaration).endswith("_EX-0000-0001_charge_limit_owner_limit".lower()) + + +def test_curation_changes_two_of_the_three_segments(hass: HomeAssistant) -> None: + """Why uniform grammar does not remove the migration promotion needs. + + The serial is the same either way. The scope becomes a curated sub-device kind + rather than `adopted_{anchor}`, and the suffix becomes a human-chosen + description key rather than a wire address. Both are the change itself, not a + formatting difference -- so a curated description cannot reproduce the adopted + id and has to take it over instead. + """ + declaration = _property(node_id="meter", property_id="active-power") + adopted = adopted_unique_id(adopted_identifier(PANEL_SERIAL, "generator-1"), declaration) + curated_if_modelled = build_panel_unique_id(PANEL_SERIAL, "generator.active_power") + + assert adopted != curated_if_modelled + assert ADOPTED_IDENTIFIER_TOKEN in adopted + assert ADOPTED_IDENTIFIER_TOKEN not in curated_if_modelled + + +# -- The grammar is not injective, and the collision is caught ---------------- + + +def _colliding_pair() -> tuple[AdoptedProperty, AdoptedProperty]: + """Two wire addresses that differ only in which side of the hyphen the `2` falls. + + The review's own example, and a realistic one: a battery vendor hanging a + second pack off the device publishes `battery-2/cell-temperature`, and a + vendor that numbers the property rather than the node publishes + `battery/2-cell-temperature`. + """ + sorts_first = _property( + node_id="battery-2", property_id="cell-temperature", unit="°C", value="31.4" + ) + sorts_second = _property( + node_id="battery", property_id="2-cell-temperature", unit="°C", value="99.9" + ) + # `-` (0x2D) sorts before `/` (0x2F), so `battery-2/cell-temperature` is the + # lexically first of the two paths. The values differ so the survivor is + # identifiable by what it reads. + assert sorts_first.path < sorts_second.path + return sorts_first, sorts_second + + +def test_the_adopted_grammar_is_not_injective() -> None: + """Stated as an assertion because it is a permanent property of these ids. + + `{node}.{property}` with the hyphens flattened cannot distinguish where the + hyphen was, so two distinct wire addresses reach one id. The id form is + frozen -- an adopted `unique_id` is as permanent as a curated one, and + moving it would strand every entity already keyed on it -- so the collision + is *handled* rather than removed. `extension.py`'s grammar carries the wire + path verbatim and is injective by construction; that is the encoding a future + identity namespace would adopt, and this test records why it cannot be + adopted here. + """ + identifier = adopted_identifier(PANEL_SERIAL, "battery-pack-1") + first, second = _colliding_pair() + + assert adopted_unique_id(identifier, first) == adopted_unique_id(identifier, second) + + +@pytest.mark.parametrize("reversed_order", [False, True], ids=["as-declared", "reversed"]) +def test_the_lexically_first_of_two_colliding_properties_wins_either_way( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, reversed_order: bool +) -> None: + """Home Assistant drops the loser of a unique_id collision permanently. + + Two entities added under one id leaves the second unregistered with only a + core log line naming a `unique_id` the user cannot map back to a wire + address. Keeping one and saying so names both addresses, which is what makes + the case reportable. + + **Run in both declaration orders, and that is the point of the test.** + Adapter emission order tracks the wire, so a winner picked by arrival order + would let a firmware update that reordered a description change what a + standing entity reads -- silently, since the `unique_id` does not move and + nothing here would migrate it. The survivor is the lexically first wire path + in both runs. Registry preference cannot resolve this the way the extension + cap's does: both properties resolve to the same id, so the registry row + cannot say which of them put it there. + """ + sorts_first, sorts_second = _colliding_pair() + declared = (sorts_second, sorts_first) if reversed_order else (sorts_first, sorts_second) + snapshot = _snapshot(_device(properties=declared)) + + sensors = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + + assert len(sensors) == 1 + assert sensors[0].native_value == 31.4 + assert str(sensors[0].name) == "Cell Temperature" + assert "battery-2/cell-temperature" in caplog.text + assert "battery/2-cell-temperature" in caplog.text + + +def test_the_same_address_on_two_devices_is_not_a_collision(hass: HomeAssistant) -> None: + """The anchor is part of the id, so the guard must not pool devices together. + + Two identical vendor devices publishing the same property is the ordinary + case, not the pathological one. + """ + declaration = _property(node_id="meter", property_id="active-power") + snapshot = _snapshot( + _device("generator-1", properties=(declaration,)), + _device("generator-2", properties=(declaration,)), + ) + + sensors = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + + assert len({str(sensor.unique_id) for sensor in sensors}) == 2 + + +# -- A vendor string longer than a Home Assistant state ----------------------- + + +def test_a_string_over_the_state_limit_is_clamped_rather_than_written( + hass: HomeAssistant, +) -> None: + """`9567cb9` fixed this for extension properties and left adoption behind. + + Home Assistant refuses a state over 255 characters -- the assertion below is + core's own rule, not this integration's. A vendor string is unbounded on the + wire, and an adopted `string` property with no unit reaches the state machine + verbatim: the entity platform's own setter does not raise, it logs an ERROR + and substitutes `unknown`, so the reading was lost on every update and the + log filled up saying so. + """ + with pytest.raises(InvalidStateError): + hass.states.async_set("sensor.synthetic_adopted_notes", "x" * 300) + + declaration = _property( + node_id="diagnostics", property_id="notes", datatype="string", unit=None, value="x" * 300 + ) + snapshot = _snapshot(_device(properties=(declaration,))) + + (sensor,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + + assert sensor.native_value == "x" * MAX_STATE_LENGTH + + +def test_a_string_within_the_limit_is_passed_through_untouched(hass: HomeAssistant) -> None: + """Clamping the pathological case may not truncate the ordinary one.""" + declaration = _property( + node_id="diagnostics", property_id="notes", datatype="string", unit=None, value="ok" + ) + snapshot = _snapshot(_device(properties=(declaration,))) + + (sensor,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + + assert sensor.native_value == "ok" + + +# -- What a value is parsed as is what the declaration says it is ------------- + + +def test_a_unit_less_numeric_declaration_still_publishes_a_number(hass: HomeAssistant) -> None: + """A bare count declares no unit and is a number all the same. + + The unit used to stand in for "this is numeric", which was true enough while + an uncurated row could assert nothing: a count published as `"42"` read as + the string `"42"` and nobody could put a state class behind it. Curation + makes that consequential -- the datatype admits `measurement`, so the + recorder would be handed text under a numeric state class. + """ + declaration = _property( + node_id="meter", property_id="cycle-count", datatype="integer", unit=None, value="42" + ) + snapshot = _snapshot(_device(properties=(declaration,))) + + (sensor,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + + assert sensor.native_value == 42.0 + + +def test_a_curated_unit_less_numeric_reports_a_float_under_its_state_class( + hass: HomeAssistant, +) -> None: + """The gap this closes, stated through the record that opens it.""" + declaration = _property( + node_id="meter", property_id="cycle-count", datatype="integer", unit=None, value="42" + ) + snapshot = _snapshot(_device(properties=(declaration,))) + identifier = resolve_identifier( + dr.async_get(hass), + snapshot.serial_number, + snapshot.adopted_devices[0], + config_entry_id=ENTRY_ID, + ) + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT) + + (sensor,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=_overlay_for(declaration.path, record, identifier), + ) + + assert sensor.state_class is SensorStateClass.MEASUREMENT + assert sensor.native_value == 42.0 + + +def test_a_unit_less_numeric_that_publishes_text_reports_nothing(hass: HomeAssistant) -> None: + """Declared numeric is declared numeric: unparseable text is not a fallback to text.""" + declaration = _property( + node_id="meter", property_id="cycle-count", datatype="integer", unit=None, value="lots" + ) + snapshot = _snapshot(_device(properties=(declaration,))) + + (sensor,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + + assert sensor.native_value is None + + +# -- Diagnostics report the proxy relationship, never the parent's id --------- + + +def test_diagnostics_report_whether_a_device_is_proxied_and_not_by_whom() -> None: + """A device id can embed a serial, so the id must not go in the payload. + + Producers derive a DER's id preferring a serial over a default slug -- which + is why this repository holds PV's `info/serial-number` unvalued -- so + reporting `parent` verbatim would leak the serial the block deliberately + withholds. The boolean answers the maintainer's actual question. + """ + device = replace(_device("gateway-1-sensor"), parent="gateway-1", proxied=True) + block = _adoption(_snapshot(device)) + + (row,) = block["devices"] + assert row["proxied"] is True + assert "parent" not in row + assert "gateway-1" not in json.dumps(block) + + +# -- A numeric format this integration cannot read --------------------------- + +MALFORMED_FORMATS = [ + "0-100", + "auto", + "0:banana", + "0:100:step", + "nan:100", + "0:inf", + "%", + "16 A", + "80:10", + "0:100:0", + "0:100:-1", +] +"""Formats a non-compliant publisher can put on a numeric, none of them a range. + +Homie 5 spells a numeric domain `min:max` with an optional `:step` and permits +nothing else, so every one of these needs a publisher that ignores the +specification -- which a vendor-extensible schema is exactly where to meet. +`nan:100` and `0:inf` are here because `float()` accepts both: they parse +without raising and are still not bounds anything can be clamped to. + +The last three are the same class one step further in. `80:10` reverses the +range, so the control admits no value at all; `0:100:0` and `0:100:-1` give a +step that the frontend's own slider arithmetic divides by or counts backwards +with. Each parses cleanly and none of them is a domain. +""" + + +@pytest.mark.parametrize("fmt", MALFORMED_FORMATS) +def test_a_numeric_format_that_states_no_range_surfaces_as_a_reading(fmt: str) -> None: + """The same rule as a missing format, for a format that says nothing usable. + + An unparseable range is no range, and a number with no bounds is not a safer + control than none -- it is an invented one. The property still has a value, + so it surfaces as the reading it can be. + """ + assert classify(_property(datatype="float", fmt=fmt, settable=True)) is Platform.SENSOR + assert classify(_property(datatype="integer", fmt=fmt, settable=True)) is Platform.SENSOR + + +@pytest.mark.parametrize("fmt", MALFORMED_FORMATS) +def test_parsing_a_format_nothing_can_read_answers_none_rather_than_raising(fmt: str) -> None: + """`float()` raising here reached `async_setup_entry` and took the platform down.""" + assert parse_number_format(fmt) is None + + +@pytest.mark.parametrize( + ("fmt", "expected"), + [ + ("0:100:1", (0.0, 100.0, 1.0)), + ("10:80:5", (10.0, 80.0, 5.0)), + ("10:80", (10.0, 80.0, 1.0)), + (":80", (0.0, 80.0, 1.0)), + ("10:", (10.0, 100.0, 1.0)), + (":", (0.0, 100.0, 1.0)), + ("-5:5:0.5", (-5.0, 5.0, 0.5)), + # Equal bounds are degenerate, not broken: a single-valued domain still + # admits a value, so refusing it would move a property nobody has + # complained about. The boundary is asserted from both sides -- `80:10` + # is in MALFORMED_FORMATS. + ("5:5:1", (5.0, 5.0, 1.0)), + ], +) +def test_a_format_homie_permits_parses_exactly_as_it_always_did( + fmt: str, expected: tuple[float, float, float] +) -> None: + """Defending against the malformed case may not move the well-formed one. + + Homie leaves either bound omittable, and an omitted one has always taken + Home Assistant's own default -- `":"` included, which states neither and + still yields 0-100. Whether *that* is a range worth building a control from + is a question this change deliberately does not reopen: it is the behaviour + every existing install already has, and it is not what the crash was about. + """ + assert parse_number_format(fmt) == expected + + +def test_an_adopted_number_refuses_to_invent_the_bounds_it_was_not_given() -> None: + """The invariant `classify` establishes, asserted where it is relied on. + + Nothing routes such a property here any more, so this is the guard rather + than the behaviour: a number built without bounds would present a 0-100 + control the panel never declared, and being loud about a broken invariant is + better than shipping that. `number.async_setup_entry` bounds what the noise + can cost by adding the curated controls first. + """ + declaration = _property(node_id="generator", property_id="setpoint", datatype="float", fmt="0-100", settable=True) + with pytest.raises(ValueError, match="states no range"): + AdoptedNumber( + MagicMock(data=_snapshot(_device(properties=(declaration,)))), + adopted_identifier(PANEL_SERIAL, "generator-1"), + _device(properties=(declaration,)), + declaration, + panel_device_id="panel-device-id", + ) + + +def _panel_with_a_charger_and_a_malformed_numeric() -> SpanPanelSnapshot: + """One curated EVSE that declares a settable limit, beside two unreadable formats.""" + declarations = ( + _property(node_id="generator", property_id="setpoint", datatype="float", fmt="0-100", settable=True, unit="%"), + _property(node_id="generator", property_id="ceiling", datatype="integer", fmt="auto", settable=True, unit="A"), + ) + charger = replace( + SpanEvseSnapshotFactory.create(node_id="evse-0", serial_number="SN-EVSE-SYNTH"), + charge_current_limit_a=16, + charge_current_ceiling_a=48, + charge_current_limit_settable=True, + ) + return replace(_snapshot(_device(properties=declarations)), evse={"SN-EVSE-SYNTH": charger}) + + +async def test_a_vendor_format_nothing_can_read_leaves_the_curated_control_standing( + hass: HomeAssistant, +) -> None: + """The blast radius this finding is about, asserted through the platform. + + A `float()` that raised while building an adopted number raised inside + `number.async_setup_entry`, so a non-compliant publisher on some device + nobody modelled cost the user the EVSE charge-current limit -- a curated + control on curated hardware, with nothing on screen to say why. + """ + snapshot = _panel_with_a_charger_and_a_malformed_numeric() + coordinator = MagicMock(data=snapshot) + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.unresolved_paths = frozenset() + entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, title="SPAN Panel", unique_id=PANEL_SERIAL) + entry.add_to_hass(hass) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + coordinator.config_entry = entry + added: list[object] = [] + + await number_async_setup_entry(hass, entry, lambda entities, *args, **kwargs: added.extend(entities)) + + assert [type(entity).__name__ for entity in added] == ["SpanEvseNumber"] + assert isinstance(added[0], SpanEvseNumber) + + +def test_the_malformed_numeric_still_reaches_the_user_as_a_reading(hass: HomeAssistant) -> None: + """Routed to a sensor, not dropped: the property has a value, only no domain.""" + snapshot = _panel_with_a_charger_and_a_malformed_numeric() + sensors = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + + assert sorted(str(entity.name) for entity in sensors) == ["Ceiling", "Setpoint"] + assert adopted_control_count(snapshot) == 0 + + +# -- A curated record reaches the entity at construction ---------------------- + + +def _overlay_for(declaration_path: str, record: CurationRecord, identifier: str) -> CurationOverlay: + """One overlay holding one record, under the key `_create` will look up.""" + return CurationOverlay({f"{identifier}/{declaration_path}": record}) + + +def test_a_curated_record_shapes_the_sensor_at_construction(hass: HomeAssistant) -> None: + """What the user asserted is what the entity is born with. + + Applied at construction rather than patched onto a live entity: a + `state_class` decides whether the recorder writes long-term statistics, and + that decision is read when the entity is added. + """ + declarations = (_property(unit="V", datatype="float"),) + snapshot = _snapshot(_device(properties=declarations)) + identifier = resolve_identifier( + dr.async_get(hass), + snapshot.serial_number, + snapshot.adopted_devices[0], + config_entry_id=ENTRY_ID, + ) + record = CurationRecord( + state_class=SensorStateClass.MEASUREMENT, device_class="voltage", promote=True + ) + (entity,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=_overlay_for(declarations[0].path, record, identifier), + ) + assert entity.state_class is SensorStateClass.MEASUREMENT + assert entity.device_class is SensorDeviceClass.VOLTAGE + assert entity.entity_category is None + assert entity.entity_registry_enabled_default is False + + +def test_an_uncurated_row_is_exactly_todays_entity(hass: HomeAssistant) -> None: + """Curation is opt-in per row: an empty overlay changes nothing at all.""" + declarations = (_property(unit="V", datatype="float"),) + snapshot = _snapshot(_device(properties=declarations)) + (entity,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=CurationOverlay.empty(), + ) + assert entity.state_class is None + assert entity.entity_category is EntityCategory.DIAGNOSTIC + + +def test_a_stale_record_field_is_skipped_and_the_rest_applied(hass: HomeAssistant) -> None: + """A record can outlive the declaration it was written against. + + The vendor moved this row to `string`, so the asserted `state_class` no + longer has a numeric sensor to sit on -- and a state class on a text + reading is exactly the corrupt statistics adoption refuses to write. The + field is dropped; the prominence the user also asked for still applies. + """ + declarations = (_property(unit=None, datatype="string"),) + snapshot = _snapshot(_device(properties=declarations)) + identifier = resolve_identifier( + dr.async_get(hass), + snapshot.serial_number, + snapshot.adopted_devices[0], + config_entry_id=ENTRY_ID, + ) + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT, promote=True) + (entity,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=_overlay_for(declarations[0].path, record, identifier), + ) + assert entity.state_class is None + assert entity.entity_category is None + + +def test_a_curated_binary_sensor_takes_the_device_class_it_was_given(hass: HomeAssistant) -> None: + """The record is the only device class a binary sensor can have. + + A sensor's comes off the declared unit; a boolean declares no unit, so + `door` and `problem` and `running` are indistinguishable on the wire and + there is nothing for `DEVICE_CLASS_BY_UNIT` to answer with. + """ + declarations = (_property(node_id="relay", property_id="closed", datatype="boolean", unit=None),) + snapshot = _snapshot(_device(properties=declarations)) + identifier = resolve_identifier( + dr.async_get(hass), + snapshot.serial_number, + snapshot.adopted_devices[0], + config_entry_id=ENTRY_ID, + ) + record = CurationRecord(device_class=BinarySensorDeviceClass.DOOR.value) + (entity,) = create_adopted_binary_sensors( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=_overlay_for(declarations[0].path, record, identifier), + ) + assert entity.device_class is BinarySensorDeviceClass.DOOR + assert entity.entity_category is EntityCategory.DIAGNOSTIC + + +def test_a_control_takes_prominence_and_nothing_else(hass: HomeAssistant) -> None: + """A control row carries one field, and the entity is what proves it. + + `sanitise` refuses a device class on a row that is not a sensor, so nothing + downstream re-filters the record -- which is only safe if the refusal + actually holds at the entity. Asserted here rather than only against + `sanitise` so that a control growing its own description later cannot start + honouring a field the editor never offered. + """ + declarations = ( + _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True), + ) + snapshot = _snapshot(_device(properties=declarations)) + identifier = resolve_identifier( + dr.async_get(hass), + snapshot.serial_number, + snapshot.adopted_devices[0], + config_entry_id=ENTRY_ID, + ) + record = CurationRecord(device_class="voltage", promote=True) + (entity,) = create_adopted_switches( + MagicMock(data=snapshot), + snapshot, + dr.async_get(hass), + config_entry_id=ENTRY_ID, + panel_device_id="panel-device-id", + overlay=_overlay_for(declarations[0].path, record, identifier), + ) + assert entity.entity_category is None + assert entity.device_class is None + assert entity.entity_registry_enabled_default is False + + +async def test_a_device_another_entry_owns_does_not_freeze_this_panels_identity( + hass: HomeAssistant, +) -> None: + """The identity freeze reads *this* entry's registry, not every entry's. + + `resolve_identifier` decides which spelling this install already uses by + asking the registry what it already holds. Identifiers are unique within a + config entry and nowhere else, so an unscoped question can be answered by a + device another entry owns -- and this panel's device is then frozen onto a + stranger's identifier, permanently, because the freeze is by design + irreversible. Nothing of ours was ever adopted under the wire id here, so + the serial is the anchor the specification asks for. + """ + registry = dr.async_get(hass) + other = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-other", unique_id="other") + other.add_to_hass(hass) + registry.async_get_or_create( + config_entry_id=other.entry_id, + identifiers={(DOMAIN, adopted_identifier(PANEL_SERIAL, "generator-1"))}, + name="Somebody else's generator", + ) + mine = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-mine", unique_id=PANEL_SERIAL) + mine.add_to_hass(hass) + panel = registry.async_get_or_create( + config_entry_id=mine.entry_id, + identifiers={(DOMAIN, PANEL_SERIAL)}, + name="Span Panel", + ) + snapshot = _snapshot(_device("generator-1", serial_number="EX-0000-0001")) + + async_register_adopted_devices(hass, mine.entry_id, snapshot, panel_device_id=panel.id) + + frozen_onto_the_stranger = registry.async_get_device_by_identifier( + (DOMAIN, adopted_identifier(PANEL_SERIAL, "generator-1")), mine.entry_id + ) + assert frozen_onto_the_stranger is None + assert ( + registry.async_get_device_by_identifier( + (DOMAIN, adopted_identifier(PANEL_SERIAL, "EX-0000-0001")), mine.entry_id + ) + is not None + ) + + +def test_the_builders_resolve_a_frozen_device_within_the_entry_they_are_handed( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The freeze holds through the builders, not only through `resolve_identifier`. + + `_create` resolves each device's identifier again rather than reading the one + registration froze, so the scope it is handed decides which card it finds. + A device adopted under its wire id that later publishes a serial keeps the + wire id's unique_id; asked in the wrong entry, the lookup misses, the serial + is minted, and the registry reads the new unique_id as an entity replacement + that takes the entity's history with it. + """ + entry_id, panel_device_id = registered_panel + registry = dr.async_get(hass) + frozen = adopted_identifier(PANEL_SERIAL, "generator-1") + registry.async_get_or_create( + config_entry_id=entry_id, + identifiers={(DOMAIN, frozen)}, + name="Backup Generator", + via_device_id=panel_device_id, + ) + declaration = _property() + snapshot = _snapshot( + _device("generator-1", serial_number="EX-0000-0001", properties=(declaration,)) + ) + + (sensor,) = create_adopted_sensors( + MagicMock(data=snapshot), + snapshot, + registry, + config_entry_id=entry_id, + panel_device_id=panel_device_id, + overlay=CurationOverlay.empty(), + ) + + assert sensor.unique_id == adopted_unique_id(frozen, declaration) diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py new file mode 100644 index 00000000..2983ab1a --- /dev/null +++ b/tests/test_bess_telemetry.py @@ -0,0 +1,674 @@ +"""What the BESS reports about itself, surfaced as two sensors on its own device. + +`meter/active-power` and `status/communication-state` were declared, published +and read by nobody. They are the battery's own view of its power and its own view +of its link, as opposed to the enclosure's view of both. + +Every assertion runs against a real snapshot built by the real schema_1 adapter +over the reference capture, and every expected value is read out of that capture +rather than written as a literal — a test that pins the same constant the code +pins passes whether or not the wire is ever read. Each reading is proved by +republishing it, deleting it, or dropping the node that carries it. + +**The sign is the hard part, and it is what most of this module is about.** The +capture is of a *charging* battery, published as a positive `meter/active-power` +because the enclosure meters the BESS the way it meters a circuit it feeds, in a +frame where positive means power flowing *into* the thing being metered. Each +path applies exactly one negation — the snapshot negates the BESS's own meter, +and `battery_power` negates the enclosure's `power-flows/battery` — so both +sensors land on the same convention in the UI, reading negative for this charging +battery. A sensor whose sign contradicted the one beside it would be worse than +no sensor, so the agreement is asserted directly rather than inferred from the +two definitions. + +**The agreement is structural; the direction is inherited from the fixture.** +The capture is in the frame firmware through r202633 publishes: both wire +properties carry the *same* sign as each other and each path applies exactly one +negation, so the two entities cannot disagree whatever the convention. The tests +that read the capture unmodified are scoped to that frame; r202639, which +publishes the BESS meter as the flow's negation, has its own section at the end +of the sign tests. That is what the agreement assertions below pin, and it holds +independently of which way is charging. Neither negation changed here: +`BATTERY_POWER_SENSOR` is the single negation it has been since 2.0.8, and what a +real panel displays depends on what that panel publishes, not on this fixture. + +Which way *is* charging is read off the reference capture, and the capture now +carries the post-`distribution-enclosure-simulator#39` frame: `power-flows` 0.3 +defines the node balance as every term positive when power flows into the thing +it names, so `pv` is negative while producing and the four terms sum to zero. +The capture this module was first written against predated that fix and satisfied +`grid + pv + battery == site` instead, which is why these assertions read the +other way until span-panel-api re-captured the reference tree. The library pins +the same four values and states the same frame (`test_schema_one_panel.py`, +`test_power_flows`). Refresh the fixture and revisit these assertions together; +do not change one without the other. +""" + +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + DerivedReason, + Producibility, +) +from custom_components.span_panel.helpers import detect_capabilities, has_bess_telemetry +from custom_components.span_panel.sensor import create_battery_sensors +from custom_components.span_panel.sensor_definitions import ( + BATTERY_POWER_SENSOR, + BESS_TELEMETRY_SENSORS, +) +from custom_components.span_panel.sensor_panel import ( + SpanBessMetadataSensor, + SpanPanelBattery, + SpanPanelPowerSensor, +) +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import CONF_HOST, UnitOfPower +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.typing import StateType + +from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree +from .factories import SpanPanelSnapshotFactory, pv_binding_for + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +BESS = "bess" + +POWER_TOPIC = "meter/active-power" +COMMS_TOPIC = "status/communication-state" +ENCLOSURE_FLOW_TOPIC = "power-flows/battery" + +POWER_KEY = "meter_power" +COMMS_KEY = "communication_state" +ENCLOSURE_FLOW_KEY = BATTERY_POWER_SENSOR.key + + +@pytest.fixture(autouse=True) +def _mock_entity_registry() -> Any: + """Patch entity registry lookups used during sensor construction.""" + registry = MagicMock() + registry.async_get_entity_id.return_value = None + with patch( + "custom_components.span_panel.sensor_base.er.async_get", + return_value=registry, + ): + yield registry + + +def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: + """A coordinator-like mock carrying one snapshot.""" + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = MockConfigEntry( + domain="span_panel", + data={CONF_HOST: "192.168.1.50"}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + return coordinator + + +def _published(device_id: str, topic: str) -> str: + """What the capture publishes on one topic, or fail saying it does not.""" + value = schema_one_tree()[device_id].get(topic) + assert value is not None, f"{device_id} publishes no {topic} in the capture" + return value + + +def _republishing(**topics: str) -> SpanPanelSnapshot: + """A snapshot from the capture with some BESS topics rewritten.""" + tree = schema_one_tree() + for name, value in topics.items(): + tree[BESS][name.replace("__", "/").replace("_", "-")] = value + return schema_one_snapshot(tree) + + +def _without(topic: str) -> SpanPanelSnapshot: + """A snapshot from a BESS that stopped publishing (and declaring) a property.""" + tree = schema_one_tree() + node, _, property_id = topic.partition("/") + description = json.loads(tree[BESS]["$description"]) + del tree[BESS][topic] + del description["nodes"][node]["properties"][property_id] + tree[BESS]["$description"] = json.dumps(description) + return schema_one_snapshot(tree) + + +def _without_node(node: str) -> SpanPanelSnapshot: + """A snapshot from a BESS with no such capability node at all.""" + tree = schema_one_tree() + for topic in [t for t in tree[BESS] if t.startswith(f"{node}/")]: + del tree[BESS][topic] + description = json.loads(tree[BESS]["$description"]) + del description["nodes"][node] + tree[BESS]["$description"] = json.dumps(description) + return schema_one_snapshot(tree) + + +def _without_bess() -> SpanPanelSnapshot: + """A snapshot from a capture with no BESS device in the tree at all.""" + tree = {device: topics for device, topics in schema_one_tree().items() if device != BESS} + return schema_one_snapshot(tree) + + +BessSensor = SpanPanelBattery | SpanPanelPowerSensor | SpanBessMetadataSensor +"""What `create_battery_sensors` returns: everything on the BESS sub-device.""" + + +def _sensors(snapshot: SpanPanelSnapshot) -> dict[str, BessSensor]: + """Whatever the platform creates for this snapshot, keyed by description key.""" + created = create_battery_sensors(_coordinator(snapshot), snapshot) + return {sensor.entity_description.key: sensor for sensor in created} + + +def _state(snapshot: SpanPanelSnapshot, key: str) -> StateType | date | datetime | Decimal: + """The state one BESS sensor reports for a snapshot. + + Typed as `SensorEntity.native_value` is, rather than narrowed to what these + two sensors happen to report: narrowing here would be the test asserting its + own expectation twice, once in the annotation and once in the body. + """ + sensor = _sensors(snapshot)[key] + sensor._update_native_value() + return sensor.native_value + + +# --------------------------------------------------------------------------- +# The premise: the capture publishes both properties, on a charging battery +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_both_properties() -> None: + """Guard the premise for every test below, all of which read the capture for + their expected value: a capture that stopped publishing one would make them + vacuously true rather than failing.""" + assert _published(BESS, POWER_TOPIC) + assert _published(BESS, COMMS_TOPIC) + + +def test_the_capture_is_a_charging_battery() -> None: + """The premise of every sign assertion below, derived rather than assumed. + + A sign convention can only be tested against a known physical state, and + "negative means charging" is the very claim under test, so reading the state + off the sign would be circular. The enclosure's four power flows balance + instead — `pv + battery + grid == site`, with `grid` positive when importing — + and solving that identity says which way the battery is going without + appealing to any convention this codebase chose. + + The capture: 8500 W of PV meets 2653 W of site load and exports 2347 W, and + the 3500 W left over is going into the battery. So this battery is charging, + and the enclosure publishes that as a *positive* number. + + `power-flows` 0.3 defines the balance as a node balance — every term positive + when power flows into the thing it names — so the four sum to zero rather than + three of them summing to the fourth, and PV reads negative while producing. + Asserting the sum against zero rather than against `site` is what makes this a + statement about the frame and not just about four numbers. + + Were the capture ever retaken with the battery discharging, this fails first + and says so, rather than the sign tests failing and reading as a wiring bug. + """ + flows = { + name: float(_published(SCHEMA_ONE_PANEL, f"power-flows/{name}")) + for name in ("pv", "battery", "grid", "site") + } + + assert sum(flows.values()) == pytest.approx(0.0, abs=1e-6) + # PV alone exceeds the site load, so the surplus has nowhere to go but the + # battery and the grid — and the grid term is an export, which in this frame + # is power flowing into the grid and therefore positive. + assert -flows["pv"] > flows["site"] + assert flows["grid"] > 0 + assert flows["battery"] > 0 + + +def test_the_bess_meter_agrees_with_the_enclosure_about_direction() -> None: + """The two properties describing this battery must not disagree on the wire. + + `battery_power` negates the enclosure's flow and `bess_meter_power` negates + the BESS's own meter; that is only coherent because the two are published in + the same frame in this r202633-frame capture. Pinned here rather than + assumed, because "negate exactly one of them" is the wrong rule for r202639, + which publishes them opposed (covered below). + """ + bess_meter = float(_published(BESS, POWER_TOPIC)) + enclosure_flow = float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)) + + assert (bess_meter < 0) == (enclosure_flow < 0) + + +# --------------------------------------------------------------------------- +# The sign, and the agreement with the sensor beside it +# --------------------------------------------------------------------------- + + +def test_charging_reads_negative() -> None: + """The convention, asserted on the state a user sees rather than on the field. + + The wire is charge-positive — the enclosure meters the BESS the way it meters + anything else it feeds, positive into the device — and the snapshot negates + it, so a charging battery reads negative and the sensor is discharge-positive. + The library pins the same relation on the same capture + (`test_schema_one_devices.py`: `battery.power_w == -raw`, and `< 0` here). + + Magnitude and sign are asserted separately on purpose: losing the negation + keeps the magnitude, so only the sign check catches it. + """ + published = float(_published(BESS, POWER_TOPIC)) + state = _state(schema_one_snapshot(), POWER_KEY) + + assert state == -published + assert isinstance(state, float) and state < 0 + + +def test_it_agrees_with_the_battery_power_sensor_beside_it() -> None: + """The two battery-power sensors on this device must not contradict each other. + + `battery_power` reads the enclosure's arbitrated `power-flows/battery`; this + one reads the BESS's own meter. The specification defines the first as the + negation of the second, and the r202633-frame capture publishes them with the + *same* sign instead (r202639 conforms; see the end of the sign tests). Each + path negates exactly once here, so the UI shows one convention either way, and + the check is on the states rather than on either definition. A flip on either side fails here even if the side that flipped + still looks self-consistent. + """ + snapshot = schema_one_snapshot() + + own_meter = _state(snapshot, POWER_KEY) + enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY) + + assert isinstance(own_meter, float) and isinstance(enclosure_flow, float) + assert (own_meter > 0) == (enclosure_flow > 0) + + +def test_they_agree_when_the_battery_discharges_too() -> None: + """Agreement at one operating point could be coincidence; this is the other. + + Both properties are republished with the battery discharging — the capture's + two values negated — and both sensors must go positive together, the sensors + being discharge-positive. + """ + snapshot = _republishing_both( + power=-float(_published(BESS, POWER_TOPIC)), + enclosure_flow=-float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)), + ) + + own_meter = _state(snapshot, POWER_KEY) + enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY) + + assert isinstance(own_meter, float) and own_meter > 0 + assert isinstance(enclosure_flow, float) and enclosure_flow > 0 + + +def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnapshot: + """A snapshot with the BESS meter and the enclosure's flow both rewritten.""" + tree = schema_one_tree() + tree[BESS][POWER_TOPIC] = str(power) + tree[SCHEMA_ONE_PANEL][ENCLOSURE_FLOW_TOPIC] = str(enclosure_flow) + return schema_one_snapshot(tree) + + +# --------------------------------------------------------------------------- +# Firmware r202639: the BESS meter is published as the flow's negation +# --------------------------------------------------------------------------- + +R202639_FIRMWARE = "spanos2/r202639/03" + + +def _r202639(*, enclosure_flow: float, power: float | None = None) -> SpanPanelSnapshot: + """Re-publish the capture the way r202639 firmware publishes it. + + From r202639 the BESS meter is the negation of `power-flows/battery` rather + than equal to it, and the enclosure reports a version string the library can + read the release from. `power` overrides the meter for the one test that + needs the two properties to disagree. + """ + tree = schema_one_tree() + tree[SCHEMA_ONE_PANEL]["info/firmware-version"] = R202639_FIRMWARE + tree[SCHEMA_ONE_PANEL][ENCLOSURE_FLOW_TOPIC] = str(enclosure_flow) + tree[BESS][POWER_TOPIC] = str(-enclosure_flow if power is None else power) + return schema_one_snapshot(tree) + + +@pytest.mark.parametrize("direction", ["charging", "discharging"]) +def test_r202639_the_two_sensors_agree(direction: str) -> None: + """On r202639 the wire properties are opposed, and the sensors still agree. + + The capture's enclosure flow is a charging battery; negating it gives the + discharging case. Both sensors must report the same value, positive exactly + when the battery discharges, so the library's pass-through on this firmware + lands on the convention `battery_power` already had. + """ + captured = float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)) + enclosure_flow = captured if direction == "charging" else -captured + snapshot = _r202639(enclosure_flow=enclosure_flow) + + own_meter = _state(snapshot, POWER_KEY) + flow_sensor = _state(snapshot, ENCLOSURE_FLOW_KEY) + + assert isinstance(own_meter, float) and isinstance(flow_sensor, float) + assert own_meter == flow_sensor + assert (own_meter > 0) == (direction == "discharging") + + +def test_r202639_the_firmware_version_decides_when_the_flow_is_idle() -> None: + """With the flow idle, only the firmware version says the meter's sign. + + With `power-flows/battery` at zero the signs cannot be compared, so only the + release build in the firmware version can say the meter is already + discharge-positive. A value that passes through unchanged proves it was read. + """ + snapshot = _r202639(enclosure_flow=0.0, power=1200.0) + + assert _state(snapshot, POWER_KEY) == 1200.0 + + +# --------------------------------------------------------------------------- +# States follow the wire +# --------------------------------------------------------------------------- + + +def test_republishing_the_meter_moves_the_sensor() -> None: + """The mutation proof. The republished value differs in magnitude and in sign + from what the capture carries, so a sensor pinned to a constant — or wired to + the enclosure's flow instead — cannot report it. + + The panel is pinned to an r202633 firmware version. The capture's own version + string does not parse, and without one the library reads a BESS meter whose + sign opposes the enclosure's flow as r202639's frame, which is what this + one-sided republish would otherwise look like.""" + published = float(_published(BESS, POWER_TOPIC)) + discharging = -published / 2 + + tree = schema_one_tree() + tree[BESS][POWER_TOPIC] = str(discharging) + tree[SCHEMA_ONE_PANEL]["info/firmware-version"] = "spanos2/r202633/01" + snapshot = schema_one_snapshot(tree) + + assert _state(snapshot, POWER_KEY) == -discharging + assert _state(snapshot, POWER_KEY) != -published + + +def test_a_battery_at_rest_reports_zero_and_not_negative_zero() -> None: + """`-0.0` compares equal to `0.0` and renders as "-0.0" beside it, so a + negation added without a guard produces a reading that looks broken exactly + when nothing is happening.""" + snapshot = _republishing(meter__active_power="0.0") + + assert _state(snapshot, POWER_KEY) == 0.0 + assert str(_state(snapshot, POWER_KEY)) == "0.0" + + +def test_zero_watts_is_a_state_and_not_an_absence() -> None: + """An idle battery is a reading. A gate that treated zero as absence would + delete the entity whenever the battery stopped moving power.""" + snapshot = _republishing(meter__active_power="0.0") + + assert POWER_KEY in _sensors(snapshot) + + +def test_the_communication_state_is_the_published_enum_lowercased() -> None: + """Lowercase because HA looks the state up as a translation key, which its own + contract restricts to `[a-z0-9-_]+`.""" + published = _published(BESS, COMMS_TOPIC) + + assert _state(schema_one_snapshot(), COMMS_KEY) == published.lower() + + +@pytest.mark.parametrize("republished", ["DEGRADED", "LOST", "UNKNOWN"]) +def test_republishing_the_communication_state_moves_the_sensor(republished: str) -> None: + """Every other member of the enum the BESS's own `$description` declares, so + a sensor pinned to the captured OK cannot report any of them.""" + snapshot = _republishing(status__communication_state=republished) + + assert _state(snapshot, COMMS_KEY) == republished.lower() + assert _state(snapshot, COMMS_KEY) != _published(BESS, COMMS_TOPIC).lower() + + +def test_the_declared_options_are_the_enum_the_bess_declares() -> None: + """The sensor's "Possible states" against the wire's `format`, so a firmware + that widens the enum is caught here rather than by the runtime append.""" + description = json.loads(schema_one_tree()[BESS]["$description"]) + declared = description["nodes"]["status"]["properties"]["communication-state"]["format"] + + options = next(d for d in BESS_TELEMETRY_SENSORS if d.key == COMMS_KEY).options + + assert options is not None + assert set(options) == {value.lower() for value in declared.split(",")} + + +def test_communication_state_is_not_the_connected_binary_sensor() -> None: + """The two link facts this task deliberately keeps apart. + + `bess_connected` is the enclosure's `connection/fed-by-device-status` view; + this sensor is the BESS's report about itself. A BESS can report its own link + LOST while the enclosure still claims it as OK, and a mapping that conflated + them could not express that. + """ + snapshot = _republishing(status__communication_state="LOST") + + assert _state(snapshot, COMMS_KEY) == "lost" + assert snapshot.battery.connected is True + + +# --------------------------------------------------------------------------- +# Absence: deleted property, dropped node, no BESS, flat panel +# --------------------------------------------------------------------------- + + +def test_the_capture_creates_both_sensors() -> None: + created = _sensors(schema_one_snapshot()) + + assert POWER_KEY in created + assert COMMS_KEY in created + + +def test_a_bess_with_no_meter_node_gets_no_power_sensor() -> None: + """A dead entity stuck at unknown is worse than no entity: it occupies the + entity list, breaks a dashboard card, and cannot be told apart from a battery + whose meter has failed.""" + snapshot = _without_node("meter") + + assert POWER_KEY not in _sensors(snapshot) + # The other half of the pair is unaffected — a partial BESS is legal firmware. + assert COMMS_KEY in _sensors(snapshot) + + +def test_a_bess_with_no_status_node_gets_no_communication_sensor() -> None: + snapshot = _without_node("status") + + assert COMMS_KEY not in _sensors(snapshot) + assert POWER_KEY in _sensors(snapshot) + + +@pytest.mark.parametrize( + ("key", "topic", "unknown"), + [(POWER_KEY, POWER_TOPIC, None), (COMMS_KEY, COMMS_TOPIC, "unknown")], +) +def test_a_reading_that_stops_arriving_goes_unknown_rather_than_stale( + key: str, topic: str, unknown: str | None +) -> None: + """Absence after setup is a different event from absence at setup. + + Creation is decided once, from what the panel was publishing when the entry + loaded; a property that stops arriving afterwards cannot delete an entity a + user already has on a dashboard, so it has to degrade instead. The last value + persisting would be the worse outcome — a battery reading 3500 W forever is + indistinguishable from one that is actually charging. + + Driven through the coordinator rather than by rebuilding the entity, because + that is the path a live update takes. + """ + sensor = _sensors(schema_one_snapshot())[key] + + sensor.coordinator.data = _without(topic) + sensor._update_native_value() + + assert sensor.native_value == unknown + + +@pytest.mark.parametrize(("key", "topic"), [(POWER_KEY, POWER_TOPIC), (COMMS_KEY, COMMS_TOPIC)]) +def test_a_property_declared_and_never_published_creates_no_entity(key: str, topic: str) -> None: + """The gate is the value, not the declaration. + + A BESS may declare a property in its `$description` and publish nothing on it + — 19 instances in this capture do. An entity created from a declaration alone + would be permanently unknown, which is the outcome the per-description gate + exists to prevent, so this is the same answer as a missing node reached by a + different route. + """ + tree = schema_one_tree() + del tree[BESS][topic] + + created = _sensors(schema_one_snapshot(tree)) + + assert key not in created + + +def test_a_bess_publishing_neither_gets_neither_sensor() -> None: + tree = schema_one_tree() + description = json.loads(tree[BESS]["$description"]) + for node in ("meter", "status"): + for topic in [t for t in tree[BESS] if t.startswith(f"{node}/")]: + del tree[BESS][topic] + del description["nodes"][node] + tree[BESS]["$description"] = json.dumps(description) + snapshot = schema_one_snapshot(tree) + + assert has_bess_telemetry(snapshot) is False + created = _sensors(snapshot) + assert POWER_KEY not in created + assert COMMS_KEY not in created + # The BESS itself is still commissioned, so its metadata sensors survive. + assert created + + +def test_no_bess_device_creates_no_battery_sensors_at_all() -> None: + snapshot = _without_bess() + + assert create_battery_sensors(_coordinator(snapshot), snapshot) == [] + + +def test_a_flat_panel_gets_neither_sensor() -> None: + """The same absence by the other route: flat's BESS device class declares + neither property, so the factory's default snapshot carries neither field.""" + snapshot = SpanPanelSnapshotFactory.create() + + assert has_bess_telemetry(snapshot) is False + created = _sensors(snapshot) + assert POWER_KEY not in created + assert COMMS_KEY not in created + + +def test_the_telemetry_appearing_is_a_capability_change() -> None: + """Which is how a BESS that gains these nodes mid-life gets the sensors: the + coordinator reloads on a new capability.""" + assert "bess_telemetry" not in detect_capabilities(SpanPanelSnapshotFactory.create()) + assert "bess_telemetry" in detect_capabilities(schema_one_snapshot()) + assert "bess_telemetry" in detect_capabilities(_without_node("meter")) + assert "bess_telemetry" not in detect_capabilities(_without_bess()) + + +# --------------------------------------------------------------------------- +# Shape of the entities +# --------------------------------------------------------------------------- + + +def test_the_power_sensor_is_a_watt_measurement_enabled_by_default() -> None: + """The battery's own charge/discharge figure is a reading a user graphs and + automates on, so it belongs beside the other power sensors rather than under + the diagnostics fold.""" + description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY) + + assert description.device_class is SensorDeviceClass.POWER + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.native_unit_of_measurement == UnitOfPower.WATT + assert description.entity_registry_enabled_default is True + assert description.entity_category is not EntityCategory.DIAGNOSTIC + + +def test_the_communication_sensor_is_a_diagnostic_off_by_default() -> None: + """A fault signal: interesting when something is wrong, noise on a device card + the rest of the time.""" + description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == COMMS_KEY) + + assert description.device_class is SensorDeviceClass.ENUM + assert description.entity_category is EntityCategory.DIAGNOSTIC + assert description.entity_registry_enabled_default is False + + +def test_the_declared_unit_matches_what_the_bess_declares() -> None: + """HA's unit against the tree's, for the path schema_1 carries metadata for. A + disagreement here is what the unit-mismatch Repair reports at runtime.""" + from .adapter_fixtures import schema_one_metadata + + description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY) + + assert schema_one_metadata()["battery.power_w"].unit == (description.native_unit_of_measurement) + + +def test_both_sensors_live_on_the_bess_sub_device() -> None: + """Beside the metadata sensors and the battery level, not on the panel.""" + created = _sensors(schema_one_snapshot()) + bess_device = created["vendor"].device_info + + assert created[POWER_KEY].device_info == bess_device + assert created[COMMS_KEY].device_info == bess_device + + +def test_every_bess_sensor_gets_a_distinct_unique_id() -> None: + """They live on one device and differ only by description key, so a key reused + from the metadata group would silently collide.""" + created = _sensors(schema_one_snapshot()) + unique_ids = {sensor.unique_id for sensor in created.values()} + + assert len(unique_ids) == len(created) + + +# --------------------------------------------------------------------------- +# Conformance annotations +# --------------------------------------------------------------------------- + + +def test_both_paths_are_exempt_as_schema_1_only() -> None: + """Pinned here as well as in the conformance suite, because the reason is + specific to these properties: flat's BESS device class declares neither, so + the producible gate cannot be satisfied and the descriptions must stay + derived. schema_1 does map both, which is what makes the annotation + SCHEMA_1_ONLY rather than NEITHER.""" + assert RESIDUAL_EXEMPT_PATHS["battery.power_w"] is Producibility.SCHEMA_1_ONLY + assert RESIDUAL_EXEMPT_PATHS["battery.communication_state"] is Producibility.SCHEMA_1_ONLY + + +@pytest.mark.parametrize("description", BESS_TELEMETRY_SENSORS, ids=lambda d: d.key) +def test_each_description_names_its_field_as_well_as_its_reason(description: Any) -> None: + """`field_path` says what the entity's value is and `derived` says why that + path is outside the both-adapters gate. Leaving the first unset excuses the + entity from its Repair mention and from going unavailable when the panel stops + resolving the property.""" + assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD + assert description.field_path in RESIDUAL_EXEMPT_PATHS diff --git a/tests/test_binary_sensor_platform.py b/tests/test_binary_sensor_platform.py index 5c8ac2b8..722e657b 100644 --- a/tests/test_binary_sensor_platform.py +++ b/tests/test_binary_sensor_platform.py @@ -4,6 +4,8 @@ from unittest.mock import AsyncMock, MagicMock +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.control_gate import ControlPolicy from custom_components.span_panel.binary_sensor import ( BESS_CONNECTED_SENSOR, BINARY_SENSORS, @@ -18,12 +20,14 @@ PANEL_STATUS, SYSTEM_DOOR_STATE, ) +from custom_components.span_panel.curation import CurationOverlay from homeassistant.core import HomeAssistant from .factories import ( SpanBatterySnapshotFactory, SpanEvseSnapshotFactory, SpanPanelSnapshotFactory, + pv_binding_for, ) from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -34,6 +38,7 @@ def _make_coordinator(snapshot) -> MagicMock: coordinator = MagicMock() coordinator.data = snapshot coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.last_update_success = True coordinator.config_entry = MockConfigEntry( domain=DOMAIN, @@ -42,6 +47,13 @@ def _make_coordinator(snapshot) -> MagicMock: title="SPAN Panel", unique_id=snapshot.serial_number, ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) coordinator.async_request_refresh = AsyncMock() return coordinator @@ -98,6 +110,45 @@ def test_evse_binary_sensor_reports_unknown_when_panel_offline() -> None: assert entity.is_on is None +def test_grid_islandable_is_created_on_a_v1_panel_with_no_battery() -> None: + """No MID is the answer, not the absence of one. + + Observed on a live upgrade: this entity read `Off` on flat and went + `Unavailable` the moment the panel took v1.0, on an install with no battery. + Nothing about the site changed — v1.0 simply retired the property, and the + gate admitted the entity only when the answer was going to be `True`. + + `devices/bess.md` makes the signal structural: "a MID `grid` child means + premises-segment backup ... neither means no backup". So a panel without one + does not island, and `False` is a reading rather than a default. + """ + snapshot = SpanPanelSnapshotFactory.create(grid_islandable=None, mid=None) + coordinator = _make_coordinator(snapshot) + entity = SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR) + entity.async_write_ha_state = MagicMock() + + entity._handle_coordinator_update() + + assert entity.is_on is False, "no MID means it cannot island, which is an answer" + assert entity.available is True, "and the entity must exist to give it" + + +def test_grid_islandable_reads_the_flat_property_when_the_panel_publishes_one() -> None: + """Flat is unchanged, which is the constraint that matters until the fleet moves. + + A flat panel publishes `core/grid-islandable`, so the value comes from the + panel exactly as it always has, and MID presence is never consulted. + """ + snapshot = SpanPanelSnapshotFactory.create(grid_islandable=True, mid=None) + coordinator = _make_coordinator(snapshot) + entity = SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR) + entity.async_write_ha_state = MagicMock() + + entity._handle_coordinator_update() + + assert entity.is_on is True, "the published value wins over the structural fallback" + + def test_grid_islandable_sensor_uses_online_status_value() -> None: """Non-status binary sensors should mirror their live boolean value when online.""" snapshot = SpanPanelSnapshotFactory.create(grid_islandable=False) @@ -187,7 +238,12 @@ async def test_binary_sensor_async_setup_entry_adds_panel_bess_and_evse_entities ) coordinator = _make_coordinator(snapshot) config_entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") - config_entry.runtime_data = MagicMock(coordinator=coordinator) + config_entry.runtime_data = MagicMock( + control_policy=ControlPolicy.default(), + coordinator=coordinator, + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) async_add_entities = MagicMock() await async_setup_entry(hass, config_entry, async_add_entities) diff --git a/tests/test_button.py b/tests/test_button.py index 521ad6cb..787f6c86 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -2,9 +2,13 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import PublishOutcome, PublishState from span_panel_api.exceptions import SpanPanelServerError from custom_components.span_panel.button import ( @@ -13,18 +17,17 @@ async_setup_entry, ) from custom_components.span_panel.const import DOMAIN -from homeassistant.core import HomeAssistant +from custom_components.span_panel.control_gate import ControlPolicy from .factories import SpanBatterySnapshotFactory, SpanPanelSnapshotFactory -from pytest_homeassistant_custom_component.common import MockConfigEntry - def _make_button_coordinator(snapshot) -> MagicMock: """Create a coordinator-like mock for button tests.""" coordinator = MagicMock() coordinator.data = snapshot coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.config_entry = MockConfigEntry( domain=DOMAIN, data={}, @@ -55,29 +58,34 @@ async def test_gfe_override_button_success_refreshes_coordinator() -> None: @pytest.mark.asyncio -async def test_gfe_override_button_logs_when_client_lacks_support( - caplog: pytest.LogCaptureFixture, -) -> None: - """Buttons should return early when the client cannot publish overrides.""" +async def test_gfe_override_button_refusal_is_raised_at_the_caller() -> None: + """A refused override reaches the person who pressed the button.""" snapshot = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create(connected=False), dominant_power_source="BATTERY", ) coordinator = _make_button_coordinator(snapshot) - coordinator.client = object() + coordinator.client = MagicMock() + coordinator.client.set_dominant_power_source = AsyncMock( + side_effect=SpanPanelServerError("Core node not found in panel topology") + ) button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") + button.hass = MagicMock() - caplog.set_level("WARNING") - - await button.async_press() + with pytest.raises(HomeAssistantError) as raised: + await button.async_press() - assert "Client does not support GFE override" in caplog.text + assert raised.value.translation_key == "gfe_override_failed" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["value"] == "GRID" + assert placeholders["reason"] == "Core node not found in panel topology" coordinator.async_request_refresh.assert_not_awaited() @pytest.mark.asyncio -async def test_gfe_override_button_server_error_creates_notification() -> None: - """Server errors should notify the user that override failed.""" +async def test_gfe_override_button_undelivered_is_raised_at_the_caller() -> None: + """A `FAILED` outcome is the promise this override will not arrive later.""" snapshot = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create(connected=False), dominant_power_source="BATTERY", @@ -85,26 +93,36 @@ async def test_gfe_override_button_server_error_creates_notification() -> None: coordinator = _make_button_coordinator(snapshot) coordinator.client = MagicMock() coordinator.client.set_dominant_power_source = AsyncMock( - side_effect=SpanPanelServerError("unsupported") + return_value=PublishOutcome( + state=PublishState.FAILED, + topic=None, + value="GRID", + detail="broker not connected; refused rather than queued", + ) ) button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") button.hass = MagicMock() - with patch( - "custom_components.span_panel.button.async_create_span_notification", - new=AsyncMock(), - ) as mock_notification: + with pytest.raises(HomeAssistantError) as raised: await button.async_press() - mock_notification.assert_awaited_once() + assert raised.value.translation_key == "gfe_override_not_delivered" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["reason"] == "broker not connected; refused rather than queued" coordinator.async_request_refresh.assert_not_awaited() def test_gfe_override_button_available_only_when_override_is_relevant() -> None: - """Availability should reflect panel state and whether firmware already has control.""" + """Availability should reflect panel state and whether firmware already has control. + + Keyed on `dsm_state` rather than `dominant_power_source`: the guard asks "are we + already on the grid", and that is populated on both wire schemas where the GFE is + not — under v1.0 it is `None`, so the old comparison never fired. + """ snapshot = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create(connected=False), - dominant_power_source="BATTERY", + dsm_state="DSM_OFF_GRID", ) coordinator = _make_button_coordinator(snapshot) button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") @@ -114,43 +132,89 @@ def test_gfe_override_button_available_only_when_override_is_relevant() -> None: coordinator.panel_offline = True assert button.available is False + # BESS reachable: firmware sets the grid state itself, so there is nothing to assert. coordinator.panel_offline = False coordinator.data = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create(connected=True), - dominant_power_source="BATTERY", + dsm_state="DSM_OFF_GRID", ) assert button.available is False + # Already on grid: the assertion would be a no-op, and firmware would reject it. coordinator.data = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create(connected=False), - dominant_power_source="GRID", + dsm_state="DSM_ON_GRID", ) assert button.available is False +@pytest.mark.parametrize( + ("communication_state", "connected", "expected"), + [ + ("OK", False, False), + ("OK", None, False), + ("ok", False, False), + ("UNKNOWN", True, True), + ("UNKNOWN", False, True), + ("LOST", True, True), + ("LOST", None, True), + ("DEGRADED", True, True), + ("DEGRADED", False, True), + (None, True, False), + (None, False, True), + (None, None, True), + ], +) +def test_gfe_override_button_follows_the_battery_communication_state( + communication_state: str | None, connected: bool | None, expected: bool +) -> None: + """The battery's own communication state gates the button when it is published. + + The panel accepts the assertion only while that state is not OK, whatever + `connected` says. `connected` is consulted only when no communication state is + published, and an absent connection status counts as eligible. + """ + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create( + connected=connected, communication_state=communication_state + ), + dsm_state="DSM_OFF_GRID", + ) + coordinator = _make_button_coordinator(snapshot) + button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") + + assert button.available is expected + + +def test_gfe_override_button_stays_unavailable_on_grid_with_comms_lost() -> None: + """A LOST battery link does not override the on-grid check.""" + snapshot = SpanPanelSnapshotFactory.create( + battery=SpanBatterySnapshotFactory.create(communication_state="LOST"), + dsm_state="DSM_ON_GRID", + ) + coordinator = _make_button_coordinator(snapshot) + button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") + + assert button.available is False + + @pytest.mark.asyncio -async def test_button_async_setup_entry_only_adds_button_for_mqtt_with_bess( +async def test_button_async_setup_entry_only_adds_button_when_the_panel_has_bess( hass: HomeAssistant, ) -> None: """Button platform should only expose the override when it can work.""" - - class FakeSpanMqttClient: - """Minimal client type used for isinstance checks.""" - snapshot = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create(connected=False), dominant_power_source="BATTERY", ) coordinator = _make_button_coordinator(snapshot) - coordinator.client = FakeSpanMqttClient() config_entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") - config_entry.runtime_data = MagicMock(coordinator=coordinator) + config_entry.runtime_data = MagicMock( + control_policy=ControlPolicy.default(), coordinator=coordinator, setup_snapshot=coordinator.data + ) async_add_entities = MagicMock() - with patch( - "custom_components.span_panel.button.SpanMqttClient", FakeSpanMqttClient - ): - await async_setup_entry(hass, config_entry, async_add_entities) + await async_setup_entry(hass, config_entry, async_add_entities) entities = async_add_entities.call_args.args[0] assert len(entities) == 1 @@ -161,13 +225,13 @@ class FakeSpanMqttClient: battery=SpanBatterySnapshotFactory.create(soe_percentage=None) ) ) - coordinator_no_bess.client = FakeSpanMqttClient() - config_entry.runtime_data = MagicMock(coordinator=coordinator_no_bess) + config_entry.runtime_data = MagicMock( + control_policy=ControlPolicy.default(), + coordinator=coordinator_no_bess, + setup_snapshot=coordinator_no_bess.data, + ) async_add_entities = MagicMock() - with patch( - "custom_components.span_panel.button.SpanMqttClient", FakeSpanMqttClient - ): - await async_setup_entry(hass, config_entry, async_add_entities) + await async_setup_entry(hass, config_entry, async_add_entities) assert async_add_entities.call_args.args[0] == [] diff --git a/tests/test_circuit_control.py b/tests/test_circuit_control.py index 59ac49f3..17afc46b 100644 --- a/tests/test_circuit_control.py +++ b/tests/test_circuit_control.py @@ -4,13 +4,16 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, PropertyMock +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import pytest +from span_panel_api.exceptions import SpanPanelServerError +from custom_components.span_panel.control_gate import ControlPolicy from custom_components.span_panel.switch import ( SpanPanelCircuitsSwitch, async_setup_entry, ) -from homeassistant.core import HomeAssistant from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory @@ -61,7 +64,9 @@ async def test_switch_creation_for_controllable_circuit(hass: HomeAssistant) -> mock_entry = MagicMock() mock_entry.title = "SPAN Panel" mock_entry.data = {} - mock_entry.runtime_data = MagicMock(coordinator=coordinator) + mock_entry.runtime_data = MagicMock( + control_policy=ControlPolicy.default(), coordinator=coordinator, setup_snapshot=coordinator.data + ) await async_setup_entry(hass, mock_entry, lambda e, **kw: entities.extend(e)) @@ -79,7 +84,7 @@ async def test_switch_turn_on_operation() -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.async_write_ha_state = MagicMock() await switch.async_turn_on() @@ -97,7 +102,7 @@ async def test_switch_turn_off_operation() -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.async_write_ha_state = MagicMock() await switch.async_turn_off() @@ -105,6 +110,66 @@ async def test_switch_turn_off_operation() -> None: assert switch.is_on is False +@pytest.mark.asyncio +async def test_a_refused_relay_command_is_raised_and_leaves_the_switch_alone() -> None: + """The panel declares this circuit's relay non-commandable. + + Nothing is published, so no coordinator update is coming to correct an + optimistic write -- which is why `_async_set_relay` writes its state only + after the publish, and why the refusal has to reach the caller instead of + being logged and swallowed. + """ + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", + name="Kitchen Outlets", + relay_state="CLOSED", + ) + coordinator = _make_coordinator({"1": circuit}) + coordinator.client.set_circuit_relay = AsyncMock( + side_effect=SpanPanelServerError("Circuit '1' declares its relay non-commandable") + ) + + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") + switch.async_write_ha_state = MagicMock() + + with pytest.raises(HomeAssistantError) as raised: + await switch.async_turn_off() + + assert raised.value.translation_key == "circuit_relay_failed" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["circuit"] == "Kitchen Outlets" + assert placeholders["reason"] == "Circuit '1' declares its relay non-commandable" + # The relay never moved, so neither did the switch. + assert switch.is_on is True + switch.async_write_ha_state.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_refusal_names_an_unnamed_circuit_by_its_panel_positions() -> None: + """The identifier the entity was named from, not the wire id.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="abc123", + name="", + relay_state="OPEN", + tabs=[30, 32], + ) + coordinator = _make_coordinator({"abc123": circuit}) + coordinator.client.set_circuit_relay = AsyncMock( + side_effect=SpanPanelServerError("Circuit 'abc123' declares its relay non-commandable") + ) + + switch = SpanPanelCircuitsSwitch(coordinator, "abc123", "SPAN Panel") + switch.async_write_ha_state = MagicMock() + + with pytest.raises(HomeAssistantError) as raised: + await switch.async_turn_on() + + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["circuit"] == "Circuit 30 32" + + def test_switch_state_reflects_relay_state() -> None: """Switch is_on mirrors the circuit relay state.""" closed = SpanCircuitSnapshotFactory.create( @@ -112,7 +177,7 @@ def test_switch_state_reflects_relay_state() -> None: ) coordinator_closed = _make_coordinator({"1": closed}) assert ( - SpanPanelCircuitsSwitch(coordinator_closed, "1", "Kitchen", "SPAN Panel").is_on + SpanPanelCircuitsSwitch(coordinator_closed, "1", "SPAN Panel").is_on is True ) @@ -121,7 +186,7 @@ def test_switch_state_reflects_relay_state() -> None: ) coordinator_open = _make_coordinator({"1": opened}) assert ( - SpanPanelCircuitsSwitch(coordinator_open, "1", "Kitchen", "SPAN Panel").is_on + SpanPanelCircuitsSwitch(coordinator_open, "1", "SPAN Panel").is_on is False ) @@ -131,7 +196,7 @@ def test_switch_handles_missing_circuit() -> None: coordinator = _make_coordinator({}) with pytest.raises(ValueError, match="Circuit 1 not found"): - SpanPanelCircuitsSwitch(coordinator, "1", "Missing Circuit", "SPAN Panel") + SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") def test_switch_coordinator_update_handling(hass: HomeAssistant) -> None: @@ -143,7 +208,7 @@ def test_switch_coordinator_update_handling(hass: HomeAssistant) -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.hass = hass switch.entity_id = "switch.span_panel_kitchen_outlets_breaker" switch.registry_entry = MagicMock() @@ -168,7 +233,7 @@ def test_circuit_name_change_triggers_reload_request(hass: HomeAssistant) -> Non ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen Outlets", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.hass = hass switch.entity_id = "switch.span_panel_kitchen_outlets_breaker" switch.registry_entry = MagicMock() @@ -187,7 +252,7 @@ def test_switch_unavailable_when_panel_offline() -> None: circuit = SpanCircuitSnapshotFactory.create(circuit_id="1") coordinator = _make_coordinator({"1": circuit}, panel_offline=True) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") assert switch.available is False @@ -196,25 +261,10 @@ def test_switch_extra_state_attributes_include_tabs_and_voltage() -> None: circuit = SpanCircuitSnapshotFactory.create(circuit_id="1", tabs=[1]) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") assert switch.extra_state_attributes == {"tabs": "tabs [1]", "voltage": 120} -@pytest.mark.asyncio -async def test_switch_turn_on_without_relay_support_logs_and_returns( - caplog: pytest.LogCaptureFixture, -) -> None: - """Switches should no-op when the client lacks relay control.""" - circuit = SpanCircuitSnapshotFactory.create(circuit_id="1", relay_state="OPEN") - coordinator = _make_coordinator({"1": circuit}) - coordinator.client = MagicMock(spec=[]) - - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") - await switch.async_turn_on() - - assert "Client does not support relay control" in caplog.text - - def test_switch_relay_state_target_shows_pending_state() -> None: """Switch should show the target state while a relay command is pending.""" # Panel reports OPEN but has a pending target of CLOSED @@ -223,7 +273,7 @@ def test_switch_relay_state_target_shows_pending_state() -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") # Target differs from actual — switch should show target (CLOSED = on) assert switch.is_on is True @@ -243,7 +293,7 @@ def test_switch_relay_state_target_none_uses_actual_state() -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") assert switch.is_on is False @@ -294,7 +344,9 @@ async def test_switch_async_setup_entry_filters_supported_circuits( mock_entry = MagicMock() mock_entry.title = "SPAN Panel" mock_entry.data = {} - mock_entry.runtime_data = MagicMock(coordinator=coordinator) + mock_entry.runtime_data = MagicMock( + control_policy=ControlPolicy.default(), coordinator=coordinator, setup_snapshot=coordinator.data + ) await async_setup_entry(hass, mock_entry, lambda e, **kw: entities.extend(e)) @@ -323,7 +375,7 @@ def test_switch_existing_entity_uses_solar_fallback_name(hass: HomeAssistant) -> "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "15", "", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "15", "SPAN Panel") assert switch.name == "Solar Breaker" @@ -331,7 +383,13 @@ def test_switch_existing_entity_uses_solar_fallback_name(hass: HomeAssistant) -> def test_switch_initial_install_uses_circuit_numbers_when_enabled( hass: HomeAssistant, ) -> None: - """Initial switch names should honor the use-circuit-numbers option.""" + """The option decides the id base, and the displayed name is the panel's regardless. + + It used to decide the name too, on a first install only, because the name was + the only thing an id could be composed from. The base is composed from the + option directly now, so the name is free to be the one the panel reports -- + the same string an existing entity has always shown. + """ circuit = SpanCircuitSnapshotFactory.create( circuit_id="2", name="Kitchen", @@ -350,15 +408,21 @@ def test_switch_initial_install_uses_circuit_numbers_when_enabled( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "2", "Kitchen", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") - assert switch.name == "Circuit 2 3 Breaker" + assert switch.name == "Kitchen Breaker" + assert switch.suggested_object_id == "Circuit 2 3 breaker" -def test_switch_initial_install_without_name_lets_ha_default( +def test_switch_initial_install_without_name_falls_back_to_the_tabs( hass: HomeAssistant, ) -> None: - """Unnamed switches on first install should let HA provide the name.""" + """An unnamed circuit is named for its breaker position, not left to HA. + + Letting HA default it gave every unnamed circuit on the panel the same name + and the same id stem, which the registry then disambiguated with `_2`, `_3`, + ... in whatever order the platform happened to add them. + """ circuit = SpanCircuitSnapshotFactory.create( circuit_id="3", name="", @@ -375,9 +439,10 @@ def test_switch_initial_install_without_name_lets_ha_default( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "3", "", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "3", "SPAN Panel") - assert switch._attr_name is None + assert switch.name == "Circuit 3 Breaker" + assert switch.suggested_object_id == "Circuit 3 breaker" assert switch._previous_circuit_name is not None @@ -398,7 +463,7 @@ def test_switch_first_update_requests_reload_without_user_override( "custom_components.span_panel.switch.er.async_get", lambda _hass: init_registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.hass = hass switch.entity_id = "switch.kitchen_breaker" @@ -431,7 +496,7 @@ def test_switch_user_override_skips_reload_on_update(hass: HomeAssistant) -> Non "custom_components.span_panel.switch.er.async_get", lambda _hass: init_registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Kitchen", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") renamed = replace(circuit, name="Renamed Kitchen") coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": renamed}) @@ -471,7 +536,7 @@ def test_switch_update_is_on_clears_state_when_circuit_disappears( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") coordinator.data = SpanPanelSnapshotFactory.create(circuits={}) switch._update_is_on() @@ -479,21 +544,6 @@ def test_switch_update_is_on_clears_state_when_circuit_disappears( assert switch.is_on is None -@pytest.mark.asyncio -async def test_switch_turn_off_without_relay_support_logs_and_returns( - caplog: pytest.LogCaptureFixture, -) -> None: - """Turning off should no-op when the client lacks relay control.""" - circuit = SpanCircuitSnapshotFactory.create(circuit_id="1", relay_state="CLOSED") - coordinator = _make_coordinator({"1": circuit}) - coordinator.client = MagicMock(spec=[]) - - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") - await switch.async_turn_off() - - assert "Client does not support relay control" in caplog.text - - def test_switch_turn_on_off_schedule_async_tasks(hass: HomeAssistant) -> None: """Sync turn_on/turn_off wrappers should schedule async tasks.""" circuit = SpanCircuitSnapshotFactory.create( @@ -509,7 +559,7 @@ def test_switch_turn_on_off_schedule_async_tasks(hass: HomeAssistant) -> None: "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") switch.hass = MagicMock() on_task = object() @@ -535,7 +585,7 @@ def test_switch_relay_state_target_exposed_in_attributes() -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") attrs = switch.extra_state_attributes assert attrs is not None assert attrs["relay_state_target"] == "CLOSED" @@ -551,7 +601,7 @@ def test_switch_relay_state_target_absent_when_none() -> None: ) coordinator = _make_coordinator({"1": circuit}) - switch = SpanPanelCircuitsSwitch(coordinator, "1", "Test Circuit", "SPAN Panel") + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") attrs = switch.extra_state_attributes assert attrs is not None assert "relay_state_target" not in attrs @@ -560,7 +610,12 @@ def test_switch_relay_state_target_absent_when_none() -> None: def test_switch_circuit_numbers_entity_id_stable_after_reload( hass: HomeAssistant, ) -> None: - """Entity_id must stay circuit-based after name sync sets friendly display name.""" + """The base stays circuit-based while the displayed name follows the panel. + + One name path in both modes -- the panel's -- and one id path: the base + handed to Core, which is composed from the naming flag and never from the + name. The two are decoupled, so the display name cannot move an id. + """ circuit = SpanCircuitSnapshotFactory.create( circuit_id="2", name="Air Conditioner", @@ -580,12 +635,10 @@ def test_switch_circuit_numbers_entity_id_stable_after_reload( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch( - coordinator, "2", "Air Conditioner", "SPAN Panel" - ) + switch = SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") - assert switch.name == "Circuit 15 17 Breaker" - assert switch.entity_id == "switch.span_panel_circuit_15_17_breaker" + assert switch.name == "Air Conditioner Breaker" + assert switch.suggested_object_id == "Circuit 15 17 breaker" # --- After reload: entity EXISTS in registry --- with pytest.MonkeyPatch.context() as mp: @@ -597,19 +650,18 @@ def test_switch_circuit_numbers_entity_id_stable_after_reload( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch2 = SpanPanelCircuitsSwitch( - coordinator, "2", "Air Conditioner", "SPAN Panel" - ) + switch2 = SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") - # Entity_id must still be circuit-based - assert switch2.name == "Circuit 15 17 Breaker" - assert switch2.entity_id == "switch.span_panel_circuit_15_17_breaker" + # The panel's name, carried by original_name rather than the registry's + # `name`, which would outrank the base. + assert switch2.name == "Air Conditioner Breaker" + assert switch2.suggested_object_id == "Circuit 15 17 breaker" def test_switch_circuit_numbers_entity_id_120v_single_tab( hass: HomeAssistant, ) -> None: - """120V single-tab circuit should produce entity_id with one tab number.""" + """120V single-tab circuit should produce a base with one tab number.""" circuit = SpanCircuitSnapshotFactory.create( circuit_id="5", name="Kitchen Outlets", @@ -628,18 +680,16 @@ def test_switch_circuit_numbers_entity_id_120v_single_tab( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch( - coordinator, "5", "Kitchen Outlets", "SPAN Panel" - ) + switch = SpanPanelCircuitsSwitch(coordinator, "5", "SPAN Panel") - assert switch.name == "Circuit 10 Breaker" - assert switch.entity_id == "switch.span_panel_circuit_10_breaker" + assert switch.name == "Kitchen Outlets Breaker" + assert switch.suggested_object_id == "Circuit 10 breaker" -def test_switch_circuit_numbers_syncs_friendly_name_to_registry( +def test_switch_circuit_numbers_releases_the_synced_registry_name( hass: HomeAssistant, ) -> None: - """Registry display name should be synced to the panel friendly name.""" + """A name an older release wrote is handed back, so it stops deciding the id.""" circuit = SpanCircuitSnapshotFactory.create( circuit_id="2", name="Air Conditioner", @@ -660,16 +710,16 @@ def test_switch_circuit_numbers_syncs_friendly_name_to_registry( # Use PropertyMock because MagicMock(name=...) sets the mock's # internal label rather than the .name attribute. entity_entry = MagicMock() - type(entity_entry).name = PropertyMock(return_value=None) + type(entity_entry).name = PropertyMock(return_value="Air Conditioner Breaker") registry.async_get.return_value = entity_entry mp.setattr( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - SpanPanelCircuitsSwitch(coordinator, "2", "Air Conditioner", "SPAN Panel") + SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") registry.async_update_entity.assert_called_once_with( - "switch.span_panel_circuit_15_17_breaker", name="Air Conditioner Breaker" + "switch.span_panel_circuit_15_17_breaker", name=None ) @@ -703,15 +753,15 @@ def test_switch_circuit_numbers_preserves_user_custom_name( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - SpanPanelCircuitsSwitch(coordinator, "2", "Air Conditioner", "SPAN Panel") + SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") registry.async_update_entity.assert_not_called() -def test_switch_coordinator_update_circuit_numbers_updates_registry( +def test_switch_coordinator_update_circuit_numbers_requests_reload( hass: HomeAssistant, ) -> None: - """In circuit-numbers mode, a name change should update the registry display name.""" + """A renamed circuit reloads, which is what rebuilds original_name.""" circuit = SpanCircuitSnapshotFactory.create( circuit_id="2", name="Air Conditioner", @@ -736,9 +786,7 @@ def test_switch_coordinator_update_circuit_numbers_updates_registry( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch( - coordinator, "2", "Air Conditioner", "SPAN Panel" - ) + switch = SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") # Simulate a circuit name change from "Air Conditioner" to "Kitchen AC" renamed = replace(circuit, name="Kitchen AC") @@ -750,7 +798,8 @@ def test_switch_coordinator_update_circuit_numbers_updates_registry( with pytest.MonkeyPatch.context() as mp: runtime_registry = MagicMock() runtime_entry = MagicMock() - type(runtime_entry).name = PropertyMock(return_value="Air Conditioner Breaker") + # Released at construction, so nothing occupies the field any more. + type(runtime_entry).name = PropertyMock(return_value=None) runtime_registry.async_get.return_value = runtime_entry mp.setattr( "custom_components.span_panel.switch.er.async_get", @@ -758,10 +807,8 @@ def test_switch_coordinator_update_circuit_numbers_updates_registry( ) switch._handle_coordinator_update() - runtime_registry.async_update_entity.assert_called_once_with( - "switch.span_panel_circuit_15_17_breaker", name="Kitchen AC Breaker" - ) - coordinator.request_reload.assert_not_called() + coordinator.request_reload.assert_called_once() + runtime_registry.async_update_entity.assert_not_called() def test_switch_coordinator_update_circuit_numbers_preserves_user_override( @@ -792,9 +839,7 @@ def test_switch_coordinator_update_circuit_numbers_preserves_user_override( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch( - coordinator, "2", "Air Conditioner", "SPAN Panel" - ) + switch = SpanPanelCircuitsSwitch(coordinator, "2", "SPAN Panel") # Simulate a circuit name change; user has set "My AC Unit" in the registry renamed = replace(circuit, name="Kitchen AC") @@ -845,9 +890,7 @@ def test_switch_coordinator_update_friendly_mode_still_reloads( "custom_components.span_panel.switch.er.async_get", lambda _hass: registry, ) - switch = SpanPanelCircuitsSwitch( - coordinator, "1", "Kitchen Outlets", "SPAN Panel" - ) + switch = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") assert switch._previous_circuit_name == "Kitchen Outlets" diff --git a/tests/test_circuit_manifest_service.py b/tests/test_circuit_manifest_service.py index 922ef680..ac655d13 100644 --- a/tests/test_circuit_manifest_service.py +++ b/tests/test_circuit_manifest_service.py @@ -11,13 +11,14 @@ _async_register_services, ) from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory, pv_binding_for from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -90,7 +91,11 @@ async def test_basic_manifest(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) _register_power_entity( @@ -166,10 +171,18 @@ async def test_multiple_panels(self, hass: HomeAssistant): entry_a.mock_state(hass, ConfigEntryState.LOADED) entry_b.mock_state(hass, ConfigEntryState.LOADED) entry_a.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot_a) + coordinator=_make_coordinator(snapshot_a), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot_a), + setup_snapshot=snapshot_a, ) entry_b.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot_b) + coordinator=_make_coordinator(snapshot_b), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot_b), + setup_snapshot=snapshot_b, ) _register_power_entity( @@ -211,7 +224,11 @@ async def test_unmapped_tabs_excluded(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) _register_power_entity( @@ -254,7 +271,11 @@ async def test_circuit_without_entity_excluded(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) # Only register entity for one circuit @@ -315,7 +336,11 @@ async def test_all_device_types_included(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) _register_power_entity( @@ -361,7 +386,11 @@ async def test_bess_device_type_mapped_to_battery(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) _register_power_entity( @@ -395,7 +424,11 @@ async def test_panel_with_no_resolvable_circuits_omitted(self, hass: HomeAssista entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) # No entities registered @@ -426,7 +459,11 @@ async def test_template_uses_min_tab(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) _register_power_entity( @@ -458,7 +495,11 @@ async def test_host_included_from_config_entry(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) _register_power_entity( diff --git a/tests/test_config_flow_validation.py b/tests/test_config_flow_validation.py index e61e3236..2fb26b15 100644 --- a/tests/test_config_flow_validation.py +++ b/tests/test_config_flow_validation.py @@ -2,16 +2,26 @@ from __future__ import annotations +import dataclasses +import socket import ssl from unittest.mock import AsyncMock, MagicMock, patch import pytest from span_panel_api import DetectionResult, V2AuthResponse, V2StatusInfo -from span_panel_api.exceptions import SpanPanelConnectionError +from span_panel_api.exceptions import ( + SpanPanelConnectionError, + SpanPanelPassphraseUnavailableError, +) from custom_components.span_panel.config_flow_validation import ( + PanelRestTransport, + async_download_ca_or_none, + async_leaf_chains_to_ca, + async_resolve_host, check_fqdn_tls_ready, is_fqdn, + port_or_none, validate_host, validate_v2_passphrase, validate_v2_proximity, @@ -134,30 +144,27 @@ def test_validate_ipv4_and_fqdn_classification() -> None: @pytest.mark.asyncio -async def test_validate_v2_helpers_delegate_register_v2() -> None: - """Passphrase and proximity helpers should delegate to register_v2.""" - hass = MagicMock() - fake_client = MagicMock() - with ( - patch( - "custom_components.span_panel.config_flow_validation.get_async_client", - return_value=fake_client, - ), - patch( - "custom_components.span_panel.config_flow_validation.register_v2", - new=AsyncMock(return_value=MOCK_V2_AUTH), - ) as mock_register, - ): - assert ( - await validate_v2_passphrase( - hass, "panel.example.com", "passphrase", port=8080 - ) - == MOCK_V2_AUTH - ) +async def test_validate_v2_helpers_send_credentials_over_the_given_transport() -> None: + """Both helpers register over the transport they are handed, and only that. + + Neither takes a port or builds a client of its own any more. These are the + two calls that carry the panel passphrase and return the broker password, so + a caller that has nothing pinned cannot reach them at all rather than + silently getting a plaintext one. + """ + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + transport = PanelRestTransport( + port=8443, ssl_context=context, httpx_client=None, ca_pem="-----BEGIN..." + ) + with patch( + "custom_components.span_panel.config_flow_validation.register_v2", + new=AsyncMock(return_value=MOCK_V2_AUTH), + ) as mock_register: assert ( - await validate_v2_proximity(hass, "panel.example.com", port=9090) + await validate_v2_passphrase("panel.example.com", "passphrase", transport) == MOCK_V2_AUTH ) + assert await validate_v2_proximity("panel.example.com", transport) == MOCK_V2_AUTH assert mock_register.await_args_list[0].args == ( "panel.example.com", @@ -165,22 +172,44 @@ async def test_validate_v2_helpers_delegate_register_v2() -> None: "passphrase", ) assert mock_register.await_args_list[0].kwargs == { - "port": 8080, - "httpx_client": fake_client, + "port": 8443, + "httpx_client": None, + "ssl_context": context, } assert mock_register.await_args_list[1].args == ( "panel.example.com", "Home Assistant", ) assert mock_register.await_args_list[1].kwargs == { - "port": 9090, - "httpx_client": fake_client, + "port": 8443, + "httpx_client": None, + "ssl_context": context, } +@pytest.mark.parametrize("password", [None, ""]) @pytest.mark.asyncio -async def test_check_fqdn_tls_ready_returns_false_when_ca_download_fails() -> None: - """TLS readiness should fail fast when the CA certificate cannot be downloaded.""" +async def test_validate_v2_helpers_refuse_a_registration_without_a_broker_password( + password: str | None, +) -> None: + """Both helpers raise rather than hand back a password there is nothing to store for.""" + transport = PanelRestTransport( + port=8443, ssl_context=None, httpx_client=None, ca_pem="-----BEGIN..." + ) + no_password = dataclasses.replace(MOCK_V2_AUTH, ebus_broker_password=password) + with patch( + "custom_components.span_panel.config_flow_validation.register_v2", + new=AsyncMock(return_value=no_password), + ): + with pytest.raises(SpanPanelPassphraseUnavailableError): + await validate_v2_passphrase("panel.example.com", "passphrase", transport) + with pytest.raises(SpanPanelPassphraseUnavailableError): + await validate_v2_proximity("panel.example.com", transport) + + +@pytest.mark.asyncio +async def test_the_ca_fetch_returns_none_when_the_panel_will_not_serve_one() -> None: + """An unpinned caller that cannot fetch a CA has nothing to check against.""" hass = MagicMock() fake_client = MagicMock() with ( @@ -193,7 +222,7 @@ async def test_check_fqdn_tls_ready_returns_false_when_ca_download_fails() -> No side_effect=SpanPanelConnectionError("no cert"), ) as mock_download, ): - assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883) is False + assert await async_download_ca_or_none(hass, "panel.example.com") is None mock_download.assert_awaited_once_with( "panel.example.com", port=80, httpx_client=fake_client @@ -201,8 +230,8 @@ async def test_check_fqdn_tls_ready_returns_false_when_ca_download_fails() -> No @pytest.mark.asyncio -async def test_check_fqdn_tls_ready_forwards_custom_http_port() -> None: - """TLS readiness should forward the provided HTTP port to CA download.""" +async def test_the_ca_fetch_forwards_a_custom_http_port() -> None: + """The unpinned fetch honours the panel's HTTP port.""" hass = MagicMock() fake_client = MagicMock() with ( @@ -215,10 +244,7 @@ async def test_check_fqdn_tls_ready_forwards_custom_http_port() -> None: side_effect=SpanPanelConnectionError("no cert"), ) as mock_download, ): - assert ( - await check_fqdn_tls_ready(hass, "panel.example.com", 8883, http_port=8080) - is False - ) + assert await async_download_ca_or_none(hass, "panel.example.com", http_port=8080) is None mock_download.assert_awaited_once_with( "panel.example.com", port=8080, httpx_client=fake_client @@ -226,8 +252,14 @@ async def test_check_fqdn_tls_ready_forwards_custom_http_port() -> None: @pytest.mark.asyncio -async def test_check_fqdn_tls_ready_returns_true_on_success() -> None: - """TLS readiness should pass when the handshake succeeds.""" +async def test_check_fqdn_tls_ready_uses_the_pinned_anchor_without_refetching() -> None: + """A caller that already holds the anchor is checked against that anchor. + + Refetching would make a fresh trust decision on every poll, against whatever + answered plain HTTP — and anything that can answer it can also serve a leaf + signed by the CA it just handed over, so the poll would report the FQDN + ready on a certificate the pinned anchor does not sign. + """ class FakeLoop: async def run_in_executor(self, _executor, func): @@ -240,46 +272,84 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False - class FakeWrappedSocket(FakeSocket): - pass + def getpeercert(self): + # The handshake now establishes only the chain; the name binding is + # judged separately from the certificate it hands back. + return {"subjectAltName": (("DNS", "panel.example.com"),)} class FakeSSLContext: - def __init__(self, _protocol) -> None: - self.check_hostname = False - self.verify_mode = ssl.CERT_NONE - - def load_verify_locations(self, _path: str) -> None: - return None - def wrap_socket(self, _sock, server_hostname: str): assert server_hostname == "panel.example.com" - return FakeWrappedSocket() + return FakeSocket() - hass = MagicMock() - fake_client = MagicMock() with ( patch( - "custom_components.span_panel.config_flow_validation.get_async_client", - return_value=fake_client, + "custom_components.span_panel.config_flow_validation.download_ca_cert", + new=AsyncMock(), + ) as mock_download, + patch( + "custom_components.span_panel.config_flow_validation.asyncio.get_running_loop", + return_value=FakeLoop(), ), patch( - "custom_components.span_panel.config_flow_validation.download_ca_cert", - new=AsyncMock(return_value="pem-data"), + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=FakeSSLContext(), + ) as build_context, + patch( + "custom_components.span_panel.config_flow_validation.socket.create_connection", + return_value=FakeSocket(), ), + ): + assert await check_fqdn_tls_ready("panel.example.com", 8883, "pinned-pem") is True + + mock_download.assert_not_awaited() + build_context.assert_called_once_with("pinned-pem", check_hostname=False) + + +@pytest.mark.asyncio +async def test_check_fqdn_tls_ready_returns_true_on_success() -> None: + """TLS readiness should pass when the handshake succeeds.""" + + class FakeLoop: + async def run_in_executor(self, _executor, func): + return func() + + class FakeSocket: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def getpeercert(self): + # The handshake now establishes only the chain; the name binding is + # judged separately from the certificate it hands back. + return {"subjectAltName": (("DNS", "panel.example.com"),)} + + class FakeSSLContext: + def wrap_socket(self, _sock, server_hostname: str): + assert server_hostname == "panel.example.com" + return FakeSocket() + + with ( patch( "custom_components.span_panel.config_flow_validation.asyncio.get_running_loop", return_value=FakeLoop(), ), patch( - "custom_components.span_panel.config_flow_validation.ssl.SSLContext", - side_effect=FakeSSLContext, - ), + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=FakeSSLContext(), + ) as build_context, patch( "custom_components.span_panel.config_flow_validation.socket.create_connection", return_value=FakeSocket(), ), ): - assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883) is True + assert await check_fqdn_tls_ready("panel.example.com", 8883, "pem-data") is True + + # The library's builder, not a hand-rolled context: it is the one that clears + # VERIFY_X509_STRICT, which the panel's AKI-less CA fails without. + build_context.assert_called_once_with("pem-data", check_hostname=False) @pytest.mark.asyncio @@ -297,39 +367,168 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False - class FakeSSLContext: - def __init__(self, _protocol) -> None: - self.check_hostname = False - self.verify_mode = ssl.CERT_NONE - - def load_verify_locations(self, _path: str) -> None: - return None + def getpeercert(self): + # The handshake now establishes only the chain; the name binding is + # judged separately from the certificate it hands back. + return {"subjectAltName": (("DNS", "panel.example.com"),)} + class FakeSSLContext: def wrap_socket(self, _sock, server_hostname: str): raise ssl.SSLError(f"bad cert for {server_hostname}") - hass = MagicMock() - fake_client = MagicMock() with ( patch( - "custom_components.span_panel.config_flow_validation.get_async_client", - return_value=fake_client, + "custom_components.span_panel.config_flow_validation.asyncio.get_running_loop", + return_value=FakeLoop(), ), patch( - "custom_components.span_panel.config_flow_validation.download_ca_cert", - new=AsyncMock(return_value="pem-data"), + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=FakeSSLContext(), + ), + patch( + "custom_components.span_panel.config_flow_validation.socket.create_connection", + return_value=FakeSocket(), ), + ): + assert await check_fqdn_tls_ready("panel.example.com", 8883, "pem-data") is False + + +@pytest.mark.asyncio +async def test_leaf_validation_rejects_a_ca_that_does_not_parse() -> None: + """A PEM the ssl module will not load is not an anchor, and is not fatal.""" + + class FakeLoop: + async def run_in_executor(self, _executor, func): + return func() + + with ( + patch( + "custom_components.span_panel.config_flow_validation.asyncio.get_running_loop", + return_value=FakeLoop(), + ), + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + side_effect=ssl.SSLError("not a certificate"), + ), + patch( + "custom_components.span_panel.config_flow_validation.socket.create_connection" + ) as connect, + ): + assert await async_leaf_chains_to_ca("panel.example.com", 443, "junk") is False + + # Nothing is dialled when there is no anchor to dial with. + connect.assert_not_called() + + +def test_port_or_none_reports_an_unreadable_value_as_unknown() -> None: + """A substituted default and a stored value are different answers. + + `as_port` exists for callers that just need a number to dial. This one is + for the caller deciding whether the panel's TLS port is *known*, which must + not be told yes because a fallback was available. + """ + assert port_or_none(8443) == 8443 + assert port_or_none("8443") == 8443 + assert port_or_none(None) is None + assert port_or_none("not-a-port") is None + assert port_or_none("") is None + # `True` is an int in Python and would otherwise read as port 1. + assert port_or_none(True) is None + + +@pytest.mark.asyncio +async def test_resolve_host_returns_an_ip_literal_unchanged() -> None: + """An address needs no resolving, and must not be handed to the resolver.""" + hass = MagicMock() + hass.async_add_executor_job = AsyncMock() + assert await async_resolve_host(hass, "192.168.1.50") == "192.168.1.50" + assert await async_resolve_host(hass, "fd00::1") == "fd00::1" + hass.async_add_executor_job.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_host_returns_the_first_ipv4_answer() -> None: + """Only IPv4, because the library builds URLs without bracketing v6.""" + + async def _run(func): + return func() + + hass = MagicMock() + hass.async_add_executor_job = _run + with patch( + "custom_components.span_panel.config_flow_validation.socket.getaddrinfo", + return_value=[(2, 1, 6, "", ("192.168.1.50", 443))], + ) as resolve: + assert await async_resolve_host(hass, "panel.example.com") == "192.168.1.50" + + assert resolve.call_args.kwargs["family"] == socket.AF_INET + + +@pytest.mark.asyncio +async def test_resolve_host_returns_none_when_the_name_does_not_resolve() -> None: + """The caller falls back to the name it was given, which is what shipped.""" + + async def _run(func): + return func() + + hass = MagicMock() + hass.async_add_executor_job = _run + with patch( + "custom_components.span_panel.config_flow_validation.socket.getaddrinfo", + side_effect=socket.gaierror("no such host"), + ): + assert await async_resolve_host(hass, "panel.example.com") is None + + with patch( + "custom_components.span_panel.config_flow_validation.socket.getaddrinfo", + return_value=[], + ): + assert await async_resolve_host(hass, "panel.example.com") is None + + +@pytest.mark.asyncio +async def test_resolve_host_survives_a_name_that_cannot_be_idna_encoded() -> None: + """A label over 63 characters fails encoding, not lookup, and raises a ValueError. + + `socket.getaddrinfo` raises `UnicodeEncodeError` — a `UnicodeError`, which is + a `ValueError` and not an `OSError` — before it attempts any lookup. A host + the user typed into a form must not be able to end the flow in a traceback. + """ + + async def _run(func): + return func() + + hass = MagicMock() + hass.async_add_executor_job = _run + overlong = "a" * 70 + ".example.com" + with patch( + "custom_components.span_panel.config_flow_validation.socket.getaddrinfo", + side_effect=UnicodeEncodeError("idna", overlong, 0, 70, "label too long"), + ): + assert await async_resolve_host(hass, overlong) is None + + +@pytest.mark.asyncio +async def test_leaf_check_survives_a_name_that_cannot_be_idna_encoded() -> None: + """The leaf check resolves the name too, and fails the same way.""" + + class FakeLoop: + async def run_in_executor(self, _executor, func): + return func() + + overlong = "a" * 70 + ".example.com" + with ( patch( "custom_components.span_panel.config_flow_validation.asyncio.get_running_loop", return_value=FakeLoop(), ), patch( - "custom_components.span_panel.config_flow_validation.ssl.SSLContext", - side_effect=FakeSSLContext, + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), ), patch( "custom_components.span_panel.config_flow_validation.socket.create_connection", - return_value=FakeSocket(), + side_effect=UnicodeEncodeError("idna", overlong, 0, 70, "label too long"), ), ): - assert await check_fqdn_tls_ready(hass, "panel.example.com", 8883) is False + assert await async_leaf_chains_to_ca(overlong, 443, "pem") is False diff --git a/tests/test_control_gate.py b/tests/test_control_gate.py new file mode 100644 index 00000000..e6b68b8e --- /dev/null +++ b/tests/test_control_gate.py @@ -0,0 +1,1157 @@ +"""Tests for the control gate: who may operate the panel, and the record of it. + +These exercise the interceptor directly rather than going through the platforms. +Every existing control test assigns a mock client to `coordinator.client`, and a +mock has no interceptor — so a platform test proves nothing about the gate, and +assuming otherwise would leave the gate untested while looking covered. +""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.core import Context, Event, HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.util import dt as dt_util +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser +from span_panel_api import ControlCommand, PublishOutcome, PublishState + +from custom_components.span_panel.const import DOMAIN, EVENT_CONTROL_COMMAND +from custom_components.span_panel.control_gate import ( + CONTROL_CALLER, + ControlGate, + ControlLock, + ControlMode, + ControlPolicy, + async_bind_caller, + outcome_is_failure, +) +from custom_components.span_panel.migrations import ( + CURRENT_CONFIG_MINOR_VERSION, + CURRENT_CONFIG_VERSION, + async_migrate_entry, +) + +RELAY = ControlCommand( + device_id="sp3-test-001", + node_id="circuit-1", + property_id="relay", + value="CLOSED", + topic="ebus/5/circuit-1/switch/relay/set", +) +PRIORITY = ControlCommand( + device_id="sp3-test-001", + node_id="circuit-1", + property_id="priority", + value="OFF_GRID", + topic="ebus/5/circuit-1/load-shed/priority/set", +) + +CONFIRMED = PublishOutcome(state=PublishState.CONFIRMED, topic=RELAY.topic, value="CLOSED") +VETOED = PublishOutcome( + state=PublishState.FAILED, topic=RELAY.topic, value="CLOSED", detail="vetoed" +) + + +def _entry(hass: HomeAssistant) -> MockConfigEntry: + entry = MockConfigEntry(domain=DOMAIN, title="Span Panel", version=7) + entry.add_to_hass(hass) + return entry + + +def _gate( + hass: HomeAssistant, + *, + mode: ControlMode = ControlMode.ALL_USERS, + allow_contextless: bool = True, + debounce: float = 0.0, + lock: ControlLock | None = None, +) -> ControlGate: + policy = ControlPolicy( + mode=mode, + allow_contextless=allow_contextless, + lock_timeout_minutes=None, + relay_debounce_seconds=debounce, + ) + return ControlGate(hass, _entry(hass), policy, lock or ControlLock()) + + +# ---------- authorization ---------- + + +@pytest.mark.asyncio +async def test_all_users_lets_anyone_through(hass: HomeAssistant) -> None: + """The default reproduces the behaviour every existing entry already has.""" + gate = _gate(hass) + user = MockUser().add_to_hass(hass) + + token = async_bind_caller(Context(user_id=user.id), "switch.span_panel_kitchen") + try: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + +@pytest.mark.asyncio +async def test_admin_only_allows_an_admin(hass: HomeAssistant) -> None: + """An administrator is exactly who admin_only is for.""" + gate = _gate(hass, mode=ControlMode.ADMIN_ONLY) + admin = MockUser(is_owner=True).add_to_hass(hass) + + token = async_bind_caller(Context(user_id=admin.id), "switch.span_panel_kitchen") + try: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + +@pytest.mark.asyncio +async def test_admin_only_refuses_a_non_admin(hass: HomeAssistant) -> None: + """Home Assistant's default user policy grants a non-admin control of everything.""" + gate = _gate(hass, mode=ControlMode.ADMIN_ONLY) + user = MockUser().add_to_hass(hass) + + token = async_bind_caller(Context(user_id=user.id), "switch.span_panel_kitchen") + try: + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + assert err.value.translation_key == "admin_only_control" + + +@pytest.mark.asyncio +async def test_admin_only_refuses_a_user_that_no_longer_resolves( + hass: HomeAssistant, +) -> None: + """A deleted user is not an administrator.""" + gate = _gate(hass, mode=ControlMode.ADMIN_ONLY) + + token = async_bind_caller(Context(user_id="gone"), "switch.span_panel_kitchen") + try: + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + assert err.value.translation_key == "admin_only_control" + + +@pytest.mark.asyncio +async def test_admin_only_does_not_by_itself_refuse_a_contextless_caller( + hass: HomeAssistant, +) -> None: + """The two options are independent, and conflating them would be a surprise. + + `admin_only` is about *which user*; there is no user here to judge. Refusing + an automation is what `allow_contextless_control` is for, and an installation + that tightened one option must not silently get the other. + """ + gate = _gate(hass, mode=ControlMode.ADMIN_ONLY, allow_contextless=True) + + token = async_bind_caller(Context(), "switch.span_panel_kitchen") + try: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + +@pytest.mark.asyncio +async def test_contextless_control_can_be_refused(hass: HomeAssistant) -> None: + """An automation, script or integration calls with no user attached.""" + gate = _gate(hass, allow_contextless=False) + + token = async_bind_caller(Context(parent_id="automation-context"), "switch.x") + try: + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + assert err.value.translation_key == "contextless_control_refused" + + +@pytest.mark.asyncio +async def test_an_unbound_caller_is_treated_as_contextless(hass: HomeAssistant) -> None: + """The fail-closed default, and the reason the gate is not in the helper. + + A future control path that forgets `_async_guarded_control` arrives here with + nothing bound. Permitting it would make the gate a suggestion. + """ + gate = _gate(hass, allow_contextless=False) + + # Deliberately no `async_bind_caller`. + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + + assert err.value.translation_key == "contextless_control_refused" + + +def _gate_for_entry(hass: HomeAssistant, entry: MockConfigEntry) -> ControlGate: + """Build the gate the way setup does, from the entry's own options.""" + return ControlGate(hass, entry, ControlPolicy.from_options(entry.options), ControlLock()) + + +@pytest.mark.asyncio +async def test_a_new_entry_refuses_a_contextless_command(hass: HomeAssistant) -> None: + """An entry created at the current version has made no choice, so it is off.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Span Panel", + version=CURRENT_CONFIG_VERSION, + minor_version=CURRENT_CONFIG_MINOR_VERSION, + ) + entry.add_to_hass(hass) + gate = _gate_for_entry(hass, entry) + + token = async_bind_caller(Context(parent_id="automation-context"), "switch.x") + try: + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + assert err.value.translation_key == "contextless_control_refused" + + +@pytest.mark.asyncio +async def test_a_migrated_entry_still_allows_a_contextless_command( + hass: HomeAssistant, +) -> None: + """An upgrade must not start refusing the automations an install already runs.""" + entry = MockConfigEntry(domain=DOMAIN, title="Span Panel", version=7, minor_version=1) + entry.add_to_hass(hass) + assert await async_migrate_entry(hass, entry) is True + gate = _gate_for_entry(hass, entry) + + token = async_bind_caller(Context(parent_id="automation-context"), "switch.x") + try: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + +# ---------- the control lock ---------- + + +@pytest.mark.asyncio +async def test_an_armed_lock_refuses_every_path(hass: HomeAssistant) -> None: + """Armed means armed, for an administrator as much as for anyone else.""" + lock = ControlLock() + lock.arm() + gate = _gate(hass, lock=lock) + admin = MockUser(is_owner=True).add_to_hass(hass) + + for command in (RELAY, PRIORITY): + token = async_bind_caller(Context(user_id=admin.id), "switch.x") + try: + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(command) + finally: + CONTROL_CALLER.reset(token) + assert err.value.translation_key == "control_locked" + + +def test_a_disarm_with_no_timeout_stays_disarmed() -> None: + """Zero means manual relock, which is a real choice and not "immediately".""" + lock = ControlLock() + lock.arm() + + lock.disarm(0) + + assert lock.armed is False + + +def test_auto_relock_fires_once_the_window_passes() -> None: + """Evaluated on read, so a suspended event loop cannot skip it.""" + lock = ControlLock() + lock.arm() + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=0.0): + lock.disarm(5) + assert lock.armed is False + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=299.0): + assert lock.armed is False + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=301.0): + assert lock.armed is True + + +# ---------- restoring the state a previous run left behind ---------- + + +def test_restoring_a_live_window_resumes_the_remainder() -> None: + """Ten minutes left before the reload is ten minutes left after it. + + The entity converts the deadline to and from wall-clock time; this object + only ever sees what is left of it. + """ + lock = ControlLock() + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=0.0): + lock.resume_disarm(600.0) + assert lock.armed is False + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=599.0): + assert lock.armed is False + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=601.0): + assert lock.armed is True + + +def test_restoring_a_window_that_already_closed_arms_the_lock() -> None: + """The lock was asked to re-arm at a moment that has already passed.""" + lock = ControlLock() + + lock.resume_disarm(-30.0) + + assert lock.armed is True + + +def test_restoring_a_disarm_with_no_window_leaves_it_open() -> None: + """`None` is "there was no countdown", not "the countdown has run out".""" + lock = ControlLock() + lock.arm() + + lock.resume_disarm(None) + + assert lock.armed is False + assert lock.relock_in_seconds is None + + +def test_an_armed_lock_reports_no_pending_window() -> None: + """There is nothing to count down to while control is already locked out.""" + lock = ControlLock() + lock.disarm(5) + assert lock.relock_in_seconds == pytest.approx(300, abs=1) + + lock.arm() + + assert lock.relock_in_seconds is None + + +def test_the_stored_deadline_survives_a_round_trip() -> None: + """What `as_dict` writes is what `from_dict` reads, to the microsecond.""" + from custom_components.span_panel.control_gate import ControlLockExtraStoredData + + deadline = dt_util.utcnow() + timedelta(minutes=7) + + restored = ControlLockExtraStoredData.from_dict( + ControlLockExtraStoredData(relock_at=deadline).as_dict() + ) + + assert restored is not None + assert restored.relock_at == deadline + + +@pytest.mark.parametrize("stored", [{"relock_at": 1234}, {"relock_at": "not-a-time"}]) +def test_an_unreadable_stored_deadline_is_reported_rather_than_guessed( + stored: dict[str, object], +) -> None: + """A hand-edited record says nothing usable, and says so. + + Reported as None rather than as a guessed deadline, because the entity has + to be able to tell "no window was pending" from "the window is unreadable" + — it re-arms for the second and not the first. + """ + from custom_components.span_panel.control_gate import ControlLockExtraStoredData + + assert ControlLockExtraStoredData.from_dict(stored) is None + + +@pytest.mark.parametrize("stored", [{}, {"relock_at": None}]) +def test_an_absent_stored_deadline_reads_as_no_countdown(stored: dict[str, object]) -> None: + """A run that ended with nothing pending recorded exactly that.""" + from custom_components.span_panel.control_gate import ControlLockExtraStoredData + + restored = ControlLockExtraStoredData.from_dict(stored) + + assert restored is not None + assert restored.relock_at is None + + +# ---------- relay debounce ---------- + + +@pytest.mark.asyncio +async def test_a_second_relay_command_inside_the_window_is_refused( + hass: HomeAssistant, +) -> None: + """Refused, not queued: a late relay command against a changed panel is worse.""" + gate = _gate(hass, debounce=2.0) + + with patch( + "custom_components.span_panel.control_gate.time.monotonic", return_value=100.0 + ): + await gate.before_publish(RELAY) + await gate.after_publish(RELAY, CONFIRMED) + + with patch( + "custom_components.span_panel.control_gate.time.monotonic", return_value=101.0 + ), pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + + assert err.value.translation_key == "relay_debounced" + + +@pytest.mark.asyncio +async def test_the_debounce_is_per_circuit(hass: HomeAssistant) -> None: + """Two breakers are two independent physical things.""" + gate = _gate(hass, debounce=2.0) + other = ControlCommand( + device_id=RELAY.device_id, + node_id="circuit-2", + property_id="relay", + value="OPEN", + topic="ebus/5/circuit-2/switch/relay/set", + ) + + with patch( + "custom_components.span_panel.control_gate.time.monotonic", return_value=100.0 + ): + await gate.before_publish(RELAY) + await gate.after_publish(RELAY, CONFIRMED) + await gate.before_publish(other) + await gate.after_publish(other, CONFIRMED) + + +@pytest.mark.asyncio +async def test_the_debounce_does_not_touch_other_controls(hass: HomeAssistant) -> None: + """A priority write is not a relay actuation and has no contactor to protect.""" + gate = _gate(hass, debounce=2.0) + + priority_confirmed = PublishOutcome( + state=PublishState.CONFIRMED, topic=PRIORITY.topic, value="OFF_GRID" + ) + + with patch( + "custom_components.span_panel.control_gate.time.monotonic", return_value=100.0 + ): + await gate.before_publish(PRIORITY) + await gate.after_publish(PRIORITY, priority_confirmed) + await gate.before_publish(PRIORITY) + + +@pytest.mark.asyncio +async def test_a_zero_debounce_disables_it(hass: HomeAssistant) -> None: + """The option has to be switchable off, and zero is how.""" + gate = _gate(hass, debounce=0.0) + + await gate.before_publish(RELAY) + await gate.after_publish(RELAY, CONFIRMED) + await gate.before_publish(RELAY) + + +@pytest.mark.asyncio +async def test_a_command_that_was_never_delivered_does_not_debounce_the_retry( + hass: HomeAssistant, +) -> None: + """The debounce protects a contactor, and a FAILED command never reached one. + + The bridge being down is exactly when a person presses the button again, and + telling them the breaker "was operated less than 2 seconds ago" is both false + and unactionable. + """ + gate = _gate(hass, debounce=2.0) + never_sent = PublishOutcome( + state=PublishState.FAILED, + topic=RELAY.topic, + value="CLOSED", + detail="broker not connected; refused rather than queued", + ) + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=100.0): + await gate.before_publish(RELAY) + await gate.after_publish(RELAY, never_sent) + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=100.5): + await gate.before_publish(RELAY) + + +@pytest.mark.asyncio +async def test_a_delivered_command_debounces_the_next_one(hass: HomeAssistant) -> None: + """The window opens when the panel was actually told to move the relay.""" + gate = _gate(hass, debounce=2.0) + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=100.0): + await gate.before_publish(RELAY) + await gate.after_publish(RELAY, CONFIRMED) + + with ( + patch("custom_components.span_panel.control_gate.time.monotonic", return_value=101.0), + pytest.raises(ServiceValidationError) as err, + ): + await gate.before_publish(RELAY) + + assert err.value.translation_key == "relay_debounced" + + +@pytest.mark.asyncio +async def test_a_refusal_does_not_open_a_debounce_window(hass: HomeAssistant) -> None: + """A vetoed command is a FAILED outcome too, and moved nothing.""" + lock = ControlLock(armed=True) + gate = _gate(hass, debounce=2.0, lock=lock) + + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=100.0): + with pytest.raises(ServiceValidationError): + await gate.before_publish(RELAY) + await gate.after_publish(RELAY, VETOED) + + lock.disarm(0) + with patch("custom_components.span_panel.control_gate.time.monotonic", return_value=100.5): + await gate.before_publish(RELAY) + + +# ---------- disabled ---------- + + +@pytest.mark.asyncio +async def test_a_disabled_entry_refuses_a_command_that_reaches_the_gate( + hass: HomeAssistant, +) -> None: + """`disabled` is a policy, not merely an absence of entities. + + Not creating the entities is what a user sees; it is not a choke point. A + command that arrives by any other route -- a service this integration grows + later, a caller that forgets the helper -- has to be refused here, which is + what the module promises. + """ + gate = _gate(hass, mode=ControlMode.DISABLED) + + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + + assert err.value.translation_key == "control_disabled" + + +@pytest.mark.asyncio +async def test_a_disabled_entry_refuses_an_administrator_too(hass: HomeAssistant) -> None: + """Nobody operates the panel through Home Assistant while control is off.""" + gate = _gate(hass, mode=ControlMode.DISABLED) + admin = MockUser(is_owner=True).add_to_hass(hass) + + token = async_bind_caller(Context(user_id=admin.id), "switch.span_panel_kitchen") + try: + with pytest.raises(ServiceValidationError) as err: + await gate.before_publish(RELAY) + finally: + CONTROL_CALLER.reset(token) + + assert err.value.translation_key == "control_disabled" + + +# ---------- the audit ---------- + + +def _events(hass: HomeAssistant) -> list[Event]: + captured: list[Event] = [] + hass.bus.async_listen(EVENT_CONTROL_COMMAND, captured.append) + return captured + + +@pytest.mark.asyncio +async def test_a_successful_command_is_recorded_with_its_caller( + hass: HomeAssistant, +) -> None: + """The audit needs who, what and how it went.""" + gate = _gate(hass) + captured = _events(hass) + user = MockUser().add_to_hass(hass) + + token = async_bind_caller(Context(user_id=user.id), "switch.span_panel_kitchen") + try: + await gate.after_publish(RELAY, CONFIRMED) + finally: + CONTROL_CALLER.reset(token) + await hass.async_block_till_done() + + assert len(captured) == 1 + data = captured[0].data + assert data["user_id"] == user.id + assert data["entity_id"] == "switch.span_panel_kitchen" + assert data["command"] == "circuit-1/relay" + assert data["value"] == "CLOSED" + assert data["outcome"] == "confirmed" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "state", + [PublishState.CONFIRMED, PublishState.ACCEPTED, PublishState.UNCONFIRMED, PublishState.FAILED], +) +async def test_every_publish_state_reaches_the_audit_intact( + hass: HomeAssistant, state: PublishState +) -> None: + """Four states, not two — each sends an investigation somewhere different.""" + gate = _gate(hass) + captured = _events(hass) + + await gate.after_publish( + RELAY, PublishOutcome(state=state, topic=RELAY.topic, value="CLOSED") + ) + await hass.async_block_till_done() + + assert captured[0].data["outcome"] == state.value + + +@pytest.mark.asyncio +async def test_a_refusal_is_recorded_with_its_reason(hass: HomeAssistant) -> None: + """An audit that omitted the refusals would be worse than no audit. + + The reason survives from `before_publish` to `after_publish` because the + library fires the callback as a task created from the same context. + """ + gate = _gate(hass, mode=ControlMode.ADMIN_ONLY) + captured = _events(hass) + user = MockUser().add_to_hass(hass) + + token = async_bind_caller(Context(user_id=user.id), "switch.span_panel_kitchen") + try: + with pytest.raises(ServiceValidationError): + await gate.before_publish(RELAY) + # The library fires this itself on a veto, with FAILED and "vetoed". + await gate.after_publish(RELAY, VETOED) + finally: + CONTROL_CALLER.reset(token) + await hass.async_block_till_done() + + assert captured[0].data["outcome"] == "refused:admin_only_control" + + +@pytest.mark.asyncio +async def test_a_contextless_command_is_attributed_to_its_parent( + hass: HomeAssistant, +) -> None: + """A write with no user is attributed to *what* rather than left blank.""" + gate = _gate(hass) + captured = _events(hass) + + token = async_bind_caller(Context(parent_id="automation-context"), "switch.x") + try: + await gate.after_publish(RELAY, CONFIRMED) + finally: + CONTROL_CALLER.reset(token) + await hass.async_block_till_done() + + assert captured[0].data["user_id"] is None + assert captured[0].data["parent_id"] == "automation-context" + + +# ---------- outcome classification ---------- + + +@pytest.mark.parametrize( + ("state", "is_failure"), + [ + (PublishState.CONFIRMED, False), + # Handed to the broker and possibly already acted on. Calling either of + # these a failure would tell a user something untrue about their panel. + (PublishState.ACCEPTED, False), + (PublishState.UNCONFIRMED, False), + (PublishState.FAILED, True), + ], +) +def test_only_failed_means_it_will_never_be_delivered( + state: PublishState, is_failure: bool +) -> None: + """`FAILED` is the one state that is a promise about the future.""" + outcome = PublishOutcome(state=state, topic=RELAY.topic, value="CLOSED") + + assert outcome_is_failure(outcome) is is_failure + + +# ---------- policy resolution ---------- + + +def test_an_entry_with_no_control_options_gets_the_defaults() -> None: + """Permissive except for contextless control, which an older entry has stored. + + A silent tightening on upgrade is worse than the status quo, so the one + default that tightens is safe only because migration 7.2 wrote the old + behaviour into every entry from before it; see the two tests above. + """ + policy = ControlPolicy.from_options({}) + + assert policy == ControlPolicy.default() + assert policy.mode is ControlMode.ALL_USERS + assert policy.allow_contextless is False + assert policy.lock_enabled is False + assert policy.relay_debounce_seconds == 2.0 + + +def test_an_unrecognised_mode_falls_back_to_the_permissive_one() -> None: + """A typo in `.storage` must not lock a household out of its own panel.""" + policy = ControlPolicy.from_options({"control_mode": "nonsense"}) + + assert policy.mode is ControlMode.ALL_USERS + + +def test_a_negative_lock_timeout_turns_the_feature_off() -> None: + """Absent and zero are different states, so the option needs a third value.""" + assert ControlPolicy.from_options({"control_lock_timeout": -1}).lock_enabled is False + assert ControlPolicy.from_options({"control_lock_timeout": 0}).lock_enabled is True + + +@pytest.mark.asyncio +async def test_a_disabled_entry_creates_no_control_entities(hass: HomeAssistant) -> None: + """The half of `disabled` this test actually proves: nothing is created. + + That the registry entries *survive* is the other half, and it is asserted + separately below rather than in this docstring — a claim made here and + checked nowhere reads as coverage to anyone auditing by name. + """ + from custom_components.span_panel.switch import async_setup_entry + + from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", is_user_controllable=True + ) + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": circuit}) + coordinator.config_entry = MagicMock() + coordinator.config_entry.options = {} + coordinator.client = AsyncMock() + + entry = MagicMock() + entry.title = "SPAN Panel" + entry.data = {} + entry.runtime_data = MagicMock( + coordinator=coordinator, + setup_snapshot=coordinator.data, + control_policy=ControlPolicy( + mode=ControlMode.DISABLED, + allow_contextless=True, + lock_timeout_minutes=None, + relay_debounce_seconds=2.0, + ), + ) + + added: list[object] = [] + await async_setup_entry(hass, entry, lambda e, **kw: added.extend(e)) + + assert added == [] + + +# ---------- the lock entity's own gate ---------- + + +def _lock_switch(hass: HomeAssistant, lock: ControlLock, timeout: float | None): + """Build the control-lock switch against a mock coordinator.""" + from custom_components.span_panel.switch import SpanPanelControlLockSwitch + + from .factories import SpanPanelSnapshotFactory + + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(circuits={}) + coordinator.config_entry = MagicMock() + coordinator.config_entry.options = {} + + policy = ControlPolicy( + mode=ControlMode.ALL_USERS, + allow_contextless=True, + lock_timeout_minutes=timeout, + relay_debounce_seconds=0.0, + ) + entity = SpanPanelControlLockSwitch(coordinator, lock, policy, "SPAN Panel") + entity.hass = hass + entity.async_write_ha_state = MagicMock() + return entity + + +@pytest.mark.asyncio +async def test_anyone_may_arm_the_lock(hass: HomeAssistant) -> None: + """Making a household safer should not require admin.""" + lock = ControlLock() + entity = _lock_switch(hass, lock, 0) + user = MockUser().add_to_hass(hass) + entity._context = Context(user_id=user.id) + + await entity.async_turn_on() + + assert lock.armed is True + assert entity.is_on is True + + +@pytest.mark.asyncio +async def test_an_automation_may_arm_the_lock(hass: HomeAssistant) -> None: + """Arming is the safe direction, so it is not gated on a user at all.""" + lock = ControlLock() + entity = _lock_switch(hass, lock, 0) + entity._context = Context() + + await entity.async_turn_on() + + assert lock.armed is True + + +@pytest.mark.asyncio +async def test_a_non_admin_cannot_disarm_the_lock(hass: HomeAssistant) -> None: + """A lock a non-admin can disarm is not a lock.""" + lock = ControlLock() + lock.arm() + entity = _lock_switch(hass, lock, 0) + user = MockUser().add_to_hass(hass) + entity._context = Context(user_id=user.id) + + with pytest.raises(ServiceValidationError) as err: + await entity.async_turn_off() + + assert err.value.translation_key == "control_lock_disarm_requires_admin" + assert lock.armed is True + + +@pytest.mark.asyncio +async def test_an_automation_can_never_disarm_the_lock(hass: HomeAssistant) -> None: + """Refused regardless of `allow_contextless_control`. + + An automation that can unlock the panel defeats the purpose of the lock, so + this is not the same decision as whether an automation may operate circuits. + """ + lock = ControlLock() + lock.arm() + entity = _lock_switch(hass, lock, 0) + entity._context = Context() + + with pytest.raises(ServiceValidationError) as err: + await entity.async_turn_off() + + assert err.value.translation_key == "control_lock_disarm_requires_user" + assert lock.armed is True + + +@pytest.mark.asyncio +async def test_an_admin_can_disarm_the_lock(hass: HomeAssistant) -> None: + """Making a household less safe is the direction that needs admin.""" + lock = ControlLock() + lock.arm() + entity = _lock_switch(hass, lock, 0) + admin = MockUser(is_owner=True).add_to_hass(hass) + entity._context = Context(user_id=admin.id) + + await entity.async_turn_off() + + assert lock.armed is False + + +# ---------- optimistic state ---------- + + +@pytest.mark.asyncio +async def test_a_refused_relay_leaves_the_switch_where_it_was( + hass: HomeAssistant, +) -> None: + """A gate refusal must not leave the UI showing a position the relay never took. + + Nothing changed on the panel, so no coordinator update is coming to correct + an optimistic write — it would simply stay wrong. + """ + from custom_components.span_panel.switch import SpanPanelCircuitsSwitch + + from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", relay_state="OPEN", is_user_controllable=True + ) + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": circuit}) + coordinator.config_entry = MagicMock() + coordinator.config_entry.options = {} + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.client = MagicMock() + coordinator.client.set_circuit_relay = AsyncMock( + side_effect=ServiceValidationError("refused") + ) + + entity = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") + entity.hass = hass + entity.async_write_ha_state = MagicMock() + before = entity.is_on + + with pytest.raises(ServiceValidationError): + await entity.async_turn_on() + + assert entity.is_on == before + entity.async_write_ha_state.assert_not_called() + + +@pytest.mark.asyncio +async def test_an_undelivered_relay_command_does_not_move_the_switch( + hass: HomeAssistant, +) -> None: + """`FAILED` is the one outcome that promises the command will never arrive. + + Raised, not logged. It means the same thing to the person who pressed the + switch as a refusal does -- the command did not happen and will not happen + later -- and it is worded as the different fact it is: this one resolved an + address and was never handed to the broker. + """ + from custom_components.span_panel.switch import SpanPanelCircuitsSwitch + + from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", relay_state="OPEN", is_user_controllable=True + ) + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": circuit}) + coordinator.config_entry = MagicMock() + coordinator.config_entry.options = {} + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.client = MagicMock() + coordinator.client.set_circuit_relay = AsyncMock( + return_value=PublishOutcome( + state=PublishState.FAILED, + topic=RELAY.topic, + value="CLOSED", + detail="broker not connected; refused rather than queued", + ) + ) + + entity = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") + entity.hass = hass + entity.async_write_ha_state = MagicMock() + before = entity.is_on + + with pytest.raises(HomeAssistantError) as raised: + await entity.async_turn_on() + + assert raised.value.translation_key == "circuit_relay_not_delivered" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["reason"] == "broker not connected; refused rather than queued" + # The optimistic write is last, so the raise cannot have reached it. + assert entity.is_on == before + entity.async_write_ha_state.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("state", [PublishState.ACCEPTED, PublishState.UNCONFIRMED]) +async def test_a_handed_over_relay_command_still_shows_the_requested_state( + hass: HomeAssistant, state: PublishState +) -> None: + """Neither is a failure, and neither may discard what the user asked for. + + `UNCONFIRMED` most often means the relay was already in that position, and + `ACCEPTED` means the broker took the message. Both were handed over and may + already have been acted on, so both keep the optimistic write that `FAILED` + and a refusal are denied. + """ + from custom_components.span_panel.switch import SpanPanelCircuitsSwitch + + from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", relay_state="OPEN", is_user_controllable=True + ) + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": circuit}) + coordinator.config_entry = MagicMock() + coordinator.config_entry.options = {} + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + coordinator.client = MagicMock() + coordinator.client.set_circuit_relay = AsyncMock( + return_value=PublishOutcome( + state=state, + topic=RELAY.topic, + value="CLOSED", + no_op=state is PublishState.UNCONFIRMED, + detail="the property already reports this value", + ) + ) + + entity = SpanPanelCircuitsSwitch(coordinator, "1", "SPAN Panel") + entity.hass = hass + entity.async_write_ha_state = MagicMock() + + await entity.async_turn_on() + + assert entity.is_on is True + entity.async_write_ha_state.assert_called_once() + + +# ---------- the logbook rendering ---------- + + +def _describe(hass: HomeAssistant, data: dict[str, object]) -> dict[str, str]: + """Run the registered describer over one event's data.""" + from custom_components.span_panel.logbook import async_describe_events + + described: dict[str, object] = {} + + def register(domain: str, event: str, describer: object) -> None: + described["fn"] = describer + + async_describe_events(hass, register) # type: ignore[arg-type] + describer = described["fn"] + assert callable(describer) + return describer(Event(EVENT_CONTROL_COMMAND, data)) + + +@pytest.mark.asyncio +async def test_the_logbook_names_the_user_and_the_outcome(hass: HomeAssistant) -> None: + """A logbook line has to say who and how it went, or it is not worth writing.""" + described = _describe( + hass, + { + "user_id": "abc123", + "parent_id": None, + "entity_id": "switch.span_panel_kitchen", + "command": "circuit-1/relay", + "value": "CLOSED", + "outcome": "confirmed", + }, + ) + + assert "circuit-1/relay" in described["message"] + assert "abc123" in described["message"] + assert "confirmed it" in described["message"] + assert described["entity_id"] == "switch.span_panel_kitchen" + + +@pytest.mark.asyncio +async def test_the_logbook_does_not_call_unconfirmed_a_failure( + hass: HomeAssistant, +) -> None: + """The distinction is the reason `PublishState` has four members.""" + described = _describe( + hass, + { + "user_id": None, + "parent_id": None, + "command": "circuit-1/relay", + "value": "CLOSED", + "outcome": "unconfirmed", + }, + ) + + assert "fail" not in described["message"].lower() + assert "no acknowledgement" in described["message"] + + +@pytest.mark.asyncio +async def test_the_logbook_explains_a_refusal(hass: HomeAssistant) -> None: + """A refused command that read as a plain failure would send someone hunting.""" + described = _describe( + hass, + { + "user_id": None, + "parent_id": None, + "command": "circuit-1/relay", + "value": "CLOSED", + "outcome": "refused:relay_debounced", + }, + ) + + assert "operated moments ago" in described["message"] + + +@pytest.mark.asyncio +async def test_the_logbook_explains_a_refusal_under_disabled(hass: HomeAssistant) -> None: + """Every reason the gate can refuse for has a sentence, or the audit is a code.""" + described = _describe( + hass, + { + "user_id": None, + "parent_id": None, + "command": "circuit-1/relay", + "value": "CLOSED", + "outcome": "refused:control_disabled", + }, + ) + + assert "switched off" in described["message"] + + +# ---------- `disabled` keeps the entity_ids it stops creating ---------- + + +@pytest.mark.asyncio +async def test_disabling_control_leaves_the_registry_entries_alone( + hass: HomeAssistant, +) -> None: + """The permanence half of §5.6, which the creation test does not reach. + + `entity_id` is permanent in this integration, so `disabled` had to be a mode + that stops *creating* controls rather than one that removes them. The + difference is invisible from the created-entity list — both spellings add + nothing — and shows up only in what the registry still holds afterwards. + + Asserted through a customised entry rather than a bare one because the cost + of getting this wrong is not the identifier alone: a user's name, area and + icon travel with the registry entry, and a removed entry loses them even if + the same `entity_id` is later reissued. + """ + from homeassistant.helpers import entity_registry as er + + from custom_components.span_panel.entity_resolver import ( + build_switch_unique_id_for_entry, + ) + from custom_components.span_panel.switch import async_setup_entry + + from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen", is_user_controllable=True + ) + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"1": circuit}) + coordinator.config_entry = MagicMock() + coordinator.config_entry.options = {} + coordinator.client = AsyncMock() + + config_entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") + config_entry.add_to_hass(hass) + + unique_id = build_switch_unique_id_for_entry( + coordinator, coordinator.data, "1", "SPAN Panel" + ) + + registry = er.async_get(hass) + existing = registry.async_get_or_create( + "switch", + DOMAIN, + unique_id, + suggested_object_id="span_panel_kitchen_breaker", + config_entry=config_entry, + ) + registry.async_update_entity( + existing.entity_id, name="Kitchen Breaker", icon="mdi:toaster" + ) + + entry = MagicMock() + entry.title = "SPAN Panel" + entry.data = {} + entry.entry_id = config_entry.entry_id + entry.runtime_data = MagicMock( + coordinator=coordinator, + setup_snapshot=coordinator.data, + control_policy=ControlPolicy( + mode=ControlMode.DISABLED, + allow_contextless=True, + lock_timeout_minutes=None, + relay_debounce_seconds=2.0, + ), + ) + + added: list[object] = [] + await async_setup_entry(hass, entry, lambda e, **kw: added.extend(e)) + + assert added == [] + + survivor = registry.async_get(existing.entity_id) + assert survivor is not None, ( + "`disabled` removed the registry entry for a control it merely stopped " + "creating. The entity_id is now free for anything else to claim, and " + "re-enabling control would reissue it with a _2 suffix at best." + ) + assert registry.async_get_entity_id("switch", DOMAIN, unique_id) == existing.entity_id + assert survivor.name == "Kitchen Breaker" + assert survivor.icon == "mdi:toaster" diff --git a/tests/test_control_lock_state.py b/tests/test_control_lock_state.py new file mode 100644 index 00000000..a747395a --- /dev/null +++ b/tests/test_control_lock_state.py @@ -0,0 +1,371 @@ +"""The control lock's state across a reload, and what it is before there is one. + +Separate from `test_control_gate.py` because these go through a real config +entry setup and a real `async_reload`. The gate tests construct the switch +against a mock coordinator and never add it to Home Assistant, so they cannot +see the entity lifecycle at all -- and the lifecycle is the whole subject here: +`ControlLock` is rebuilt by every `async_setup_entry`, and the only thing that +knows what the last run left behind is the entity. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +import contextlib +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON, Platform +from homeassistant.core import Context, HomeAssistant, State +from homeassistant.util import dt as dt_util +import pytest +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + MockUser, + mock_restore_cache_with_extra_data, +) + +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_HTTP_PORT, + DOMAIN, +) +from custom_components.span_panel.control_gate import ControlLock +from custom_components.span_panel.migrations import ( + CURRENT_CONFIG_MINOR_VERSION, + CURRENT_CONFIG_VERSION, +) +from custom_components.span_panel.options import CONTROL_LOCK_TIMEOUT + +from .common import async_fire_time_changed +from .factories import SpanPanelSnapshotFactory + +LOCK_ENTITY = "switch.span_panel_control_lock" +SERIAL = "sp3-lock-001" + + +def _entry(timeout: float) -> MockConfigEntry: + """Build a v2 entry with the control-lock feature configured to `timeout`.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_VERSION: "v2", + CONF_HOST: "192.168.1.50", + CONF_EBUS_BROKER_HOST: "span-panel.local", + CONF_EBUS_BROKER_USERNAME: "mqtt-user", + CONF_EBUS_BROKER_PASSWORD: "hunter2-synthetic", + CONF_EBUS_BROKER_PORT: 8883, + CONF_HTTP_PORT: 80, + }, + options={CONTROL_LOCK_TIMEOUT: timeout}, + entry_id="entry-control-lock", + title="SPAN Panel", + unique_id=SERIAL, + version=CURRENT_CONFIG_VERSION, + minor_version=CURRENT_CONFIG_MINOR_VERSION, + ) + + +@contextlib.asynccontextmanager +async def _panel(hass: HomeAssistant, *, timeout: float) -> AsyncIterator[MockConfigEntry]: + """Set the entry up for real, with only the switch platform forwarded. + + The transport and the coordinator are stubs -- neither has anything to do + with the lock -- but the entry setup, the platform forward, the entity + registry and the unload path are all the real ones, because a reload is + exactly those four things. + """ + entry = _entry(timeout) + entry.add_to_hass(hass) + + snapshot = SpanPanelSnapshotFactory.create(serial_number=SERIAL, circuits={}) + client = MagicMock() + client.connect = AsyncMock() + client.close = AsyncMock() + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.transport_dead = False + coordinator.current_monitor = None + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.async_shutdown = AsyncMock() + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch("custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch("custom_components.span_panel.PLATFORMS", [Platform.SWITCH]), + ): + assert await hass.config_entries.async_setup(entry.entry_id) is True + await hass.async_block_till_done() + try: + yield entry + finally: + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +def _lock(entry: MockConfigEntry) -> ControlLock: + """Return the live lock object this entry's gate consults.""" + lock = entry.runtime_data.control_lock + assert isinstance(lock, ControlLock) + return lock + + +async def _reload(hass: HomeAssistant, entry: MockConfigEntry) -> None: + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + +async def _arm(hass: HomeAssistant) -> None: + """Arm through the service, so the entity writes its own state.""" + await hass.services.async_call( + "switch", "turn_on", {"entity_id": LOCK_ENTITY}, blocking=True + ) + + +async def _disarm(hass: HomeAssistant) -> None: + """Disarm through the service as an administrator, the only caller allowed to.""" + admin = MockUser(is_owner=True).add_to_hass(hass) + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": LOCK_ENTITY}, + blocking=True, + context=Context(user_id=admin.id), + ) + + +# ---------- across a reload ---------- + + +@pytest.mark.asyncio +async def test_an_armed_lock_is_still_armed_after_a_reload(hass: HomeAssistant) -> None: + """A reload is not consent to operate the panel. + + Every options save, every `rotate_credentials`, every circuit rename that + asks for a reload and every Home Assistant restart runs `async_setup_entry` + again. A lock that came back disarmed would have the household's second + factor removed by routine maintenance, with the switch showing off and no + notice that anything had changed. + """ + async with _panel(hass, timeout=0) as entry: + await _arm(hass) + assert _lock(entry).armed is True + + await _reload(hass, entry) + + assert _lock(entry).armed is True + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_ON + + +@pytest.mark.asyncio +async def test_a_disarmed_lock_is_still_disarmed_after_a_reload(hass: HomeAssistant) -> None: + """The restore has to carry both answers, not only the safe one. + + Re-arming on every reload would be its own defect: an options save would + lock a household out of its own circuits mid-task, and the alarm-panel + metaphor only works if the disarmed state is as durable as the armed one. + """ + async with _panel(hass, timeout=0) as entry: + await _disarm(hass) + assert _lock(entry).armed is False + + await _reload(hass, entry) + + assert _lock(entry).armed is False + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_OFF + + +@pytest.mark.asyncio +async def test_a_pending_auto_relock_survives_a_reload(hass: HomeAssistant) -> None: + """A reload must not drop the countdown, nor turn it into an immediate re-arm.""" + async with _panel(hass, timeout=30) as entry: + await _disarm(hass) + assert _lock(entry).relock_in_seconds == pytest.approx(1800, abs=5) + + await _reload(hass, entry) + + assert _lock(entry).armed is False + assert _lock(entry).relock_in_seconds == pytest.approx(1800, abs=5) + + +# ---------- the state before there is one to restore ---------- + + +@pytest.mark.asyncio +async def test_the_lock_starts_armed_when_the_feature_is_first_enabled( + hass: HomeAssistant, +) -> None: + """The option text promises an armed lock, and nothing else can deliver it. + + "0 keeps the lock armed until someone disarms it" is only true if turning + the feature on arms it. A lock that arrives disarmed protects nothing until + the user notices it is off and arms it by hand -- and the user turned the + option on precisely because they expected not to have to. + """ + async with _panel(hass, timeout=0) as entry: + assert _lock(entry).armed is True + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_ON + + +# ---------- what a restart does to a countdown ---------- + + +@pytest.mark.asyncio +async def test_a_restart_resumes_the_remaining_window_rather_than_a_fresh_one( + hass: HomeAssistant, +) -> None: + """Ten minutes left before the restart is ten minutes left after it. + + `_relock_at` is a monotonic reading and cannot outlive the process, so the + entity stores the deadline as a wall-clock instant and the restore converts + it back. Restarting the full window instead would let an entry that saves + its options every few minutes hold the lock open indefinitely, which is the + opposite of what `ControlLock.armed` documents. + """ + deadline = dt_util.utcnow() + timedelta(minutes=10) + mock_restore_cache_with_extra_data( + hass, + ((State(LOCK_ENTITY, STATE_OFF), {"relock_at": deadline.isoformat()}),), + ) + + async with _panel(hass, timeout=30) as entry: + assert _lock(entry).armed is False + assert _lock(entry).relock_in_seconds == pytest.approx(600, abs=5) + + +@pytest.mark.asyncio +async def test_a_window_that_closed_while_home_assistant_was_down_comes_back_armed( + hass: HomeAssistant, +) -> None: + """The lock was asked to re-arm at a moment that has already passed.""" + deadline = dt_util.utcnow() - timedelta(minutes=1) + mock_restore_cache_with_extra_data( + hass, + ((State(LOCK_ENTITY, STATE_OFF), {"relock_at": deadline.isoformat()}),), + ) + + async with _panel(hass, timeout=30) as entry: + assert _lock(entry).armed is True + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_ON + + +@pytest.mark.asyncio +async def test_a_disarm_with_no_readable_deadline_comes_back_armed( + hass: HomeAssistant, +) -> None: + """An entry that auto-relocks cannot restore a window it cannot read. + + A record written before this release, or edited by hand, says the lock was + open and says nothing about how much of its window was left. Granting a + fresh one would hand back more open time than the previous run had, on no + evidence at all — and a restart is not consent to operate the panel. + """ + mock_restore_cache_with_extra_data( + hass, + ((State(LOCK_ENTITY, STATE_OFF), {"relock_at": "not-a-time"}),), + ) + + async with _panel(hass, timeout=30) as entry: + assert _lock(entry).armed is True + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_ON + + +@pytest.mark.asyncio +async def test_a_disarm_with_no_countdown_configured_stays_open( + hass: HomeAssistant, +) -> None: + """A zero timeout has no window to lose, so there is nothing to fail closed on. + + "0 keeps the lock disarmed until someone arms it" is the option's own + promise, and re-arming across a restart would break it for every entry that + never asked for a countdown. + """ + mock_restore_cache_with_extra_data( + hass, + ((State(LOCK_ENTITY, STATE_OFF), {}),), + ) + + async with _panel(hass, timeout=0) as entry: + assert _lock(entry).armed is False + assert _lock(entry).relock_in_seconds is None + + +# ---------- the auto-relock is visible when it happens ---------- + + +@pytest.mark.asyncio +async def test_the_auto_relock_writes_the_state_when_it_falls_due( + hass: HomeAssistant, +) -> None: + """Otherwise the switch shows off until something else happens to read it. + + Auto-relock was evaluated only on read, and nothing reads a switch on a + schedule. The panel was locked and the UI said it was not -- for hours, if + nothing touched the entity. + """ + async with _panel(hass, timeout=1) as entry: + await _disarm(hass) + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_OFF + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=90)) + await hass.async_block_till_done() + + assert _lock(entry).armed is True + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_ON + + +# ---------- a dead transport does not take the lock with it ---------- + + +@pytest.mark.asyncio +async def test_the_lock_stays_operable_when_the_transport_dies(hass: HomeAssistant) -> None: + """Arming is local, so it is answerable with no panel at the other end. + + Every other control goes unavailable once the transport is gone, and should: + they report something read from the panel. This one reports the state of an + in-process object the interceptor consults, and the moment a transport dies + is a plausible moment to want the lock shut before it comes back. + + Inheriting the base class's transport probe cost more than a greyed-out + tile: core's entity-service helper skips an unavailable entity outright, so + `switch.turn_on` was dropped without an error and the lock stayed open. That + is what this asserts — the arm lands, not merely that the state renders. + """ + async with _panel(hass, timeout=0) as entry: + await _disarm(hass) + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_OFF + + entry.runtime_data.coordinator.transport_dead = True + + await _arm(hass) + + state = hass.states.get(LOCK_ENTITY) + assert state is not None + assert state.state == STATE_ON + assert _lock(entry).armed is True diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 48427766..38d5fb43 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -2,11 +2,26 @@ from __future__ import annotations +from dataclasses import replace import logging from typing import cast from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import ( + AdoptedDevice, + AdoptedProperty, + ExtensionProperty, + ExtensionSubject, + SpanMqttClient, +) from span_panel_api.exceptions import ( SpanPanelAPIError, SpanPanelAuthError, @@ -14,24 +29,25 @@ SpanPanelServerError, SpanPanelTimeoutError, ) +from span_panel_api.models import FieldMetadata +from span_panel_api.protocol import SpanPanelClientProtocol from custom_components.span_panel.coordinator import SpanPanelCoordinator -from homeassistant.core import HomeAssistant -from span_panel_api import SpanMqttClient -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, +from custom_components.span_panel.helpers import ( + adopted_capability_tokens, + circuit_has_a_breaker_switch, + circuit_has_a_priority_select, + detect_capabilities, ) +from .adapter_fixtures import schema_one_snapshot from .factories import ( SpanBatterySnapshotFactory, + SpanCircuitSnapshotFactory, SpanEvseSnapshotFactory, SpanPanelSnapshotFactory, ) -from pytest_homeassistant_custom_component.common import MockConfigEntry - def _create_coordinator( hass: HomeAssistant, @@ -117,8 +133,17 @@ async def test_run_post_update_tasks_validates_once_and_schedules_reload( snapshot = SpanPanelSnapshotFactory.create() coordinator._reload_requested = True + # The real `_run_schema_validation` sets the guard itself, and only once it + # has actually produced findings; the stand-in has to emulate that or the + # second pass would legitimately retry. `_a_none_first_pass...` below covers + # the retry side. + def _succeed() -> None: + coordinator._schema_validated = True + with ( - patch.object(coordinator, "_run_schema_validation") as mock_validate, + patch.object( + coordinator, "_run_schema_validation", side_effect=_succeed + ) as mock_validate, patch.object(coordinator, "_fire_dip_notification", AsyncMock()) as mock_notify, patch.object(coordinator, "_async_reload_task", AsyncMock()) as mock_reload, patch.object(hass, "async_create_task") as mock_create_task, @@ -230,61 +255,62 @@ async def test_on_snapshot_push_updates_state_and_runs_post_tasks( async def test_run_schema_validation_skips_without_metadata( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: - """Schema validation should skip cleanly when no metadata is available.""" - - class FakeSpanMqttClient: - field_metadata = None - - client = FakeSpanMqttClient() + """No metadata means "unknown", so the pass must leave findings untouched. + + `field_metadata` is None for the whole _on_pre_rebuild -> retained-message + window, which an ordinary reconnect opens. Producing empty findings here + would read as "every issue is resolved" to the Repairs reconciler. + """ + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = None coordinator = _create_coordinator(hass, client=client) caplog.set_level(logging.DEBUG) - - with patch( - "custom_components.span_panel.coordinator.SpanMqttClient", - FakeSpanMqttClient, - ): - coordinator._run_schema_validation() + coordinator._run_schema_validation() assert "Schema validation skipped" in caplog.text + assert coordinator.schema_findings is None + assert coordinator.unresolved_paths == frozenset() -async def test_run_schema_validation_validates_field_metadata( +async def test_run_schema_validation_preserves_prior_findings( hass: HomeAssistant, ) -> None: - """Schema validation should pass field metadata to the validator.""" + """A later pass without metadata must not erase what an earlier one found.""" + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = { + "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False) + } + coordinator = _create_coordinator(hass, client=client) - class FakeField: - def __init__(self, unit: str, datatype: str) -> None: - self.unit = unit - self.datatype = datatype + coordinator._run_schema_validation() + assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"}) - class FakeSpanMqttClient: - field_metadata = {"instantPowerW": FakeField("W", "number")} + client.field_metadata = None + coordinator._run_schema_validation() - client = FakeSpanMqttClient() + assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"}) + + +async def test_run_schema_validation_reads_metadata_through_the_protocol( + hass: HomeAssistant, +) -> None: + """The unit cross-check must key sensor definitions by field path. + + Not `circuit.instant_power_w`/"kW": that pair is in + `KNOWN_BAD_SCHEMA_UNITS`, so it would prove the exception, not the wiring. + """ + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = {"panel.l1_voltage": FieldMetadata("kV", "float")} coordinator = _create_coordinator(hass, client=client) - with ( - patch( - "custom_components.span_panel.coordinator.SpanMqttClient", - FakeSpanMqttClient, - ), - patch( - "custom_components.span_panel.coordinator.collect_sensor_definitions", - return_value={"sensor_defs": "ok"}, - ) as mock_collect, - patch( - "custom_components.span_panel.coordinator.validate_field_metadata" - ) as mock_validate, - ): - coordinator._run_schema_validation() + coordinator._run_schema_validation() - mock_collect.assert_called_once() - mock_validate.assert_called_once_with( - {"instantPowerW": {"unit": "W", "datatype": "number"}}, - sensor_defs={"sensor_defs": "ok"}, - ) + findings = coordinator.schema_findings + assert findings is not None + assert [m.field_path for m in findings.unit_mismatches] == ["panel.l1_voltage"] + assert findings.unit_mismatches[0].schema_unit == "kV" + assert findings.unit_mismatches[0].ha_unit == "V" @pytest.mark.parametrize( @@ -432,9 +458,11 @@ async def test_on_connection_change_false_flips_offline_and_notifies_listeners( coordinator = _create_coordinator(hass) assert coordinator.panel_offline is False - with patch.object(coordinator, "async_update_listeners") as notify: - with caplog.at_level(logging.INFO): - coordinator._on_connection_change(False) + with ( + patch.object(coordinator, "async_update_listeners") as notify, + caplog.at_level(logging.INFO), + ): + coordinator._on_connection_change(False) assert coordinator.panel_offline is True notify.assert_called_once_with() @@ -451,9 +479,11 @@ async def test_on_connection_change_true_clears_offline_and_notifies_listeners( coordinator = _create_coordinator(hass) coordinator._panel_offline = True - with patch.object(coordinator, "async_update_listeners") as notify: - with caplog.at_level(logging.INFO): - coordinator._on_connection_change(True) + with ( + patch.object(coordinator, "async_update_listeners") as notify, + caplog.at_level(logging.INFO), + ): + coordinator._on_connection_change(True) assert coordinator.panel_offline is False notify.assert_called_once_with() @@ -507,3 +537,574 @@ async def test_async_update_data_stale_data_error_marks_offline_and_returns_last "is unavailable" in r.message and "MQTT broker disconnected" in r.message for r in caplog.records ) + + +async def test_entities_register_themselves_against_the_field_they_read( + hass: HomeAssistant, +) -> None: + """The affected-entity map is recorded by entities, never derived from them. + + Three unique_id builders are in play and they disagree, so reconstructing + entity ids from entity descriptions reported "0 affected" for most fields. + tests/test_schema_repairs.py drives real entities through a real platform; + this pins the coordinator side of the contract. + """ + coordinator = _create_coordinator(hass) + + coordinator.async_register_field_path_entity("panel.door_state", "binary_sensor.door") + coordinator.async_register_field_path_entity("circuit.instant_power_w", "sensor.b") + coordinator.async_register_field_path_entity("circuit.instant_power_w", "sensor.a") + coordinator.async_register_field_path_entity("circuit.instant_power_w", "sensor.a") + + assert coordinator.entity_ids_by_field_path == { + "panel.door_state": ["binary_sensor.door"], + "circuit.instant_power_w": ["sensor.a", "sensor.b"], + } + + coordinator.async_unregister_field_path_entity("circuit.instant_power_w", "sensor.a") + coordinator.async_unregister_field_path_entity("panel.door_state", "binary_sensor.door") + # Unknown pairs are ignored rather than raising: removal can outlive setup. + coordinator.async_unregister_field_path_entity("panel.door_state", "binary_sensor.gone") + + assert coordinator.entity_ids_by_field_path == { + "circuit.instant_power_w": ["sensor.b"] + } + + +async def test_sync_schema_repairs_raises_a_repair_naming_the_dead_entities( + hass: HomeAssistant, +) -> None: + """The whole point of the feature: a dead field names the sensors it killed.""" + from homeassistant.helpers import issue_registry as ir + + entry = MockConfigEntry(domain="span_panel", entry_id="entry-affected") + entry.add_to_hass(hass) + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = { + "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False) + } + coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry) + coordinator.async_register_field_path_entity( + "circuit.instant_power_w", "sensor.span_panel_kitchen_power" + ) + + coordinator._run_schema_validation() + coordinator.async_sync_schema_repairs() + + issue = ir.async_get(hass).async_get_issue( + "span_panel", "unresolved_entry-affected_circuit.instant_power_w" + ) + assert issue is not None + assert issue.translation_placeholders["count"] == "1" + assert issue.translation_placeholders["examples"] == "sensor.span_panel_kitchen_power" + + +async def test_sync_schema_repairs_is_a_no_op_while_findings_are_unknown( + hass: HomeAssistant, +) -> None: + """"Unknown" must not reconcile at all. + + `field_metadata` is None for the whole retained-message window, which an + ordinary reconnect opens. Reconciling against no findings would delete every + schema issue, and with it every dismissal the user has made. + """ + from homeassistant.helpers import issue_registry as ir + + entry = MockConfigEntry(domain="span_panel", entry_id="entry-unknown") + entry.add_to_hass(hass) + ir.async_create_issue( + hass, + "span_panel", + "unresolved_entry-unknown_circuit.instant_power_w", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="schema_field_unresolved", + translation_placeholders={"field_path": "x", "count": "0", "examples": "none"}, + ) + + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = None + coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry) + + coordinator._run_schema_validation() + coordinator.async_sync_schema_repairs() + + assert coordinator.schema_findings is None + assert ir.async_get(hass).async_get_issue( + "span_panel", "unresolved_entry-unknown_circuit.instant_power_w" + ) + + +async def test_a_none_first_pass_does_not_disable_validation_for_the_session( + hass: HomeAssistant, +) -> None: + """One unlucky first pass must not silence the feature until the next reload. + + `field_metadata` is None for the whole not-ready / pre-rebuild window, which + an ordinary reconnect opens. Setting the once-only guard before validation + ran meant that a first pass landing in that window left findings at None + forever: the guard was set, later passes skipped, and the single reconcile at + setup had nothing to raise. Zero issues, for the life of the entry. + """ + from homeassistant.helpers import issue_registry as ir + + entry = MockConfigEntry(domain="span_panel", entry_id="entry-late") + entry.add_to_hass(hass) + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = None + coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry) + coordinator.async_register_field_path_entity( + "circuit.instant_power_w", "sensor.kitchen_power" + ) + snapshot = SpanPanelSnapshotFactory.create() + + with patch.object(coordinator, "_fire_dip_notification", AsyncMock()): + # The pass that happens during setup's first refresh — metadata not ready. + await coordinator._run_post_update_tasks(snapshot) + assert coordinator.schema_findings is None + assert coordinator._schema_validated is False + + # Setup finishes and reconciles; there is nothing to raise yet. + coordinator.async_sync_schema_repairs() + assert not [k for k in ir.async_get(hass).issues if k[0] == "span_panel"] + + # Metadata arrives, and the panel turns out to be degraded. + client.field_metadata = { + "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False) + } + for _ in range(5): + await coordinator._run_post_update_tasks(snapshot) + + assert coordinator.schema_findings is not None + assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"}) + + issue = ir.async_get(hass).async_get_issue( + "span_panel", "unresolved_entry-late_circuit.instant_power_w" + ) + assert issue is not None + assert issue.translation_placeholders["count"] == "1" + assert issue.translation_placeholders["examples"] == "sensor.kitchen_power" + + +async def test_validation_runs_at_most_once_successfully(hass: HomeAssistant) -> None: + """Metadata is static within a session; re-reading it every pass is waste. + + The retry above must not turn into revalidation. Once a pass has produced + findings, later passes leave them alone. + """ + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = { + "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False) + } + coordinator = _create_coordinator(hass, client=client) + snapshot = SpanPanelSnapshotFactory.create() + + with patch.object(coordinator, "_fire_dip_notification", AsyncMock()): + await coordinator._run_post_update_tasks(snapshot) + assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"}) + + # A later pass sees different metadata and must not act on it. + client.field_metadata = {} + for _ in range(3): + await coordinator._run_post_update_tasks(snapshot) + + assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"}) + + +def test_the_reload_trigger_sees_every_capability_the_platforms_gate_on() -> None: + """The coordinator must not derive its own, narrower capability set. + + It used to. `_detect_capabilities` was a hand-rolled copy that knew only + bess/pv/power_flows/evse, so a panel that gained `mid`, `shed_forecast`, + `bess_telemetry`, `pcs` or `der_link_health` on a firmware upgrade + published the properties, created no entities, and asked for no reload -- + the user saw nothing until they restarted Home Assistant. + + Asserting equality on a snapshot that exercises the capabilities is what + catches a re-divergence; asserting the two names are the same object would + pass the moment someone re-inlined the logic. + """ + snapshot = schema_one_snapshot() + + from custom_components.span_panel.helpers import detect_capabilities + + assert SpanPanelCoordinator._detect_capabilities(snapshot) == detect_capabilities( + snapshot + ) + # The capabilities the old copy was blind to are present in this capture, + # so the assertion above is not comparing two empty sets. + assert {"shed_forecast", "pcs", "bess_telemetry", "der_link_health"} <= ( + detect_capabilities(snapshot) + ) + + +# --- a device or a property that arrives after setup ------------------------- + + +def _adopted_device(device_id: str = "generator-1") -> AdoptedDevice: + return AdoptedDevice( + device_id=device_id, + device_type="energy.ebus.device.generator", + name="Backup Generator", + properties=( + AdoptedProperty( + node_id="meter", property_id="active-power", datatype="float", unit="W" + ), + ), + ) + + +def _extension_row(property_id: str = "cell-temperature") -> ExtensionProperty: + return ExtensionProperty( + subject=ExtensionSubject(kind="battery", instance_key=None), + node_id="battery-2", + property_id=property_id, + datatype="float", + unit="°C", + ) + + +async def test_an_adopted_device_arriving_after_setup_asks_for_a_reload( + hass: HomeAssistant, +) -> None: + """Adoption's module docstring calls a device nobody modelled an *expected* event. + + Nothing adds these entities dynamically, so the only way one reaches the user + without a manual restart is the capability reload -- and the capability set + used to be blind to them, so a vendor device that appeared an hour after + setup produced no device, no entity and no reload until somebody restarted + Home Assistant. + """ + coordinator = _create_coordinator(hass) + baseline = SpanPanelSnapshotFactory.create() + arrived = replace(baseline, adopted_devices=(_adopted_device(),)) + + assert detect_capabilities(arrived) != detect_capabilities(baseline) + + coordinator._check_capability_change(baseline) + coordinator._check_capability_change(arrived) + + assert coordinator._reload_requested is True + + +async def test_an_extension_property_arriving_after_setup_asks_for_a_reload( + hass: HomeAssistant, +) -> None: + """The other half of vendor extensibility, and the same silence.""" + coordinator = _create_coordinator(hass) + baseline = SpanPanelSnapshotFactory.create() + arrived = replace(baseline, extension_properties=(_extension_row(),)) + + assert detect_capabilities(arrived) != detect_capabilities(baseline) + + coordinator._check_capability_change(baseline) + coordinator._check_capability_change(arrived) + + assert coordinator._reload_requested is True + + +async def test_a_device_that_leaves_the_tree_asks_for_nothing(hass: HomeAssistant) -> None: + """Expansion-only, exactly like every other capability flag. + + A device that stops publishing costs nothing to leave alone: its entities + read unknown and nothing here removes a registry row, so a reload would + rebuild the identical set. Reloading on a shrink would also make a flapping + publisher reload the integration on a loop. + """ + coordinator = _create_coordinator(hass) + present = replace( + SpanPanelSnapshotFactory.create(), + adopted_devices=(_adopted_device(), _adopted_device("generator-2")), + ) + gone = replace(present, adopted_devices=(_adopted_device(),)) + + coordinator._check_capability_change(present) + coordinator._check_capability_change(gone) + + assert coordinator._reload_requested is False + + +async def test_the_same_devices_and_properties_ask_for_nothing(hass: HomeAssistant) -> None: + """The fingerprint has to be stable, or every refresh would reload the integration.""" + coordinator = _create_coordinator(hass) + snapshot = replace( + SpanPanelSnapshotFactory.create(), + adopted_devices=(_adopted_device(),), + extension_properties=(_extension_row(), _extension_row("pack-voltage")), + ) + + coordinator._check_capability_change(snapshot) + for _ in range(5): + coordinator._check_capability_change( + replace( + SpanPanelSnapshotFactory.create(), + adopted_devices=(_adopted_device(),), + extension_properties=(_extension_row("pack-voltage"), _extension_row()), + ) + ) + + assert coordinator._reload_requested is False + + +async def test_no_wire_identity_reaches_the_capability_set_in_the_clear() -> None: + """The capability set is logged at INFO whenever it expands, so it may not carry a serial. + + `diagnostics.AdoptedDeviceRow` states the repo rule: a device id can embed a + serial, because producers derive a DER's id preferring a serial over a + default slug -- which is why this repository holds PV's `info/serial-number` + unvalued and why that block reports `proxied` rather than `parent`. A + capability token naming the id verbatim would put the same value in a log + line users paste into issues. + + The subject kind and the wire path stay in the clear on purpose: they are + vendor vocabulary and name no install. + """ + snapshot = replace( + SpanPanelSnapshotFactory.create(), + adopted_devices=(_adopted_device("panel-EX-0000-0001"),), + extension_properties=( + replace( + _extension_row(), + subject=ExtensionSubject(kind="evse", instance_key="evse-EX-0000-0002"), + ), + ), + ) + + tokens = adopted_capability_tokens(snapshot) + + assert len(tokens) == 2 + rendered = " ".join(sorted(tokens)) + assert "EX-0000-0001" not in rendered + assert "EX-0000-0002" not in rendered + assert "panel-" not in rendered + # What is kept is what a maintainer can act on without an identity. + assert "extension:evse:" in rendered + assert "battery-2/cell-temperature" in rendered + + +async def test_the_digest_is_stable_across_calls_and_distinguishes_devices() -> None: + """A digest that moved between snapshots would reload the integration on every refresh.""" + first = replace(SpanPanelSnapshotFactory.create(), adopted_devices=(_adopted_device(),)) + again = replace(SpanPanelSnapshotFactory.create(), adopted_devices=(_adopted_device(),)) + other = replace( + SpanPanelSnapshotFactory.create(), adopted_devices=(_adopted_device("generator-2"),) + ) + + assert adopted_capability_tokens(first) == adopted_capability_tokens(again) + assert adopted_capability_tokens(first) != adopted_capability_tokens(other) +async def test_a_circuit_that_becomes_controllable_requests_a_reload( + hass: HomeAssistant, +) -> None: + """The direction no entity can see: a circuit that gains a control. + + A circuit the panel declares non-commandable gets no switch and no select, + so there is no entity on that circuit to notice the panel changing its mind + later. Only a reader that sees every circuit can, and the coordinator is + the one thing that reads the whole snapshot on every push. + """ + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = None + coordinator = _create_coordinator(hass, client=client) + + locked = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=False + ) + baseline = SpanPanelSnapshotFactory.create(circuits={"1": locked}) + commissioned = SpanPanelSnapshotFactory.create( + circuits={"1": replace(locked, is_user_controllable=True)} + ) + + with patch.object(coordinator, "request_reload") as request_reload: + await coordinator._on_snapshot_push(baseline) + request_reload.assert_not_called() + + await coordinator._on_snapshot_push(commissioned) + request_reload.assert_called_once() + + +async def test_a_circuit_that_stops_being_controllable_requests_a_reload( + hass: HomeAssistant, +) -> None: + """A control entity can outlive its own controllability, and must not. + + `is_user_controllable` decided at setup that this circuit gets a switch and + a select. When the panel withdraws it mid-session both stay on the + dashboard and every press and every choice is refused, which is a worse + experience than the entities going away on the reload the change earns. + """ + coordinator = _create_coordinator(hass) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=True + ) + + coordinator._check_settability_change(SpanPanelSnapshotFactory.create(circuits={"1": circuit})) + assert coordinator._known_settability == {"1": (True, True)} + assert coordinator._reload_requested is False + + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create( + circuits={"1": replace(circuit, is_user_controllable=False)} + ) + ) + + assert coordinator._known_settability == {"1": (False, False)} + assert coordinator._reload_requested is True + + +async def test_a_circuit_commissioned_never_backup_requests_a_reload( + hass: HomeAssistant, +) -> None: + """Priority settability is its own flag, and it decides whether a select exists. + + A circuit the panel commissions never-backup keeps a relay it can still + operate, so the switch is unaffected -- but its priority select now refuses + every choice made in it. + """ + coordinator = _create_coordinator(hass) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=True + ) + + coordinator._check_settability_change(SpanPanelSnapshotFactory.create(circuits={"1": circuit})) + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create(circuits={"1": replace(circuit, is_never_backup=True)}) + ) + + assert coordinator._known_settability == {"1": (True, False)} + assert coordinator._reload_requested is True + + +async def test_a_circuit_whose_settability_is_unchanged_requests_no_reload( + hass: HomeAssistant, +) -> None: + """The watch has to be an edge, or every push would reload the entry.""" + coordinator = _create_coordinator(hass) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=True, relay_state="CLOSED" + ) + + coordinator._check_settability_change(SpanPanelSnapshotFactory.create(circuits={"1": circuit})) + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create(circuits={"1": replace(circuit, relay_state="OPEN")}) + ) + + assert coordinator._reload_requested is False + + +async def test_a_settability_change_asks_for_one_reload_and_not_a_storm( + hass: HomeAssistant, +) -> None: + """The baseline moves on the pass that asks, exactly like the capability check. + + Without that, every push after a withdrawn control would re-request a + reload, and the entry would reload for as long as the panel held its new + answer. + """ + coordinator = _create_coordinator(hass) + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=True + ) + withdrawn = SpanPanelSnapshotFactory.create( + circuits={"1": replace(circuit, is_user_controllable=False)} + ) + + coordinator._check_settability_change(SpanPanelSnapshotFactory.create(circuits={"1": circuit})) + + with patch.object(coordinator, "request_reload") as request_reload: + coordinator._check_settability_change(withdrawn) + coordinator._check_settability_change(withdrawn) + coordinator._check_settability_change(withdrawn) + + request_reload.assert_called_once() + + +async def test_a_circuit_dropping_out_of_a_snapshot_requests_no_reload( + hass: HomeAssistant, +) -> None: + """Absence is not an answer about settability, and must not be read as one. + + Circuits go missing from a snapshot and come back -- both control platforms + guard for exactly that. Counting the gap as a change would turn a flapping + circuit into a reload loop, so only circuits present in both readings are + judged. The one that returns is judged again on its return. + """ + coordinator = _create_coordinator(hass) + kitchen = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=True + ) + garage = SpanCircuitSnapshotFactory.create( + circuit_id="2", name="Garage Outlets", is_user_controllable=True + ) + + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create(circuits={"1": kitchen, "2": garage}) + ) + coordinator._check_settability_change(SpanPanelSnapshotFactory.create(circuits={"1": kitchen})) + assert coordinator._reload_requested is False + + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create(circuits={"1": kitchen, "2": garage}) + ) + assert coordinator._reload_requested is False + + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create( + circuits={"1": kitchen, "2": replace(garage, is_user_controllable=False)} + ) + ) + assert coordinator._reload_requested is True + + +async def test_the_settability_reader_gates_on_what_the_platforms_gate_on( + hass: HomeAssistant, +) -> None: + """The coordinator must not derive its own, narrower notion of settable. + + A second copy of either predicate would drift, and the drift would be + silent: the platforms and the reload trigger disagreeing about what the + panel allows. Both read `helpers`, and this pins the pair by driving a case + only the shared predicate answers -- a PV circuit with no physical breaker, + which is controllable and still gets neither entity. + """ + upstream_pv = SpanCircuitSnapshotFactory.create( + circuit_id="9", + name="Solar", + device_type="pv", + relative_position="UPSTREAM", + is_user_controllable=True, + ) + coordinator = _create_coordinator(hass) + + coordinator._check_settability_change( + SpanPanelSnapshotFactory.create(circuits={"9": upstream_pv}) + ) + + assert coordinator._known_settability == {"9": (False, False)} + assert circuit_has_a_breaker_switch(upstream_pv) is False + assert circuit_has_a_priority_select(upstream_pv) is False + + +async def test_the_polling_path_watches_settability_too(hass: HomeAssistant) -> None: + """A panel with no live MQTT stream has only the fallback poll to notice on.""" + client = MagicMock(spec=SpanPanelClientProtocol) + client.field_metadata = None + coordinator = _create_coordinator(hass, client=client) + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="1", name="Kitchen Outlets", is_user_controllable=False + ) + client.get_snapshot = AsyncMock( + return_value=SpanPanelSnapshotFactory.create(circuits={"1": circuit}) + ) + await coordinator._async_update_data() + + client.get_snapshot = AsyncMock( + return_value=SpanPanelSnapshotFactory.create( + circuits={"1": replace(circuit, is_user_controllable=True)} + ) + ) + with patch.object(coordinator, "request_reload") as request_reload: + await coordinator._async_update_data() + + request_reload.assert_called_once() diff --git a/tests/test_coordinator_migration.py b/tests/test_coordinator_migration.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_curation.py b/tests/test_curation.py new file mode 100644 index 00000000..11b53097 --- /dev/null +++ b/tests/test_curation.py @@ -0,0 +1,341 @@ +"""Curation records: parsing, validation, sanitisation, and the description helpers.""" + +from unittest.mock import MagicMock + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.span_panel import async_remove_entry +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import ( + CurationError, + CurationOverlay, + CurationRecord, + RowContext, + allowed_device_classes, + allowed_state_classes, + async_forget_curation, + async_load_curation, + async_save_record, + binary_sensor_device_class, + entity_category_for, + parse_record, + record_as_dict, + sanitise, + sensor_description, + validate_record, +) + +SENSOR_FLOAT_V = RowContext(platform=Platform.SENSOR, datatype="float", unit="V") +SENSOR_STRING = RowContext(platform=Platform.SENSOR, datatype="string", unit=None) +BINARY = RowContext(platform=Platform.BINARY_SENSOR, datatype="boolean", unit=None) +SWITCH = RowContext(platform=Platform.SWITCH, datatype="boolean", unit=None) + + +def test_a_numeric_sensor_row_accepts_a_state_class() -> None: + record = validate_record({"state_class": "measurement"}, SENSOR_FLOAT_V) + assert record.state_class is SensorStateClass.MEASUREMENT + + +def test_a_string_sensor_row_refuses_a_state_class() -> None: + with pytest.raises(CurationError) as err: + validate_record({"state_class": "measurement"}, SENSOR_STRING) + assert err.value.code == "invalid_state_class" + + +def test_a_binary_row_refuses_a_state_class() -> None: + with pytest.raises(CurationError) as err: + validate_record({"state_class": "measurement"}, BINARY) + assert err.value.code == "invalid_state_class" + + +def test_a_control_row_accepts_only_promotion() -> None: + record = validate_record({"entity_category": "none"}, SWITCH) + assert record.promote is True + with pytest.raises(CurationError) as err: + validate_record({"device_class": "power", "entity_category": "none"}, SWITCH) + assert err.value.code == "invalid_field_for_platform" + + +def test_a_device_class_incompatible_with_the_wire_unit_is_refused() -> None: + with pytest.raises(CurationError) as err: + validate_record({"device_class": "temperature"}, SENSOR_FLOAT_V) + assert err.value.code == "incompatible_device_class" + + +def test_a_device_class_compatible_with_the_wire_unit_is_accepted() -> None: + record = validate_record({"device_class": "voltage"}, SENSOR_FLOAT_V) + assert record.device_class == "voltage" + + +def test_an_unknown_device_class_is_refused() -> None: + with pytest.raises(CurationError) as err: + validate_record({"device_class": "not-a-class"}, SENSOR_FLOAT_V) + assert err.value.code == "invalid_device_class" + + +def test_a_binary_row_takes_binary_device_classes_only() -> None: + assert validate_record({"device_class": "problem"}, BINARY).device_class == "problem" + with pytest.raises(CurationError): + validate_record({"device_class": "voltage"}, BINARY) + + +def test_an_entity_category_other_than_none_is_refused() -> None: + with pytest.raises(CurationError) as err: + validate_record({"entity_category": "config"}, SENSOR_FLOAT_V) + assert err.value.code == "invalid_entity_category" + + +def test_sanitise_drops_a_field_the_wire_no_longer_supports_and_keeps_the_rest() -> None: + record = CurationRecord( + state_class=SensorStateClass.MEASUREMENT, device_class="voltage", promote=True + ) + sanitised, dropped = sanitise(record, SENSOR_STRING) + assert sanitised.state_class is None + assert sanitised.device_class is None + assert sanitised.promote is True + assert set(dropped) == {"state_class", "device_class"} + + +def test_allowed_state_classes_are_empty_off_numeric_sensor_rows() -> None: + assert allowed_state_classes(SENSOR_FLOAT_V) == [cls.value for cls in SensorStateClass] + assert allowed_state_classes(SENSOR_STRING) == [] + assert allowed_state_classes(SWITCH) == [] + + +def test_allowed_device_classes_respect_the_wire_unit() -> None: + allowed = allowed_device_classes(SENSOR_FLOAT_V) + assert "voltage" in allowed + assert "temperature" not in allowed + assert allowed_device_classes(SWITCH) == [] + + +def test_allowed_device_classes_respect_the_wire_datatype() -> None: + """A device class the row's datatype cannot satisfy is not an offer worth making. + + The unit gate alone let a `string` row be offered `power_factor` and `aqi` -- + classes that constrain no unit, and so passed -- which reads unknown forever + because the value behind them is text. + """ + numeric = allowed_device_classes(SENSOR_FLOAT_V) + assert "enum" not in numeric + assert "date" not in numeric + assert "timestamp" not in numeric + assert "voltage" in numeric + + text = allowed_device_classes(SENSOR_STRING) + assert "power_factor" not in text + assert "aqi" not in text + assert {"enum", "timestamp"} <= set(text) + + +def test_a_device_class_the_datatype_cannot_satisfy_is_refused() -> None: + """Both directions, because the partition has two halves and each is a real mistake.""" + with pytest.raises(CurationError) as err: + validate_record({"device_class": "enum"}, SENSOR_FLOAT_V) + assert err.value.code == "incompatible_device_class" + assert "float" in str(err.value) + + with pytest.raises(CurationError) as err: + validate_record({"device_class": "power_factor"}, SENSOR_STRING) + assert err.value.code == "incompatible_device_class" + assert "string" in str(err.value) + + +def test_a_device_class_the_datatype_does_satisfy_is_accepted() -> None: + assert validate_record({"device_class": "enum"}, SENSOR_STRING).device_class == "enum" + + +def test_record_round_trips_through_its_dict_form() -> None: + record = CurationRecord( + state_class=SensorStateClass.TOTAL_INCREASING, device_class="energy", promote=True + ) + assert parse_record(record_as_dict(record)) == record + assert parse_record({"unknown": "shape"}) is None + assert parse_record("not a mapping") is None + + +def test_an_unknown_state_class_is_refused_on_a_row_that_could_carry_one() -> None: + with pytest.raises(CurationError) as err: + validate_record({"state_class": "not-a-state-class"}, SENSOR_FLOAT_V) + assert err.value.code == "invalid_state_class" + + +def test_a_control_row_refuses_a_state_class_before_reading_it() -> None: + with pytest.raises(CurationError) as err: + validate_record({"state_class": "measurement"}, SWITCH) + assert err.value.code == "invalid_field_for_platform" + + +def test_a_stored_record_the_wire_vocabulary_has_outgrown_reads_as_absent() -> None: + assert parse_record({"state_class": "no-longer-a-state-class"}) is None + assert parse_record({"entity_category": "diagnostic"}) is None + assert parse_record({}) is None + + +def test_a_binary_row_is_offered_every_binary_device_class() -> None: + assert allowed_device_classes(BINARY) == [cls.value for cls in BinarySensorDeviceClass] + + +# The store. Loading is awaited from `async_setup_entry`, so every shape the disk +# is allowed to hold has to end in an overlay rather than an exception -- these +# are the shapes, not a survey of them. + + +def _entry() -> MagicMock: + entry = MagicMock() + entry.entry_id = "test-entry" + return entry + + +async def test_save_load_round_trip(hass: HomeAssistant) -> None: + entry = _entry() + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT, device_class="voltage") + await async_save_record(hass, entry, "bess/battery-2/cell-voltage", record) + overlay = await async_load_curation(hass, entry) + assert overlay.record_for("bess/battery-2/cell-voltage") == record + assert overlay.as_dicts() == { + "bess/battery-2/cell-voltage": {"state_class": "measurement", "device_class": "voltage"} + } + + +async def test_saving_none_clears_the_record(hass: HomeAssistant) -> None: + entry = _entry() + await async_save_record(hass, entry, "bess/b/p", CurationRecord(promote=True)) + await async_save_record(hass, entry, "bess/b/p", None) + overlay = await async_load_curation(hass, entry) + assert overlay.record_for("bess/b/p") is None + + +async def test_a_record_that_asserts_nothing_clears_rather_than_being_written( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Saving `CurationRecord()` may not leave the one shape the loader calls damaged. + + Its stored form is `{}`, which `parse_record` refuses -- so writing it would + put a record on disk that the next load reports as unreadable and the save + after that silently deletes, over a value the signature accepts. + """ + entry = _entry() + await async_save_record(hass, entry, "bess/b/p", CurationRecord(promote=True)) + await async_save_record(hass, entry, "bess/b/p", CurationRecord()) + overlay = await async_load_curation(hass, entry) + assert overlay.as_dicts() == {} + assert "unreadable" not in caplog.text + + +async def test_a_wrong_shaped_store_loads_as_empty( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + hass_storage["span_panel.curation.test-entry"] = { + "version": 1, + "data": {"records": "not a mapping"}, + } + overlay = await async_load_curation(hass, _entry()) + assert overlay.as_dicts() == {} + + +async def test_an_unreadable_record_is_skipped_not_fatal( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + hass_storage["span_panel.curation.test-entry"] = { + "version": 1, + "data": {"records": {"good/b/p": {"entity_category": "none"}, "bad/b/p": {"x": 1}}}, + } + overlay = await async_load_curation(hass, _entry()) + assert overlay.record_for("good/b/p") == CurationRecord(promote=True) + assert overlay.record_for("bad/b/p") is None + + +async def test_removing_the_entry_forgets_the_curated_records( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + """The keys are wire addresses, not registry ids. + + A store left behind is one the next entry added for the same panel would + load and apply, re-asserting metadata for a panel the user removed. + """ + entry = _entry() + await async_save_record(hass, entry, "bess/b/p", CurationRecord(promote=True)) + + await async_forget_curation(hass, entry) + + assert "span_panel.curation.test-entry" not in hass_storage + assert (await async_load_curation(hass, entry)).as_dicts() == {} + + +async def test_removing_the_config_entry_is_what_calls_the_forget(hass: HomeAssistant) -> None: + """The store outliving the entry is only prevented if the removal hook says so.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="removed-entry", unique_id="sp3-001") + entry.add_to_hass(hass) + await async_save_record(hass, entry, "bess/b/p", CurationRecord(promote=True)) + + await async_remove_entry(hass, entry) + + assert (await async_load_curation(hass, entry)).as_dicts() == {} + + +def test_for_row_sanitises_and_stale_fields_reports() -> None: + overlay = CurationOverlay( + {"k": CurationRecord(state_class=SensorStateClass.MEASUREMENT, promote=True)} + ) + sanitised = overlay.for_row("k", SENSOR_STRING) + assert sanitised == CurationRecord(promote=True) + assert overlay.stale_fields("k", SENSOR_STRING) == ("state_class",) + assert overlay.for_row("missing", SENSOR_STRING) is None + assert overlay.stale_fields("missing", SENSOR_STRING) == () + + +def test_an_empty_overlay_answers_for_a_row_it_has_never_heard_of() -> None: + """What a setup that stored nothing runs against, and every row goes through it.""" + overlay = CurationOverlay.empty() + assert overlay.as_dicts() == {} + assert overlay.for_row("bess/b/p", SENSOR_STRING) is None + + +def test_a_dropped_field_is_named_in_the_warning_it_logs( + caplog: pytest.LogCaptureFixture, +) -> None: + """The line is all the user gets: their assertion stops applying and nothing else says so.""" + overlay = CurationOverlay( + {"bess/battery-2/cell-voltage": CurationRecord(device_class="voltage")} + ) + assert overlay.for_row("bess/battery-2/cell-voltage", SENSOR_STRING) == CurationRecord() + assert "bess/battery-2/cell-voltage" in caplog.text + assert "device_class" in caplog.text + + +# The description helpers. Every curated value reaches an entity through one of +# these three, which is what lets `adoption.py` and `extension.py` stay free of +# the tokens their AST guards forbid. + + +def test_sensor_description_without_a_record_matches_todays_behaviour() -> None: + description = sensor_description("b/p", "V", SensorDeviceClass.VOLTAGE, None) + assert description.state_class is None + assert description.device_class is SensorDeviceClass.VOLTAGE + assert description.native_unit_of_measurement == "V" + + +def test_a_record_supplies_state_class_and_overrides_the_default_device_class() -> None: + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT, device_class="energy") + description = sensor_description("b/p", "Wh", SensorDeviceClass.ENERGY, record) + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.device_class is SensorDeviceClass.ENERGY + + +def test_entity_category_promotes_only_on_an_explicit_record() -> None: + assert entity_category_for(None) is EntityCategory.DIAGNOSTIC + assert entity_category_for(CurationRecord()) is EntityCategory.DIAGNOSTIC + assert entity_category_for(CurationRecord(promote=True)) is None + + +def test_binary_sensor_device_class_reads_the_record_only() -> None: + assert binary_sensor_device_class(None) is None + assert binary_sensor_device_class(CurationRecord(device_class="problem")) is ( + BinarySensorDeviceClass.PROBLEM + ) diff --git a/tests/test_declared_but_unread.py b/tests/test_declared_but_unread.py new file mode 100644 index 00000000..30cca99f --- /dev/null +++ b/tests/test_declared_but_unread.py @@ -0,0 +1,388 @@ +"""Every property the panel declares must reach a user, or say why it does not. + +The consumer-side mirror of panelbench's `test_declared_but_unvalued`, which +asks the producer whether it ever publishes what it declares. This asks whether +anything ever reads what arrives. + +**Why this and not the producible gate.** `test_field_path_conformance` starts +from what the integration declares it reads and checks an adapter produces it. +That direction cannot see a property nobody reads: an unread declaration is +absent from every list the gate consults, so it is invisible by construction. +`panel.wifi_ssid` is the worked example — flat surfaces it, v1.0 declares it, +schema_1 maps nothing to it, and its `RESIDUAL_EXEMPT_PATHS` annotation said +`SCHEMA_0_ONLY`, which was *true* and still sanctioned a silent regression. +Every check in the codebase agreed, and a user upgrading lost an attribute. + +**How consumption is decided, and why not from a list.** A hand-kept map of +"properties we read" is what made a new property invisible in the first place, +so this derives the answer by experiment instead: republish one declared +property with a different value, rebuild the snapshot through the real schema_1 +mapper, and see which snapshot fields moved. A property that moves a field the +integration reads is surfaced; one that moves nothing reaches nobody. Nothing +here restates `_PROPERTY_FIELD_MAP`, the lugs direction tables, the topology +readers or the device_info builders — the experiment sees all of them, and sees +them the way a user does, through what the panel actually renders. + +That is deliberately stricter than "mapped by `_PROPERTY_FIELD_MAP`". A property +the library reads into a snapshot field no entity, attribute or device card ever +touches has not reached anybody: `circuit.is_240v`, `evse.part_number` and +`pv.software_version` are each one library line and no user-visible effect. They +are baselined here rather than counted as read. + +**The two enumerations, and why neither can absorb a new property.** +`_INTERNAL_ROUTES` holds what the snapshot cannot express — adapter dispatch, a +shadowed fallback tier, a topology branch no producer reaches. The baseline file +holds what is genuinely unread, one reason per line. Both are compared as exact +sets, so an entry that stops being true fails just as loudly as a declaration +that arrives untriaged. A new property can only ever land in either by somebody +writing the line. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import dataclasses +import json +import pathlib +from typing import NamedTuple + +from span_panel_api import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanEvseSnapshot, + SpanMidSnapshot, + SpanPanelSnapshot, + SpanPcsSnapshot, + SpanPVSnapshot, +) + +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + declared_field_paths, +) +from tests.adapter_fixtures import schema_one_snapshot, schema_one_tree + +BASELINE = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json" + + +class Declaration(NamedTuple): + """One ``(device type, node, property)`` the fixture's `$description` declares. + + Keyed by device *type* rather than device id, matching the granularity of + the capability catalogs and of the gap inventory: five circuits declare the + same properties, and three of them going unread is one gap, not three. + """ + + device_type: str + node: str + property_id: str + + def __str__(self) -> str: + """Render as the baseline file's key: ``device-type/node/property``.""" + return f"{self.device_type}/{self.node}/{self.property_id}" + + +_INTERNAL_ROUTES: Mapping[Declaration, str] = { + Declaration("distribution-enclosure", "info", "data-model-version"): ( + "tier-1 adapter dispatch (span_panel_api/dispatch.py) — it chooses which " + "adapter parses the tree, so it is consumed before any snapshot exists" + ), + Declaration("distribution-enclosure", "shed", "asserted-islanding-state"): ( + "tier 1 of resolve_islanding_state (schema_1 panel.py), whose probe value " + "ON_GRID matches this fixture's MID, and the write target of the existing " + "dominant-power-source control (schema_1 adapter.py)" + ), + Declaration("lugs", "connection", "feeds-device-id"): ( + "the downstream-lugs feedthrough branch of resolve_relative_position " + "(schema_1 devices.py), which no producer currently reaches" + ), +} +"""Declarations consumed by a route no snapshot field can show. + +Three, and each names the code that reads it. This is the category the +experiment cannot measure, so it is the category most at risk of becoming the +allowlist that swallowed the problem — hence +`test_no_internal_route_is_observable_after_all`, which fails the moment an +entry stops being needed. +""" + + +def _mapping(value: object) -> Mapping[str, object]: + """Return `value` as a string-keyed mapping, or an empty one. + + `json.loads` answers `object`, and every level of a Homie `$description` is + optional. Narrowing here rather than at each call site keeps the walk below + readable and keeps `Any` out of the module. + """ + if not isinstance(value, Mapping): + return {} + return {str(key): item for key, item in value.items()} + + +def _text(value: object) -> str: + return value if isinstance(value, str) else "" + + +class _Property(NamedTuple): + """One declared property, and the device that declares it.""" + + device_id: str + topic: str + datatype: str + format_spec: str + + +def _declared(tree: Mapping[str, Mapping[str, str]]) -> dict[Declaration, list[_Property]]: + """Every property declared anywhere in the tree, by declaration. + + The authoritative property set is the `$description`, per the enclosure data + model: what a device publishes is a subset of what it declares, and the gap + between the two is precisely what this module is about. + """ + found: dict[Declaration, list[_Property]] = {} + for device_id, topics in tree.items(): + description = _mapping(json.loads(topics["$description"])) + device_type = _text(description.get("type")).rsplit(".", 1)[-1] + for node_id, node in _mapping(description.get("nodes")).items(): + for property_id, definition in _mapping(_mapping(node).get("properties")).items(): + body = _mapping(definition) + found.setdefault(Declaration(device_type, node_id, property_id), []).append( + _Property( + device_id=device_id, + topic=f"{node_id}/{property_id}", + datatype=_text(body.get("datatype")), + format_spec=_text(body.get("format")), + ) + ) + return found + + +def _perturbed(declared: _Property, current: str | None) -> str: + """Return a legal value for this property that differs from `current`. + + Legal matters: a parser that rejects the probe value would leave the field + unchanged and the property would read as unconsumed. So the replacement is + built from the declared `datatype` and `format`, which is the same + information the adapter parses against. + + `current` is `None` for a property the fixture declares and never publishes + — 19 of the 203 instances. Publishing one is the right probe for exactly + those: it asks whether a value arriving would change anything, which is the + question `status/wifi-ssid` needed answering. + """ + if declared.datatype in {"float", "integer"}: + try: + number = float(current or "") + except ValueError: + return "7" if declared.datatype == "integer" else "7.5" + return str(int(number) + 7) if declared.datatype == "integer" else str(number + 7.5) + if declared.datatype == "boolean": + return "false" if (current or "").lower() == "true" else "true" + if declared.datatype == "enum": + for option in declared.format_spec.split(","): + if option and option != current: + return option + return "probe-value" if current != "probe-value" else "probe-value-2" + + +_SubSnapshot = ( + SpanCircuitSnapshot + | SpanEvseSnapshot + | SpanBatterySnapshot + | SpanPVSnapshot + | SpanMidSnapshot + | SpanPcsSnapshot +) + +_COLLECTIONS = frozenset({"circuits", "evse", "battery", "pv", "mid", "pcs"}) +"""Panel-snapshot attributes that hold sub-snapshots rather than a reading. + +Their fields are addressed by their own prefix — `circuit.x`, not +`panel.circuits.x` — matching the field-path convention `field_paths` documents +and `RESIDUAL_EXEMPT_PATHS` is written in. +""" + + +def _record(fields: dict[str, str], prefix: str, obj: _SubSnapshot, suffix: str = "") -> None: + for field in dataclasses.fields(obj): + fields[f"{prefix}.{field.name}{suffix}"] = repr(getattr(obj, field.name)) + + +def _snapshot_fields(snapshot: SpanPanelSnapshot) -> dict[str, str]: + """Flatten a snapshot to ``{field path: value}``. + + The per-instance collections are keyed by circuit and EVSE id so two + circuits cannot mask each other's change; `_bare` strips the key again for + the reader lookup, which is per field and not per instance. + + Values are held as `repr` rather than compared by equality so the diff is a + plain set operation over strings, whatever a field happens to hold. + """ + fields: dict[str, str] = {} + for field in dataclasses.fields(snapshot): + if field.name not in _COLLECTIONS: + fields[f"panel.{field.name}"] = repr(getattr(snapshot, field.name)) + for circuit_id, circuit in snapshot.circuits.items(): + _record(fields, "circuit", circuit, f"@{circuit_id}") + for evse_key, evse in snapshot.evse.items(): + _record(fields, "evse", evse, f"@{evse_key}") + _record(fields, "battery", snapshot.battery) + _record(fields, "pv", snapshot.pv) + if snapshot.mid is not None: + _record(fields, "mid", snapshot.mid) + if snapshot.pcs is not None: + _record(fields, "pcs", snapshot.pcs) + return fields + + +def _bare(field_path: str) -> str: + return field_path.split("@", 1)[0] + + +def _moved_fields() -> dict[Declaration, frozenset[str]]: + """Republish each declared property once; return the snapshot fields it moved. + + One rebuild per declaring device, so the two lugs devices and the five + circuits are probed separately and their results unioned: only the upstream + lugs' `fed-by-*` properties are read, and a single probe against whichever + came first would answer for both. + """ + tree = schema_one_tree() + baseline = _snapshot_fields(schema_one_snapshot(tree)) + moved: dict[Declaration, frozenset[str]] = {} + + for declaration, instances in _declared(tree).items(): + changed: set[str] = set() + for instance in instances: + current = tree[instance.device_id].get(instance.topic) + replacement = _perturbed(instance, current) + assert replacement != current, ( + f"{declaration} on {instance.device_id}: the probe value equals the " + f"published one ({current!r}), so this property is not being tested" + ) + mutated = {device_id: dict(topics) for device_id, topics in tree.items()} + mutated[instance.device_id][instance.topic] = replacement + after = _snapshot_fields(schema_one_snapshot(mutated)) + changed.update(path for path, value in after.items() if baseline.get(path) != value) + moved[declaration] = frozenset(changed) + return moved + + +def _read_field_paths() -> frozenset[str]: + """Every snapshot field the integration reads, by any route. + + `declared_field_paths()` is descriptions plus residual entity-code reads; + `RESIDUAL_EXEMPT_PATHS` is the rest — device_info fields, circuit + attributes, entity-creation gates. Together they are the integration's + complete enumeration of its own reads, which is the claim + `test_every_exempt_path_still_has_a_reader` and the conformance suite hold + it to. A field in neither is one nothing renders. + """ + return declared_field_paths() | frozenset(RESIDUAL_EXEMPT_PATHS) + + +def _classified() -> tuple[dict[Declaration, frozenset[str]], dict[Declaration, frozenset[str]]]: + """Split every declaration into (surfaced, unread).""" + read = _read_field_paths() + surfaced: dict[Declaration, frozenset[str]] = {} + unread: dict[Declaration, frozenset[str]] = {} + for declaration, moved in _moved_fields().items(): + if any(_bare(path) in read for path in moved): + surfaced[declaration] = moved + else: + unread[declaration] = moved + return surfaced, unread + + +def _baseline() -> dict[Declaration, str]: + loaded: dict[str, str] = json.loads(BASELINE.read_text(encoding="utf-8")) + entries: dict[Declaration, str] = {} + for key, reason in loaded.items(): + device_type, node, property_id = key.split("/", 2) + entries[Declaration(device_type, node, property_id)] = reason + return entries + + +def _lines(declarations: Iterable[Declaration]) -> str: + return "\n".join(f" {declaration}" for declaration in sorted(declarations)) or " (none)" + + +def test_the_unread_declarations_match_the_recorded_baseline() -> None: + """Fails in both directions, so neither a gap nor a fix can land unnoticed. + + A declaration nothing reads must be triaged: surfaced by a catch-up task, or + written into the baseline with the reason it stays unread. A declaration that + starts being read must lose its baseline line in the same commit — otherwise + the file drifts into a description of an older codebase and the count it + reports stops meaning anything. + """ + _, unread = _classified() + expected = _baseline() + + appeared = sorted(set(unread) - set(expected) - set(_INTERNAL_ROUTES)) + resolved = sorted(set(expected) - set(unread)) + + assert set(unread) - set(_INTERNAL_ROUTES) == set(expected), ( + "the set of declarations nothing reads moved.\n" + f" newly unread (nothing renders these):\n{_lines(appeared)}\n" + f" now read (delete their lines from {BASELINE.name}):\n{_lines(resolved)}\n\n" + "A newly unread property is a declaration that reaches no entity, attribute " + "or device card. Surface it, or record why it stays unread." + ) + + +def test_every_baseline_entry_carries_a_reason() -> None: + """A line with no reason is an allowlist entry wearing a baseline's clothes.""" + empty = sorted(str(key) for key, reason in _baseline().items() if len(reason.split()) < 4) + assert not empty, ( + f"baseline entries with no usable reason: {empty}. Each line says why the " + "property is not surfaced, so a reader can tell a deliberate skip from a backlog item." + ) + + +def test_every_baseline_entry_is_still_declared() -> None: + """A baseline outlives its declaration silently; the file only ever grows.""" + declared = set(_declared(schema_one_tree())) + stale = sorted(str(key) for key in _baseline() if key not in declared) + assert not stale, ( + f"baseline entries the fixture no longer declares: {stale}. The property " + "went away; drop its line with it." + ) + + +def test_no_internal_route_is_observable_after_all() -> None: + """An internal-route entry must be the only thing keeping its property out. + + This is the entry that could quietly become an allowlist: unlike the + baseline it claims the property *is* consumed, and a claim the experiment + could check is one it should. So the moment a route's property does move a + field the integration reads, the entry has to go — otherwise the next + property added beside it inherits an exemption nobody re-examined. + """ + surfaced, _ = _classified() + redundant = sorted(str(key) for key in _INTERNAL_ROUTES if key in surfaced) + assert not redundant, ( + f"internal-route entries whose property now reaches a reader: {redundant}. " + "The route is no longer the only thing consuming it; delete the entry." + ) + + +def test_every_internal_route_is_still_declared() -> None: + declared = set(_declared(schema_one_tree())) + stale = sorted(str(key) for key in _INTERNAL_ROUTES if key not in declared) + assert not stale, f"internal-route entries the fixture no longer declares: {stale}" + + +def test_the_probe_moves_something_for_a_known_reading() -> None: + """The experiment must be able to observe a change at all. + + Every assertion above is satisfied by a probe that changes nothing, ever: + the unread set would simply be every declaration, matched by a baseline + somebody had grown to fit. This is the floor under that — a property whose + reading is unarguably rendered has to come back surfaced, and has to name + the field it moved. + """ + surfaced, _ = _classified() + power = Declaration("circuit", "meter", "active-power") + assert power in surfaced + assert any(_bare(path) == "circuit.instant_power_w" for path in surfaced[power]) diff --git a/tests/test_dependency_sync.py b/tests/test_dependency_sync.py new file mode 100644 index 00000000..d0cae0ff --- /dev/null +++ b/tests/test_dependency_sync.py @@ -0,0 +1,215 @@ +"""The pin sync must fail loudly rather than quietly do nothing. + +`scripts/sync-dependencies.py` holds `pyproject.toml` and `requirements_test.txt` +to the pins in `manifest.json`, which is the only file whose versions reach a +user. It runs on every commit through `prek.toml` and again in CI. + +Lives here rather than beside the script in `tests/scripts/`, which `pytest.ini` +excludes with `norecursedirs`. A test that is never collected is the same failure +as a hook that never matches: green, silent, and worth nothing. + +It is tested because the previous version was inert for its entire life and said +so to nobody. It matched versions with `[0-9.]+`, so `3.0.0b7` was read as +`3.0.0`, which matched nothing and rewrote nothing -- and every version this +project ships is a pre-release. A hook that matches nothing produces no changes, +which is indistinguishable from a tree that was already correct. That is the +failure these tests exist to catch, so most of them drift a file on purpose and +check that the hook notices. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +REPO = Path(__file__).resolve().parent.parent +SCRIPT = REPO / "scripts" / "sync-dependencies.py" + + +def _run(cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT)], cwd=cwd, capture_output=True, text=True, check=False + ) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + """Return a miniature of this repository: a manifest and the two files it governs. + + Built rather than copied so a test cannot pass by accident of what the real + tree happens to hold today, and so the pins can be pre-release versions -- + the case the old implementation could not see. + """ + component = tmp_path / "custom_components" / "span_panel" + component.mkdir(parents=True) + (component / "manifest.json").write_text( + json.dumps( + { + "domain": "span_panel", + "requirements": [ + "span-panel-api==3.0.0b7", + "span-panel-api-schema-0==1.0.0b5", + "span-panel-api-schema-1==0.1.0b6", + ], + }, + indent=2, + ) + ) + (tmp_path / "pyproject.toml").write_text( + "[project]\n" + 'name = "span-panel"\n' + "dependencies = [\n" + ' "span-panel-api==3.0.0b7",\n' + ' "span-panel-api-schema-0==1.0.0b5",\n' + ' "span-panel-api-schema-1==0.1.0b6",\n' + ' "homeassistant>=2026.8.0",\n' + "]\n" + "\n" + "[tool.uv.sources]\n" + 'span-panel-api = { path = "../span-panel-api", editable = true }\n' + "\n" + "[tool.uv]\n" + "# A deliberately different pin, in a table the manifest does not govern.\n" + 'constraint-dependencies = ["span-panel-api==3.0.0b3"]\n' + ) + (tmp_path / "requirements_test.txt").write_text( + "pytest>=9.0.3\nspan-panel-api==3.0.0b7\nspan-panel-api-schema-0==1.0.0b5\n" + "span-panel-api-schema-1==0.1.0b6\n" + ) + return tmp_path + + +def _write_script_into(repo: Path) -> None: + """Give the miniature its own copy, since the script locates files from its own path.""" + scripts = repo / "scripts" + scripts.mkdir(exist_ok=True) + (scripts / "sync-dependencies.py").write_text(SCRIPT.read_text()) + + +def _sync(repo: Path) -> subprocess.CompletedProcess[str]: + _write_script_into(repo) + return subprocess.run( + [sys.executable, str(repo / "scripts" / "sync-dependencies.py")], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + + +def test_an_already_synced_tree_passes(repo: Path) -> None: + result = _sync(repo) + assert result.returncode == 0, result.stderr + + +def test_a_pre_release_pin_is_synced(repo: Path) -> None: + """The exact case the previous implementation could not see. + + `3.0.0b7` was read as `3.0.0` by a `[0-9.]+` version pattern, so the + substitution never matched and the hook reported success on a stale file. + Every version this project has shipped is a pre-release, so this was not an + edge case -- it was every case. + """ + pyproject = repo / "pyproject.toml" + pyproject.write_text(pyproject.read_text().replace("3.0.0b7", "3.0.0b4")) + + result = _sync(repo) + + assert result.returncode == 1, "a stale pin must stop the commit" + assert "span-panel-api==3.0.0b7" in pyproject.read_text() + assert "3.0.0b4" not in pyproject.read_text() + + +def test_the_adapter_packages_are_synced_too(repo: Path) -> None: + """`span-panel-api-schema-0` starts with `span-panel-api`, which is how it was missed. + + The old implementation entered its bootstrap branch on the prefix and then + failed its own regex on the `-schema-0` that followed, so the adapters were + never synced by anything. + """ + pyproject = repo / "pyproject.toml" + pyproject.write_text(pyproject.read_text().replace("0.1.0b6", "0.1.0b3")) + + assert _sync(repo).returncode == 1 + assert "span-panel-api-schema-1==0.1.0b6" in pyproject.read_text() + + +def test_requirements_test_is_governed(repo: Path) -> None: + """It drifted to a stale beta precisely because nothing looked at it.""" + requirements = repo / "requirements_test.txt" + requirements.write_text(requirements.read_text().replace("3.0.0b7", "3.0.0b4")) + + assert _sync(repo).returncode == 1 + assert "span-panel-api==3.0.0b7" in requirements.read_text() + + +def test_only_the_project_dependencies_are_governed(repo: Path) -> None: + """The manifest governs `[project] dependencies` and no other table. + + `pyproject.toml` names these same packages in several places: as editable + path overrides under `[tool.uv.sources]`, in mypy's search path, and -- the + case that makes scoping load-bearing rather than merely tidy -- as a uv + constraint, which is a version specifier someone set deliberately and to a + different value. A rewrite that walked the whole file would silently drag it + to the manifest's pin and undo the constraint. + """ + pyproject = repo / "pyproject.toml" + pyproject.write_text( + pyproject.read_text().replace( + '"span-panel-api==3.0.0b7",\n', '"span-panel-api==3.0.0b4",\n', 1 + ) + ) + + assert _sync(repo).returncode == 1 + body = pyproject.read_text() + + assert '"span-panel-api==3.0.0b7",' in body, "the governed pin is synced" + assert 'constraint-dependencies = ["span-panel-api==3.0.0b3"]' in body, ( + "the ungoverned one is not" + ) + assert '{ path = "../span-panel-api", editable = true }' in body + + +def test_a_dependency_the_manifest_does_not_pin_is_untouched(repo: Path) -> None: + """The manifest governs its own requirements and nothing else.""" + pyproject = repo / "pyproject.toml" + pyproject.write_text(pyproject.read_text().replace("3.0.0b7", "3.0.0b4")) + + _sync(repo) + + assert '"homeassistant>=2026.8.0"' in pyproject.read_text() + + +def test_an_unparseable_manifest_fails_rather_than_reporting_success(repo: Path) -> None: + """An unreadable source of truth is not a reason to leave every copy alone.""" + (repo / "custom_components" / "span_panel" / "manifest.json").write_text("{not json") + + result = _sync(repo) + + assert result.returncode == 1 + assert "not valid JSON" in result.stderr + + +def test_an_unpinned_manifest_requirement_is_an_error(repo: Path) -> None: + """Home Assistant installs these verbatim, so a missing specifier is a bug.""" + manifest = repo / "custom_components" / "span_panel" / "manifest.json" + manifest.write_text(json.dumps({"requirements": ["span-panel-api"]}, indent=2)) + + result = _sync(repo) + + assert result.returncode == 1 + assert "no version specifier" in result.stderr + + +def test_the_real_repository_is_in_sync() -> None: + """The hook runs on every commit, so this should already be true. + + Here so that a stale pin is reported by the test suite as well as by the + hook: CI runs both, and a developer who bypasses hooks still gets told. + """ + result = _run(REPO) + assert result.returncode == 0, f"{result.stdout}{result.stderr}" diff --git a/tests/test_der_link_health.py b/tests/test_der_link_health.py new file mode 100644 index 00000000..8f08daeb --- /dev/null +++ b/tests/test_der_link_health.py @@ -0,0 +1,525 @@ +"""The enclosure's link to each circuit-fed DER, as two diagnostic binary sensors. + +`bess_connected` has shown the panel's view of the link to the battery since v1.0 +landed, because the upstream lugs' `connection/fed-by-device-status` was already +read. The identical fact for a PV or a charger is published by the **circuit that +feeds it** — `connection/feeds-device-status`, the other half of the same +capability — and reached nothing, so one DER class had a link sensor and the +other two did not. + +`pv_panel_link` and `evse_panel_link` close that. + +**The naming is load-bearing.** `evse_ev_connected` already exists on the same +device and reads the charger's own `status/status`: *a vehicle is plugged in*. +The new one reads the feeding circuit's connection record: *the enclosure can +reach the charger*. Those are different questions with different answers, and +`test_the_charger_link_is_not_the_ev_plug` produces the state where they +disagree rather than asserting the distinction in prose. + +**Every expectation is read out of the capture**, including the enum's members, +which come from the circuit's own `$description` `format` rather than from a list +written here. And the capture publishes `OK` on all three records, so no reading +is proved by the baseline alone: each is proved by republishing values that +differ per DER, and by swapping them, because two chargers fed by two circuits is +what makes cross-wiring falsifiable. + +**Absence is a reading too.** Two of the capture's five circuits publish no +connection record at all — the enclosure data model calls that normal for a +mixed-load circuit, and the enum has no UNKNOWN member, so an unpublished +property is the only way a panel can say it does not know. The entity is gated on +the record existing, never on the circuit's type, and never treats silence as a +fault. +""" + +from __future__ import annotations + +from collections.abc import Sequence +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.binary_sensor import ( + EVSE_BINARY_SENSORS, + EVSE_PANEL_LINK_SENSOR, + PV_PANEL_LINK_SENSOR, + SpanEvseBinarySensor, + SpanPanelBinarySensor, + SpanPanelBinarySensorEntityDescription, + async_setup_entry, +) +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + DerivedReason, + Producibility, +) +from custom_components.span_panel.helpers import detect_capabilities, has_der_link_health +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory + +from .adapter_fixtures import schema_one_snapshot, schema_one_tree +from .factories import SpanPanelSnapshotFactory, pv_binding_for + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +CONNECTION_NODE = "connection" +FEEDS_ID_TOPIC = f"{CONNECTION_NODE}/feeds-device-id" +FEEDS_STATUS_TOPIC = f"{CONNECTION_NODE}/feeds-device-status" + +# The DER the capture commissions: one inverter and two chargers, each fed by its +# own circuit. Two chargers is what makes the wiring falsifiable, so a capture +# that lost one has to fail rather than quietly halve the evidence. +PV = "pv" +EVSE = "evse" +EVSE_2 = "evse-2" + +STATUS_OK = "OK" + +PanelBinarySensor = SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription] +LinkEntity = PanelBinarySensor | SpanEvseBinarySensor +"""Everything `binary_sensor.async_setup_entry` can add, and nothing wider. + +Narrowed at the boundary rather than carried as `object`, so every lookup below +reads a real attribute instead of one the type checker had to be told to ignore. +""" + + +# --------------------------------------------------------------------------- +# Reading the capture +# --------------------------------------------------------------------------- + + +def _feeding_circuit(tree: dict[str, dict[str, str]], device_id: str) -> str: + """The circuit the capture says feeds `device_id`, or fail saying none does.""" + feeders = [ + circuit for circuit, topics in tree.items() if topics.get(FEEDS_ID_TOPIC) == device_id + ] + assert len(feeders) == 1, f"{len(feeders)} circuits feed {device_id} in the capture, expected 1" + return feeders[0] + + +def _status_options() -> list[str]: + """The enum as the feeding circuit's own `$description` declares it. + + Read off the wire because the legal values are the panel's claim, not this + module's — and because the absence of an `UNKNOWN` member is the premise + that makes `None` the only way to report an unknown link. + """ + tree = schema_one_tree() + description = json.loads(tree[_feeding_circuit(tree, PV)]["$description"]) + declared = description["nodes"][CONNECTION_NODE]["properties"]["feeds-device-status"] + assert declared["datatype"] == "enum" + return str(declared["format"]).split(",") + + +def _not_ok() -> list[str]: + return [option for option in _status_options() if option != STATUS_OK] + + +def _republishing(**statuses: str) -> SpanPanelSnapshot: + """A snapshot from the capture with each named DER's link status rewritten. + + Keyed by DER rather than by circuit so a test says what it means — "the + charger's link is down" — while the indirection through `_feeding_circuit` + keeps it reading the capture's own topology rather than a circuit id copied + into the test. + """ + tree = schema_one_tree() + for der, status in statuses.items(): + tree[_feeding_circuit(tree, der.replace("_", "-"))][FEEDS_STATUS_TOPIC] = status + return schema_one_snapshot(tree) + + +def _without_status(der: str) -> SpanPanelSnapshot: + """A snapshot whose feeding circuit stopped publishing the status half.""" + tree = schema_one_tree() + del tree[_feeding_circuit(tree, der)][FEEDS_STATUS_TOPIC] + return schema_one_snapshot(tree) + + +def _without_record(der: str) -> SpanPanelSnapshot: + """A snapshot whose feeding circuit publishes no connection record at all. + + What a circuit feeding an ordinary load looks like, applied to a circuit + that used to feed a DER. + """ + tree = schema_one_tree() + circuit = _feeding_circuit(tree, der) + del tree[circuit][FEEDS_ID_TOPIC] + del tree[circuit][FEEDS_STATUS_TOPIC] + return schema_one_snapshot(tree) + + +# --------------------------------------------------------------------------- +# Building the entities +# --------------------------------------------------------------------------- + + +def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + coordinator.async_request_refresh = AsyncMock() + return coordinator + + +async def _created(hass: HomeAssistant, snapshot: SpanPanelSnapshot) -> list[LinkEntity]: + """Everything `binary_sensor.async_setup_entry` creates for one snapshot. + + Driven through the platform rather than by constructing descriptions, + because the gate under test is a creation gate: an entity that must not + exist cannot be observed by asking an entity for its state. + """ + coordinator = _coordinator(snapshot) + config_entry = coordinator.config_entry + async_add_entities = MagicMock() + + await async_setup_entry(hass, config_entry, async_add_entities) + + added: Sequence[LinkEntity] = async_add_entities.call_args.args[0] + for entity in added: + assert isinstance(entity, SpanPanelBinarySensor | SpanEvseBinarySensor) + return list(added) + + +def _keys(created: Sequence[LinkEntity]) -> list[str]: + return [entity.entity_description.key for entity in created] + + +def _state(entity: LinkEntity) -> bool | None: + entity.async_write_ha_state = MagicMock() + entity._handle_coordinator_update() + return entity.is_on + + +def _pv_link(created: Sequence[LinkEntity]) -> PanelBinarySensor: + matches = [ + entity + for entity in created + if isinstance(entity, SpanPanelBinarySensor) + and entity.entity_description.key == PV_PANEL_LINK_SENSOR.key + ] + assert len(matches) == 1, f"{len(matches)} PV link sensors created, expected 1" + return matches[0] + + +def _evse_links_by_feed( + created: Sequence[LinkEntity], snapshot: SpanPanelSnapshot +) -> dict[str, SpanEvseBinarySensor]: + """The charger link sensors, keyed by the circuit that feeds each charger. + + Keyed by feed rather than by the snapshot's EVSE key, which is a harmonised + serial: a test that named one would still pass if every record landed on the + same charger, which is the failure this module exists to rule out. + """ + links: dict[str, SpanEvseBinarySensor] = {} + for entity in created: + if ( + isinstance(entity, SpanEvseBinarySensor) + and entity.entity_description.key == EVSE_PANEL_LINK_SENSOR.key + ): + links[snapshot.evse[entity._evse_id].feed_circuit_id] = entity + return links + + +# --------------------------------------------------------------------------- +# The premise +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_a_link_record_for_three_ders_and_three_bare_circuits() -> None: + """Guard every expectation below, and the negative case with them. + + Three DER-feeding circuits publish the record; the remaining circuits + declare the node and publish neither half of it. That second group is the + absence case, and it is in the capture rather than manufactured here. + """ + tree = schema_one_tree() + circuits = { + device_id + for device_id, topics in tree.items() + if json.loads(topics["$description"])["type"].endswith(".circuit") + } + feeding = {_feeding_circuit(tree, der) for der in (PV, EVSE, EVSE_2)} + + assert feeding <= circuits + for circuit in feeding: + assert tree[circuit][FEEDS_STATUS_TOPIC] in _status_options() + + bare = circuits - feeding + assert len(bare) == 3, f"expected three circuits feeding no DER, found {len(bare)}" + for circuit in bare: + declared = json.loads(tree[circuit]["$description"])["nodes"] + assert CONNECTION_NODE in declared, ( + f"{circuit} does not declare the node, so its silence proves nothing about " + "a circuit that declares the record and publishes none of it" + ) + assert not [topic for topic in tree[circuit] if topic.startswith(f"{CONNECTION_NODE}/")] + + +def test_the_status_enum_has_no_unknown_member() -> None: + """Why absence has to mean unknown: the enum cannot say it.""" + options = _status_options() + + assert STATUS_OK in options + assert "UNKNOWN" not in options + assert _not_ok(), "the enum offers no bad status, so nothing below can observe a broken link" + + +# --------------------------------------------------------------------------- +# The readings +# --------------------------------------------------------------------------- + + +async def test_each_der_reports_the_link_its_own_circuit_publishes(hass: HomeAssistant) -> None: + """Baseline, with every expectation computed from the capture.""" + tree = schema_one_tree() + snapshot = schema_one_snapshot(tree) + created = await _created(hass, snapshot) + + assert _state(_pv_link(created)) is ( + tree[_feeding_circuit(tree, PV)][FEEDS_STATUS_TOPIC] == STATUS_OK + ) + + links = _evse_links_by_feed(created, snapshot) + assert len(links) == 2, "the capture commissions two chargers; both should carry a link sensor" + for der in (EVSE, EVSE_2): + circuit = _feeding_circuit(tree, der) + assert _state(links[circuit]) is (tree[circuit][FEEDS_STATUS_TOPIC] == STATUS_OK) + + +@pytest.mark.parametrize("status", _not_ok()) +async def test_a_bad_status_flips_the_sensor(hass: HomeAssistant, status: str) -> None: + """Both non-OK members, so a check written as `!= "LOST"` fails on DEGRADED.""" + snapshot = _republishing(pv=status, evse=status) + created = await _created(hass, snapshot) + + assert _state(_pv_link(created)) is False + links = _evse_links_by_feed(created, snapshot) + assert _state(links[_feeding_circuit(schema_one_tree(), EVSE)]) is False + + +async def test_two_chargers_do_not_share_one_link(hass: HomeAssistant) -> None: + """The cross-wiring case, and the reason the capture carries two chargers. + + Both read `OK` as captured, so the baseline test above is satisfied by an + implementation that hands every charger the first record it finds. Here the + two are republished differing and then swapped: getting one arrangement + right by luck is possible, both is not. + """ + down, degraded = _not_ok()[0], _not_ok()[-1] + tree = schema_one_tree() + first_circuit, second_circuit = _feeding_circuit(tree, EVSE), _feeding_circuit(tree, EVSE_2) + + for first, second in ((down, STATUS_OK), (STATUS_OK, down), (degraded, STATUS_OK)): + snapshot = _republishing(evse=first, **{"evse_2": second}) + links = _evse_links_by_feed(await _created(hass, snapshot), snapshot) + + assert _state(links[first_circuit]) is (first == STATUS_OK) + assert _state(links[second_circuit]) is (second == STATUS_OK) + + +async def test_the_inverters_link_is_not_a_chargers(hass: HomeAssistant) -> None: + """The third DER, held apart from the two chargers the same way.""" + snapshot = _republishing(pv=_not_ok()[0]) + created = await _created(hass, snapshot) + + assert _state(_pv_link(created)) is False + for link in _evse_links_by_feed(created, snapshot).values(): + assert _state(link) is True + + +# --------------------------------------------------------------------------- +# Absence is not a fault +# --------------------------------------------------------------------------- + + +async def test_a_der_whose_circuit_publishes_no_status_gets_no_entity( + hass: HomeAssistant, +) -> None: + """The gate is the record, and a retained topic can simply go away. + + Not "unavailable" and not `False`: the panel has said nothing, the enum has + no way to say it, and an entity reporting a broken link on that basis would + be inventing the one reading a user would act on. + """ + snapshot = _without_status(PV) + created = await _created(hass, snapshot) + + assert PV_PANEL_LINK_SENSOR.key not in _keys(created) + assert len(_evse_links_by_feed(created, snapshot)) == 2, ( + "removing the inverter's status removed a charger's sensor too" + ) + + +async def test_a_charger_whose_circuit_publishes_no_record_gets_no_entity( + hass: HomeAssistant, +) -> None: + """Per charger, not per panel. + + One of two chargers losing its record must remove one of two sensors. A gate + that asked "does any DER have a record" would keep both, and the surviving + one would report a link nothing publishes. + """ + snapshot = _without_record(EVSE) + created = await _created(hass, snapshot) + + links = _evse_links_by_feed(created, snapshot) + assert len(links) == 1 + assert _feeding_circuit(schema_one_tree(), EVSE_2) in links + assert PV_PANEL_LINK_SENSOR.key in _keys(created), "the inverter's sensor went with it" + + +async def test_the_circuits_that_feed_no_der_create_nothing(hass: HomeAssistant) -> None: + """The capture's three bare circuits, asserted as producing no entity. + + The `feeds-*` triple is absent from a mixed-load circuit by design, so the + count of link sensors must equal the count of DER the capture claims — three + — and not the count of circuits, five. + """ + snapshot = schema_one_snapshot() + created = await _created(hass, snapshot) + + link_keys = [key for key in _keys(created) if key.endswith("_panel_link")] + assert len(link_keys) == 3 + + +async def test_a_panel_with_no_connection_records_creates_neither_sensor( + hass: HomeAssistant, +) -> None: + """A flat panel, and any v1.0 panel whose circuits publish no record. + + The factory snapshot leaves both fields `None`, which is what every flat + panel produces: flat publishes `connected` on the BESS and on no other + device class. + """ + snapshot = SpanPanelSnapshotFactory.create() + created = await _created(hass, snapshot) + + assert [key for key in _keys(created) if key.endswith("_panel_link")] == [] + + +# --------------------------------------------------------------------------- +# The fact this must not be confused with +# --------------------------------------------------------------------------- + + +async def test_the_charger_link_is_not_the_ev_plug(hass: HomeAssistant) -> None: + """`evse_panel_link` and `evse_ev_connected` disagree, and both are right. + + The state that separates them: a charger mid-session behind a link the + enclosure has lost. `status/status` still says a vehicle is plugged in — + that is the last thing the panel heard — while the feeding circuit reports + the link as down. One entity for both facts would have to pick, and would be + wrong about one of them every time they diverge. + + The two sensors are also told apart at a glance: `CONNECTIVITY` against + `PLUG`, diagnostic against primary, and neither key is a prefix of the + other, so no automation can select one meaning to get the other. + """ + tree = schema_one_tree() + circuit = _feeding_circuit(tree, EVSE) + plugged_in = tree[EVSE]["status/status"] + snapshot = _republishing(evse=_not_ok()[0]) + created = await _created(hass, snapshot) + + link = _evse_links_by_feed(created, snapshot)[circuit] + plug = next( + entity + for entity in created + if isinstance(entity, SpanEvseBinarySensor) + and entity.entity_description.key == "evse_ev_connected" + and snapshot.evse[entity._evse_id].feed_circuit_id == circuit + ) + + assert snapshot.evse[link._evse_id].status == plugged_in + assert _state(link) is False + assert _state(plug) is True + + ev_connected = next(desc for desc in EVSE_BINARY_SENSORS if desc.key == "evse_ev_connected") + assert EVSE_PANEL_LINK_SENSOR.device_class is BinarySensorDeviceClass.CONNECTIVITY + assert ev_connected.device_class is BinarySensorDeviceClass.PLUG + assert EVSE_PANEL_LINK_SENSOR.entity_category is EntityCategory.DIAGNOSTIC + assert ev_connected.entity_category is None + assert EVSE_PANEL_LINK_SENSOR.field_path != ev_connected.field_path + assert not EVSE_PANEL_LINK_SENSOR.key.startswith(ev_connected.key) + assert not ev_connected.key.startswith(EVSE_PANEL_LINK_SENSOR.key) + + +async def test_the_inverter_link_does_not_displace_the_batterys(hass: HomeAssistant) -> None: + """`bess_connected` reads the lugs; these read a circuit. Both survive. + + Breaking every circuit-side record must leave the battery's sensor reporting + what the upstream lugs say, or the new route has quietly taken over a field + that was already right. + """ + down = _not_ok()[0] + snapshot = _republishing(pv=down, evse=down, **{"evse_2": down}) + created = await _created(hass, snapshot) + + bess = next( + entity + for entity in created + if isinstance(entity, SpanPanelBinarySensor) + and entity.entity_description.key == "bess_connected" + ) + assert _state(bess) is True + + +# --------------------------------------------------------------------------- +# Declarations and gating +# --------------------------------------------------------------------------- + + +def test_both_descriptions_name_their_field_and_say_why_it_is_exempt() -> None: + """`SCHEMA_CONDITIONAL_FIELD` *and* `field_path`, per the established rule. + + Flat firmware publishes `connected` on the BESS alone, so neither path can + satisfy the both-adapters gate — while both entities still need their Repair + mention and their unavailability when the panel stops resolving the property. + """ + for description in (PV_PANEL_LINK_SENSOR, EVSE_PANEL_LINK_SENSOR): + assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD + assert description.field_path is not None + assert RESIDUAL_EXEMPT_PATHS[description.field_path] is Producibility.SCHEMA_1_ONLY + + assert PV_PANEL_LINK_SENSOR.field_path == "pv.connected" + assert EVSE_PANEL_LINK_SENSOR.field_path == "evse.connected" + + +def test_the_capability_gate_follows_the_record_and_reaches_the_reload() -> None: + """A panel that starts publishing the record must be able to gain the entities. + + Entities are created at setup, so a capability that appears later reaches a + user only through `detect_capabilities` and the reload it triggers. + """ + assert has_der_link_health(schema_one_snapshot()) is True + assert has_der_link_health(SpanPanelSnapshotFactory.create()) is False + assert has_der_link_health(_without_record(PV)) is True, ( + "the chargers still publish records; the gate must not be all-or-nothing" + ) + + assert "der_link_health" in detect_capabilities(schema_one_snapshot()) + assert "der_link_health" not in detect_capabilities(SpanPanelSnapshotFactory.create()) diff --git a/tests/test_device_links.py b/tests/test_device_links.py new file mode 100644 index 00000000..6ea08af2 --- /dev/null +++ b/tests/test_device_links.py @@ -0,0 +1,273 @@ +"""Sub-devices hang off the panel by registry id, not by identifiers. + +Home Assistant deprecated `via_device=(DOMAIN, serial)` and the unscoped +`async_get_device(identifiers=...)` together in 2026.8, for one reason: device +identifiers are unique only *within* a config entry, so anything treating them as +globally unique is ambiguous by construction. Both stop working in 2027.8. + +The replacement is a registry id, which only exists once the panel device does. +That is the whole design constraint here — a sub-device cannot name its parent +until its parent is registered — and it is why setup resolves the id once and +carries it on the entry's runtime data instead of every platform looking it up. + +The end-to-end test below is the one that matters. `via_device_id` naming a +device that does not exist is dropped by the registry rather than rejected, so a +wrong id threaded through these builders would not raise anywhere: the device +would simply appear unparented in the UI, which is exactly the kind of failure +a unit test asserting on a dict cannot see. +""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import SpanMidSnapshot, SpanPVSnapshot + +from custom_components.span_panel import ensure_device_registered +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.util import ( + bess_device_info, + classify_sub_device_identifier, + evse_device_info, + mid_device_info, + pv_device_info, +) + +from .factories import ( + SpanBatterySnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, +) + +_PANEL_ID = "a-registry-id" + + +def _mid() -> SpanMidSnapshot: + return SpanMidSnapshot( + node_id="sp3-link-001-mid", + serial_number="sp3-link-001-mid", + vendor_name="Span", + model=None, + islanding_state="ON_GRID", + grid_state="UP", + grid_forming_entity="GRID", + ) + + +def _builders() -> list[tuple[str, Any]]: + """Every sub-device builder, so a new one is covered the day it lands.""" + return [ + ( + "bess", + lambda: bess_device_info( + "sp3-link-001", + SpanBatterySnapshotFactory.create(), + "Panel", + panel_device_id=_PANEL_ID, + ), + ), + ( + "mid", + lambda: mid_device_info( + "sp3-link-001", _mid(), "Panel", panel_device_id=_PANEL_ID + ), + ), + ( + "pv", + lambda: pv_device_info( + "sp3-link-001", + SpanPVSnapshot(vendor_name="Enphase", model="IQ8", software_version="v1"), + "Panel", + panel_device_id=_PANEL_ID, + ), + ), + ( + "evse", + lambda: evse_device_info( + "sp3-link-001", + SpanEvseSnapshotFactory.create(), + "Panel", + panel_device_id=_PANEL_ID, + ), + ), + ] + + +@pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "") +def test_every_builders_identifier_classifies_as_its_own_kind(label: str, build: Any) -> None: + """The writing end and the reading end must agree, for every kind. + + They did not: the topology command restated the identifier grammar instead of + reading it back, so when the MID was added it classified as `unknown` and a + card rendered it as a device with a name and no type. Parametrised over the + same builder list as the link test, so a fourth sub-device added to `util` + without teaching `classify_sub_device_identifier` fails here rather than in + someone's dashboard. + """ + info = build() + identifiers = info.get("identifiers") or set() + kinds = {classify_sub_device_identifier(identifier) for _domain, identifier in identifiers} + + assert kinds == {label}, f"{label} identifier classifies as {kinds}" + + +def test_the_panel_is_not_mistaken_for_a_sub_device() -> None: + """A bare serial names the panel, and the panel is nobody's child. + + The classifier reads suffixes, so the guard worth having is that an + identifier carrying no kind at all comes back as None rather than matching + something by accident. + """ + assert classify_sub_device_identifier("sp3-link-001") is None + assert classify_sub_device_identifier("sim-40t-001") is None + + +def test_a_charger_node_ending_in_a_kind_is_still_a_charger() -> None: + """The infix wins over the suffixes, which is why it is tested first. + + A charger's identifier carries its Homie node id, and nothing stops a node id + from ending in `_pv` or `_bess`. Testing the suffix rules first would classify + such a charger as whatever its node id happened to end with -- a device + rendering as the wrong type on a dashboard, which is the failure the MID + already shipped once. + """ + assert classify_sub_device_identifier("sp3-link-001_evse_inverter_pv") == "evse" + assert classify_sub_device_identifier("sp3-link-001_evse_garage_bess") == "evse" + + +@pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "") +def test_sub_devices_link_by_registry_id(label: str, build: Any) -> None: + """Both halves asserted: the new key is set and the old one is gone. + + Checking only that `via_device_id` is present would pass a builder that + passes both, which Home Assistant accepts today and stops accepting in + 2027.8 -- a regression that would sit unnoticed until the deadline. + """ + info = build() + + assert info.get("via_device_id") == _PANEL_ID, label + assert "via_device" not in info, f"{label} still links by identifiers" + + +async def test_registering_the_panel_answers_with_the_id_sub_devices_need( + hass: HomeAssistant, +) -> None: + """The returned id is the created device's, on the branch that creates it.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.30"}, + entry_id="entry-link-new", + unique_id="sp3-link-new", + ) + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-link-new") + + panel_device_id = await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") + + created = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, "sp3-link-new"), entry.entry_id + ) + assert created is not None + assert panel_device_id == created.id + + +async def test_an_already_registered_panel_answers_with_the_same_id( + hass: HomeAssistant, +) -> None: + """And on the branch that finds one, which is every reload after the first. + + A second registration must not mint a new id: sub-devices registered against + the old one would be orphaned, and a user would watch their battery and + chargers detach from the panel on a restart. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.31"}, + entry_id="entry-link-existing", + unique_id="sp3-link-existing", + ) + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-link-existing") + + first = await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") + second = await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") + + assert first == second + + +async def test_a_foreign_device_sharing_the_identifier_is_not_adopted( + hass: HomeAssistant, +) -> None: + """The ambiguity the deprecation exists to remove, made concrete. + + Identifiers are unique within a config entry and nowhere else, so a lookup + that searches every entry can answer with a device this entry does not own -- + and then every sub-device built from that answer hangs off somebody else's + panel. Scoping the lookup is what makes the answer unambiguous. + + A second SPAN entry cannot reach this state today, because the serial is the + entry's unique_id. That is a property of our config flow rather than of the + registry, though, and it is not the assumption the sub-device links should + rest on. + """ + other = MockConfigEntry( + domain=DOMAIN, data={}, entry_id="entry-other", unique_id="other" + ) + other.add_to_hass(hass) + registry = dr.async_get(hass) + foreign = registry.async_get_or_create( + config_entry_id=other.entry_id, + identifiers={(DOMAIN, "sp3-link-shared")}, + name="Somebody else's panel", + ) + + mine = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.33"}, + entry_id="entry-mine", + unique_id="sp3-link-shared", + ) + mine.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-link-shared") + + panel_device_id = await ensure_device_registered(hass, mine, snapshot, "SPAN Panel") + + assert panel_device_id != foreign.id + assert registry.async_get(panel_device_id) is not None + + +async def test_a_sub_device_really_lands_under_the_panel(hass: HomeAssistant) -> None: + """End to end through the registry, because a bad id fails silently. + + The registry drops a `via_device_id` it cannot resolve instead of raising, so + the only way to know the id threaded from setup through runtime data into a + builder is the right one is to register a device with it and read the link + back. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.32"}, + entry_id="entry-link-e2e", + unique_id="sp3-link-e2e", + ) + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-link-e2e") + registry = dr.async_get(hass) + + panel_device_id = await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") + bess = registry.async_get_or_create( + config_entry_id=entry.entry_id, + **bess_device_info( + "sp3-link-e2e", + SpanBatterySnapshotFactory.create(), + "SPAN Panel", + panel_device_id=panel_device_id, + ), + ) + + assert bess.via_device_id == panel_device_id diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index c5af3331..009a1a88 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -2,10 +2,19 @@ from __future__ import annotations +from dataclasses import replace +import json from types import SimpleNamespace from unittest.mock import MagicMock from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.sensor import SensorStateClass +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import SpanPanelSnapshot, SpanPVSnapshot + from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.const import ( CONF_EBUS_BROKER_PASSWORD, @@ -13,21 +22,27 @@ CONF_HOP_PASSPHRASE, DOMAIN, ) +from custom_components.span_panel.curation import ( + CurationOverlay, + CurationRecord, + async_load_curation, + async_save_record, +) from custom_components.span_panel.diagnostics import ( async_get_config_entry_diagnostics, ) -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.core import HomeAssistant +from custom_components.span_panel.helpers import identity_digest +from custom_components.span_panel.pv_binding import PvBinding, StoredPvBinding, resolve +from .adapter_fixtures import schema_one_snapshot from .factories import ( SpanBatterySnapshotFactory, SpanCircuitSnapshotFactory, SpanEvseSnapshotFactory, SpanPanelSnapshotFactory, + pv_binding_for, ) -from pytest_homeassistant_custom_component.common import MockConfigEntry - async def test_config_entry_diagnostics_includes_redacted_runtime_data( hass: HomeAssistant, @@ -63,10 +78,18 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data( coordinator = MagicMock() coordinator.data = snapshot coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.last_update_success = True + # Explicit: a MagicMock answers `len()` and iteration happily, so leaving + # this unset would let the discovery block render as an empty report rather + # than as the "no metadata yet" state it actually is. + coordinator.schema_findings = None + # Version 6 deliberately: an entry that has not yet been through the v7 + # migration still carries the passphrase, and redaction must still cover it. entry = MockConfigEntry( domain=DOMAIN, + version=6, data={ CONF_ACCESS_TOKEN: "access-secret", CONF_EBUS_BROKER_PASSWORD: "mqtt-password", @@ -76,7 +99,13 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data( title="SPAN Panel", unique_id="sp3-diag-001", ) - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) result = await async_get_config_entry_diagnostics(hass, entry) @@ -89,6 +118,9 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data( "serial_number": "sp3-diag-001", "firmware_version": "spanos2/r202603/05", "panel_size": 32, + "lugs_at_service_entrance": True, + "instant_grid_power_w": 2500.75, + "power_flow_grid": None, "wifi_ssid": "Span WiFi", "eth0_link": True, "wlan_link": False, @@ -136,28 +168,44 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable eth0_link=None, wlan_link=None, circuits={ - "uuid_minimal": SimpleNamespace( - name=None, + # The real model, not a namespace: every field the diagnostics dump + # reads off a circuit is present on `SpanCircuitSnapshot`, and a hand + # -rolled double that omitted two of them is what kept a pair of + # unreachable `hasattr` guards alive in the dump. + "uuid_minimal": SpanCircuitSnapshotFactory.create( + circuit_id="uuid_minimal", + name="", relay_state="OPEN", - relay_state_target=None, priority="NEVER", - priority_target=None, is_user_controllable=False, instant_power_w=0.0, produced_energy_wh=0.0, consumed_energy_wh=0.0, + tabs=[], ) }, evse={}, + pv_inverters={}, battery=None, + adopted_devices=(), + lugs_at_service_entrance=True, + instant_grid_power_w=0.0, + power_flow_grid=None, ) coordinator = MagicMock() coordinator.data = snapshot coordinator.panel_offline = True coordinator.last_update_success = False + coordinator.schema_findings = None entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) result = await async_get_config_entry_diagnostics(hass, entry) @@ -165,9 +213,12 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable "serial_number": "sp3-diag-002", "firmware_version": "spanos2/r202603/06", "panel_size": None, + "lugs_at_service_entrance": True, + "instant_grid_power_w": 0.0, + "power_flow_grid": None, } assert result["circuits"]["uuid_minimal"] == { - "name": None, + "name": "", "relay_state": "OPEN", "relay_state_target": None, "priority": "NEVER", @@ -176,6 +227,8 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable "instant_power_w": 0.0, "produced_energy_wh": 0.0, "consumed_energy_wh": 0.0, + "device_type": "circuit", + "tabs": [], } assert result["evse"] == {} assert result["battery"] == {} @@ -183,3 +236,267 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable "panel_offline": True, "last_update_success": False, } + + +async def test_diagnostics_reports_the_entity_registry(hass: HomeAssistant) -> None: + """The registry is where an upgrade complaint is settled, and the UI hides it. + + Home Assistant says "This entity is disabled" without saying by what, and a + user without shell access to `.storage` cannot read `disabled_by` at all. Four + causes look identical on screen and need four different fixes, so the field + that distinguishes them has to leave the machine somehow. + + `unique_id` rides along because it answers the question underneath: an entity + whose id changed is a new entity however familiar its name, and that is the + difference between an upgrade defect and a surprise. + """ + entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") + entry.add_to_hass(hass) + registry = er.async_get(hass) + registry.async_get_or_create( + "sensor", + DOMAIN, + "span_sp3_diag_003_l1_voltage", + config_entry=entry, + suggested_object_id="span_panel_l1_voltage", + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + ) + + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-diag-003") + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.schema_findings = None + # The real runtime data, not a namespace, for the reason the circuit double + # above is the real model: a hand-rolled stand-in carrying only the fields + # the dump happened to read when it was written goes stale silently, and + # mypy cannot see the drift on a `SimpleNamespace`. + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + + result = await async_get_config_entry_diagnostics(hass, entry) + + rows = {row["entity_id"]: row for row in result["entities"]} + assert "sensor.span_panel_l1_voltage" in rows + row = rows["sensor.span_panel_l1_voltage"] + assert row["disabled_by"] == "integration" + assert row["unique_id"] == "span_sp3_diag_003_l1_voltage" + + +async def test_diagnostics_reports_the_stored_curation(hass: HomeAssistant) -> None: + """What the user asserted about adopted rows is the other half of the adoption block. + + The adoption block says what the panel published. Without this one, a + maintainer reading an attachment cannot tell a state class the integration + derived from one the household declared, and those two fail differently. + + Seeded through the store rather than by handing an overlay in, because what + the payload has to carry is what a *loaded* entry holds: the overlay + diagnostics reads is the one setup read off disk. + """ + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="curated-entry", title="SPAN Panel") + entry.add_to_hass(hass) + await async_save_record( + hass, + entry, + "bess/battery-2/cell-voltage", + CurationRecord(state_class=SensorStateClass.MEASUREMENT, device_class="voltage"), + ) + await async_save_record(hass, entry, "bess/battery-2/enabled", CurationRecord(promote=True)) + + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-diag-004") + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.schema_findings = None + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=await async_load_curation(hass, entry), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + + result = await async_get_config_entry_diagnostics(hass, entry) + + assert result["adopted_curation"] == { + "bess/battery-2/cell-voltage": {"state_class": "measurement", "device_class": "voltage"}, + "bess/battery-2/enabled": {"entity_category": "none"}, + } + # Keys and enum values, and the assertion is on the shape rather than on a + # filter this test applies: a record that grew a name, an icon or a wire + # value would be a leak the payload cannot redact, because `TO_REDACT` is + # key-based over the config entry and reaches nothing here. + assert all( + set(record) <= {"state_class", "device_class", "entity_category"} + for record in result["adopted_curation"].values() + ) + + +async def test_diagnostics_reports_which_inverter_the_solar_card_reads(hass: HomeAssistant) -> None: + """The first facts a span#269-style report needs: a circuit-fed inverter, shown by its circuit.""" + snapshot = schema_one_snapshot() + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="pv-diag-entry", title="SPAN Panel") + entry.add_to_hass(hass) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.schema_findings = None + identity = pv_binding_for(snapshot) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=identity, + setup_snapshot=snapshot, + ) + + result = await async_get_config_entry_diagnostics(hass, entry) + + (key,) = snapshot.pv_inverters + inverter = snapshot.pv_inverters[key] + assert result["pv"] == { + "mode": "inverter", + "bound_circuit_id": key, + "legacy_key": key, + "solar_card_reads": key, + "solar_link": True, + "withheld": [], + "inverters": { + key: { + "feed_circuit_id": inverter.feed_circuit_id, + "relative_position": inverter.relative_position, + "connected": inverter.connected, + "own_card": False, + "own_link": False, + } + }, + } + + +async def test_diagnostics_says_an_unbound_card_reads_several_inverters_together(hass: HomeAssistant) -> None: + snapshot = replace( + SpanPanelSnapshotFactory.create(serial_number="sp3-diag-pv2"), + pv_inverters={ + "c-1": SpanPVSnapshot(device_id="pv-1", node_id="c-1", feed_circuit_id="c-1"), + "c-2": SpanPVSnapshot(device_id="pv-2", node_id="c-2", feed_circuit_id="c-2"), + }, + ) + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="pv-diag-two", title="SPAN Panel") + entry.add_to_hass(hass) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.schema_findings = None + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + + result = await async_get_config_entry_diagnostics(hass, entry) + + assert result["pv"]["mode"] == "unbound" + assert result["pv"]["solar_card_reads"] == "together" + assert {row["own_card"] for row in result["pv"]["inverters"].values()} == {True} + # Neither inverter's circuit publishes a link record, so neither card, nor the Solar card, has a link. + assert {row["own_link"] for row in result["pv"]["inverters"].values()} == {False} + assert result["pv"]["solar_link"] is False + + +async def _pv_block(hass: HomeAssistant, entry_id: str, snapshot: SpanPanelSnapshot, binding: PvBinding) -> dict[str, object]: + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id=entry_id, title="SPAN Panel") + entry.add_to_hass(hass) + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.schema_findings = None + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=binding, + setup_snapshot=snapshot, + ) + result = await async_get_config_entry_diagnostics(hass, entry) + block: dict[str, object] = result["pv"] + return block + + +SERIAL_PV = "sp3-diag-serial-001" +DEVICE_ID_KEY = f"{SERIAL_PV}-pv" +"""An inverter no circuit feeds is keyed by its device id, which embeds the panel serial.""" + + +async def test_an_unfed_inverters_device_id_key_is_digested_where_the_card_reads_it(hass: HomeAssistant) -> None: + """Unbound, one inverter no circuit feeds: its key is shown digested, as the Solar card's legacy key too.""" + snapshot = replace( + SpanPanelSnapshotFactory.create(serial_number=SERIAL_PV), + pv_inverters={DEVICE_ID_KEY: SpanPVSnapshot(device_id=DEVICE_ID_KEY, node_id=DEVICE_ID_KEY)}, + ) + binding = resolve(snapshot, None, frozenset(), link_held=False, inverter_links_held=frozenset())[0] + assert binding.legacy_key == DEVICE_ID_KEY + + block = await _pv_block(hass, "pv-diag-unfed", snapshot, binding) + + digest = identity_digest(DEVICE_ID_KEY) + assert block["mode"] == "unbound" + assert block["legacy_key"] == digest + assert block["solar_card_reads"] == digest + assert block["inverters"] == { + digest: { + "feed_circuit_id": None, + "relative_position": None, + "connected": None, + "own_card": False, + "own_link": False, + } + } + assert SERIAL_PV not in json.dumps(block) + + +async def test_a_withheld_inverters_device_id_key_is_digested(hass: HomeAssistant) -> None: + """Pending: the bound circuit is published, its inverter is not, and a device-id inverter is withheld.""" + snapshot = replace( + SpanPanelSnapshotFactory.create( + serial_number=SERIAL_PV, circuits={"c-bound": SpanCircuitSnapshotFactory.create(circuit_id="c-bound")} + ), + pv_inverters={DEVICE_ID_KEY: SpanPVSnapshot(device_id=DEVICE_ID_KEY, node_id=DEVICE_ID_KEY)}, + ) + binding = resolve(snapshot, StoredPvBinding(circuit_id="c-bound"), frozenset(), link_held=False, inverter_links_held=frozenset())[0] + assert binding.withheld == frozenset({DEVICE_ID_KEY}) + + block = await _pv_block(hass, "pv-diag-withheld", snapshot, binding) + + digest = identity_digest(DEVICE_ID_KEY) + assert block["mode"] == "inverter" + assert block["bound_circuit_id"] == "c-bound" + assert block["legacy_key"] == "c-bound" + assert block["withheld"] == [digest] + assert block["inverters"] == { + digest: { + "feed_circuit_id": None, + "relative_position": None, + "connected": None, + "own_card": False, + "own_link": False, + } + } + assert SERIAL_PV not in json.dumps(block) diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py index 8985afa2..9193c352 100644 --- a/tests/test_dps_and_bess.py +++ b/tests/test_dps_and_bess.py @@ -1,7 +1,9 @@ """Tests for GFE override buttons and BESS connected binary sensor.""" -from unittest.mock import AsyncMock, MagicMock, patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +from homeassistant.exceptions import HomeAssistantError import pytest from span_panel_api import SpanBatterySnapshot from span_panel_api.exceptions import SpanPanelServerError @@ -12,6 +14,8 @@ SpanPanelGFEOverrideButton, ) from custom_components.span_panel.helpers import has_bess +from custom_components.span_panel.sensor_definitions import BESS_METADATA_SENSORS +from custom_components.span_panel.sensor_panel import _grid_forming_device_name from .factories import SpanPanelSnapshotFactory @@ -67,15 +71,22 @@ def test_bess_sensor_created_with_bess(self) -> None: def _make_gfe_coordinator( dominant_power_source: str | None = "GRID", battery: SpanBatterySnapshot | None = None, + dsm_state: str = "DSM_ON_GRID", ) -> MagicMock: - """Build a mock coordinator for GFE button tests.""" + """Build a mock coordinator for GFE button tests. + + `dsm_state` is what the button's availability guard reads; `dominant_power_source` + stays because other tests here assert on the GFE sensor itself. + """ snapshot = SpanPanelSnapshotFactory.create( dominant_power_source=dominant_power_source, + dsm_state=dsm_state, battery=battery if battery is not None else SpanBatterySnapshot(), ) coordinator = MagicMock() coordinator.data = snapshot + coordinator.transport_dead = False coordinator.config_entry = MagicMock() coordinator.config_entry.title = "SPAN Panel" coordinator.config_entry.data = {} @@ -113,50 +124,28 @@ async def test_grid_button_press_publishes_grid(self) -> None: coordinator.client.set_dominant_power_source.assert_called_once_with("GRID") coordinator.async_request_refresh.assert_called_once() - @pytest.mark.asyncio - async def test_button_missing_control_method(self) -> None: - """Client without set_dominant_power_source method logs warning.""" - coordinator = _make_gfe_coordinator() - button = SpanPanelGFEOverrideButton( - coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" - ) - button.hass = MagicMock() - - # Client without set_dominant_power_source - coordinator.client = MagicMock(spec=[]) - - await button.async_press() - - # Should not raise, just log - coordinator.async_request_refresh.assert_not_called() - @pytest.mark.asyncio async def test_button_server_error(self) -> None: - """SpanPanelServerError triggers a notification.""" + """SpanPanelServerError surfaces as a translated Home Assistant error.""" coordinator = _make_gfe_coordinator() - with patch( - "custom_components.span_panel.button.async_create_span_notification", - new_callable=AsyncMock, - ) as mock_notification: - button = SpanPanelGFEOverrideButton( - coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" - ) - button.hass = MagicMock() + button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID") + button.hass = MagicMock() - coordinator.client = AsyncMock() - coordinator.client.set_dominant_power_source = AsyncMock( - side_effect=SpanPanelServerError("test error") - ) + coordinator.client = AsyncMock() + coordinator.client.set_dominant_power_source = AsyncMock( + side_effect=SpanPanelServerError("test error") + ) + with pytest.raises(HomeAssistantError) as raised: await button.async_press() - mock_notification.assert_called_once() + assert raised.value.translation_key == "gfe_override_failed" def test_available_when_bess_offline_and_not_grid(self) -> None: - """Button is available when BESS is offline and GFE is not GRID.""" + """Button is available when BESS is offline and the panel is not on grid.""" coordinator = _make_gfe_coordinator( - dominant_power_source="BATTERY", + dsm_state="DSM_OFF_GRID", battery=SpanBatterySnapshot(soe_percentage=50.0, connected=False), ) coordinator.panel_offline = False @@ -190,9 +179,9 @@ def test_unavailable_when_gfe_is_grid(self) -> None: assert button.available is False def test_available_when_no_bess(self) -> None: - """Button is available when no BESS is commissioned and GFE is not GRID.""" + """Button is available when no BESS is commissioned and the panel is not on grid.""" coordinator = _make_gfe_coordinator( - dominant_power_source="BATTERY", + dsm_state="DSM_OFF_GRID", battery=SpanBatterySnapshot(), ) coordinator.panel_offline = False @@ -200,3 +189,50 @@ def test_available_when_no_bess(self) -> None: coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID" ) assert button.available is True + + +class TestGridFormingDeviceAttribute: + """The GFE sensor's `grid_forming_device` attribute.""" + + def test_the_attribute_is_absent_when_the_library_has_no_mid(self) -> None: + """Degrades to nothing against span-panel-api 2.6.4, which has no `mid` field. + + Also the correct answer on any flat panel: no flat firmware publishes a MID, so + there is no forming device to name. + """ + snapshot = SpanPanelSnapshotFactory.create(dominant_power_source="BATTERY") + + assert _grid_forming_device_name(snapshot) is None + + def test_the_attribute_is_the_display_name_not_the_wire_id(self) -> None: + """A Homie device id is not a Home Assistant device id. + + The state keeps the closed enum automations compare against; this refines it with + the part a person recognises. + """ + mid = SimpleNamespace( + grid_forming_entity="sim-40t-001-SIM-BESS-40T-001", + grid_forming_device_name="Battery", + ) + snapshot = SimpleNamespace(mid=mid) + + assert _grid_forming_device_name(snapshot) == "Battery" + + def test_no_name_when_the_grid_itself_is_forming(self) -> None: + """There is no device to name, so the attribute stays off the sensor.""" + snapshot = SimpleNamespace(mid=SimpleNamespace(grid_forming_device_name=None)) + + assert _grid_forming_device_name(snapshot) is None + + +# --------------------------------------------------------------------------- +# BESS metadata sensor declarations +# --------------------------------------------------------------------------- + + +def test_bess_part_number_sensor_is_declared() -> None: + """The BESS SKU is surfaced, and declares the field it reads.""" + part = next(d for d in BESS_METADATA_SENSORS if d.key == "part_number") + + assert part.field_path == "battery.part_number" + assert part.derived is None diff --git a/tests/test_energy_dip_compensation.py b/tests/test_energy_dip_compensation.py index 394b878a..10092a09 100644 --- a/tests/test_energy_dip_compensation.py +++ b/tests/test_energy_dip_compensation.py @@ -6,7 +6,10 @@ from unittest.mock import MagicMock from homeassistant.components.sensor import SensorStateClass + from custom_components.span_panel.const import ENABLE_ENERGY_DIP_COMPENSATION +from custom_components.span_panel.energy_dip import CONFIRMED_RETRACTION_WINDOW, PendingDip +from custom_components.span_panel.energy_orientation import EnergyBinding from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD from custom_components.span_panel.sensor_base import ( SpanEnergyExtraStoredData, @@ -23,8 +26,10 @@ def __init__( # pylint: disable=super-init-not-called state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING, ) -> None: """Bypass parent __init__ to avoid full HA dependencies.""" + self._bind_energy(EnergyBinding(meter=None, role=None, net=None)) self.coordinator = SimpleNamespace( panel_offline=False, + transport_dead=False, config_entry=SimpleNamespace( options={ ENERGY_REPORTING_GRACE_PERIOD: 15, @@ -55,21 +60,49 @@ def __init__( # pylint: disable=super-init-not-called self._energy_offset: float = 0.0 self._last_panel_reading: float | None = None self._last_dip_delta: float | None = None - self._is_total_increasing: bool = ( - state_class == SensorStateClass.TOTAL_INCREASING - ) + self._pending_dip: PendingDip | None = None + self._recently_confirmed_dip: PendingDip | None = None + self._is_total_increasing: bool = state_class == SensorStateClass.TOTAL_INCREASING self._dip_compensation_enabled: bool = dip_enabled def _generate_unique_id(self, snapshot, description): return "dummy_sensor" - def _generate_friendly_name(self, snapshot, description): - return "Dummy" - def get_data_source(self, snapshot): return "dummy_data" +def _restart(before: DummyDipSensor) -> DummyDipSensor: + """Persist a sensor's dip state and restore it into a fresh one. + + Mirrors what `SpanEnergySensorBase.async_added_to_hass` does with the + record `extra_restore_state_data` wrote, without an HA instance to store it. + """ + restored = SpanEnergyExtraStoredData.from_dict(before.extra_restore_state_data.as_dict()) + assert restored is not None + + after = DummyDipSensor(dip_enabled=True) + after._energy_offset = restored.energy_offset or 0.0 + after._last_panel_reading = restored.last_panel_reading + after._last_dip_delta = restored.last_dip_delta + if restored.pending_dip_baseline is not None and restored.pending_dip_delta is not None: + after._pending_dip = PendingDip( + baseline=restored.pending_dip_baseline, + delta=restored.pending_dip_delta, + ) + if ( + restored.confirmed_dip_baseline is not None + and restored.confirmed_dip_delta is not None + and restored.confirmed_dip_ticks_left is not None + ): + after._recently_confirmed_dip = PendingDip( + baseline=restored.confirmed_dip_baseline, + delta=restored.confirmed_dip_delta, + confirmed_ticks_left=restored.confirmed_dip_ticks_left, + ) + return after + + # ============================================================================= # SpanEnergyExtraStoredData round-trip with new fields # ============================================================================= @@ -109,6 +142,10 @@ def test_from_dict_restores_dip_fields(self): assert result is not None assert result.energy_offset == 10.0 assert result.last_panel_reading == 190.0 + # Written before the corroborated slot existed, so it records only an + # unsettled dip -- which is the state those records were always in. + assert result.confirmed_dip_baseline is None + assert result.confirmed_dip_ticks_left is None assert result.last_dip_delta == 10.0 def test_backward_compat_missing_dip_fields(self): @@ -225,7 +262,13 @@ def test_below_threshold_ignored(self): assert sensor._last_dip_delta is None def test_multiple_dips_accumulate(self): - """Multiple dips accumulate the offset.""" + """Multiple confirmed dips accumulate the offset. + + Each has to be corroborated by the counter climbing from its new, lower + base — which is what a firmware reset actually looks like — rather than + by the counter returning to where it was, which is what a transient bad + reading looks like and is now retracted instead. + """ sensor = DummyDipSensor(dip_enabled=True) sensor._mock_panel_value = 1000.0 @@ -237,16 +280,47 @@ def test_multiple_dips_accumulate(self): assert sensor._energy_offset == 20.0 assert sensor._attr_native_value == 1000.0 - # Normal increase - sensor._mock_panel_value = 1010.0 + # Counting up from the lower base confirms it + sensor._mock_panel_value = 985.0 + sensor._update_native_value() + assert sensor._energy_offset == 20.0 + assert sensor._attr_native_value == 1005.0 # 985 + 20 + + # Second dip: 25 Wh + sensor._mock_panel_value = 960.0 + sensor._update_native_value() + assert sensor._energy_offset == 45.0 # 20 + 25 + assert sensor._attr_native_value == 1005.0 # 960 + 45 + + sensor._mock_panel_value = 965.0 + sensor._update_native_value() + assert sensor._energy_offset == 45.0 + assert sensor._attr_native_value == 1010.0 # 965 + 45 + + def test_a_counter_that_comes_back_leaves_no_offset_behind(self): + """The behaviour this rule changed, stated directly. + + A reading that dipped and then rose past where it started was compensated + permanently: the offset stayed, inflating the sensor by the size of a + drop that never happened. `SpanPanel/span#259` is the same arithmetic on + a lifetime counter instead of 20 Wh. + """ + sensor = DummyDipSensor(dip_enabled=True) + + sensor._mock_panel_value = 1000.0 sensor._update_native_value() - assert sensor._attr_native_value == 1030.0 # 1010 + 20 - # Second dip: 30 Wh sensor._mock_panel_value = 980.0 sensor._update_native_value() - assert sensor._energy_offset == 50.0 # 20 + 30 - assert sensor._attr_native_value == 1030.0 # 980 + 50 + assert sensor._energy_offset == 20.0 + + sensor._mock_panel_value = 1010.0 + sensor._update_native_value() + + assert sensor._energy_offset == 0.0 + assert sensor._attr_native_value == 1010.0 + assert sensor._last_dip_delta is None + sensor.coordinator.report_energy_dip.assert_not_called() def test_disabled_passthrough(self): """When disabled, dips pass through without compensation.""" @@ -279,21 +353,43 @@ def test_non_total_increasing_passthrough(self): assert sensor._attr_native_value == 950.0 assert sensor._energy_offset == 0.0 - def test_dip_reports_to_coordinator(self): - """Dip detection calls coordinator.report_energy_dip.""" + def test_dip_reports_to_coordinator_once_it_settles(self): + """The notification waits until nothing can take the offset back. + + Not on detection: firing on sight is what produced the + `SpanPanel/span#259` notification naming essentially every circuit on + the panel for an event that had not happened. And not on corroboration + either, because the readings just after it can still retract the dip, + while a persistent notification cannot be retracted at all. + """ sensor = DummyDipSensor(dip_enabled=True) sensor.entity_id = "sensor.test_energy" - sensor._mock_panel_value = 1000.0 - sensor._update_native_value() + for value in (1000.0, 950.0, 955.0, 956.0, 957.0): + sensor._mock_panel_value = value + sensor._update_native_value() + sensor.coordinator.report_energy_dip.assert_not_called() - sensor._mock_panel_value = 950.0 + sensor._mock_panel_value = 958.0 sensor._update_native_value() sensor.coordinator.report_energy_dip.assert_called_once_with( "sensor.test_energy", 50.0, 50.0 ) + def test_a_dip_retracted_inside_its_window_is_never_reported(self): + """A notification that cannot be taken back is not sent speculatively.""" + sensor = DummyDipSensor(dip_enabled=True) + sensor.entity_id = "sensor.test_energy" + + for value in (1000.0, 950.0, 955.0, 1001.0, 1002.0, 1003.0, 1004.0): + sensor._mock_panel_value = value + sensor._update_native_value() + + assert sensor._energy_offset == 0.0 + assert sensor._attr_native_value == 1004.0 + sensor.coordinator.report_energy_dip.assert_not_called() + def test_no_report_when_no_dip(self): """Normal increases don't trigger coordinator notification.""" sensor = DummyDipSensor(dip_enabled=True) @@ -454,3 +550,188 @@ def test_extra_restore_state_data_zero_offset_stored_as_none(self): stored = sensor.extra_restore_state_data d = stored.as_dict() assert d["energy_offset"] is None + + +# ============================================================================= +# An unsettled dip across a restart +# ============================================================================= + + +class TestPendingDipSurvivesARestart: + """A restart between booking a dip and settling it must not settle it. + + This is the sequence that matters most, because the two are adjacent: the + reading that would settle a dip is the one after the reading that booked it, + and Home Assistant can stop in between. Forgetting the dip was provisional + would confirm it by default — turning the restart itself into the thing that + makes a bad offset permanent, which is `SpanPanel/span#259` exactly. + """ + + def test_the_pending_record_is_persisted(self): + sensor = DummyDipSensor(dip_enabled=True) + + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + sensor._mock_panel_value = 0.0 + sensor._update_native_value() + + stored = sensor.extra_restore_state_data.as_dict() + + assert stored["pending_dip_baseline"] == 1000.0 + assert stored["pending_dip_delta"] == 1000.0 + + def test_nothing_pending_is_persisted_as_none(self): + sensor = DummyDipSensor(dip_enabled=True) + sensor._attr_native_value = 1000.0 + + stored = sensor.extra_restore_state_data.as_dict() + + assert stored["pending_dip_baseline"] is None + assert stored["pending_dip_delta"] is None + + def test_a_restart_mid_dip_still_retracts_when_the_counter_returns(self): + """The offset booked before the restart is given back after it.""" + before = DummyDipSensor(dip_enabled=True) + before._mock_panel_value = 1000.0 + before._update_native_value() + before._mock_panel_value = 0.0 + before._update_native_value() + + after = _restart(before) + assert after._pending_dip is not None + + after._mock_panel_value = 1007.6 + after._update_native_value() + + assert after._energy_offset == 0.0 + assert after._attr_native_value == 1007.6 + after.coordinator.report_energy_dip.assert_not_called() + + +class TestTheConfirmationWindowSurvivesARestart: + """A confirmed dip is still provisional for a few readings, restart or not. + + Both halves of that matter across a restart. Dropping the window would make + a mis-confirmed offset permanent; restoring the record as though it had + never been confirmed would leave it retractable indefinitely, so a counter + that genuinely reset would hand its offset back on the day it climbed past + its old lifetime total. + """ + + @staticmethod + def _confirmed() -> DummyDipSensor: + """Build a sensor that has booked a dip and just had it confirmed.""" + sensor = DummyDipSensor(dip_enabled=True) + for value in (1000.0, 0.0, 5.0): + sensor._mock_panel_value = value + sensor._update_native_value() + return sensor + + def test_the_remaining_window_is_persisted(self): + stored = self._confirmed().extra_restore_state_data.as_dict() + + assert stored["confirmed_dip_baseline"] == 1000.0 + assert stored["confirmed_dip_delta"] == 1000.0 + assert stored["confirmed_dip_ticks_left"] == CONFIRMED_RETRACTION_WINDOW + # The corroborated dip left the unsettled slot when it was corroborated. + assert stored["pending_dip_baseline"] is None + + def test_a_return_after_the_restart_still_retracts(self): + after = _restart(self._confirmed()) + + after._mock_panel_value = 1001.0 + after._update_native_value() + + assert after._energy_offset == 0.0 + assert after._attr_native_value == 1001.0 + after.coordinator.report_energy_dip.assert_not_called() + + def test_the_window_keeps_running_out_across_the_restart(self): + """Restarting does not buy the record more readings.""" + after = _restart(self._confirmed()) + + for value in (6.0, 7.0, 8.0): + after._mock_panel_value = value + after._update_native_value() + + assert after._recently_confirmed_dip is None + + after._mock_panel_value = 1001.0 + after._update_native_value() + + assert after._energy_offset == 1000.0 + assert after._attr_native_value == 2001.0 + + def test_the_dip_is_reported_when_it_settles_after_the_restart(self): + """A restart delays the notification with the window, it does not lose it.""" + after = _restart(self._confirmed()) + after.entity_id = "sensor.test_energy" + + for value in (6.0, 7.0, 8.0): + after._mock_panel_value = value + after._update_native_value() + + after.coordinator.report_energy_dip.assert_called_once_with( + "sensor.test_energy", 1000.0, 1000.0 + ) + + +class TestAnOutageDoesNotSettleADip: + """Grace period and dip compensation share a stored record but not a decision. + + While the panel is offline `_update_native_value` hands off to the grace + period and returns before any dip processing, so an outage cannot book, + confirm or retract anything. That is the intended division: grace period + answers "what should this sensor read while the panel is unreachable", dip + compensation answers "what did the counter do", and an outage is evidence + about the transport rather than about the counter. + """ + + def test_an_outage_leaves_an_unsettled_dip_exactly_as_it_was(self): + sensor = DummyDipSensor(dip_enabled=True) + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + sensor._mock_panel_value = 0.0 + sensor._update_native_value() + booked = sensor._pending_dip + + sensor.coordinator.panel_offline = True + sensor._mock_panel_value = 12345.0 # must not be read while offline + sensor._update_native_value() + + assert sensor._pending_dip == booked + assert sensor._energy_offset == 1000.0 + assert sensor._last_panel_reading == 0.0 + + def test_grace_period_holds_the_compensated_value(self): + """What HA was showing is what it keeps showing, offset included.""" + sensor = DummyDipSensor(dip_enabled=True) + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + sensor._mock_panel_value = 0.0 + sensor._update_native_value() + + sensor.coordinator.panel_offline = True + sensor._update_native_value() + + assert sensor._attr_native_value == 1000.0 + assert sensor._last_valid_state == 1000.0 + + def test_the_dip_settles_normally_once_the_panel_returns(self): + """The outage delays the verdict rather than deciding it.""" + sensor = DummyDipSensor(dip_enabled=True) + sensor._mock_panel_value = 1000.0 + sensor._update_native_value() + sensor._mock_panel_value = 0.0 + sensor._update_native_value() + + sensor.coordinator.panel_offline = True + sensor._update_native_value() + sensor.coordinator.panel_offline = False + sensor._mock_panel_value = 1007.6 + sensor._update_native_value() + + assert sensor._energy_offset == 0.0 + assert sensor._pending_dip is None + assert sensor._attr_native_value == 1007.6 + sensor.coordinator.report_energy_dip.assert_not_called() diff --git a/tests/test_energy_dip_option.py b/tests/test_energy_dip_option.py new file mode 100644 index 00000000..4fe3cc6a --- /dev/null +++ b/tests/test_energy_dip_option.py @@ -0,0 +1,58 @@ +"""An entry that never stored the dip compensation option compensates nothing, and saving its options keeps it so. + +Installs from before the option have no value for it, and the sensors read that +absence as off. The General Options form used to pre-fill the same absence as on, +so saving the form for any other reason turned compensation on. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from unittest.mock import AsyncMock, patch + +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from pytest_homeassistant_custom_component.common import MockConfigEntry +import voluptuous as vol + +from custom_components.span_panel.const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION + + +def _shown_values(schema: vol.Schema) -> dict[str, object]: + """The values the form shows: what saving it unchanged submits.""" + shown: dict[str, object] = {} + for key in schema.schema: + description = key.description + if isinstance(description, Mapping) and "suggested_value" in description: + shown[str(key)] = description["suggested_value"] + return shown + + +async def test_saving_general_options_unchanged_leaves_compensation_off(hass: HomeAssistant) -> None: + entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, entry_id="entry-before-the-option") + entry.add_to_hass(hass) + + with patch("custom_components.span_panel.async_apply_panel_registration", AsyncMock()): + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + shown = _shown_values(result["data_schema"]) + assert shown[ENABLE_ENERGY_DIP_COMPENSATION] is False + + result = await hass.config_entries.options.async_configure(result["flow_id"], user_input=shown) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert entry.options[ENABLE_ENERGY_DIP_COMPENSATION] is False + + +async def test_an_entry_that_stored_the_option_keeps_its_choice(hass: HomeAssistant) -> None: + entry = MockConfigEntry( + domain=DOMAIN, data={}, options={ENABLE_ENERGY_DIP_COMPENSATION: True}, entry_id="entry-with-the-option" + ) + entry.add_to_hass(hass) + + with patch("custom_components.span_panel.async_apply_panel_registration", AsyncMock()): + result = await hass.config_entries.options.async_init(entry.entry_id) + shown = _shown_values(result["data_schema"]) + result = await hass.config_entries.options.async_configure(result["flow_id"], user_input=shown) + + assert entry.options[ENABLE_ENERGY_DIP_COMPENSATION] is True diff --git a/tests/test_energy_dip_retraction.py b/tests/test_energy_dip_retraction.py new file mode 100644 index 00000000..4787ff0b --- /dev/null +++ b/tests/test_energy_dip_retraction.py @@ -0,0 +1,405 @@ +"""A counter reset is permanent, so a dip the next reading disproves is retracted. + +Dip compensation books an offset the moment a counter reads lower than it did, +and that write is irreversible: it lands in Home Assistant's long-term +statistics. The evidence for it is one sample. `SpanPanel/span#259` is what that +costs when the sample is wrong — a transient zero booked the whole lifetime +counter as an offset on every restart, reaching megawatt-hours. + +The library no longer fabricates that particular zero (an unreported reading is +`None`, which skips compensation entirely). This is the layer under it: whatever +the source, a reading that drops and then comes back was never a reset, because +a reset is permanent. So the offset is provisional until a later reading either +disproves it — the counter returns to where it was — or corroborates it, by +counting up from the lower base. + +**Corroboration needs independent progress, not merely a second low reading.** +Confirming on any reading below the baseline would confirm on a second copy of +the same bad sample, which is exactly the shape a replay produces: snapshots are +dispatched on a debounce, so one burst can deliver the same incomplete state +twice. That is the failure this rule exists to catch, so it must not be the +failure that satisfies it. + +**And corroboration is evidence rather than proof.** The same burst that +replays one stale sample can replay two, the second slightly higher than the +first, which is indistinguishable on the tick from a counter climbing away from +a fresh base. So corroborating does not close the record: for +`CONFIRMED_RETRACTION_WINDOW` more readings a return to the baseline still +retracts it. The reading that closes the window *settles* the dip — the offset +is final and the user is told — because a compensation that could be undone at +any future reading would be undone by the ordinary growth of a counter that +really did reset. + +**A dip inside that window is a dip of its own**, kept apart from the older one +and judged against the reading it fell from. A 3 Wh artefact folded into a +1000 Wh dip's record would be measured against a baseline the counter cannot +reach again inside the window, so it could never be retracted — a small replay +made permanent by a large one. + +The cost is that a genuine reset on a circuit drawing nothing stays unconfirmed +until it next moves. Compensation is applied throughout — only the notification +waits — so the sensor reads correctly while the evidence is outstanding. +""" + +from __future__ import annotations + +import pytest + +from custom_components.span_panel.energy_dip import ( + CONFIRMED_RETRACTION_WINDOW, + DipEvent, + DipOutcome, + PendingDip, + process_energy_dip, +) + +BASELINE = 163562.4 +"""A plausible lifetime counter, from the report in #259.""" + + +def _book(raw: float = 0.0, offset: float = 0.0) -> tuple[float, PendingDip]: + """Run the tick that books a dip, returning the new offset and pending record.""" + outcome = process_energy_dip(raw, BASELINE, offset, None) + + assert outcome.event is DipEvent.BOOKED + assert outcome.pending is not None + return outcome.offset, outcome.pending + + +def _feed(readings: list[float], *, after: float) -> list[DipOutcome]: + """Run a run of readings through the rule from a standing start. + + `after` is what the counter read before the first of them, so the caller + writes the panel's sequence and gets one outcome per reading back. + """ + outcomes: list[DipOutcome] = [] + previous = after + offset = 0.0 + pending: PendingDip | None = None + confirmed: PendingDip | None = None + + for raw in readings: + outcome = process_energy_dip(raw, previous, offset, pending, confirmed) + outcomes.append(outcome) + offset, pending, previous = outcome.offset, outcome.pending, raw + confirmed = outcome.recently_confirmed + + return outcomes + + +class TestBooking: + """The tick on which the counter first reads low.""" + + def test_a_drop_books_an_offset_and_leaves_it_unconfirmed(self) -> None: + """Compensated at once, believed later.""" + outcome = process_energy_dip(0.0, BASELINE, 0.0, None) + + assert outcome.event is DipEvent.BOOKED + assert outcome.offset == pytest.approx(BASELINE) + assert outcome.pending == PendingDip(baseline=BASELINE, delta=BASELINE) + + def test_the_compensated_value_does_not_move_on_the_booking_tick(self) -> None: + """The dip tick is invisible downstream. + + Which is what makes the spike arrive one sample later and look like real + consumption. + """ + outcome = process_energy_dip(0.0, BASELINE, 0.0, None) + + assert outcome.compensated == pytest.approx(BASELINE) + + def test_a_drop_below_the_threshold_books_nothing(self) -> None: + """Noise is not a dip.""" + outcome = process_energy_dip(BASELINE - 0.5, BASELINE, 0.0, None) + + assert outcome.event is None + assert outcome.pending is None + assert outcome.offset == 0.0 + + def test_the_first_reading_of_all_books_nothing(self) -> None: + """Nothing to compare a first reading against.""" + outcome = process_energy_dip(BASELINE, None, 0.0, None) + + assert outcome.event is None + assert outcome.pending is None + + +class TestRetraction: + """The counter comes back, so no reset happened.""" + + def test_a_reading_back_at_the_baseline_retracts_the_offset(self) -> None: + """The counter is where it started, so it never reset.""" + offset, pending = _book() + + outcome = process_energy_dip(BASELINE, 0.0, offset, pending) + + assert outcome.event is DipEvent.RETRACTED + assert outcome.offset == 0.0 + assert outcome.pending is None + + def test_a_reading_above_the_baseline_retracts_too(self) -> None: + """The ordinary case: the panel kept counting while the reading was lost.""" + offset, pending = _book() + + outcome = process_energy_dip(BASELINE + 7.6, 0.0, offset, pending) + + assert outcome.event is DipEvent.RETRACTED + assert outcome.offset == 0.0 + + def test_the_compensated_value_returns_to_the_uninflated_reading(self) -> None: + """The whole point: no step. + + #259 saw the counter's full value added to the sensor in a single sample. + """ + offset, pending = _book() + + outcome = process_energy_dip(BASELINE + 7.6, 0.0, offset, pending) + + assert outcome.compensated == pytest.approx(BASELINE + 7.6) + + def test_an_earlier_offset_survives_the_retraction(self) -> None: + """Retraction removes the provisional amount, not the whole offset. + + A real reset compensated last month is not undone by this month's + artefact. + """ + offset, pending = _book(offset=500.0) + + outcome = process_energy_dip(BASELINE, 0.0, offset, pending) + + assert outcome.offset == pytest.approx(500.0) + + def test_a_reading_just_under_the_baseline_still_retracts(self) -> None: + """Symmetric with the booking threshold. + + A difference under 1.0 Wh is not a difference. + """ + offset, pending = _book() + + outcome = process_energy_dip(BASELINE - 0.5, 0.0, offset, pending) + + assert outcome.event is DipEvent.RETRACTED + + +class TestConfirmation: + """The counter counts up from the lower base, which only a reset produces. + + Believed, not closed: the dip is corroborated here and settles later. + """ + + def test_progress_from_the_dipped_reading_confirms(self) -> None: + """Counting up from the lower base is what a reset looks like.""" + offset, pending = _book() + + outcome = process_energy_dip(5.0, 0.0, offset, pending) + + assert outcome.event is DipEvent.CONFIRMED + assert outcome.delta == pytest.approx(BASELINE) + assert outcome.offset == pytest.approx(BASELINE) + # Nothing is unsettled any more; the dip moves to the corroborated slot + # and waits out its window there. See `TestConfirmThenReturn`. + assert outcome.pending is None + + def test_the_offset_stays_applied_through_confirmation(self) -> None: + """Confirming settles the belief, it does not change the arithmetic.""" + offset, pending = _book() + + outcome = process_energy_dip(5.0, 0.0, offset, pending) + + assert outcome.compensated == pytest.approx(BASELINE + 5.0) + + +class TestHolding: + """No evidence either way, so the decision waits.""" + + def test_a_repeat_of_the_dipped_reading_does_not_confirm(self) -> None: + """The reason a single-tick confirmation is unsafe. + + Snapshots are dispatched on a debounce, so one replay can deliver the + same incomplete state more than once. A rule that took any low reading + as corroboration would be satisfied by the second copy of the very + sample it exists to catch. + """ + offset, pending = _book() + + outcome = process_energy_dip(0.0, 0.0, offset, pending) + + assert outcome.event is None + assert outcome.pending == pending + assert outcome.offset == pytest.approx(BASELINE) + + def test_holding_keeps_the_sensor_compensated(self) -> None: + """Waiting for evidence must not make the reading wrong meanwhile.""" + offset, pending = _book() + + outcome = process_energy_dip(0.0, 0.0, offset, pending) + + assert outcome.compensated == pytest.approx(BASELINE) + + def test_a_second_phantom_zero_then_recovery_still_retracts(self) -> None: + """The #259 sequence end to end, with the burst delivering twice.""" + offset, pending = _book() + + held = process_energy_dip(0.0, 0.0, offset, pending) + recovered = process_energy_dip(BASELINE + 7.6, 0.0, held.offset, held.pending) + + assert recovered.event is DipEvent.RETRACTED + assert recovered.offset == 0.0 + assert recovered.compensated == pytest.approx(BASELINE + 7.6) + + +class TestConfirmThenReturn: + """A dip corroborated by one tick of progress, and then undone. + + The confirming reading is evidence, not proof: a replay burst can deliver a + low sample and then a slightly higher low sample before the real reading + arrives. If confirmation closed the record for good, that shape would leave + the counter's whole lifetime total in the offset permanently. + """ + + def test_a_return_to_the_baseline_after_confirmation_still_retracts(self) -> None: + """The review's sequence: 1000 -> 0 (booked) -> 5 (corroborated) -> 1001.""" + outcomes = _feed([0.0, 5.0, BASELINE + 1.0], after=BASELINE) + + assert outcomes[-1].event is DipEvent.RETRACTED + assert outcomes[-1].offset == pytest.approx(0.0) + assert outcomes[-1].compensated == pytest.approx(BASELINE + 1.0) + + def test_the_corroborated_dip_is_kept_with_a_window(self) -> None: + """Believed, but not closed, and held apart from anything unsettled.""" + outcomes = _feed([0.0, 5.0], after=BASELINE) + + assert outcomes[-1].event is DipEvent.CONFIRMED + assert outcomes[-1].pending is None + assert outcomes[-1].recently_confirmed == PendingDip( + baseline=BASELINE, delta=BASELINE, confirmed_ticks_left=CONFIRMED_RETRACTION_WINDOW + ) + + def test_the_last_reading_of_the_window_can_still_retract(self) -> None: + """The window is three readings, and the third of them is one of them.""" + outcomes = _feed([0.0, 5.0, 6.0, 7.0, BASELINE + 1.0], after=BASELINE) + + assert [outcome.event for outcome in outcomes[1:]] == [ + DipEvent.CONFIRMED, + None, + None, + DipEvent.RETRACTED, + ] + assert outcomes[-1].offset == pytest.approx(0.0) + assert outcomes[-1].compensated == pytest.approx(BASELINE + 1.0) + + def test_the_window_runs_out_and_the_offset_becomes_final(self) -> None: + """A fourth reading is past it, so a return then is just consumption. + + Which is the cost of the window: a counter that genuinely reset and then + climbed its whole lifetime total back within three readings would be + mis-retracted. It is the offset staying put that matters here. + """ + outcomes = _feed([0.0, 5.0, 6.0, 7.0, 8.0, BASELINE + 1.0], after=BASELINE) + + assert outcomes[-2].event is DipEvent.SETTLED + assert outcomes[-2].recently_confirmed is None + assert outcomes[-1].event is None + assert outcomes[-1].offset == pytest.approx(BASELINE) + assert outcomes[-1].compensated == pytest.approx(2 * BASELINE + 1.0) + + def test_a_genuine_reset_settles_on_the_reading_that_closes_the_window(self) -> None: + """Nothing stays provisional forever, and settling names the amount. + + Which is what the notification reports, because it is the first moment + nothing can take the offset back. + """ + outcomes = _feed([0.0, 5.0, 6.0, 7.0, 8.0], after=BASELINE) + + assert [outcome.settled for outcome in outcomes] == [None, None, None, None, BASELINE] + assert [outcome.recently_confirmed is None for outcome in outcomes[1:]] == [ + False, + False, + False, + True, + ] + + +class TestADipInsideAConfirmedWindow: + """A fall while an older dip is still provisional is its own dip. + + Its baseline is the reading it fell from, not the older dip's high-water + mark, because that is what the counter has to come back to for *this* fall + to be undone. Folding the two together would judge a 3 Wh artefact against a + counter's whole lifetime total — a mark it can never reach again inside the + window, so the artefact would be compensated for good. + """ + + def test_the_smaller_dip_retracts_against_its_own_baseline(self) -> None: + """`1000 -> 0 -> 5 -> 2 -> 6`: the 3 Wh comes back, the 1000 Wh stays.""" + outcomes = _feed([0.0, 5.0, 2.0, 6.0], after=BASELINE) + + assert outcomes[2].event is DipEvent.BOOKED + assert outcomes[2].pending == PendingDip(baseline=5.0, delta=3.0) + assert outcomes[-1].event is DipEvent.RETRACTED + assert outcomes[-1].delta == pytest.approx(3.0) + assert outcomes[-1].offset == pytest.approx(BASELINE) + + def test_a_return_to_the_older_baseline_retracts_both(self) -> None: + """`1000 -> 0 -> 5 -> 2 -> 1001`: the counter is where it started.""" + outcomes = _feed([0.0, 5.0, 2.0, BASELINE + 1.0], after=BASELINE) + + assert outcomes[-1].event is DipEvent.RETRACTED + assert outcomes[-1].delta == pytest.approx(BASELINE + 3.0) + assert outcomes[-1].offset == pytest.approx(0.0) + assert outcomes[-1].compensated == pytest.approx(BASELINE + 1.0) + + def test_the_older_dip_keeps_ageing_through_the_newer_one(self) -> None: + """A fall is not a return, so it does not buy the window more readings.""" + outcomes = _feed([0.0, 5.0, 2.0, 6.0, 7.0], after=BASELINE) + + assert outcomes[-1].event is DipEvent.SETTLED + assert outcomes[-1].settled == pytest.approx(BASELINE) + assert outcomes[-1].offset == pytest.approx(BASELINE) + + def test_corroborating_the_newer_dip_settles_the_older_one(self) -> None: + """The bound stays three readings from the most recent corroboration. + + So the older dip is made final rather than merged into the newer one's + window, which would let a chain of falls keep it provisional forever. + """ + outcomes = _feed([0.0, 5.0, 2.0, 3.0], after=BASELINE) + + assert outcomes[-1].event is DipEvent.CONFIRMED + assert outcomes[-1].settled == pytest.approx(BASELINE) + assert outcomes[-1].recently_confirmed == PendingDip( + baseline=5.0, delta=3.0, confirmed_ticks_left=CONFIRMED_RETRACTION_WINDOW + ) + + +class TestASecondDropWhileUnconfirmed: + """A counter that keeps falling before anything is settled.""" + + def test_the_provisional_amount_accumulates(self) -> None: + """A second fall extends the amount that is still provisional.""" + offset, pending = _book(raw=100.0) + + outcome = process_energy_dip(20.0, 100.0, offset, pending) + + # Two falls, BASELINE -> 100 and 100 -> 20, so the counter is short by + # everything above its current reading. + assert outcome.event is DipEvent.BOOKED + assert outcome.offset == pytest.approx(BASELINE - 20.0) + assert outcome.pending is not None + assert outcome.pending.delta == pytest.approx(BASELINE - 20.0) + + def test_the_baseline_stays_the_high_water_mark(self) -> None: + """Retraction restores what the counter read before any of it. + + So a later, lower reading must not move the mark it is judged against. + """ + offset, pending = _book(raw=100.0) + second = process_energy_dip(20.0, 100.0, offset, pending) + + assert second.pending is not None + assert second.pending.baseline == pytest.approx(BASELINE) + + recovered = process_energy_dip(BASELINE, 20.0, second.offset, second.pending) + + assert recovered.event is DipEvent.RETRACTED + assert recovered.offset == pytest.approx(0.0) diff --git a/tests/test_energy_orientation.py b/tests/test_energy_orientation.py new file mode 100644 index 00000000..2e292639 --- /dev/null +++ b/tests/test_energy_orientation.py @@ -0,0 +1,316 @@ +"""Which way each Net Energy counts, and which sensor plays which part in it. + +A net sensor is the difference of two counters on one meter. Its value and the +dip-compensation adjustment it adds are both read from one orientation, so the +two cannot disagree about which counter is credited -- the disagreement that +made a solar circuit's Net move the wrong way at each compensated dip. +""" + +from __future__ import annotations + +from collections import Counter +from typing import Final + +from homeassistant.components.sensor import SensorEntityDescription +import pytest + +from custom_components.span_panel import sensor_definitions +from custom_components.span_panel.energy_orientation import ( + GENERATION, + LOAD, + CircuitMeter, + EnergyBinding, + EnergyCounter, + EnergyRole, + NetEnergy, + NetEnergyOrientation, + PanelMeter, + circuit_is_generation, + circuit_net_orientation, + panel_meter_net_orientation, +) +from custom_components.span_panel.sensor_definitions import ( + CIRCUIT_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_SENSORS, + PANEL_ENERGY_SENSORS, + UNMAPPED_SENSORS, + SpanPanelCircuitsSensorEntityDescription, + SpanPanelDataSensorEntityDescription, +) + +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + +OFFSETS: Final = {EnergyCounter.CONSUMED: 7.0, EnergyCounter.PRODUCED: 50.0} + + +def test_a_load_credits_consumed_and_generation_credits_produced() -> None: + assert NetEnergyOrientation(credit=EnergyCounter.CONSUMED, debit=EnergyCounter.PRODUCED) == LOAD + assert ( + NetEnergyOrientation(credit=EnergyCounter.PRODUCED, debit=EnergyCounter.CONSUMED) + == GENERATION + ) + + +def test_an_orientation_cannot_credit_and_debit_one_counter() -> None: + with pytest.raises(ValueError, match="one counter"): + NetEnergyOrientation(credit=EnergyCounter.CONSUMED, debit=EnergyCounter.CONSUMED) + + +@pytest.mark.parametrize( + ("orientation", "expected"), + [(LOAD, (30.0, 1000.0)), (GENERATION, (1000.0, 30.0))], + ids=["load", "generation"], +) +def test_ordered_puts_the_credited_reading_first( + orientation: NetEnergyOrientation, expected: tuple[float, float] +) -> None: + assert orientation.ordered(consumed=30.0, produced=1000.0) == expected + + +def test_ordered_keeps_an_unreported_reading_unreported() -> None: + assert LOAD.ordered(consumed=None, produced=4.0) == (None, 4.0) + assert GENERATION.ordered(consumed=None, produced=4.0) == (4.0, None) + + +@pytest.mark.parametrize( + ("orientation", "expected"), + [(LOAD, 7.0 - 50.0), (GENERATION, 50.0 - 7.0)], + ids=["load", "generation"], +) +def test_the_adjustment_is_the_credited_offset_minus_the_debited_one( + orientation: NetEnergyOrientation, expected: float +) -> None: + assert orientation.adjustment(OFFSETS.__getitem__) == pytest.approx(expected) + + +@pytest.mark.parametrize( + ("device_type", "generation"), [("pv", True), ("circuit", False), ("evse", False)] +) +def test_a_circuit_is_oriented_by_its_device_type(device_type: str, generation: bool) -> None: + circuit = SpanCircuitSnapshotFactory.create(device_type=device_type) + + assert circuit_is_generation(circuit) is generation + assert circuit_net_orientation(circuit) is (GENERATION if generation else LOAD) + + +def test_a_counter_role_names_its_counter_and_net_names_none() -> None: + assert EnergyRole.PRODUCED.counter is EnergyCounter.PRODUCED + assert EnergyRole.CONSUMED.counter is EnergyCounter.CONSUMED + assert EnergyRole.NET.counter is None + + +def test_a_counter_key_answers_neither_a_role_nor_a_string() -> None: + """The offset map is keyed by counter, and that holds at runtime, not only for mypy.""" + offsets = {(PanelMeter.MAIN_METER, EnergyCounter.PRODUCED): 1.0} + + assert (PanelMeter.MAIN_METER, EnergyRole.PRODUCED) not in offsets + assert (PanelMeter.MAIN_METER, "produced") not in offsets + + +def test_the_panel_meters_are_load_oriented() -> None: + assert panel_meter_net_orientation(SpanPanelSnapshotFactory.create()) is LOAD + + +# --------------------------------------------------------------------------- +# A Net is one orientation, read by both its value and its adjustment +# --------------------------------------------------------------------------- + +CIRCUIT_NET: Final = NetEnergy( + orientation_of=circuit_net_orientation, + consumed=lambda c: c.consumed_energy_wh, + produced=lambda c: c.produced_energy_wh, +) + + +@pytest.mark.parametrize(("device_type", "expected"), [("pv", 970.0), ("circuit", -970.0)]) +def test_a_net_value_is_the_credited_reading_minus_the_debited_one(device_type: str, expected: float) -> None: + circuit = SpanCircuitSnapshotFactory.create( + device_type=device_type, consumed_energy_wh=30.0, produced_energy_wh=1000.0 + ) + + assert CIRCUIT_NET.value(circuit) == pytest.approx(expected) + + +def test_a_net_value_is_unknown_while_a_counter_is_unreported() -> None: + circuit = SpanCircuitSnapshotFactory.create(consumed_energy_wh=None, produced_energy_wh=4.0) + + assert CIRCUIT_NET.value(circuit) is None + + +def test_a_binding_declares_a_net_exactly_when_its_role_is_net() -> None: + with pytest.raises(ValueError, match="Net"): + EnergyBinding(meter=CircuitMeter("c1"), role=EnergyRole.NET, net=None) + with pytest.raises(ValueError, match="Net"): + EnergyBinding(meter=CircuitMeter("c1"), role=EnergyRole.PRODUCED, net=CIRCUIT_NET) + + +def test_a_binding_plays_its_role_on_a_meter() -> None: + with pytest.raises(ValueError, match="meter"): + EnergyBinding(meter=None, role=EnergyRole.CONSUMED, net=None) + + +def test_a_binding_names_the_counter_it_offers() -> None: + meter = CircuitMeter("c1") + assert EnergyBinding(meter=meter, role=EnergyRole.PRODUCED, net=None).counter is EnergyCounter.PRODUCED + assert EnergyBinding(meter=meter, role=EnergyRole.CONSUMED, net=None).counter is EnergyCounter.CONSUMED + assert EnergyBinding(meter=meter, role=EnergyRole.NET, net=CIRCUIT_NET).counter is None + assert EnergyBinding(meter=None, role=None, net=None).counter is None + + +def test_meters_are_hashable_keys() -> None: + keys = { + (CircuitMeter("c1"), EnergyCounter.PRODUCED), + (CircuitMeter("c1"), EnergyCounter.PRODUCED), + (CircuitMeter("c2"), EnergyCounter.PRODUCED), + (PanelMeter.MAIN_METER, EnergyCounter.PRODUCED), + (PanelMeter.FEEDTHROUGH, EnergyCounter.PRODUCED), + } + assert len(keys) == 4 + + +# --------------------------------------------------------------------------- +# The roles are data on the descriptions +# --------------------------------------------------------------------------- + + +def _every_description() -> list[SensorEntityDescription]: + """Every sensor description the catalog module defines, alone or in a tuple, once each.""" + found: dict[int, SensorEntityDescription] = {} + for value in vars(sensor_definitions).values(): + candidates = value if isinstance(value, tuple) else (value,) + for candidate in candidates: + if isinstance(candidate, SensorEntityDescription): + found[id(candidate)] = candidate + return list(found.values()) + + +def test_each_circuit_energy_description_declares_its_role() -> None: + assert {d.key: d.energy_role for d in CIRCUIT_SENSORS} == { + "circuit_power": None, + "circuit_energy_produced": EnergyRole.PRODUCED, + "circuit_energy_consumed": EnergyRole.CONSUMED, + "circuit_energy_net": EnergyRole.NET, + } + for other in (*UNMAPPED_SENSORS, CIRCUIT_CURRENT_SENSOR, CIRCUIT_BREAKER_RATING_SENSOR): + assert other.energy_role is None, other.key + + +def test_panel_energy_descriptions_set_role_and_meter_together_and_nothing_else_does() -> None: + for description in PANEL_ENERGY_SENSORS: + assert description.energy_role is not None, description.key + assert description.panel_meter is not None, description.key + others = [ + d + for d in _every_description() + if isinstance(d, SpanPanelDataSensorEntityDescription) and d not in PANEL_ENERGY_SENSORS + ] + assert others, "the catalog has other panel data descriptions to check" + for description in others: + assert description.energy_role is None, description.key + assert description.panel_meter is None, description.key + + +def test_each_panel_meter_has_one_produced_one_consumed_and_one_net() -> None: + for meter in PanelMeter: + roles = [d.energy_role for d in PANEL_ENERGY_SENSORS if d.panel_meter is meter] + assert Counter(roles) == Counter(EnergyRole), meter + + +def test_a_description_carries_a_net_exactly_when_its_role_is_net() -> None: + energy_descriptions = [ + d + for d in _every_description() + if isinstance(d, SpanPanelCircuitsSensorEntityDescription | SpanPanelDataSensorEntityDescription) + ] + for description in energy_descriptions: + assert (description.net_energy is not None) is (description.energy_role is EnergyRole.NET), description.key + + +def test_exactly_three_descriptions_are_net_sensors() -> None: + nets = [d for d in _every_description() if getattr(d, "energy_role", None) is EnergyRole.NET] + assert sorted(d.key for d in nets) == [ + "circuit_energy_net", + "feedthroughNetEnergyWh", + "mainMeterNetEnergyWh", + ] + + +def test_a_circuits_net_comes_after_its_siblings() -> None: + roles = [d.energy_role for d in CIRCUIT_SENSORS] + assert roles.index(EnergyRole.NET) > roles.index(EnergyRole.PRODUCED) + assert roles.index(EnergyRole.NET) > roles.index(EnergyRole.CONSUMED) + + +def test_each_panel_meters_net_comes_after_its_siblings() -> None: + for meter in PanelMeter: + roles = [d.energy_role for d in PANEL_ENERGY_SENSORS if d.panel_meter is meter] + assert roles[-1] is EnergyRole.NET, meter + + +# --------------------------------------------------------------------------- +# Values are unchanged by the move to the orientation +# --------------------------------------------------------------------------- + + +def _value( + key: str, + *, + device_type: str, + produced_energy_wh: float | None, + consumed_energy_wh: float | None, + instant_power_w: float | None = 0.0, +) -> float | None: + description = next(d for d in CIRCUIT_SENSORS if d.key == key) + return description.value_fn( + SpanCircuitSnapshotFactory.create( + device_type=device_type, + produced_energy_wh=produced_energy_wh, + consumed_energy_wh=consumed_energy_wh, + instant_power_w=instant_power_w, + ) + ) + + +def test_circuit_values_are_unchanged() -> None: + assert _value( + "circuit_energy_net", device_type="pv", produced_energy_wh=1000.0, consumed_energy_wh=30.0 + ) == pytest.approx(970.0) + assert _value( + "circuit_energy_net", + device_type="circuit", + produced_energy_wh=3.0, + consumed_energy_wh=500.0, + ) == pytest.approx(497.0) + assert _value( + "circuit_power", + device_type="pv", + produced_energy_wh=0.0, + consumed_energy_wh=0.0, + instant_power_w=-2100.0, + ) == pytest.approx(2100.0) + assert _value( + "circuit_power", + device_type="circuit", + produced_energy_wh=0.0, + consumed_energy_wh=0.0, + instant_power_w=120.0, + ) == pytest.approx(120.0) + assert ( + _value( + "circuit_energy_net", device_type="pv", produced_energy_wh=None, consumed_energy_wh=4.0 + ) + is None + ) + + +def test_panel_net_values_are_unchanged() -> None: + snapshot = SpanPanelSnapshotFactory.create( + main_meter_energy_consumed_wh=2500.0, + main_meter_energy_produced_wh=300.0, + feedthrough_energy_consumed_wh=80.0, + feedthrough_energy_produced_wh=20.0, + ) + values = {d.key: d.value_fn(snapshot) for d in PANEL_ENERGY_SENSORS} + assert values["mainMeterNetEnergyWh"] == pytest.approx(2200.0) + assert values["feedthroughNetEnergyWh"] == pytest.approx(60.0) diff --git a/tests/test_entity_bulk_naming_patterns.py b/tests/test_entity_bulk_naming_patterns.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_enum_sensor_options.py b/tests/test_enum_sensor_options.py new file mode 100644 index 00000000..b9c80338 --- /dev/null +++ b/tests/test_enum_sensor_options.py @@ -0,0 +1,115 @@ +"""Every enum sensor must declare the states it can actually report. + +Home Assistant renders `options` as the entity's "Possible states" and validates the +state against it. Eight sensors declared `["unknown"]` and nothing else, so a panel +reporting `dsm_on_grid` showed a live state that its own entity said was impossible. + +`sensor_base` tried to fix this at runtime, appending each value as it was first +observed. That does not work, and could not: options would only ever list states the +panel had already reached, so `dsm_off_grid` stays absent until the day of an actual +outage, and the advertised set differs between two identical panels depending on what +each has lived through. A value domain that is known when the code is written should +be declared there. + +The translations are the authority rather than a second hand-maintained list: a state +with no translation renders to a user as a raw key like `dsm_on_grid`, and an option +with no state to match is dead weight. Both are the same defect seen from either end, +so one list is derived from the other. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from homeassistant.components.sensor import SensorDeviceClass + +from custom_components.span_panel.sensor_definitions import ( + BESS_METADATA_SENSORS, + BESS_TELEMETRY_SENSORS, + CIRCUIT_SENSORS, + EVSE_SENSORS, + MID_SENSORS, + PANEL_DATA_STATUS_SENSORS, + PCS_SENSORS, + PV_METADATA_SENSORS, + STATUS_SENSORS, + UNMAPPED_SENSORS, +) + +_COMPONENT = Path(__file__).resolve().parent.parent / "custom_components" / "span_panel" + + +def _translated_states() -> dict[str, set[str]]: + """The state keys `en.json` renders, per sensor translation key.""" + entities = json.loads((_COMPONENT / "translations" / "en.json").read_text()) + sensors = entities["entity"]["sensor"] + return {key: set(body.get("state", {})) for key, body in sensors.items()} + + +def _enum_descriptions() -> list[Any]: + # Every published group, so a new enum in any of them is covered the day it + # lands rather than the day someone remembers this file. + groups = ( + PANEL_DATA_STATUS_SENSORS, + STATUS_SENSORS, + UNMAPPED_SENSORS, + MID_SENSORS, + BESS_METADATA_SENSORS, + BESS_TELEMETRY_SENSORS, + PCS_SENSORS, + PV_METADATA_SENSORS, + CIRCUIT_SENSORS, + EVSE_SENSORS, + ) + return [ + description + for group in groups + for description in group + if description.device_class is SensorDeviceClass.ENUM + ] + + +def test_there_are_enum_sensors_to_check() -> None: + """Guard the guard. + + Every assertion below is a loop over discovered descriptions, so an import that + silently stopped finding any would leave this file passing while checking nothing. + """ + assert len(_enum_descriptions()) >= 8 + + +@pytest.mark.parametrize("description", _enum_descriptions(), ids=lambda d: str(d.key)) +def test_declared_options_match_the_states_the_ui_can_render(description: Any) -> None: + """Declared options and translated states are the same set. + + Not a subset in either direction. An option with no translation reaches a user as + a raw key; a translated state absent from options is one Home Assistant will + reject when the panel reports it -- which is how `dsm_grid_state` came to show + "Possible states: Unknown" while sitting at `dsm_on_grid`. + """ + translated = _translated_states().get(description.translation_key) + assert translated, f"{description.key} has no translated states to compare against" + + declared = set(description.options or []) + + assert declared == translated, ( + f"{description.key}: options {sorted(declared)} do not match the states " + f"en.json renders {sorted(translated)}. Add the missing ones to whichever " + "side is short; the two lists describe the same thing." + ) + + +@pytest.mark.parametrize("description", _enum_descriptions(), ids=lambda d: str(d.key)) +def test_unknown_is_offered_as_a_fallback(description: Any) -> None: + """Every enum can be unknown, so every enum must say so. + + The value functions fall back to `"unknown"` when the panel publishes nothing, and + Home Assistant rejects a state outside `options`. A set that omits it is one + missing reading away from an error. + """ + assert "unknown" in (description.options or []), ( + f"{description.key} can report 'unknown' but does not declare it" + ) diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py new file mode 100644 index 00000000..b6d38629 --- /dev/null +++ b/tests/test_evse_charge_limit.py @@ -0,0 +1,718 @@ +"""The EVSE charge-current limit, as the integration's first number entity. + +The only settable property the v1.0 catch-up surfaces, so this is the one place +in the catch-up where being wrong reaches the panel rather than the dashboard. +The tests are shaped around that. + +**Nothing here names an amperage.** Every expectation is computed from the +capture, and where the capture publishes the same value on both chargers — it +publishes 32 on each — the test republishes differing values first, because an +assertion satisfied by reading one charger twice proves nothing about two. + +**The write is proved to the wire, not to a mock.** `test_the_write_reaches_the +_wire_as_one_publish` drives the entity through the real client and the real +schema_1 adapter and asserts the exact topic and payload the transport hands the +broker — including that the topic is addressed by the charger's *device id* +while the entity holds its serial-harmonised snapshot key, which are different +strings and would both look plausible in a log. + +**The control exists only where the panel declares it.** `$settable` is the +gate, so a charger whose limit is not declared settable gets no entity at all — +asserted through `async_setup_entry`, because an entity that must not exist +cannot be observed by asking an entity for its state. +""" + +from __future__ import annotations + +from collections.abc import Sequence +import json +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.number import NumberDeviceClass, NumberMode +from homeassistant.const import Platform, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import EntityCategory +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import PLATFORMS, SpanPanelRuntimeData +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + DerivedReason, + Producibility, + platform_descriptions, +) +from custom_components.span_panel.number import ( + EVSE_CHARGE_CURRENT_LIMIT, + SpanEvseNumber, + async_setup_entry, +) + +from .adapter_fixtures import ( + SCHEMA_ONE_PANEL, + schema_one_metadata, + schema_one_snapshot, + schema_one_tree, +) +from .factories import pv_binding_for + +EVSE = "evse" +EVSE_2 = "evse-2" +"""The two chargers the capture commissions. Two is what makes cross-wiring falsifiable.""" + +CONFIG_NODE = "config" +CEILING_TOPIC = f"{CONFIG_NODE}/max-charge-current" +LIMIT_TOPIC = f"{CONFIG_NODE}/user-max-charge-current" + +FIELD_LIMIT = "evse.charge_current_limit_a" +FIELD_CEILING = "evse.charge_current_ceiling_a" +FIELD_TARGET = "evse.charge_current_limit_target_a" +FIELD_SETTABLE = "evse.charge_current_limit_settable" + + +# --------------------------------------------------------------------------- +# Reading the capture +# --------------------------------------------------------------------------- + + +def _published(tree: dict[str, dict[str, str]], device_id: str, topic: str) -> int: + """What the capture publishes on this topic, or fail saying it does not.""" + value = tree[device_id].get(topic) + assert value is not None, f"{device_id} publishes no {topic} in the capture" + return int(value) + + +def _declaration(tree: dict[str, dict[str, str]], device_id: str, property_id: str) -> dict[str, object]: + """One property's declaration out of the charger's own `$description`.""" + description = json.loads(tree[device_id]["$description"]) + declared = description["nodes"][CONFIG_NODE]["properties"][property_id] + assert isinstance(declared, dict) + return declared + + +def _snapshot(**overrides: dict[str, int | None]) -> SpanPanelSnapshot: + """A snapshot from the capture with each charger's topics rewritten or removed.""" + tree = schema_one_tree() + for device_id, topics in overrides.items(): + for topic, value in topics.items(): + if value is None: + tree[device_id].pop(topic, None) + else: + tree[device_id][topic] = str(value) + return schema_one_snapshot(tree) + + +def _mutated_description( + tree: dict[str, dict[str, str]], device_id: str, mutate: object +) -> dict[str, dict[str, str]]: + """Rewrite one charger's `$description` through `mutate`, in place on `tree`.""" + description = json.loads(tree[device_id]["$description"]) + assert callable(mutate) + mutate(description) + tree[device_id]["$description"] = json.dumps(description) + return tree + + +def _not_settable(device_id: str) -> SpanPanelSnapshot: + """A charger that publishes a limit and does not declare it writable.""" + + def drop(description: dict[str, dict[str, dict[str, dict[str, dict[str, object]]]]]) -> None: + description["nodes"][CONFIG_NODE]["properties"]["user-max-charge-current"].pop("settable") + + return schema_one_snapshot(_mutated_description(schema_one_tree(), device_id, drop)) + + +def _renamed_to_catalog(device_id: str) -> dict[str, dict[str, str]]: + """The capture with one charger publishing the eBus `charge-limit` spelling. + + The naming the catalog specifies and no producer we have publishes. The + entity must not be able to tell: the library resolves the node from the + `$description`, so the rename is a rename and nothing else. + """ + tree = schema_one_tree() + description = json.loads(tree[device_id]["$description"]) + properties = description["nodes"].pop(CONFIG_NODE)["properties"] + description["nodes"]["charge-limit"] = { + "name": "charge-limit", + "type": "energy.ebus.capability.charge-limit", + "properties": { + "installer-max": properties["max-charge-current"], + "owner-limit": properties["user-max-charge-current"], + }, + } + tree[device_id]["$description"] = json.dumps(description) + tree[device_id]["charge-limit/installer-max"] = tree[device_id].pop(CEILING_TOPIC) + tree[device_id]["charge-limit/owner-limit"] = tree[device_id].pop(LIMIT_TOPIC) + return tree + + +def _fed_adapter(*extra: tuple[str, str]) -> object: + """A real schema_1 adapter fed the capture the way the broker replays it. + + `extra` appends messages the retained-topic fixture cannot express — a + Homie `$target` is published on `//$target`, which the + fixture's flat `{topic: value}` shape has no room for. + """ + from span_panel_api.models import V2HomieSchema + from span_panel_api_schema_1 import SchemaOneAdapter + + tree = schema_one_tree() + adapter = SchemaOneAdapter( + SCHEMA_ONE_PANEL, + V2HomieSchema( + firmware_version="spanos2/r202633/01", + types_schema_hash="sha256:test", + types={}, + data_model_version="1.0", + ), + ) + for device_id in [SCHEMA_ONE_PANEL, *[d for d in tree if d != SCHEMA_ONE_PANEL]]: + topics = tree[device_id] + prefix = f"ebus/5/{device_id}" + adapter.handle_message(f"{prefix}/$description", topics["$description"]) + adapter.handle_message(f"{prefix}/$state", topics["$state"]) + for topic, value in topics.items(): + if not topic.startswith("$"): + adapter.handle_message(f"{prefix}/{topic}", value) + for topic, value in extra: + adapter.handle_message(topic, value) + return adapter + + +# --------------------------------------------------------------------------- +# Building the entities +# --------------------------------------------------------------------------- + + +def _coordinator(snapshot: SpanPanelSnapshot, client: object | None = None) -> MagicMock: + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.unresolved_paths = frozenset() + coordinator.client = MagicMock() if client is None else client + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + coordinator.async_request_refresh = AsyncMock() + return coordinator + + +async def _created( + hass: HomeAssistant, snapshot: SpanPanelSnapshot, client: object | None = None +) -> list[SpanEvseNumber]: + """Everything `number.async_setup_entry` creates for one snapshot. + + Every call, not the last one: the platform adds the curated numbers in their + own call before it attempts the adopted ones, so that a device this + integration models nothing for cannot cost the user a control it does model. + """ + coordinator = _coordinator(snapshot, client) + async_add_entities = MagicMock() + + await async_setup_entry(hass, coordinator.config_entry, async_add_entities) + + added: Sequence[SpanEvseNumber] = [ + entity for call in async_add_entities.call_args_list for entity in call.args[0] + ] + for entity in added: + assert isinstance(entity, SpanEvseNumber) + return list(added) + + +def _serial(tree: dict[str, dict[str, str]], device_id: str) -> str: + serial = tree[device_id].get("info/serial-number") + assert serial, f"{device_id} publishes no serial in the capture" + return serial + + +def _for(created: Sequence[SpanEvseNumber], tree: dict[str, dict[str, str]], device_id: str) -> SpanEvseNumber: + """The number belonging to one charger, found by the serial the snapshot keys it on.""" + serial = _serial(tree, device_id) + matches = [entity for entity in created if entity._evse_id == serial] + assert len(matches) == 1, f"{len(matches)} numbers created for {device_id}, expected 1" + return matches[0] + + +def _refreshed(entity: SpanEvseNumber, snapshot: SpanPanelSnapshot | None) -> SpanEvseNumber: + """Push a new snapshot through the coordinator update the way HA does.""" + entity.coordinator.data = snapshot + entity.async_write_ha_state = MagicMock() + entity._handle_coordinator_update() + return entity + + +# --------------------------------------------------------------------------- +# The platform exists +# --------------------------------------------------------------------------- + + +def test_the_number_platform_is_forwarded() -> None: + """A platform module nothing forwards creates nothing, silently.""" + assert Platform.NUMBER in PLATFORMS + + +async def test_one_number_per_charger_that_declares_a_settable_limit(hass: HomeAssistant) -> None: + tree = schema_one_tree() + created = await _created(hass, schema_one_snapshot(tree)) + + assert [entity._evse_id for entity in created] == [_serial(tree, EVSE), _serial(tree, EVSE_2)] + assert {entity.entity_description.key for entity in created} == {EVSE_CHARGE_CURRENT_LIMIT.key} + assert len({entity.unique_id for entity in created}) == 2 + + +async def test_no_number_where_the_limit_is_not_declared_settable(hass: HomeAssistant) -> None: + """The refusal, at the point where it costs a user nothing. + + A charger publishes a perfectly readable limit and does not declare it + writable. Offering the control anyway would put a write on the wire the + panel never offered, and the user would find out by it not working. + """ + tree = schema_one_tree() + created = await _created(hass, _not_settable(EVSE)) + + assert [entity._evse_id for entity in created] == [_serial(tree, EVSE_2)] + + +async def test_no_number_where_the_charger_declares_no_limit_at_all(hass: HomeAssistant) -> None: + """`charge-limit.md`: absence means the charger charges at a fixed rate.""" + tree = schema_one_tree() + + def drop(description: dict[str, dict[str, object]]) -> None: + description["nodes"].pop(CONFIG_NODE) + + _mutated_description(tree, EVSE, drop) + del tree[EVSE][CEILING_TOPIC] + del tree[EVSE][LIMIT_TOPIC] + + created = await _created(hass, schema_one_snapshot(tree)) + + assert [entity._evse_id for entity in created] == [_serial(tree, EVSE_2)] + + +# --------------------------------------------------------------------------- +# Reading — per charger, from the wire +# --------------------------------------------------------------------------- + + +async def test_each_number_reads_its_own_charger(hass: HomeAssistant) -> None: + """Read each charger's own limit, from values made to differ first. + + The capture publishes 32 on both, so an assertion against it as-published is + satisfied by a platform that reads one charger twice. + """ + tree = schema_one_tree() + first = _published(tree, EVSE, LIMIT_TOPIC) - 8 + second = _published(tree, EVSE_2, LIMIT_TOPIC) - 16 + assert first != second + + created = await _created(hass, _snapshot(evse={LIMIT_TOPIC: first}, **{"evse-2": {LIMIT_TOPIC: second}})) + + assert _for(created, tree, EVSE).native_value == first + assert _for(created, tree, EVSE_2).native_value == second + + +async def test_each_number_is_bounded_by_its_own_ceiling(hass: HomeAssistant) -> None: + """`native_max_value` is the commissioned ceiling, and per charger.""" + tree = schema_one_tree() + first = _published(tree, EVSE, CEILING_TOPIC) - 8 + second = _published(tree, EVSE_2, CEILING_TOPIC) - 16 + assert first != second + + created = await _created(hass, _snapshot(evse={CEILING_TOPIC: first}, **{"evse-2": {CEILING_TOPIC: second}})) + + assert _for(created, tree, EVSE).native_max_value == first + assert _for(created, tree, EVSE_2).native_max_value == second + + +async def test_republishing_moves_the_state_and_the_bound(hass: HomeAssistant) -> None: + tree = schema_one_tree() + created = await _created(hass, schema_one_snapshot(tree)) + entity = _for(created, tree, EVSE) + assert entity.native_value == _published(tree, EVSE, LIMIT_TOPIC) + + lowered = _published(tree, EVSE, LIMIT_TOPIC) - 16 + recommissioned = _published(tree, EVSE, CEILING_TOPIC) - 8 + _refreshed(entity, _snapshot(evse={LIMIT_TOPIC: lowered, CEILING_TOPIC: recommissioned})) + + assert entity.native_value == lowered + assert entity.native_max_value == recommissioned + + +async def test_an_unpublished_limit_shows_the_ceiling(hass: HomeAssistant) -> None: + """No user limit means the commissioned ceiling is the limit in force. + + From r202639 a charger nobody has limited publishes no user limit at all. + The control is still offered, and shows what the charger is enforcing. + """ + tree = schema_one_tree() + recommissioned = _published(tree, EVSE, CEILING_TOPIC) - 8 + created = await _created(hass, _snapshot(evse={LIMIT_TOPIC: None, CEILING_TOPIC: recommissioned})) + entity = _for(created, tree, EVSE) + + assert entity.native_value == recommissioned + assert entity.available is True + + +async def test_a_retained_limit_equal_to_the_ceiling_reads_the_same(hass: HomeAssistant) -> None: + """A stale retained limit at the ceiling and an absent one are the same setting.""" + tree = schema_one_tree() + ceiling = _published(tree, EVSE, CEILING_TOPIC) + retained = _for(await _created(hass, _snapshot(evse={LIMIT_TOPIC: ceiling})), tree, EVSE) + absent = _for(await _created(hass, _snapshot(evse={LIMIT_TOPIC: None})), tree, EVSE) + + assert retained.native_value == absent.native_value == ceiling + assert retained.available is absent.available is True + + +async def test_neither_limit_nor_ceiling_is_unknown_rather_than_zero(hass: HomeAssistant) -> None: + """With nothing published there is no reading, and no bound to offer either.""" + tree = schema_one_tree() + created = await _created(hass, _snapshot(evse={LIMIT_TOPIC: None, CEILING_TOPIC: None})) + entity = _for(created, tree, EVSE) + + assert entity.native_value is None + assert entity.available is False + + +async def test_an_unpublished_ceiling_makes_the_control_unavailable(hass: HomeAssistant) -> None: + """A number must report some maximum, and Home Assistant's default is 100. + + Rendering an uncommissioned charger as a 0-100 A control would put a + plausible-looking amperage in front of a user that no installer ever set. + Unavailable says the panel has not told us what the charger is rated for. + """ + tree = schema_one_tree() + created = await _created(hass, _snapshot(evse={CEILING_TOPIC: None})) + + assert _for(created, tree, EVSE).available is False + assert _for(created, tree, EVSE_2).available is True + + +async def test_a_coordinator_with_no_snapshot_yet_takes_the_control_down(hass: HomeAssistant) -> None: + """A failed first refresh leaves `coordinator.data` unset. + + Every other read here goes through the snapshot, so the entity has to answer + without one rather than raising inside a property Home Assistant polls. + """ + tree = schema_one_tree() + created = await _created(hass, schema_one_snapshot(tree)) + entity = _for(created, tree, EVSE) + + _refreshed(entity, None) + + assert entity.available is False + assert entity.native_value is None + assert entity.extra_state_attributes is None + + +async def test_the_control_is_unavailable_while_the_panel_is_offline(hass: HomeAssistant) -> None: + tree = schema_one_tree() + coordinator = _coordinator(schema_one_snapshot(tree)) + async_add_entities = MagicMock() + await async_setup_entry(hass, coordinator.config_entry, async_add_entities) + entity = _for( + [entity for call in async_add_entities.call_args_list for entity in call.args[0]], tree, EVSE + ) + assert entity.available is True + + coordinator.panel_offline = True + + assert entity.available is False + + +# --------------------------------------------------------------------------- +# The pending write +# --------------------------------------------------------------------------- + + +async def test_a_pending_write_is_an_attribute_and_not_the_state(hass: HomeAssistant) -> None: + """The `$target` echo, rendered the way the priority select renders its own. + + Reporting the requested value as the state would show a limit the charger + may never have accepted. + """ + tree = schema_one_tree() + pending = _published(tree, EVSE, LIMIT_TOPIC) - 8 + adapter = _fed_adapter((f"ebus/5/{EVSE}/{LIMIT_TOPIC}/$target", str(pending))) + snapshot = adapter.build_snapshot() + + created = await _created(hass, snapshot) + entity = _for(created, tree, EVSE) + + assert entity.extra_state_attributes == {"charge_current_limit_target": pending} + assert entity.native_value == _published(tree, EVSE, LIMIT_TOPIC) + assert _for(created, tree, EVSE_2).extra_state_attributes is None + + +# --------------------------------------------------------------------------- +# The other spelling +# --------------------------------------------------------------------------- + + +async def test_the_catalogued_spelling_produces_the_same_control(hass: HomeAssistant) -> None: + """`charge-limit/{installer-max,owner-limit}` — the eBus 0.1 naming. + + Nothing in this integration names either spelling, so a charger publishing + the specified one has to produce an identical entity. Asserted against the + unrenamed capture rather than against literals, so the two are held to each + other. + """ + tree = schema_one_tree() + published = _for(await _created(hass, schema_one_snapshot(tree)), tree, EVSE) + catalogued = _for(await _created(hass, schema_one_snapshot(_renamed_to_catalog(EVSE))), tree, EVSE) + + assert catalogued.unique_id == published.unique_id + assert catalogued.native_value == published.native_value + assert catalogued.native_max_value == published.native_max_value + assert catalogued.available == published.available + + +# --------------------------------------------------------------------------- +# The control's shape, taken from the declaration +# --------------------------------------------------------------------------- + + +def test_the_unit_is_the_one_the_charger_declares() -> None: + """The entity's unit against the panel's, through the adapter's metadata row. + + The same check `evaluate_field_metadata` makes for every sensor, made here + because a number carries a unit and this platform is not a sensor platform. + """ + declared = schema_one_metadata()[FIELD_LIMIT] + + assert declared.resolved is True + assert declared.unit == EVSE_CHARGE_CURRENT_LIMIT.native_unit_of_measurement + assert EVSE_CHARGE_CURRENT_LIMIT.native_unit_of_measurement == UnitOfElectricCurrent.AMPERE + + +def test_the_step_is_the_granularity_the_charger_declares() -> None: + """A step of 1 is a claim about the datatype, so it is checked against it.""" + tree = schema_one_tree() + + assert _declaration(tree, EVSE, "user-max-charge-current")["datatype"] == "integer" + assert schema_one_metadata()[FIELD_LIMIT].datatype == "integer" + assert EVSE_CHARGE_CURRENT_LIMIT.native_step == 1 + + +def test_the_control_is_configuration_rather_than_measurement() -> None: + assert EVSE_CHARGE_CURRENT_LIMIT.entity_category is EntityCategory.CONFIG + assert EVSE_CHARGE_CURRENT_LIMIT.device_class is NumberDeviceClass.CURRENT + assert EVSE_CHARGE_CURRENT_LIMIT.mode is NumberMode.BOX + assert EVSE_CHARGE_CURRENT_LIMIT.native_min_value == 0 + + +# --------------------------------------------------------------------------- +# Writing +# --------------------------------------------------------------------------- + + +def _live_client() -> tuple[object, MagicMock]: + """A real transport over a real schema_1 adapter fed the capture. + + Not a mock: the point of the write tests is the topic and the payload, and a + mocked client asserts only that the integration called the method it was + written to call. + """ + from span_panel_api.mqtt.client import MqttClientConfig, SpanMqttClient + + client = SpanMqttClient( + host="192.168.1.1", + serial_number=SCHEMA_ONE_PANEL, + broker_config=MqttClientConfig(broker_host="h", username="u", password="p"), + ) + client._adapter = _fed_adapter() + bridge = MagicMock() + client._bridge = bridge + return client, bridge + + +async def test_the_write_reaches_the_wire_as_one_publish(hass: HomeAssistant) -> None: + """Entity to broker, with the exact topic and payload asserted. + + The topic is addressed by the charger's **device id** while the entity holds + its serial-harmonised snapshot key — two different strings, both plausible + in a log, and only one of which any panel subscribes to. + """ + tree = schema_one_tree() + client, bridge = _live_client() + created = await _created(hass, schema_one_snapshot(tree), client) + entity = _for(created, tree, EVSE) + asked = _published(tree, EVSE, CEILING_TOPIC) - 8 + + await entity.async_set_native_value(float(asked)) + + assert entity._evse_id != EVSE + bridge.publish.assert_called_once_with( + f"ebus/5/{EVSE}/config/user-max-charge-current/set", str(asked) + ) + entity.coordinator.async_request_refresh.assert_awaited_once() + + +async def test_the_write_goes_to_the_charger_the_entity_belongs_to(hass: HomeAssistant) -> None: + tree = schema_one_tree() + client, bridge = _live_client() + created = await _created(hass, schema_one_snapshot(tree), client) + asked = _published(tree, EVSE_2, CEILING_TOPIC) - 8 + + await _for(created, tree, EVSE_2).async_set_native_value(float(asked)) + + bridge.publish.assert_called_once_with( + f"ebus/5/{EVSE_2}/config/user-max-charge-current/set", str(asked) + ) + + +async def test_a_fractional_request_truncates_downward(hass: HomeAssistant) -> None: + """The property is declared `integer`, so some whole number has to be chosen. + + Down, because this is a ceiling: asking for a fraction and getting the lower + whole number is a slower charge, getting the higher one is a current the + user did not request. + """ + tree = schema_one_tree() + client, bridge = _live_client() + created = await _created(hass, schema_one_snapshot(tree), client) + whole = _published(tree, EVSE, CEILING_TOPIC) - 8 + + await _for(created, tree, EVSE).async_set_native_value(whole + 0.7) + + bridge.publish.assert_called_once_with( + f"ebus/5/{EVSE}/config/user-max-charge-current/set", str(whole) + ) + + +async def test_a_value_above_the_ceiling_is_refused_before_it_reaches_the_wire( + hass: HomeAssistant, +) -> None: + """The second of the two range checks, and the one that matters. + + Home Assistant rejects a service call outside the range this entity last + reported. This is the library rejecting it against what the panel is + publishing now — which is the same check only until an installer + recommissions the charger between the two. + """ + tree = schema_one_tree() + client, bridge = _live_client() + created = await _created(hass, schema_one_snapshot(tree), client) + over = _published(tree, EVSE, CEILING_TOPIC) + 1 + + with pytest.raises(HomeAssistantError) as raised: + await _for(created, tree, EVSE).async_set_native_value(float(over)) + + assert raised.value.translation_key == "evse_charge_limit_failed" + bridge.publish.assert_not_called() + + +async def test_a_command_the_transport_never_handed_over_is_reported( + hass: HomeAssistant, +) -> None: + """`FAILED`, produced by the real transport declining while disconnected. + + The bridge refuses rather than letting paho queue the publish across a + reconnect, which is what makes `FAILED` a promise: nothing fires later + against a panel nobody is watching. A promise that specific is worth telling + the user, and it is a different fact from a refusal -- this one resolved an + address and never handed it over. + """ + tree = schema_one_tree() + client, bridge = _live_client() + bridge.publish.return_value = None + created = await _created(hass, schema_one_snapshot(tree), client) + entity = _for(created, tree, EVSE) + asked = _published(tree, EVSE, CEILING_TOPIC) - 8 + + with pytest.raises(HomeAssistantError) as raised: + await entity.async_set_native_value(float(asked)) + + assert raised.value.translation_key == "evse_charge_limit_not_delivered" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["reason"] == "broker not connected; refused rather than queued" + entity.coordinator.async_request_refresh.assert_not_awaited() + + +async def test_a_failure_names_the_charger_the_user_sees(hass: HomeAssistant) -> None: + """`charger` reaches a person, so it carries the name on the device card. + + The wire addresses a charger by node id and the snapshot keys it by a + serial-harmonised id. Neither string appears anywhere the user looks, so a + notification carrying one names a charger they cannot find. + """ + tree = schema_one_tree() + client, bridge = _live_client() + bridge.publish.return_value = None + created = await _created(hass, schema_one_snapshot(tree), client) + entity = _for(created, tree, EVSE) + asked = _published(tree, EVSE, CEILING_TOPIC) - 8 + + with pytest.raises(HomeAssistantError) as raised: + await entity.async_set_native_value(float(asked)) + + placeholders = raised.value.translation_placeholders + assert placeholders is not None + device_info = entity._attr_device_info + assert device_info is not None + assert placeholders["charger"] == device_info["name"] + assert placeholders["charger"] != entity._evse_id + + +async def test_the_range_home_assistant_checks_is_the_commissioned_one(hass: HomeAssistant) -> None: + """The first of the two checks, asserted through the range the entity reports. + + `number.async_set_value` refuses a service call outside `min_value` / + `max_value` before it ever reaches this platform, and for a non-temperature + device class those are `native_min_value` / `native_max_value` unconverted. + So the range the entity reports has to be the panel's, which is what reading + `native_max_value` off the wire buys — the state-side properties are not + read here because they raise until the entity is attached to a platform. + """ + tree = schema_one_tree() + created = await _created(hass, schema_one_snapshot(tree)) + entity = _for(created, tree, EVSE) + + assert entity.native_max_value == _published(tree, EVSE, CEILING_TOPIC) + assert entity.native_min_value == 0 + + +# --------------------------------------------------------------------------- +# The declarations this entity makes about itself +# --------------------------------------------------------------------------- + + +def test_the_description_names_its_source_field_and_why_it_is_exempt() -> None: + """Both `field_path` and `derived`, as a schema-conditional description must. + + The only settable entity to carry the pair, and the Repair naming a dead + field has to be able to name this entity too. + """ + assert EVSE_CHARGE_CURRENT_LIMIT.field_path == FIELD_LIMIT + assert EVSE_CHARGE_CURRENT_LIMIT.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD + assert EVSE_CHARGE_CURRENT_LIMIT in platform_descriptions() + + +def test_every_field_this_control_reads_is_enumerated() -> None: + """Four reads, four annotations, each checked against the adapters elsewhere. + + The pair the panel publishes as readings carries a schema_1 metadata row; the + pair that describes a command carries none on either adapter. + """ + assert RESIDUAL_EXEMPT_PATHS[FIELD_LIMIT] is Producibility.SCHEMA_1_ONLY + assert RESIDUAL_EXEMPT_PATHS[FIELD_CEILING] is Producibility.SCHEMA_1_ONLY + assert RESIDUAL_EXEMPT_PATHS[FIELD_TARGET] is Producibility.NEITHER + assert RESIDUAL_EXEMPT_PATHS[FIELD_SETTABLE] is Producibility.NEITHER diff --git a/tests/test_evse_entities.py b/tests/test_evse_entities.py index de88028a..06c9e36a 100644 --- a/tests/test_evse_entities.py +++ b/tests/test_evse_entities.py @@ -77,19 +77,19 @@ class TestEvseSensorDefinitions: """Test EVSE sensor definition structure.""" def test_evse_sensors_count(self): - assert len(EVSE_SENSORS) == 3 + assert len(EVSE_SENSORS) == 4 def test_evse_status_sensor_is_enum(self): status_desc = next(d for d in EVSE_SENSORS if d.key == "evse_status") assert status_desc.device_class is not None assert status_desc.device_class.value == "enum" - assert status_desc.options == ["unknown"] + assert "charging" in (status_desc.options or []) def test_evse_lock_state_sensor_is_enum(self): lock_desc = next(d for d in EVSE_SENSORS if d.key == "evse_lock_state") assert lock_desc.device_class is not None assert lock_desc.device_class.value == "enum" - assert lock_desc.options == ["unknown"] + assert "locked" in (lock_desc.options or []) def test_evse_advertised_current_is_measurement(self): current_desc = next( @@ -172,12 +172,16 @@ def test_evse_device_info_full_metadata(self): evse = SpanEvseSnapshotFactory.create( node_id="evse-0", vendor_name="SPAN", - product_name="SPAN Drive", + model="SPAN Drive", serial_number="SN123", software_version="2.0.0", ) info = evse_device_info( - "panel-serial", evse, "Main House", display_suffix="Garage" + "panel-serial", + evse, + "Main House", + display_suffix="Garage", + panel_device_id="panel-device-id", ) identifiers = info.get("identifiers") assert identifiers is not None @@ -187,45 +191,64 @@ def test_evse_device_info_full_metadata(self): assert info.get("model") == "SPAN Drive" assert info.get("serial_number") == "SN123" assert info.get("sw_version") == "2.0.0" - via = info.get("via_device") - assert via == ("span_panel", "panel-serial") + assert info.get("via_device_id") == "panel-device-id" def test_evse_device_info_fallback_names(self): evse = SpanEvseSnapshotFactory.create( vendor_name=None, - product_name=None, + model=None, + ) + info = evse_device_info( + "panel-serial", evse, "Span Panel", display_suffix=None, panel_device_id="pd" ) - info = evse_device_info("panel-serial", evse, "Span Panel", display_suffix=None) assert info.get("name") == "Span Panel EV Charger" assert info.get("manufacturer") == "SPAN" assert info.get("model") == "SPAN Drive" def test_evse_device_info_serial_suffix(self): evse = SpanEvseSnapshotFactory.create( - product_name="SPAN Drive", + model="SPAN Drive", serial_number="SN-EVSE-001", ) info = evse_device_info( - "panel-serial", evse, "Museum Garage", display_suffix="SN-EVSE-001" + "panel-serial", + evse, + "Museum Garage", + display_suffix="SN-EVSE-001", + panel_device_id="pd", ) assert info.get("name") == "Museum Garage SPAN Drive (SN-EVSE-001)" def test_evse_device_info_no_serial(self): evse = SpanEvseSnapshotFactory.create(serial_number=None) - info = evse_device_info("panel-serial", evse, "Span Panel") + info = evse_device_info("panel-serial", evse, "Span Panel", panel_device_id="pd") assert info.get("serial_number") is None class TestEvseStatusOptions: - """Test EVSE enum options seed with 'unknown' only.""" + """EVSE enums declare every state they can report. + + These asserted the opposite -- that both seeded with `["unknown"]` and nothing + else -- pinning a bug rather than a contract. A sensor sitting at `charging` while + declaring only `unknown` as possible is what Home Assistant renders as + "Possible states: Unknown", and the runtime discovery meant to fill the gap could + only ever list states the charger had already reached. - def test_status_options_seed_with_unknown(self): + The exhaustive comparison against the translations lives in + `test_enum_sensor_options.py`; these keep the EVSE-specific expectations local. + """ + + def test_status_declares_the_full_charging_lifecycle(self): status_desc = next(d for d in EVSE_SENSORS if d.key == "evse_status") - assert status_desc.options == ["unknown"] + options = set(status_desc.options or []) + + assert {"available", "charging", "preparing", "finishing", "faulted"} <= options + assert "unknown" in options - def test_lock_state_options_seed_with_unknown(self): + def test_lock_state_declares_both_positions(self): lock_desc = next(d for d in EVSE_SENSORS if d.key == "evse_lock_state") - assert lock_desc.options == ["unknown"] + + assert set(lock_desc.options or []) == {"locked", "unlocked", "unknown"} class TestEvseMultipleDevices: @@ -248,8 +271,8 @@ def test_multiple_evse_in_snapshot(self): def test_multiple_evse_device_infos_are_distinct(self): evse_a = SpanEvseSnapshotFactory.create(node_id="evse-0") evse_b = SpanEvseSnapshotFactory.create(node_id="evse-1") - info_a = evse_device_info("panel", evse_a, "Span Panel") - info_b = evse_device_info("panel", evse_b, "Span Panel") + info_a = evse_device_info("panel", evse_a, "Span Panel", panel_device_id="pd") + info_b = evse_device_info("panel", evse_b, "Span Panel", panel_device_id="pd") assert info_a.get("identifiers") != info_b.get("identifiers") @@ -263,7 +286,7 @@ def test_default_factory_values(self): assert evse.lock_state == "LOCKED" assert evse.advertised_current_a == 32.0 assert evse.vendor_name == "SPAN" - assert evse.product_name == "SPAN Drive" + assert evse.model == "SPAN Drive" def test_available_factory(self): evse = SpanEvseSnapshotFactory.create_available() diff --git a/tests/test_extension_entities.py b/tests/test_extension_entities.py new file mode 100644 index 00000000..74340c9b --- /dev/null +++ b/tests/test_extension_entities.py @@ -0,0 +1,1006 @@ +"""Vendor extensions on curated devices become entities, on the right card, forever. + +Three properties carry this half of the design and each fails loudly here if it +stops holding: an extension entity lands on the device it belongs to and never +mints a card of its own, the platform a row is born under is the platform it +keeps however the declaration changes, and nothing is created for a card that is +not there yet. +""" + +from __future__ import annotations + +import ast +from dataclasses import replace +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.persistent_notification import async_dismiss +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, EntityCategory, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import ExtensionProperty, ExtensionSubject, SpanPVSnapshot + +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay, CurationRecord +from custom_components.span_panel.extension import ( + HINT_DETAIL, + HINT_READING, + MAX_PER_DEVICE, + ExtensionBinarySensor, + ExtensionSensor, + adoptable, + async_notice_declined_extensions, + classify_extension, + create_extension_binary_sensors, + create_extension_sensors, + extension_curation_key, + extension_device_identifier, + extension_scope, + extension_unique_id, + prominence_hint, + resolve_platform, + subject_key, +) +from custom_components.span_panel.notices import _DATA, async_restore +from custom_components.span_panel.pv_binding import ( + UNDECIDED_PV_BINDING, + PvBinding, + StoredPvBinding, + resolve, +) +from custom_components.span_panel.util import SUB_DEVICE_BESS + +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + +if TYPE_CHECKING: + from span_panel_api import SpanPanelSnapshot + +PANEL_SERIAL = "sp3-242424-001" +BESS_IDENTIFIER = f"{PANEL_SERIAL}_{SUB_DEVICE_BESS}" + + +@pytest.fixture +def registered_panel(hass: HomeAssistant) -> tuple[str, str]: + """Return a config entry with the panel and its BESS card registered, as setup leaves them.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id=PANEL_SERIAL) + mock.add_to_hass(hass) + registry = dr.async_get(hass) + panel = registry.async_get_or_create( + config_entry_id=mock.entry_id, + identifiers={(DOMAIN, PANEL_SERIAL)}, + name="Span Panel", + ) + registry.async_get_or_create( + config_entry_id=mock.entry_id, + identifiers={(DOMAIN, BESS_IDENTIFIER)}, + name="Span Panel Battery", + via_device_id=panel.id, + ) + return str(mock.entry_id), panel.id + + +def _row( + node_id: str = "battery-2", + property_id: str = "cell-temperature", + datatype: str = "float", + unit: str | None = "°C", + value: str | None = "31.4", + kind: str = "battery", + instance_key: str | None = None, + settable: bool = False, +) -> ExtensionProperty: + return ExtensionProperty( + subject=ExtensionSubject(kind=kind, instance_key=instance_key), + node_id=node_id, + property_id=property_id, + datatype=datatype, + unit=unit, + value=value, + settable=settable, + ) + + +def _snapshot(*rows: ExtensionProperty) -> SpanPanelSnapshot: + """Return a complete curated snapshot carrying the given extension rows.""" + return replace( + SpanPanelSnapshotFactory.create_complete(serial_number=PANEL_SERIAL), + extension_properties=rows, + ) + + +def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: + coordinator = MagicMock() + coordinator.data = snapshot + return coordinator + + +# --- the platform table, and the one-way door ------------------------------- + + +@pytest.mark.parametrize( + ("datatype", "expected"), + [ + ("boolean", Platform.BINARY_SENSOR), + ("float", Platform.SENSOR), + ("integer", Platform.SENSOR), + ("enum", Platform.SENSOR), + ("string", Platform.SENSOR), + ], +) +def test_two_platforms_only(datatype: str, expected: Platform) -> None: + """No controls, whatever the declaration says -- adoption's three rows are absent.""" + assert classify_extension(datatype) is expected + + +def test_a_settable_property_is_still_a_reading() -> None: + """The read-only ruling, at the classifier. + + A control here would sit beside curated controls that do real safety work on + the same wire -- the EVSE limit refuses a value above the commissioned + ceiling -- with none of their translation or bounds. + """ + assert classify_extension("enum") is Platform.SENSOR + assert classify_extension("float") is Platform.SENSOR + + +def test_the_platform_a_row_is_born_under_is_the_one_it_keeps(hass: HomeAssistant) -> None: + """Metadata may reshape an entity; it may never move its domain. + + The registry refuses a cross-domain rename outright, so re-deriving the + platform from a changed declaration would not move the row -- it would strand + it and mint a second entity beside it. + """ + registry = er.async_get(hass) + unique_id = "span_sp3-242424-001_adopted_bess/battery-2/cell-temperature" + registry.async_get_or_create( + Platform.SENSOR.value, DOMAIN, unique_id, suggested_object_id="battery_2_cell_temperature" + ) + + # The publisher relabels the property as a boolean. The row stays a sensor. + assert resolve_platform(registry, unique_id, "boolean") is Platform.SENSOR + + +def test_an_unregistered_id_takes_the_platform_its_datatype_implies(hass: HomeAssistant) -> None: + assert ( + resolve_platform(er.async_get(hass), "span_x_adopted_bess/n/p", "boolean") + is Platform.BINARY_SENSOR + ) + + +# --- placement -------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("kind", "instance_key", "expected"), + [ + ("panel", None, PANEL_SERIAL), + ("circuit", "abc123", PANEL_SERIAL), + ("battery", None, f"{PANEL_SERIAL}_bess"), + ("mid", None, f"{PANEL_SERIAL}_mid"), + ("pv", None, f"{PANEL_SERIAL}_pv"), + ("pv", "abc123", f"{PANEL_SERIAL}_pv_abc123"), + ("evse", "acme-001", f"{PANEL_SERIAL}_evse_acme-001"), + ], +) +def test_each_subject_resolves_to_an_existing_card( + kind: str, instance_key: str | None, expected: str +) -> None: + """A circuit's entities live on the panel's card, as its curated ones do.""" + subject = ExtensionSubject(kind=kind, instance_key=instance_key) + assert extension_device_identifier(PANEL_SERIAL, subject) == expected + + +def test_a_row_whose_card_is_not_registered_yet_is_deferred( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A capability race defers the entity to the next reload rather than minting a card.""" + snapshot = _snapshot(_row(kind="pv")) + assert adoptable(snapshot, dr.async_get(hass), er.async_get(hass), config_entry_id=registered_panel[0], pv_binding=UNDECIDED_PV_BINDING) == [] + + +def test_a_row_on_a_registered_card_is_adoptable( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + snapshot = _snapshot(_row()) + adoptable_rows = adoptable(snapshot, dr.async_get(hass), er.async_get(hass), config_entry_id=registered_panel[0], pv_binding=UNDECIDED_PV_BINDING) + assert len(adoptable_rows) == 1 + (adopted,) = adoptable_rows + assert adopted.device_identifier == BESS_IDENTIFIER + assert adopted.unique_id == "span_sp3-242424-001_adopted_bess/battery-2/cell-temperature" + assert adopted.row.path == "battery-2/cell-temperature" + + +def test_an_off_charset_address_is_declined_rather_than_sanitised( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + snapshot = _snapshot(_row(property_id="Cell_Temperature")) + assert adoptable(snapshot, dr.async_get(hass), er.async_get(hass), config_entry_id=registered_panel[0], pv_binding=UNDECIDED_PV_BINDING) == [] + + +# --- the cap ---------------------------------------------------------------- + + +def test_a_vendor_flooding_one_device_is_capped( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Registry rows are permanent and nothing removes them, so the flood is bounded.""" + rows = tuple(_row(property_id=f"reading-{index}") for index in range(MAX_PER_DEVICE + 25)) + adopted = adoptable(_snapshot(*rows), dr.async_get(hass), er.async_get(hass), config_entry_id=registered_panel[0], pv_binding=UNDECIDED_PV_BINDING) + assert len(adopted) == MAX_PER_DEVICE + + +def test_the_cap_is_per_wire_device_not_per_card( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """One noisy device must not crowd out a quiet one that shares its card. + + The panel, every circuit and both lugs render on the panel's card. Counting + per card would pool thirty-five wire devices against one allowance, so two + vendor properties on each circuit of a 32-circuit panel would truncate with + no misbehaving publisher anywhere. The cap counts the wire device. + """ + noisy = tuple( + _row(kind="circuit", instance_key="circuit-a", node_id="acme", property_id=f"reading-{index}") + for index in range(MAX_PER_DEVICE + 5) + ) + quiet = ( + _row(kind="circuit", instance_key="circuit-b", node_id="acme", property_id="reading-0"), + _row(kind="panel", node_id="acme", property_id="site-reading"), + _row(kind="lugs", instance_key="upstream", node_id="acme", property_id="phase-balance"), + ) + adopted = adoptable(_snapshot(*noisy, *quiet), dr.async_get(hass), er.async_get(hass), config_entry_id=registered_panel[0], pv_binding=UNDECIDED_PV_BINDING) + + # Every quiet device keeps its readings, though all four share the panel card. + adopted_keys = [subject_key(extension.row.subject) for extension in adopted] + assert adopted_keys.count("circuit:circuit-a") == MAX_PER_DEVICE + assert adopted_keys.count("circuit:circuit-b") == 1 + assert adopted_keys.count("panel") == 1 + assert adopted_keys.count("lugs:upstream") == 1 + assert {extension.device_identifier for extension in adopted} == {PANEL_SERIAL} + + +def test_two_lugs_publishing_the_same_property_get_two_identities( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The collision that folding lugs into `panel` produced. + + Both lugs devices run the same firmware, so a vendor extension on one is the + expected case of the same extension on both. One subject for the pair minted + one unique_id for two readings: Home Assistant drops the second, and the + survivor shows whichever device sorted first. + """ + upstream = _row(kind="lugs", instance_key="upstream", node_id="acme", property_id="phase-balance", value="1.5") + downstream = _row(kind="lugs", instance_key="downstream", node_id="acme", property_id="phase-balance", value="99.9") + adopted = adoptable(_snapshot(upstream, downstream), dr.async_get(hass), er.async_get(hass), config_entry_id=registered_panel[0], pv_binding=UNDECIDED_PV_BINDING) + + ids = [extension.unique_id for extension in adopted] + assert len(ids) == len(set(ids)) == 2 + # Both still render on the panel's card: identity distinguishes, placement merges. + assert {extension.device_identifier for extension in adopted} == {PANEL_SERIAL} + + +def test_a_registered_entity_is_never_displaced_by_the_cap( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A standing entity outranks a new arrival, whatever order the wire sends them in. + + The row order tracks the wire, so a firmware update declaring a property + earlier shifts everything after it. Capping on arrival order alone would let + a new property evict a standing entity whose registry row is permanent and + for which nothing would ever build an entity again -- unavailable forever, + with no migration path by design. + """ + registry = er.async_get(hass) + standing = _row(property_id="long-standing") + standing_id = extension_unique_id(PANEL_SERIAL, standing.subject, standing.node_id, standing.property_id) + assert standing_id is not None + registry.async_get_or_create(Platform.SENSOR.value, DOMAIN, standing_id) + + # The standing property now arrives *last*, behind a full cap of new ones. + newcomers = tuple(_row(property_id=f"new-{index}") for index in range(MAX_PER_DEVICE)) + adopted = adoptable(_snapshot(*newcomers, standing), dr.async_get(hass), registry, config_entry_id=registered_panel[0], pv_binding=UNDECIDED_PV_BINDING) + + assert standing_id in {extension.unique_id for extension in adopted} + assert len(adopted) == MAX_PER_DEVICE + + +# --- the entities themselves ------------------------------------------------ + + +def test_a_sensor_arrives_disabled_diagnostic_and_without_statistics( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The arrival state, and the one guarantee that makes reshaping safe. + + No `state_class` means no long-term statistics, so a later unit or + device-class change has nothing to corrupt. + """ + snapshot = _snapshot(_row()) + sensors = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=CurationOverlay.empty(), + pv_binding=UNDECIDED_PV_BINDING, + ) + assert len(sensors) == 1 + sensor = sensors[0] + assert isinstance(sensor, ExtensionSensor) + assert sensor._attr_entity_registry_enabled_default is False + assert sensor._attr_entity_category is EntityCategory.DIAGNOSTIC + assert getattr(sensor.entity_description, "state_class", None) is None + assert sensor.native_value == 31.4 + assert sensor.entity_description.native_unit_of_measurement == "°C" + + +def test_a_name_carries_the_node_so_it_cannot_collide_with_a_curated_one( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Curated names on these cards carry no wire vocabulary, so prefixing avoids collisions.""" + snapshot = _snapshot(_row()) + sensor = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=CurationOverlay.empty(), + pv_binding=UNDECIDED_PV_BINDING, + )[0] + assert sensor._attr_name == "Battery 2 Cell Temperature" + + +def test_a_declared_boolean_becomes_a_binary_sensor( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + snapshot = _snapshot( + _row(property_id="pack-enabled", datatype="boolean", unit=None, value="true") + ) + binary = create_extension_binary_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=CurationOverlay.empty(), + pv_binding=UNDECIDED_PV_BINDING, + ) + assert len(binary) == 1 + assert isinstance(binary[0], ExtensionBinarySensor) + assert binary[0].is_on is True + # And it is not also a sensor: one property, one platform. + assert ( + create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=CurationOverlay.empty(), + pv_binding=UNDECIDED_PV_BINDING, + ) + == [] + ) + + +def test_a_property_that_stops_being_published_reads_unknown_rather_than_vanishing( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Absence on the wire is ambiguous, so the entity stays and reports nothing.""" + snapshot = _snapshot(_row()) + sensor = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=CurationOverlay.empty(), + pv_binding=UNDECIDED_PV_BINDING, + )[0] + + sensor.coordinator.data = _snapshot() + assert sensor.native_value is None + + +def test_an_unparseable_number_is_reported_as_nothing_rather_than_as_text( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A string behind a unit and a device class is a worse lie than no reading.""" + snapshot = _snapshot(_row(value="not-a-number")) + sensor = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=CurationOverlay.empty(), + pv_binding=UNDECIDED_PV_BINDING, + )[0] + assert sensor.native_value is None + + +# --- what a value is parsed as is what the declaration says it is ----------- + + +def test_a_unit_less_numeric_row_still_publishes_a_number( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A vendor count declares no unit and is a number all the same. + + The unit stood in for "this is numeric" while an uncurated row could assert + nothing. Curation makes the gap consequential: the datatype admits a state + class, so the recorder would be handed the string `"42"` under one. + """ + snapshot = _snapshot( + _row(property_id="cycle-count", datatype="integer", unit=None, value="42") + ) + (sensor,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=CurationOverlay.empty(), + pv_binding=UNDECIDED_PV_BINDING, + ) + assert sensor.native_value == 42.0 + + +def test_a_unit_less_numeric_row_that_publishes_text_reports_nothing( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Declared numeric is declared numeric: unparseable text is not a fallback to text.""" + snapshot = _snapshot( + _row(property_id="cycle-count", datatype="integer", unit=None, value="lots") + ) + (sensor,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=CurationOverlay.empty(), + pv_binding=UNDECIDED_PV_BINDING, + ) + assert sensor.native_value is None + + +def test_a_unit_less_string_row_is_still_text( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The half of the rule that must not move: a declared string stays a string.""" + snapshot = _snapshot(_row(property_id="mode", datatype="string", unit=None, value="idle")) + (sensor,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=CurationOverlay.empty(), + pv_binding=UNDECIDED_PV_BINDING, + ) + assert sensor.native_value == "idle" + + +# --- what the owner of the device is allowed to assert ---------------------- + + +def _overlay_keyed(row: ExtensionProperty, record: CurationRecord) -> CurationOverlay: + """Key a record the way `_create` looks one up: the scope segment, then the wire path.""" + return CurationOverlay({f"{extension_scope(row.subject)}/{row.path}": record}) + + +def test_the_curation_key_is_scoped_because_a_wire_path_alone_is_not_unique() -> None: + """Three wire devices publishing one path are three rows, so they are three keys. + + `path` is unique only within one wire device, so keying on it bare would let + a record asserted for one circuit reshape the identically-named property on + every other circuit -- and on the battery. The scope segment is exactly the + one `extension_unique_id` carries, so the key is injective for the reason + the id is. + """ + rows = ( + _row(), + _row(kind="circuit", instance_key="circuit-a"), + _row(kind="circuit", instance_key="circuit-b"), + ) + assert {extension_curation_key(row.subject, row.path) for row in rows} == { + "bess/battery-2/cell-temperature", + "circuit_circuit-a/battery-2/cell-temperature", + "circuit_circuit-b/battery-2/cell-temperature", + } + + +def test_a_subject_that_names_no_card_has_nothing_to_curate() -> None: + """`None` mirrors `extension_unique_id`: no card, no entity, and so no key.""" + subject = ExtensionSubject(kind="thermostat", instance_key=None) + assert extension_curation_key(subject, "acme/setpoint") is None + + +def test_a_curated_record_shapes_the_extension_sensor( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Every field the owner of the device may assert reaches the entity. + + Including the `state_class` this module may not infer: the entity is built + from a description `curation` composed, so a user's assertion arrives + without `extension.py` ever naming the thing its AST guard forbids. + """ + row = _row(datatype="float", unit="V") + snapshot = _snapshot(row) + record = CurationRecord( + state_class=SensorStateClass.MEASUREMENT, device_class="voltage", promote=True + ) + (entity,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=_overlay_keyed(row, record), + pv_binding=UNDECIDED_PV_BINDING, + ) + assert entity.state_class is SensorStateClass.MEASUREMENT + assert entity.device_class is SensorDeviceClass.VOLTAGE + assert entity.entity_category is None + # Promotion is not enablement. Enabling stays the user's separate act. + assert entity.entity_registry_enabled_default is False + + +def test_a_curated_unit_less_numeric_reports_a_float_under_its_state_class( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The gap the datatype rule closes, stated through the record that opens it.""" + row = _row(property_id="cycle-count", datatype="integer", unit=None, value="42") + snapshot = _snapshot(row) + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT) + (entity,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=_overlay_keyed(row, record), + pv_binding=UNDECIDED_PV_BINDING, + ) + assert entity.state_class is SensorStateClass.MEASUREMENT + assert entity.native_value == 42.0 + + +def test_an_uncurated_extension_row_is_exactly_todays_entity( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Curation adds a path; it moves nothing that nobody curated.""" + row = _row(datatype="float", unit="V") + snapshot = _snapshot(row) + (entity,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=CurationOverlay.empty(), + pv_binding=UNDECIDED_PV_BINDING, + ) + assert entity.state_class is None + assert entity.entity_category is EntityCategory.DIAGNOSTIC + # The unit map still supplies what the wire declared. + assert entity.device_class is SensorDeviceClass.VOLTAGE + + +def test_a_stale_extension_record_field_is_skipped_and_the_rest_applied( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A record outlives the declaration it was asserted against, and must not fail setup. + + The publisher has relabelled a numeric property as a string, so the stored + state class no longer fits. It is dropped -- the row is read through + `for_row` rather than off the overlay -- and the prominence the same user + asserted is honoured all the same. + """ + row = _row(datatype="string", unit=None) + snapshot = _snapshot(row) + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT, promote=True) + (entity,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=_overlay_keyed(row, record), + pv_binding=UNDECIDED_PV_BINDING, + ) + assert entity.state_class is None + assert entity.entity_category is None + + +def test_a_curated_binary_sensor_gets_the_only_device_class_there_can_be( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A boolean declares no unit, so `door` and `problem` are indistinguishable on the wire. + + There is nothing to default from, which makes the user's assertion the only + device class a vendor boolean can ever carry. + """ + row = _row(property_id="pack-fault", datatype="boolean", unit=None, value="true") + snapshot = _snapshot(row) + (entity,) = create_extension_binary_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=_overlay_keyed(row, CurationRecord(device_class="problem")), + pv_binding=UNDECIDED_PV_BINDING, + ) + assert entity.device_class == BinarySensorDeviceClass.PROBLEM + assert entity.entity_category is EntityCategory.DIAGNOSTIC + + +# --- the prominence hint ---------------------------------------------------- + + +def test_identity_naming_outranks_a_unit() -> None: + """The highest-confidence signal is negative, which is why it is checked first.""" + assert prominence_hint(_row(property_id="firmware-version", unit=None)) == HINT_DETAIL + assert prominence_hint(_row(property_id="pack-serial-number", unit="W")) == HINT_DETAIL + + +def test_a_unit_with_a_device_class_leans_reading() -> None: + assert prominence_hint(_row(property_id="cell-temperature", unit="°C")) == HINT_READING + assert prominence_hint(_row(property_id="acme-power", unit="W")) == HINT_READING + + +def test_a_percentage_is_never_promoted() -> None: + """`%` is a state of charge, a confidence, or a duty cycle, and nothing tells them apart. + + The systematic false negative the design accepts: the most headline-worthy + number a battery publishes lands as a detail, and `entity_category` is free + to revise later. + """ + assert prominence_hint(_row(property_id="state-of-charge", unit="%")) == HINT_DETAIL + + +def test_the_hint_is_carried_on_the_entity_for_curation_triage( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + snapshot = _snapshot(_row()) + sensor = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=CurationOverlay.empty(), + pv_binding=UNDECIDED_PV_BINDING, + )[0] + assert sensor._attr_extra_state_attributes["prominence_hint"] == HINT_READING + assert sensor._attr_extra_state_attributes["wire_path"] == "battery-2/cell-temperature" + + +# --- the overflow notice is told once, not at every setup -------------------- + + +def _overflowing(count: int = MAX_PER_DEVICE + 3) -> SpanPanelSnapshot: + """One battery declaring more vendor properties than the cap will admit.""" + return _snapshot(*(_row(property_id=f"cell-{index}") for index in range(count))) + + +def _notification_id(entry: MockConfigEntry) -> str: + return f"{DOMAIN}_extension_overflow_{entry.entry_id}" + + +async def _notice(hass: HomeAssistant, entry: MockConfigEntry, snapshot: SpanPanelSnapshot) -> None: + await async_notice_declined_extensions( + hass, entry, snapshot, dr.async_get(hass), er.async_get(hass), pv_binding=UNDECIDED_PV_BINDING + ) + + +async def test_the_overflow_notice_is_raised_when_the_cap_declines_something( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A silent truncation reads as "that is everything the vendor publishes".""" + entry = hass.config_entries.async_get_entry(registered_panel[0]) + assert entry is not None + await async_restore(hass, entry) + + await _notice(hass, entry, _overflowing()) + + assert _notification_id(entry) in dict(hass.data.get("persistent_notification", {})) + + +async def test_a_dismissed_overflow_notice_is_not_raised_again_next_setup( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """`notices.py:21-26` states the rule this violated: dismissal is the acknowledgement. + + The cap is re-derived from the same wire on every setup, so re-raising made + the notice un-dismissable -- it came back on every restart and every reload + for as long as the publisher kept publishing, which is forever. + """ + entry = hass.config_entries.async_get_entry(registered_panel[0]) + assert entry is not None + await async_restore(hass, entry) + snapshot = _overflowing() + await _notice(hass, entry, snapshot) + + async_dismiss(hass, _notification_id(entry)) + await hass.async_block_till_done() + await _notice(hass, entry, snapshot) + + assert _notification_id(entry) not in dict(hass.data.get("persistent_notification", {})) + + +async def test_a_dismissed_overflow_notice_returns_when_more_is_declined( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Remembering the announcement may not silence a *different* announcement. + + A second vendor device overflowing later is news the first notice never + carried, so the record is of what was said rather than of having said + something. + """ + entry = hass.config_entries.async_get_entry(registered_panel[0]) + assert entry is not None + await async_restore(hass, entry) + await _notice(hass, entry, _overflowing()) + async_dismiss(hass, _notification_id(entry)) + await hass.async_block_till_done() + + await _notice(hass, entry, _overflowing(MAX_PER_DEVICE + 9)) + + assert _notification_id(entry) in dict(hass.data.get("persistent_notification", {})) + + +async def test_the_overflow_record_survives_a_restart( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """The in-memory view dies with the process; the reason it must not come back does not.""" + entry = hass.config_entries.async_get_entry(registered_panel[0]) + assert entry is not None + await async_restore(hass, entry) + snapshot = _overflowing() + await _notice(hass, entry, snapshot) + async_dismiss(hass, _notification_id(entry)) + await hass.async_block_till_done() + + # Flushed through core's own final-write event rather than by advancing the + # clock. A dismissal and a raise each queue a delayed save, and a `Store` + # that has been re-armed will reschedule its timer rather than write when a + # test fires it early -- so the clock trick reports "nothing was recorded" + # for a record that was. This is the path Home Assistant takes at shutdown. + hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) + await hass.async_block_till_done() + hass.data.get(_DATA, {}).pop(entry.entry_id, None) + hass.data.get("persistent_notification", {}).clear() + await async_restore(hass, entry) + await _notice(hass, entry, snapshot) + + assert _notification_id(entry) not in dict(hass.data.get("persistent_notification", {})) + + +def test_no_state_class_is_set_anywhere_in_the_extension_module() -> None: + """Assert the no-statistics rule against the syntax, not against an instance. + + This closes the gap the adoption module's guard left: `extension.py` had only + per-instance coverage, so a future branch setting a state class on a platform + no test instantiates would pass everything above. The one module allowed to + spell `state_class` is `curation.py`, where every value comes from a + validated user record. + """ + from custom_components.span_panel import extension + + tree = ast.parse(Path(extension.__file__).read_text(encoding="utf-8")) + keywords = [node.arg for node in ast.walk(tree) if isinstance(node, ast.keyword)] + targets = [node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)] + assert "state_class" not in keywords + assert "_attr_state_class" not in targets + assert not [ + node.id for node in ast.walk(tree) if isinstance(node, ast.Name) and "StateClass" in node.id + ] + + +def test_a_card_another_entry_owns_does_not_make_a_row_adoptable( + hass: HomeAssistant, +) -> None: + """The ambiguity the deprecation exists to remove, on the extension side. + + Identifiers are unique within a config entry and nowhere else, so a lookup + that searches every entry can answer with a card this entry does not own -- + and the extension entity then renders on somebody else's device, carrying a + reading from a panel that device has nothing to do with. There is no card of + our own here, so the row is deferred exactly as it is when the card has not + been registered yet. + """ + registry = dr.async_get(hass) + other = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-other", unique_id="other") + other.add_to_hass(hass) + registry.async_get_or_create( + config_entry_id=other.entry_id, + identifiers={(DOMAIN, BESS_IDENTIFIER)}, + name="Somebody else's battery", + ) + mine = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-mine", unique_id=PANEL_SERIAL) + mine.add_to_hass(hass) + registry.async_get_or_create( + config_entry_id=mine.entry_id, + identifiers={(DOMAIN, PANEL_SERIAL)}, + name="Span Panel", + ) + + adopted = adoptable( + _snapshot(_row()), registry, er.async_get(hass), config_entry_id=mine.entry_id, pv_binding=UNDECIDED_PV_BINDING + ) + + assert adopted == [] + + +# --- vendor readings follow the binding, as the PV metadata does ---------------- + +BOUND_CIRCUIT = "c-bound" +OTHER_CIRCUIT = "c-other" +DEVICE_ID_KEY = f"{PANEL_SERIAL}-pv" +"""An inverter no circuit feeds is keyed by its device id, which embeds the panel serial.""" + + +def _fed(key: str) -> SpanPVSnapshot: + return SpanPVSnapshot(device_id=f"device-{key}", node_id=key, feed_circuit_id=key) + + +def _unfed(key: str) -> SpanPVSnapshot: + return SpanPVSnapshot(device_id=key, node_id=key) + + +def _panel_with( + inverters: dict[str, SpanPVSnapshot], *rows: ExtensionProperty, circuits: tuple[str, ...] = () +) -> SpanPanelSnapshot: + """A curated snapshot publishing these inverters (and circuits) beside the given rows.""" + base = _snapshot(*rows) + return replace( + base, + pv_inverters=inverters, + circuits={ + **base.circuits, + **{circuit: SpanCircuitSnapshotFactory.create(circuit_id=circuit) for circuit in circuits}, + }, + ) + + +def _binding( + snapshot: SpanPanelSnapshot, record: StoredPvBinding | None, held: frozenset[str] = frozenset() +) -> PvBinding: + return resolve(snapshot, record, held, link_held=False, inverter_links_held=frozenset())[0] + + +def _cards(hass: HomeAssistant, entry_id: str, *suffixes: str) -> None: + """Register PV cards (`pv`, `pv_{key}`) in the entry, as the PV platforms leave them.""" + for suffix in suffixes: + dr.async_get(hass).async_get_or_create( + config_entry_id=entry_id, identifiers={(DOMAIN, f"{PANEL_SERIAL}_{suffix}")} + ) + + +def _pv_row(instance_key: str | None) -> ExtensionProperty: + return _row(kind="pv", instance_key=instance_key, node_id="acme", property_id="string-voltage", unit="V") + + +SOLAR_ID = f"span_{PANEL_SERIAL}_adopted_pv/acme/string-voltage" + + +def test_the_bound_inverters_keyed_readings_stay_on_the_solar_card( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Once a sibling appears the library keys the bound inverter's subject; its readings must not move.""" + _cards(hass, registered_panel[0], "pv", f"pv_{OTHER_CIRCUIT}") + snapshot = _panel_with( + {BOUND_CIRCUIT: _fed(BOUND_CIRCUIT), OTHER_CIRCUIT: _fed(OTHER_CIRCUIT)}, + _pv_row(BOUND_CIRCUIT), + _pv_row(OTHER_CIRCUIT), + ) + binding = _binding(snapshot, StoredPvBinding(circuit_id=BOUND_CIRCUIT)) + + adopted = adoptable( + snapshot, dr.async_get(hass), er.async_get(hass), config_entry_id=registered_panel[0], pv_binding=binding + ) + + placed = {extension.row.subject.instance_key: extension for extension in adopted} + assert placed[BOUND_CIRCUIT].device_identifier == f"{PANEL_SERIAL}_pv" + assert placed[BOUND_CIRCUIT].unique_id == SOLAR_ID + assert placed[BOUND_CIRCUIT].subject == ExtensionSubject(kind="pv") + assert placed[OTHER_CIRCUIT].device_identifier == f"{PANEL_SERIAL}_pv_{OTHER_CIRCUIT}" + assert placed[OTHER_CIRCUIT].unique_id == f"span_{PANEL_SERIAL}_adopted_pv_{OTHER_CIRCUIT}/acme/string-voltage" + + +def test_a_lone_bound_inverters_keyless_readings_stay_on_the_solar_card( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Every install today: one inverter, bound, its readings where they always were.""" + _cards(hass, registered_panel[0], "pv") + snapshot = _panel_with({BOUND_CIRCUIT: _fed(BOUND_CIRCUIT)}, _pv_row(None)) + binding = _binding(snapshot, None) + + (adopted,) = adoptable( + snapshot, dr.async_get(hass), er.async_get(hass), config_entry_id=registered_panel[0], pv_binding=binding + ) + + assert adopted.unique_id == SOLAR_ID + assert adopted.subject == ExtensionSubject(kind="pv") + + +def test_a_lone_inverter_that_is_not_the_bound_one_keeps_its_readings_on_its_own_card( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Bound C, C gone, one other inverter X: X's keyless readings never take C's Solar ids.""" + _cards(hass, registered_panel[0], "pv", f"pv_{OTHER_CIRCUIT}") + snapshot = _panel_with({OTHER_CIRCUIT: _fed(OTHER_CIRCUIT)}, _pv_row(None)) + binding = _binding(snapshot, StoredPvBinding(circuit_id=BOUND_CIRCUIT)) + + (adopted,) = adoptable( + snapshot, dr.async_get(hass), er.async_get(hass), config_entry_id=registered_panel[0], pv_binding=binding + ) + + assert adopted.device_identifier == f"{PANEL_SERIAL}_pv_{OTHER_CIRCUIT}" + assert adopted.unique_id == f"span_{PANEL_SERIAL}_adopted_pv_{OTHER_CIRCUIT}/acme/string-voltage" + + +def test_a_lone_unbound_inverter_holding_a_card_keeps_its_readings_there( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Unbound, its sibling gone: the inverter's card keeps its vendor readings as it keeps its metadata.""" + _cards(hass, registered_panel[0], "pv", f"pv_{OTHER_CIRCUIT}") + snapshot = _panel_with({OTHER_CIRCUIT: _fed(OTHER_CIRCUIT)}, _pv_row(None)) + binding = _binding(snapshot, StoredPvBinding(circuit_id=None), frozenset({OTHER_CIRCUIT})) + + (adopted,) = adoptable( + snapshot, dr.async_get(hass), er.async_get(hass), config_entry_id=registered_panel[0], pv_binding=binding + ) + + assert adopted.device_identifier == f"{PANEL_SERIAL}_pv_{OTHER_CIRCUIT}" + assert adopted.unique_id == f"span_{PANEL_SERIAL}_adopted_pv_{OTHER_CIRCUIT}/acme/string-voltage" + + +def test_a_withheld_inverters_readings_are_deferred( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """Pending: nothing is minted for the device-id key, so its readings take no card -- not even the Solar one.""" + _cards(hass, registered_panel[0], "pv", f"pv_{DEVICE_ID_KEY}") + snapshot = _panel_with({DEVICE_ID_KEY: _unfed(DEVICE_ID_KEY)}, _pv_row(None), circuits=(BOUND_CIRCUIT,)) + binding = _binding(snapshot, StoredPvBinding(circuit_id=BOUND_CIRCUIT)) + assert binding.withheld == frozenset({DEVICE_ID_KEY}) + + adopted = adoptable( + snapshot, dr.async_get(hass), er.async_get(hass), config_entry_id=registered_panel[0], pv_binding=binding + ) + + assert adopted == [] + + +def test_the_bound_inverters_reading_is_curated_by_the_solar_cards_key( + hass: HomeAssistant, registered_panel: tuple[str, str] +) -> None: + """A record kept under `pv/...` still applies once the library keys the bound inverter's row.""" + _cards(hass, registered_panel[0], "pv", f"pv_{OTHER_CIRCUIT}") + row = _pv_row(BOUND_CIRCUIT) + snapshot = _panel_with({BOUND_CIRCUIT: _fed(BOUND_CIRCUIT), OTHER_CIRCUIT: _fed(OTHER_CIRCUIT)}, row) + record = CurationRecord(state_class=SensorStateClass.MEASUREMENT, device_class="voltage") + + (entity,) = create_extension_sensors( + _coordinator(snapshot), + snapshot, + dr.async_get(hass), + er.async_get(hass), + config_entry_id=registered_panel[0], + overlay=CurationOverlay({f"pv/{row.path}": record}), + pv_binding=_binding(snapshot, StoredPvBinding(circuit_id=BOUND_CIRCUIT)), + ) + + assert entity.unique_id == SOLAR_ID + assert entity.state_class is SensorStateClass.MEASUREMENT + assert entity.device_class is SensorDeviceClass.VOLTAGE diff --git a/tests/test_extension_ids.py b/tests/test_extension_ids.py new file mode 100644 index 00000000..e90fc813 --- /dev/null +++ b/tests/test_extension_ids.py @@ -0,0 +1,179 @@ +"""The identity grammar for vendor extension properties, and what holds it honest. + +The unique id is the one irreversible commitment in extension adoption: nothing +migrates, so an id minted wrong is minted wrong forever. Three properties are +asserted here rather than assumed. + +**Injectivity.** Two distinct wire addresses must never produce one id. The +grammar buys this by carrying the wire path verbatim -- the id *is* the address +-- and the adversarial pairs below are the ones any normalising scheme would +collapse. + +**Namespace closure.** No curated description key contains the `adopted` token, +so a curated id and an extension id cannot be confused for one another. + +**Refusal over sanitisation.** An id outside the Homie charset is refused, not +cleaned up, because cleaning it is what would make the slash-split ambiguous. +""" + +from __future__ import annotations + +import pytest +from span_panel_api import ExtensionSubject + +from custom_components.span_panel.extension import ( + HOMIE_ID, + extension_scope, + extension_unique_id, + is_extension_unique_id, +) +from custom_components.span_panel.field_paths import platform_descriptions +from custom_components.span_panel.util import ADOPTED_IDENTIFIER_TOKEN + +SERIAL = "sp3-000000-001" + + +def _id(kind: str, node: str, prop: str, instance_key: str | None = None) -> str | None: + return extension_unique_id( + SERIAL, ExtensionSubject(kind=kind, instance_key=instance_key), node, prop + ) + + +# --- the grammar ------------------------------------------------------------ + + +def test_the_grammar_is_serial_token_scope_then_the_wire_path() -> None: + assert ( + _id("battery", "battery-2", "cell-temperature") + == "span_sp3-000000-001_adopted_bess/battery-2/cell-temperature" + ) + + +@pytest.mark.parametrize( + ("kind", "instance_key", "scope"), + [ + ("panel", None, "panel"), + ("battery", None, "bess"), + ("mid", None, "mid"), + ("pv", None, "pv"), + ("pv", "abc123", "pv_abc123"), + ("evse", "acme-001", "evse_acme-001"), + ("circuit", "0ab966b95f92a6a51ec548485aa85f54", "circuit_0ab966b95f92a6a51ec548485aa85f54"), + ], +) +def test_each_subject_maps_to_the_scope_its_curated_entities_use( + kind: str, instance_key: str | None, scope: str +) -> None: + assert extension_scope(ExtensionSubject(kind=kind, instance_key=instance_key)) == scope + + +def test_a_multi_instance_subject_without_a_key_names_no_device() -> None: + """No card to hang it on, so no id: inventing a scope would mint a homeless entity.""" + assert extension_scope(ExtensionSubject(kind="evse")) is None + assert _id("evse", "acme", "charge-limit") is None + + +def test_an_unknown_subject_kind_is_refused_rather_than_guessed() -> None: + assert extension_scope(ExtensionSubject(kind="something-new")) is None + + +# --- injectivity ------------------------------------------------------------ + + +def test_the_pairs_a_normalising_grammar_would_collapse_stay_distinct() -> None: + """The concrete counterexample the verbatim rule exists to defeat. + + Any scheme that turned hyphens into underscores and joined the two segments + with an underscore would render both of these as + `..._battery_2_cell_temperature`. + """ + first = _id("battery", "battery-2", "cell-temperature") + second = _id("battery", "battery", "2-cell-temperature") + assert first is not None + assert second is not None + assert first != second + + +def test_ids_are_injective_over_adversarial_addresses() -> None: + """Distinct addresses, distinct ids -- asserted over a set built to collide.""" + addresses = [ + ("battery-2", "cell-temperature"), + ("battery", "2-cell-temperature"), + ("battery-2-cell", "temperature"), + ("meter", "acme-cell-balance"), + ("meter-acme", "cell-balance"), + ("a", "b-c"), + ("a-b", "c"), + ] + minted: dict[str, tuple[str, str]] = {} + for node, prop in addresses: + unique_id = _id("battery", node, prop) + assert unique_id is not None, (node, prop) + assert unique_id not in minted, f"{(node, prop)} collides with {minted[unique_id]}" + minted[unique_id] = (node, prop) + + +def test_the_same_address_on_two_instances_stays_distinct() -> None: + """Two chargers publishing the same vendor node are two entities, not one.""" + first = _id("evse", "acme", "cell-temperature", instance_key="acme-001") + second = _id("evse", "acme", "cell-temperature", instance_key="acme-002") + assert first != second + + +# --- namespace closure ------------------------------------------------------ + + +def test_no_curated_description_key_contains_the_adopted_token() -> None: + """The namespace cannot be invaded silently by a curated key. + + The same closed-mapping idiom the suffix tests use: asserted over every + platform description rather than over a hand-listed sample. + """ + invaded = [ + description.key + for description in platform_descriptions() + if ADOPTED_IDENTIFIER_TOKEN in description.key.lower() + ] + assert not invaded, f"curated keys carrying the adoption token: {invaded}" + + +def test_an_extension_id_is_distinguishable_from_a_device_adoption_id() -> None: + """The slash is the discriminator; device-level adoption ids carry none.""" + extension = _id("battery", "battery-2", "cell-temperature") + assert extension is not None + assert is_extension_unique_id(extension) + + # The device-level grammar, as `adoption.adopted_unique_id` builds it. + device_level = f"span_{SERIAL}_{ADOPTED_IDENTIFIER_TOKEN}_acme-001_meter_active_power" + assert not is_extension_unique_id(device_level) + + curated = f"span_{SERIAL}_bess_battery_power" + assert not is_extension_unique_id(curated) + + +# --- refusal over sanitisation ---------------------------------------------- + + +@pytest.mark.parametrize( + "node", + ["Battery-2", "battery_2", "battery/2", "battery 2", "battery.2", ""], +) +def test_an_off_charset_node_is_refused(node: str) -> None: + assert _id("battery", node, "cell-temperature") is None + + +@pytest.mark.parametrize( + "prop", + ["Cell-Temperature", "cell_temperature", "cell/temperature", "cell temperature", ""], +) +def test_an_off_charset_property_is_refused(prop: str) -> None: + assert _id("battery", "battery-2", prop) is None + + +def test_the_charset_is_exactly_the_homie_one() -> None: + """Lowercase alphanumerics and hyphens, and nothing that could break the split.""" + assert HOMIE_ID.match("battery-2") + assert HOMIE_ID.match("a1") + assert not HOMIE_ID.match("A") + assert not HOMIE_ID.match("a_b") + assert not HOMIE_ID.match("a/b") diff --git a/tests/test_favorites_service.py b/tests/test_favorites_service.py index 74681487..d529a1d3 100644 --- a/tests/test_favorites_service.py +++ b/tests/test_favorites_service.py @@ -9,6 +9,7 @@ from homeassistant.core import SupportsResponse from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr from custom_components.span_panel.const import DOMAIN from custom_components.span_panel.frontend import ( @@ -287,7 +288,7 @@ def _make_device_entry( identifiers: set[tuple[str, str]] | None = None, via_device_id: str | None = None, ) -> MagicMock: - device = MagicMock() + device = MagicMock(spec=dr.DeviceEntry) device.id = device_id device.identifiers = identifiers if identifiers is not None else {(DOMAIN, "serial_a")} device.via_device_id = via_device_id @@ -307,7 +308,7 @@ def _patch_registries(entity: MagicMock | None, device: MagicMock | None) -> Any return patch.multiple( "custom_components.span_panel.services", er=MagicMock(async_get=MagicMock(return_value=entity_reg)), - dr=MagicMock(async_get=MagicMock(return_value=device_reg)), + dr=MagicMock(async_get=MagicMock(return_value=device_reg), DeviceEntry=dr.DeviceEntry), ) @@ -333,7 +334,7 @@ def _device_lookup(device_id: str) -> MagicMock | None: return patch.multiple( "custom_components.span_panel.services", er=MagicMock(async_get=MagicMock(return_value=entity_reg)), - dr=MagicMock(async_get=MagicMock(return_value=device_reg)), + dr=MagicMock(async_get=MagicMock(return_value=device_reg), DeviceEntry=dr.DeviceEntry), ) @@ -536,6 +537,33 @@ async def test_add_favorite_rejects_entity_with_no_device_id( with pytest.raises(ServiceValidationError): await handler(_make_service_call({"entity_id": "sensor.orphan"})) + @pytest.mark.asyncio + async def test_add_favorite_rejects_an_entity_on_a_child_device( + self, _patched_store: Any + ) -> None: + """A child device is not a SPAN Panel device, whatever identifier it carries. + + From Home Assistant 2026.9 ``async_get`` can answer with a child device -- + a part of another device, with no ``via_device_id`` to walk to a panel. + SPAN registers none, so an entity on one is refused rather than followed. + The fake is deliberately not a ``DeviceEntry``, which is what sets a child + device apart, and it carries a SPAN identifier so only that can refuse it. + """ + hass = MagicMock() + registered = _capture_registered_handlers(hass) + handler = registered["handlers"]["add_favorite"] + + entity = _make_entity_entry(device_id="d_child") + child_device = MagicMock() + child_device.id = "d_child" + child_device.identifiers = {(DOMAIN, "serial_a_outlet")} + + with _patch_registries(entity, child_device): + with pytest.raises(ServiceValidationError) as refused: + await handler(_make_service_call({"entity_id": "switch.outlet_1"})) + + assert refused.value.translation_key == "favorite_not_span_entity" + @pytest.mark.asyncio async def test_add_favorite_rejects_subdevice_with_non_span_parent( self, _patched_store: Any @@ -592,7 +620,7 @@ def _device_lookup(device_id: str) -> MagicMock | None: with patch.multiple( "custom_components.span_panel.services", er=MagicMock(async_get=MagicMock(return_value=entity_reg)), - dr=MagicMock(async_get=MagicMock(return_value=device_reg)), + dr=MagicMock(async_get=MagicMock(return_value=device_reg), DeviceEntry=dr.DeviceEntry), ): with pytest.raises(ServiceValidationError): await handler( diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py new file mode 100644 index 00000000..d4fc1bc7 --- /dev/null +++ b/tests/test_field_path_conformance.py @@ -0,0 +1,485 @@ +"""Every declared field path must be producible by each adapter, or derived. + +This is the test that would have caught the battery.product_name drift. + +A read that no adapter, or only one, produces cannot satisfy that gate, so it is +exempted in `RESIDUAL_EXEMPT_PATHS`. The second half of this module holds those +exemptions to the same standard: each one states which adapters produce it, and +that statement is checked against the adapters rather than left as prose. +""" + +from __future__ import annotations + +import ast +from collections import Counter +from collections.abc import Callable, Iterator +import pathlib + +import pytest +from span_panel_api.models import FieldMetadata +from span_panel_api_schema_0.adapter import SchemaZeroAdapter +from span_panel_api_schema_1.adapter import SchemaOneAdapter + +from custom_components.span_panel import field_paths as field_paths_module, sensor_panel +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + Producibility, + declared_field_paths, + residual_field_paths, +) +from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata + +MetadataFn = Callable[[], dict[str, FieldMetadata]] + +_ADAPTERS: list[tuple[str, MetadataFn]] = [ + ("schema_0", schema_zero_metadata), + ("schema_1", schema_one_metadata), +] + +_PACKAGE_ROOT = pathlib.Path(field_paths_module.__file__).parent + + +def _source_declared_paths() -> dict[str, str]: + """Every ``field_path="..."`` literal in the integration source, by module. + + Read from the source text rather than from the platform collections, so the + hand-written tuple `declared_field_paths` iterates is pinned against what + the modules actually declare. A runtime scan of module attributes could not + do this: most descriptions are inline literals inside their collection, so + emptying the collection would hide them from the check as well as from the + gate. + """ + found: dict[str, str] = {} + for source in sorted(_PACKAGE_ROOT.rglob("*.py")): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + for keyword in node.keywords: + if keyword.arg != "field_path": + continue + # Non-literal values are pass-throughs that copy an existing + # description's declaration, not new declarations. + if isinstance(keyword.value, ast.Constant) and isinstance(keyword.value.value, str): + found.setdefault(keyword.value.value, source.name) + return found + + +@pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS) +def test_every_declared_path_is_producible(adapter: str, metadata_fn: MetadataFn) -> None: + metadata = metadata_fn() + missing = sorted(path for path in declared_field_paths() if path not in metadata) + assert not missing, ( + f"{adapter} does not produce declared field paths: {missing}. " + "Either the declaration is stale, or the entity should declare a DerivedReason." + ) + + +def test_gate_covers_every_declaration_in_the_source() -> None: + """A declaration the gate stops iterating must fail, not shrink silently. + + `test_every_declared_path_is_producible` is monotone: a smaller declared set + always passes, because any subset of a producible set is producible. So + dropping a platform collection from `declared_field_paths`, or letting a + description fall out of its collection, would silently retire the gate for + every entity involved with no signal at all — the same invisible-omission + failure this whole module exists to prevent, one level up. + + This is that signal. It compares against the source text, so it holds even + when a collection is emptied rather than unreferenced. + """ + declared = declared_field_paths() + # A `SCHEMA_CONDITIONAL_FIELD` description names its source field too, and + # that path is by definition one adapter short of this gate. It is covered + # instead by `RESIDUAL_EXEMPT_PATHS`, whose annotation is checked against + # both adapters below — so being enumerated there is the alternative to + # being in `declared`, not an escape from being checked. + uncovered = sorted( + (path, module) + for path, module in _source_declared_paths().items() + if path not in declared and path not in RESIDUAL_EXEMPT_PATHS + ) + assert not uncovered, ( + "declared_field_paths() does not cover field paths declared in the source: " + f"{uncovered}. A platform collection likely stopped being iterated, or a " + "description fell out of its collection — the gate is no longer checking " + "those entities." + ) + + +def _iter_source_residuals() -> Iterator[tuple[str, str]]: + """Yield ``(field_path, module_filename)`` for every residual literal in the source. + + The runtime counterpart, `residual_field_paths()`, unions the class + attribute over a `SpanPanelEntity.__subclasses__()` walk, and a walk sees + only what has been imported. This reads the same declarations out of the + source text, where importedness is not a factor. + + A path declared by two modules yields twice, deliberately: which *paths* + exist and which *modules* declare one are different questions, and collapsing + to the first module that mentions a path answers the second wrongly. + """ + for source in sorted(_PACKAGE_ROOT.rglob("*.py")): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + for node in ast.walk(tree): + targets: list[ast.expr] + if isinstance(node, ast.AnnAssign): + targets = [node.target] + elif isinstance(node, ast.Assign): + targets = list(node.targets) + else: + continue + if not any( + isinstance(target, ast.Name) and target.id == "_residual_field_paths" + for target in targets + ): + continue + if not isinstance(node.value, ast.Tuple): + continue + for element in node.value.elts: + if isinstance(element, ast.Constant) and isinstance(element.value, str): + yield element.value, source.name + + +def _source_residual_paths() -> dict[str, str]: + """Every residual path in the integration source, against a module declaring it.""" + found: dict[str, str] = {} + for path, module in _iter_source_residuals(): + found.setdefault(path, module) + return found + + +def _source_residual_modules() -> set[str]: + """Every integration module that declares at least one residual path.""" + return {module.removesuffix(".py") for _, module in _iter_source_residuals()} + + +def test_source_residuals_match_the_subclass_walk() -> None: + """A residual the walk cannot see must fail, not vanish. + + `residual_field_paths()` imports the platform modules that declare + residuals so their classes exist to be walked. A class declaring one in a + module it does not import — a new platform, or an existing one that stops + being imported — would drop out of the producible gate and out of the + Repair's affected-entity count with no signal at all: the derived set would + simply be smaller, and every check downstream of it is monotone in exactly + the direction that hides the loss. + + This is that signal, read from the source text where import order does not + apply. The converse direction matters too: a residual assembled at runtime + rather than written as a tuple literal is invisible to this scan, so the + scan would silently stop pinning it. + """ + from_source = _source_residual_paths() + from_walk = residual_field_paths() + + unwalked = sorted( + (path, module) for path, module in from_source.items() if path not in from_walk + ) + assert not unwalked, ( + f"residual paths declared in the source but not reached by the subclass walk: " + f"{unwalked}. `residual_field_paths()` does not import the declaring module, so " + "the producible gate and the Repair's affected-entity count both miss these reads." + ) + + unscanned = sorted(from_walk - from_source.keys()) + assert not unscanned, ( + f"residual paths the source scan cannot see: {unscanned}. They are not written " + "as string literals in a `_residual_field_paths` tuple, so this pin no longer " + "covers them — declare them literally." + ) + + +def _walk_imported_modules() -> set[str]: + """Return the sibling modules `residual_field_paths()` imports for its walk. + + Read out of the source rather than by calling the function, because what is + under test is the import list itself: a module missing from it still gets + walked in-process whenever some *other* importer has already pulled it in, + which is true of every module in the test suite and is exactly why the walk + test below cannot see the omission. + """ + tree = ast.parse((_PACKAGE_ROOT / "field_paths.py").read_text(encoding="utf-8")) + function = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "residual_field_paths" + ) + return { + alias.name + for node in ast.walk(function) + if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module is None + for alias in node.names + } + + +def test_the_residual_walk_imports_exactly_the_modules_that_declare_one() -> None: + """The walk's import list, pinned against the declarations it exists to reach. + + `residual_field_paths()` walks `SpanPanelEntity.__subclasses__()`, which sees + only classes Python has already imported, so it imports the declaring + platform modules itself. Nothing held that list to the declarations: a module + dropped from it goes on being walked under pytest, where the platform modules + are imported many times over for other reasons, and fails only in production + where `field_paths` may be reached first. A path that leaves the walk leaves + the producible gate and the Repair's affected-entity count with it, silently. + + Both directions. An unlisted module is the failure above; a listed module + that declares nothing is a stale import, which is how the list stops meaning + what its docstring says and starts being copied forward unread. + """ + declaring = _source_residual_modules() + imported = _walk_imported_modules() + + assert declaring, "the source scan found no residual declarations at all" + assert imported == declaring, ( + f"`residual_field_paths()` imports {sorted(imported)} for its subclass walk but " + f"residuals are declared in {sorted(declaring)}. Unlisted modules drop out of the " + "producible gate in any process that reaches `field_paths` first; listed modules " + "that declare nothing are stale." + ) + + +@pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS) +def test_gate_is_one_directional( + adapter: str, metadata_fn: MetadataFn, capsys: pytest.CaptureFixture[str] +) -> None: + """A produced path nothing reads must NOT fail the build. + + Additions are legal within a major version, so asserting the converse would + turn correct upstream behaviour into a red CI every time SPAN ships a + property. This test exists to stop someone "completing" the gate by adding + that assertion. + """ + metadata = metadata_fn() + unread = sorted(set(metadata) - set(declared_field_paths())) + # The CI inventory the spec asks for: never asserted against, an addition is + # legal within a major version. Written with capture suspended so it reaches + # the build log under the plain `pytest` CI runs -- pytest swallows stdout + # from a passing test, which would leave the inventory existing in code and + # nowhere else. + with capsys.disabled(): + print(f"\n[{adapter}] produced but unread ({len(unread)}):") + for path in unread: + print(f" {path}") + + +def _adapter_paths() -> tuple[frozenset[str], frozenset[str]]: + """Return the paths each adapter actually produces, from the reference payloads.""" + return frozenset(schema_zero_metadata()), frozenset(schema_one_metadata()) + + +def _observed_producibility( + path: str, s0: frozenset[str], s1: frozenset[str] +) -> Producibility | None: + """Classify a path by which adapters produce it. + + `None` is the fourth, illegal case — produced by both — which has no + `Producibility` member precisely because such a path is not exemptible. + """ + match (path in s0, path in s1): + case (True, True): + return None + case (True, False): + return Producibility.SCHEMA_0_ONLY + case (False, True): + return Producibility.SCHEMA_1_ONLY + case _: + return Producibility.NEITHER + + +def test_every_exempt_path_matches_its_annotation() -> None: + """Each exemption's stated reason is checked against the adapters that run. + + Before these annotations the 26 exemptions were checked against nothing: + the neither/one-adapter distinction lived in prose, so an entry could be + mislabelled from the day it was written, or go stale when the library + changed what it publishes, with no signal anywhere. + """ + s0, s1 = _adapter_paths() + wrong = [ + (path, annotated.name, observed.name if observed else "BOTH") + for path, annotated in RESIDUAL_EXEMPT_PATHS.items() + if (observed := _observed_producibility(path, s0, s1)) is not annotated + ] + assert not wrong, ( + "RESIDUAL_EXEMPT_PATHS annotations disagree with the adapters " + f"(path, annotated, actual): {wrong}. Either the annotation is stale or " + "the library changed what it produces." + ) + + +def test_no_exempt_path_is_producible_by_both() -> None: + """A path both adapters produce is a declaration, not an exemption. + + The exemption exists only because the gate demands producibility by both. + Once both produce it, the reason has evaporated and the path should be + declared — silently leaving it exempt would retire the gate for that read. + """ + s0, s1 = _adapter_paths() + promotable = sorted(RESIDUAL_EXEMPT_PATHS.keys() & s0 & s1) + assert not promotable, ( + f"exempt paths are now producible by both adapters: {promotable}. " + "Promote each to a declaration — a description's `field_path=`, or an " + "entity's `_residual_field_paths` for a reader in entity code — so the " + "producible gate covers it again." + ) + + +_EXPECTED_EXEMPT_COUNTS: dict[Producibility, int] = { + # +3 with the shed forecast: the two full-charge refinements and the + # confidence enum, read as attributes on the two forecast sensors and + # carried by no adapter's metadata map. + # +1 for `mid.grid_state`, the `mid_grid_state` sensor's source field. Like + # `panel.dominant_power_source` below it was read by a description and + # enumerated nowhere, so nothing held it against the adapters and + # `evaluate_field_metadata` had no way to tell it from an unread field. + # +15 with the PCS: the twelve arbitration inputs and `pcs.enabled` behind + # `pcs_import_limit`'s attributes, plus the two circuit participation fields + # read as attributes on the circuit power sensor. schema_1 reads all fifteen + # and maps none of them, deliberately — they explain the effective limit + # rather than being readings of their own. + # +3 for the enclosure's own build identity -- `panel.vendor_name`, + # `panel.model`, `panel.hardware_version` -- read by `snapshot_to_device_info` + # for the panel's device card. Flat declares none of the three, and a + # schema_1 row exists to carry a unit and a datatype for a reading, which an + # identity string is not; the `mid.*` device-card reads sit here for the + # same reason. + # +4 for the shed policy -- the raw `shed/policy` document plus the + # algorithm and the two SoC thresholds parsed out of it -- read as + # attributes on `dsm_state`. Flat has no `shed` node, and a JSON document + # has no unit surface for a schema_1 row to describe. + # +2 for the EVSE charge-current control's two non-readings: + # `charge_current_limit_settable`, the `$settable` flag `number.py` gates + # entity creation on, and `charge_current_limit_target_a`, the Homie + # `$target` echo it renders as an attribute. Facts about a command rather + # than readings, so no adapter carries a row for either -- the same shape as + # the `circuit.*_target` pair. + Producibility.NEITHER: 43, + # +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's + # source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and + # enumerated nowhere, so `evaluate_field_metadata` counted it as produced- + # but-unread while an entity was reading it. + # -1 for `panel.wifi_ssid`, which left this map entirely: schema_1 grew the + # `status/wifi-ssid` row, both adapters produce the path, and + # `test_no_exempt_path_is_producible_by_both` demanded it become a + # declaration -- `SpanPanelStatus._residual_field_paths`. Its time here as a + # true `SCHEMA_0_ONLY` annotation is what sanctioned a flat -> v1.0 + # regression: the attribute a flat panel filled, a v1.0 panel did not. + # +1 for `pv.software_version`, the firmware row on the solar inverter's own + # device card, which moved here from `NEITHER`. It was annotated on the + # claim that flat's `pv` device class declares no firmware version; flat + # declares `software-version` on it, and the library grew the mapping row + # once a producer valued the v1.0 half. schema_1 still carries no row -- a + # version string is identity rather than a reading, the same argument as the + # `mid.*` and `panel.*` card reads above -- so this stays an exemption + # rather than becoming a declaration. + Producibility.SCHEMA_0_ONLY: 11, + # +2 with the shed forecast: the two live estimates, which schema_1 maps and + # flat firmware does not publish at all. + # +2 for `battery.power_w` and `battery.communication_state`, the BESS's own + # meter and link health behind `bess_meter_power` and + # `bess_communication_state`. schema_1 maps both; flat's BESS device class + # declares neither property, so neither can ever satisfy the both-adapters + # gate. + # +3 for the PCS's result: `pcs.import_limit_a`, `pcs.binding_constraint` + # and `pcs.active`, behind the two PCS sensors and the `pcs_active` binary + # sensor. schema_1 maps all three; no flat panel declares the capability at + # all, so none can ever satisfy the both-adapters gate. + # +2 for `pv.connected` and `evse.connected`, the enclosure's view of the + # link to each circuit-fed DER, behind `pv_panel_link` and + # `evse_panel_link`. schema_1 maps both from one property — the feeding + # circuit's `connection/feeds-device-status` — while flat publishes + # `connected` on the BESS and on no other device class, so neither can + # satisfy the both-adapters gate. + # +2 for the EVSE charge-current pair behind the + # `evse_charge_current_limit` number: the settable limit its description + # names, and the commissioned ceiling the entity reads for + # `native_max_value`. schema_1 resolves both from the charger's own + # `$description`; flat's `evse` type declares no settable ceiling at all, so + # neither can satisfy the both-adapters gate. + Producibility.SCHEMA_1_ONLY: 12, +} +"""The exemption inventory, by reason. See `test_exempt_inventory_is_complete`.""" + + +def test_exempt_inventory_is_complete() -> None: + """An exemption disappearing must be a deliberate edit, not an accident. + + Every other check here is consistency-only: deleting an entry leaves the + survivors perfectly annotated, so the set could quietly shrink and no test + would notice — the read would simply stop being enumerated anywhere. The + completeness of the set cannot be derived (a `Producibility.NEITHER` path + has, by definition, no adapter row to discover it from, and matching reads + by leaf attribute name collides across snapshot types), so it is pinned by + size per reason instead. Changing a count is fine; doing it in the same + commit as the entry, with a reason, is the point. + """ + counts = Counter(RESIDUAL_EXEMPT_PATHS.values()) + assert counts == _EXPECTED_EXEMPT_COUNTS, ( + f"exemption inventory changed: {counts} != {_EXPECTED_EXEMPT_COUNTS}. An " + "entry was added or removed; update the expected counts in the same " + "commit if that was intended." + ) + + +def test_every_exempt_path_still_has_a_reader() -> None: + """An exemption outlives its reader silently; the list only ever grows. + + Heuristic by necessity — an exempt path is read outside any description, so + there is no declaration to match against, only source text. Matching the + leaf attribute anywhere in the package is deliberately generous: it cannot + accuse a live read, and it still catches the last reader of a field being + deleted while its exemption stays behind. + + A ``field_path="..."`` literal counts as a reader too: the entity registers + that path against its metadata row even when its value comes through a + library property built on the field rather than the attribute itself. + """ + declared = _source_declared_paths() + read_attributes = { + node.attr + for source in sorted(_PACKAGE_ROOT.rglob("*.py")) + for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"), filename=str(source))) + if isinstance(node, ast.Attribute) + } + unread = sorted( + path + for path in RESIDUAL_EXEMPT_PATHS + if path.split(".", 1)[1] not in read_attributes and path not in declared + ) + assert not unread, ( + f"exempt paths no longer read anywhere in the package: {unread}. The " + "reader was removed; drop the exemption with it." + ) + + +def test_the_service_entrance_gate_names_the_installed_adapter_keys() -> None: + """The one adapter fact this integration states outside `RESIDUAL_EXEMPT_PATHS`. + + `sensor_panel._SERVICE_ENTRANCE_ADAPTER` says which adapter resolves + `lugs_at_service_entrance`, and the grid power sensor publishes its + `at_service_entrance` attribute only when that adapter is the one running. + It is here for the reason the exemption annotations are: a statement about + what an adapter produces belongs against the adapters, not in prose. + + Without this the failure is silent and one-directional. A renamed adapter key + matches nothing, every panel looks like an adapter that does not resolve the + field, and the attribute simply stops appearing -- no error, no unavailable + entity, nothing in the log. The `!=` half matters just as much: the constant + naming the *flat* key would publish the library's `True` default on every + flat panel, which is the defect this gate was added to fix. + + Read off the adapter classes rather than off a live client because the class + attribute is what a client reports (`SpanMqttClient.schema_major` returns + `self._adapter.schema_major`), and a class needs no MQTT connection. + """ + assert SchemaOneAdapter.schema_major == sensor_panel._SERVICE_ENTRANCE_ADAPTER, ( + f"the parent/child adapter's key is {SchemaOneAdapter.schema_major!r} but the " + f"grid sensor gates on {sensor_panel._SERVICE_ENTRANCE_ADAPTER!r}; " + "at_service_entrance would silently stop being published" + ) + assert SchemaZeroAdapter.schema_major != sensor_panel._SERVICE_ENTRANCE_ADAPTER, ( + "the grid sensor gates on the flat adapter's key, which would publish the " + "library's default as a reading on every flat panel" + ) diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py new file mode 100644 index 00000000..b86110a1 --- /dev/null +++ b/tests/test_field_path_introspection.py @@ -0,0 +1,374 @@ +"""Verify each declared field_path against what its value_fn actually reads. + +Runs every value_fn against a proxy that records attribute access. The +declaration stays authoritative — this only stops it drifting from the reader. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator, Mapping +from typing import Any, Protocol, get_args, get_type_hints, runtime_checkable + +from span_panel_api import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanEvseSnapshot, + SpanMidSnapshot, + SpanPanelSnapshot, + SpanPcsSnapshot, + SpanPVSnapshot, +) + +from custom_components.span_panel.field_paths import ( + DerivedReason, + declared_field_paths, + residual_field_paths, +) +from custom_components.span_panel.sensor_definitions import all_sensor_descriptions +from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata + +# Attributes of the panel snapshot that are themselves sub-snapshots. Their +# fields are addressed as "battery.x", not "panel.battery.x". +_SUB_SNAPSHOTS = {"battery", "pv", "evse", "mid", "pcs"} + + +class _Recorder: + """Records every attribute path touched, and survives arithmetic. + + value_fns do real work — `or "unknown"`, `a - b`, unary minus for PV sign + flips — so the proxy has to absorb those without raising and without + ending the recording. + """ + + def __init__(self, sink: set[str], prefix: str, root: bool = False) -> None: + object.__setattr__(self, "_sink", sink) + object.__setattr__(self, "_prefix", prefix) + object.__setattr__(self, "_root", root) + + def __getattr__(self, name: str) -> _Recorder: + if name.startswith("_"): + raise AttributeError(name) + sink: set[str] = object.__getattribute__(self, "_sink") + prefix: str = object.__getattribute__(self, "_prefix") + root: bool = object.__getattribute__(self, "_root") + if root and name in _SUB_SNAPSHOTS: + return _Recorder(sink, name) + path = f"{prefix}.{name}" if prefix else name + sink.add(path) + return _Recorder(sink, path) + + # Absorb the operations value_fns perform on the values they read. + def __bool__(self) -> bool: + return True + + def __sub__(self, other: Any) -> _Recorder: + return self + + def __rsub__(self, other: Any) -> _Recorder: + return self + + def __neg__(self) -> _Recorder: + return self + + def __or__(self, other: Any) -> _Recorder: + return self + + def __call__(self, *args: Any, **kwargs: Any) -> _Recorder: + """Absorb method calls, e.g. `(m.grid_state or "unknown").lower()`. + + The extra path this records (`mid.grid_state.lower`) is harmless: the + check is membership of the declared path, not set equality. + """ + return self + + def __eq__(self, other: Any) -> bool: + return False + + def __hash__(self) -> int: + return 0 + + +@runtime_checkable +class _DeclaringDescription(Protocol): + """The surface this test needs from an entity description. + + A protocol rather than a concrete type because the eight sensor classes and + the two binary-sensor classes share no base beyond + `FieldPathDeclarationMixin`, which does not carry `value_fn`. + """ + + @property + def key(self) -> str: ... + + @property + def field_path(self) -> str | None: ... + + @property + def derived(self) -> DerivedReason | None: ... + + @property + def value_fn(self) -> Callable[[Any], object]: ... + + +def _declaring_descriptions() -> Iterator[_DeclaringDescription]: + """Every entity description that carries a field-path declaration. + + Mirrors the collections `declared_field_paths()` walks, so the gate and this + verifier cover the same descriptions; + `test_introspection_covers_every_declared_path` pins that they still do. + """ + # Deferred for the same reason `field_paths` defers it: `binary_sensor` + # reaches the package root, and the root imports the platforms. + from custom_components.span_panel.binary_sensor import ( # noqa: PLC0415 + BESS_CONNECTED_SENSOR, + BINARY_SENSORS, + EVSE_BINARY_SENSORS, + EVSE_PANEL_LINK_SENSOR, + GRID_ISLANDABLE_SENSOR, + PCS_ACTIVE_SENSOR, + PV_INVERTER_LINK_SENSOR, + PV_PANEL_LINK_SENSOR, + ) + + for description in ( + *all_sensor_descriptions(), + *BINARY_SENSORS, + *EVSE_BINARY_SENSORS, + GRID_ISLANDABLE_SENSOR, + BESS_CONNECTED_SENSOR, + PCS_ACTIVE_SENSOR, + PV_PANEL_LINK_SENSOR, + PV_INVERTER_LINK_SENSOR, + EVSE_PANEL_LINK_SENSOR, + ): + if not isinstance(description, _DeclaringDescription): + raise TypeError( + f"entity description '{description.key}' carries no field-path declaration" + ) + yield description + + +# Every snapshot type a value_fn can take, and the prefix its fields are +# addressed by. Keyed by the snapshot type rather than by description class +# name: the class name map had to be edited for every new description class — +# the recurring event — while this one grows only when the library grows a new +# snapshot type. A new description class over an existing snapshot type needs no +# edit here, and its prefix cannot be wrong, because it comes from the same +# annotation mypy checks the value_fn bodies against. +_SNAPSHOT_PREFIX: Mapping[type, str] = { + SpanCircuitSnapshot: "circuit", + SpanPanelSnapshot: "panel", + SpanBatterySnapshot: "battery", + SpanEvseSnapshot: "evse", + SpanMidSnapshot: "mid", + SpanPcsSnapshot: "pcs", + SpanPVSnapshot: "pv", +} + + +class _UndeterminedPrefix(Exception): + """A description's snapshot prefix could not be determined. + + Raised, never swallowed: a description whose prefix is unknown must be + reported by its caller as a mismatch. Skipping it is what let a description + class absent from the old class-name map drop out of verification entirely. + """ + + +def _snapshot_type(description: _DeclaringDescription) -> type: + """Return the snapshot type this description's `value_fn` is annotated to take. + + `from __future__ import annotations` stringifies the annotation, so this + resolves it with `get_type_hints`, which evaluates it in the defining + module's namespace — every mixin's module imports the snapshot types it + names, so resolution succeeds. + """ + cls = type(description) + try: + hints = get_type_hints(cls) + except Exception as err: # noqa: BLE001 + raise _UndeterminedPrefix( + f"{cls.__name__}: value_fn annotation does not resolve ({err!r})" + ) from err + annotation = hints.get("value_fn") + if annotation is None: + raise _UndeterminedPrefix(f"{cls.__name__} carries no value_fn annotation") + args = get_args(annotation) + if len(args) != 2 or not isinstance(args[0], list) or not args[0]: + raise _UndeterminedPrefix( + f"{cls.__name__}: value_fn annotated {annotation!r} names no parameter type" + ) + parameter = args[0][0] + if not isinstance(parameter, type): + raise _UndeterminedPrefix( + f"{cls.__name__}: value_fn takes {parameter!r}, which is not a snapshot class" + ) + return parameter + + +def _record_reads(description: _DeclaringDescription) -> set[str]: + """Run a description's `value_fn` against the recorder, return what it read. + + Raises `_UndeterminedPrefix` when the snapshot type cannot be resolved or is + absent from `_SNAPSHOT_PREFIX`; anything the `value_fn` itself raises + propagates unchanged. + """ + snapshot_type = _snapshot_type(description) + prefix = _SNAPSHOT_PREFIX.get(snapshot_type) + if prefix is None: + raise _UndeterminedPrefix( + f"{type(description).__name__}: value_fn takes {snapshot_type.__name__}, " + "which is absent from _SNAPSHOT_PREFIX" + ) + sink: set[str] = set() + description.value_fn(_Recorder(sink, prefix, root=snapshot_type is SpanPanelSnapshot)) + return sink + + +def test_declared_paths_match_what_value_fns_read() -> None: + """Every named source field must be one the `value_fn` actually reads. + + `derived` is not consulted: a `SCHEMA_CONDITIONAL_FIELD` description names + its source field too, and that name is what the Repair and the availability + probe act on. An unverified one would send both at the wrong path. + """ + mismatches: list[str] = [] + + for description in _declaring_descriptions(): + if description.field_path is None: + continue + try: + sink = _record_reads(description) + except _UndeterminedPrefix as err: + mismatches.append(f"{description.key}: {err}, so its declaration would go unverified") + continue + except Exception as err: # noqa: BLE001 + mismatches.append(f"{description.key}: value_fn raised {err!r}") + continue + if description.field_path not in sink: + mismatches.append( + f"{description.key}: declares {description.field_path!r} but reads {sorted(sink)}" + ) + + assert not mismatches, "Declarations disagree with readers:\n" + "\n".join(mismatches) + + +def test_introspection_covers_every_declared_path() -> None: + """Every path the gate accepts must be one this test verified, or residual. + + `_declaring_descriptions` restates the collections `declared_field_paths()` + walks. Without this, a platform collection added to the gate but not here + would be gated for producibility and never checked against its reader. + """ + introspected = { + description.field_path + for description in _declaring_descriptions() + if not description.derived and description.field_path is not None + } + assert declared_field_paths() == frozenset(introspected | residual_field_paths()) + + +def test_no_derived_description_reads_one_producible_field() -> None: + """`derived` must mean no field, several fields, or an unproducible one. + + Pins the rule, not the instance that broke it. `evse_ev_connected` read + exactly `evse.status` — one field both adapters produce — while declaring + itself derived, so `_declared_field_paths` skipped it: the Repair for a dead + `evse.status` never named it and the availability probe never fired for it, + though its sibling `evse_charging` got both from the very same field. + + Producibility is what makes this checkable: the recorder also picks up + method names and other noise, and intersecting with what both adapters + actually emit leaves only real fields. + """ + producible = set(schema_zero_metadata()) & set(schema_one_metadata()) + offenders: list[str] = [] + + for description in _declaring_descriptions(): + if not description.derived: + continue + try: + sink = _record_reads(description) + except _UndeterminedPrefix as err: + offenders.append( + f"{description.key}: {err}, so its derived classification would go unverified" + ) + continue + except Exception as err: # noqa: BLE001 + offenders.append(f"{description.key}: value_fn raised {err!r}") + continue + read = sorted(sink & producible) + if len(read) == 1: + offenders.append( + f"{description.key}: derived={description.derived} but reads exactly one " + f"producible field, {read[0]!r} — that is a declaration, so set " + f"field_path={read[0]!r}" + ) + + assert not offenders, "Misclassified derived descriptions:\n" + "\n".join(offenders) + + +def test_derived_reasons_match_what_value_fns_read() -> None: + """Each derived description's stated reason must be the one its reads imply. + + `derived` used to be a `bool` covering four different situations, and it was + that conflation which hid `evse_ev_connected`: a single producible field + marked derived looked exactly like a genuine multi-field derivation. The + reason is only worth its syntax if it is checked, so each variant is a claim + about the recorder's output and is asserted as one: + + * `NO_SOURCE_FIELD` — reads nothing either adapter publishes, + * `MULTIPLE_FIELDS` — reads two or more fields an adapter publishes, + * `SCHEMA_CONDITIONAL_FIELD` — reads exactly one, produced by one adapter + only. When the other adapter grows it, this fails and demands promotion to + a `field_path` declaration. + + Intersecting with what the adapters emit is what makes the count meaningful: + the recorder also picks up method names and other noise. + """ + schema_0 = set(schema_zero_metadata()) + schema_1 = set(schema_one_metadata()) + produced = schema_0 | schema_1 + offenders: list[str] = [] + + for description in _declaring_descriptions(): + reason = description.derived + if reason is None: + continue + try: + sink = _record_reads(description) + except _UndeterminedPrefix as err: + offenders.append(f"{description.key}: {err}, so its reason would go unverified") + continue + except Exception as err: # noqa: BLE001 + offenders.append(f"{description.key}: value_fn raised {err!r}") + continue + read = sorted(sink & produced) + if reason is DerivedReason.NO_SOURCE_FIELD and read: + offenders.append( + f"{description.key}: claims NO_SOURCE_FIELD but reads {read} — " + "the reason is MULTIPLE_FIELDS, SCHEMA_CONDITIONAL_FIELD, or it is a " + "declaration" + ) + elif reason is DerivedReason.MULTIPLE_FIELDS and len(read) < 2: + offenders.append( + f"{description.key}: claims MULTIPLE_FIELDS but reads {read} — " + "one field or none is a different reason" + ) + elif reason is DerivedReason.SCHEMA_CONDITIONAL_FIELD: + if len(read) != 1 or read[0] in schema_0 & schema_1: + offenders.append( + f"{description.key}: claims SCHEMA_CONDITIONAL_FIELD but reads {read}, " + f"of which {sorted(set(read) & schema_0 & schema_1)} are produced by both " + "adapters" + ) + elif description.field_path != read[0]: + # The one reason that still names a field names the right one. + # That name is what the Repair and the availability probe act + # on, so a stale one degrades the wrong entity or none. + offenders.append( + f"{description.key}: declares field_path={description.field_path!r} " + f"but reads {read[0]!r}" + ) + + assert not offenders, "Derived reasons disagree with readers:\n" + "\n".join(offenders) diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py new file mode 100644 index 00000000..1656583e --- /dev/null +++ b/tests/test_field_paths.py @@ -0,0 +1,118 @@ +"""Every entity that reads one snapshot field must say which field.""" + +from __future__ import annotations + +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + DerivedReason, + FieldPathDeclarationMixin, + Producibility, + conditional_field_paths, + declared_field_paths, + platform_descriptions, + residual_field_paths, +) +from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS + + +def test_circuit_power_declares_its_field_path() -> None: + power = next(d for d in CIRCUIT_SENSORS if d.key == "circuit_power") + assert power.field_path == "circuit.instant_power_w" + assert power.derived is None + + +def test_derived_sensor_declares_no_path() -> None: + """dsm_state is a multi-signal derivation with no single source field.""" + from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS + + dsm = next(d for d in PANEL_DATA_STATUS_SENSORS if d.key == "dsm_state") + assert dsm.derived is DerivedReason.NO_SOURCE_FIELD + assert dsm.field_path is None + + +def test_declared_field_paths_includes_residuals() -> None: + """Readers that live in entity code rather than on a description still count.""" + paths = declared_field_paths() + assert "circuit.relay_state" in paths + assert "circuit.priority" in paths + + +def test_every_description_declares_exactly_one() -> None: + """A description inheriting the mixin but setting neither field is invisible. + + The `TypeError` guard in `declared_field_paths` only catches a description + that lacks the mixin entirely. Every new sensor inherits it automatically, + so the likelier mistake is inheriting it and declaring nothing — which + drops the entity from every gate with no signal. This is that signal. + + "Exactly one" is per `DerivedReason`, because the reasons differ on whether + a single source field exists at all. `NO_SOURCE_FIELD` and + `MULTIPLE_FIELDS` have none to name. `SCHEMA_CONDITIONAL_FIELD` has exactly + one and must name it: `derived` excuses the path from the *producible* gate, + which is a claim about the other adapter, and saying nothing about the field + would additionally excuse the entity from the Repair count and the + availability probe — the invisibility this whole module exists to prevent. + """ + for description in platform_descriptions(): + assert isinstance(description, FieldPathDeclarationMixin), description + names_field = description.field_path is not None + if description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD: + assert names_field, ( + f"{description.key} is SCHEMA_CONDITIONAL_FIELD, which reads exactly one " + "field, and must declare it as field_path= so the Repair and the " + "availability probe can see it" + ) + continue + assert names_field != (description.derived is not None), ( + f"{description.key} must declare exactly one of field_path / a DerivedReason" + ) + + +def test_schema_conditional_descriptions_name_an_exempt_field() -> None: + """The field a schema-conditional description names must be enumerated. + + Its source path cannot enter `declared_field_paths()` — one adapter does not + produce it — so `RESIDUAL_EXEMPT_PATHS` is the only place it is written down + and the only place its producibility is checked against the adapters. A + schema-conditional description naming a path absent from there would be read + by an entity, gated by nothing, and reported by `evaluate_field_metadata` as + produced-but-unread. `panel.dominant_power_source` was exactly that. + + `NEITHER` is excluded on purpose: a path no adapter publishes a metadata row + for has nothing to resolve, so a description reading one is + `NO_SOURCE_FIELD`, not schema-conditional. + """ + for description in platform_descriptions(): + assert isinstance(description, FieldPathDeclarationMixin), description + if description.derived is not DerivedReason.SCHEMA_CONDITIONAL_FIELD: + continue + path = description.field_path + assert path in RESIDUAL_EXEMPT_PATHS, ( + f"{description.key} reads {path!r}, which no adapter pair produces and " + "RESIDUAL_EXEMPT_PATHS does not enumerate" + ) + assert RESIDUAL_EXEMPT_PATHS[path] is not Producibility.NEITHER, ( + f"{description.key} claims SCHEMA_CONDITIONAL_FIELD but {path!r} is " + "annotated NEITHER — no adapter produces a row for it, so there is " + "nothing schema-conditional about it" + ) + + +def test_conditional_paths_are_exactly_the_unresolvable_reads() -> None: + """`conditional_field_paths()` must be disjoint from the declared set. + + The two together are what `evaluate_field_metadata` asks the adapter about. + An overlap would mean a path was both gated and exempted, which is the + contradiction `test_residual_buckets_are_disjoint` rules out one level down; + asserting it here keeps the union honest as the two functions change. + """ + conditional = conditional_field_paths() + assert conditional + assert not (conditional & declared_field_paths()) + assert conditional <= RESIDUAL_EXEMPT_PATHS.keys() + + +def test_residual_buckets_are_disjoint() -> None: + """A residual path is either producible or exempt, never both.""" + assert not (residual_field_paths() & RESIDUAL_EXEMPT_PATHS.keys()) + assert not (declared_field_paths() & RESIDUAL_EXEMPT_PATHS.keys()) diff --git a/tests/test_grace_period_restoration.py b/tests/test_grace_period_restoration.py index 9617e354..52444920 100644 --- a/tests/test_grace_period_restoration.py +++ b/tests/test_grace_period_restoration.py @@ -6,7 +6,9 @@ from types import SimpleNamespace from homeassistant.components.sensor import SensorStateClass + from custom_components.span_panel.const import ENABLE_ENERGY_DIP_COMPENSATION +from custom_components.span_panel.energy_orientation import EnergyBinding from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD from custom_components.span_panel.sensor_base import ( SpanEnergyExtraStoredData, @@ -37,6 +39,11 @@ def test_as_dict_with_all_values(self): "energy_offset": None, "last_panel_reading": None, "last_dip_delta": None, + "pending_dip_baseline": None, + "pending_dip_delta": None, + "confirmed_dip_baseline": None, + "confirmed_dip_delta": None, + "confirmed_dip_ticks_left": None, } def test_as_dict_with_none_values(self): @@ -58,6 +65,11 @@ def test_as_dict_with_none_values(self): "energy_offset": None, "last_panel_reading": None, "last_dip_delta": None, + "pending_dip_baseline": None, + "pending_dip_delta": None, + "confirmed_dip_baseline": None, + "confirmed_dip_delta": None, + "confirmed_dip_ticks_left": None, } def test_from_dict_with_all_values(self): @@ -137,9 +149,7 @@ def test_roundtrip_serialization(self): assert restored is not None assert restored.native_value == original.native_value - assert ( - restored.native_unit_of_measurement == original.native_unit_of_measurement - ) + assert restored.native_unit_of_measurement == original.native_unit_of_measurement assert restored.last_valid_state == original.last_valid_state assert restored.last_valid_changed == original.last_valid_changed @@ -221,11 +231,7 @@ def test_grace_period_edge_case_exactly_at_limit(self): # At exactly the limit, should still be within grace period (<= comparison) # Allow small timing difference assert ( - abs( - time_since_last_valid.total_seconds() - - grace_period_duration.total_seconds() - ) - < 1 + abs(time_since_last_valid.total_seconds() - grace_period_duration.total_seconds()) < 1 ) def test_grace_period_zero_disabled(self): @@ -375,6 +381,7 @@ def __init__( # pylint: disable=super-init-not-called self, grace_minutes: int | str = 15 ) -> None: # Bypass parent __init__ to avoid full HA dependencies for unit testing + self._bind_energy(EnergyBinding(meter=None, role=None, net=None)) self.coordinator = SimpleNamespace( panel_offline=True, config_entry=SimpleNamespace( @@ -411,9 +418,6 @@ def __init__( # pylint: disable=super-init-not-called def _generate_unique_id(self, snapshot, description): return "dummy" - def _generate_friendly_name(self, snapshot, description): - return "dummy" - def get_data_source(self, snapshot): return "dummy_data" diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 7a5ca1da..33ba14af 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -2,30 +2,18 @@ # pylint: disable=reimported -from unittest.mock import MagicMock, patch - -import pytest +from homeassistant.util import slugify -from custom_components.span_panel.const import ( - USE_CIRCUIT_NUMBERS, - USE_DEVICE_PREFIX, -) from custom_components.span_panel.helpers import ( - async_create_span_notification, construct_circuit_identifier_from_tabs, - construct_multi_circuit_entity_id, - construct_unmapped_entity_id, detect_capabilities, get_suffix_from_sensor_key, - get_unmapped_circuit_entity_id, get_user_friendly_suffix, is_panel_level_sensor_key, ) -from homeassistant.util import slugify from .factories import ( SpanBatterySnapshotFactory, - SpanCircuitSnapshotFactory, SpanEvseSnapshotFactory, SpanPanelSnapshotFactory, ) @@ -98,269 +86,9 @@ def test_is_panel_level_sensor_key(self): ) assert is_panel_level_sensor_key("invalid_format") is False - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_config_entry_none(self, mock_registry): - """Test construct_multi_circuit_entity_id works with valid coordinator (None config_entry should be caught at coordinator level).""" - mock_registry.return_value = None - - coordinator = MagicMock() - coordinator.config_entry.options = {} - coordinator.config_entry.title = "SPAN Panel" - span_panel = MagicMock() - - # This should work fine - the coordinator should validate config_entry at construction time - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[3032], - friendly_name="Solar Inverter", - ) - # With empty options, should use legacy naming (no device prefix) - assert result == "sensor.solar_inverter_power" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_empty_options(self, mock_registry): - """Test construct_multi_circuit_entity_id with stable naming (synthetic sensors are always stable).""" - mock_registry.return_value = None - - coordinator = MagicMock() - coordinator.config_entry.options = {} - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - span_panel = MagicMock() - - # Test with friendly name - legacy installation should not use device prefix - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Production Power", - ) - assert result == "sensor.solar_production_power" - - # Test with default friendly name - legacy installation should not use device prefix - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert result == "sensor.solar_inverter_power" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_no_device_name(self, mock_registry): - """Test construct_multi_circuit_entity_id with no device name - should return None.""" - mock_registry.return_value = None - - coordinator = MagicMock() - coordinator.config_entry.options = {USE_CIRCUIT_NUMBERS: True} - coordinator.config_entry.title = None - coordinator.config_entry.data = {"device_name": None} - span_panel = MagicMock() - - # Multi-circuit sensors should return None if no device name available - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert result is None - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_circuit_numbers_pattern( - self, mock_registry - ): - """Test construct_multi_circuit_entity_id with circuit numbers pattern.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = { - USE_CIRCUIT_NUMBERS: True, - USE_DEVICE_PREFIX: True, - } - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test with multiple circuit numbers (solar inverter case) - result = construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=[30, 32] - ) - assert result == "sensor.span_panel_circuit_30_32_power" - - # Test with single circuit number - result = construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=[15] - ) - assert result == "sensor.span_panel_circuit_15_power" - - # Test with different suffix - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "energy_produced", - circuit_numbers=[30, 32], - ) - assert result == "sensor.span_panel_circuit_30_32_energy_produced" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_friendly_names_pattern( - self, mock_registry - ): - """Test construct_multi_circuit_entity_id with friendly names pattern.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = { - USE_CIRCUIT_NUMBERS: False, - USE_DEVICE_PREFIX: True, - } - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test with friendly name (should ignore circuit_numbers when USE_CIRCUIT_NUMBERS is False) - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert result == "sensor.span_panel_solar_inverter_power" - - # Test without friendly name (should return None when not using circuit numbers) - result = construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=[30, 32] - ) - assert result is None - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_circuit_numbers_no_device_prefix( - self, mock_registry - ): - """Test construct_multi_circuit_entity_id with circuit numbers but no device prefix.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = { - USE_CIRCUIT_NUMBERS: True, - USE_DEVICE_PREFIX: False, - } - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test with circuit numbers but no device prefix (should still work for multi-circuit sensors) - result = construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=[30, 32] - ) - assert result == "sensor.circuit_30_32_power" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_empty_circuit_numbers( - self, mock_registry - ): - """Test construct_multi_circuit_entity_id with empty circuit numbers.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = { - USE_CIRCUIT_NUMBERS: True, - USE_DEVICE_PREFIX: True, - } - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test with empty circuit numbers (should raise ValueError) - with pytest.raises( - ValueError, - match="Circuit-based naming is enabled but no valid circuit numbers provided", - ): - construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=[] - ) - - # Test with None circuit numbers (should raise ValueError) - with pytest.raises( - ValueError, - match="Circuit-based naming is enabled but no valid circuit numbers provided", - ): - construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=None - ) - - # Test with invalid circuit numbers (should raise ValueError) - with pytest.raises( - ValueError, - match="Circuit-based naming is enabled but no valid circuit numbers provided", - ): - construct_multi_circuit_entity_id( - coordinator, span_panel, "sensor", "power", circuit_numbers=[0, -1] - ) - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_construct_multi_circuit_entity_id_legacy_compatibility( - self, mock_registry - ): - """Test construct_multi_circuit_entity_id maintains legacy compatibility.""" - mock_registry.return_value = MagicMock() - mock_registry.return_value.async_get_entity_id = MagicMock(return_value=None) - - coordinator = MagicMock() - coordinator.config_entry.options = {} # Legacy installation - coordinator.config_entry.title = "SPAN Panel" - coordinator.config_entry.data = {"device_name": "SPAN Panel"} - coordinator.hass = MagicMock() - span_panel = MagicMock() - - # Test legacy compatibility - should work with circuit_numbers parameter - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert result == "sensor.solar_inverter_power" - - # Test legacy compatibility - should work with circuit_numbers but ignore them in legacy mode - result = construct_multi_circuit_entity_id( - coordinator, - span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert result == "sensor.solar_inverter_power" - def test_construct_synthetic_friendly_name_with_user_name(self): """Test construct_synthetic_friendly_name with user-provided name.""" - result = construct_synthetic_friendly_name( - [30, 32], "Instant Power", "Solar Production" - ) + result = construct_synthetic_friendly_name([30, 32], "Instant Power", "Solar Production") assert result == "Solar Production Instant Power" def test_construct_synthetic_friendly_name_multiple_circuits(self): @@ -383,58 +111,11 @@ def test_construct_synthetic_friendly_name_empty_circuits(self): result = construct_synthetic_friendly_name([], "Instant Power") assert result == "Unknown Circuit Instant Power" - def test_construct_unmapped_entity_helpers(self): - """Test helper functions for unmapped circuits.""" - snapshot = SpanPanelSnapshotFactory.create( - circuits={ - "unmapped_tab_7": SpanCircuitSnapshotFactory.create( - circuit_id="unmapped_tab_7" - ) - } - ) - - assert ( - construct_unmapped_entity_id( - snapshot, "unmapped_tab_7", "power", "SPAN Panel" - ) - == "sensor.span_panel_unmapped_tab_7_power" - ) - assert ( - get_unmapped_circuit_entity_id(snapshot, 7, "power", "SPAN Panel") - == "sensor.span_panel_unmapped_tab_7_power" - ) - assert ( - get_unmapped_circuit_entity_id(snapshot, 99, "power", "SPAN Panel") is None - ) - def test_construct_circuit_identifier_from_tabs(self): """Test fallback circuit naming from tabs.""" assert construct_circuit_identifier_from_tabs([5, 6], "c1") == "Circuit 5 6" assert construct_circuit_identifier_from_tabs([7], "c1") == "Circuit 7" - assert ( - construct_circuit_identifier_from_tabs([], "fallback") == "Circuit fallback" - ) - - @patch("custom_components.span_panel.helpers.async_create") - @pytest.mark.asyncio - async def test_async_create_span_notification_logs_and_forwards(self, mock_create): - """Test notification helper forwarding.""" - hass = MagicMock() - - await async_create_span_notification( - hass, - "Panel connection lost", - "SPAN Alert", - "notif-1", - level="error", - ) - - mock_create.assert_called_once_with( - hass, - message="Panel connection lost", - title="SPAN Alert", - notification_id="notif-1", - ) + assert construct_circuit_identifier_from_tabs([], "fallback") == "Circuit fallback" def test_detect_capabilities_helper(self): """Test capability detection from a populated snapshot.""" @@ -445,6 +126,4 @@ def test_detect_capabilities_helper(self): evse={"evse-0": SpanEvseSnapshotFactory.create()}, ) - assert detect_capabilities(snapshot) == frozenset( - {"bess", "evse", "power_flows", "pv"} - ) + assert detect_capabilities(snapshot) == frozenset({"bess", "evse", "power_flows", "pv"}) diff --git a/tests/test_init_helpers.py b/tests/test_init_helpers.py index a13774aa..d396a5fc 100644 --- a/tests/test_init_helpers.py +++ b/tests/test_init_helpers.py @@ -9,17 +9,19 @@ from custom_components.span_panel import ( SpanPanelRuntimeData, + _defer_reload_until_started, async_remove_config_entry_device, async_unload_entry, ensure_device_registered, update_listener, ) from custom_components.span_panel.const import DOMAIN -from homeassistant.const import CONF_HOST +from custom_components.span_panel.curation import CurationOverlay +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr -from .factories import SpanPanelSnapshotFactory +from .factories import SpanPanelSnapshotFactory, pv_binding_for from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -42,7 +44,13 @@ async def test_async_remove_config_entry_device_rejects_main_panel_device( coordinator = MagicMock() coordinator.data = snapshot entry = MockConfigEntry(domain=DOMAIN, data={}) - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) device = MagicMock() device.identifiers = {(DOMAIN, "sp3-main-001")} @@ -57,7 +65,13 @@ async def test_async_remove_config_entry_device_allows_subdevice_removal( coordinator = MagicMock() coordinator.data = snapshot entry = MockConfigEntry(domain=DOMAIN, data={}) - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) device = MagicMock() device.identifiers = {(DOMAIN, "sp3-main-001_evse")} @@ -75,19 +89,121 @@ async def test_update_listener_reloads_running_entry(hass: HomeAssistant) -> Non mock_reload.assert_awaited_once_with("entry-123") -async def test_update_listener_skips_reload_when_not_running( +async def test_update_listener_defers_the_reload_until_home_assistant_has_started( + hass: HomeAssistant, +) -> None: + """An update while Home Assistant is starting reloads once it has started, not never.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-123") + entry.add_to_hass(hass) + hass.set_state(CoreState.starting) + + with patch.object(hass.config_entries, "async_reload", AsyncMock()) as mock_reload: + await update_listener(hass, entry) + await hass.async_block_till_done() + mock_reload.assert_not_awaited() + + hass.set_state(CoreState.running) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + mock_reload.assert_awaited_once_with("entry-123") + + +async def test_updates_while_starting_coalesce_into_one_reload(hass: HomeAssistant) -> None: + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-123") + entry.add_to_hass(hass) + hass.set_state(CoreState.starting) + + with patch.object(hass.config_entries, "async_reload", AsyncMock()) as mock_reload: + await update_listener(hass, entry) + await update_listener(hass, entry) + hass.set_state(CoreState.running) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + mock_reload.assert_awaited_once_with("entry-123") + + +async def test_each_entry_updated_while_starting_reloads_once_started(hass: HomeAssistant) -> None: + """Two panels changed during start-up each get their own reload; neither absorbs the other's.""" + first = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-first") + second = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-second") + first.add_to_hass(hass) + second.add_to_hass(hass) + hass.set_state(CoreState.starting) + + with patch.object(hass.config_entries, "async_reload", AsyncMock()) as mock_reload: + await update_listener(hass, first) + await update_listener(hass, second) + hass.set_state(CoreState.running) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert sorted(call.args for call in mock_reload.await_args_list) == [("entry-first",), ("entry-second",)] + + +async def test_a_deferral_made_while_running_reloads_now_and_leaves_nothing_pending( hass: HomeAssistant, ) -> None: - """Options updates should be ignored before Home Assistant is running.""" + """`async_at_started` runs the job at once when started, before the helper has stored anything. + + Unreachable through `update_listener`, whose guard is the same predicate; pinned so + the bookkeeping cannot leave a stale handle that blocks every later deferral. + """ + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-123") + entry.add_to_hass(hass) + hass.set_state(CoreState.running) + + with patch.object(hass.config_entries, "async_reload", AsyncMock()) as mock_reload: + _defer_reload_until_started(hass, entry) + await hass.async_block_till_done() + assert mock_reload.await_count == 1 + + hass.set_state(CoreState.starting) + await update_listener(hass, entry) + hass.set_state(CoreState.running) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert mock_reload.await_count == 2 + + +async def test_unloading_before_start_cancels_the_deferred_reload(hass: HomeAssistant) -> None: entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-123") - hass.state = CoreState.starting + entry.add_to_hass(hass) + hass.set_state(CoreState.starting) with patch.object(hass.config_entries, "async_reload", AsyncMock()) as mock_reload: await update_listener(hass, entry) + await entry._async_process_on_unload(hass) + hass.set_state(CoreState.running) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() mock_reload.assert_not_awaited() +async def test_an_update_after_the_deferred_reload_ran_defers_again(hass: HomeAssistant) -> None: + """The deferral is one-shot: once it has run, a later start-up update gets its own.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-123") + entry.add_to_hass(hass) + hass.set_state(CoreState.starting) + + with patch.object(hass.config_entries, "async_reload", AsyncMock()) as mock_reload: + await update_listener(hass, entry) + hass.set_state(CoreState.running) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.set_state(CoreState.starting) + await update_listener(hass, entry) + hass.set_state(CoreState.running) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert mock_reload.await_count == 2 + + async def test_update_listener_propagates_cancelled_error(hass: HomeAssistant) -> None: """Cancellation should not be swallowed by the update listener.""" entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-123") @@ -164,7 +280,9 @@ async def test_ensure_device_registered_creates_missing_device( await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") - created = device_registry.async_get_device(identifiers={(DOMAIN, "sp3-create-001")}) + created = device_registry.async_get_device_by_identifier( + (DOMAIN, "sp3-create-001"), entry.entry_id + ) assert created is not None assert created.name == "SPAN Panel" @@ -176,7 +294,13 @@ async def test_async_unload_entry_shuts_down_runtime_data( coordinator = MagicMock() coordinator.async_shutdown = AsyncMock() entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-789") - entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(SpanPanelSnapshotFactory.create()), + setup_snapshot=SpanPanelSnapshotFactory.create(), + ) with patch.object( hass.config_entries, "async_unload_platforms", AsyncMock(return_value=True) diff --git a/tests/test_leaf_name_mismatch_repair.py b/tests/test_leaf_name_mismatch_repair.py new file mode 100644 index 00000000..f3586302 --- /dev/null +++ b/tests/test_leaf_name_mismatch_repair.py @@ -0,0 +1,391 @@ +"""Tests for the Repair raised when the panel's certificate does not name the host. + +The condition this covers is the one the library used to report to nobody: a +handshake that fails against a pin the panel still advertises, because the +certificate underneath names an address the entry does not use. The user was left +with a minute-by-minute log warning, every entity unavailable, and nothing in the +UI at all. + +The wiring is the substance of it, so most of these tests are about *where* the +subscription happens rather than what it does with the signal. The library runs +the diagnosis inside `connect()`, and a mismatch makes `connect()` raise, so a +subscription placed after it -- the natural place, and where the fatal-error one +lives -- would never see the case it exists for. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import issue_registry as ir +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import LeafNameMismatch +from span_panel_api.exceptions import SpanPanelConnectionError + +from custom_components.span_panel import async_setup_entry +from custom_components.span_panel.ca_repairs import async_raise_ca_changed, ca_changed_issue_id +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_HTTP_PORT, + CONF_PANEL_CA_PEM, + DOMAIN, +) +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.leaf_repairs import ( + async_clear_leaf_name_mismatch, + async_raise_leaf_name_mismatch, + leaf_name_mismatch_issue_id, +) + +from .factories import SpanPanelSnapshotFactory + +PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" + +_STRINGS = ( + Path(__file__).resolve().parent.parent / "custom_components" / "span_panel" / "strings.json" +) + +MOVED = LeafNameMismatch(host="192.168.1.100", leaf_names=("span-panel.local", "192.168.1.187")) +NAMELESS = LeafNameMismatch(host="192.168.1.100", leaf_names=()) + + +def _entry(hass: HomeAssistant) -> MockConfigEntry: + """Add a pinned v2 entry, the only shape this condition can arise on.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=7, + title="Span Panel", + data={ + CONF_HOST: "192.168.1.100", + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: PEM, + }, + source=config_entries.SOURCE_USER, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + return entry + + +def _setup_entry(hass: HomeAssistant) -> MockConfigEntry: + """Add an entry carrying everything `async_setup_entry` needs of a v2 panel.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_VERSION: "v2", + CONF_HOST: "192.168.1.100", + CONF_EBUS_BROKER_HOST: "span-panel.local", + CONF_EBUS_BROKER_USERNAME: "mqtt-user", + CONF_EBUS_BROKER_PASSWORD: "mqtt-pass", + CONF_EBUS_BROKER_PORT: 8883, + CONF_HTTP_PORT: 80, + }, + entry_id="entry-leaf", + title="sp3-leaf-001", + unique_id="sp3-leaf-001", + ) + entry.add_to_hass(hass) + return entry + + +def _issue(hass: HomeAssistant, entry: MockConfigEntry) -> ir.IssueEntry | None: + return ir.async_get(hass).async_get_issue(DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) + + +# ---------- what the notice says ---------- + + +async def test_a_mismatch_names_the_addresses_the_panel_does_answer_to( + hass: HomeAssistant, +) -> None: + """Saying the address is wrong without saying which is right is the log line again.""" + entry = _entry(hass) + + async_raise_leaf_name_mismatch(hass, entry, MOVED) + + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_key == "panel_leaf_name_mismatch" + assert issue.translation_placeholders == { + "panel": "Span Panel", + "host": "192.168.1.100", + "leaf_names": "span-panel.local, 192.168.1.187", + } + + +async def test_the_notice_is_a_warning_the_user_cannot_click_through( + hass: HomeAssistant, +) -> None: + """Not fixable, because Reconfigure is the remedy and a fix flow cannot start one. + + Not persistent either, and not an error: the transport is alive and still + retrying, the chain verified under the pin, and nothing was refused. + """ + entry = _entry(hass) + + async_raise_leaf_name_mismatch(hass, entry, MOVED) + + issue = _issue(hass, entry) + assert issue is not None + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.severity == ir.IssueSeverity.WARNING + + +async def test_a_certificate_naming_nothing_gets_its_own_description( + hass: HomeAssistant, +) -> None: + """The empty case is a different sentence, not an empty value. + + A placeholder is substituted verbatim, so a phrase passed as one would reach + a Spanish reader in English. It is also a different fault -- the panel's + certificate rather than the user's address -- and "names only: " followed by + nothing is not a sentence in any language. + """ + entry = _entry(hass) + + async_raise_leaf_name_mismatch(hass, entry, NAMELESS) + + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_key == "panel_leaf_name_mismatch_no_names" + assert issue.translation_placeholders == { + "panel": "Span Panel", + "host": "192.168.1.100", + } + + +async def test_both_descriptions_exist_and_use_only_the_placeholders_supplied( + hass: HomeAssistant, +) -> None: + """`strings.json` is the source of truth, and it has to match what is passed. + + A description referencing a placeholder nothing supplies renders the literal + `{leaf_names}` to the user, which is how the empty case would fail if it were + ever pointed at the other key. + """ + strings = json.loads(_STRINGS.read_text(encoding="utf-8")) + issues = strings["issues"] + entry = _entry(hass) + + for mismatch in (MOVED, NAMELESS): + async_raise_leaf_name_mismatch(hass, entry, mismatch) + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_placeholders is not None + block = issues[issue.translation_key] + assert block["title"] + block["description"].format(**issue.translation_placeholders) + + +# ---------- when it goes away ---------- + + +async def test_a_connection_clears_a_standing_notice(hass: HomeAssistant) -> None: + """The same edge the library re-arms its once-per-outage signal on. + + Cleared on the connection callback rather than on a snapshot so the two + cannot drift: if the notice outlived the re-arm, the next mismatch would fire + against an issue that is already standing and tell the user nothing new. + """ + entry = _entry(hass) + async_raise_leaf_name_mismatch(hass, entry, MOVED) + coordinator = SpanPanelCoordinator(hass, MagicMock(), entry) + + coordinator._on_connection_change(True) + + assert _issue(hass, entry) is None + + +async def test_a_disconnect_leaves_the_notice_standing(hass: HomeAssistant) -> None: + """Losing the connection is the condition, not its resolution.""" + entry = _entry(hass) + async_raise_leaf_name_mismatch(hass, entry, MOVED) + coordinator = SpanPanelCoordinator(hass, MagicMock(), entry) + + coordinator._on_connection_change(False) + + assert _issue(hass, entry) is not None + + +async def test_a_ca_change_takes_the_notice_over(hass: HomeAssistant) -> None: + """Two findings out of one handshake, and only one of them carries a decision. + + Telling somebody to re-point their configuration at an address served by a + certificate the integration has just refused to trust is the wrong + instruction, so the CA Repair does not stand beside this one. + """ + entry = _entry(hass) + async_raise_leaf_name_mismatch(hass, entry, MOVED) + + async_raise_ca_changed(hass, entry, "aa" * 32, "bb" * 32) + + assert _issue(hass, entry) is None + assert ( + ir.async_get(hass).async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) is not None + ) + + +async def test_clearing_a_notice_that_was_never_raised_is_not_an_error( + hass: HomeAssistant, +) -> None: + """Setup and every connect call this, and almost every one has nothing to drop.""" + entry = _entry(hass) + + async_clear_leaf_name_mismatch(hass, entry) + + assert _issue(hass, entry) is None + + +# ---------- the wiring ---------- + + +def _diagnosing_client(mismatch: LeafNameMismatch | None) -> tuple[MagicMock, list[str]]: + """Build a client that diagnoses inside `connect()` and then raises, as the library does. + + The signal is fired from within `connect`, so a callback registered after it + returns -- or after it raises -- never sees it. That is the whole point of + the ordering under test, and a mock that fired it from anywhere else would + prove nothing. + + Returns the calls it saw, in order, so the ordering can be asserted directly + as well as through its consequence. + """ + client = MagicMock() + subscribers: list[Any] = [] + order: list[str] = [] + + def register(callback: Any) -> Any: + order.append("register") + subscribers.append(callback) + return MagicMock(name="unregister") + + async def connect() -> None: + order.append("connect") + for callback in subscribers: + if mismatch is not None: + callback(mismatch) + raise SpanPanelConnectionError("TLS handshake failed") + + client.register_leaf_mismatch_callback = MagicMock(side_effect=register) + client.connect = AsyncMock(side_effect=connect) + client.close = AsyncMock() + return client, order + + +async def test_a_mismatch_on_the_very_first_connect_still_reaches_the_user( + hass: HomeAssistant, +) -> None: + """The regression this ordering exists for. + + A mismatch makes `connect()` raise, setup raises `ConfigEntryNotReady`, and + the retry builds a new client -- so a subscription registered after a + successful connect would be registered on a client that never has one. Every + setup attempt would fire the signal into nothing. + """ + entry = _setup_entry(hass) + client, _order = _diagnosing_client(MOVED) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_placeholders is not None + assert issue.translation_placeholders["leaf_names"] == "span-panel.local, 192.168.1.187" + + +async def test_the_subscription_is_registered_before_the_connect_attempt( + hass: HomeAssistant, +) -> None: + """Stated as an ordering too, not only through its consequence. + + The test above would keep passing on a subscription moved back after + `connect()` if the library ever stopped raising on a mismatch, which is a + change to the library rather than to this decision. + """ + entry = _setup_entry(hass) + client, order = _diagnosing_client(None) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + assert order == ["register", "connect"] + + +async def test_a_successful_setup_clears_a_notice_left_from_a_previous_run( + hass: HomeAssistant, +) -> None: + """A handshake that succeeded is the same disproof a reconnect is.""" + entry = _setup_entry(hass) + async_raise_leaf_name_mismatch(hass, entry, MOVED) + + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-leaf-001") + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = snapshot + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch("custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator), + patch("custom_components.span_panel.ensure_device_registered", AsyncMock()), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + assert _issue(hass, entry) is None + + +async def test_unloading_the_entry_takes_the_subscription_with_it( + hass: HomeAssistant, +) -> None: + """The client goes when the entry does, and so must the callback holding `hass`.""" + entry = _setup_entry(hass) + unregister = MagicMock(name="unregister") + client = MagicMock() + client.connect = AsyncMock() + client.register_leaf_mismatch_callback = MagicMock(return_value=unregister) + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-leaf-001") + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch("custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator), + patch("custom_components.span_panel.ensure_device_registered", AsyncMock()), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + unregister.assert_not_called() + await entry._async_process_on_unload(hass) + unregister.assert_called_once_with() diff --git a/tests/test_library_path_hook.py b/tests/test_library_path_hook.py new file mode 100644 index 00000000..4ead5f1b --- /dev/null +++ b/tests/test_library_path_hook.py @@ -0,0 +1,295 @@ +"""The commit-time gate on `pyproject.toml`'s library paths must actually fire. + +`scripts/check-library-path.py` runs on every commit that touches +`pyproject.toml` and rejects a `span-panel-api` path naming anything but +`../../span/span-panel-api`. It exists because `3cbf02a` pointed both blocks that +decide where the library comes from at a scratch worktree and committed it, and +nothing noticed for several commits. + +Tested for the same reason `tests/test_dependency_sync.py` exists: a hook that +matches nothing produces no output, and no output is indistinguishable from a +clean tree. + +Two things this file is careful about, because getting either wrong would leave a +gate that is only proven against itself. + +**It drives the real entry point.** Every case runs +`scripts/check-library-path.py` as a subprocess and reads its exit status and +output, exactly as prek invokes it -- never the module's functions, and never a +reimplementation of the rule in the test. A test that reimplements the logic +proves the test's copy of the logic. `test_the_hook_prek_runs_is_the_one_tested` +closes the remaining gap by reading the entry out of `prek.toml`: a hook renamed +there and not here would otherwise go unregistered while these tests kept passing. + +**The failing cases are the real files.** `tests/fixtures/pyproject_*.toml` are +byte copies of `pyproject.toml` at the two commits where this actually went +wrong, taken straight out of this repository's history and held to it by +`test_the_vendored_copies_are_the_commits_they_name`. A gate proven against a +made-up example is proven against the wrong thing: the synthetic version of a +defect is written by someone who already knows what the rule checks, so it +exercises the rule rather than the mistake. The two states nothing in history +provides -- an absent `[tool.uv.sources]` block, an `extraPaths` naming only +unrelated repositories -- are the only ones constructed here. + +Lives here rather than beside the script in `tests/scripts/`, which `pytest.ini` +excludes with `norecursedirs`. A test that is never collected is the same failure +as a hook that never matches. +""" + +from __future__ import annotations + +from pathlib import Path +import re +import subprocess +import sys + +import pytest + +REPO = Path(__file__).resolve().parent.parent +SCRIPT = REPO / "scripts" / "check-library-path.py" +PYPROJECT = REPO / "pyproject.toml" +PREK = REPO / "prek.toml" +FIXTURES = Path(__file__).resolve().parent / "fixtures" + +HOOK_ID = "library-path" +"""The hook's id in `prek.toml`, which is how prek is asked to run it.""" + +CHECKOUT = "../../span/span-panel-api" +WORKTREE = "../../span/span-panel-api-security" +"""The worktree `3cbf02a` actually named. + +A sibling of the checkout, sharing every path component but the last, which is +why a prefix comparison has to be separator-aware to tell them apart. Not written +here as the definition of the defect -- the vendored copies are that -- but as +what the failure output is checked to name. +""" + +BOTH_BLOCKS = "pyproject_both_blocks_redirected" +"""`3cbf02a`: `[tool.uv.sources]` and `[tool.pyright].extraPaths` both on the worktree.""" + +TYPE_CHECKER_ONLY = "pyproject_type_checker_path_left_behind" +"""`82a512f^`: the sources block corrected, `extraPaths` still on the worktree. + +The interval between the two corrections -- several commits, during which the +repository looked fixed. A hook covering only `[tool.uv.sources]` calls this +clean, which is precisely how the second half survived as long as it did. +""" + + +def _run(*targets: Path) -> subprocess.CompletedProcess[str]: + """Invoke the hook the way prek does: the script, given filenames, judged by exit status.""" + return subprocess.run( + [sys.executable, str(SCRIPT), *(str(target) for target in targets)], + capture_output=True, + text=True, + check=False, + ) + + +def _historical(name: str) -> Path: + """Return the vendored copy of `pyproject.toml` at one historical commit.""" + return FIXTURES / f"{name}.toml" + + +def _recorded_commit(name: str) -> str: + """Return the commit a vendored copy records itself as having come from.""" + return (FIXTURES / f"{name}.source").read_text(encoding="utf-8").strip() + + +def _synthetic(root: Path, sources: str | None, pyright: str | None) -> Path: + """Write a miniature `pyproject.toml` for a state this repository's history does not hold. + + Deliberately not used for the failing cases. History has real instances of + those, and a real instance is worth more than a constructed one -- see this + module's docstring. + """ + lines = ["[project]", 'name = "span"', ""] + if sources is not None: + lines += [ + "[tool.uv.sources]", + f'span-panel-api = {{ path = "{sources}", editable = true }}', + "", + ] + if pyright is not None: + lines += [ + "[tool.pyright]", + f'extraPaths = ["./custom_components", "{pyright}/src", "../ha-synthetic-sensors/src"]', + "", + ] + written = root / "pyproject.toml" + written.write_text("\n".join(lines), encoding="utf-8") + return written + + +_HOOK_TABLE = re.compile(r"\{(?P[^{}]*)\}", re.DOTALL) +"""One hook's inline table. `prek.toml` nests no braces inside one, so this is exact.""" + +_ENTRY = re.compile(r'^\s*entry\s*=\s*"(?P[^"]*)"', re.MULTILINE) + + +def _declared_entries(hook_id: str) -> list[str]: + """Return the `entry` of every hook in `prek.toml` with this id. + + Read as text rather than through `tomllib`, because `prek.toml` is not TOML + 1.0: it writes each hook as an inline table spread over several lines, which + the spec allows only from 1.1 and `tomllib` rejects outright. prek's own + parser accepts it and so does `check-toml`, so the file is not wrong -- a + strict parser is simply the wrong tool for reading it, and reformatting the + file to suit one test would be the tail wagging the dog. + """ + text = PREK.read_text(encoding="utf-8") + entries: list[str] = [] + for table in _HOOK_TABLE.finditer(text): + body = table.group("body") + if f'id = "{hook_id}"' not in body: + continue + entry = _ENTRY.search(body) + if entry is not None: + entries.append(entry.group("entry")) + return entries + + +def test_the_hook_prek_runs_is_the_one_tested() -> None: + """`prek.toml` must invoke the script these tests invoke. + + The gap this closes is a quiet one: rename the script or the hook and every + case below goes on passing against a file prek no longer runs. Nothing else + would report it, because an unregistered hook produces no output either. + """ + entries = _declared_entries(HOOK_ID) + + assert entries, f"prek.toml declares no hook with id {HOOK_ID!r}; nothing runs at commit time" + assert len(entries) == 1, f"prek.toml declares {HOOK_ID!r} more than once: {entries}" + assert SCRIPT.name in entries[0], ( + f"prek runs {entries[0]!r} for {HOOK_ID!r} and these tests exercise {SCRIPT.name}. " + f"One of the two is not the gate." + ) + + +def test_the_committed_file_passes() -> None: + """The real `pyproject.toml`, which is the state the hook must be silent on. + + A gate nobody can commit through is not a gate, it is an outage. This is the + half of the proof that is easy to skip and the one that decides whether the + hook survives its first day. + """ + result = _run(PYPROJECT) + + assert result.returncode == 0, result.stdout + result.stderr + + +def test_the_commit_that_redirected_both_blocks_is_rejected() -> None: + """`3cbf02a` itself, byte for byte, not an approximation of it. + + All four declarations it moved are reported, rather than the first one found. + Reporting one at a time would have made correcting this a four-commit + conversation with the hook, and it is the last one -- `extraPaths` -- that + nobody was looking at. + """ + result = _run(_historical(BOTH_BLOCKS)) + + assert result.returncode == 1, result.stdout or "the hook said nothing about 3cbf02a" + for declaration in ( + f"span-panel-api = '{WORKTREE}'", + f"span-panel-api-schema-0 = '{WORKTREE}/packages/schema-0'", + f"span-panel-api-schema-1 = '{WORKTREE}/packages/schema-1'", + f"extraPaths = '{WORKTREE}/src'", + ): + assert declaration in result.stdout, f"the failure does not report {declaration}" + + +def test_the_commit_that_left_the_type_checker_path_behind_is_rejected() -> None: + """`82a512f^`: half-corrected, and still wrong. + + The sources block had been moved back to the checkout by this point and the + type-checker path had not. The assertion that matters is the second one: the + corrected half must not be reported, so what fails here is the half that was + genuinely still broken rather than a hook that objects to the file in general. + """ + result = _run(_historical(TYPE_CHECKER_ONLY)) + + assert result.returncode == 1, result.stdout or "the hook said nothing about 82a512f^" + assert f"extraPaths = '{WORKTREE}/src'" in result.stdout + assert f"[tool.uv.sources] span-panel-api = '{WORKTREE}'" not in result.stdout, ( + "the sources block was already corrected at this commit; reporting it would mean the " + "hook is not reading the file it was given" + ) + + +def test_the_failure_says_what_to_do_instead() -> None: + """Somebody hits this precisely when they are trying to do the thing it forbids. + + The message has to carry the alternative, because a gate that only says "no" + to a developer with a real reason to redirect the library teaches them to pass + `--no-verify` rather than to use the venv. + """ + result = _run(_historical(BOTH_BLOCKS)) + + assert "uv pip install -e" in result.stdout, "the failure does not name the alternative" + assert "uv sync" in result.stdout, "the failure does not say how to undo it" + assert "developer.md" in result.stdout, "the failure does not point at the documentation" + + +def test_an_absent_sources_block_passes(tmp_path: Path) -> None: + """CI deletes `[tool.uv.sources]` before installing, then re-runs these hooks. + + Constructed rather than taken from history, because no commit here has ever + been in this state -- it exists only inside a CI job, after a `sed`. A block + that is not there declares nothing to be wrong about, and failing on its + absence would make the hook impossible to satisfy in the one environment that + resolves from PyPI. + """ + result = _run(_synthetic(tmp_path, None, CHECKOUT)) + + assert result.returncode == 0, result.stdout + result.stderr + + +def test_an_unrelated_path_is_left_alone(tmp_path: Path) -> None: + """`extraPaths` carries other repositories, and they are none of this hook's business.""" + target = tmp_path / "pyproject.toml" + target.write_text( + '[tool.pyright]\nextraPaths = ["../ha-synthetic-sensors/src", "./custom_components"]\n', + encoding="utf-8", + ) + + result = _run(target) + + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("name", [BOTH_BLOCKS, TYPE_CHECKER_ONLY]) +def test_the_vendored_copies_are_the_commits_they_name(name: str) -> None: + """Each copy must still be the blob its `.source` records. + + Without this, "the real historical file" is a claim in a docstring, and the + obvious way to make a failing case pass is to edit the fixture. + + **A skip here is genuinely not the pass that a skip usually hides**, and the + difference is worth stating because a guard over anything that can move must + refuse exactly this exit: skipping there means not looking at something that + changes. A commit is content-addressed: the bytes at `3cbf02a:pyproject.toml` + cannot become different bytes. The only way this comparison can fail is a copy + that was wrong when it was taken, and that is caught wherever the history is + reachable, which is every developer machine and any CI checkout with depth. A + shallow clone does not have the object to compare against, and asserting + against an object that is not there would fail on the clone rather than on the + fixture. + """ + recorded = _recorded_commit(name) + blob = subprocess.run( + ["git", "-C", str(REPO), "cat-file", "-p", f"{recorded}:pyproject.toml"], + capture_output=True, + check=False, + ) + if blob.returncode != 0: + pytest.skip( + f"{recorded} is not reachable from this checkout (a shallow clone has no history " + f"to compare against); the copy is verified wherever the commit is present" + ) + + assert blob.stdout == _historical(name).read_bytes(), ( + f"tests/fixtures/{name}.toml is no longer a byte copy of {recorded}:pyproject.toml, " + f"which is what makes it evidence rather than an example. Re-vendor it with " + f"`git show {recorded}:pyproject.toml > tests/fixtures/{name}.toml`, or correct the " + f"commit in {name}.source if the copy was meant to come from a different one." + ) diff --git a/tests/test_library_resolution.py b/tests/test_library_resolution.py new file mode 100644 index 00000000..5191a93e --- /dev/null +++ b/tests/test_library_resolution.py @@ -0,0 +1,472 @@ +"""The `span_panel_api` under test must be the one the pins name. + +Every other guard in this suite reads a version. `manifest.json` pins one, +`scripts/sync-dependencies.py` holds the copies to it, and the reference payloads +the conformance tests replay are whatever the pinned wheels ship. All of that +assumes the version string identifies the code, and it does not: a checkout +sitting on unreleased work +declares the same version as the release it is ahead of, and a scratch worktree +declares the same version as the checkout it was branched from. A version is a +claim; a filesystem location is a fact. + +That gap is not hypothetical here. `3cbf02a` pointed `[tool.uv.sources]` at a +scratch worktree and committed it, so every import in the suite resolved through +a checkout that had never received span-panel-api#161. The conformance tests ran +against an emitter defect that had already been fixed, for as long as it stood. +Every version check in both repositories passed, because the stale worktree +declared the same version number as the corrected code. + +So this file checks the one thing no version can: **where the module actually came +from**. It reads `span_panel_api.__file__`, not metadata, because those two can +disagree -- see `Verdict.INCONSISTENT` -- and when they do it is metadata that is +wrong. + +Nothing here writes down a version or a path. The pinned version is read from +`manifest.json`, which is the only file whose pins reach a user, and the expected +location is read from `[tool.uv.sources]` in `pyproject.toml`, which is what the +environment is actually built from. A guard carrying its own copy of the fact it +is guarding is the same duplicated-knowledge defect in miniature, and it fails +the day somebody bumps the real one. + +Deliberate local overrides **skip** rather than fail, and only off CI. Testing +against an unreleased library is a supported workflow -- `developer.md`, "Working +against unreleased library code" -- and a guard that punishes the workflow it is +protecting gets deleted. Under `CI` there is no such thing as a legitimate +override: the workflow deletes `[tool.uv.sources]` and resolves from PyPI, so +anything but the pinned distribution in the environment's own site-packages means +the wiring has come undone. The reason a skip is not a pass is `span-panel-api`'s, +stated in its DEVELOPMENT.md -- "A skip reads in a summary line exactly like a +pass, and that is how a stale vendored capture went unnoticed for nine days". +""" + +from __future__ import annotations + +from enum import Enum +from importlib.metadata import Distribution, PackageNotFoundError, distribution +import json +import os +from pathlib import Path +import tomllib +from typing import NamedTuple +from urllib.parse import unquote, urlparse + +import pytest +import span_panel_api + +_REPO_ROOT = Path(__file__).resolve().parent.parent + +DISTRIBUTION = "span-panel-api" +"""The bootstrap distribution: the one every import in this suite goes through.""" + +MANIFEST = _REPO_ROOT / "custom_components" / "span_panel" / "manifest.json" +PYPROJECT = _REPO_ROOT / "pyproject.toml" + +WORKFLOW = "developer.md, 'Working against unreleased library code'" +"""Where the supported way to test against unreleased library work is written down.""" + + +class Verdict(Enum): + """What the resolved module is, relative to what the pins name.""" + + AS_PINNED = "the module the pins name, at the version they pin" + STALE = "the location the pins name, at a version they do not" + OVERRIDDEN = "a deliberate local override: somewhere else, consistently" + INCONSISTENT = "metadata and the imported module disagree about where the code is" + + +class Resolution(NamedTuple): + """Where `span_panel_api` came from, and where it was supposed to come from. + + Four facts rather than two because two of them can lie independently. + `installed` and `metadata_origin` both come from the installed distribution's + metadata and describe each other; `imported` is the module Python actually + loaded. `expected` is the declaration those are judged against. + """ + + imported: Path + """`span_panel_api.__file__` -- the code this run will execute.""" + + metadata_origin: Path + """Where the installed distribution says its code lives: an editable target, or site-packages.""" + + expected: Path + """Where `pyproject.toml` says it should come from, or site-packages when nothing overrides.""" + + installed: str + """The version `importlib.metadata` reports.""" + + pinned: str + """The version `manifest.json` requires.""" + + +def verdict(resolution: Resolution) -> Verdict: + """Classify a resolution. Pure, so every outcome can be exercised directly. + + Order matters. The consistency check comes first because when metadata and + the imported module disagree, every fact derived from metadata -- including + the version -- describes a different copy of the library than the one about to + run, and comparing it against anything is meaningless. That is the state a + `PYTHONPATH` prefix produces, and it is the most dangerous of the four + precisely because the version check it invalidates still passes. + """ + if not resolution.imported.is_relative_to(resolution.metadata_origin): + return Verdict.INCONSISTENT + if not resolution.imported.is_relative_to(resolution.expected): + return Verdict.OVERRIDDEN + if resolution.installed != resolution.pinned: + return Verdict.STALE + return Verdict.AS_PINNED + + +def explain(outcome: Verdict, resolution: Resolution) -> str: + """Say what is resolved and what to do about it, to someone who has not read this file.""" + if outcome is Verdict.INCONSISTENT: + return ( + f"{DISTRIBUTION} metadata and the imported module name different directories. " + f"The installed distribution is {resolution.installed} at " + f"{resolution.metadata_origin}, and `span_panel_api.__file__` is " + f"{resolution.imported}. A worktree on `PYTHONPATH` or `MYPYPATH` does this: " + f"it has no distribution metadata of its own, so every version check reads the " + f"installed distribution and passes while the code under test comes from " + f"somewhere else entirely. Install the worktree instead -- " + f"`uv pip install -e ` gives a consistent path and consistent " + f"metadata, and `uv sync` puts it back. See {WORKFLOW}." + ) + if outcome is Verdict.OVERRIDDEN: + return ( + f"{DISTRIBUTION} resolves to {resolution.imported} at {resolution.installed}, " + f"not to {resolution.expected}, which is what pyproject.toml names. That is what " + f"a local override looks like and it is a supported one -- `uv sync` ends it. " + f"This check cannot say anything useful about a library it was not pointed at, " + f"so it stops here rather than reporting a pass it did not earn. See {WORKFLOW}." + ) + return ( + f"{DISTRIBUTION} resolves to {resolution.imported}, which is where pyproject.toml " + f"names, but the installed version is {resolution.installed} and manifest.json pins " + f"{resolution.pinned}. The checkout is behind or ahead of the pin, and every version " + f"check in this suite reads {resolution.installed} and agrees with itself. Run " + f"`uv sync` to rebuild the environment from the pins, or move the checkout to the " + f"release {resolution.pinned} names." + ) + + +def _pinned_version() -> str: + """Read the `span-panel-api` pin out of `manifest.json`. + + From the manifest rather than from `pyproject.toml` because the manifest is + the only file whose pins reach a user; `scripts/sync-dependencies.py` exists + to hold every other declaration to it. Reading a copy would make this guard + agree with a drifted file. + """ + manifest: object = json.loads(MANIFEST.read_text(encoding="utf-8")) + requirements = manifest.get("requirements") if isinstance(manifest, dict) else None + if isinstance(requirements, list): + for requirement in requirements: + if isinstance(requirement, str) and requirement.startswith(f"{DISTRIBUTION}=="): + return requirement.split("==", 1)[1].strip() + raise AssertionError( + f"{MANIFEST} has no exact `{DISTRIBUTION}==` requirement. That pin is what Home " + f"Assistant installs and what this check reads; it cannot be derived from anything else." + ) + + +def _declared_source() -> Path | None: + """Return the checkout `[tool.uv.sources]` points `span-panel-api` at, if it points anywhere. + + `None` is the ordinary CI answer, not an error. The workflow deletes the whole + block before installing so that uv resolves from PyPI, then re-runs the suite + against a file that no longer has one. Nothing is declared, so nothing is + overridden, and site-packages is where the library is supposed to be. + + Relative values resolve against the repository root rather than the working + directory, which is what they mean in the file uv reads -- and pytest can be + run from anywhere. + """ + document: object = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + if not isinstance(document, dict): + return None + for key in ("tool", "uv", "sources", DISTRIBUTION): + document = document.get(key) if isinstance(document, dict) else None + path = document.get("path") if isinstance(document, dict) else None + if not isinstance(path, str): + return None + declared = Path(path) + return (declared if declared.is_absolute() else _REPO_ROOT / declared).resolve() + + +def _metadata_origin(installed: Distribution) -> Path: + """Return where the installed distribution says its code lives. + + An editable install records its target in `direct_url.json` (PEP 610), which + is the only place the redirection is written down as data -- the `.pth` file + that implements it holds a `src` directory rather than the checkout. A + non-editable install has no such record and its code is under site-packages, + which is what `locate_file` returns. + + This is deliberately not used to decide *which* code runs. It is used to + decide whether metadata is describing the code that runs at all. + """ + record = installed.read_text("direct_url.json") + if record is not None: + direct_url: object = json.loads(record) + if isinstance(direct_url, dict): + url = direct_url.get("url") + info = direct_url.get("dir_info") + editable = isinstance(info, dict) and info.get("editable") is True + if editable and isinstance(url, str): + return Path(unquote(urlparse(url).path)).resolve() + return Path(str(installed.locate_file(""))).resolve() + + +def resolve() -> Resolution: + """Gather the four facts about this environment's `span_panel_api`.""" + try: + installed = distribution(DISTRIBUTION) + except PackageNotFoundError: + pytest.fail( + f"{DISTRIBUTION} is not installed, so there is nothing for this suite to test " + f"against. Run `uv sync`." + ) + + imported = span_panel_api.__file__ + if imported is None: + pytest.fail( + f"span_panel_api has no `__file__`, so it was imported as a namespace package " + f"rather than from a distribution -- there is a directory of that name on the " + f"path with no code in it. Run `uv sync`." + ) + + metadata_origin = _metadata_origin(installed) + return Resolution( + imported=Path(imported).resolve(), + metadata_origin=metadata_origin, + expected=_declared_source() or metadata_origin, + installed=installed.version, + pinned=_pinned_version(), + ) + + +def report(resolution: Resolution) -> None: + """Return on a clean resolution; otherwise stop the run, failing or skipping. + + `STALE` stops everywhere, CI or not: the pins and the environment disagree in + the one place nothing downstream can see, and that is a defect wherever it + happens. The other two are only defects in CI, where no legitimate override + exists -- locally they are the supported workflow, reported loudly enough that + nobody mistakes the skip for a clean run. + """ + outcome = verdict(resolution) + if outcome is Verdict.AS_PINNED: + return + + reason = explain(outcome, resolution) + if outcome is Verdict.STALE or os.environ.get("CI"): + pytest.fail(reason) + pytest.skip(reason) + + +def test_the_library_under_test_is_the_one_the_pins_name() -> None: + """The guard itself, against this environment.""" + report(resolve()) + + +def _resolution( + imported: str, metadata: str, expected: str, installed: str, pinned: str +) -> Resolution: + """Build a resolution from strings, so a case reads as the situation it describes.""" + return Resolution( + imported=Path(imported), + metadata_origin=Path(metadata), + expected=Path(expected), + installed=installed, + pinned=pinned, + ) + + +_CHECKOUT = "/w/span/span-panel-api" +_WORKTREE = "/w/span/span-panel-api-security" +_SITE_PACKAGES = "/w/span/.venv/lib/python3.14/site-packages" + + +def test_the_checkout_at_the_pinned_version_is_the_only_clean_answer() -> None: + """Case 1: the module is where the pins point, at the version they pin.""" + resolution = _resolution( + imported=f"{_CHECKOUT}/src/span_panel_api/__init__.py", + metadata=_CHECKOUT, + expected=_CHECKOUT, + installed="3.1.0", + pinned="3.1.0", + ) + + assert verdict(resolution) is Verdict.AS_PINNED + + +def test_the_checkout_at_another_version_is_the_defect_this_file_exists_for() -> None: + """Case 2: the right location, the wrong content. + + A checkout that has not been re-synced after a pin moves, or one sitting on + unreleased work. Nothing else in either repository can see this: the version + the environment reports is the checkout's own, so every copy of the pin agrees + with it, and the tests pass against a library that is not the one being + shipped. + """ + resolution = _resolution( + imported=f"{_CHECKOUT}/src/span_panel_api/__init__.py", + metadata=_CHECKOUT, + expected=_CHECKOUT, + installed="3.0.1", + pinned="3.1.0", + ) + + assert verdict(resolution) is Verdict.STALE + + +def test_an_editable_worktree_is_an_override_rather_than_a_defect() -> None: + """Case 3: somewhere else, but metadata says so too. + + `uv pip install -e ` rewrites the distribution's `direct_url.json` + along with the path, so the location and the metadata move together. This is + the workflow `developer.md` documents in place of editing `pyproject.toml`, + and treating it as a failure is what would drive somebody back to editing the + committed file. + """ + resolution = _resolution( + imported=f"{_WORKTREE}/src/span_panel_api/__init__.py", + metadata=_WORKTREE, + expected=_CHECKOUT, + installed="3.2.0", + pinned="3.1.0", + ) + + assert verdict(resolution) is Verdict.OVERRIDDEN + + +def test_metadata_describing_one_copy_while_another_runs_is_not_a_pass() -> None: + """Case 4: the version check passes and means nothing. + + A worktree prefixed onto `PYTHONPATH` or `MYPYPATH` has no distribution + metadata of its own. `importlib.metadata` therefore keeps reporting the + installed distribution -- the right name, the right version, the right + location -- while `span_panel_api.__file__` is inside the worktree. Every + version-based check in this suite passes on facts about a copy that is not + running. + + Classified before the version is compared, and before the location is judged + against the pins, because in this state neither comparison is about the code + that will execute. + """ + resolution = _resolution( + imported=f"{_WORKTREE}/src/span_panel_api/__init__.py", + metadata=_CHECKOUT, + expected=_CHECKOUT, + installed="3.1.0", + pinned="3.1.0", + ) + + assert verdict(resolution) is Verdict.INCONSISTENT + + +def test_site_packages_is_the_expected_location_when_nothing_overrides() -> None: + """CI: no `[tool.uv.sources]`, so the pins name PyPI and the venv is where it lands. + + The workflow deletes the block before installing. `_declared_source` returns + `None` there and the expected location becomes the distribution's own, which + makes the check in CI exactly "the pinned version, out of site-packages" -- + the only correct answer when there is nothing to override with. + """ + resolution = _resolution( + imported=f"{_SITE_PACKAGES}/span_panel_api/__init__.py", + metadata=_SITE_PACKAGES, + expected=_SITE_PACKAGES, + installed="3.1.0", + pinned="3.1.0", + ) + + assert verdict(resolution) is Verdict.AS_PINNED + + +@pytest.mark.parametrize( + "outcome", + [Verdict.OVERRIDDEN, Verdict.INCONSISTENT], + ids=["editable override", "metadata and module disagree"], +) +def test_an_override_fails_in_ci_and_skips_locally( + monkeypatch: pytest.MonkeyPatch, outcome: Verdict +) -> None: + """The guard on the guard, for the two outcomes whose severity depends on where they happen. + + Both halves are asserted, in both environments. Asserting only the CI half + would leave the local half free to become a failure, which is what makes a + developer delete a check rather than work around it; asserting only the local + half would leave CI free to skip, and a skip reads in a summary line exactly + like a pass. That is not a hypothetical -- it is how a stale vendored capture + went unnoticed for nine days in `span-panel-api` (DEVELOPMENT.md, "A skip here + is not a pass"). + """ + elsewhere = _WORKTREE if outcome is Verdict.OVERRIDDEN else _CHECKOUT + resolution = _resolution( + imported=f"{_WORKTREE}/src/span_panel_api/__init__.py", + metadata=elsewhere, + expected=_CHECKOUT, + installed="3.1.0", + pinned="3.1.0", + ) + assert verdict(resolution) is outcome + + outcomes = (pytest.fail.Exception, pytest.skip.Exception) + + monkeypatch.delenv("CI", raising=False) + with pytest.raises(outcomes) as local: + report(resolution) + assert local.type is pytest.skip.Exception, ( + f"off CI, {outcome.value} must skip rather than {local.typename.lower()}: pointing this " + f"suite at unreleased library work is a documented workflow, and failing it is what " + f"sends somebody back to editing pyproject.toml" + ) + + monkeypatch.setenv("CI", "true") + with pytest.raises(outcomes) as in_ci: + report(resolution) + assert in_ci.type is pytest.fail.Exception, ( + f"under CI, {outcome.value} must fail rather than {in_ci.typename.lower()}: the " + f"workflow deletes [tool.uv.sources] and resolves from PyPI, so no legitimate " + f"override exists there and a skip is indistinguishable from a pass" + ) + + +def test_every_message_names_the_paths_it_is_talking_about() -> None: + """A guard that stops a run has to say what it found, not that it found something. + + Each of these is read by somebody who has just had a green suite stop, and the + first question is always which library is actually loaded. The answer is the + path, and it is not derivable from anywhere else in the output. + """ + resolution = _resolution( + imported=f"{_WORKTREE}/src/span_panel_api/__init__.py", + metadata=_CHECKOUT, + expected=_CHECKOUT, + installed="3.0.1", + pinned="3.1.0", + ) + + for outcome in (Verdict.STALE, Verdict.OVERRIDDEN, Verdict.INCONSISTENT): + reason = explain(outcome, resolution) + assert str(resolution.imported) in reason, f"{outcome.name} does not name what resolved" + assert resolution.installed in reason, f"{outcome.name} does not name the version found" + + +def test_the_pinned_version_is_read_from_the_manifest_rather_than_written_here() -> None: + """The derivation, against the real file. + + Not a tautology: it asserts the pin can still be *found*, by checking that + what came back reconstitutes a requirement the manifest actually lists. A + manifest that stopped using an exact `==` requirement, or renamed the + distribution, would otherwise leave this whole file comparing against a + version nobody declared -- which is the failure mode + `scripts/sync-dependencies.py` was rewritten for, a matcher that quietly found + nothing and reported success. + """ + requirements: object = json.loads(MANIFEST.read_text(encoding="utf-8"))["requirements"] + + assert isinstance(requirements, list) + assert f"{DISTRIBUTION}=={_pinned_version()}" in requirements diff --git a/tests/test_metadata_sweep.py b/tests/test_metadata_sweep.py new file mode 100644 index 00000000..3d48e9fe --- /dev/null +++ b/tests/test_metadata_sweep.py @@ -0,0 +1,554 @@ +"""Identity that reaches a device card, an attribute or a diagnostic sensor. + +Four surfaces, one new entity class, and one of them is a regression rather +than a feature. Grouped because they share a proof obligation: each is a value +the panel has published all along that nothing rendered, so a test asserting a +constant the code also holds would pass whether or not the wire is ever read. + +Every expectation below is therefore read out of the reference capture, and every +reading is proved by republishing it, unpublishing it, or both. The device-card +assertions go through the real device registry after a real registration rather +than through the `DeviceInfo` dict, because the dict is what the code returns and +the registry is what a user sees. + +**`panel.wifi_ssid` is the regression.** Flat published `core/wifi-ssid` and the +integration has surfaced it as an attribute since; v1.0 declares +`status/wifi-ssid`, schema_1 mapped nothing to it, and the path's exemption +annotation said `SCHEMA_0_ONLY` -- which was true, and sanctioned a user losing +an attribute on upgrade. With the library reading it, both adapters produce the +path, so it is a declaration now and the producible gate covers it. + +It is published on the **Wi-Fi Link binary sensor**, which is the coherent host: +the entity that reports whether Wi-Fi is up is the one that should say which +network it is up on, and both values come off the same node on the wire. The +Software Version sensor no longer carries it. That narrowing had already begun +undocumented -- at v2.0.8 four `STATUS_SENSORS` descriptions rendered the +attribute and three have since moved elsewhere, leaving one -- and this finishes +it and writes it down. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered +from custom_components.span_panel.binary_sensor import ( + SpanPanelWifiLinkBinarySensor, + async_setup_entry as binary_sensor_async_setup_entry, +) +from custom_components.span_panel.const import DOMAIN, SYSTEM_DOOR_STATE, SYSTEM_WIFI_LINK +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + declared_field_paths, +) +from custom_components.span_panel.sensor import create_evse_sensors, create_panel_sensors +from custom_components.span_panel.sensor_definitions import EVSE_SENSORS +from custom_components.span_panel.sensor_panel import SpanPanelPanelStatus, SpanPanelStatus +from homeassistant.const import CONF_HOST, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from .factories import pv_binding_for + +EVSE_PART_NUMBER_KEY = "evse_part_number" +DSM_STATE_KEY = "dsm_state" +SOFTWARE_VERSION_KEY = "software_version" + +WIFI_SSID_TOPIC = "status/wifi-ssid" +POLICY_TOPIC = "shed/policy" +VENDOR_TOPIC = "info/vendor-name" +MODEL_TOPIC = "info/model" +HARDWARE_TOPIC = "info/hardware-version" +FIRMWARE_TOPIC = "info/firmware-version" +PART_NUMBER_TOPIC = "info/part-number" + +EVSE = "evse" + +# What the panel's device card showed before any of this was readable, and what +# a panel publishing nothing must go on showing. +FALLBACK_MANUFACTURER = "Span" +FALLBACK_MODEL = "SPAN Panel" + + +@pytest.fixture(autouse=True) +def _mock_entity_registry() -> Any: + """Patch the entity-registry lookup sensor construction performs.""" + registry = MagicMock() + registry.async_get_entity_id.return_value = None + with patch( + "custom_components.span_panel.sensor_base.er.async_get", + return_value=registry, + ): + yield registry + + +def _published(device_id: str, topic: str) -> str: + """What the capture publishes on one topic, or fail saying it does not.""" + value = schema_one_tree()[device_id].get(topic) + assert value is not None, f"{device_id} publishes no {topic} in the capture" + return value + + +def _snapshot(**rewrites: str | None) -> SpanPanelSnapshot: + """A snapshot from the capture with panel topics rewritten or unpublished. + + Keyword spelling is `node__property_name`. `None` removes the topic, which is + what a panel whose firmware omits a property looks like -- a different event + from publishing an empty string, and the one the fallbacks exist for. + """ + tree = schema_one_tree() + for path, value in rewrites.items(): + node, _, prop = path.partition("__") + topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}" + if value is None: + tree[SCHEMA_ONE_PANEL].pop(topic, None) + else: + tree[SCHEMA_ONE_PANEL][topic] = value + return schema_one_snapshot(tree) + + +def _evse_snapshot(**rewrites: str | None) -> SpanPanelSnapshot: + """The same, against the first EVSE in the capture.""" + tree = schema_one_tree() + for path, value in rewrites.items(): + node, _, prop = path.partition("__") + topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}" + if value is None: + tree[EVSE].pop(topic, None) + else: + tree[EVSE][topic] = value + return schema_one_snapshot(tree) + + +def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: + """A coordinator-like mock carrying one snapshot.""" + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.50"}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + return coordinator + + +def _panel_sensors(snapshot: SpanPanelSnapshot) -> dict[str, Any]: + """Every panel-level sensor the platform creates, keyed by description key.""" + coordinator = _coordinator(snapshot) + created = create_panel_sensors(coordinator, snapshot, coordinator.config_entry) + return {sensor.entity_description.key: sensor for sensor in created} + + +def _attributes(snapshot: SpanPanelSnapshot, key: str) -> dict[str, Any]: + """The attributes one panel sensor reports, or an empty dict for none.""" + sensor = _panel_sensors(snapshot)[key] + return sensor.extra_state_attributes or {} + + +async def _binary_sensors(hass: HomeAssistant, snapshot: SpanPanelSnapshot) -> dict[str, Any]: + """Every binary sensor the platform creates, keyed by description key. + + Through `async_setup_entry` rather than by constructing an entity directly: + which entity class serves which description is exactly what is under test + here, and a direct construction would only assert the class the test itself + picked. + """ + coordinator = _coordinator(snapshot) + added = MagicMock() + await binary_sensor_async_setup_entry(hass, coordinator.config_entry, added) + return {entity.entity_description.key: entity for entity in added.call_args.args[0]} + + +async def _registered_panel( + hass: HomeAssistant, snapshot: SpanPanelSnapshot, entry_id: str +) -> dr.DeviceEntry: + """Register the panel the way setup does, and read its card back. + + Through the registry rather than through `snapshot_to_device_info`'s return + value: the dict is this integration's claim, the registry entry is the device + a user opens. A field the registry drops or overwrites is invisible to an + assertion on the dict. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.30"}, + entry_id=entry_id, + unique_id=snapshot.serial_number, + ) + entry.add_to_hass(hass) + await ensure_device_registered(hass, entry, snapshot, "SPAN Panel") + device = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, snapshot.serial_number), entry.entry_id + ) + assert device is not None + return device + + +# --------------------------------------------------------------------------- +# The premise +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_everything_this_module_reads() -> None: + """Guard the premise, since every expectation below is read from the capture. + + The capture was eight identity properties behind the producer until it was + refreshed, and the shape of that gap is exactly what makes this guard worth + having: a test whose expected value comes from an unpublished topic does not + fail, it stops asserting anything. + """ + assert _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC) + assert _published(SCHEMA_ONE_PANEL, POLICY_TOPIC) + assert _published(SCHEMA_ONE_PANEL, VENDOR_TOPIC) + assert _published(SCHEMA_ONE_PANEL, MODEL_TOPIC) + assert _published(SCHEMA_ONE_PANEL, HARDWARE_TOPIC) + assert _published(EVSE, PART_NUMBER_TOPIC) + + +# --------------------------------------------------------------------------- +# The panel's device card +# --------------------------------------------------------------------------- + + +async def test_the_panel_card_shows_the_identity_the_panel_publishes( + hass: HomeAssistant, +) -> None: + """Manufacturer, model and hardware revision come off the wire, not a constant. + + Read back from the registry, and compared against the capture rather than + against literals: the model in particular is `MAIN_40`, which is also the + string `panel_size` is derived from, so an assertion written as a literal + would agree with the size lookup rather than with the panel. + """ + device = await _registered_panel(hass, _snapshot(), "entry-card-published") + + assert device.manufacturer == _published(SCHEMA_ONE_PANEL, VENDOR_TOPIC) + assert device.model == _published(SCHEMA_ONE_PANEL, MODEL_TOPIC) + assert device.hw_version == _published(SCHEMA_ONE_PANEL, HARDWARE_TOPIC) + assert device.sw_version == _published(SCHEMA_ONE_PANEL, FIRMWARE_TOPIC) + + +async def test_the_panel_card_follows_a_republished_identity(hass: HomeAssistant) -> None: + """The card tracks the wire, so nothing here is passing on a coincidence. + + `MAIN_32` is deliberately another real model: it keeps `panel_size` resolvable, + so the only thing the rewrite changes is the string on the card. + """ + rewritten = _snapshot( + info__vendor_name="Another Vendor", + info__model="MAIN_32", + info__hardware_version="rev9", + ) + + device = await _registered_panel(hass, rewritten, "entry-card-rewritten") + + assert device.manufacturer == "Another Vendor" + assert device.model == "MAIN_32" + assert device.hw_version == "rev9" + + +async def test_a_panel_publishing_no_identity_keeps_the_card_it_has_always_had( + hass: HomeAssistant, +) -> None: + """The fallbacks are the compatibility guarantee, not a courtesy. + + Flat firmware declares none of these three, so every existing installation + lands here. A panel that omits one must keep the row it has rather than + losing it -- and `hw_version`, which never had a string to fall back to, must + be absent rather than blank: `DeviceInfo` omits a `None` and renders an empty + string as a present-but-empty row. + """ + bare = _snapshot(info__vendor_name=None, info__model=None, info__hardware_version=None) + + device = await _registered_panel(hass, bare, "entry-card-bare") + + assert device.manufacturer == FALLBACK_MANUFACTURER + assert device.model == FALLBACK_MODEL + assert device.hw_version is None + + +def test_the_panel_identity_paths_are_enumerated_as_device_card_reads() -> None: + """`snapshot_to_device_info` is not an entity, so its reads are exempt residuals. + + Annotated `NEITHER` beside the `mid.*` device-card reads: flat declares none + of the three, and a schema_1 metadata row exists to carry a unit and a + datatype for a reading, which an identity string is not. Asserted here so the + three cannot quietly leave the inventory that is the only record of them. + """ + for path in ("panel.vendor_name", "panel.model", "panel.hardware_version"): + assert path in RESIDUAL_EXEMPT_PATHS, path + assert path not in declared_field_paths(), path + + +# --------------------------------------------------------------------------- +# `panel.wifi_ssid` -- the flat -> v1.0 regression +# --------------------------------------------------------------------------- + + +def test_the_ssid_moved_off_the_software_version_sensor() -> None: + """The old site, asserted absent — deliberately, and not coming back. + + A network name on a firmware-version sensor was incoherent; it only ever sat + there because `panel_size` was already occupying the attribute block. The + value is not lost, it moved: `test_the_wifi_link_sensor_carries_the_network_it_is_linked_to` + reads it back out of this same capture on the Wi-Fi Link binary sensor. + + Asserted against a snapshot that *does* publish an SSID, so restoring the + read fails here rather than passing on a panel that happens to carry none. + """ + attributes = _attributes(_snapshot(), SOFTWARE_VERSION_KEY) + + assert _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC) + assert "wifi_ssid" not in attributes + # The attribute block did not collapse; only the SSID left it. + assert "panel_size" in attributes + + +def test_the_ssid_is_a_declaration_now_rather_than_an_exemption() -> None: + """The gate's own ratchet, asserted where a reader will find it. + + Both adapters map `wifi_ssid`, so the path satisfies the producible gate and + `test_no_exempt_path_is_producible_by_both` refuses to let it stay exempt. + It is declared on the entity that reads it -- one entity, now that the read + has moved -- which is what lets a Repair name the entity a dead field takes + with it. + """ + assert "panel.wifi_ssid" not in RESIDUAL_EXEMPT_PATHS + assert "panel.wifi_ssid" in declared_field_paths() + assert "panel.wifi_ssid" in SpanPanelWifiLinkBinarySensor._residual_field_paths + assert "panel.wifi_ssid" not in SpanPanelStatus._residual_field_paths + + +async def test_the_wifi_link_sensor_carries_the_network_it_is_linked_to( + hass: HomeAssistant, +) -> None: + """The coherent host: the link sensor says which network the link is to. + + Read out of the capture rather than compared against a literal, so what is + under test is the whole route -- published topic, mapper, snapshot field, + attribute -- and not a constant the code also holds. + """ + sensors = await _binary_sensors(hass, _snapshot()) + + attributes = sensors[SYSTEM_WIFI_LINK].extra_state_attributes + + assert attributes == {"wifi_ssid": _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC)} + + +async def test_the_wifi_link_attribute_follows_a_republished_ssid( + hass: HomeAssistant, +) -> None: + """A panel that joins another network says so, which a hardcoded value never could.""" + sensors = await _binary_sensors(hass, _snapshot(status__wifi_ssid="another-network")) + + assert sensors[SYSTEM_WIFI_LINK].extra_state_attributes == {"wifi_ssid": "another-network"} + + +async def test_an_unpublished_ssid_leaves_the_wifi_link_attribute_off_entirely( + hass: HomeAssistant, +) -> None: + """Absent, not `None`. A present-but-empty attribute reads as a failed reading.""" + sensors = await _binary_sensors(hass, _snapshot(status__wifi_ssid=None)) + + assert sensors[SYSTEM_WIFI_LINK].extra_state_attributes is None + + +async def test_only_the_wifi_link_sensor_declares_the_ssid_it_reads( + hass: HomeAssistant, +) -> None: + """The reason the Wi-Fi link gets an entity class of its own. + + `_residual_field_paths` is a `ClassVar` and one class serves every panel + binary sensor, so declaring the SSID on that base class would claim the door + sensor reads it. That is not cosmetic: the declaration is what a Repair + consults to name the entities a dead field took down with it, so an + unresolved `panel.wifi_ssid` would name the door. + """ + sensors = await _binary_sensors(hass, _snapshot()) + wifi_link = sensors[SYSTEM_WIFI_LINK] + door = sensors[SYSTEM_DOOR_STATE] + + assert "panel.wifi_ssid" in type(wifi_link)._residual_field_paths + assert "panel.wifi_ssid" in wifi_link._declared_field_paths() + + assert "panel.wifi_ssid" not in type(door)._residual_field_paths + assert "panel.wifi_ssid" not in door._declared_field_paths() + + +# --------------------------------------------------------------------------- +# `shed/policy` -- attributes on `dsm_state` +# --------------------------------------------------------------------------- + + +def test_the_shed_policy_reaches_dsm_state_as_its_two_thresholds() -> None: + """The numbers that make the panel's shed behaviour predictable. + + Compared against the document the capture publishes rather than against + literals, so the parse is checked against the producer's own encoding of it. + """ + document = json.loads(_published(SCHEMA_ONE_PANEL, POLICY_TOPIC)) + attributes = _attributes(_snapshot(), DSM_STATE_KEY) + + assert attributes["shed_algorithm"] == document["algorithm"] + assert attributes["soc_threshold_shed"] == document["parameters"]["soc-threshold-shed"] + assert attributes["soc_threshold_release"] == document["parameters"]["soc-threshold-release"] + # Fully parsed, so the raw document adds nothing a user could act on. + assert "shed_policy" not in attributes + + +def test_the_thresholds_follow_a_republished_policy() -> None: + """A panel reconfigured to shed later says so.""" + rewritten = json.dumps( + { + "algorithm": "soc-priority.v1", + "parameters": {"soc-threshold-shed": 5, "soc-threshold-release": 15}, + } + ) + + attributes = _attributes(_snapshot(shed__policy=rewritten), DSM_STATE_KEY) + + assert attributes["soc_threshold_shed"] == 5 + assert attributes["soc_threshold_release"] == 15 + + +def test_an_unknown_algorithm_degrades_to_the_raw_document() -> None: + """The policy schema is versioned in its own `$id`, so another algorithm may arrive. + + Reporting `soc-priority.v1`'s thresholds for a document that never had them + would be worse than reporting nothing, and raising would take the sensor + down. Naming the algorithm and showing the document is what a user can act on. + """ + other = json.dumps({"algorithm": "runtime-priority.v2", "parameters": {"minutes-shed": 30}}) + + attributes = _attributes(_snapshot(shed__policy=other), DSM_STATE_KEY) + + assert attributes["shed_algorithm"] == "runtime-priority.v2" + assert attributes["shed_policy"] == other + assert "soc_threshold_shed" not in attributes + assert "soc_threshold_release" not in attributes + + +def test_an_unparseable_policy_still_leaves_the_sensor_standing() -> None: + """A panel is a publisher this integration does not control. + + One malformed string must not take `dsm_state` -- the sensor a button reads + to decide whether the panel is already on grid -- down with it. + """ + sensor = _panel_sensors(_snapshot(shed__policy="{not json"))[DSM_STATE_KEY] + sensor._update_native_value() + + assert sensor.native_value is not None + assert (sensor.extra_state_attributes or {}) == {"shed_policy": "{not json"} + + +def test_a_panel_publishing_no_policy_reports_no_policy_attributes() -> None: + """`dsm_state` keeps its state and simply carries nothing extra.""" + sensor = _panel_sensors(_snapshot(shed__policy=None))[DSM_STATE_KEY] + sensor._update_native_value() + + assert sensor.native_value is not None + assert sensor.extra_state_attributes is None + + +def test_the_policy_attributes_hang_off_dsm_state_and_nothing_else() -> None: + """One sensor's attributes, not every sensor this class renders. + + `SpanPanelPanelStatus` renders the relay state and the run config too, and a + shed policy repeated on each of them is noise on three cards. + """ + sensors = _panel_sensors(_snapshot()) + carrying = { + key + for key, sensor in sensors.items() + if isinstance(sensor, SpanPanelPanelStatus) + and "shed_algorithm" in (sensor.extra_state_attributes or {}) + } + + assert carrying == {DSM_STATE_KEY} + + +# --------------------------------------------------------------------------- +# `evse.part_number` -- the promotion the producible gate demanded +# --------------------------------------------------------------------------- + + +def _evse_states(snapshot: SpanPanelSnapshot, key: str) -> set[Any]: + """What every charger's sensor of this key reports.""" + states: set[Any] = set() + for sensor in create_evse_sensors(_coordinator(snapshot), snapshot): + if sensor.entity_description.key != key: + continue + sensor._update_native_value() + states.add(sensor.native_value) + return states + + +def test_the_charger_reports_the_part_number_the_panel_publishes() -> None: + """The BESS has shown its SKU since it shipped; the charger beside it had none.""" + assert _evse_states(_snapshot(), EVSE_PART_NUMBER_KEY) == {_published(EVSE, PART_NUMBER_TOPIC)} + + +def test_the_part_number_follows_a_republished_value() -> None: + """Two chargers, one rewritten, so the sensor cannot be reading a constant. + + The capture publishes the same SKU on both, which is what makes this the + mutation worth running: a rewrite of one has to show up as two distinct + states rather than as one. + """ + states = _evse_states(_evse_snapshot(info__part_number="SPN-DRV-999"), EVSE_PART_NUMBER_KEY) + + assert states == {"SPN-DRV-999", _published(EVSE, PART_NUMBER_TOPIC)} + + +def test_a_charger_publishing_no_part_number_reports_unknown_rather_than_a_default() -> None: + """An unpublished SKU is unknown, and no charger invents one for another. + + `STATE_UNKNOWN` rather than `None` because the platform renders a + non-numeric sensor's absent value that way, which is how `bess_part_number` + has always behaved on a BESS that publishes none. The other charger keeps + its value in the same breath, so this is one charger going quiet rather than + the sensor failing. + """ + states = _evse_states(_evse_snapshot(info__part_number=None), EVSE_PART_NUMBER_KEY) + + assert states == {STATE_UNKNOWN, _published(EVSE, PART_NUMBER_TOPIC)} + + +def test_the_part_number_is_a_plain_declaration_on_both_adapters() -> None: + """The promotion this task's schema_1 metadata row demanded. + + Flat has mapped `evse/part-number` all along; adding the v1.0 row made the + path producible by both, and a both-producible path is a declaration rather + than an exemption. Diagnostic and off by default, matching `bess_part_number` + -- build metadata is not something a user wants on a card by default. + """ + (description,) = [d for d in EVSE_SENSORS if d.key == EVSE_PART_NUMBER_KEY] + + assert description.field_path == "evse.part_number" + assert description.derived is None + assert description.entity_registry_enabled_default is False + assert "evse.part_number" in declared_field_paths() + assert "evse.part_number" not in RESIDUAL_EXEMPT_PATHS diff --git a/tests/test_mid_device.py b/tests/test_mid_device.py new file mode 100644 index 00000000..ac203365 --- /dev/null +++ b/tests/test_mid_device.py @@ -0,0 +1,198 @@ +"""The Microgrid Interconnect Device, surfaced as its own device. + +v1.0 publishes a MID and puts the `grid` capability on it rather than on the enclosure. +Everything here is additive: no flat panel publishes a MID, so `has_mid` is false on +every existing install and nothing a user has today changes. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +from span_panel_api import SpanMidSnapshot + +from custom_components.span_panel.helpers import detect_capabilities, has_mid +from custom_components.span_panel.sensor import create_mid_sensors +from custom_components.span_panel.sensor_definitions import MID_SENSORS +from custom_components.span_panel.sensor_panel import _grid_forming_device_name +from custom_components.span_panel.util import mid_device_info + +from .factories import SpanPanelSnapshotFactory + + +def _mid(**overrides: str | None) -> SpanMidSnapshot: + defaults: dict[str, str | None] = { + "node_id": "SIM-BESS-40T-001-mid", + "serial_number": "SIM-BESS-40T-001-mid", + "vendor_name": "Span", + "model": None, + "islanding_state": "ON_GRID", + "grid_state": "UP", + "grid_forming_entity": "GRID", + } + defaults.update(overrides) + return SpanMidSnapshot(**defaults) # type: ignore[arg-type] + + +def _coordinator() -> MagicMock: + coordinator = MagicMock() + coordinator.config_entry = MagicMock() + coordinator.config_entry.title = "SPAN Panel" + coordinator.config_entry.data = {} + return coordinator + + +def test_a_flat_panel_has_no_mid() -> None: + """The whole reason this is additive rather than a migration risk.""" + assert has_mid(SpanPanelSnapshotFactory.create()) is False + assert create_mid_sensors(_coordinator(), SpanPanelSnapshotFactory.create()) == [] + + +def test_presence_needs_no_sentinel() -> None: + """`snapshot.mid is not None`, unlike `has_bess`, which infers from soe_percentage + because the battery field is always present. + """ + assert has_mid(SpanPanelSnapshotFactory.create(mid=_mid())) is True + + +def test_the_mid_becomes_its_own_device_hung_off_the_panel() -> None: + """Identity renders on a device card rather than being folded onto the panel. + + Linked to the panel even though the wire tree makes the MID a child of the + BESS: Home Assistant's device graph is what a user navigates, and every SPAN + sub-device hangs off the panel there. + """ + info = mid_device_info( + "sim-40t-001", _mid(), "SPAN Panel", panel_device_id="panel-device-id" + ) + + assert info["identifiers"] == {("span_panel", "sim-40t-001_mid")} + assert info["name"] == "SPAN Panel Microgrid Interconnect" + assert info["manufacturer"] == "Span" + assert info["serial_number"] == "SIM-BESS-40T-001-mid" + assert info["via_device_id"] == "panel-device-id" + # A panel that publishes no MID model still needs a legible card. This was written + # when no producer published one at all; panelbench does now, so this is the + # fallback path rather than the only path — `test_the_mid_card_carries_the_identity + # _a_producer_publishes` covers the other. + assert info["model"] == "Microgrid Interconnect Device" + assert "sw_version" not in info or info["sw_version"] is None + assert "hw_version" not in info or info["hw_version"] is None + + +def test_the_mid_carries_grid_state_and_nothing_already_surfaced() -> None: + """Utility-supply health is genuinely new; islanding state is not. + + `dsm_state` and `grid_forming_entity` already reach a user from the panel and must + keep their ids and history. Duplicating them here would show the same fact twice, + which is not the benign cell of the absorb-or-surface policy — adding a fact nobody + had is. + """ + keys = {desc.key for desc in MID_SENSORS} + + assert keys == {"mid_grid_state"} + + +def test_grid_state_is_lowercased_into_its_enum_options() -> None: + """Home Assistant validates an ENUM sensor against `options`, and the wire sends + `UP` / `DOWN` / `DEGRADED`. + """ + (grid_state,) = MID_SENSORS + + assert grid_state.value_fn(_mid(grid_state="UP")) == "up" + assert grid_state.value_fn(_mid(grid_state="DEGRADED")) == "degraded" + # A MID mid-discovery has a description and no values yet. + assert grid_state.value_fn(_mid(grid_state=None)) == "unknown" + assert set(grid_state.options or []) >= {"up", "down", "degraded", "unknown"} + + +def test_a_mid_appearing_is_a_capability_change() -> None: + """The coordinator reloads on a new capability, which is how the device and its + sensors get created on a panel that gains a MID mid-life. + """ + without = detect_capabilities(SpanPanelSnapshotFactory.create()) + with_mid = detect_capabilities(SpanPanelSnapshotFactory.create(mid=_mid())) + + assert "mid" not in without + assert "mid" in with_mid + + +def test_every_schema_conditional_is_findable() -> None: + """The integration serves flat and parent/child side by side until every panel has + hot-loaded v1.0, and the branches that make that work have to be findable when the + flat path is finally retired. + + Asserted rather than left to a convention, because the failure mode is silent: a + later addition that assumes parent/child would work on the developer's panel and + break on everyone else's, and nothing would say so. + """ + component = Path(__file__).resolve().parent.parent / "custom_components" / "span_panel" + marked = sorted( + path.name for path in component.rglob("*.py") if "DUAL-SCHEMA" in path.read_text() + ) + + assert marked == ["binary_sensor.py", "helpers.py", "sensor.py", "sensor_panel.py"], ( + "the set of schema-conditional modules moved. If a conditional was added, mark it " + "DUAL-SCHEMA so it can be found when flat support is dropped; if one was removed, " + "update this list." + ) + + +def test_nothing_reads_the_mid_without_checking_it_is_there() -> None: + """A flat snapshot must survive every MID code path untouched. + + This is the constraint that matters more than any single feature: the integration + has to keep working on a panel that will never publish a MID, for as long as such + panels exist. + """ + flat = SpanPanelSnapshotFactory.create() + + assert flat.mid is None + assert has_mid(flat) is False + assert create_mid_sensors(_coordinator(), flat) == [] + assert _grid_forming_device_name(flat) is None + assert "mid" not in detect_capabilities(flat) + + +def test_the_mid_card_carries_the_identity_a_producer_publishes() -> None: + """Model, firmware and hardware revision reach the device card when published. + + r202633 documents all three on the MID's `info` node. Until the library carried the + latter two, `mid_device_info` could set a model and a serial and nothing else, so a + user saw a Microgrid Interconnect card with no firmware row beside a battery that + had one — the battery's identical property having been mapped from the start. + + Not gated on schema. Flat publishes no MID at all, so `has_mid` keeps every caller + of this builder off a flat panel; a guard here would be unreachable code implying a + case that cannot arise. + """ + info = mid_device_info( + "sim-40t-001", + _mid(model="SPAN MID", software_version="sim-mid/v0.1.0", hardware_version="rev1"), + "SPAN Panel", + panel_device_id="panel-device-id", + ) + + assert info["model"] == "SPAN MID" + assert info["sw_version"] == "sim-mid/v0.1.0" + assert info["hw_version"] == "rev1" + + +def test_an_unpublished_revision_omits_the_row_rather_than_blanking_it() -> None: + """`None` and `""` are different to a user, so the library's distinction is kept. + + `DeviceInfo` omits a `None` field and renders an empty string as a present-but-blank + row. Defaulting with `or ""` here would invent a firmware row reading empty for a + panel that published nothing, which is worse than no row: it asserts the panel + answered and the answer was nothing. + """ + info = mid_device_info( + "sim-40t-001", + _mid(software_version=None, hardware_version=None), + "SPAN Panel", + panel_device_id="panel-device-id", + ) + + assert info.get("sw_version") is None + assert info.get("hw_version") is None diff --git a/tests/test_naming.py b/tests/test_naming.py new file mode 100644 index 00000000..edfbb99f --- /dev/null +++ b/tests/test_naming.py @@ -0,0 +1,269 @@ +"""The base Home Assistant composes a circuit entity's id from.""" + +from __future__ import annotations + +import pytest + +from custom_components.span_panel.naming import ( + ENTITY_ID_SUFFIX_FORMS, + NEW_ENTITY_ID_SUFFIX_WORDS, + circuit_object_id_base, +) + + +@pytest.mark.parametrize( + ("identifier", "suffix", "expected"), + [ + ("Circuit 15", "power", "Circuit 15 power"), + ("Circuit 30 32", "energy_consumed", "Circuit 30 32 consumed energy"), + ("Kitchen Outlets", "energy_produced", "Kitchen Outlets produced energy"), + ("Kitchen Outlets", "energy_net", "Kitchen Outlets net energy"), + ("Kitchen Outlets", "current", "Kitchen Outlets current"), + ("Kitchen Outlets", "breaker_rating", "Kitchen Outlets breaker rating"), + ("Kitchen Outlets", "breaker", "Kitchen Outlets breaker"), + ("Kitchen Outlets", "circuit_priority", "Kitchen Outlets circuit priority"), + ("Unmapped Tab 32", "energy_consumed", "Unmapped Tab 32 consumed energy"), + ], +) +def test_a_new_entity_gets_the_noun_last_wording( + identifier: str, suffix: str, expected: str +) -> None: + assert circuit_object_id_base(identifier, suffix, None) == expected + + +@pytest.mark.parametrize( + ("existing", "suffix", "expected"), + [ + # Preset-era spelling is kept, so Recreate proposes the entity its own id. + ( + "sensor.span_panel_circuit_15_energy_consumed", + "energy_consumed", + "Circuit 15 energy consumed", + ), + ( + "sensor.span_panel_circuit_15_energy_produced", + "energy_produced", + "Circuit 15 energy produced", + ), + ("sensor.span_panel_circuit_15_energy_net", "energy_net", "Circuit 15 energy net"), + # Pre-preset spelling is kept too. + ( + "sensor.span_panel_circuit_15_consumed_energy", + "energy_consumed", + "Circuit 15 consumed energy", + ), + # The oldest power spelling. + ("sensor.span_panel_circuit_15_current_power", "power", "Circuit 15 current power"), + # A suffix with one form only. + ("switch.span_panel_circuit_15_breaker", "breaker", "Circuit 15 breaker"), + ], +) +def test_an_existing_entity_keeps_the_suffix_wording_it_has( + existing: str, suffix: str, expected: str +) -> None: + assert circuit_object_id_base("Circuit 15", suffix, existing) == expected + + +def test_an_existing_entity_whose_id_matches_no_known_form_gets_the_default() -> None: + assert ( + circuit_object_id_base("Circuit 15", "energy_consumed", "sensor.something_else") + == "Circuit 15 consumed energy" + ) + + +def test_the_words_are_omitted_when_the_identifier_already_ends_with_them() -> None: + """Preserves the preset builder's rule: a circuit named "Solar Power" got `..._solar_power`.""" + assert circuit_object_id_base("Solar Power", "power", None) == "Solar Power" + assert ( + circuit_object_id_base("Solar Power", "power", "sensor.span_panel_solar_power") + == "Solar Power" + ) + + +def test_an_identifier_that_is_only_the_suffix_word_keeps_both_halves() -> None: + """The builder's test was `endswith(f"_{suffix}")`, underscore included. + + A circuit named exactly "Power" therefore got `..._power_power`, because + "power" does not end with "_power" and there was nothing to omit. Omitting + it here would offer every such circuit a rename to `..._power` -- a move the + user did not cause, which R1 forbids. + """ + assert circuit_object_id_base("Power", "power", None) == "Power power" + assert ( + circuit_object_id_base("Power", "power", "sensor.span_panel_power_power") + == "Power power" + ) + + +def test_a_renamed_circuit_takes_the_new_name_and_reads_the_suffix_from_its_id() -> None: + """Issue #252 at the layer that decides it: only the name half follows the panel. + + The identifier is the circuit's *current* name; the id it already carries is + consulted for nothing but which spelling of the suffix it shipped with. So a + circuit renamed in the SPAN app is proposed the new name and the same suffix. + """ + assert ( + circuit_object_id_base("Kitchen", "power", "sensor.span_panel_kitchen_outlets_power") + == "Kitchen power" + ) + + +@pytest.mark.parametrize( + ("identifier", "suffix", "existing", "expected"), + [ + # A circuit named "Current". The id's trailing `_current_power` is the + # identifier followed by `power`, not the `current_power` spelling of the + # suffix -- reading it as the latter proposed `..._current_current_power`. + ("Current", "power", "sensor.span_panel_current_power", "Current power"), + # A circuit named "Solar Power" whose id kept both halves. The omission + # rule looks only at the identifier, so it proposed `..._solar_power` and + # offered every such circuit a rename. + ("Solar Power", "power", "sensor.span_panel_solar_power_power", "Solar Power power"), + # The same circuit on an install whose id did omit them. + ("Solar Power", "power", "sensor.span_panel_solar_power", "Solar Power"), + # Both, on an install with no device prefix: the identifier opens the + # object id rather than following an underscore. + ("Solar Power", "power", "sensor.solar_power_power", "Solar Power power"), + ("Solar Power", "power", "sensor.solar_power", "Solar Power"), + # A circuit named exactly "Power", which never omitted anything. + ("Power", "power", "sensor.span_panel_power_power", "Power power"), + ], +) +def test_the_suffix_is_read_back_from_what_follows_the_identifier( + identifier: str, suffix: str, existing: str, expected: str +) -> None: + """Where the id names this circuit, what follows the name settles both halves. + + Both halves, because they are one question: an id that spells the identifier + and then a known form carries that form and omitted nothing, and an id that + stops at the identifier omitted the form. Reading the suffix by a plain + `endswith` and then deciding omission from the identifier alone answered + each half without the other, and disagreed with the id in both directions. + """ + assert circuit_object_id_base(identifier, suffix, existing) == expected + + +def test_every_form_table_entry_has_a_new_wording_and_vice_versa() -> None: + assert set(ENTITY_ID_SUFFIX_FORMS) == set(NEW_ENTITY_ID_SUFFIX_WORDS) + for suffix, words in NEW_ENTITY_ID_SUFFIX_WORDS.items(): + assert words.replace(" ", "_") in ENTITY_ID_SUFFIX_FORMS[suffix], ( + f"the default wording for {suffix} must itself be a known form" + ) + + +@pytest.mark.parametrize( + ("identifier", "suffix", "expected"), + [ + ("Kitchen Outlets", "power", "Kitchen Outlets power"), + ("Circuit 15", "power", "Circuit 15 power"), + ("Circuit 15 17", "power", "Circuit 15 17 power"), + ("Circuit 30 32", "power", "Circuit 30 32 power"), + ], +) +def test_the_id_shapes_the_upgrade_scenarios_pinned( + identifier: str, suffix: str, expected: str +) -> None: + """The four shapes the deleted preset builder's own tests pinned. + + A friendly name, a single-pole breaker and both two-pole spellings. The + builder produced the whole id and so had to be told the device prefix and + the naming mode; here the caller has already resolved the identifier and + only the base is at stake, which is the same string with the device part + left to Home Assistant. + """ + assert circuit_object_id_base(identifier, suffix, None) == expected + + +# --- Releasing the registry name an older release wrote ---------------------- + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.naming import release_registry_name_written_by_older_release + + +def _seed(hass: HomeAssistant, name: str | None) -> str: + registry = er.async_get(hass) + entry = registry.async_get_or_create("sensor", DOMAIN, "uid-release-test") + registry.async_update_entity(entry.entity_id, name=name) + return entry.entity_id + + +async def test_a_name_an_older_release_wrote_is_released(hass: HomeAssistant) -> None: + entity_id = _seed(hass, "Kitchen Outlets Consumed Energy") + registry = er.async_get(hass) + + release_registry_name_written_by_older_release( + registry, entity_id, "Kitchen Outlets", ("Consumed Energy",) + ) + + assert registry.async_get(entity_id).name is None + + +async def test_a_name_written_under_a_since_changed_label_is_released_too( + hass: HomeAssistant, +) -> None: + """The label was "Energy Consumed" for a few betas; a legacy-names tuple covers it.""" + entity_id = _seed(hass, "Kitchen Outlets Energy Consumed") + registry = er.async_get(hass) + + release_registry_name_written_by_older_release( + registry, entity_id, "Kitchen Outlets", ("Consumed Energy", "Energy Consumed") + ) + + assert registry.async_get(entity_id).name is None + + +async def test_a_name_the_user_set_is_left_alone(hass: HomeAssistant) -> None: + entity_id = _seed(hass, "Beer Fridge") + registry = er.async_get(hass) + + release_registry_name_written_by_older_release( + registry, entity_id, "Kitchen Outlets", ("Consumed Energy",) + ) + + assert registry.async_get(entity_id).name == "Beer Fridge" + + +async def test_no_name_is_a_no_op(hass: HomeAssistant) -> None: + entity_id = _seed(hass, None) + registry = er.async_get(hass) + + release_registry_name_written_by_older_release( + registry, entity_id, "Kitchen Outlets", ("Consumed Energy",) + ) + + assert registry.async_get(entity_id).name is None + + +async def test_an_unknown_entity_is_a_no_op(hass: HomeAssistant) -> None: + registry = er.async_get(hass) + release_registry_name_written_by_older_release( + registry, "sensor.does_not_exist", "Kitchen Outlets", ("Consumed Energy",) + ) + + +from unittest.mock import MagicMock + +from custom_components.span_panel.entity import SpanPanelEntity + + +class _Probe(SpanPanelEntity): + _attr_name = "Display Name" + + def __init__(self) -> None: + coordinator = MagicMock() + super().__init__(coordinator) + + +def test_the_base_is_what_core_is_told_when_one_is_set() -> None: + probe = _Probe() + probe._span_object_id_base = "Circuit 15 power" + assert probe.suggested_object_id == "Circuit 15 power" + + +def test_without_a_base_core_gets_the_stock_answer() -> None: + """Panel, BESS, EVSE and MID entities keep composing from their name.""" + probe = _Probe() + assert probe.suggested_object_id == "Display Name" diff --git a/tests/test_net_energy_fanout.py b/tests/test_net_energy_fanout.py new file mode 100644 index 00000000..fe3485fe --- /dev/null +++ b/tests/test_net_energy_fanout.py @@ -0,0 +1,320 @@ +"""Net Energy reads its siblings' dip offsets on the very update that books them. + +Through the real coordinator fan-out, with the entities built by the platform's +own factories in production order -- never in an order the test chooses, since +the order is what is under test. Net is created after Produced and Consumed, +listeners run in the order they were added, and so Net sees this update's +offsets rather than the previous update's. A PanelBench restart, which resets +every counter to zero, is the dip this models. +""" + +from __future__ import annotations + +import logging +from typing import Final +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockEntityPlatform +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.sensor import create_native_sensors +from custom_components.span_panel.sensor_base import SpanEnergySensorBase +from custom_components.span_panel.sensor_circuit import SpanCircuitEnergySensor + +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory, pv_binding_for + +PV: Final = "pv-circuit" +LOAD: Final = "load-circuit" +SERIAL: Final = "sp3-fanout-001" + + +@pytest.fixture(autouse=True) +def expected_lingering_timers() -> bool: + """The real coordinator schedules its fallback poll on every pushed update.""" + return True + + +def _snapshot( + *, + pv: tuple[float, float], + load: tuple[float, float], + main: tuple[float, float], + feed: tuple[float, float], +) -> SpanPanelSnapshot: + """Each pair is (produced, consumed).""" + return SpanPanelSnapshotFactory.create( + serial_number=SERIAL, + circuits={ + PV: SpanCircuitSnapshotFactory.create( + circuit_id=PV, + name="Solar", + device_type="pv", + tabs=[1, 3], + produced_energy_wh=pv[0], + consumed_energy_wh=pv[1], + ), + LOAD: SpanCircuitSnapshotFactory.create( + circuit_id=LOAD, + name="Kitchen", + tabs=[2], + produced_energy_wh=load[0], + consumed_energy_wh=load[1], + ), + }, + main_meter_energy_produced_wh=main[0], + main_meter_energy_consumed_wh=main[1], + feedthrough_energy_produced_wh=feed[0], + feedthrough_energy_consumed_wh=feed[1], + ) + + +BEFORE: Final = _snapshot(pv=(1000.0, 40.0), load=(30.0, 500.0), main=(300.0, 2500.0), feed=(20.0, 80.0)) +# Every counter dropped by at least 1 Wh, as a PanelBench restart drops them all. +AFTER: Final = _snapshot(pv=(50.0, 4.0), load=(3.0, 20.0), main=(10.0, 100.0), feed=(1.0, 5.0)) + + +def _key(entity: SpanEnergySensorBase[object, object]) -> str: + if isinstance(entity, SpanCircuitEnergySensor): + return f"{entity.circuit_id}:{entity.original_key}" + return entity.entity_description.key + + +def _entry(hass: HomeAssistant, *, compensate: bool = True) -> MockConfigEntry: + entry = MockConfigEntry( + domain=DOMAIN, + data={"device_name": "SPAN Panel"}, + options={ENABLE_ENERGY_DIP_COMPENSATION: compensate}, + title="SPAN Panel", + unique_id=SERIAL, + ) + entry.add_to_hass(hass) + return entry + + +async def _built( + hass: HomeAssistant, *, compensate: bool = True +) -> tuple[SpanPanelCoordinator, dict[str, SpanEnergySensorBase[object, object]]]: + coordinator, energy, _ = await _set_up(hass, _entry(hass, compensate=compensate)) + return coordinator, energy + + +async def _set_up( + hass: HomeAssistant, entry: MockConfigEntry +) -> tuple[SpanPanelCoordinator, dict[str, SpanEnergySensorBase[object, object]], MockEntityPlatform]: + """Build the entry's sensors with the platform's own factories and add them, as a (re)load does.""" + coordinator = SpanPanelCoordinator(hass, MagicMock(), entry) + coordinator.data = BEFORE + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(BEFORE), + setup_snapshot=BEFORE, + ) + entities = create_native_sensors(coordinator, BEFORE, entry) + + # Main Meter Net and the Feed Through sensors are disabled by default; the + # rehearsal enables them, and so does this, before the platform sees them. + registry = er.async_get(hass) + for entity in entities: + if entity.unique_id is not None and entity.entity_registry_enabled_default is False: + registry.async_get_or_create( + "sensor", DOMAIN, entity.unique_id, config_entry=entry, disabled_by=None + ) + + platform = MockEntityPlatform( + hass, domain="sensor", platform_name=DOMAIN, logger=logging.getLogger(__name__) + ) + platform.config_entry = entry + await platform.platform_data.async_load_translations() + await platform.async_add_entities(entities) + + energy = {_key(e): e for e in entities if isinstance(e, SpanEnergySensorBase)} + return coordinator, energy, platform + + +def _reading(energy: dict[str, SpanEnergySensorBase[object, object]], key: str) -> float: + value = energy[key].native_value + assert isinstance(value, float), (key, value) + return value + + +# Each compensated Net with the sibling it credits and the one it debits. +NETS: Final = ( + (f"{PV}:circuit_energy_net", f"{PV}:circuit_energy_produced", f"{PV}:circuit_energy_consumed"), + (f"{LOAD}:circuit_energy_net", f"{LOAD}:circuit_energy_consumed", f"{LOAD}:circuit_energy_produced"), + ("mainMeterNetEnergyWh", "mainMeterEnergyConsumedWh", "mainMeterEnergyProducedWh"), +) + + +def _entity_ids(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, str]: + """Every registered entity of the entry, unique id to entity id.""" + return { + row.unique_id: row.entity_id + for row in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) + } + + +def _nets(energy: dict[str, SpanEnergySensorBase[object, object]]) -> dict[str, float]: + return {net: _reading(energy, net) for net, _, _ in NETS} + + +def _assert_every_net_on_its_siblings(energy: dict[str, SpanEnergySensorBase[object, object]]) -> None: + for net, credit, debit in NETS: + assert _reading(energy, net) == pytest.approx( + _reading(energy, credit) - _reading(energy, debit) + ), net + + +async def test_net_reads_this_updates_offsets_on_the_update_that_books_them(hass: HomeAssistant) -> None: + coordinator, energy = await _built(hass) + coordinator.async_set_updated_data(BEFORE) + + coordinator.async_set_updated_data(AFTER) + + # Compensated counters continue from where they were, with no drop. + assert _reading(energy, f"{PV}:circuit_energy_produced") == pytest.approx(1000.0) + assert _reading(energy, f"{PV}:circuit_energy_consumed") == pytest.approx(40.0) + assert _reading(energy, "mainMeterEnergyConsumedWh") == pytest.approx(2500.0) + # On this same update, every Net is its compensated difference. + assert _reading(energy, f"{PV}:circuit_energy_net") == pytest.approx( + _reading(energy, f"{PV}:circuit_energy_produced") - _reading(energy, f"{PV}:circuit_energy_consumed") + ) + assert _reading(energy, f"{LOAD}:circuit_energy_net") == pytest.approx( + _reading(energy, f"{LOAD}:circuit_energy_consumed") - _reading(energy, f"{LOAD}:circuit_energy_produced") + ) + assert _reading(energy, "mainMeterNetEnergyWh") == pytest.approx( + _reading(energy, "mainMeterEnergyConsumedWh") - _reading(energy, "mainMeterEnergyProducedWh") + ) + + +async def test_feed_through_net_stays_the_raw_difference(hass: HomeAssistant) -> None: + coordinator, energy = await _built(hass) + coordinator.async_set_updated_data(BEFORE) + + coordinator.async_set_updated_data(AFTER) + + assert _reading(energy, "feedthroughNetEnergyWh") == pytest.approx(5.0 - 1.0) + assert energy["feedthroughEnergyProducedWh"].energy_offset == 0.0 + assert energy["feedthroughEnergyConsumedWh"].energy_offset == 0.0 + + +async def test_net_is_the_raw_difference_with_dip_compensation_off(hass: HomeAssistant) -> None: + """Review Focus 1.""" + coordinator, energy = await _built(hass, compensate=False) + coordinator.async_set_updated_data(BEFORE) + + coordinator.async_set_updated_data(AFTER) + + assert _reading(energy, f"{PV}:circuit_energy_net") == pytest.approx(50.0 - 4.0) + assert _reading(energy, f"{LOAD}:circuit_energy_net") == pytest.approx(20.0 - 3.0) + assert _reading(energy, "mainMeterNetEnergyWh") == pytest.approx(100.0 - 10.0) + + +# --------------------------------------------------------------------------- +# A dip that is taken back, a restart, and an option changed before a reload +# --------------------------------------------------------------------------- + +# Every counter at zero, as a replay that has not yet delivered the readings. +ZERO: Final = _snapshot(pv=(0.0, 0.0), load=(0.0, 0.0), main=(0.0, 0.0), feed=(0.0, 0.0)) +# Back above where they were: the drop was an artefact, and every dip retracts. +BACK: Final = _snapshot(pv=(1001.0, 41.0), load=(31.0, 501.0), main=(301.0, 2501.0), feed=(21.0, 81.0)) +# Counting up from AFTER, and once more from there. +LATER: Final = _snapshot(pv=(55.0, 5.0), load=(4.0, 22.0), main=(11.0, 110.0), feed=(1.5, 6.0)) +LATER_STILL: Final = _snapshot(pv=(60.0, 6.0), load=(5.0, 24.0), main=(12.0, 120.0), feed=(2.0, 7.0)) + + +async def test_net_stays_flat_when_a_booked_dip_is_retracted(hass: HomeAssistant) -> None: + """Audit P1: the offset goes on and comes off on the updates the siblings book and retract it.""" + coordinator, energy = await _built(hass) + coordinator.async_set_updated_data(BEFORE) + before = _nets(energy) + + coordinator.async_set_updated_data(ZERO) + + assert energy[f"{PV}:circuit_energy_produced"].energy_offset == pytest.approx(1000.0) + assert _nets(energy) == pytest.approx(before) + _assert_every_net_on_its_siblings(energy) + + coordinator.async_set_updated_data(BACK) + + assert energy[f"{PV}:circuit_energy_produced"].energy_offset == 0.0 + assert energy["mainMeterEnergyConsumedWh"].energy_offset == 0.0 + assert _nets(energy) == pytest.approx(before) + _assert_every_net_on_its_siblings(energy) + + +async def test_restored_offsets_reach_nets_first_update_after_a_restart(hass: HomeAssistant) -> None: + """Audit P2: through the real platform add order, a restart moves no Net.""" + entry = _entry(hass) + coordinator, energy, platform = await _set_up(hass, entry) + coordinator.async_set_updated_data(BEFORE) + coordinator.async_set_updated_data(AFTER) + before_restart = _nets(energy) + assert energy["mainMeterEnergyConsumedWh"].energy_offset == pytest.approx(2400.0) + entity_ids = _entity_ids(hass, entry) + assert len(entity_ids) >= len(energy) + + # Removing the entities hands their state and dip records to the restore + # cache, which the next set of entities reads as they are added. + await platform.async_reset() + coordinator, energy, _ = await _set_up(hass, entry) + coordinator.async_set_updated_data(AFTER) + + assert _entity_ids(hass, entry) == entity_ids + assert energy["mainMeterEnergyConsumedWh"].energy_offset == pytest.approx(2400.0) + assert _nets(energy) == pytest.approx(before_restart) + _assert_every_net_on_its_siblings(energy) + + +async def test_compensation_turned_off_before_a_reload_leaves_every_net_on_its_siblings( + hass: HomeAssistant, +) -> None: + """Audit R2: the counters stop compensating at once, so their Nets must stop too. + + While Home Assistant is starting, an options change waits for the reload + that applies it, and the sensors re-read the option on every update. + """ + coordinator, energy = await _built(hass) + coordinator.async_set_updated_data(BEFORE) + coordinator.async_set_updated_data(AFTER) + assert energy["mainMeterEnergyConsumedWh"].energy_offset == pytest.approx(2400.0) + + hass.config_entries.async_update_entry( + coordinator.config_entry, options={ENABLE_ENERGY_DIP_COMPENSATION: False} + ) + coordinator.async_set_updated_data(LATER) + + assert _reading(energy, "mainMeterEnergyConsumedWh") == pytest.approx(110.0) + assert _reading(energy, "mainMeterNetEnergyWh") == pytest.approx(110.0 - 11.0) + assert _reading(energy, f"{PV}:circuit_energy_net") == pytest.approx(55.0 - 5.0) + _assert_every_net_on_its_siblings(energy) + + +async def test_compensation_turned_back_on_before_a_reload_returns_to_every_net( + hass: HomeAssistant, +) -> None: + """The held offsets come back to the counters and their Nets on the same update.""" + coordinator, energy = await _built(hass) + coordinator.async_set_updated_data(BEFORE) + coordinator.async_set_updated_data(AFTER) + hass.config_entries.async_update_entry( + coordinator.config_entry, options={ENABLE_ENERGY_DIP_COMPENSATION: False} + ) + coordinator.async_set_updated_data(LATER) + + hass.config_entries.async_update_entry( + coordinator.config_entry, options={ENABLE_ENERGY_DIP_COMPENSATION: True} + ) + coordinator.async_set_updated_data(LATER_STILL) + + assert _reading(energy, "mainMeterEnergyConsumedWh") == pytest.approx(120.0 + 2400.0) + assert _reading(energy, f"{PV}:circuit_energy_produced") == pytest.approx(60.0 + 950.0) + _assert_every_net_on_its_siblings(energy) diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py new file mode 100644 index 00000000..8b4ff0bb --- /dev/null +++ b/tests/test_new_entity_announcement.py @@ -0,0 +1,658 @@ +"""An entity this integration adds must not arrive silently, switched on or off. + +`battery.part_number` shipped with `entity_registry_enabled_default=False` so +upgrading would not grow anybody's entity list uninvited. It worked, and the cost +was that nothing told the user the sensor existed -- they found it by opening the +device's disabled-entity list on a hunch. + +The first fix covered only *disabled* additions, on the reasoning that an enabled +one is already visible in the entity list and its history. That reasoning does +not survive contact with how anyone uses Home Assistant: nobody watches their +entity count, so an addition that breaks nothing is indistinguishable from no +addition at all. These cover both, and the four ways the announcement could be +worse than nothing -- shouting on a first install, shouting a release's worth of +history on upgrade, nagging on every restart, or naming a hundred entities at +once. +""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.persistent_notification import async_dismiss +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.span_panel.additions import ( + _ANNOUNCED, + _SECTION, + _STORE_VERSION, + COLLAPSE_ABOVE, + async_announce_new_entities, + async_forget_announcements, +) +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.notices import read_translations + +_PART_NUMBER = "sp3-001_bess_part_number" + + +@pytest.fixture +def entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a config entry in hass. No conftest fixture exists for this.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001") + mock.add_to_hass(hass) + return mock + + +def _register( + hass: HomeAssistant, + entry: MockConfigEntry, + unique_id: str, + *, + disabled: bool = True, + name: str | None = None, + device_id: str | None = None, +) -> er.RegistryEntry: + """Register one entity the way a platform would.""" + return er.async_get(hass).async_get_or_create( + "sensor", + DOMAIN, + unique_id, + config_entry=entry, + original_name=name, + device_id=device_id, + disabled_by=er.RegistryEntryDisabler.INTEGRATION if disabled else None, + ) + + +def _notifications(hass: HomeAssistant) -> dict[str, Any]: + """Every persistent notification currently standing, by id.""" + return dict(hass.data.get("persistent_notification", {})) + + +def _announcement(hass: HomeAssistant, entry: MockConfigEntry) -> Any | None: + return _notifications(hass).get(f"{DOMAIN}_new_entities_{entry.entry_id}") + + +def _text(notification: Any) -> str: + message = notification.message if hasattr(notification, "message") else notification["message"] + return str(message) + + +# -- Silence where silence is right ------------------------------------------ + + +async def test_a_first_install_announces_nothing( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Every entity is new on a first install, so the notice would name them all. + + Which would teach the user that this category is noise, and cost them the + real additions later. + """ + _register(hass, entry, _PART_NUMBER, name="Part Number") + + await async_announce_new_entities(hass, entry) + + assert _announcement(hass, entry) is None + + +async def test_an_install_that_predates_the_record_announces_nothing_once( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Entities that were never announced are not therefore new. + + The first pass adopts what is already registered as known. Without it, the + release that ships this mechanism would announce every entity the integration + has ever created. + """ + for index in range(5): + _register(hass, entry, f"sp3-001_existing_{index}", name=f"Existing {index}") + + await async_announce_new_entities(hass, entry) + + assert _announcement(hass, entry) is None + + +async def test_a_restart_that_adds_nothing_announces_nothing( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The failure that would make the whole thing worse than useless.""" + _register(hass, entry, _PART_NUMBER, name="Part Number") + await async_announce_new_entities(hass, entry) + + for _ in range(3): + await async_announce_new_entities(hass, entry) + + assert _announcement(hass, entry) is None + + +# -- What it says ------------------------------------------------------------ + + +async def test_a_disabled_addition_is_announced_and_says_it_needs_enabling( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + _register(hass, entry, _PART_NUMBER, name="Part Number") + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Part Number" in message + assert "Added but switched off" in message + assert "enable the ones you want" in message + + +async def test_an_enabled_addition_is_announced_too( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The gap this replaced the Repair to close. + + An enabled entity is visible in the entity list and starts recording, which is + only an announcement to somebody already looking at the entity list. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + _register(hass, entry, "sp3-001_grid_state", name="Grid State", disabled=False) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Grid State" in message + assert "Added and ready to use" in message + + +async def test_both_kinds_are_split_rather_than_pooled( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The split is the actionable part: one kind needs an action, the other does not.""" + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + _register(hass, entry, "sp3-001_on", name="Switched On", disabled=False) + _register(hass, entry, "sp3-001_off", name="Switched Off") + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert message.index("Added and ready to use") < message.index("Switched On") + assert message.index("Added but switched off") < message.index("Switched Off") + + +async def test_every_added_entity_is_named_rather_than_sampled( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Naming what was added means naming all of it. + + The Repair this replaced showed a count plus three examples, which tells a + user that something happened and not what. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + names = [f"Reading {index}" for index in range(8)] + for index, name in enumerate(names): + _register(hass, entry, f"sp3-001_new_{index}", name=name) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert all(name in message for name in names) + + +# -- Adopted devices are counted, not listed --------------------------------- + + +async def test_an_adopted_device_contributes_one_line_with_a_count( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """A vendor device declaring a dozen properties must not consume the message. + + Listing them would spend the whole notification on one device and teach the + user to skip it -- costing them the curated additions in the same message. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + device = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001_adopted_generator-1")}, + name="Backup Generator", + ) + for index in range(6): + _register( + hass, entry, f"sp3-001_adopted_{index}", name=f"Adopted {index}", device_id=device.id + ) + _register(hass, entry, "sp3-001_curated", name="Curated Addition") + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Backup Generator (6 entities)" in message + assert "Adopted 0" not in message + assert "Curated Addition" in message + + +async def test_an_adopted_device_is_listed_as_switched_off( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Every adopted entity registers disabled, so it must not read as ready to use. + + `AdoptedEntity` sets `_attr_entity_registry_enabled_default = False` for all of + them. Listing them beside the enabled additions said the opposite of the truth. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + device = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001_adopted_generator-1")}, + name="Backup Generator", + ) + _register(hass, entry, "sp3-001_adopted_0", name="Adopted 0", device_id=device.id) + _register(hass, entry, "sp3-001_ready", name="Ready Sensor", disabled=False) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + enabled_at = message.index("Added and ready to use") + disabled_at = message.index("Added but switched off") + assert enabled_at < message.index("Ready Sensor") < disabled_at + assert disabled_at < message.index("Backup Generator (1 entities)") + + +async def test_an_adopted_only_release_still_says_how_to_switch_them_on( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The case adoption exists for was the case that told the user least. + + A vendor device appearing is often the *only* addition in a release. Counting + adopted entities apart from the disabled ones meant no heading rendered and, + worse, `how_to_enable` was suppressed -- the one string in the message with an + action attached. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + device = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001_adopted_generator-1")}, + name="Backup Generator", + ) + _register(hass, entry, "sp3-001_adopted_0", name="Adopted 0", device_id=device.id) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Added but switched off" in message + assert "show its disabled entities" in message + assert "Added and ready to use" not in message + + +async def test_an_announced_entity_is_not_announced_again_after_a_restart( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The record must grow by what was registered, not be replaced by what it held. + + Recording only the previously-announced set would leave every entity announced + in this pass still absent from the record, so the next startup would find them + new again -- and every startup after that, forever. The nothing-added path + returns before the record is written, so it cannot catch this. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + _register(hass, entry, _PART_NUMBER, name="Part Number") + await async_announce_new_entities(hass, entry) + assert _announcement(hass, entry) is not None + + async_dismiss(hass, f"{DOMAIN}_new_entities_{entry.entry_id}") + await async_announce_new_entities(hass, entry) + + assert _announcement(hass, entry) is None + + +# -- Translations ------------------------------------------------------------ + + +@pytest.mark.parametrize("language", ["en", "es", "fr", "ja", "pt"]) +def test_every_shipped_locale_carries_the_notification_strings(language: str) -> None: + """The notification is assembled here, so nothing else checks these keys. + + Home Assistant's translation helper filters to the categories it defines and a + persistent notification is not one of them, so these are read from this + component's own files. That is precisely why a missing key would fail silently + into English rather than being caught by the platform. + """ + text = read_translations(language, _SECTION) + assert set(text) >= { + "title", + "intro_one", + "intro_many", + "enabled_heading", + "disabled_heading", + "how_to_enable", + "nothing_broken", + } + + +def test_an_unknown_language_falls_back_to_english_rather_than_to_nothing() -> None: + assert read_translations("xx", _SECTION)["title"] == read_translations("en", _SECTION)["title"] + + +def test_a_regional_language_resolves_to_its_base(hass: HomeAssistant) -> None: + """`pt-BR` is not shipped; `pt` is, and is a better answer than English.""" + assert read_translations("pt-BR", _SECTION) == read_translations("pt", _SECTION) + + +# -- Removal ----------------------------------------------------------------- + + +async def test_removing_the_entry_forgets_what_was_announced( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Otherwise re-adding the same panel announces none of the entities it recreates.""" + _register(hass, entry, _PART_NUMBER, name="Part Number") + await async_announce_new_entities(hass, entry) + + await async_forget_announcements(hass, entry) + _register(hass, entry, "sp3-001_new", name="New One") + await async_announce_new_entities(hass, entry) + + assert _announcement(hass, entry) is None + + +# -- Sub-device entities are told apart -------------------------------------- + + +def _sub_device(hass: HomeAssistant, entry: MockConfigEntry, name: str, ident: str) -> str: + """Return a device hanging off the panel, the way every SPAN sub-device does.""" + devices = dr.async_get(hass) + panel = devices.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={(DOMAIN, "sp3-001")}, name="SPAN Panel" + ) + child = devices.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, ident)}, + name=name, + via_device_id=panel.id, + ) + return str(child.id) + + +async def test_two_sub_devices_with_one_entity_name_are_told_apart( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The defect this fixes, in the shape it actually shipped in. + + Two commissioned chargers each gain a charge-current limit. Every platform + sets `_attr_has_entity_name`, so the registry stores only "Charge Current + Limit" for both and Home Assistant prepends the device in its own UI. A flat + list does not, so the notification showed the same row twice with nothing to + tell them apart -- which is what teaches somebody to skip the category. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + first = _sub_device(hass, entry, "SPAN Drive 1", "sp3-001_evse_1") + second = _sub_device(hass, entry, "SPAN Drive 2", "sp3-001_evse_2") + _register(hass, entry, "sp3-001_evse_1_limit", name="Charge Current Limit", device_id=first) + _register(hass, entry, "sp3-001_evse_2_limit", name="Charge Current Limit", device_id=second) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "SPAN Drive 1 Charge Current Limit" in message + assert "SPAN Drive 2 Charge Current Limit" in message + assert message.count("Charge Current Limit") == 2 + + +async def test_a_panel_entity_is_not_prefixed(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """The panel needs no prefix, and adding one would be noise. + + A notification that already says which panel it is about does not need the + panel's name on every line. The prefix exists to break collisions, and the + panel is the one device that cannot collide with a sibling. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + devices = dr.async_get(hass) + panel = devices.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={(DOMAIN, "sp3-001")}, name="SPAN Panel" + ) + _register(hass, entry, "sp3-001_dsm", name="DSM State", device_id=panel.id) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "DSM State" in message + assert "SPAN Panel DSM State" not in message + + +async def test_vendor_extensions_on_a_curated_device_collapse_to_one_line( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Fifteen new vendor properties are one line, not fifteen. + + These sit on a *curated* card, so the adopted-device detector cannot see + them: the card is the battery's, shared with curated entities that must + still be listed individually. Their unique_id is what says what they are. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + battery = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001_bess")}, + name="Span Panel Battery", + ) + for index in range(15): + _register( + hass, + entry, + f"span_sp3-001_adopted_bess/battery-2/reading-{index}", + name=f"Battery 2 Reading {index}", + device_id=battery.id, + ) + # A curated entity added to the same device in the same release is still + # named: collapsing is for the vendor surface, not for the card. + _register(hass, entry, "sp3-001_bess_meter_power", name="Meter Power", device_id=battery.id) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Span Panel Battery (15 entities)" in message + assert "Battery 2 Reading 0" not in message + assert "Meter Power" in message + + +async def test_a_few_vendor_extensions_are_named_rather_than_collapsed( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Collapsing two tells the reader less than naming them. + + The collapse exists for a firmware update adding fifteen properties at once. + An adopted *device*'s line at least names a device that did not exist + before; a curated card is one the user already has, so its name alone says + nothing about what appeared on it -- which is what a live install showed, + reading "Span Panel (2 entities)" for a postal code and a time zone. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + panel = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001")}, + name="Span Panel", + ) + for wire_property, label in ( + ("postal-code", "Status Postal Code"), + ("time-zone", "Status Time Zone"), + ): + _register( + hass, + entry, + f"span_sp3-001_adopted_panel/status/{wire_property}", + name=label, + device_id=panel.id, + ) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Status Postal Code" in message + assert "Status Time Zone" in message + assert "(2 entities)" not in message + + +async def test_at_the_threshold_they_are_still_named( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Exactly the threshold is named, so the boundary is asserted from both sides.""" + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + panel = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001")}, + name="Span Panel", + ) + for index in range(COLLAPSE_ABOVE): + _register( + hass, + entry, + f"span_sp3-001_adopted_panel/acme/reading-{index}", + name=f"Acme Reading {index}", + device_id=panel.id, + ) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Acme Reading 0" in message + assert "entities)" not in message + + +async def test_one_past_the_threshold_collapses( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """One more than the threshold, in the same update, and the names give way to a count. + + Counted per notification rather than per device lifetime, because the + message describes this update: five readings announced last month and one + today is a one-line update, not a flood. + """ + _register(hass, entry, "sp3-001_existing", name="Existing") + await async_announce_new_entities(hass, entry) + + panel = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "sp3-001")}, + name="Span Panel", + ) + for index in range(COLLAPSE_ABOVE + 1): + _register( + hass, + entry, + f"span_sp3-001_adopted_panel/acme/reading-{index}", + name=f"Acme Reading {index}", + device_id=panel.id, + ) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert f"Span Panel ({COLLAPSE_ABOVE + 1} entities)" in message + assert "Acme Reading 0" not in message + + +# -- A store file that is not what we wrote ------------------------------------ +# +# `notices` narrows its own store for this reason and has three tests for it; +# this one had none. The consequence here is worse, because this coroutine is +# awaited from inside `async_setup_entry`: a wrong-shaped file raised out of it, +# the coordinator was shut down, and the entry failed with no retry -- so a panel +# stayed dead until somebody found and deleted a file about *notifications*. + + +def _seed(hass_storage: dict[str, Any], entry: MockConfigEntry, data: object) -> None: + key = f"{DOMAIN}.announced.{entry.entry_id}" + hass_storage[key] = {"version": _STORE_VERSION, "key": key, "data": data} + + +@pytest.mark.parametrize( + ("shape", "description"), + [ + ({_ANNOUNCED: 7}, "a number where the list should be"), + ({_ANNOUNCED: {"a": 1}}, "a mapping where the list should be"), + ({_ANNOUNCED: ["fine", 3]}, "a list holding something that is not an id"), + ({_ANNOUNCED: None}, "an explicit null"), + ({}, "an object with no announced key"), + (["announced_unique_ids"], "a list at the top level"), + ("nonsense", "a bare string"), + ], +) +async def test_a_malformed_store_does_not_take_the_integration_down( + hass: HomeAssistant, + hass_storage: dict[str, Any], + entry: MockConfigEntry, + shape: object, + description: str, +) -> None: + """Setup must survive every one of these; the record is recoverable, the entry is not.""" + _seed(hass_storage, entry, shape) + _register(hass, entry, _PART_NUMBER, name="Part Number") + + await async_announce_new_entities(hass, entry) + + assert _announcement(hass, entry) is None + + +async def test_a_malformed_store_is_re_seeded_rather_than_left_broken( + hass: HomeAssistant, hass_storage: dict[str, Any], entry: MockConfigEntry +) -> None: + """Self-healing is what makes re-seeding the right trade. + + The bad file is replaced by a valid one holding what is registered now, so + the *next* addition is announced normally and the user never has to find the + file. The cost is one pass of silence, which is the same trade a first + install already makes. + """ + _seed(hass_storage, entry, {_ANNOUNCED: 7}) + _register(hass, entry, _PART_NUMBER, name="Part Number") + await async_announce_new_entities(hass, entry) + + _register(hass, entry, "sp3-001_grid_state", name="Grid State", disabled=False) + await async_announce_new_entities(hass, entry) + + message = _text(_announcement(hass, entry)) + assert "Grid State" in message + assert "Part Number" not in message + + +@pytest.mark.parametrize( + ("shape", "expected"), + [ + ({}, "found missing"), + ({_ANNOUNCED: 7}, "found int"), + ({_ANNOUNCED: None}, "found NoneType"), + ({_ANNOUNCED: ["fine", 3]}, "found a list holding int"), + ("nonsense", "found str"), + ], +) +async def test_the_warning_says_what_was_actually_on_disk( + hass: HomeAssistant, + hass_storage: dict[str, Any], + entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + shape: object, + expected: str, +) -> None: + """The line is the only thing anybody has to go on, so it may not mislead. + + A record with no `announced_unique_ids` key at all reported "found NoneType" + -- the type of what `get` returned rather than of anything on disk -- which + reads as a null somebody wrote and sends the reader to the wrong part of the + file. `None` written *explicitly* still reports NoneType, because there it is + the truth. + """ + _seed(hass_storage, entry, shape) + + await async_announce_new_entities(hass, entry) + + assert expected in caplog.text diff --git a/tests/test_notices.py b/tests/test_notices.py new file mode 100644 index 00000000..91c5b348 --- /dev/null +++ b/tests/test_notices.py @@ -0,0 +1,397 @@ +"""Notices survive a restart without pretending to be defects. + +The property under test is the one that used to cost a Repair: a notice raised +while nobody was looking is still there when they look. Every test here either +restarts (drop the in-memory view, restore from the store) or dismisses, because +those are the only two things that decide whether a notice comes back. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from homeassistant.components.persistent_notification import async_dismiss +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry, async_fire_time_changed + +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.notices import ( + _DATA, + _STORE_VERSION, + async_forget, + async_raise, + async_restore, + read_translations, +) + +_NOTICE = "panel_upgraded" + + +@pytest.fixture +def entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a config entry in hass.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001") + mock.add_to_hass(hass) + return mock + + +@pytest.fixture +def other(hass: HomeAssistant) -> MockConfigEntry: + """Return a second panel. Notices are per entry, and this is what proves it.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-002") + mock.add_to_hass(hass) + return mock + + +def _standing(hass: HomeAssistant) -> dict[str, Any]: + """Every persistent notification currently on screen, by id.""" + return dict(hass.data.get("persistent_notification", {})) + + +def _id(entry: MockConfigEntry, notice_id: str = _NOTICE) -> str: + return f"{DOMAIN}_{notice_id}_{entry.entry_id}" + + +async def _flush(hass: HomeAssistant) -> None: + """Let the delayed store writes fire. Home Assistant does this at shutdown.""" + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + + +async def _restart(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Restart Home Assistant, as far as this module can tell. + + The store is on disk and survives; the live view and the notifications are in + memory and do not. Dropping exactly those two is what a restart does. + + Cleared rather than dismissed, which is the distinction the whole module turns + on: a restart destroys a notification without anybody having read it, and + dismissing here would quietly test the acknowledged path instead and report + that notices vanish when they should not. + """ + await _flush(hass) + hass.data.get(_DATA, {}).pop(entry.entry_id, None) + hass.data.get("persistent_notification", {}).clear() + await async_restore(hass, entry) + + +# -- Raising ----------------------------------------------------------------- + + +async def test_a_raised_notice_is_shown(hass: HomeAssistant, entry: MockConfigEntry) -> None: + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + notification = _standing(hass)[_id(entry)] + assert notification["title"] == "Upgraded" + assert notification["message"] == "Body" + + +async def test_a_notice_raised_before_a_restart_is_shown_after_it( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The whole point. A panel that upgrades while the owner is away still tells them.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + await _restart(hass, entry) + + assert _standing(hass)[_id(entry)]["message"] == "Body" + + +async def test_a_notice_survives_more_than_one_restart( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Restoring must re-record, not consume. Undismissed is undismissed.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + await _restart(hass, entry) + await _restart(hass, entry) + + assert _id(entry) in _standing(hass) + + +async def test_raising_the_same_notice_again_replaces_its_text( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Restoring a notice already on screen must not stack a second copy.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="First") + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Second") + + assert [key for key in _standing(hass) if key.startswith(DOMAIN)] == [_id(entry)] + assert _standing(hass)[_id(entry)]["message"] == "Second" + + +# -- Dismissal --------------------------------------------------------------- + + +async def test_a_dismissed_notice_does_not_come_back( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Dismissal is the acknowledgement. Ignoring it would turn a notice into a nag.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + async_dismiss(hass, _id(entry)) + await hass.async_block_till_done() + hass.data.get(_DATA, {}).pop(entry.entry_id, None) + await async_restore(hass, entry) + + assert _id(entry) not in _standing(hass) + + +async def test_dismissing_someone_elses_notification_leaves_ours_standing( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The dispatcher delivers every notification change in the system, not just ours.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + async_dismiss(hass, "some_other_integration_thing") + await _restart(hass, entry) + + assert _id(entry) in _standing(hass) + + +async def test_dismissing_one_panels_notice_leaves_the_other_panels_alone( + hass: HomeAssistant, entry: MockConfigEntry, other: MockConfigEntry +) -> None: + """Two panels upgrading are two notices; one owner reading is not both.""" + await async_restore(hass, entry) + await async_restore(hass, other) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="First panel") + async_raise(hass, other, _NOTICE, title="Upgraded", message="Second panel") + + async_dismiss(hass, _id(entry)) + await _restart(hass, entry) + await _restart(hass, other) + + assert _id(entry) not in _standing(hass) + assert _standing(hass)[_id(other)]["message"] == "Second panel" + + +# -- The reload race --------------------------------------------------------- + + +async def test_a_notice_raised_moments_before_a_reload_is_not_lost( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """A schema upgrade raises a notice and schedules a reload in the same breath. + + Writes are delayed to coalesce them, so the setup that follows can arrive + before the file has been written. Re-reading the store there would drop the + notice from the standing set while it sat on the user's screen -- it would + look fine until the next restart, and then be gone. + """ + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + await async_restore(hass, entry) # the reload, before the delayed write lands + + # Asserted on the live view rather than on what comes back after a restart, + # because the store is what makes this hard to see: it hands a pending write + # back to its own reader, so a reload that re-read it would look correct here + # and lose the notice only on the restart after the process ended. + assert _NOTICE in hass.data[_DATA][entry.entry_id].standing + + await _restart(hass, entry) + assert _standing(hass)[_id(entry)]["message"] == "Body" + + +# -- Removal ----------------------------------------------------------------- + + +async def test_removing_the_entry_takes_its_notices_off_the_screen( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Nothing else clears them, so the user would keep reading about a panel they removed.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + await async_forget(hass, entry) + + assert _id(entry) not in _standing(hass) + + +async def test_re_adding_a_removed_panel_does_not_restore_its_old_notices( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The record has to go with the entry, or a new panel inherits an old upgrade.""" + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + await async_forget(hass, entry) + + await async_restore(hass, entry) + + assert _id(entry) not in _standing(hass) + + +async def test_removing_one_panel_leaves_the_others_notices_alone( + hass: HomeAssistant, entry: MockConfigEntry, other: MockConfigEntry +) -> None: + await async_restore(hass, entry) + await async_restore(hass, other) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="First panel") + async_raise(hass, other, _NOTICE, title="Upgraded", message="Second panel") + + await async_forget(hass, entry) + + assert _id(entry) not in _standing(hass) + assert _standing(hass)[_id(other)]["message"] == "Second panel" + + +async def test_raising_against_an_untracked_entry_still_reaches_the_user( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """A notice in flight when the entry is removed describes something that happened. + + Showing it is right; recreating the record `async_forget` just deleted is not. + """ + await async_restore(hass, entry) + await async_forget(hass, entry) + + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + assert _standing(hass)[_id(entry)]["message"] == "Body" + assert entry.entry_id not in hass.data.get(_DATA, {}) + + +# -- A store file that is not what we wrote ------------------------------------- +# +# `StoredNotices` and `StandingNotice` are compile-time only, so the disk can hold +# anything. Every shape below used to raise straight out of `async_setup_entry` -- +# and out of the part of it above the `try`, so the entry went to SETUP_ERROR with +# no retry and stayed dead until somebody found and deleted the file by hand. +# Bookkeeping for a notification is not worth an integration that will not load. + + +def _seed(hass_storage: dict[str, Any], entry: MockConfigEntry, data: object) -> None: + key = f"{DOMAIN}.notices.{entry.entry_id}" + hass_storage[key] = {"version": _STORE_VERSION, "key": key, "data": data} + + +@pytest.mark.parametrize( + ("shape", "description"), + [ + ({"standing": ["panel_upgraded"]}, "a list where the mapping should be"), + ({"standing": {"panel_upgraded": "oops"}}, "a string where the notice should be"), + ({"standing": {"panel_upgraded": {"title": "T"}}}, "a notice with no message"), + ({"standing": {"panel_upgraded": {"title": 1, "message": 2}}}, "non-string text"), + (["standing"], "a list at the top level"), + ({}, "an object with no standing key"), + ("nonsense", "a bare string"), + ], +) +async def test_a_malformed_store_does_not_take_the_integration_down( + hass: HomeAssistant, + hass_storage: dict[str, Any], + entry: MockConfigEntry, + shape: object, + description: str, +) -> None: + """Setup must survive every one of these; the record is recoverable, the entry is not.""" + _seed(hass_storage, entry, shape) + + await async_restore(hass, entry) + + assert hass.data[_DATA][entry.entry_id].standing == {} + + +async def test_a_malformed_store_is_overwritten_by_the_next_notice( + hass: HomeAssistant, hass_storage: dict[str, Any], entry: MockConfigEntry +) -> None: + """Self-healing is what makes falling back to empty the right trade. + + The user never has to find the file, because the next raise replaces it. + """ + _seed(hass_storage, entry, {"standing": "nonsense"}) + await async_restore(hass, entry) + + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + await _restart(hass, entry) + + assert _standing(hass)[_id(entry)]["message"] == "Body" + + +async def test_one_malformed_notice_does_not_discard_its_healthy_neighbours( + hass: HomeAssistant, hass_storage: dict[str, Any], entry: MockConfigEntry +) -> None: + """Dropping the whole file for one bad row would lose notices that are still valid.""" + _seed( + hass_storage, + entry, + { + "standing": { + "panel_upgraded": {"title": "Upgraded", "message": "Body"}, + "broken": {"title": "no message"}, + } + }, + ) + + await async_restore(hass, entry) + + assert _standing(hass)[_id(entry)]["message"] == "Body" + assert "broken" not in hass.data[_DATA][entry.entry_id].standing + + +# -- The dismissal watch is unregistered on unload ------------------------------ + + +async def test_unloading_stops_the_dismissal_watch( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Every setup registers a dispatcher handler, and the dispatcher fans out to all of them. + + Without `entry.async_on_unload` a reload leaves the previous handler behind, + bound to an entry that is gone, and the leak grows by one per reload. + + Asserted through what a leaked handler would *do* rather than by counting + subscribers: after unload nothing of ours is listening, so a dismissal reaches + no one and the standing set is untouched. + """ + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + await entry._async_process_on_unload(hass) + async_dismiss(hass, _id(entry)) + await hass.async_block_till_done() + + assert _NOTICE in hass.data[_DATA][entry.entry_id].standing + + +# -- A queued write records what was shown, not what came after ----------------- + + +async def test_a_queued_write_records_the_set_as_it_was_when_raised( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """`async_delay_save` calls its argument when the write fires, not when it is queued. + + Closing over the live dict would record whatever the set had become by then -- + a state nobody was ever shown. + """ + await async_restore(hass, entry) + async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body") + + # A second notice queues its own write; the first write must still be able to + # produce the snapshot it was given rather than reading through to this one. + async_raise(hass, entry, "second", title="Second", message="Also") + await _flush(hass) + hass.data[_DATA].pop(entry.entry_id) + await async_restore(hass, entry) + + assert _standing(hass)[_id(entry)]["message"] == "Body" + assert _standing(hass)[_id(entry, "second")]["message"] == "Also" + + +# -- Translations off disk ------------------------------------------------------ + + +def test_a_translation_file_of_the_wrong_shape_falls_through_to_english() -> None: + """Same class as the store: our own files are fine, and the disk is not ours.""" + assert read_translations("xx", "panel_upgraded")["title"] diff --git a/tests/test_panel_ca_pinning.py b/tests/test_panel_ca_pinning.py new file mode 100644 index 00000000..a208aa85 --- /dev/null +++ b/tests/test_panel_ca_pinning.py @@ -0,0 +1,596 @@ +"""Tests for pinning the panel's CA: acquisition, deferral, and the change repair.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.update_coordinator import UpdateFailed +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import PassphraseRotation +from span_panel_api.exceptions import SpanPanelCAChangedError, SpanPanelConnectionError + +from custom_components.span_panel import _async_pinned_ca, async_migrate_entry +from custom_components.span_panel.ca_repairs import ( + async_clear_ca_changed, + async_raise_ca_changed, + ca_changed_issue_id, +) +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_HTTP_PORT, + CONF_PANEL_CA_PEM, + DOMAIN, + PANEL_CA_PENDING, + PANEL_STATUS, +) +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor +from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS + +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory, pv_binding_for + +PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" +OTHER_PEM = "-----BEGIN CERTIFICATE-----\nb3RoZXI=\n-----END CERTIFICATE-----\n" + + +def _entry(hass: HomeAssistant, **data: object) -> MockConfigEntry: + """Add a v7 entry carrying the given data.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=7, + title="Span Panel", + data={ + CONF_HOST: "192.168.1.100", + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + **data, + }, + source=config_entries.SOURCE_USER, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + return entry + + +# ---------- acquisition at setup ---------- + + +@pytest.mark.asyncio +async def test_a_stored_pin_is_used_without_any_fetch(hass: HomeAssistant) -> None: + """An entry that already carries a CA does not go back to the panel for one.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + + with patch("custom_components.span_panel.async_fetch_panel_ca", new=AsyncMock()) as fetch: + assert await _async_pinned_ca(hass, entry, "192.168.1.100", 80) == PEM + + fetch.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_the_deferred_fetch_stores_the_ca_and_clears_the_flag( + hass: HomeAssistant, +) -> None: + """A migrated entry acquires its CA at the first setup that reaches the panel. + + The leaf check is stood down here and exercised for real in + `test_panel_ca_pinning_tls`: this is about the bookkeeping either side of it. + """ + entry = _entry(hass, **{PANEL_CA_PENDING: True}) + + with ( + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ) as fetch, + patch( + "custom_components.span_panel.async_ca_signs_panel_leaf", + new=AsyncMock(return_value=True), + ), + ): + assert await _async_pinned_ca(hass, entry, "192.168.1.100", 8080) == PEM + + fetch.assert_awaited_once_with(hass, "192.168.1.100", http_port=8080) + assert entry.data[CONF_PANEL_CA_PEM] == PEM + assert PANEL_CA_PENDING not in entry.data + + +@pytest.mark.asyncio +async def test_a_failed_deferred_fetch_keeps_the_flag_and_does_not_fail_setup( + hass: HomeAssistant, +) -> None: + """An unreachable certificate endpoint must not cost the user their integration.""" + entry = _entry(hass, **{PANEL_CA_PENDING: True}) + + with patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(side_effect=SpanPanelConnectionError("unreachable")), + ): + assert await _async_pinned_ca(hass, entry, "192.168.1.100", 80) is None + + # Still pending, so the next setup retries for free. + assert entry.data[PANEL_CA_PENDING] is True + assert CONF_PANEL_CA_PEM not in entry.data + + +@pytest.mark.asyncio +async def test_an_entry_with_neither_pin_nor_flag_is_left_alone( + hass: HomeAssistant, +) -> None: + """Nothing acquires a CA behind the user's back.""" + entry = _entry(hass) + + with patch("custom_components.span_panel.async_fetch_panel_ca", new=AsyncMock()) as fetch: + assert await _async_pinned_ca(hass, entry, "192.168.1.100", 80) is None + + fetch.assert_not_awaited() + + +# ---------- the migration that queues it ---------- + + +@pytest.mark.asyncio +async def test_v7_flags_a_v2_entry_for_ca_acquisition(hass: HomeAssistant) -> None: + """The migration queues the fetch rather than performing it.""" + entry = MockConfigEntry( + version=6, + domain=DOMAIN, + title="Span Panel", + data={CONF_HOST: "192.168.1.100", CONF_API_VERSION: "v2"}, + source=config_entries.SOURCE_USER, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + with patch( + "custom_components.span_panel.migrations.async_migrate_entry", + wraps=async_migrate_entry, + ): + assert await async_migrate_entry(hass, entry) is True + + assert entry.data[PANEL_CA_PENDING] is True + # No PEM: the migration does no I/O at all. + assert CONF_PANEL_CA_PEM not in entry.data + + +@pytest.mark.asyncio +@pytest.mark.parametrize("api_version", ["v1", "simulation"]) +async def test_non_v2_entries_are_never_flagged(hass: HomeAssistant, api_version: str) -> None: + """v1 fails setup before it reaches a panel, and a simulation has none.""" + entry = MockConfigEntry( + version=6, + domain=DOMAIN, + title="Span Panel", + data={CONF_HOST: "192.168.1.100", CONF_API_VERSION: api_version}, + source=config_entries.SOURCE_USER, + unique_id=f"SPAN-{api_version}", + ) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert PANEL_CA_PENDING not in entry.data + + +# ---------- the repair ---------- + + +@pytest.mark.asyncio +async def test_a_changed_ca_raises_a_fixable_repair_carrying_both_fingerprints( + hass: HomeAssistant, +) -> None: + """The user needs both values, because the two remedies are opposite.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + + async_raise_ca_changed(hass, entry, "aa" * 32, "bb" * 32) + + issue = ir.async_get(hass).async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) + assert issue is not None + assert issue.is_fixable is True + # Persistent, because the transport it describes is already dead — there is + # no live state left to re-assert it from. + assert issue.is_persistent is True + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders is not None + assert issue.translation_placeholders["expected_fingerprint"] == "aa" * 32 + assert issue.translation_placeholders["observed_fingerprint"] == "bb" * 32 + + +@pytest.mark.asyncio +async def test_a_clean_connection_clears_a_standing_repair(hass: HomeAssistant) -> None: + """The Repair describes a state that a successful handshake disproves.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + async_raise_ca_changed(hass, entry, "aa" * 32, "bb" * 32) + + async_clear_ca_changed(hass, entry) + + assert ir.async_get(hass).async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) is None + + +@pytest.mark.asyncio +async def test_the_fix_flow_re_pins_only_on_an_explicit_confirmation( + hass: HomeAssistant, +) -> None: + """Showing the fingerprint must not itself accept it.""" + from custom_components.span_panel.repairs import PanelCAChangedRepairFlow + + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTP_PORT: 8080}) + flow = PanelCAChangedRepairFlow(entry.entry_id) + flow.hass = hass + + with ( + patch( + "custom_components.span_panel.repairs.async_fetch_panel_ca", + new=AsyncMock(return_value=OTHER_PEM), + ) as fetch, + # Stood down here and exercised for real in `test_panel_ca_pinning_tls`; + # this test is about what an explicit confirmation does and does not do. + patch( + "custom_components.span_panel.repairs.async_ca_signs_panel_leaf", + new=AsyncMock(return_value=True), + ), + ): + shown = await flow.async_step_init() + + fetch.assert_awaited_once_with(hass, "192.168.1.100", http_port=8080) + assert shown["type"] == "form" + assert shown["description_placeholders"] is not None + # The fingerprint offered is of the certificate that would actually be + # stored, not a record of an earlier observation. + assert shown["description_placeholders"]["fingerprint"] + # Nothing has been accepted yet. + assert entry.data[CONF_PANEL_CA_PEM] == PEM + + with patch.object(hass.config_entries, "async_reload", new=AsyncMock()) as reload: + done = await flow.async_step_confirm({}) + + assert done["type"] == "create_entry" + assert entry.data[CONF_PANEL_CA_PEM] == OTHER_PEM + reload.assert_awaited_once_with(entry.entry_id) + + +@pytest.mark.asyncio +async def test_the_fix_flow_aborts_when_the_panel_cannot_be_read( + hass: HomeAssistant, +) -> None: + """A fingerprint that cannot be read is not one a user can be asked to accept.""" + from custom_components.span_panel.repairs import PanelCAChangedRepairFlow + + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + flow = PanelCAChangedRepairFlow(entry.entry_id) + flow.hass = hass + + with patch( + "custom_components.span_panel.repairs.async_fetch_panel_ca", + new=AsyncMock(side_effect=SpanPanelConnectionError("unreachable")), + ): + result = await flow.async_step_init() + + assert result["type"] == "abort" + assert result["reason"] == "ca_unreadable" + assert entry.data[CONF_PANEL_CA_PEM] == PEM + + +# ---------- a transport that has stopped for good ---------- + + +def _panel_reading_1200_watts( + hass: HomeAssistant, +) -> tuple[SpanPanelCoordinator, SpanCircuitPowerSensor]: + """Build a live entry whose next read finds the panel behind a different CA. + + The sensor is a real one on a real coordinator: the defect was in how the + two of them agreed on availability, so a mock of either proves nothing. + """ + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="c1", name="Kitchen Outlets", instant_power_w=1200.0 + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = MagicMock() + client.get_snapshot = AsyncMock(side_effect=SpanPanelCAChangedError("aa" * 32, "bb" * 32)) + coordinator = SpanPanelCoordinator(hass, client, entry) + # A previous good snapshot, which the ordinary offline path would keep serving. + coordinator.data = snapshot + + power = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_power") + return coordinator, SpanCircuitPowerSensor(coordinator, power, snapshot, "c1") + + +@pytest.mark.asyncio +async def test_the_coordinator_takes_entities_unavailable_on_a_ca_change( + hass: HomeAssistant, +) -> None: + """A dead transport must not keep serving the snapshot read before it died.""" + coordinator, _ = _panel_reading_1200_watts(hass) + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + # Dead, not merely offline: the offline flag is what keeps sensors serving + # the last snapshot, and a transport that is not coming back must not. + assert coordinator.transport_dead is True + assert coordinator.panel_offline is False + + +@pytest.mark.asyncio +async def test_a_power_sensor_goes_unavailable_rather_than_reporting_zero( + hass: HomeAssistant, +) -> None: + """The defect: a CA change left every POWER sensor 'available' at 0 W.""" + coordinator, sensor = _panel_reading_1200_watts(hass) + + sensor._update_native_value() + assert sensor.available is True + assert sensor.native_value == 1200.0 + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + sensor._update_native_value() + + assert sensor.available is False + assert sensor.native_value != 0.0 + + +@pytest.mark.asyncio +async def test_the_fatal_channel_takes_the_entities_down_without_waiting_for_a_poll( + hass: HomeAssistant, +) -> None: + """The mid-session route in: no exception reaches anybody's call stack. + + The reconnect loop is fire-and-forget, so the only notification is this + callback. Without it the entities keep rendering the last snapshot until + the fallback poll comes round. + """ + coordinator, sensor = _panel_reading_1200_watts(hass) + coordinator.client.start_streaming = AsyncMock() + await coordinator.async_setup_streaming() + on_fatal = coordinator.client.register_fatal_error_callback.call_args.args[0] + + rendered: list[None] = [] + # Listening is also what starts the coordinator's refresh timer, hence the + # unsubscribe below. + unsubscribe = coordinator.async_add_listener(lambda: rendered.append(None)) + + on_fatal(SpanPanelCAChangedError("aa" * 32, "bb" * 32)) + + assert coordinator.transport_dead is True + assert sensor.available is False + # Told at once, rather than a minute later: nothing else fires here. + assert rendered == [None] + unsubscribe() + + +@pytest.mark.asyncio +async def test_an_ordinary_disconnect_still_holds_the_last_reading( + hass: HomeAssistant, +) -> None: + """The grace period is for outages, and this fix must not have taken it.""" + coordinator, sensor = _panel_reading_1200_watts(hass) + + coordinator._on_connection_change(False) + + assert coordinator.panel_offline is True + assert coordinator.transport_dead is False + assert sensor.available is True + + +@pytest.mark.asyncio +async def test_a_reconnection_brings_the_entities_back(hass: HomeAssistant) -> None: + """Dead is not permanent -- it is 'until something connects again'.""" + coordinator, sensor = _panel_reading_1200_watts(hass) + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + assert sensor.available is False + + coordinator._on_connection_change(True) + + assert coordinator.transport_dead is False + assert sensor.available is True + + +@pytest.mark.asyncio +async def test_a_held_hardware_reading_and_a_control_go_with_the_transport( + hass: HomeAssistant, +) -> None: + """The other two shapes of availability, both wrong for a dead transport. + + `door_state` returns True on the offline branch so it can render Unknown + through an outage, and the switch returns False there because a control + that cannot reach the panel is not a control. Neither branch is consulted + now: the transport probe settles both ahead of them. + """ + from custom_components.span_panel.binary_sensor import BINARY_SENSORS, SpanPanelBinarySensor + from custom_components.span_panel.const import SYSTEM_DOOR_STATE + from custom_components.span_panel.switch import SpanPanelCircuitsSwitch + + coordinator, _ = _panel_reading_1200_watts(hass) + door = SpanPanelBinarySensor( + coordinator, next(desc for desc in BINARY_SENSORS if desc.key == SYSTEM_DOOR_STATE) + ) + relay = SpanPanelCircuitsSwitch(coordinator, "c1", "SPAN Panel") + assert door.available is True + assert relay.available is True + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + assert door.available is False + assert relay.available is False + + +@pytest.mark.asyncio +async def test_the_connectivity_sensor_reports_disconnected_rather_than_vanishing( + hass: HomeAssistant, +) -> None: + """The one entity that must survive the condition it reports. + + `panel_status` is exempt from the transport probe, so it has to read the + flag itself -- otherwise it would sit at 'connected' describing a transport + that has stopped for good. + """ + from custom_components.span_panel.binary_sensor import BINARY_SENSORS, SpanPanelBinarySensor + + coordinator, _ = _panel_reading_1200_watts(hass) + description = next(desc for desc in BINARY_SENSORS if desc.key == PANEL_STATUS) + status = SpanPanelBinarySensor(coordinator, description) + status.async_write_ha_state = MagicMock() + + status._handle_coordinator_update() + assert status.is_on is True + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + status._handle_coordinator_update() + + assert status.available is True + assert status.is_on is False + + +# ---------- the transport those credentials travel over ---------- + + +@pytest.mark.asyncio +async def test_an_unpinned_entry_uses_the_plaintext_bootstrap_port( + hass: HomeAssistant, +) -> None: + """No pin, no context: exactly the transport this integration always had.""" + from custom_components.span_panel.config_flow_validation import panel_rest_transport + + entry = _entry(hass, **{CONF_HTTP_PORT: 8080}) + + transport = panel_rest_transport(hass, entry.data) + + assert transport.port == 8080 + assert transport.ssl_context is None + # Home Assistant's shared client, which it owns and closes at shutdown. + assert transport.httpx_client is not None + + +@pytest.mark.asyncio +async def test_a_pinned_entry_moves_to_tls_and_drops_the_shared_client( + hass: HomeAssistant, +) -> None: + """A pin needs its own client: httpx fixes its trust store at construction.""" + from custom_components.span_panel.config_flow_validation import panel_rest_transport + + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTP_PORT: 8080}) + context = MagicMock() + + with patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ): + transport = panel_rest_transport(hass, entry.data) + + # 443, not the stored plaintext 8080 — the library refuses port 80 with a + # context outright, and a plaintext port under TLS is never what was meant. + assert transport.port == 443 + assert transport.ssl_context is context + assert transport.httpx_client is None + + +@pytest.mark.asyncio +async def test_a_pinned_entry_honours_a_configured_https_port( + hass: HomeAssistant, +) -> None: + """A reverse proxy does not have to listen on 443.""" + from custom_components.span_panel.config_flow_validation import panel_rest_transport + from custom_components.span_panel.const import CONF_HTTPS_PORT + + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTPS_PORT: 9443}) + + with patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ): + transport = panel_rest_transport(hass, entry.data) + + assert transport.port == 9443 + + +@pytest.mark.asyncio +async def test_an_unusable_stored_pem_falls_back_rather_than_bricking_the_entry( + hass: HomeAssistant, +) -> None: + """A hand-edited `.storage` must not leave an entry unable to make a call.""" + import ssl + + from custom_components.span_panel.config_flow_validation import panel_rest_transport + + entry = _entry(hass, **{CONF_PANEL_CA_PEM: "not a certificate"}) + + with patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + side_effect=ssl.SSLError("nope"), + ): + transport = panel_rest_transport(hass, entry.data) + + assert transport.ssl_context is None + assert transport.port == 80 + + +@pytest.mark.asyncio +async def test_rotation_goes_over_the_pin_when_the_entry_has_one( + hass: HomeAssistant, +) -> None: + """Fresh secrets over unverified HTTP would undo the pin where it matters most.""" + from homeassistant.config_entries import ConfigEntryState + from homeassistant.core import Context + from pytest_homeassistant_custom_component.common import MockUser + + from custom_components.span_panel import ( + SpanPanelRuntimeData, + _async_register_credential_services, + ) + from custom_components.span_panel.const import CONF_EBUS_BROKER_PASSWORD + + entry = _entry( + hass, + **{CONF_PANEL_CA_PEM: PEM, CONF_EBUS_BROKER_PASSWORD: "old", CONF_HTTP_PORT: 8080}, + ) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=MagicMock(), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(SpanPanelSnapshotFactory.create()), + setup_snapshot=SpanPanelSnapshotFactory.create(), + ) + _async_register_credential_services(hass) + + context = MagicMock() + user = MockUser(is_owner=True).add_to_hass(hass) + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ), + patch( + "custom_components.span_panel.services.rotate_passphrase", + new=AsyncMock( + return_value=PassphraseRotation(ebus_broker_password="new", hop_passphrase="new") + ), + ) as rotate, + patch.object(hass.config_entries, "async_reload", new=AsyncMock(return_value=True)), + ): + await hass.services.async_call( + DOMAIN, + "rotate_credentials", + {}, + blocking=True, + context=Context(user_id=user.id), + return_response=True, + ) + + assert rotate.await_args is not None + assert rotate.await_args.kwargs["ssl_context"] is context + assert rotate.await_args.kwargs["port"] == 443 + assert rotate.await_args.kwargs["httpx_client"] is None + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == "new" diff --git a/tests/test_panel_ca_pinning_tls.py b/tests/test_panel_ca_pinning_tls.py new file mode 100644 index 00000000..cad235ed --- /dev/null +++ b/tests/test_panel_ca_pinning_tls.py @@ -0,0 +1,246 @@ +"""The two pins that happen outside the config flow, against a real handshake. + +The config flow proves the CA it fetched signs the certificate the panel serves +before it pins anything. Two other places pin: the deferred fetch at setup, for +an entry migrated from a release that predates pinning, and the CA-changed +repair, where a person accepts a new authority. Both took whatever the plaintext +fetch returned. + +Anything on the path can answer that fetch — a reverse proxy in front of the +panel answers it with an authority of its own — and pinning that CA is +unrecoverable: the broker connection then fails against a certificate the pin +rejects, the change diagnosis sees the fetched CA and the pinned one agree, and +the entry retries forever with no repair to offer. So these tests use a listener +serving a genuine leaf rather than the suite's stubs: the refusal is the +verification, and stubbing it out would test nothing. +""" + +from __future__ import annotations + +from collections.abc import Iterator +import ipaddress +from typing import Any +from unittest.mock import AsyncMock, patch + +from cryptography import x509 +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.span_panel import _async_pinned_ca +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_PORT, + CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, + DOMAIN, + PANEL_CA_PENDING, +) +from custom_components.span_panel.repairs import PanelCAChangedRepairFlow + +from .tls_panel import ( + PANEL_LOOPBACK, + PANEL_SERIAL, + PANEL_SHORTNAME, + Panel, + resolving_to_loopback, + unrelated_ca_pem, +) + + +@pytest.fixture +def panel(tmp_path: Any) -> Iterator[Panel]: + """Serve a leaf naming the panel's address, signed by the panel's own CA.""" + instance = Panel(tmp_path) + instance.present([x509.IPAddress(ipaddress.ip_address(PANEL_LOOPBACK))]) + yield instance + instance.close() + + +def _entry(hass: HomeAssistant, panel: Panel, **data: object) -> MockConfigEntry: + """Add an entry whose broker is the listener, shaped as a v7 migration leaves it. + + No `https_port`: that key and `panel_ca_pending` arrived in the same commit, + so an entry carrying the flag predates both and can never hold one. The + broker port is what the pin is checked against and every v2 entry has it -- + setup refuses one without it before the pin is ever reached. + """ + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="SPAN Panel", + data={ + CONF_HOST: PANEL_LOOPBACK, + CONF_ACCESS_TOKEN: "synthetic-token", + CONF_API_VERSION: "v2", + CONF_HTTP_PORT: 8080, + CONF_EBUS_BROKER_PORT: panel.port, + **data, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id=PANEL_SERIAL, + ) + entry.add_to_hass(hass) + return entry + + +# ---------- the deferred pin at setup ---------- + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_the_deferred_pin_refuses_a_ca_that_signs_nothing_the_panel_serves( + hass: HomeAssistant, panel: Panel +) -> None: + """The upgrade pin is trust on first use; it still has to be the panel's CA.""" + entry = _entry(hass, panel, **{PANEL_CA_PENDING: True}) + + with patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=unrelated_ca_pem()), + ): + assert await _async_pinned_ca(hass, entry, PANEL_LOOPBACK, 8080) is None + + # Nothing stored, and still pending: the next setup tries again, and until + # then the entry runs unpinned exactly as a failed fetch leaves it. + assert CONF_PANEL_CA_PEM not in entry.data + assert entry.data[PANEL_CA_PENDING] is True + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_the_deferred_pin_still_stores_the_panels_own_ca( + hass: HomeAssistant, panel: Panel +) -> None: + """The check must not cost a healthy upgrade its pin.""" + entry = _entry(hass, panel, **{PANEL_CA_PENDING: True}) + + with patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ): + assert await _async_pinned_ca(hass, entry, PANEL_LOOPBACK, 8080) == panel.ca_pem + + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + assert PANEL_CA_PENDING not in entry.data + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_the_deferred_pin_checks_the_broker_port_an_upgraded_entry_actually_has( + hass: HomeAssistant, panel: Panel +) -> None: + """The one population this pin exists for has no `https_port` and never will. + + `https_port` and `panel_ca_pending` were added in the same commit, so an + entry reaching the deferred pin is by definition migrated from before either + existed. Checking a port such an entry cannot hold means checking 443 for + all of them, which refuses every pre-pinning install whose TLS is somewhere + else -- behind a port forward, a NAT rule, a proxy -- and leaves exactly + those entries on the unauthenticated refetch path for good. + + The broker's port is on every v2 entry by construction and is the connection + the anchor is used for, so it is what the anchor is checked against. + """ + entry = _entry(hass, panel, **{PANEL_CA_PENDING: True}) + assert CONF_HTTPS_PORT not in entry.data + # Nothing is listening on 443 here, so a check made there could not pass. + assert entry.data[CONF_EBUS_BROKER_PORT] == panel.port + + with patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ): + assert await _async_pinned_ca(hass, entry, PANEL_LOOPBACK, 8080) == panel.ca_pem + + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + assert PANEL_CA_PENDING not in entry.data + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_the_deferred_pin_reaches_a_named_host_by_its_address( + hass: HomeAssistant, panel: Panel +) -> None: + """A migrated entry recorded by name is checked over the address the leaf names. + + The panel's leaf names the addresses it knows itself by. An entry configured + with a hostname -- an add-on's container name, a search-domain short name -- + would fail hostname verification against it and never pin, so the address it + resolves to is tried too, exactly as the config flow does. + """ + entry = _entry(hass, panel, **{CONF_HOST: PANEL_SHORTNAME, PANEL_CA_PENDING: True}) + + with ( + resolving_to_loopback(PANEL_SHORTNAME), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ), + ): + pinned = await _async_pinned_ca(hass, entry, PANEL_SHORTNAME, 8080) + + assert pinned == panel.ca_pem + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + + +# ---------- the re-pin the CA-changed repair performs ---------- + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_the_repair_refuses_a_ca_that_signs_nothing_the_panel_serves( + hass: HomeAssistant, panel: Panel +) -> None: + """The repair asks a person to accept a fingerprint; it must be a real one. + + A CA that does not sign what the panel serves cannot be the panel's, so the + user is never shown its fingerprint and the standing pin is left alone. The + issue stays raised, so the repair can be run again once whatever answered + the fetch is out of the way. + """ + entry = _entry(hass, panel, **{CONF_PANEL_CA_PEM: panel.ca_pem}) + flow = PanelCAChangedRepairFlow(entry.entry_id) + flow.hass = hass + + with patch( + "custom_components.span_panel.repairs.async_fetch_panel_ca", + new=AsyncMock(return_value=unrelated_ca_pem()), + ): + result = await flow.async_step_init() + + assert result["type"] == "abort" + assert result["reason"] == "ca_leaf_mismatch" + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_the_repair_still_offers_a_ca_the_panel_serves_a_leaf_for( + hass: HomeAssistant, panel: Panel +) -> None: + """A panel that legitimately rotated both certificate and authority re-pins.""" + entry = _entry(hass, panel, **{CONF_PANEL_CA_PEM: unrelated_ca_pem()}) + flow = PanelCAChangedRepairFlow(entry.entry_id) + flow.hass = hass + + with patch( + "custom_components.span_panel.repairs.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ): + shown = await flow.async_step_init() + assert shown["type"] == "form" + assert shown["description_placeholders"] is not None + assert shown["description_placeholders"]["fingerprint"] + + with patch.object(hass.config_entries, "async_reload", new=AsyncMock()) as reload: + done = await flow.async_step_confirm({}) + + assert done["type"] == "create_entry" + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + reload.assert_awaited_once_with(entry.entry_id) diff --git a/tests/test_panel_sensors.py b/tests/test_panel_sensors.py index 746ed1c9..60e933c6 100644 --- a/tests/test_panel_sensors.py +++ b/tests/test_panel_sensors.py @@ -202,24 +202,33 @@ def test_software_version_extra_state_attributes_panel_size( assert attrs is not None assert attrs["panel_size"] == 32 - def test_software_version_extra_state_attributes_wifi_ssid( + def test_software_version_no_longer_carries_the_wifi_ssid( self, mock_coordinator: MagicMock ) -> None: - """Test that wifi_ssid appears in extra_state_attributes when present.""" + """The SSID moved to the Wi-Fi Link binary sensor, deliberately. + + A network name on a firmware-version sensor was incoherent; it only sat + here because `panel_size` was already occupying the attribute block. The + value is not lost -- `SpanPanelWifiLinkBinarySensor` publishes it, and + `tests/test_metadata_sweep.py` reads it back out of the capture there. + Asserted even with an SSID published, so a restored read fails here + rather than passing on a snapshot that happens to carry none. + """ description = next(d for d in STATUS_SENSORS if d.key == "software_version") - snapshot = SpanPanelSnapshotFactory.create(panel_size=24, wifi_ssid="MyNetwork") + snapshot = SpanPanelSnapshotFactory.create(panel_size=24, wifi_ssid="synthetic-network") mock_coordinator.data = snapshot sensor = SpanPanelStatus(mock_coordinator, description, snapshot) attrs = sensor.extra_state_attributes assert attrs is not None + assert "wifi_ssid" not in attrs + # The attribute block did not collapse; only the SSID left it. assert attrs["panel_size"] == 24 - assert attrs["wifi_ssid"] == "MyNetwork" - def test_software_version_extra_state_attributes_no_wifi( + def test_software_version_keeps_panel_size_without_a_wifi_ssid( self, mock_coordinator: MagicMock ) -> None: - """Test that wifi_ssid is omitted when None.""" + """A panel publishing no SSID is indistinguishable here, which is the point.""" description = next(d for d in STATUS_SENSORS if d.key == "software_version") snapshot = SpanPanelSnapshotFactory.create(panel_size=16, wifi_ssid=None) diff --git a/tests/test_pcs.py b/tests/test_pcs.py new file mode 100644 index 00000000..b80ea0f0 --- /dev/null +++ b/tests/test_pcs.py @@ -0,0 +1,853 @@ +"""The Power Control System, surfaced as three entities and fifteen attributes. + +Thirteen of the attributes are on the effective-limit sensor and two are on +every circuit's power sensor. + +`energy.ebus.capability.pcs` 0.3 is the largest capability the enclosure +publishes — sixteen properties on the panel, two on every circuit — and nothing +read a byte of it. The capability itself says what to surface: it reconciles +every active import constraint to one enforced current limit, and "what `pcs` +publishes is the **result**: the effective `import-limit` and the +`binding-constraint`". So the result is the entities, and the arbitration behind +it is their attributes. + +**The capture is a PCS that is switched off, and every test here is written +around that.** Every limit is `0.0`, every enablement `UNCONFIGURED`, every +boolean `false`. Uniform data makes an assertion cheap to satisfy for the wrong +reason — an entity wired to the neighbouring property reports the identical +value, and an entity hardcoding a zero agrees with the wire by accident. So no +state or attribute here is asserted against the captured values. Presence is +checked against the capture; every *reading* is proved by republishing a value +that differs from the captured one and from every sibling's, and the sensor's +whole attribute dictionary is compared at once, so a wrong wiring shows up as a +value under the wrong name rather than as a value that happens to match. +""" + +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.binary_sensor import ( + PCS_ACTIVE_SENSOR, + SpanPanelBinarySensor, + SpanPanelBinarySensorEntityDescription, +) +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + DerivedReason, + Producibility, +) +from custom_components.span_panel.helpers import detect_capabilities, has_pcs +from custom_components.span_panel.sensor import create_circuit_sensors, create_pcs_sensors +from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor +from custom_components.span_panel.sensor_definitions import ( + PCS_BINDING_CONSTRAINT_OPTIONS, + PCS_CONSTRAINT_FAMILIES, + PCS_SENSORS, +) +from custom_components.span_panel.sensor_panel import SpanPcsSensor +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import CONF_HOST, UnitOfElectricCurrent +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.typing import StateType + +from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory, pv_binding_for + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +NODE = "pcs" + +IMPORT_LIMIT_KEY = "pcs_import_limit" +BINDING_CONSTRAINT_KEY = "pcs_binding_constraint" + +# A circuit the capture reports as PCS-managed, and one it reports as not. +_SOURCES = ("feed", "operator", "off-grid", "requested") +"""The catalog's four amps-native constraint classes, in the order it names them.""" + +MANAGED_CIRCUIT = "0ab966b95f92a6a51ec548485aa85f54" +UNMANAGED_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" + +# One republished value per panel property, every one different from the +# captured value *and* from every sibling's. That distinctness is the whole +# apparatus of this module: against a capture of zeros, `false` and +# `UNCONFIGURED`, an attribute asserted to equal what was published is satisfied +# by eleven wrong wirings as easily as by the right one. +# +# Two of the sixteen cannot be made unique, and saying why matters more than +# hiding it. The enablement enum has four members and one of them is what the +# capture already publishes, so four families can differ from the capture and +# from each other in at most three ways. Every `-active` flag is worse: all four +# are `false` in the capture, so all four must be republished `true` to be +# testing anything at all. +# +# Telling those apart is what `test_republishing_any_property_moves_only_what_reads_it` +# is for. It flips one property at a time on top of this state and requires +# exactly one observable to move, which catches a cross-wiring the dictionary +# comparison below cannot see. +_CONFIGURED: dict[str, str] = { + "enabled": "true", + "active": "true", + "import-limit": "37.5", + "binding-constraint": "DOE", + "feed-import-limit": "100.0", + "feed-import-limit-enablement": "ENABLED", + "feed-import-limit-active": "true", + "operator-import-limit": "62.5", + "operator-import-limit-enablement": "DISABLED", + "operator-import-limit-active": "true", + "off-grid-import-limit": "25.0", + "off-grid-import-limit-enablement": "UNSPECIFIED", + "off-grid-import-limit-active": "true", + "requested-import-limit": "80.0", + "requested-import-limit-enablement": "ENABLED", + "requested-import-limit-active": "true", +} + +# What `pcs_import_limit` must publish beside its state for `_CONFIGURED`. The +# effective limit and the binding constraint are absent: those are the two +# entities, and repeating an entity's state as its own attribute would be a +# second copy to keep in step. +_CONFIGURED_ATTRIBUTES: dict[str, float | str | bool] = { + "pcs_enabled": True, + "feed_import_limit": 100.0, + "feed_import_limit_enablement": "ENABLED", + "feed_import_limit_active": True, + "operator_import_limit": 62.5, + "operator_import_limit_enablement": "DISABLED", + "operator_import_limit_active": True, + "off_grid_import_limit": 25.0, + "off_grid_import_limit_enablement": "UNSPECIFIED", + "off_grid_import_limit_active": True, + "requested_import_limit": 80.0, + "requested_import_limit_enablement": "ENABLED", + "requested_import_limit_active": True, +} + + +@pytest.fixture(autouse=True) +def _mock_entity_registry() -> Any: + """Patch entity registry lookups used during sensor construction.""" + registry = MagicMock() + registry.async_get_entity_id.return_value = None + with patch( + "custom_components.span_panel.sensor_base.er.async_get", + return_value=registry, + ): + yield registry + + +def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: + """A coordinator-like mock carrying one snapshot.""" + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = MockConfigEntry( + domain="span_panel", + data={CONF_HOST: "192.168.1.50"}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + return coordinator + + +def _published(property_id: str, device_id: str = SCHEMA_ONE_PANEL) -> str: + """What the capture publishes on one PCS topic, or fail saying it does not.""" + value = schema_one_tree()[device_id].get(f"{NODE}/{property_id}") + assert value is not None, f"{device_id} publishes no {NODE}/{property_id} in the capture" + return value + + +def _republishing(device_id: str = SCHEMA_ONE_PANEL, **properties: str) -> SpanPanelSnapshot: + """A snapshot from the capture with some PCS topics rewritten.""" + tree = schema_one_tree() + for property_id, value in properties.items(): + tree[device_id][f"{NODE}/{property_id.replace('_', '-')}"] = value + return schema_one_snapshot(tree) + + +def _configured() -> SpanPanelSnapshot: + """A snapshot of a PCS that is switched on, every property distinct. + + The capture cannot be used for a reading test — see the module docstring — + so this is the state the reading tests run against. + """ + tree = schema_one_tree() + for property_id, value in _CONFIGURED.items(): + tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"] = value + return schema_one_snapshot(tree) + + +def _without(*property_ids: str, device_id: str = SCHEMA_ONE_PANEL) -> SpanPanelSnapshot: + """A snapshot from a panel that stopped publishing (and declaring) properties.""" + tree = schema_one_tree() + description = json.loads(tree[device_id]["$description"]) + for property_id in property_ids: + del tree[device_id][f"{NODE}/{property_id}"] + del description["nodes"][NODE]["properties"][property_id] + tree[device_id]["$description"] = json.dumps(description) + return schema_one_snapshot(tree) + + +def _without_node(device_id: str = SCHEMA_ONE_PANEL) -> SpanPanelSnapshot: + """A snapshot from a capture with no `pcs` node on one device at all.""" + tree = schema_one_tree() + for topic in [t for t in tree[device_id] if t.startswith(f"{NODE}/")]: + del tree[device_id][topic] + description = json.loads(tree[device_id]["$description"]) + del description["nodes"][NODE] + tree[device_id]["$description"] = json.dumps(description) + return schema_one_snapshot(tree) + + +def _sensors(snapshot: SpanPanelSnapshot) -> dict[str, SpanPcsSensor]: + """Whatever the platform creates for this snapshot, keyed by description key.""" + created = create_pcs_sensors(_coordinator(snapshot), snapshot) + return {sensor.entity_description.key: sensor for sensor in created} + + +def _state(snapshot: SpanPanelSnapshot, key: str) -> StateType | date | datetime | Decimal: + """The state one PCS sensor reports for a snapshot. + + Typed as `SensorEntity.native_value` is rather than narrowed to what these + two sensors happen to report: narrowing here would be the test asserting its + own expectation twice, once in the annotation and once in the body. + """ + sensor = _sensors(snapshot)[key] + sensor._update_native_value() + return sensor.native_value + + +def _attributes(snapshot: SpanPanelSnapshot, key: str) -> dict[str, Any]: + return _sensors(snapshot)[key].extra_state_attributes or {} + + +def _binary( + snapshot: SpanPanelSnapshot, +) -> SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]: + """The `pcs_active` binary sensor, updated from the snapshot.""" + sensor: SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription] = SpanPanelBinarySensor( + _coordinator(snapshot), PCS_ACTIVE_SENSOR + ) + sensor.async_write_ha_state = MagicMock() + sensor._handle_coordinator_update() + return sensor + + +def _circuit_power_sensor(snapshot: SpanPanelSnapshot, circuit_id: str) -> SpanCircuitPowerSensor: + """The power sensor for one circuit, which is where PCS participation lands.""" + coordinator = _coordinator(snapshot) + created = create_circuit_sensors(coordinator, snapshot, coordinator.config_entry) + for sensor in created: + if isinstance(sensor, SpanCircuitPowerSensor) and sensor.circuit_id == circuit_id: + return sensor + raise AssertionError(f"no power sensor created for circuit {circuit_id}") + + +def _circuit_attributes(snapshot: SpanPanelSnapshot, circuit_id: str) -> dict[str, Any]: + return _circuit_power_sensor(snapshot, circuit_id).extra_state_attributes or {} + + +# --------------------------------------------------------------------------- +# The premise: what the capture carries, and why it cannot prove a reading +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_the_whole_system_surface() -> None: + """Guard the premise for every test below. Sixteen properties, each declared + and published; a capture that dropped one would make its absence test + vacuous rather than failing.""" + declared = json.loads(schema_one_tree()[SCHEMA_ONE_PANEL]["$description"])["nodes"][NODE] + + assert set(declared["properties"]) == set(_CONFIGURED) + for property_id in _CONFIGURED: + assert _published(property_id) + + +def test_the_capture_is_a_pcs_that_is_switched_off() -> None: + """The fact this module is written around, asserted rather than assumed. + + Every limit zero, every enablement `UNCONFIGURED`, every boolean false. That + is why no reading below is proved by comparing an entity against the + capture: fifteen wrong wirings report the same value as the right one. Were + the capture ever retaken with a configured PCS, this fails first and says + so, rather than the reading tests silently becoming redundant. + """ + assert _published("enabled") == "false" + assert _published("active") == "false" + assert _published("binding-constraint") == "NONE" + assert {float(_published(f"{source}-import-limit")) for source in _SOURCES} == {0.0} + assert {_published(f"{source}-import-limit-enablement") for source in _SOURCES} == { + "UNCONFIGURED" + } + assert float(_published("import-limit")) == 0.0 + + +def test_the_republished_values_are_all_different_from_each_other() -> None: + """Guard the apparatus itself. + + Every reading test below rests on `_CONFIGURED` giving each property a value + no sibling shares — that is what turns "the attribute equals what was + published" into a statement about which property it came from. Two entries + accidentally made equal would silently weaken every one of them. + """ + numeric = [value for value in _CONFIGURED.values() if value.replace(".", "").isdigit()] + assert len(set(numeric)) == len(numeric) + + # As distinct as the enum allows: four families, four members, one of which + # is the value the capture already publishes. + declared = json.loads(schema_one_tree()[SCHEMA_ONE_PANEL]["$description"])["nodes"][NODE][ + "properties" + ]["feed-import-limit-enablement"]["format"] + enablements = {_CONFIGURED[f"{source}-import-limit-enablement"] for source in _SOURCES} + assert len(enablements) == len(declared.split(",")) - 1 + + for property_id, value in _CONFIGURED.items(): + assert value != _published(property_id), f"{property_id} is not being changed" + + +# --------------------------------------------------------------------------- +# The effective limit, and the arbitration it carries as attributes +# --------------------------------------------------------------------------- + + +def test_the_import_limit_sensor_reports_the_effective_limit() -> None: + """The headline reading, on a PCS that is switched on.""" + assert _state(_configured(), IMPORT_LIMIT_KEY) == float(_CONFIGURED["import-limit"]) + + +def test_the_effective_limit_is_not_any_of_its_inputs() -> None: + """The capability calls `import-limit` the arbitration *result*. A sensor + wired to the FSR would be plausible and wrong, so the four inputs are + republished to one shared value the result does not share.""" + snapshot = _republishing( + **{ + "import_limit": "12.5", + "feed_import_limit": "99.0", + "operator_import_limit": "99.0", + "off_grid_import_limit": "99.0", + "requested_import_limit": "99.0", + } + ) + + assert _state(snapshot, IMPORT_LIMIT_KEY) == 12.5 + + +def test_republishing_the_effective_limit_moves_the_sensor() -> None: + """The mutation proof, twice over: two values, neither the captured zero.""" + assert _state(_republishing(import_limit="15.0"), IMPORT_LIMIT_KEY) == 15.0 + assert _state(_republishing(import_limit="16.5"), IMPORT_LIMIT_KEY) == 16.5 + + +def test_zero_amps_is_a_reading_and_not_an_absence() -> None: + """The captured state, and a real one: the PCS is permitting no import at + all. An entity that treated it as missing would go blank exactly when the + panel is most restrictive.""" + assert _state(schema_one_snapshot(), IMPORT_LIMIT_KEY) == 0.0 + + +def test_the_arbitration_rides_as_attributes_on_the_limit() -> None: + """The whole attribute dictionary at once, against a PCS where every value + is distinct. + + Compared as a dictionary rather than key by key, deliberately. Twelve of + these are the same shape and the capture makes them identical, so the + failure worth catching is a value landing under the wrong name — which a + per-key assertion on a matching value cannot see and this does. + """ + assert _attributes(_configured(), IMPORT_LIMIT_KEY) == _CONFIGURED_ATTRIBUTES + + +def test_the_attributes_are_the_inputs_and_not_the_result() -> None: + """`import-limit` and `binding-constraint` are the two entities. Repeating + either as an attribute of the other would be a second copy to keep in + step.""" + attributes = _attributes(_configured(), IMPORT_LIMIT_KEY) + + assert "import_limit" not in attributes + assert "binding_constraint" not in attributes + + +@pytest.mark.parametrize("family", PCS_CONSTRAINT_FAMILIES, ids=lambda f: f.attribute) +def test_each_constraint_family_publishes_its_own_three_attributes(family: Any) -> None: + """Every family contributes a limit, an enablement and an active flag, and + the names extend the limit's. Asserted per family so a copied line that left + one family reading another's fields fails naming the family.""" + snapshot = _configured() + pcs = snapshot.pcs + assert pcs is not None + attributes = _attributes(snapshot, IMPORT_LIMIT_KEY) + + assert attributes[family.attribute] == family.limit_fn(pcs) + assert attributes[f"{family.attribute}_enablement"] == family.enablement_fn(pcs) + assert attributes[f"{family.attribute}_active"] == family.active_fn(pcs) + + +@pytest.mark.parametrize("property_id", sorted(_CONFIGURED)) +def test_republishing_any_property_moves_only_what_reads_it(property_id: str) -> None: + """The strongest statement this module makes, and the one the uniform + capture demands. + + One property is republished on top of the fully-configured PCS, and the + entity states plus the whole attribute dictionary are compared against the + unmodified configured baseline. Exactly one thing may move. An attribute + wired to a neighbouring property moves when it should not, which no + assertion against the captured zeros could ever detect — every sibling + already holds the value a wrong wiring would report. + """ + tree = schema_one_tree() + for name, value in _CONFIGURED.items(): + tree[SCHEMA_ONE_PANEL][f"{NODE}/{name}"] = value + + def observe(snapshot: SpanPanelSnapshot) -> dict[str, object]: + observed: dict[str, object] = { + f"state:{IMPORT_LIMIT_KEY}": _state(snapshot, IMPORT_LIMIT_KEY), + f"state:{BINDING_CONSTRAINT_KEY}": _state(snapshot, BINDING_CONSTRAINT_KEY), + "state:pcs_active": _binary(snapshot).is_on, + } + observed.update(_attributes(snapshot, IMPORT_LIMIT_KEY)) + return observed + + baseline = observe(schema_one_snapshot(tree)) + + # A second value for this property, again unlike anything else published. + republished = "false" if _CONFIGURED[property_id] == "true" else "true" + if property_id == "binding-constraint": + republished = "OPERATOR" + elif property_id.endswith("-enablement"): + republished = "DISABLED" if _CONFIGURED[property_id] != "DISABLED" else "ENABLED" + elif property_id.endswith("import-limit"): + republished = "7.25" + + tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"] = republished + after = observe(schema_one_snapshot(tree)) + + moved = {name for name, value in after.items() if baseline[name] != value} + assert len(moved) == 1, f"republishing {property_id} moved {sorted(moved)}" + + +@pytest.mark.parametrize("property_id", sorted(_CONFIGURED)) +def test_an_unpublished_property_is_omitted_rather_than_shown_empty(property_id: str) -> None: + """Three of the four constraint classes are `MAY`, so an absent family is + conformant firmware. An attribute present and holding `None` would read as a + reading the panel failed to produce, which is a different claim.""" + attributes = _attributes(_without(property_id), IMPORT_LIMIT_KEY) + + assert None not in attributes.values() + + +def test_a_panel_publishing_no_constraint_families_still_shows_its_limit() -> None: + """The minimum conformant PCS: the two results, and none of the working.""" + snapshot = _without( + *[ + f"{source}-import-limit{suffix}" + for source in _SOURCES + for suffix in ("", "-enablement", "-active") + ], + "enabled", + ) + + assert _state(snapshot, IMPORT_LIMIT_KEY) == 0.0 + assert _attributes(snapshot, IMPORT_LIMIT_KEY) == {} + + +# --------------------------------------------------------------------------- +# The binding constraint +# --------------------------------------------------------------------------- + + +def test_the_binding_constraint_is_the_published_enum_lowercased() -> None: + """Lowercase because Home Assistant looks the state up as a translation key, + which its own contract restricts to `[a-z0-9-_]+`.""" + assert ( + _state(_configured(), BINDING_CONSTRAINT_KEY) == _CONFIGURED["binding-constraint"].lower() + ) + + +@pytest.mark.parametrize("republished", ["FSR", "DOE", "VOLTAGE", "OFF_GRID", "OPERATOR"]) +def test_republishing_the_binding_constraint_moves_the_sensor(republished: str) -> None: + """Members of the enum the panel's own `$description` declares, none of them + the captured `NONE`, so a sensor pinned to the capture cannot report any.""" + assert _state(_republishing(binding_constraint=republished), BINDING_CONSTRAINT_KEY) == ( + republished.lower() + ) + + +def test_the_declared_options_are_the_enum_the_panel_declares() -> None: + """The sensor's "Possible states" against the wire's `format`, so a firmware + that widens the enum is caught here rather than by a runtime append. + + This is the assertion against the catalog: the fixture's `$format` is a + verbatim copy of the eight members `capabilities/pcs.md` lists, so checking + the options against it checks them against the catalog without a third + hand-written copy in this file. + """ + declared = json.loads(schema_one_tree()[SCHEMA_ONE_PANEL]["$description"])["nodes"][NODE][ + "properties" + ]["binding-constraint"]["format"] + + assert set(PCS_BINDING_CONSTRAINT_OPTIONS) == {value.lower() for value in declared.split(",")} + + +def test_the_binding_constraint_publishes_no_attributes_of_its_own() -> None: + """The other half of putting the pairing on the description: one PCS sensor + carries thirteen attributes and the other carries none, and which is which is + data rather than a comparison against `key` inside the entity.""" + assert _sensors(_configured())[BINDING_CONSTRAINT_KEY].extra_state_attributes is None + + +def test_none_is_a_binding_constraint_and_not_an_absence() -> None: + """The captured value. `NONE` means nothing is constraining import, which is + a state the catalog defines; reporting it as unknown would lose that.""" + assert _state(schema_one_snapshot(), BINDING_CONSTRAINT_KEY) == "none" + + +def test_a_binding_constraint_that_stops_arriving_goes_unknown() -> None: + """Absence after setup is a different event from absence at setup: an entity + a user already has cannot be deleted, so it degrades instead.""" + sensor = _sensors(schema_one_snapshot())[BINDING_CONSTRAINT_KEY] + + sensor.coordinator.data = _without("binding-constraint") + sensor._update_native_value() + + assert sensor.native_value == "unknown" + + +# --------------------------------------------------------------------------- +# The activity binary sensor +# --------------------------------------------------------------------------- + + +def test_the_activity_sensor_follows_the_published_flag() -> None: + """Both directions, because the capture only shows one of them.""" + assert _binary(_republishing(active="true")).is_on is True + assert _binary(_republishing(active="false")).is_on is False + + +def test_activity_is_not_enablement() -> None: + """A configured PCS spends most of its life enabled and inactive, which is + exactly the state a sensor reading the wrong flag would misreport. Both are + `false` in the capture, so crossing them there is invisible.""" + snapshot = _republishing(enabled="true", active="false") + + assert _binary(snapshot).is_on is False + assert _attributes(snapshot, IMPORT_LIMIT_KEY)["pcs_enabled"] is True + + +def test_the_activity_sensor_goes_unknown_when_the_flag_stops_arriving() -> None: + """`None` reaches Home Assistant as unknown, not as unavailable: the panel + is reachable and the entity is fine, the fact simply is not being stated.""" + assert _binary(_without("active")).is_on is None + + +def test_the_activity_sensor_is_a_running_diagnostic() -> None: + """A panel throttling the user's supply is a state worth seeing, but it + belongs beside the other panel-state sensors rather than the power + readings.""" + assert PCS_ACTIVE_SENSOR.device_class is BinarySensorDeviceClass.RUNNING + assert PCS_ACTIVE_SENSOR.entity_category is EntityCategory.DIAGNOSTIC + assert PCS_ACTIVE_SENSOR.entity_registry_enabled_default is True + + +# --------------------------------------------------------------------------- +# Absence: no node, no PCS, a flat panel +# --------------------------------------------------------------------------- + + +def test_the_capture_creates_all_three_entities() -> None: + created = _sensors(schema_one_snapshot()) + + assert set(created) == {IMPORT_LIMIT_KEY, BINDING_CONSTRAINT_KEY} + assert has_pcs(schema_one_snapshot()) is True + + +def test_a_panel_with_no_pcs_node_gets_no_entities() -> None: + """The presence gate, from the tree end.""" + snapshot = _without_node() + + assert has_pcs(snapshot) is False + assert _sensors(snapshot) == {} + + +def test_a_flat_panel_gets_no_entities() -> None: + """The same absence by the other route: no flat panel declares the + capability, so the factory's default snapshot carries no PCS at all.""" + snapshot = SpanPanelSnapshotFactory.create() + + assert has_pcs(snapshot) is False + assert _sensors(snapshot) == {} + + +def test_a_switched_off_pcs_still_gets_its_entities() -> None: + """The reason the gate is the node and not a value, stated as a test. + + The capture publishes `0.0` on every limit and `false` on every flag. A + creation rule that read those as absence would delete the entities of every + panel whose PCS is merely unconfigured — which is the state most panels are + in, and the state a user most wants reported. + """ + snapshot = schema_one_snapshot() + + assert snapshot.pcs is not None + assert snapshot.pcs.import_limit_a == 0.0 + assert set(_sensors(snapshot)) == {IMPORT_LIMIT_KEY, BINDING_CONSTRAINT_KEY} + assert _binary(snapshot).is_on is False + + +def test_a_declared_node_that_publishes_nothing_still_gets_its_entities() -> None: + """Mid-discovery, and the same rule: the panel has announced the capability + and not yet retained its topics, so the entities exist and read unknown.""" + tree = schema_one_tree() + for property_id in _CONFIGURED: + del tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"] + snapshot = schema_one_snapshot(tree) + + assert set(_sensors(snapshot)) == {IMPORT_LIMIT_KEY, BINDING_CONSTRAINT_KEY} + assert _state(snapshot, IMPORT_LIMIT_KEY) is None + assert _attributes(snapshot, IMPORT_LIMIT_KEY) == {} + + +def test_a_reading_that_stops_arriving_goes_unknown_rather_than_stale() -> None: + """The last value persisting would be worse than unknown: a panel reporting + a 40 A limit forever is indistinguishable from one still enforcing it.""" + sensor = _sensors(_configured())[IMPORT_LIMIT_KEY] + + sensor.coordinator.data = _without("import-limit") + sensor._update_native_value() + + assert sensor.native_value is None + + +def test_the_pcs_appearing_is_a_capability_change() -> None: + """Which is how a panel that gains the node mid-life gets the entities: the + coordinator reloads on a new capability.""" + assert "pcs" not in detect_capabilities(SpanPanelSnapshotFactory.create()) + assert "pcs" in detect_capabilities(schema_one_snapshot()) + assert "pcs" not in detect_capabilities(_without_node()) + + +# --------------------------------------------------------------------------- +# Circuit participation +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_participation_on_two_disagreeing_circuits() -> None: + """Guard the premise for the circuit tests: an attribute wired to a constant + would satisfy either circuit alone.""" + assert _published("managed", MANAGED_CIRCUIT) == "true" + assert _published("managed", UNMANAGED_CIRCUIT) == "false" + assert _published("priority", MANAGED_CIRCUIT) != _published("priority", UNMANAGED_CIRCUIT) + + +def test_a_circuit_sensor_carries_its_pcs_participation() -> None: + """Read against the capture rather than against literals, on both circuits.""" + snapshot = schema_one_snapshot() + + managed = _circuit_attributes(snapshot, MANAGED_CIRCUIT) + unmanaged = _circuit_attributes(snapshot, UNMANAGED_CIRCUIT) + + assert managed["pcs_managed"] is True + assert unmanaged["pcs_managed"] is False + assert managed["pcs_priority"] == int(_published("priority", MANAGED_CIRCUIT)) + assert unmanaged["pcs_priority"] == int(_published("priority", UNMANAGED_CIRCUIT)) + + +def test_republishing_participation_moves_the_circuit_attributes() -> None: + """The attribute-mutation proof. The republished priority is outside the + range any circuit uses in the capture, so an attribute wired to another + circuit — or to the load-shed priority beside it — cannot report it.""" + snapshot = _republishing(device_id=MANAGED_CIRCUIT, managed="false", priority="42") + + attributes = _circuit_attributes(snapshot, MANAGED_CIRCUIT) + + assert attributes["pcs_managed"] is False + assert attributes["pcs_priority"] == 42 + # The other circuit is untouched, so a shared read would show here. + assert _circuit_attributes(snapshot, UNMANAGED_CIRCUIT)["pcs_priority"] == int( + _published("priority", UNMANAGED_CIRCUIT) + ) + + +def test_pcs_priority_is_not_the_shed_priority_beside_it() -> None: + """Two policies on one relay, and two attributes on one sensor. The catalog + keeps them apart because they answer different questions, and they do not + even share a value space.""" + attributes = _circuit_attributes(schema_one_snapshot(), MANAGED_CIRCUIT) + + assert isinstance(attributes["pcs_priority"], int) + assert isinstance(attributes["shed_priority"], str) + + +@pytest.mark.parametrize("property_id", ["managed", "priority"]) +def test_a_circuit_that_does_not_publish_participation_omits_the_attribute( + property_id: str, +) -> None: + """Both properties are `MAY`. `False` and `0` would each be a claim the + panel never made, and an attribute holding `None` reads as a failed + reading.""" + snapshot = _without(property_id, device_id=MANAGED_CIRCUIT) + + assert f"pcs_{property_id}" not in _circuit_attributes(snapshot, MANAGED_CIRCUIT) + + +def test_a_circuit_outside_any_pcs_shows_neither_attribute() -> None: + snapshot = _without_node(MANAGED_CIRCUIT) + attributes = _circuit_attributes(snapshot, MANAGED_CIRCUIT) + + assert "pcs_managed" not in attributes + assert "pcs_priority" not in attributes + # The circuit's own readings are unaffected. + assert "shed_priority" in attributes + + +def test_a_flat_circuit_shows_neither_attribute() -> None: + """No flat circuit declares a `pcs` node, so the attributes simply do not + appear rather than appearing empty.""" + circuit = SpanCircuitSnapshotFactory.create(circuit_id="1", name="Kitchen") + snapshot = SpanPanelSnapshotFactory.create(circuits={circuit.circuit_id: circuit}) + + assert circuit.pcs_managed is None + attributes = _circuit_attributes(snapshot, circuit.circuit_id) + + assert "pcs_managed" not in attributes + assert "pcs_priority" not in attributes + + +# --------------------------------------------------------------------------- +# Shape of the entities, and the conformance annotations +# --------------------------------------------------------------------------- + + +def test_the_import_limit_is_an_ampere_measurement_filed_as_a_diagnostic() -> None: + """Filed where every other ampere value in this integration is filed. + + This reverses an earlier decision, which read `DIAGNOSTIC` as "about the + integration's health" and reasoned that a PCS throttling import is a fact + about the user's electricity supply instead. Home Assistant's category is + about the *device* — "a configuration parameter or diagnostics of a device" — + and by that earlier standard `l1_voltage` and `main_breaker_rating` are facts + about the user's supply too, and both are diagnostic. + + The line this codebase actually draws is not "amps are diagnostic": + `circuit_current` and `evse_advertised_current` are amps and are correctly + primary, because they are what a circuit or a charger is doing right now. It + is that the panel's own voltages, lug currents, breaker ratings and limits + describe the installation rather than its activity. Import Limit is one of + those, and was the only entity outside the rule — and the only member of its + own PCS group on the primary card. + + It is a ceiling the panel arbitrated rather than a measurement of what is + flowing, and it carries the four limits it was arbitrated from as attributes. + Changed while the entity was days old and had appeared in exactly one + release's addition notice, so no installation has a dashboard or history + depending on where it sits. + + The category is presentational: automations, templates and long-term + statistics are unaffected, which is why `state_class` stays. + """ + description = next(d for d in PCS_SENSORS if d.key == IMPORT_LIMIT_KEY) + + assert description.device_class is SensorDeviceClass.CURRENT + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.native_unit_of_measurement == UnitOfElectricCurrent.AMPERE + assert description.entity_registry_enabled_default is True + assert description.entity_category is EntityCategory.DIAGNOSTIC + + +def test_the_binding_constraint_is_an_enum_diagnostic_enabled_by_default() -> None: + """It explains a number already on screen: short, rarely changing, and + useless filed where nobody finds it.""" + description = next(d for d in PCS_SENSORS if d.key == BINDING_CONSTRAINT_KEY) + + assert description.device_class is SensorDeviceClass.ENUM + assert description.entity_category is EntityCategory.DIAGNOSTIC + assert description.entity_registry_enabled_default is True + + +def test_the_declared_unit_matches_what_the_panel_declares() -> None: + """Home Assistant's unit against the tree's, for the one PCS path schema_1 + carries metadata for. A disagreement here is what the unit-mismatch Repair + reports at runtime.""" + from .adapter_fixtures import schema_one_metadata + + description = next(d for d in PCS_SENSORS if d.key == IMPORT_LIMIT_KEY) + + assert schema_one_metadata()["pcs.import_limit_a"].unit == ( + description.native_unit_of_measurement + ) + + +def test_the_entities_live_on_the_main_panel_device() -> None: + """The PCS is the enclosure's own capability, not a sub-device.""" + created = list(_sensors(schema_one_snapshot()).values()) + panel_device = _binary(schema_one_snapshot()).device_info + + assert panel_device is not None + for sensor in created: + assert sensor.device_info == panel_device + + +def test_every_pcs_entity_gets_a_distinct_unique_id() -> None: + """They live on one device and differ only by description key, so a key + reused from another panel sensor would silently collide.""" + created = _sensors(schema_one_snapshot()) + unique_ids = {sensor.unique_id for sensor in created.values()} | { + _binary(schema_one_snapshot()).unique_id + } + + assert len(unique_ids) == len(created) + 1 + + +def test_the_three_result_paths_are_exempt_as_schema_1_only() -> None: + """Pinned here as well as in the conformance suite because the reason is + specific to this capability: no flat panel declares `pcs` at all, so the + producible gate cannot be satisfied and the descriptions must stay derived. + schema_1 maps all three, which is what makes the annotation SCHEMA_1_ONLY + rather than NEITHER.""" + for path in ("pcs.import_limit_a", "pcs.binding_constraint", "pcs.active"): + assert RESIDUAL_EXEMPT_PATHS[path] is Producibility.SCHEMA_1_ONLY + + +def test_every_attribute_read_is_enumerated_as_neither() -> None: + """The fifteen fields nothing renders as a reading are still reads, and an + unenumerated read is invisible to the Repair machinery — which is the exact + hole `panel.wifi_ssid` sat in. Derived from the families rather than listed + again, so a fifth constraint class cannot be added without one.""" + expected = {"pcs.enabled", "circuit.pcs_managed", "circuit.pcs_priority"} + for family in PCS_CONSTRAINT_FAMILIES: + field = f"pcs.{family.attribute}" + expected |= {f"{field}_a", f"{field}_enablement", f"{field}_active"} + + for path in expected: + assert RESIDUAL_EXEMPT_PATHS[path] is Producibility.NEITHER + + +@pytest.mark.parametrize("description", [*PCS_SENSORS, PCS_ACTIVE_SENSOR], ids=lambda d: str(d.key)) +def test_each_description_names_its_field_as_well_as_its_reason(description: Any) -> None: + """`field_path` says what the entity's value is and `derived` says why that + path is outside the both-adapters gate. Leaving the first unset excuses the + entity from its Repair mention and from going unavailable when the panel + stops resolving the property.""" + assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD + assert description.field_path in RESIDUAL_EXEMPT_PATHS diff --git a/tests/test_platform_entry_scope.py b/tests/test_platform_entry_scope.py new file mode 100644 index 00000000..7877d063 --- /dev/null +++ b/tests/test_platform_entry_scope.py @@ -0,0 +1,255 @@ +"""Every platform hands the builders the entry it is setting up. + +The builders look device cards up within the entry they are given, so the entry +a platform passes decides which card each lookup finds. Nothing about a wrong +entry is loud: an adopted device resolves to no card and is re-minted under a +new identifier, which the registry reads as an entity replacement that takes +the history with it, and an extension row finds no card and is deferred, so its +entity never appears. Each platform is set up here against an entry that +already holds the cards, and asked for the entity only the right entry yields. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import AdoptedDevice, AdoptedProperty, ExtensionProperty, ExtensionSubject + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.adoption import adopted_identifier, adopted_unique_id +from custom_components.span_panel.binary_sensor import ( + async_setup_entry as binary_sensor_async_setup_entry, +) +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.extension import ( + extension_device_identifier, + extension_unique_id, +) +from custom_components.span_panel.number import async_setup_entry as number_async_setup_entry +from custom_components.span_panel.select import async_setup_entry as select_async_setup_entry +from custom_components.span_panel.sensor import async_setup_entry as sensor_async_setup_entry +from custom_components.span_panel.switch import async_setup_entry as switch_async_setup_entry + +from .factories import SpanPanelSnapshotFactory, pv_binding_for + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from homeassistant.helpers.entity import Entity + from span_panel_api import SpanPanelSnapshot + + PlatformSetup = Callable[..., Awaitable[None]] + +PANEL_SERIAL = "sp3-242424-001" +WIRE_ID = "generator-1" +SERIAL = "EX-0000-0001" +BATTERY = ExtensionSubject(kind="battery", instance_key=None) + + +@pytest.fixture +def loaded_panel(hass: HomeAssistant) -> tuple[MockConfigEntry, str]: + """Return an entry holding the panel, a battery card, and a device frozen at first sighting. + + The adopted card is registered under the device's wire id, as a setup before + its serial arrived would have left it. The snapshot each test builds carries + that serial, so only a lookup in this entry keeps the wire id. + """ + entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, unique_id=PANEL_SERIAL) + entry.add_to_hass(hass) + registry = dr.async_get(hass) + panel = registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, PANEL_SERIAL)}, + name="Span Panel", + ) + battery_identifier = extension_device_identifier(PANEL_SERIAL, BATTERY) + assert battery_identifier is not None + registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, battery_identifier)}, + name="Span Panel Battery", + via_device_id=panel.id, + ) + registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, adopted_identifier(PANEL_SERIAL, WIRE_ID))}, + name="Backup Generator", + via_device_id=panel.id, + ) + return entry, panel.id + + +async def _set_up( + hass: HomeAssistant, + loaded_panel: tuple[MockConfigEntry, str], + setup: PlatformSetup, + snapshot: SpanPanelSnapshot, +) -> set[str | None]: + """Run one platform's `async_setup_entry` and return the unique_ids it built.""" + entry, panel_device_id = loaded_panel + coordinator = MagicMock(data=snapshot) + coordinator.hass = hass + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = entry + coordinator.async_request_refresh = AsyncMock() + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id=panel_device_id, + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + added: list[Entity] = [] + await setup(hass, entry, lambda entities, *_args, **_kwargs: added.extend(entities)) + return {entity.unique_id for entity in added} + + +def _adopted_snapshot(declaration: AdoptedProperty) -> SpanPanelSnapshot: + device = AdoptedDevice( + device_id=WIRE_ID, + device_type="energy.ebus.device.generator", + name="Backup Generator", + model="GEN-9000", + serial_number=SERIAL, + properties=(declaration,), + ) + return replace( + SpanPanelSnapshotFactory.create_complete(serial_number=PANEL_SERIAL), + adopted_devices=(device,), + ) + + +def _declaration( + node_id: str, + property_id: str, + datatype: str, + *, + unit: str | None = None, + fmt: str | None = None, + settable: bool = False, + value: str, +) -> AdoptedProperty: + return AdoptedProperty( + node_id=node_id, + property_id=property_id, + datatype=datatype, + unit=unit, + format=fmt, + settable=settable, + value=value, + ) + + +@pytest.mark.parametrize( + ("setup", "declaration"), + [ + pytest.param( + sensor_async_setup_entry, + _declaration("meter", "active-power", "float", unit="W", value="2400"), + id="sensor", + ), + pytest.param( + binary_sensor_async_setup_entry, + _declaration("relay", "closed", "boolean", value="true"), + id="binary_sensor", + ), + pytest.param( + switch_async_setup_entry, + _declaration("relay", "enabled", "boolean", settable=True, value="true"), + id="switch", + ), + pytest.param( + select_async_setup_entry, + _declaration( + "control", "mode", "enum", fmt="AUTO,MANUAL,OFF", settable=True, value="AUTO" + ), + id="select", + ), + pytest.param( + number_async_setup_entry, + _declaration( + "control", + "power-setpoint", + "float", + unit="W", + fmt="0:5000:100", + settable=True, + value="2400", + ), + id="number", + ), + ], +) +async def test_an_adopted_device_keeps_the_identity_this_entry_froze( + hass: HomeAssistant, + loaded_panel: tuple[MockConfigEntry, str], + setup: PlatformSetup, + declaration: AdoptedProperty, +) -> None: + """The wire id survives the serial's arrival on every platform an adopted row reaches. + + The frozen id's presence is asserted rather than the re-minted id's absence, + because absence would pass just as well for a declaration that never reached + this platform -- and then nothing here would be about this platform's scope. + """ + unique_ids = await _set_up(hass, loaded_panel, setup, _adopted_snapshot(declaration)) + + assert adopted_unique_id(adopted_identifier(PANEL_SERIAL, WIRE_ID), declaration) in unique_ids + + +@pytest.mark.parametrize( + ("setup", "row"), + [ + pytest.param( + sensor_async_setup_entry, + ExtensionProperty( + subject=BATTERY, + node_id="battery-2", + property_id="cell-temperature", + datatype="float", + unit="°C", + value="31.4", + ), + id="sensor", + ), + pytest.param( + binary_sensor_async_setup_entry, + ExtensionProperty( + subject=BATTERY, + node_id="battery-2", + property_id="balancing", + datatype="boolean", + unit=None, + value="true", + ), + id="binary_sensor", + ), + ], +) +async def test_an_extension_row_finds_the_card_this_entry_holds( + hass: HomeAssistant, + loaded_panel: tuple[MockConfigEntry, str], + setup: PlatformSetup, + row: ExtensionProperty, +) -> None: + """A row on a registered card becomes an entity rather than waiting for a reload.""" + snapshot = replace( + SpanPanelSnapshotFactory.create_complete(serial_number=PANEL_SERIAL), + extension_properties=(row,), + ) + + unique_ids = await _set_up(hass, loaded_panel, setup, snapshot) + + assert ( + extension_unique_id(PANEL_SERIAL, row.subject, row.node_id, row.property_id) in unique_ids + ) diff --git a/tests/test_platform_setup_snapshot.py b/tests/test_platform_setup_snapshot.py new file mode 100644 index 00000000..76ec3663 --- /dev/null +++ b/tests/test_platform_setup_snapshot.py @@ -0,0 +1,85 @@ +"""Every platform builds its entities from the snapshot setup decided from. + +Setup resolves the PV binding from one snapshot and then forwards the +platforms, and the coordinator keeps receiving snapshots in between. A platform +that read `coordinator.data` could build an entity the binding never decided on +-- an inverter that arrived mid-setup would get a card of its own beside a +Solar card that reads it. So each platform reads `runtime_data.setup_snapshot`, +and whatever arrived later waits for the reload its capability token requests. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.core import HomeAssistant +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.binary_sensor import async_setup_entry as binary_sensor_setup +from custom_components.span_panel.button import async_setup_entry as button_setup +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.number import async_setup_entry as number_setup +from custom_components.span_panel.select import async_setup_entry as select_setup +from custom_components.span_panel.sensor import async_setup_entry as sensor_setup +from custom_components.span_panel.switch import async_setup_entry as switch_setup + +from .adapter_fixtures import schema_one_snapshot +from .factories import SpanPanelSnapshotFactory, pv_binding_for + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from homeassistant.helpers.entity import Entity + from span_panel_api import SpanPanelSnapshot + + PlatformSetup = Callable[..., Awaitable[None]] + + +async def _built( + hass: HomeAssistant, setup: PlatformSetup, decided: SpanPanelSnapshot, published: SpanPanelSnapshot +) -> set[str | None]: + """Run one platform's setup decided from `decided` while the coordinator holds `published`.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, unique_id=decided.serial_number) + entry.add_to_hass(hass) + coordinator = MagicMock(data=published) + coordinator.hass = hass + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = entry + coordinator.async_request_refresh = AsyncMock() + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(decided), + setup_snapshot=decided, + ) + added: list[Entity] = [] + await setup(hass, entry, lambda entities, *_args, **_kwargs: added.extend(entities)) + return {entity.unique_id for entity in added} + + +@pytest.mark.parametrize( + "setup", + [binary_sensor_setup, button_setup, number_setup, select_setup, sensor_setup, switch_setup], + ids=["binary_sensor", "button", "number", "select", "sensor", "switch"], +) +async def test_a_platform_builds_from_the_snapshot_setup_decided_from( + hass: HomeAssistant, setup: PlatformSetup +) -> None: + full = schema_one_snapshot() + minimal = SpanPanelSnapshotFactory.create(serial_number=full.serial_number) + + from_minimal = await _built(hass, setup, minimal, minimal) + from_full = await _built(hass, setup, full, full) + # The capture gives this platform something the minimal panel does not, so + # reading the wrong snapshot would show. + assert from_full - from_minimal + + assert await _built(hass, setup, minimal, full) == from_minimal diff --git a/tests/test_power_debug.py b/tests/test_power_debug.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py index d1111c0c..ca56d94c 100644 --- a/tests/test_promoted_sensors.py +++ b/tests/test_promoted_sensors.py @@ -161,9 +161,30 @@ def test_value_function_false(self): snapshot = SpanPanelSnapshotFactory.create(grid_islandable=False) assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is False - def test_value_function_none(self): + def test_absent_flat_property_falls_back_to_mid_presence(self): + """v1.0 publishes no `grid-islandable`, and that is not "unknown". + + `devices/bess.md` retires the panel-level boolean deliberately -- "there is no + single 'islanded?' bit to reconcile" -- and nominates the capability set as the + classifier: "a MID `grid` child means premises-segment backup ... neither means + no backup". So an absent property with no MID is a definite *no*, not a + missing reading. + + This used to return None, which reached a user as the sensor going + `unavailable` with `restored: true` after a firmware upgrade -- indistinguishable + from a sensor that broke. + """ snapshot = SpanPanelSnapshotFactory.create(grid_islandable=None) - assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is None + assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is False + + def test_a_published_flat_value_still_wins(self): + """Flat firmware keeps answering for itself. + + DUAL-SCHEMA: a flat panel publishes the property and has no MID, so reading MID + presence first would invert the answer for every panel not yet upgraded. + """ + snapshot = SpanPanelSnapshotFactory.create(grid_islandable=True) + assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is True # --------------------------------------------------------------------------- @@ -201,22 +222,24 @@ class TestBessDeviceInfo: def test_bess_device_info_basic(self): battery = SpanBatterySnapshotFactory.create( vendor_name="Tesla", - product_name="Powerwall 2", + model="Powerwall 2", serial_number="TW-001", software_version="2.1.0", ) - info = bess_device_info("sp3-242424-001", battery, "My Panel") + info = bess_device_info( + "sp3-242424-001", battery, "My Panel", panel_device_id="panel-device-id" + ) assert info.get("identifiers") == {(DOMAIN, "sp3-242424-001_bess")} assert info.get("name") == "My Panel Battery" assert info.get("manufacturer") == "Tesla" assert info.get("model") == "Powerwall 2" assert info.get("serial_number") == "TW-001" assert info.get("sw_version") == "2.1.0" - assert info.get("via_device") == (DOMAIN, "sp3-242424-001") + assert info.get("via_device_id") == "panel-device-id" def test_bess_device_info_defaults_when_none(self): battery = SpanBatterySnapshotFactory.create() - info = bess_device_info("serial", battery, "Panel") + info = bess_device_info("serial", battery, "Panel", panel_device_id="pd") assert info.get("manufacturer") == "Unknown" assert info.get("model") == "Battery Storage" assert info.get("serial_number") is None @@ -227,7 +250,7 @@ class TestBessMetadataSensorDefinitions: """Test BESS metadata sensor definitions.""" def test_sensor_count(self): - assert len(BESS_METADATA_SENSORS) == 6 + assert len(BESS_METADATA_SENSORS) == 7 def test_all_have_translation_keys(self): for desc in BESS_METADATA_SENSORS: @@ -247,10 +270,24 @@ def test_vendor_value_function(self): assert desc.value_fn(battery) == "Enphase" def test_model_value_function(self): - battery = SpanBatterySnapshotFactory.create(product_name="IQ Battery 10") + battery = SpanBatterySnapshotFactory.create(model="IQ Battery 10") desc = next(d for d in BESS_METADATA_SENSORS if d.key == "model") assert desc.value_fn(battery) == "IQ Battery 10" + def test_part_number_value_function(self): + battery = SpanBatterySnapshotFactory.create(part_number="830-01234-01") + desc = next(d for d in BESS_METADATA_SENSORS if d.key == "part_number") + assert desc.value_fn(battery) == "830-01234-01" + + def test_part_number_is_disabled_by_default(self): + """A new entity on an existing install must not appear uninvited. + + Asserted separately from the value function so a flip of this flag names + itself in the failure rather than hiding inside a value test. + """ + desc = next(d for d in BESS_METADATA_SENSORS if d.key == "part_number") + assert desc.entity_registry_enabled_default is False + def test_serial_number_value_function(self): battery = SpanBatterySnapshotFactory.create(serial_number="BESS-12345") desc = next(d for d in BESS_METADATA_SENSORS if d.key == "serial_number") @@ -293,6 +330,7 @@ def test_build_bess_unique_id_different_keys(self): for key in ( "vendor", "model", + "part_number", "serial_number", "firmware_version", "nameplate_capacity", @@ -326,32 +364,22 @@ def test_all_are_diagnostic(self): ) def test_pv_vendor_value_function(self): - snapshot = SpanPanelSnapshotFactory.create( - pv=SpanPVSnapshot(vendor_name="SolarEdge") - ) desc = next(d for d in PV_METADATA_SENSORS if d.key == "pv_vendor") - assert desc.value_fn(snapshot) == "SolarEdge" + assert desc.value_fn(SpanPVSnapshot(vendor_name="SolarEdge")) == "SolarEdge" def test_pv_product_value_function(self): - snapshot = SpanPanelSnapshotFactory.create( - pv=SpanPVSnapshot(product_name="SE7600H") - ) desc = next(d for d in PV_METADATA_SENSORS if d.key == "pv_product") - assert desc.value_fn(snapshot) == "SE7600H" + assert desc.value_fn(SpanPVSnapshot(model="SE7600H")) == "SE7600H" def test_pv_nameplate_capacity_value_function(self): - snapshot = SpanPanelSnapshotFactory.create( - pv=SpanPVSnapshot(nameplate_capacity_w=7600.0) - ) desc = next(d for d in PV_METADATA_SENSORS if d.key == "pv_nameplate_capacity") - assert desc.value_fn(snapshot) == 7600.0 + assert desc.value_fn(SpanPVSnapshot(nameplate_capacity_w=7600.0)) == 7600.0 assert desc.device_class is None assert desc.entity_registry_enabled_default is False def test_pv_none_metadata(self): - snapshot = SpanPanelSnapshotFactory.create(pv=SpanPVSnapshot()) for desc in PV_METADATA_SENSORS: - assert desc.value_fn(snapshot) is None, f"PV {desc.key} should be None" + assert desc.value_fn(SpanPVSnapshot()) is None, f"PV {desc.key} should be None" # --------------------------------------------------------------------------- @@ -363,7 +391,7 @@ class TestEvseSensorDefinitions: """Test EVSE sensor definitions.""" def test_sensor_count(self): - assert len(EVSE_SENSORS) == 3 + assert len(EVSE_SENSORS) == 4 def test_all_have_translation_keys(self): for desc in EVSE_SENSORS: diff --git a/tests/test_pv_binding.py b/tests/test_pv_binding.py new file mode 100644 index 00000000..4684c2a5 --- /dev/null +++ b/tests/test_pv_binding.py @@ -0,0 +1,553 @@ +"""Nothing moves: the Solar card's PV entities through every shape a panel publishes.""" + +from __future__ import annotations + +import json +from typing import Final + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockEntityPlatform +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import async_remove_entry +from custom_components.span_panel.binary_sensor import ( + SpanPVInverterBinarySensor, + SpanPVSolarLinkBinarySensor, +) +from custom_components.span_panel.const import DOMAIN, PV_PANEL_LINK_KEY +from custom_components.span_panel.id_builder import ( + build_panel_unique_id, + build_pv_inverter_unique_id, +) +from custom_components.span_panel.pv_binding import async_resolve_pv_binding, keys_holding_cards +from custom_components.span_panel.sensor_panel import SpanPVMetadataSensor +from custom_components.span_panel.util import SUB_DEVICE_PV + +from .adapter_fixtures import schema_one_snapshot, schema_one_tree +from .test_pv_device import PANEL_NAME, PV_DEVICE, SOLAR_CIRCUIT, _entry +from .test_pv_inverters import ( + FIRST_PV, + SECOND_PV, + SECOND_SOLAR_CIRCUIT, + _setup, + _tree, + _unload, + inverter_cards, + solar_entities, + solar_unique_ids, +) + +ENTRY_ID: Final = "entry-pv-forget" + +FEED_TOPICS: Final = ("connection/feeds-device-id", "connection/feeds-device-status", "connection/feeds-device-type") +NEW_CIRCUIT: Final = "0123456789abcdef0123456789abcdef" + + +async def test_removing_the_entry_forgets_the_record(hass: HomeAssistant, hass_storage: dict[str, object]) -> None: + """Driven through the removal hook, so the wiring is what is proved.""" + key = f"{DOMAIN}.pv_binding.{ENTRY_ID}" + hass_storage[key] = {"version": 1, "minor_version": 1, "key": key, "data": {"circuit_id": "c"}} + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id=ENTRY_ID, unique_id="sp3-001") + entry.add_to_hass(hass) + + await async_remove_entry(hass, entry) + + assert key not in hass_storage + + +def _seed_2_1_1( + hass: HomeAssistant, entry: MockConfigEntry, *, link: bool = True, renamed: dict[str, str] | None = None +) -> dict[str, tuple[str, str | None]]: + """Write a 2.1.1 registry: the Solar card and its entities under 2.1.1's ids, no record.""" + card = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, f"{entry.unique_id}_{SUB_DEVICE_PV}")}, + name=f"{PANEL_NAME} Solar", + ) + registry = er.async_get(hass) + for suffix, unique_id in solar_unique_ids(entry.unique_id or "", link=link).items(): + domain = "binary_sensor" if suffix == PV_PANEL_LINK_KEY else "sensor" + object_id = "span_panel_" + ("pv_power" if suffix == "pv_power" else suffix) + created = registry.async_get_or_create( + domain, DOMAIN, unique_id, config_entry=entry, device_id=card.id, suggested_object_id=object_id + ) + new_id = (renamed or {}).get(suffix) + if new_id is not None: + registry.async_update_entity(created.entity_id, new_entity_id=new_id) + return solar_entities(hass, entry) + + +def _unfed_tree() -> dict[str, dict[str, str]]: + tree = schema_one_tree() + for topic in FEED_TOPICS: + tree[SOLAR_CIRCUIT].pop(topic, None) + return tree + + +def _gateway_tree(*inverters: tuple[str, str, str | None]) -> dict[str, dict[str, str]]: + """span#269: inverters no circuit feeds, as `(device id, model, recorded DC size or None)`.""" + tree = _unfed_tree() + pv_topics = tree.pop(PV_DEVICE) + for device_id, model, nominal in inverters: + topics = {**pv_topics, "info/vendor-name": "SolarEdge", "info/model": model} + topics.pop("info/nominal-power", None) + if nominal is not None: + topics["info/nominal-power"] = nominal + tree[device_id] = topics + return tree + + +def _record(hass_storage: dict[str, object], entry: MockConfigEntry) -> object: + stored = hass_storage.get(f"{DOMAIN}.pv_binding.{entry.entry_id}") + return stored.get("data") if isinstance(stored, dict) else None + + +async def test_scenario_a_a_second_inverter_on_a_lower_breaker_space_moves_nothing(hass: HomeAssistant) -> None: + """The 2.1.2b3 regression, from a 2.1.1 registry: the Solar card stays on the Enphase on 36/38.""" + one = schema_one_snapshot() + entry = _entry(hass, "entry-a", one.serial_number) + held = _seed_2_1_1(hass, entry, renamed={"pv_product": "sensor.my_pv_product"}) + await _unload(await _setup(hass, entry, one)) + assert solar_entities(hass, entry) == held + + two = schema_one_snapshot(_tree()) + platforms = await _setup(hass, entry, two) + + assert solar_entities(hass, entry) == held + assert inverter_cards(hass, entry) == {SECOND_SOLAR_CIRCUIT} + vendor = platforms[0].entities[held[solar_unique_ids(one.serial_number)["pv_vendor"]][0]] + assert isinstance(vendor, SpanPVMetadataSensor) + assert vendor.get_data_source(two) is two.pv_inverters[SOLAR_CIRCUIT] + + +async def test_scenario_b_inverters_behind_a_gateway_leave_the_solar_card_reading_them_together( + hass: HomeAssistant, +) -> None: + """span#269 from a 2.1.1 registry; r202639 shape pending confirmation.""" + before = schema_one_snapshot(_gateway_tree(("panel-se7600h-us", "SE7600H-US", "11680"))) + entry = _entry(hass, "entry-b", before.serial_number) + held = _seed_2_1_1(hass, entry, link=False, renamed={"pv_vendor": "sensor.my_solar_brand"}) + await _unload(await _setup(hass, entry, before)) + assert solar_entities(hass, entry) == held + assert inverter_cards(hass, entry) == set() + + after = schema_one_snapshot( + _gateway_tree(("panel-se7600h-us-1", "SE7600H-US", "11680"), ("panel-use7600h-us-2", "USE7600H-US", None)) + ) + await _setup(hass, entry, after) + + assert solar_entities(hass, entry) == held + assert inverter_cards(hass, entry) == {"panel-se7600h-us-1", "panel-use7600h-us-2"} + source = entry.runtime_data.pv_binding.source(after) + assert source.vendor_name == "SolarEdge" + assert source.model is None + assert source.nameplate_capacity_w is None + + +async def test_scenario_c_two_inverters_at_first_upgrade_leave_the_solar_card_unbound(hass: HomeAssistant) -> None: + snapshot = schema_one_snapshot(_tree()) + entry = _entry(hass, "entry-c", snapshot.serial_number) + held = _seed_2_1_1(hass, entry, renamed={"pv_vendor": "sensor.my_solar_brand"}) + + await _setup(hass, entry, snapshot) + + assert solar_entities(hass, entry) == held + assert "sensor.my_solar_brand" in {entity_id for entity_id, _device in held.values()} + assert inverter_cards(hass, entry) == {SOLAR_CIRCUIT, SECOND_SOLAR_CIRCUIT} + + +async def test_scenario_d_a_record_that_has_not_arrived_mints_no_card(hass: HomeAssistant) -> None: + fed = schema_one_snapshot() + entry = _entry(hass, "entry-d", fed.serial_number) + held = _seed_2_1_1(hass, entry, renamed={"pv_product": "sensor.my_pv_product"}) + await _unload(await _setup(hass, entry, fed)) + + late = schema_one_snapshot(_unfed_tree()) + await _setup(hass, entry, late) + + assert solar_entities(hass, entry) == held + assert inverter_cards(hass, entry) == set() + assert entry.runtime_data.pv_binding.withheld == frozenset({PV_DEVICE}) + + +async def test_scenario_e_a_replaced_circuit_reads_unknown_and_the_inverter_gets_a_card(hass: HomeAssistant) -> None: + """Not expected for in-panel PV; the honest outcome if a panel ever does it.""" + fed = schema_one_snapshot() + entry = _entry(hass, "entry-e", fed.serial_number) + held = _seed_2_1_1(hass, entry) + await _unload(await _setup(hass, entry, fed)) + + tree = schema_one_tree() + tree[NEW_CIRCUIT] = tree.pop(SOLAR_CIRCUIT) + moved = schema_one_snapshot(tree) + await _setup(hass, entry, moved) + + assert solar_entities(hass, entry) == held + assert inverter_cards(hass, entry) == {NEW_CIRCUIT} + assert entry.runtime_data.pv_binding.source(moved).vendor_name is None + + +async def test_scenario_f_an_identity_only_inverter_given_a_circuit_keeps_the_solar_card( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + """The one refinement: unbound, then bound to the circuit the card already reads, then a newcomer.""" + unfed = schema_one_snapshot(_unfed_tree()) + entry = _entry(hass, "entry-f", unfed.serial_number) + held = _seed_2_1_1(hass, entry) + await _unload(await _setup(hass, entry, unfed)) + assert _record(hass_storage, entry) == {"circuit_id": None} + assert inverter_cards(hass, entry) == set() + + await _unload(await _setup(hass, entry, schema_one_snapshot())) + assert _record(hass_storage, entry) == {"circuit_id": SOLAR_CIRCUIT} + + await _setup(hass, entry, schema_one_snapshot(_tree())) + assert solar_entities(hass, entry) == held + assert inverter_cards(hass, entry) == {SECOND_SOLAR_CIRCUIT} + + +async def test_scenario_g_a_single_inverter_keeps_its_2_1_1_ids(hass: HomeAssistant) -> None: + snapshot = schema_one_snapshot() + entry = _entry(hass, "entry-g", snapshot.serial_number) + held = _seed_2_1_1(hass, entry) + + await _setup(hass, entry, snapshot) + + assert solar_entities(hass, entry) == held + assert inverter_cards(hass, entry) == set() + + +async def test_without_the_record_two_inverters_leave_the_card_unbound( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + """The registry cannot carry the binding: remove the store between one inverter and two, and the card is unbound. + + This is exactly how an install upgrading from 2.1.1 meets scenario C. Neither circuit has a card, so + nothing in the registry tells C from C2 without ranking or matching, and the store is the only carrier + that does neither. + """ + one = schema_one_snapshot() + entry = _entry(hass, "entry-no-record", one.serial_number) + held = _seed_2_1_1(hass, entry, renamed={"pv_vendor": "sensor.my_solar_brand"}) + await _unload(await _setup(hass, entry, one)) + del hass_storage[f"{DOMAIN}.pv_binding.{entry.entry_id}"] + + await _setup(hass, entry, schema_one_snapshot(_tree())) + + assert _record(hass_storage, entry) == {"circuit_id": None} + assert entry.runtime_data.pv_binding.mode == "unbound" + assert inverter_cards(hass, entry) == {SOLAR_CIRCUIT, SECOND_SOLAR_CIRCUIT} + assert solar_entities(hass, entry) == held + + +async def test_a_key_that_holds_a_card_keeps_it_through_every_shape( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + """The invariant end to end: a carded inverter keeps its card, whatever the panel or the store does next. + + Runs a sequence of setups over one entry. Some steps remove the store or corrupt it first, the way a manual removal + or a damaged file reaches a real install. After each setup, every inverter that had a card before it and is still + published still has its own card. + """ + entry = _entry(hass, "entry-invariant", schema_one_snapshot().serial_number) + _seed_2_1_1(hass, entry, renamed={"pv_product": "sensor.my_pv_product"}) + key = f"{DOMAIN}.pv_binding.{entry.entry_id}" + steps: tuple[tuple[str, SpanPanelSnapshot, str | None], ...] = ( + ("two at first sight", schema_one_snapshot(_tree()), None), + ("back to one, store removed", schema_one_snapshot(_tree(second=False)), "remove"), + ("record late, store corrupt", schema_one_snapshot(_unfed_tree()), "corrupt"), + ("two again", schema_one_snapshot(_tree()), None), + ("two again, store removed", schema_one_snapshot(_tree()), "remove"), + ) + for label, snapshot, store_action in steps: + if store_action == "remove": + hass_storage.pop(key, None) + elif store_action == "corrupt": + hass_storage[key] = {"version": 1, "minor_version": 1, "key": key, "data": ["corrupt"]} + carded = inverter_cards(hass, entry) + await _unload(await _setup(hass, entry, snapshot)) + binding = entry.runtime_data.pv_binding + for card_key in carded & set(snapshot.pv_inverters): + assert binding.has_own_card(card_key), f"{label}: {card_key} lost its card" + + +async def test_a_wrong_shaped_record_does_not_stop_setup(hass: HomeAssistant, hass_storage: dict[str, object]) -> None: + snapshot = schema_one_snapshot() + entry = _entry(hass, "entry-bad-record", snapshot.serial_number) + key = f"{DOMAIN}.pv_binding.{entry.entry_id}" + hass_storage[key] = {"version": 1, "minor_version": 1, "key": key, "data": ["not", "a", "record"]} + + await _setup(hass, entry, snapshot) + + assert _record(hass_storage, entry) == {"circuit_id": SOLAR_CIRCUIT} + + +async def test_a_second_setup_writes_nothing(hass: HomeAssistant, hass_storage: dict[str, object]) -> None: + snapshot = schema_one_snapshot() + entry = _entry(hass, "entry-idempotent", snapshot.serial_number) + await _unload(await _setup(hass, entry, snapshot)) + written = _record(hass_storage, entry) + + await _unload(await _setup(hass, entry, schema_one_snapshot(_tree()))) + + assert _record(hass_storage, entry) == written + + +def _with_vendor_readings(tree: dict[str, dict[str, str]], *device_ids: str) -> dict[str, dict[str, str]]: + """Declare a vendor node on each inverter: one reading and one boolean no schema field addresses.""" + for device_id in device_ids: + description = json.loads(tree[device_id]["$description"]) + description["nodes"]["acme"] = { + "name": "acme", + "type": "acme.inverter", + "properties": { + "string-voltage": {"name": "String voltage", "datatype": "float", "unit": "V"}, + "arc-fault": {"name": "Arc fault", "datatype": "boolean"}, + }, + } + tree[device_id]["$description"] = json.dumps(description) + tree[device_id]["acme/string-voltage"] = "412.0" + tree[device_id]["acme/arc-fault"] = "false" + return tree + + +async def test_vendor_readings_follow_the_binding_through_setup(hass: HomeAssistant) -> None: + """The bound inverter's readings register on the Solar card under the keyless ids; the newcomer's on its own card.""" + one = schema_one_snapshot() + serial = one.serial_number + entry = _entry(hass, "entry-vendor", serial) + await _unload(await _setup(hass, entry, one)) + two = schema_one_snapshot(_with_vendor_readings(_tree(), FIRST_PV, SECOND_PV)) + + await _unload(await _setup(hass, entry, two)) + # The newcomer's card is minted by that setup, so its readings follow on the next. + await _unload(await _setup(hass, entry, two)) + + entities = er.async_get(hass) + devices = dr.async_get(hass) + solar = devices.async_get_device_by_identifier((DOMAIN, f"{serial}_{SUB_DEVICE_PV}"), entry.entry_id) + newcomer = devices.async_get_device_by_identifier( + (DOMAIN, f"{serial}_{SUB_DEVICE_PV}_{SECOND_SOLAR_CIRCUIT}"), entry.entry_id + ) + assert solar is not None and newcomer is not None + for domain, path in (("sensor", "acme/string-voltage"), ("binary_sensor", "acme/arc-fault")): + on_solar = entities.async_get_entity_id(domain, DOMAIN, f"span_{serial}_adopted_pv/{path}") + assert on_solar is not None, f"{domain} {path} is not on the Solar card" + assert entities.async_get(on_solar).device_id == solar.id + assert entities.async_get_entity_id(domain, DOMAIN, f"span_{serial}_adopted_pv_{SOLAR_CIRCUIT}/{path}") is None + own = entities.async_get_entity_id(domain, DOMAIN, f"span_{serial}_adopted_pv_{SECOND_SOLAR_CIRCUIT}/{path}") + assert own is not None, f"{domain} {path} is not on the newcomer's card" + assert entities.async_get(own).device_id == newcomer.id + + +# --- which inverters already hold a card ------------------------------------------- + + +def test_a_card_is_counted_only_in_its_own_entry(hass: HomeAssistant) -> None: + """An inverter id another entry owns says nothing about this entry's cards.""" + mine = _entry(hass, "entry-cards-mine", "sp3-cards-001") + theirs = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-cards-theirs", unique_id="sp3-cards-002") + theirs.add_to_hass(hass) + registry = er.async_get(hass) + registry.async_get_or_create( + "sensor", DOMAIN, build_pv_inverter_unique_id("sp3-cards-001", "c-1", "pv_vendor"), config_entry=theirs + ) + + assert keys_holding_cards(registry, mine.entry_id, "sp3-cards-001", ["c-1"]) == frozenset() + assert keys_holding_cards(registry, theirs.entry_id, "sp3-cards-001", ["c-1"]) == frozenset({"c-1"}) + + +def test_an_inverter_holding_only_its_link_sensor_holds_a_card(hass: HomeAssistant) -> None: + """The link binary sensor alone is a card of its own; the Solar card's panel-scoped ids are not.""" + entry = _entry(hass, "entry-cards-link", "sp3-cards-003") + registry = er.async_get(hass) + registry.async_get_or_create( + "binary_sensor", + DOMAIN, + build_pv_inverter_unique_id("sp3-cards-003", "c-link", PV_PANEL_LINK_KEY), + config_entry=entry, + ) + registry.async_get_or_create( + "sensor", DOMAIN, build_panel_unique_id("sp3-cards-003", "pv_vendor"), config_entry=entry + ) + + assert keys_holding_cards(registry, entry.entry_id, "sp3-cards-003", ["c-link", "c-none"]) == frozenset( + {"c-link"} + ) + + +# --- the record on disk ------------------------------------------------------------- + + +async def test_first_sight_writes_the_record_and_no_inverter_writes_none( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + empty = schema_one_snapshot(schema_one_tree(without=PV_DEVICE)) + entry = _entry(hass, "entry-record-write", empty.serial_number) + key = f"{DOMAIN}.pv_binding.{entry.entry_id}" + + undecided = await async_resolve_pv_binding(hass, entry, empty) + await hass.async_block_till_done() + assert undecided.mode == "undecided" + assert key not in hass_storage + + bound = await async_resolve_pv_binding(hass, entry, schema_one_snapshot()) + await hass.async_block_till_done() + assert bound.bound_key == SOLAR_CIRCUIT + stored = hass_storage[key] + assert isinstance(stored, dict) + assert stored["version"] == 1 + assert stored["data"] == {"circuit_id": SOLAR_CIRCUIT} + + +async def test_the_platforms_decide_from_the_snapshot_the_binding_was_resolved_from(hass: HomeAssistant) -> None: + """An inverter published while setup runs waits for the reload its capability token requests. + + Setup resolved the binding before any inverter was seen. Had the platforms read + the coordinator's newer snapshot, the newcomer would get a card of its own while + the Solar card read it too, and that card would then hold it off the Solar card. + """ + before = schema_one_snapshot(schema_one_tree(without=PV_DEVICE)) + after = schema_one_snapshot() + entry = _entry(hass, "entry-drift", after.serial_number) + + await _unload(await _setup(hass, entry, before, published=after)) + assert inverter_cards(hass, entry) == set() + + await _setup(hass, entry, after) + assert entry.runtime_data.pv_binding.bound_key == SOLAR_CIRCUIT + assert inverter_cards(hass, entry) == set() + + +# --- the Solar card's link entity ----------------------------------------------------- +# +# Created where it is already registered, or where every inverter the card reads +# publishes a link record -- never by what a link reads at setup. + +STATUS_TOPIC: Final = "connection/feeds-device-status" + + +def _solar_link(platforms: list[MockEntityPlatform], serial: str) -> SpanPVSolarLinkBinarySensor | None: + """The Solar card's link entity these platforms built, read once from the coordinator.""" + link_id = solar_unique_ids(serial)[PV_PANEL_LINK_KEY] + for platform in platforms: + for entity in platform.entities.values(): + if isinstance(entity, SpanPVSolarLinkBinarySensor) and entity.unique_id == link_id: + entity._handle_coordinator_update() + return entity + return None + + +def _one_record_less(status: str) -> dict[str, dict[str, str]]: + """Two inverters at first sight; the first circuit reports `status`, the second publishes no record.""" + tree = _tree() + tree[SOLAR_CIRCUIT][STATUS_TOPIC] = status + tree[SECOND_SOLAR_CIRCUIT].pop(STATUS_TOPIC) + return tree + + +@pytest.mark.parametrize(("recorded", "expected"), [(True, True), (False, None)], ids=["record", "no-record"]) +async def test_a_lone_inverter_has_the_link_exactly_as_2_1_1_did( + hass: HomeAssistant, recorded: bool, expected: bool | None +) -> None: + """With a record the link is created, reading it; without one it is not, as 2.1.1 decided it.""" + tree = schema_one_tree() + if not recorded: + tree[SOLAR_CIRCUIT].pop(STATUS_TOPIC) + snapshot = schema_one_snapshot(tree) + entry = _entry(hass, "entry-link-lone", snapshot.serial_number) + + link = _solar_link(await _setup(hass, entry, snapshot), snapshot.serial_number) + + assert (link is not None) is recorded + assert link is None or link.is_on is expected + + +async def test_a_bound_link_is_kept_and_reads_unknown_while_its_inverter_is_not_published( + hass: HomeAssistant, +) -> None: + fed = schema_one_snapshot() + entry = _entry(hass, "entry-link-bound", fed.serial_number) + await _unload(await _setup(hass, entry, fed)) + + pending = schema_one_snapshot(_unfed_tree()) + platforms = await _setup(hass, entry, pending) + link = _solar_link(platforms, pending.serial_number) + assert link is not None + assert link.is_on is None and link.available + await _unload(platforms) + + tree = schema_one_tree() + tree[NEW_CIRCUIT] = tree.pop(SOLAR_CIRCUIT) + departed = schema_one_snapshot(tree) + link = _solar_link(await _setup(hass, entry, departed), departed.serial_number) + assert link is not None + assert link.is_on is None and link.available + + +async def test_an_unbound_link_over_several_does_not_come_and_go_with_a_reading(hass: HomeAssistant) -> None: + """One inverter publishes no record: the link's existence is the same whether the other is OK or LOST.""" + entry = _entry(hass, "entry-link-unbound", schema_one_snapshot().serial_number) + existence = [] + for status in ("OK", "LOST", "OK"): + snapshot = schema_one_snapshot(_one_record_less(status)) + platforms = await _setup(hass, entry, snapshot) + assert entry.runtime_data.pv_binding.mode == "unbound" + existence.append(_solar_link(platforms, snapshot.serial_number) is not None) + await _unload(platforms) + assert existence == [False, False, False] + + +async def test_an_unbound_link_over_several_all_recorded_is_created(hass: HomeAssistant) -> None: + snapshot = schema_one_snapshot(_tree()) + entry = _entry(hass, "entry-link-all", snapshot.serial_number) + + link = _solar_link(await _setup(hass, entry, snapshot), snapshot.serial_number) + + assert entry.runtime_data.pv_binding.mode == "unbound" + assert link is not None and link.is_on is True + + +@pytest.mark.parametrize(("status", "reading"), [("OK", None), ("LOST", False)]) +async def test_an_install_that_had_the_link_keeps_it_when_the_record_rule_fails( + hass: HomeAssistant, status: str, reading: bool | None +) -> None: + """A 2.1.1 registry holding the link, upgraded straight to two inverters one of which publishes no record. + + Kept whatever the recorded link reads; it reads the inverters together -- + unknown with one unreported and the other up, down once the other is down. + """ + snapshot = schema_one_snapshot(_one_record_less(status)) + entry = _entry(hass, "entry-link-held", snapshot.serial_number) + held = _seed_2_1_1(hass, entry) + + link = _solar_link(await _setup(hass, entry, snapshot), snapshot.serial_number) + + assert link is not None + assert held[solar_unique_ids(snapshot.serial_number)[PV_PANEL_LINK_KEY]][0] == link.entity_id + assert link.is_on is reading + + +async def test_an_inverters_own_link_is_kept_when_its_record_goes(hass: HomeAssistant) -> None: + """The Solar link's rule per key: a link already registered is created whatever the record does, reading unknown.""" + entry = _entry(hass, "entry-own-link", schema_one_snapshot().serial_number) + await _unload(await _setup(hass, entry, schema_one_snapshot(_tree()))) + tree = _tree() + tree[SECOND_SOLAR_CIRCUIT].pop(STATUS_TOPIC) + silent = schema_one_snapshot(tree) + + platforms = await _setup(hass, entry, silent) + + own = [ + entity + for platform in platforms + for entity in platform.entities.values() + if isinstance(entity, SpanPVInverterBinarySensor) + and entity.unique_id == build_pv_inverter_unique_id(silent.serial_number, SECOND_SOLAR_CIRCUIT, PV_PANEL_LINK_KEY) + ] + assert len(own) == 1 + own[0]._handle_coordinator_update() + assert own[0].is_on is None and own[0].available + assert SECOND_SOLAR_CIRCUIT in entry.runtime_data.pv_binding.inverter_links diff --git a/tests/test_pv_binding_rules.py b/tests/test_pv_binding_rules.py new file mode 100644 index 00000000..fe122fa0 --- /dev/null +++ b/tests/test_pv_binding_rules.py @@ -0,0 +1,321 @@ +"""Which inverter the Solar card's PV entities read, decided over plain snapshots.""" + +from __future__ import annotations + +from dataclasses import replace +from itertools import combinations +from typing import Final + +from span_panel_api import SpanPanelSnapshot, SpanPVSnapshot + +from custom_components.span_panel.pv_binding import ( + UNDECIDED_PV_BINDING, + StoredPvBinding, + read_record, + resolve, +) + +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + +CIRCUIT: Final = "573066aaddd7b75114c4563ce3af18c4" +OTHER_CIRCUIT: Final = "5be1d2c3a4f5061728394a5b6c7d8e9f" +BOUND: Final = StoredPvBinding(circuit_id=CIRCUIT) +UNBOUND: Final = StoredPvBinding(circuit_id=None) + + +def _fed(device_id: str, circuit: str, *, linked: bool | None = None) -> SpanPVSnapshot: + """`linked` is the circuit's link record: `None` where it publishes none.""" + return SpanPVSnapshot( + vendor_name="Enphase", + device_id=device_id, + node_id=circuit, + feed_circuit_id=circuit, + connected=linked, + ) + + +def _unfed(device_id: str) -> SpanPVSnapshot: + return SpanPVSnapshot(vendor_name="SolarEdge", device_id=device_id, node_id=device_id) + + +def _snapshot( + *inverters: SpanPVSnapshot, circuits: tuple[str, ...] = (CIRCUIT,) +) -> SpanPanelSnapshot: + keyed: dict[str, SpanPVSnapshot] = {} + for inverter in inverters: + assert inverter.node_id is not None + keyed[inverter.node_id] = inverter + lone = inverters[0] if len(inverters) == 1 else SpanPVSnapshot(vendor_name="together") + return replace( + SpanPanelSnapshotFactory.create(), + pv_inverters=keyed, + pv=lone, + circuits={ + circuit: SpanCircuitSnapshotFactory.create(circuit_id=circuit) for circuit in circuits + }, + ) + + +# --- first sight ------------------------------------------------------------------- + + +def test_one_circuit_fed_inverter_is_bound_at_first_sight() -> None: + identity, record = resolve(_snapshot(_fed("pv", CIRCUIT)), None, frozenset(), link_held=False, inverter_links_held=frozenset()) + + assert record == BOUND + assert identity.mode == "inverter" + assert identity.legacy_key == CIRCUIT + assert not identity.has_own_card(CIRCUIT) + + +def test_an_inverter_no_circuit_feeds_is_unbound_and_read_by_the_solar_card() -> None: + identity, record = resolve(_snapshot(_unfed("panel-se7600h-us")), None, frozenset(), link_held=False, inverter_links_held=frozenset()) + + assert record == UNBOUND + assert identity.legacy_key == "panel-se7600h-us" + assert not identity.has_own_card("panel-se7600h-us") + + +def test_several_inverters_at_first_sight_are_unbound_and_each_has_a_card() -> None: + identity, record = resolve( + _snapshot(_fed("a", CIRCUIT), _fed("b", OTHER_CIRCUIT)), None, frozenset(), link_held=False, inverter_links_held=frozenset() + ) + + assert record == UNBOUND + assert identity.legacy_key is None + assert identity.has_own_card(CIRCUIT) + assert identity.has_own_card(OTHER_CIRCUIT) + + +def test_no_inverter_decides_nothing() -> None: + identity, record = resolve(_snapshot(), None, frozenset(), link_held=False, inverter_links_held=frozenset()) + + assert record is None + assert identity == UNDECIDED_PV_BINDING + + +# --- the record is kept, and refined once ----------------------------------------------- + + +def test_a_bound_record_is_never_rewritten() -> None: + _identity, record = resolve( + _snapshot(_fed("pv", OTHER_CIRCUIT), circuits=(OTHER_CIRCUIT,)), BOUND, frozenset(), link_held=False, inverter_links_held=frozenset() + ) + + assert record == BOUND + + +def test_an_unbound_record_is_refined_when_the_lone_inverter_gains_a_circuit_and_has_no_card() -> ( + None +): + identity, record = resolve(_snapshot(_fed("pv", CIRCUIT)), UNBOUND, frozenset(), link_held=False, inverter_links_held=frozenset()) + + assert record == BOUND + assert identity.legacy_key == CIRCUIT + + +def test_an_unbound_record_is_not_refined_for_an_inverter_that_has_a_card() -> None: + identity, record = resolve(_snapshot(_fed("pv", CIRCUIT)), UNBOUND, frozenset({CIRCUIT}), link_held=False, inverter_links_held=frozenset()) + + assert record == UNBOUND + assert identity.legacy_key is None + + +def test_an_unbound_record_is_not_refined_with_several_inverters() -> None: + _identity, record = resolve( + _snapshot(_fed("a", CIRCUIT), _fed("b", OTHER_CIRCUIT)), UNBOUND, frozenset(), link_held=False, inverter_links_held=frozenset() + ) + + assert record == UNBOUND + + +# --- what the Solar card reads ------------------------------------------------------ + + +def test_a_bound_card_reads_its_circuit_beside_a_newcomer() -> None: + first, second = _fed("pv-1", CIRCUIT), _fed("pv-2", OTHER_CIRCUIT) + snapshot = _snapshot(first, second, circuits=(CIRCUIT, OTHER_CIRCUIT)) + + identity, _record = resolve(snapshot, BOUND, frozenset(), link_held=False, inverter_links_held=frozenset()) + + assert identity.source(snapshot) is first + assert identity.has_own_card(OTHER_CIRCUIT) + assert not identity.has_own_card(CIRCUIT) + + +def test_an_unbound_card_reads_the_librarys_pv() -> None: + snapshot = _snapshot(_unfed("d1"), _unfed("d2")) + + identity, _record = resolve(snapshot, UNBOUND, frozenset(), link_held=False, inverter_links_held=frozenset()) + + assert identity.source(snapshot) is snapshot.pv + + +# --- a pending record -------------------------------------------------------------- + + +def test_a_bound_circuit_still_published_without_its_inverter_withholds_device_id_keys() -> None: + """The record has not arrived: nothing is minted for the device-id key, and the card reads unknown.""" + snapshot = _snapshot(_unfed("pv"), circuits=(CIRCUIT,)) + + identity, record = resolve(snapshot, BOUND, frozenset(), link_held=False, inverter_links_held=frozenset()) + + assert record == BOUND + assert identity.withheld == frozenset({"pv"}) + assert not identity.has_own_card("pv") + assert identity.source(snapshot) == SpanPVSnapshot() + + +def test_a_bound_circuit_that_is_gone_withholds_nothing() -> None: + """The panel replaced the circuit: the inverter gets a card and the Solar card reads unknown.""" + snapshot = _snapshot(_fed("pv", OTHER_CIRCUIT), circuits=(OTHER_CIRCUIT,)) + + identity, _record = resolve(snapshot, BOUND, frozenset(), link_held=False, inverter_links_held=frozenset()) + + assert identity.withheld == frozenset() + assert identity.has_own_card(OTHER_CIRCUIT) + assert identity.source(snapshot) == SpanPVSnapshot() + + +def test_pending_never_withdraws_a_card_that_exists() -> None: + """N1: an upstream PV that already has a card keeps it while the bound circuit's record is pending.""" + snapshot = _snapshot(_unfed("upstream"), circuits=(CIRCUIT,)) + + identity, _record = resolve(snapshot, BOUND, frozenset({"upstream"}), link_held=False, inverter_links_held=frozenset()) + + assert identity.withheld == frozenset() + assert identity.has_own_card("upstream") + + +def test_first_sight_never_binds_an_inverter_that_has_a_card() -> None: + """N2: a store lost or corrupt while cards exist leaves the lone carded inverter on its card, unbound.""" + identity, record = resolve(_snapshot(_fed("pv", CIRCUIT)), None, frozenset({CIRCUIT}), link_held=False, inverter_links_held=frozenset()) + + assert record == UNBOUND + assert identity.has_own_card(CIRCUIT) + + +def test_a_held_key_always_keeps_its_card() -> None: + """The invariant behind "nothing moves": for every key in `held`, `has_own_card` is True. + + For every subset of each snapshot's inverters as `held`, across first sight + (no record, which is also a removed or corrupt store), an unbound record, a + bound record and a pending bound record. + """ + snapshots = ( + _snapshot(_fed("pv", CIRCUIT)), + _snapshot(_unfed("pv")), + _snapshot(_fed("a", CIRCUIT), _fed("b", OTHER_CIRCUIT), circuits=(CIRCUIT, OTHER_CIRCUIT)), + _snapshot(_unfed("pv"), circuits=(CIRCUIT,)), + _snapshot(_fed("x", OTHER_CIRCUIT), _unfed("u"), circuits=(CIRCUIT, OTHER_CIRCUIT)), + ) + for snapshot in snapshots: + keys = sorted(snapshot.pv_inverters) + for size in range(len(keys) + 1): + for subset in combinations(keys, size): + held = frozenset(subset) + for record in (None, UNBOUND, BOUND): + identity, _record = resolve(snapshot, record, held, link_held=False, inverter_links_held=frozenset()) + for key in held: + assert identity.has_own_card(key), (sorted(held), record, key) + + +# --- the record on disk --------------------------------------------------------------- + + +def test_a_wrong_shaped_record_reads_as_absent() -> None: + for stored in (None, [], "text", 3, {}, {"circuit_id": 7}): + assert read_record(stored) is None + assert read_record({"circuit_id": CIRCUIT}) == BOUND + assert read_record({"circuit_id": None}) == UNBOUND + + +# --- whether the Solar card has a link entity ------------------------------------- +# +# Decided by what is stable, never by a reading: the entity is already registered +# (it never vanishes), or every inverter the card reads publishes a link record. + + +def _solar_link(snapshot: SpanPanelSnapshot, record: StoredPvBinding | None, *, link_held: bool) -> bool: + return resolve(snapshot, record, frozenset(), link_held=link_held, inverter_links_held=frozenset())[0].solar_link + + +def test_a_lone_inverter_has_a_solar_link_exactly_when_its_circuit_publishes_a_record() -> None: + """2.1.1 parity: 2.1.1 created the link iff the lone inverter's circuit published one.""" + assert _solar_link(_snapshot(_fed("pv", CIRCUIT, linked=True)), None, link_held=False) + assert _solar_link(_snapshot(_fed("pv", CIRCUIT, linked=False)), None, link_held=False) + assert not _solar_link(_snapshot(_fed("pv", CIRCUIT)), None, link_held=False) + assert not _solar_link(_snapshot(_unfed("pv")), None, link_held=False) + + +def test_a_bound_solar_link_follows_the_bound_inverters_record() -> None: + first, second = _fed("a", CIRCUIT, linked=True), _fed("b", OTHER_CIRCUIT) + assert _solar_link(_snapshot(first, second, circuits=(CIRCUIT, OTHER_CIRCUIT)), BOUND, link_held=False) + assert not _solar_link( + _snapshot(_fed("a", CIRCUIT), _fed("b", OTHER_CIRCUIT, linked=True), circuits=(CIRCUIT, OTHER_CIRCUIT)), + BOUND, + link_held=False, + ) + + +def test_a_bound_inverter_that_is_not_published_keeps_a_registered_link() -> None: + """Pending or departed: (b) cannot hold, so only the registry keeps the entity, and it does.""" + pending = _snapshot(_unfed("pv"), circuits=(CIRCUIT,)) + departed = _snapshot(_fed("x", OTHER_CIRCUIT, linked=True), circuits=(OTHER_CIRCUIT,)) + for snapshot in (pending, departed): + assert not _solar_link(snapshot, BOUND, link_held=False) + assert _solar_link(snapshot, BOUND, link_held=True) + + +def test_an_unbound_link_over_several_does_not_follow_a_reading() -> None: + """One record-less inverter: whether the others are up or down, the answer is the same.""" + for linked in (True, False): + snapshot = _snapshot(_fed("a", CIRCUIT, linked=linked), _fed("b", OTHER_CIRCUIT)) + assert not _solar_link(snapshot, UNBOUND, link_held=False) + assert _solar_link(snapshot, UNBOUND, link_held=True) + + +def test_an_unbound_link_over_several_all_recorded_is_created() -> None: + for first, second in ((True, True), (True, False), (False, False)): + snapshot = _snapshot(_fed("a", CIRCUIT, linked=first), _fed("b", OTHER_CIRCUIT, linked=second)) + assert _solar_link(snapshot, UNBOUND, link_held=False) + + +def test_with_no_inverter_only_a_registered_link_is_kept() -> None: + assert resolve(_snapshot(), None, frozenset(), link_held=False, inverter_links_held=frozenset())[0] == UNDECIDED_PV_BINDING + assert resolve(_snapshot(), None, frozenset(), link_held=True, inverter_links_held=frozenset())[0].solar_link + + +# --- whether an inverter's own card has its link entity ------------------------------ +# +# The Solar link's rule, applied per key: a link already registered is always created; +# otherwise the inverter's own record decides. + + +def _inverter_links( + snapshot: SpanPanelSnapshot, record: StoredPvBinding | None, *, links_held: frozenset[str] +) -> frozenset[str]: + # A registered link is one of the entities a card carries, so its key holds a card too. + return resolve(snapshot, record, links_held, link_held=False, inverter_links_held=links_held)[0].inverter_links + + +def test_a_carded_inverter_has_its_link_where_its_circuit_publishes_a_record() -> None: + snapshot = _snapshot( + _fed("a", CIRCUIT, linked=True), _fed("b", OTHER_CIRCUIT), circuits=(CIRCUIT, OTHER_CIRCUIT) + ) + assert _inverter_links(snapshot, UNBOUND, links_held=frozenset()) == frozenset({CIRCUIT}) + + +def test_a_registered_inverter_link_is_kept_when_its_record_goes() -> None: + snapshot = _snapshot(_fed("a", CIRCUIT), _fed("b", OTHER_CIRCUIT), circuits=(CIRCUIT, OTHER_CIRCUIT)) + assert _inverter_links(snapshot, UNBOUND, links_held=frozenset({OTHER_CIRCUIT})) == frozenset({OTHER_CIRCUIT}) + + +def test_only_an_inverter_with_its_own_card_has_its_own_link() -> None: + """The Solar card's inverter has no card of its own, so no own link, though it publishes a record; nor has a withheld one.""" + bound = _snapshot( + _fed("a", CIRCUIT, linked=True), _fed("b", OTHER_CIRCUIT, linked=True), circuits=(CIRCUIT, OTHER_CIRCUIT) + ) + assert _inverter_links(bound, BOUND, links_held=frozenset()) == frozenset({OTHER_CIRCUIT}) + pending = _snapshot(_fed("x", OTHER_CIRCUIT), _unfed("pv"), circuits=(CIRCUIT, OTHER_CIRCUIT)) + assert _inverter_links(pending, BOUND, links_held=frozenset()) == frozenset() diff --git a/tests/test_pv_device.py b/tests/test_pv_device.py new file mode 100644 index 00000000..3e6477ab --- /dev/null +++ b/tests/test_pv_device.py @@ -0,0 +1,846 @@ +"""The solar inverter gets a device of its own, and takes its entities with it. + +PV was the last DER on a v1.0 panel with no card. Its vendor, model and +nameplate capacity were rendered as three diagnostic sensors on the *panel's* +device — beside the panel's own manufacturer and model, so a panel card read as +if the enclosure were an Enphase inverter — and the firmware version the library +has always read reached nothing at all, because a version has nowhere to go but +a card. `pv/info/firmware-version` was baselined naming exactly that. + +**The identifier is the decision worth writing down.** `{panel serial}_pv`, and +deliberately not the inverter's serial. Every PV `$description` declares +`info/serial-number` and no producer publishes one, so an identifier that +preferred a serial would be `_pv` on every panel today and +`_` on the first panel whose firmware starts publishing one — and +a device identifier is what a consumer keys its registry on, so that day would +read as the inverter being replaced. `test_a_serial_arriving_on_the_wire_does_not_move_the_device` +produces that day and asserts nothing moves. + +**The migration is the risk.** Existing installations have these entities on the +panel device with panel-scoped `unique_id`s and panel-scoped `entity_id`s. Moving +an entity to another device must change *only* the device: a changed `entity_id` +breaks a dashboard, a changed `unique_id` orphans the entity and mints a +duplicate. Home Assistant re-homes an entity by itself when it re-registers with +new `device_info` — `async_get_or_create` returns the entry it already holds for +a `unique_id`, updates its `device_id` and leaves its `entity_id` untouched — so +there is no bespoke migration here and deliberately none written. + +**The fork in entity ids is deliberate; do not close it.** Nothing pins an +`entity_id`. Home Assistant derives a new entity's object id from the name of the +device it belongs to, so a fresh installation gets +`sensor.span_panel_solar_pv_vendor` where every existing one keeps +`sensor.span_panel_pv_vendor`. Both are correct: an existing installation must +never have an id change under it, and a new one gets the standard Home Assistant +assignment rather than a legacy shape invented to match history it does not have. +An earlier revision pinned the panel-scoped id on both to keep the two identical; +that was reversed, because the pin bought uniformity by giving every future +installation an entity id derived from the wrong device. +`test_a_fresh_install_and_a_migrated_one_differ_only_in_entity_id` is the record +of the decision. + +Every expectation about what the card shows is read out of the reference capture +and proved by republishing or unpublishing the property, never by a literal. The +registry-shape expectations *are* literals, on purpose: they record what a +released installation carries, and deriving them from the code under test would +make the migration assertions vacuous. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +import json +import logging +import pathlib +from typing import Final +from unittest.mock import AsyncMock, MagicMock + +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered +from custom_components.span_panel.binary_sensor import ( + PV_PANEL_LINK_SENSOR, + async_setup_entry as binary_sensor_setup_entry, +) +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + Producibility, + declared_field_paths, +) +from custom_components.span_panel.sensor import async_setup_entry as sensor_setup_entry +from custom_components.span_panel.sensor_definitions import PV_METADATA_SENSORS, PV_POWER_SENSOR +from custom_components.span_panel.util import ( + SUB_DEVICE_PV, + classify_sub_device_identifier, + pv_device_info, +) +from custom_components.span_panel.websocket import _classify_sub_device +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import slugify + +from .adapter_fixtures import schema_one_snapshot, schema_one_tree +from .factories import pv_binding_for + +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + MockEntityPlatform, + async_test_home_assistant, +) + +PV_DEVICE: Final = "pv" +"""The inverter's Homie device id in the capture.""" + +SOLAR_CIRCUIT: Final = "573066aaddd7b75114c4563ce3af18c4" +"""The capture's solar circuit, which feeds its one inverter.""" + +VENDOR_TOPIC: Final = "info/vendor-name" +MODEL_TOPIC: Final = "info/model" +FIRMWARE_TOPIC: Final = "info/firmware-version" +SERIAL_TOPIC: Final = "info/serial-number" + +PANEL_NAME: Final = "SPAN Panel" + +FALLBACK_MANUFACTURER: Final = "Unknown" +FALLBACK_MODEL: Final = "Solar Inverter" + +BASELINE: Final = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json" + +_LEGACY_PV_ENTITIES: Final[tuple[tuple[str, str, str], ...]] = ( + ("sensor", "pv_power", "sensor.span_panel_pv_power"), + ("sensor", "pv_vendor", "sensor.span_panel_pv_vendor"), + ("sensor", "pv_product", "sensor.span_panel_pv_product"), + ("sensor", "pv_nameplate_capacity", "sensor.span_panel_pv_nameplate_capacity"), + ("binary_sensor", "pv_panel_link", "binary_sensor.span_panel_pv_panel_link"), +) +"""``(platform, unique_id suffix, entity_id)`` as a released installation holds them. + +Literals, deliberately. This is the registry a user upgrading already has, which +is a historical fact rather than something the current code gets to decide — +deriving it from the builders under test would make every assertion below agree +with itself. `test_a_fresh_install_still_builds_the_unique_ids_users_already_have` +is what holds the literals to the code. +""" + +_FRESH_PV_ENTITIES: Final[tuple[tuple[str, str, str], ...]] = ( + ("sensor", "pv_power", "sensor.span_panel_solar_pv_power"), + ("sensor", "pv_vendor", "sensor.span_panel_solar_pv_vendor"), + ("sensor", "pv_product", "sensor.span_panel_solar_pv_product"), + ("sensor", "pv_nameplate_capacity", "sensor.span_panel_solar_pv_nameplate_capacity"), + ("binary_sensor", "pv_panel_link", "binary_sensor.span_panel_solar_pv_panel_link"), +) +"""The same five as a *new* installation gets them, on the inverter's own card. + +`{device name} {entity name}`, slugified — Home Assistant's own derivation, with +the device being `SPAN Panel Solar` rather than `SPAN Panel`. Literals for the +same reason the legacy tuple is: these are what a user, a dashboard and a +support answer will name, and deriving them from the code under test would make +the assertions agree with themselves. +`test_the_fresh_ids_are_what_the_inverters_card_derives` holds them to +`strings.json`. +""" + + +# --------------------------------------------------------------------------- +# Reading the capture +# --------------------------------------------------------------------------- + + +def _published(topic: str) -> str: + """What the capture publishes on one PV topic, or fail saying it does not.""" + value = schema_one_tree()[PV_DEVICE].get(topic) + assert value is not None, f"the capture publishes no {topic} on the inverter" + return value + + +def _declared(topic: str) -> bool: + """Whether the inverter's `$description` declares one `node/property`.""" + description = json.loads(schema_one_tree()[PV_DEVICE]["$description"]) + node, _, prop = topic.partition("/") + return prop in description["nodes"].get(node, {}).get("properties", {}) + + +def _pv_snapshot(**rewrites: str | None) -> SpanPanelSnapshot: + """A snapshot from the capture with the inverter's topics rewritten or removed. + + Keyword spelling is `node__property_name`. `None` removes the topic, which is + what firmware omitting a property looks like — a different event from + publishing an empty string, and the one the card's fallbacks exist for. + """ + tree = schema_one_tree() + for path, value in rewrites.items(): + node, _, prop = path.partition("__") + topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}" + if value is None: + tree[PV_DEVICE].pop(topic, None) + else: + tree[PV_DEVICE][topic] = value + return schema_one_snapshot(tree) + + +# --------------------------------------------------------------------------- +# Installing for real +# --------------------------------------------------------------------------- + + +def _entry(hass: HomeAssistant, entry_id: str, serial: str) -> MockConfigEntry: + """A config entry keyed on the panel's serial, as the config flow makes one.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.40", "device_name": PANEL_NAME}, + options={}, + title=PANEL_NAME, + entry_id=entry_id, + unique_id=serial, + ) + entry.add_to_hass(hass) + return entry + + +def _coordinator( + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SpanPanelSnapshot +) -> MagicMock: + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = hass + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = entry + coordinator.async_request_refresh = AsyncMock() + # Awaited when a test that marks the entry loaded is unloaded at teardown. + coordinator.async_shutdown = AsyncMock() + # Every Net Energy adds its siblings' dip offsets to its reading, so the + # offset has to be a number: a bare `MagicMock` would become the state. + coordinator.dip_offset = MagicMock(return_value=0.0) + return coordinator + + +async def _register( + hass: HomeAssistant, + entry: MockConfigEntry, + platform: str, + setup: Callable[..., object], +) -> None: + """Run one platform's `async_setup_entry` through a real `EntityPlatform`. + + Through the platform rather than by inspecting the entities the setup + function returns, because everything under test here happens *in* the + registry: which device an entity is filed under, and which `entity_id` it + keeps. Neither is observable on an entity object. + """ + added: list[object] = [] + await setup(hass, entry, lambda entities, **_: added.extend(entities)) + + entity_platform = MockEntityPlatform( + hass, domain=platform, platform_name=DOMAIN, logger=logging.getLogger(__name__) + ) + entity_platform.config_entry = entry + # The translations decide the object id Home Assistant derives, so a harness + # that skipped them would generate `sensor.span_panel_2` and prove nothing + # about the ids a user sees. + await entity_platform.platform_data.async_load_translations() + await entity_platform.async_add_entities(added) + + +async def _install( + hass: HomeAssistant, + snapshot: SpanPanelSnapshot, + entry_id: str, + *, + seed: Callable[[HomeAssistant, MockConfigEntry, str], None] | None = None, +) -> MockConfigEntry: + """Set up both platforms the way the integration does, optionally over a seeded registry.""" + entry = _entry(hass, entry_id, snapshot.serial_number) + panel_device_id = await ensure_device_registered(hass, entry, snapshot, PANEL_NAME) + if seed is not None: + seed(hass, entry, panel_device_id) + + coordinator = _coordinator(hass, entry, snapshot) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id=panel_device_id, + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + await _register(hass, entry, "sensor", sensor_setup_entry) + await _register(hass, entry, "binary_sensor", binary_sensor_setup_entry) + return entry + + +@asynccontextmanager +async def _a_second_home_assistant() -> AsyncIterator[HomeAssistant]: + """A second, empty Home Assistant, for comparing two installations. + + Both halves of the fresh-versus-migrated comparison have to be *the* install + on their instance. Running them into one registry makes the second collide + with the first and land on `..._pv_vendor_2`, which is an artefact of the + harness and would mask or invent a divergence either way. + """ + async with async_test_home_assistant() as second: + try: + yield second + finally: + await second.async_stop(force=True) + + +def _seed_the_old_shape(hass: HomeAssistant, entry: MockConfigEntry, panel_device_id: str) -> None: + """Write the PV entities onto the panel device, as a released install holds them.""" + registry = er.async_get(hass) + for platform, suffix, entity_id in _LEGACY_PV_ENTITIES: + created = registry.async_get_or_create( + platform, + DOMAIN, + f"span_{entry.unique_id}_{suffix}", + config_entry=entry, + device_id=panel_device_id, + suggested_object_id=entity_id.split(".", 1)[1], + ) + assert created.entity_id == entity_id, ( + f"the seed could not reproduce {entity_id}; it landed on {created.entity_id}" + ) + + +def _pv_device(hass: HomeAssistant, entry: MockConfigEntry) -> dr.DeviceEntry | None: + return dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, f"{entry.unique_id}_{SUB_DEVICE_PV}"), entry.entry_id + ) + + +def _registry_shape(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, tuple[str, ...]]: + """``{entity_id: (unique_id, *device identifiers)}`` for every entity of one entry. + + Devices by identifier rather than by registry id, because two installations + mint different registry ids for the same device and the identifier is the + stable name for "which card is this on". + """ + devices = dr.async_get(hass) + shape: dict[str, tuple[str, ...]] = {} + for entity in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id): + device = devices.async_get(entity.device_id) if entity.device_id else None + identifiers = sorted(name for _domain, name in device.identifiers) if device else [] + shape[entity.entity_id] = (entity.unique_id, *identifiers) + return shape + + +# --------------------------------------------------------------------------- +# The premise +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_the_identity_the_card_shows() -> None: + """Guard the premise: every card expectation below is read from these topics. + + A test whose expected value comes from an unpublished topic does not fail, it + stops asserting anything — which is how the fixture drifted eight identity + properties behind the producer without a single red test. + """ + assert _published(VENDOR_TOPIC) + assert _published(MODEL_TOPIC) + assert _published(FIRMWARE_TOPIC) + + +def test_the_capture_declares_a_serial_and_publishes_none() -> None: + """The premise of the identifier decision, held to the capture. + + If a producer ever values this, the choice of identifier stops being + hypothetical and `test_a_serial_arriving_on_the_wire_does_not_move_the_device` + stops being a simulation. Either way the identifier must not move, which is + what the two together assert. + """ + assert _declared(SERIAL_TOPIC), "the inverter no longer declares a serial number" + assert schema_one_tree()[PV_DEVICE].get(SERIAL_TOPIC) is None, ( + "the capture now values PV info/serial-number; decision 3 says it stays unvalued " + "until the flat side's PV device id is confirmed" + ) + + +# --------------------------------------------------------------------------- +# The card +# --------------------------------------------------------------------------- + + +async def test_the_inverter_gets_a_card_showing_what_it_publishes( + hass: HomeAssistant, +) -> None: + """Manufacturer, model and firmware read off the wire, not out of a constant.""" + entry = await _install(hass, _pv_snapshot(), "entry-pv-card") + + device = _pv_device(hass, entry) + assert device is not None + assert device.manufacturer == _published(VENDOR_TOPIC) + assert device.model == _published(MODEL_TOPIC) + assert device.sw_version == _published(FIRMWARE_TOPIC) + + +async def test_the_card_follows_a_republished_identity(hass: HomeAssistant) -> None: + """The card tracks the wire, so nothing above is passing on a coincidence.""" + rewritten = _pv_snapshot( + info__vendor_name="Another Vendor", + info__model="ANOTHER-MODEL-1", + info__firmware_version="example-pv/v9.9.9", + ) + + entry = await _install(hass, rewritten, "entry-pv-card-rewritten") + + device = _pv_device(hass, entry) + assert device is not None + assert device.manufacturer == "Another Vendor" + assert device.model == "ANOTHER-MODEL-1" + assert device.sw_version == "example-pv/v9.9.9" + + +async def test_an_inverter_publishing_no_identity_gets_a_card_with_no_blank_rows( + hass: HomeAssistant, +) -> None: + """The fallbacks, and the difference between an absent row and a blank one. + + Vendor and model fall back to strings because a card with no name at all is + worse than a generic one. The firmware version has no string to fall back to + and must be *absent* rather than empty: `DeviceInfo` omits a `None` field and + renders `""` as a present-but-blank row, which reads as an inverter reporting + a blank version rather than one reporting none. + """ + bare = _pv_snapshot( + info__vendor_name=None, + info__model=None, + info__firmware_version=None, + ) + + entry = await _install(hass, bare, "entry-pv-card-bare") + + device = _pv_device(hass, entry) + assert device is not None + assert device.manufacturer == FALLBACK_MANUFACTURER + assert device.model == FALLBACK_MODEL + assert device.sw_version is None + + +async def test_the_card_hangs_off_the_panel_like_every_other_sub_device( + hass: HomeAssistant, +) -> None: + """By registry id, which is the link Home Assistant stops dropping in 2027.8.""" + snapshot = _pv_snapshot() + entry = await _install(hass, snapshot, "entry-pv-link") + + device = _pv_device(hass, entry) + panel = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, snapshot.serial_number), entry.entry_id + ) + assert device is not None + assert panel is not None + assert device.via_device_id == panel.id + + +async def test_a_panel_with_no_inverter_gets_no_card(hass: HomeAssistant) -> None: + """Absence is a reading. No PV node, no device, and no entities to re-home.""" + tree = schema_one_tree() + del tree[PV_DEVICE] + for topics in tree.values(): + topics.pop("power-flows/pv", None) + for circuit, topics in tree.items(): + if topics.get("connection/feeds-device-id") == PV_DEVICE: + topics.pop("connection/feeds-device-id", None) + topics.pop("connection/feeds-device-status", None) + topics.pop("connection/feeds-device-type", None) + + entry = await _install(hass, schema_one_snapshot(tree), "entry-pv-absent") + + assert _pv_device(hass, entry) is None + shape = _registry_shape(hass, entry) + assert not [entity_id for entity_id in shape if "_pv_" in entity_id] + + +# --------------------------------------------------------------------------- +# The identifier +# --------------------------------------------------------------------------- + + +async def test_a_serial_arriving_on_the_wire_does_not_move_the_device( + hass: HomeAssistant, +) -> None: + """The whole reason the identifier does not mention the inverter's serial. + + `_der_identifier` on the producer side prefers a serial over an instance id, + so the day firmware starts publishing `info/serial-number` the inverter's + *Homie* device id changes. A Home Assistant identifier derived from it would + change with it, and a changed identifier is a new device: the card empties, + the entities orphan, and an upgrade rehearsal becomes a device-replacement + rehearsal. Keyed on the panel's serial and the kind instead, so this test + publishes a serial and watches nothing move. + """ + before = await _install(hass, _pv_snapshot(), "entry-pv-noserial") + identifier_before = _pv_device(hass, before) + shape_before = _registry_shape(hass, before) + + async with _a_second_home_assistant() as second: + after = await _install( + second, + _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001"), + "entry-pv-serial", + ) + identifier_after = _pv_device(second, after) + shape_after = _registry_shape(second, after) + + assert identifier_before is not None + assert identifier_after is not None + assert identifier_before.identifiers == identifier_after.identifiers + assert shape_before == shape_after + + +async def test_the_topology_reader_calls_the_new_card_a_pv(hass: HomeAssistant) -> None: + """The writing end and the reading end of the identifier grammar agree. + + The MID shipped classifying as `unknown` because a kind was added to the + builders and not to the reader, and a card rendered a device with a name and + no type. Asserted against the device as registered rather than against the + builder's dict, because the reader is handed a `DeviceEntry`. + """ + entry = await _install(hass, _pv_snapshot(), "entry-pv-classify") + + device = _pv_device(hass, entry) + assert device is not None + assert _classify_sub_device(device) == SUB_DEVICE_PV + assert classify_sub_device_identifier(f"{entry.unique_id}_{SUB_DEVICE_PV}") == SUB_DEVICE_PV + + +# --------------------------------------------------------------------------- +# Where the entities land, and what they keep +# --------------------------------------------------------------------------- + + +def _pv_entities(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, er.RegistryEntry]: + """The five PV entities, by `entity_id`, or fail naming the ones missing.""" + registry = er.async_get(hass) + found: dict[str, er.RegistryEntry] = {} + missing: list[str] = [] + for platform, suffix, _entity_id in _LEGACY_PV_ENTITIES: + entity_id = registry.async_get_entity_id( + platform, DOMAIN, f"span_{entry.unique_id}_{suffix}" + ) + if entity_id is None: + missing.append(suffix) + continue + entity = registry.async_get(entity_id) + assert entity is not None + found[entity_id] = entity + assert not missing, f"the platform created no entity for {missing}" + return found + + +async def test_a_fresh_install_still_builds_the_unique_ids_users_already_have( + hass: HomeAssistant, +) -> None: + """The `unique_id` literals above are the current code's output, so the migration bites. + + A `unique_id` is an identity: changing one does not rename an entity, it + orphans the old one and mints a second. The migration tests seed a registry + from `_LEGACY_PV_ENTITIES` and then expect setup to find those same ids, so + if the builders ever stopped producing them the seed would simply never be + matched and every migration assertion would pass over a registry it had + quietly rebuilt. This holds the recorded ids to the builders. + + Only the `unique_id`s. The `entity_id`s a fresh installation gets are the + ones on `_FRESH_PV_ENTITIES`, which is the intended fork. + """ + entry = await _install(hass, _pv_snapshot(), "entry-pv-uids") + + found = _pv_entities(hass, entry) + assert {entity.unique_id for entity in found.values()} == { + f"span_{entry.unique_id}_{suffix}" for _platform, suffix, _entity_id in _LEGACY_PV_ENTITIES + } + assert set(found) == {entity_id for _platform, _suffix, entity_id in _FRESH_PV_ENTITIES} + + +async def test_every_pv_entity_lands_on_the_inverters_card(hass: HomeAssistant) -> None: + """All five, and none of them left behind on the panel.""" + entry = await _install(hass, _pv_snapshot(), "entry-pv-home") + + device = _pv_device(hass, entry) + assert device is not None + for entity_id, entity in _pv_entities(hass, entry).items(): + assert entity.device_id == device.id, f"{entity_id} is not on the inverter's card" + + +def test_the_fresh_ids_are_what_the_inverters_card_derives() -> None: + """`_FRESH_PV_ENTITIES` is Home Assistant's derivation, not a guess at it. + + Home Assistant slugifies `{device name} {entity name}` for an + `has_entity_name` entity. The device is `pv_device_info`'s + `f"{panel_name} Solar"` and the name comes from `strings.json`, so both + halves are read from the sources that would change rather than restated. + Renaming one of these in `strings.json` changes the entity_id a *new* + installation gets and nothing else, which is a user-visible change worth + failing a test over. + + Also asserts each fresh id differs from the legacy one, so the fork this file + documents is a fact the test suite carries rather than a claim in a comment. + """ + strings = json.loads( + ( + pathlib.Path(__file__).parent.parent + / "custom_components" + / "span_panel" + / "strings.json" + ).read_text(encoding="utf-8") + ) + legacy = {suffix: entity_id for _platform, suffix, entity_id in _LEGACY_PV_ENTITIES} + for platform, suffix, entity_id in _FRESH_PV_ENTITIES: + name = strings["entity"][platform][suffix]["name"] + assert entity_id == f"{platform}.{slugify(f'{PANEL_NAME} Solar')}_{slugify(name)}", ( + f"{platform}.{suffix} is named {name!r}; a new installation's entity_id " + "is derived from that name and the inverter's device name" + ) + assert entity_id != legacy[suffix] + + +def test_the_descriptions_carry_the_translation_keys_the_names_are_read_from() -> None: + """Without a `translation_key` an entity has no name, and no derived object id. + + Home Assistant falls back to the device name alone for an `has_entity_name` + entity with no name, so five PV entities would all want + `sensor.span_panel_solar` and the registry would resolve the collision by + appending numbers — a silent, permanent scrambling of five entity ids on new + installations only. The keys are also the `unique_id` suffixes, which is why + they are compared against the recorded shape. + """ + keys = {PV_POWER_SENSOR.translation_key} | { + description.translation_key for description in PV_METADATA_SENSORS + } + keys.add(PV_PANEL_LINK_SENSOR.translation_key) + assert keys == {suffix for _platform, suffix, _entity_id in _LEGACY_PV_ENTITIES} + + +# --------------------------------------------------------------------------- +# The migration +# --------------------------------------------------------------------------- + + +async def test_the_migration_moves_the_device_and_nothing_else( + hass: HomeAssistant, +) -> None: + """Seed the released shape, set up, and read the registry back. + + Home Assistant re-homes an entity when it re-registers with different + `device_info` — `async_get_or_create` updates `device_id` on an existing + entry — so there is no bespoke migration to write and deliberately none + written. What that mechanism does *not* touch is the `entity_id` or the + `unique_id`, and those are what a user's dashboards and automations name, so + they are asserted one by one rather than in aggregate. + """ + entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate", seed=_seed_the_old_shape) + + device = _pv_device(hass, entry) + assert device is not None + registry = er.async_get(hass) + for platform, suffix, entity_id in _LEGACY_PV_ENTITIES: + unique_id = f"span_{entry.unique_id}_{suffix}" + entity = registry.async_get(entity_id) + assert entity is not None, f"{entity_id} no longer exists after setup" + assert entity.unique_id == unique_id, f"{entity_id} changed unique_id" + assert entity.platform == DOMAIN + assert entity.domain == platform + assert entity.device_id == device.id, f"{entity_id} did not move to the inverter" + + +async def test_the_migration_leaves_no_duplicate_and_no_orphan( + hass: HomeAssistant, +) -> None: + """The two failure modes a re-home has, made observable. + + A duplicate: the entity re-registers under a new `unique_id`, so the old + registry entry survives beside a new one and a user sees each reading twice. + An orphan: the panel device keeps an entity nothing writes to any more. Both + are counted rather than spot-checked, because either would otherwise hide + among forty circuit entities. + """ + entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate-clean", seed=_seed_the_old_shape) + + device = _pv_device(hass, entry) + panel = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, entry.unique_id or ""), entry.entry_id + ) + assert device is not None + assert panel is not None + + entities = er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) + unique_ids = [entity.unique_id for entity in entities] + assert len(unique_ids) == len(set(unique_ids)), "an entity re-registered under a second id" + + pv_unique_ids = {f"span_{entry.unique_id}_{suffix}" for _p, suffix, _e in _LEGACY_PV_ENTITIES} + left_behind = [ + entity.entity_id + for entity in entities + if entity.unique_id in pv_unique_ids and entity.device_id != device.id + ] + assert not left_behind, f"still on the panel card: {left_behind}" + + pv_devices = [ + candidate + for candidate in dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id) + if any( + classify_sub_device_identifier(name) == SUB_DEVICE_PV + for _domain, name in candidate.identifiers + ) + ] + assert len(pv_devices) == 1, f"{len(pv_devices)} PV devices registered, expected 1" + + +async def test_a_fresh_install_and_a_migrated_one_differ_only_in_entity_id( + hass: HomeAssistant, +) -> None: + """The two installations fork on `entity_id`, and on nothing else. On purpose. + + **This asymmetry is a decision, not a bug.** An earlier revision pinned the + panel-scoped `entity_id` on both installations so they matched exactly. That + was reversed. The governing constraint is that an *existing* installation's + `entity_id` and `unique_id` must never change — dashboards, automations and + history all name them — and that constraint is satisfied by Home Assistant's + own behaviour, without a pin: `async_get_or_create` honours a suggested + object id only at first registration. The pin therefore did nothing for the + installed base; all it did was give every *future* installation an entity id + derived from the panel rather than from the device the entity actually sits + on, permanently, to match a history that installation does not have. + + So: same `unique_id`s, same device, different `entity_id`s, decided by install + date. If you are reading this because the divergence looks wrong, it is + intended — the fix is not to pin the legacy shape back on. + + Compared over `{entity_id: (unique_id, device identifiers)}` — every fact a + user or a dashboard can name — so an entity filed under the wrong card fails + with its entity_id in the message. + """ + snapshot = _pv_snapshot() + migrated = await _install(hass, snapshot, "entry-pv-shape-old", seed=_seed_the_old_shape) + migrated_shape = _registry_shape(hass, migrated) + + async with _a_second_home_assistant() as second: + fresh = await _install(second, snapshot, "entry-pv-shape-new") + fresh_shape = _registry_shape(second, fresh) + + legacy_ids = {entity_id for _platform, _suffix, entity_id in _LEGACY_PV_ENTITIES} + fresh_ids = {entity_id for _platform, _suffix, entity_id in _FRESH_PV_ENTITIES} + + # The literals, both ways round: the upgraded installation kept every id it + # had, and the new one took the id its device derives. + assert legacy_ids <= set(migrated_shape) + assert not (fresh_ids & set(migrated_shape)), "an upgraded installation grew a second shape" + assert fresh_ids <= set(fresh_shape) + assert not (legacy_ids & set(fresh_shape)), "a new installation was given the legacy shape" + + # Everything that is not one of the five is identical, so the fork is exactly + # as wide as it is meant to be and no other entity moved or was renamed. + assert { + entity_id: value + for entity_id, value in migrated_shape.items() + if entity_id not in legacy_ids + } == { + entity_id: value for entity_id, value in fresh_shape.items() if entity_id not in fresh_ids + } + + # And across the fork the five are the same five: same identity, same card. + assert {migrated_shape[entity_id] for entity_id in legacy_ids} == { + fresh_shape[entity_id] for entity_id in fresh_ids + } + + +async def test_a_user_renamed_entity_id_survives_the_move(hass: HomeAssistant) -> None: + """A `entity_id` the user chose is theirs, and the re-home must not touch it. + + The same registry behaviour the whole migration rests on, asserted at its + sharpest point: an id that matches neither the legacy shape nor the shape the + inverter's card derives still survives, because Home Assistant re-derives an + object id for a `unique_id` it has never seen and for no other. + """ + + def seed(hass_: HomeAssistant, entry_: MockConfigEntry, panel_device_id: str) -> None: + _seed_the_old_shape(hass_, entry_, panel_device_id) + er.async_get(hass_).async_update_entity( + "sensor.span_panel_pv_vendor", new_entity_id="sensor.my_solar_brand" + ) + + entry = await _install(hass, _pv_snapshot(), "entry-pv-renamed", seed=seed) + + registry = er.async_get(hass) + assert registry.async_get("sensor.span_panel_pv_vendor") is None + renamed = registry.async_get("sensor.my_solar_brand") + assert renamed is not None + device = _pv_device(hass, entry) + assert device is not None + assert renamed.device_id == device.id + + +# --------------------------------------------------------------------------- +# The inventories +# --------------------------------------------------------------------------- + + +def test_the_firmware_version_is_no_longer_an_unread_declaration() -> None: + """The line this task exists to delete, and the line that stays. + + `pv/info/firmware-version` was baselined saying the inverter had no card to + carry a version; it has one now, so the line goes. `pv/info/serial-number` + stays, and its reason has to name the identifier decision rather than only + the producer-side one, because the Home Assistant identifier is now a second + thing that would have moved. + """ + baseline: dict[str, str] = json.loads(BASELINE.read_text(encoding="utf-8")) + + assert "pv/info/firmware-version" not in baseline + reason = baseline.get("pv/info/serial-number") + assert reason is not None, "the serial must stay baselined; see decision 3" + assert "identifier" in reason + + +def test_the_cards_firmware_read_is_enumerated_as_a_residual() -> None: + """`pv_device_info` is not an entity, so its read is an exempt residual. + + `SCHEMA_0_ONLY`: flat declares `software-version` on its `pv` device class + and the library maps it, while schema_1 carries no row -- a row states a + reading's unit and datatype, and a version string is identity, the same + argument as the `mid.*` and `panel.*` card reads. One adapter short of the + both-adapters gate either way, so it stays an exemption rather than becoming + a declaration. + """ + assert RESIDUAL_EXEMPT_PATHS["pv.software_version"] is Producibility.SCHEMA_0_ONLY + assert "pv.software_version" not in declared_field_paths() + + +def test_the_pv_serial_is_read_but_shown_nowhere() -> None: + """The negative half of the identifier decision, asserted rather than assumed. + + Adding the field to the snapshot would be harmless; reading it here would + not, because every read is a place a future change could route into the + identifier. The library carries the field from span-panel-api 3.5.0; this + integration declares no path for it and its device card does not show it, + and this fails the day either changes without the decision being revisited. + """ + assert "pv.serial_number" not in RESIDUAL_EXEMPT_PATHS + assert "pv.serial_number" not in declared_field_paths() + snapshot = _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001") + assert snapshot.pv.serial_number == "INVERTER-SERIAL-0001" + card = pv_device_info("panel-serial", snapshot.pv, "Panel", panel_device_id="panel-device") + assert "INVERTER-SERIAL-0001" not in repr(card) + + +def test_the_kind_vocabulary_is_closed() -> None: + """Every kind the classifier answers with, in one place. + + Not a restatement of the classifier: `test_device_links` parametrises over + the *builders*, so a fifth sub-device whose identifier nothing classifies + fails there. This is the other direction — the reader answering a kind the + writers never mint — and it is why the list is spelled out rather than + derived from the constants. + """ + assert [ + classify_sub_device_identifier("panel-serial_bess"), + classify_sub_device_identifier("panel-serial_mid"), + classify_sub_device_identifier("panel-serial_evse_node"), + classify_sub_device_identifier("panel-serial_pv"), + classify_sub_device_identifier("panel-serial"), + ] == ["bess", "mid", "evse", SUB_DEVICE_PV, None] diff --git a/tests/test_pv_inverters.py b/tests/test_pv_inverters.py new file mode 100644 index 00000000..93f94dfe --- /dev/null +++ b/tests/test_pv_inverters.py @@ -0,0 +1,424 @@ +"""A panel with more than one PV inverter gets a card and entities per inverter. + +From firmware r202639 a panel publishes every commissioned inverter as its own +device, and the library carries all of them in `snapshot.pv_inverters`, keyed by +the feeding circuit's id or, for an inverter no circuit feeds, its device id. +Before that a panel published one, and the integration rendered it on the +`{serial}_pv` card under panel-scoped unique ids. + +Two things are held here. Every inverter but the one the Solar card reads gets a +card and entities keyed by its circuit or, where none feeds it, its device id; +and the Solar card's entities never move. + +The registry-shape expectations are literals, for the reason `test_pv_device` +gives: they record what an installation carries. +""" + +from __future__ import annotations + +import logging +from typing import Final + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockEntityPlatform +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import ( + SpanPanelRuntimeData, + async_remove_config_entry_device, + ensure_device_registered, +) +from custom_components.span_panel.binary_sensor import ( + async_setup_entry as binary_sensor_setup_entry, +) +from custom_components.span_panel.const import DOMAIN, PV_PANEL_LINK_KEY +from custom_components.span_panel.curation import async_load_curation +from custom_components.span_panel.helpers import detect_capabilities +from custom_components.span_panel.id_builder import ( + build_binary_sensor_unique_id, + build_panel_unique_id, +) +from custom_components.span_panel.pv_binding import async_resolve_pv_binding +from custom_components.span_panel.sensor import async_setup_entry as sensor_setup_entry +from custom_components.span_panel.sensor_definitions import ( + CIRCUIT_SENSORS, + PV_METADATA_SENSORS, + PV_POWER_SENSOR, +) +from custom_components.span_panel.util import ( + SUB_DEVICE_PV, + classify_sub_device_identifier, + pv_inverter_device_info, +) +from custom_components.span_panel.websocket import _classify_sub_device + +from .adapter_fixtures import schema_one_snapshot, schema_one_tree +from .test_pv_device import PANEL_NAME, SOLAR_CIRCUIT, _coordinator, _entry + +SECOND_SOLAR_CIRCUIT: Final = "5be1d2c3a4f5061728394a5b6c7d8e9f" +FIRST_PV: Final = "pv-1" +SECOND_PV: Final = "pv-2" +UNFED_PV: Final = "pv-3" + +METADATA_KEYS: Final = ("pv_vendor", "pv_product", "pv_nameplate_capacity") + + +def _tree(*, second: bool = True, unfed: bool = False) -> dict[str, dict[str, str]]: + """Rewrite the capture with its inverter renamed and others added, as r202639 publishes them; the second sits on lower breaker spaces, where 2.1.2b3 would have ranked it first. + + The second also publishes a serial, so a serial is on the wire for the + identity assertions to ignore. + """ + tree = schema_one_tree() + pv_topics = tree.pop("pv") + tree[FIRST_PV] = dict(pv_topics) + tree[SOLAR_CIRCUIT]["connection/feeds-device-id"] = FIRST_PV + if second: + tree[SECOND_PV] = { + **pv_topics, + "info/vendor-name": "Second Vendor", + "info/serial-number": "INVERTER-SERIAL-0002", + "info/nominal-power": "4000.0", + } + tree[SECOND_SOLAR_CIRCUIT] = { + **tree[SOLAR_CIRCUIT], + "connection/feeds-device-id": SECOND_PV, + "info/name": "Garage Solar", + "info/spaces": "5,7", + } + if unfed: + tree[UNFED_PV] = dict(pv_topics) + return tree + + +async def _setup( + hass: HomeAssistant, + entry: MockConfigEntry, + snapshot: SpanPanelSnapshot, + *, + published: SpanPanelSnapshot | None = None, +) -> list[MockEntityPlatform]: + """Run both platforms' setup through real `EntityPlatform`s, as a (re)load does. + + In `async_setup_entry`'s order: the PV identity, then the curation overlay, then the platforms. + `published` is what the coordinator holds by the time the platforms run, where + the panel published more after setup took `snapshot`. + """ + identity = await async_resolve_pv_binding(hass, entry, snapshot) + coordinator = _coordinator(hass, entry, published if published is not None else snapshot) + panel_device_id = await ensure_device_registered(hass, entry, snapshot, PANEL_NAME) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id=panel_device_id, + curation=await async_load_curation(hass, entry), + pv_binding=identity, + setup_snapshot=snapshot, + ) + platforms: list[MockEntityPlatform] = [] + for domain, setup in ( + ("sensor", sensor_setup_entry), + ("binary_sensor", binary_sensor_setup_entry), + ): + added: list[object] = [] + await setup(hass, entry, lambda entities, **_: added.extend(entities)) + platform = MockEntityPlatform( + hass, domain=domain, platform_name=DOMAIN, logger=logging.getLogger(__name__) + ) + platform.config_entry = entry + await platform.platform_data.async_load_translations() + await platform.async_add_entities(added) + platforms.append(platform) + return platforms + + +async def _unload(platforms: list[MockEntityPlatform]) -> None: + for platform in platforms: + await platform.async_reset() + + +def _unique_ids(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, str]: + """``{unique_id: entity_id}`` for every registered entity of one entry.""" + return { + entity.unique_id: entity.entity_id + for entity in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) + } + + +def _device(hass: HomeAssistant, entry: MockConfigEntry, identifier: str) -> dr.DeviceEntry: + device = dr.async_get(hass).async_get_device_by_identifier((DOMAIN, identifier), entry.entry_id) + assert device is not None, f"no device registered for {identifier}" + return device + + +def _device_of(hass: HomeAssistant, unique_ids: dict[str, str], unique_id: str) -> dr.DeviceEntry: + entity = er.async_get(hass).async_get(unique_ids[unique_id]) + assert entity is not None + assert entity.device_id is not None + device = dr.async_get(hass).async_get(entity.device_id) + assert device is not None + return device + + +def solar_unique_ids(serial: str, *, link: bool = True) -> dict[str, str]: + """`{suffix: unique_id}` of the Solar card's PV entities, built as the entities build them.""" + ids = {"pv_power": build_panel_unique_id(serial, PV_POWER_SENSOR.key)} + ids.update( + { + description.key: build_panel_unique_id(serial, description.key) + for description in PV_METADATA_SENSORS + } + ) + if link: + ids[PV_PANEL_LINK_KEY] = build_binary_sensor_unique_id(serial, PV_PANEL_LINK_KEY) + return ids + + +def solar_entities(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, tuple[str, str | None]]: + """`{unique_id: (entity_id, device_id)}` of the Solar card's PV entities that are registered.""" + registry = er.async_get(hass) + found: dict[str, tuple[str, str | None]] = {} + for unique_id in solar_unique_ids(entry.unique_id or "").values(): + for domain in ("sensor", "binary_sensor"): + entity_id = registry.async_get_entity_id(domain, DOMAIN, unique_id) + entity = registry.async_get(entity_id) if entity_id is not None else None + if entity is not None: + found[unique_id] = (entity.entity_id, entity.device_id) + return found + + +def inverter_cards(hass: HomeAssistant, entry: MockConfigEntry) -> set[str]: + """Return the keys of every inverter card registered for the entry.""" + prefix = f"{entry.unique_id}_{SUB_DEVICE_PV}_" + return { + name.removeprefix(prefix) + for device in dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id) + for _domain, name in device.identifiers + if name.startswith(prefix) + } + + +# --------------------------------------------------------------------------- +# One inverter: nothing moves +# --------------------------------------------------------------------------- + + +async def test_a_single_inverter_keeps_every_id_it_had(hass: HomeAssistant) -> None: + snapshot = schema_one_snapshot() + assert len(snapshot.pv_inverters) == 1 + entry = _entry(hass, "entry-pv-single", snapshot.serial_number) + + await _setup(hass, entry, snapshot) + + assert set(solar_entities(hass, entry)) == set(solar_unique_ids(snapshot.serial_number).values()) + devices = dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id) + assert not [ + name for device in devices for _, name in device.identifiers if f"_{SUB_DEVICE_PV}_" in name + ] + assert len([cap for cap in detect_capabilities(snapshot) if cap.startswith("pv_inverter:")]) == 1 + + +# --------------------------------------------------------------------------- +# Two inverters, each fed by a circuit +# --------------------------------------------------------------------------- + + +async def test_each_inverter_gets_a_card_and_entities_keyed_by_its_circuit( + hass: HomeAssistant, +) -> None: + snapshot = schema_one_snapshot(_tree()) + assert set(snapshot.pv_inverters) == {SOLAR_CIRCUIT, SECOND_SOLAR_CIRCUIT} + entry = _entry(hass, "entry-pv-two", snapshot.serial_number) + + await _setup(hass, entry, snapshot) + + unique_ids = _unique_ids(hass, entry) + serial = snapshot.serial_number + for key in (SOLAR_CIRCUIT, SECOND_SOLAR_CIRCUIT): + expected = { + f"span_{serial}_pv_{key}_{suffix}" for suffix in (*METADATA_KEYS, "pv_panel_link") + } + assert expected <= set(unique_ids) + card = _device(hass, entry, f"{serial}_{SUB_DEVICE_PV}_{key}") + assert _classify_sub_device(card) == SUB_DEVICE_PV + for uid in expected: + assert _device_of(hass, unique_ids, uid).id == card.id + + # Never the serial, which only the second inverter publishes. + assert not [uid for uid in unique_ids if "INVERTER-SERIAL" in uid] + + second = _device(hass, entry, f"{serial}_{SUB_DEVICE_PV}_{SECOND_SOLAR_CIRCUIT}") + assert second.name == f"{PANEL_NAME} Solar Inverter (Garage Solar)" + assert second.manufacturer == "Second Vendor" + panel = _device(hass, entry, serial) + assert second.via_device_id == panel.id + + vendor = er.async_get(hass).async_get( + unique_ids[f"span_{serial}_pv_{SECOND_SOLAR_CIRCUIT}_pv_vendor"] + ) + assert vendor is not None + nameplate = unique_ids[f"span_{serial}_pv_{SECOND_SOLAR_CIRCUIT}_pv_nameplate_capacity"] + assert ( + er.async_get(hass).async_get(nameplate).disabled_by is er.RegistryEntryDisabler.INTEGRATION + ) + + +async def test_two_inverters_at_first_sight_leave_the_solar_card_unbound( + hass: HomeAssistant, +) -> None: + snapshot = schema_one_snapshot(_tree()) + entry = _entry(hass, "entry-pv-two-unbound", snapshot.serial_number) + + await _setup(hass, entry, snapshot) + + assert set(solar_entities(hass, entry)) == set(solar_unique_ids(snapshot.serial_number).values()) + assert inverter_cards(hass, entry) == {SOLAR_CIRCUIT, SECOND_SOLAR_CIRCUIT} + solar = _device(hass, entry, f"{snapshot.serial_number}_{SUB_DEVICE_PV}") + # The two vendors differ, so the card that reads them together claims neither. + assert solar.manufacturer == "Unknown" + assert solar.sw_version is None + + +def test_every_inverters_circuit_reads_as_solar() -> None: + """Both feeding circuits are labeled PV, so both get the solar sign.""" + snapshot = schema_one_snapshot(_tree()) + power = next(d for d in CIRCUIT_SENSORS if d.key == "circuit_power") + + first = snapshot.circuits[SOLAR_CIRCUIT] + second = snapshot.circuits[SECOND_SOLAR_CIRCUIT] + assert first.device_type == second.device_type == "pv" + assert first.instant_power_w == second.instant_power_w + assert power.value_fn(second) == power.value_fn(first) + assert not [d for d in snapshot.adopted_devices if "pv" in d.device_type] + + +def test_every_inverter_is_a_capability_so_a_new_key_reloads() -> None: + single = detect_capabilities(schema_one_snapshot()) + multi = detect_capabilities(schema_one_snapshot(_tree())) + + assert len([cap for cap in single if cap.startswith("pv_inverter:")]) == 1 + assert len([cap for cap in multi - single if cap.startswith("pv_inverter:")]) == 1 + assert not [cap for cap in multi if SOLAR_CIRCUIT in cap or SECOND_SOLAR_CIRCUIT in cap] + + +def test_the_identifier_grammar_reads_both_shapes() -> None: + assert classify_sub_device_identifier(f"serial_{SUB_DEVICE_PV}") == SUB_DEVICE_PV + assert ( + classify_sub_device_identifier(f"serial_{SUB_DEVICE_PV}_{SOLAR_CIRCUIT}") == SUB_DEVICE_PV + ) + assert classify_sub_device_identifier(f"serial_{SUB_DEVICE_PV}_{UNFED_PV}") == SUB_DEVICE_PV + + +# --------------------------------------------------------------------------- +# An inverter no circuit feeds +# --------------------------------------------------------------------------- + + +async def test_an_unfed_inverter_is_keyed_by_its_device_id(hass: HomeAssistant) -> None: + snapshot = schema_one_snapshot(_tree(second=False, unfed=True)) + assert set(snapshot.pv_inverters) == {SOLAR_CIRCUIT, UNFED_PV} + entry = _entry(hass, "entry-pv-unfed", snapshot.serial_number) + + await _setup(hass, entry, snapshot) + + unique_ids = _unique_ids(hass, entry) + serial = snapshot.serial_number + for suffix in METADATA_KEYS: + assert f"span_{serial}_pv_{UNFED_PV}_{suffix}" in unique_ids + # No circuit, so no link record to read and no entity for one. + assert f"span_{serial}_pv_{UNFED_PV}_pv_panel_link" not in unique_ids + assert f"span_{serial}_pv_{SOLAR_CIRCUIT}_pv_panel_link" in unique_ids + + card = _device(hass, entry, f"{serial}_{SUB_DEVICE_PV}_{UNFED_PV}") + # No circuit to name it after, so it is numbered. + assert card.name == f"{PANEL_NAME} Solar Inverter (1)" + + +def _names(hass: HomeAssistant, entry: MockConfigEntry, serial: str, keys: list[str]) -> list[str]: + return [str(_device(hass, entry, f"{serial}_{SUB_DEVICE_PV}_{key}").name) for key in keys] + + +async def test_two_unfed_inverters_get_distinct_names(hass: HomeAssistant) -> None: + tree = _tree(second=False, unfed=True) + tree["pv-4"] = dict(tree[UNFED_PV]) + snapshot = schema_one_snapshot(tree) + assert set(snapshot.pv_inverters) == {SOLAR_CIRCUIT, UNFED_PV, "pv-4"} + entry = _entry(hass, "entry-pv-two-unfed", snapshot.serial_number) + + await _setup(hass, entry, snapshot) + + # The captured circuit is itself named "Solar Inverter", which the device + # name already says, so it is not repeated as the suffix. + assert _names(hass, entry, snapshot.serial_number, [SOLAR_CIRCUIT, UNFED_PV, "pv-4"]) == [ + f"{PANEL_NAME} Solar Inverter", + f"{PANEL_NAME} Solar Inverter (1)", + f"{PANEL_NAME} Solar Inverter (2)", + ] + + +@pytest.mark.parametrize( + ("display_suffix", "expected"), + [ + ("Solar Inverter", f"{PANEL_NAME} Solar Inverter"), + ("solar inverter", f"{PANEL_NAME} Solar Inverter (solar inverter)"), + ("Garage Solar", f"{PANEL_NAME} Solar Inverter (Garage Solar)"), + (None, f"{PANEL_NAME} Solar Inverter"), + ], +) +def test_only_a_suffix_equal_to_the_label_is_dropped( + display_suffix: str | None, expected: str +) -> None: + pv = schema_one_snapshot(_tree()).pv_inverters[SOLAR_CIRCUIT] + + info = pv_inverter_device_info( + "serial", SOLAR_CIRCUIT, pv, PANEL_NAME, display_suffix, panel_device_id="panel-device" + ) + + assert info.get("name") == expected + + +async def test_circuits_sharing_a_name_fall_back_to_their_breaker_positions( + hass: HomeAssistant, +) -> None: + tree = _tree() + tree[SECOND_SOLAR_CIRCUIT]["info/name"] = tree[SOLAR_CIRCUIT]["info/name"] + snapshot = schema_one_snapshot(tree) + entry = _entry(hass, "entry-pv-same-name", snapshot.serial_number) + + await _setup(hass, entry, snapshot) + + assert _names(hass, entry, snapshot.serial_number, [SOLAR_CIRCUIT, SECOND_SOLAR_CIRCUIT]) == [ + f"{PANEL_NAME} Solar Inverter (Circuit 36 38)", + f"{PANEL_NAME} Solar Inverter (Circuit 5 7)", + ] + + +# --------------------------------------------------------------------------- +# Inverters leaving the panel +# --------------------------------------------------------------------------- + + +def _inverter_devices(hass: HomeAssistant, entry: MockConfigEntry) -> set[str]: + return { + name + for device in dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id) + for _, name in device.identifiers + if f"_{SUB_DEVICE_PV}_" in name + } + + +async def test_a_departed_inverter_keeps_its_device_until_its_owner_removes_it( + hass: HomeAssistant, +) -> None: + before = schema_one_snapshot(_tree(unfed=True)) + entry = _entry(hass, "entry-pv-departed", before.serial_number) + serial = before.serial_number + await _unload(await _setup(hass, entry, before)) + + await _setup(hass, entry, schema_one_snapshot(_tree())) + + assert f"{serial}_{SUB_DEVICE_PV}_{UNFED_PV}" in _inverter_devices(hass, entry) + assert [uid for uid in _unique_ids(hass, entry) if UNFED_PV in uid] + device = _device(hass, entry, f"{serial}_{SUB_DEVICE_PV}_{UNFED_PV}") + assert await async_remove_config_entry_device(hass, entry, device) diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py new file mode 100644 index 00000000..93eed91f --- /dev/null +++ b/tests/test_recreate_entity_ids.py @@ -0,0 +1,1433 @@ +"""Recreate entity IDs proposes the ID the current panel data would produce. + +Issue #252: renaming a circuit in the SPAN app left "Recreate entity IDs" (the +HA registry's `async_regenerate_entity_id`) proposing the entity's own ID, so +the button appeared to do nothing. + +The registry generates an ID from three fields in priority order: the user's +`name` override, then `suggested_object_id`, then `object_id_base`. In +friendly-names mode this integration never writes a registry `name` -- the +panel name reaches the UI through `original_name` -- so `name` is None, the +generator short-circuits to `suggested_object_id`, and that field is whatever +was suggested when the entity was first added. It was frozen because the +integration preset the entity's *stored* ID on every reload, suggesting the +value already in place. + +Every case here reloads before asserting. Asserting straight after creation +tests the first-add path, where the suggestion is trivially current and the bug +cannot appear. +""" + +from __future__ import annotations + +from typing import ClassVar +from unittest.mock import MagicMock + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_registry import EntityNamePart +import pytest +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + MockEntityPlatform, +) +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.const import ( + DOMAIN, + USE_CIRCUIT_NUMBERS, + USE_DEVICE_PREFIX, +) +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.entity import SpanPanelEntity +from custom_components.span_panel.id_builder import ( + build_circuit_unique_id, + build_select_unique_id, + build_switch_unique_id, +) +from custom_components.span_panel.select import ( + CIRCUIT_PRIORITY_DESCRIPTION, + SpanPanelCircuitsSelect, +) +from custom_components.span_panel.sensor_circuit import ( + SpanCircuitEnergySensor, + SpanCircuitPowerSensor, + SpanUnmappedCircuitSensor, +) +from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS, UNMAPPED_SENSORS +from custom_components.span_panel.switch import SpanPanelCircuitsSwitch + +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory, pv_binding_for + +CIRCUIT_ID = "15" +SERIAL = "sp3-recreate-001" + +ORIGINAL_NAME = "Refrigerator" +RENAMED = "Beer Fridge" + +ORIGINAL_ENTITY_ID = "sensor.span_panel_refrigerator_power" +RENAMED_ENTITY_ID = "sensor.span_panel_beer_fridge_power" +CIRCUIT_NUMBERS_ENTITY_ID = "sensor.span_panel_circuit_15_power" + +FRIENDLY_NAMES = {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: False} +CIRCUIT_NUMBERS = {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: True} + +POWER_DESCRIPTION = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_power") + + +def _snapshot(circuit_name: str) -> SpanPanelSnapshot: + """Build a one-circuit panel snapshot with the circuit named as given.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id=CIRCUIT_ID, name=circuit_name, tabs=[15] + ) + return SpanPanelSnapshotFactory.create(serial_number=SERIAL, circuits={CIRCUIT_ID: circuit}) + + +def _coordinator( + hass: HomeAssistant, snapshot: SpanPanelSnapshot, entry: MockConfigEntry +) -> MagicMock: + """Build a coordinator standing in for a live one, bound to the real hass.""" + coordinator = MagicMock() + coordinator.hass = hass + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.config_entry = entry + coordinator.request_reload = MagicMock() + coordinator.register_energy_sensor = MagicMock() + coordinator.dip_offset = MagicMock(return_value=0.0) + return coordinator + + +class _Install[E: SpanPanelEntity]: + """One install of one platform's circuit entity, reloadable. + + `load` a second time is what a reload is: the entry's entities are torn down + and rebuilt from the current snapshot, against the entity registry that + survived. Nothing here is faked -- `async_add_entities` is the real + `EntityPlatform` path, so a preset entity_id travels the same route into + `async_get_or_create` that it does in a running install, and the teardown is + the same `async_reset` an entry unload performs. + + Four platforms take that route and it is the same route for all four, so + there is one `load` and each subclass says only what differs: the domain it + registers under and the entity it constructs. A subclass that owned a copy of + `load` instead would be free to drift away from the path under test, which is + the one thing these cases exist to exercise. + """ + + _domain: ClassVar[str] + """The entity platform domain this install registers under.""" + + def __init__(self, hass: HomeAssistant, entry: MockConfigEntry) -> None: + self._hass = hass + self._entry = entry + self._platform: MockEntityPlatform | None = None + self._coordinator: MagicMock | None = None + self._entity: E | None = None + + @property + def coordinator(self) -> MagicMock: + """The coordinator behind the entity this install last loaded.""" + assert self._coordinator is not None, "nothing has been loaded yet" + return self._coordinator + + def _build(self, coordinator: MagicMock, snapshot: SpanPanelSnapshot) -> E: + """Construct the one entity this install adds. Subclasses answer.""" + raise NotImplementedError + + async def rename_on_the_panel(self, new_name: str) -> None: + """Deliver a snapshot in which the circuit was renamed, as a push does. + + The real phase 2 path and not a stand-in for it: a poll finds a changed + `circuit.name` and the coordinator notifies its listeners, which is + `_handle_coordinator_update` -- the method each of the three platforms + hangs its name sync off. Nothing else about the entity is touched, so + what happens next is the platform's own decision. + + The install pushes to the entity it loaded rather than being handed one, + because those two must be the same object for the push to reach the + platform whose coordinator this is. + """ + assert self._entity is not None, "nothing has been loaded yet" + self.coordinator.data = _snapshot(new_name) + self._entity._handle_coordinator_update() + await self._hass.async_block_till_done() + + async def load(self, circuit_name: str) -> E: + """Tear down any previous platform, then set one up from fresh panel data.""" + if self._platform is not None: + await self._platform.async_reset() + + snapshot = _snapshot(circuit_name) + coordinator = _coordinator(self._hass, snapshot, self._entry) + self._coordinator = coordinator + self._entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + + self._platform = MockEntityPlatform( + self._hass, domain=self._domain, platform_name=DOMAIN + ) + self._platform.config_entry = self._entry + + entity = self._build(coordinator, snapshot) + await self._platform.async_add_entities([entity]) + await self._hass.async_block_till_done() + + assert entity.hass is not None, "entity was rejected before it reached the registry" + self._entity = entity + return entity + + +class _SensorInstall(_Install[SpanCircuitPowerSensor]): + """One install of the circuit power sensor. + + The only one of the four that can be shown on a sub-device's card, so it is + the only one that takes a `device_info_override`. + """ + + _domain: ClassVar[str] = "sensor" + + def __init__( + self, + hass: HomeAssistant, + entry: MockConfigEntry, + device_info_override: DeviceInfo | None = None, + ) -> None: + super().__init__(hass, entry) + self._device_info_override = device_info_override + + def _build( + self, coordinator: MagicMock, snapshot: SpanPanelSnapshot + ) -> SpanCircuitPowerSensor: + """Build the power sensor, on the panel's card or a sub-device's.""" + return SpanCircuitPowerSensor( + coordinator, + POWER_DESCRIPTION, + snapshot, + CIRCUIT_ID, + device_info_override=self._device_info_override, + ) + + +class _SwitchInstall(_Install[SpanPanelCircuitsSwitch]): + """One install of the breaker switch.""" + + _domain: ClassVar[str] = "switch" + + def _build( + self, coordinator: MagicMock, snapshot: SpanPanelSnapshot + ) -> SpanPanelCircuitsSwitch: + """Build the breaker switch.""" + return SpanPanelCircuitsSwitch(coordinator, CIRCUIT_ID, "SPAN Panel") + + +class _SelectInstall(_Install[SpanPanelCircuitsSelect]): + """One install of the circuit-priority select.""" + + _domain: ClassVar[str] = "select" + + def _build( + self, coordinator: MagicMock, snapshot: SpanPanelSnapshot + ) -> SpanPanelCircuitsSelect: + """Build the circuit-priority select.""" + return SpanPanelCircuitsSelect( + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, CIRCUIT_ID, "SPAN Panel" + ) + + +@pytest.fixture +def entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a config entry in friendly-names mode.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.50", "device_name": "SPAN Panel"}, + options=dict(FRIENDLY_NAMES), + title="SPAN Panel", + unique_id=SERIAL, + entry_id="entry-recreate", + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def device_and_entity_parts(hass: HomeAssistant) -> None: + """Compose ids from the device and the entity, as a user may globally choose to. + + `entity_id_parts` is a registry-wide setting whose default also includes the + area. Cases about the suffix half of an id pin it here so the assertion is + about the suffix and not about whether an area happens to exist. + """ + er.async_get(hass).async_update_settings( + entity_id_parts=[EntityNamePart.DEVICE, EntityNamePart.ENTITY] + ) + + +async def test_the_first_install_takes_its_entity_id_from_the_circuit_name( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Baseline for every case below: the ID before any rename.""" + sensor = await _SensorInstall(hass, entry).load(ORIGINAL_NAME) + + assert sensor.entity_id == ORIGINAL_ENTITY_ID + + +@pytest.mark.parametrize( + ("circuit_name", "expected"), + [ + ("Kitchen Outlets", "sensor.span_panel_kitchen_outlets_power"), + ("Fridge/Freezer", "sensor.span_panel_fridge_freezer_power"), + ("A/C - Upstairs", "sensor.span_panel_a_c_upstairs_power"), + ("Garage Door", "sensor.span_panel_garage_door_power"), + ("Café Lights", "sensor.span_panel_cafe_lights_power"), + ("Solar Power", "sensor.span_panel_solar_power"), + ("Power", "sensor.span_panel_power_power"), + ("SPAN Panel", "sensor.span_panel_span_panel_power"), + ], +) +async def test_composed_ids_match_the_preset_shape_for_odd_names( + hass: HomeAssistant, + entry: MockConfigEntry, + device_and_entity_parts: None, + circuit_name: str, + expected: str, +) -> None: + """A new install must land on the id the deleted preset builder would have spelled. + + The builder slugified the circuit name on its own and joined the parts with + `_`; Home Assistant slugifies `"{device} {base}"` as one string. The two + agree for punctuation, runs of whitespace and accents, and these are the + names a panel can actually carry -- so no new install diverges from today's + shape under `(DEVICE, ENTITY)`. + + The last three are the cases where the wording, not the slug, decides: + + - "Solar Power" already ends with the suffix word, so the builder omitted it + (`circuit_part.endswith(f"_{suffix}")`) and the id reads `..._solar_power`; + - "Power" does *not* -- the builder's test carried a leading underscore, so a + circuit named exactly "Power" got `..._power_power`; + - a circuit named after the panel gets both, `..._span_panel_span_panel_power`, + because the device half and the name half are composed independently. + """ + sensor = await _SensorInstall(hass, entry).load(circuit_name) + + assert sensor.entity_id == expected + + +async def test_renaming_a_circuit_does_not_move_an_existing_entity_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The non-negotiable one: a rename must never move a live entity_id. + + Dashboards, automations, and recorder history all key off the entity_id. + Recreate is an offer the user accepts; a rename is not. + """ + install = _SensorInstall(hass, entry) + await install.load(ORIGINAL_NAME) + sensor = await install.load(RENAMED) + + assert sensor.entity_id == ORIGINAL_ENTITY_ID + + registry = er.async_get(hass) + assert registry.async_get(ORIGINAL_ENTITY_ID) is not None + assert registry.async_get(RENAMED_ENTITY_ID) is None + + +async def test_renaming_a_circuit_does_not_move_the_unique_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Unique IDs are derived from the description key and never from a name. + + A moved unique_id orphans the entity and drops its long-term statistics, and + it would break any future migration that has to predict what a unique_id + looks like. + """ + install = _SensorInstall(hass, entry) + before = await install.load(ORIGINAL_NAME) + unique_id_before = before.unique_id + + after = await install.load(RENAMED) + + assert after.unique_id == unique_id_before + + registry = er.async_get(hass) + entry_after = registry.async_get(ORIGINAL_ENTITY_ID) + assert entry_after is not None + assert entry_after.unique_id == unique_id_before + + +async def test_renaming_a_circuit_refreshes_the_registrys_entity_id_suggestion( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The stored base has to track the panel, not the install date. + + This is what `async_regenerate_entity_id` composes from when there is no + user `name` override, which in friendly-names mode is always. It is the + *base*, not a whole object id: Core prefixes the device and, where the user + has assigned one, the area. + """ + install = _SensorInstall(hass, entry) + await install.load(ORIGINAL_NAME) + await install.load(RENAMED) + + registry = er.async_get(hass) + registry_entry = registry.async_get(ORIGINAL_ENTITY_ID) + assert registry_entry is not None + assert registry_entry.object_id_base == "Beer Fridge power" + assert registry_entry.suggested_object_id is None + + +async def test_recreate_entity_ids_proposes_the_renamed_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Issue #252 itself, at the API the button calls.""" + install = _SensorInstall(hass, entry) + await install.load(ORIGINAL_NAME) + await install.load(RENAMED) + + registry = er.async_get(hass) + registry_entry = registry.async_get(ORIGINAL_ENTITY_ID) + assert registry_entry is not None + + proposed = registry.async_regenerate_entity_id(registry_entry) + + assert proposed == RENAMED_ENTITY_ID + assert proposed != registry_entry.entity_id + + +async def test_an_unrenamed_circuit_is_offered_its_own_entity_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Recreate must be a no-op when nothing changed. + + Without this the previous test passes for the wrong reason -- a suggestion + that moves on every reload would satisfy it while offering every user a + pointless rename. + """ + install = _SensorInstall(hass, entry) + await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + registry = er.async_get(hass) + registry_entry = registry.async_get(ORIGINAL_ENTITY_ID) + assert registry_entry is not None + + assert registry.async_regenerate_entity_id(registry_entry) == ORIGINAL_ENTITY_ID + + +async def test_circuit_numbers_mode_is_offered_its_own_tab_based_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Recreate must not offer to convert a circuit-numbered panel to friendly names. + + The mode exists so an id follows the breaker position rather than the name. + Accepting a friendly-name proposal would undo that for every circuit at once. + + It used to be offered because phase 2 sync wrote the panel's name into the + registry's `name`, which Home Assistant reads ahead of `suggested_object_id`. + The name now travels as `original_name`, which ranks below it. + """ + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + + install = _SensorInstall(hass, entry) + await install.load(ORIGINAL_NAME) + sensor = await install.load(RENAMED) + + assert sensor.entity_id == CIRCUIT_NUMBERS_ENTITY_ID + + registry = er.async_get(hass) + registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) + assert registry_entry is not None + + assert registry.async_regenerate_entity_id(registry_entry) == CIRCUIT_NUMBERS_ENTITY_ID + + +async def test_circuit_numbers_mode_still_shows_the_panels_name( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Phase 2 sync still follows the panel -- through a field that cannot move an id.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + + install = _SensorInstall(hass, entry) + await install.load(ORIGINAL_NAME) + sensor = await install.load(RENAMED) + + registry = er.async_get(hass) + registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) + assert registry_entry is not None + + assert registry_entry.original_name == f"{RENAMED} Power" + assert sensor.name == f"{RENAMED} Power" + assert registry_entry.name is None + + +async def test_circuit_numbers_mode_releases_a_name_an_older_release_wrote( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """An install upgrading has the old scheme's name handed back to it.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + + install = _SensorInstall(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + + registry = er.async_get(hass) + # Exactly what the previous release's phase 2 sync would have written. + registry.async_update_entity(sensor.entity_id, name=f"{ORIGINAL_NAME} Power") + assert registry.async_get(sensor.entity_id).name == f"{ORIGINAL_NAME} Power" + + await install.load(ORIGINAL_NAME) + + registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) + assert registry_entry is not None + assert registry_entry.name is None + assert registry.async_regenerate_entity_id(registry_entry) == CIRCUIT_NUMBERS_ENTITY_ID + + +async def test_releasing_the_name_is_idempotent( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Two reloads in a row are the same as one. There is no migration to run twice.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + + install = _SensorInstall(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + registry = er.async_get(hass) + registry.async_update_entity(sensor.entity_id, name=f"{ORIGINAL_NAME} Power") + + await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) + assert registry_entry is not None + assert registry_entry.name is None + assert registry_entry.entity_id == CIRCUIT_NUMBERS_ENTITY_ID + + +async def test_a_name_the_user_set_is_never_released( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The registry's `name` is the user's field, and only their own writes are theirs. + + A name we did not write is left exactly where it is -- which also means it + keeps outranking the suggestion, so Recreate composes from it. That is what + Home Assistant does for any integration once a user names an entity. + """ + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + + install = _SensorInstall(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + + registry = er.async_get(hass) + registry.async_update_entity(sensor.entity_id, name="Beverage Cooling") + + await install.load(ORIGINAL_NAME) + + registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) + assert registry_entry is not None + assert registry_entry.name == "Beverage Cooling" + assert registry_entry.entity_id == CIRCUIT_NUMBERS_ENTITY_ID + + +async def test_circuit_numbers_mode_does_not_move_ids_across_the_change( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Neither id moves, whatever happens to the name.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + + install = _SensorInstall(hass, entry) + before = await install.load(ORIGINAL_NAME) + unique_id_before = before.unique_id + + registry = er.async_get(hass) + registry.async_update_entity(before.entity_id, name=f"{ORIGINAL_NAME} Power") + + after = await install.load(RENAMED) + + assert after.entity_id == CIRCUIT_NUMBERS_ENTITY_ID + assert after.unique_id == unique_id_before + assert registry.async_get(RENAMED_ENTITY_ID) is None + + +# --- The two spellings a circuit energy id has shipped with ------------------- +# +# Every case above builds its entities with the current code, so the base and +# the live id agree by construction and a suffix disagreement cannot appear. A +# real install upgrading is the case that matters, and there are two eras of +# them. Before the integration preset an entity_id, Home Assistant composed one +# from the descriptor name ("Consumed Energy" -> `..._consumed_energy`); the +# preset era spelled the same sensor `..._energy_consumed`, after the unique-id +# suffix mapping. Both are live on real panels, so the base an entity is given +# has to read its own spelling back out of the id it already carries -- +# otherwise Recreate offers a rename for every circuit at once, which on a +# measured panel was 74 of them. + +LEGACY_ENTITY_ID = "sensor.span_panel_refrigerator_consumed_energy" +PRESET_ERA_ENTITY_ID = "sensor.span_panel_refrigerator_energy_consumed" +RENAMED_LEGACY_ENTITY_ID = "sensor.span_panel_beer_fridge_consumed_energy" + +ENERGY_DESCRIPTION = next( + desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_energy_consumed" +) + + +class _LegacyInstall(_Install[SpanCircuitEnergySensor]): + """An install whose energy sensor id was composed from the descriptor name.""" + + _domain: ClassVar[str] = "sensor" + + def _seed(self) -> str: + """Register the entity the way a pre-preset install left it. + + Called by the cases that need one, not by `load`: the point of several of + them is what happens when there is nothing seeded at all. + """ + registry = er.async_get(self._hass) + unique_id = build_circuit_unique_id(SERIAL, CIRCUIT_ID, "consumedEnergyWh") + entry = registry.async_get_or_create( + "sensor", + DOMAIN, + unique_id, + suggested_object_id="span_panel_refrigerator_consumed_energy", + original_name=f"{ORIGINAL_NAME} Consumed Energy", + config_entry=self._entry, + ) + return entry.entity_id + + def _build( + self, coordinator: MagicMock, snapshot: SpanPanelSnapshot + ) -> SpanCircuitEnergySensor: + """Build the energy sensor, whose id has shipped with two suffix spellings.""" + return SpanCircuitEnergySensor(coordinator, ENERGY_DESCRIPTION, snapshot, CIRCUIT_ID) + + +async def test_upgrading_does_not_offer_to_renormalise_a_legacy_suffix( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The 74-rename case. Nothing was renamed on the panel, so nothing is offered.""" + install = _LegacyInstall(hass, entry) + seeded = install._seed() + assert seeded == LEGACY_ENTITY_ID + + sensor = await install.load(ORIGINAL_NAME) + + assert sensor.entity_id == LEGACY_ENTITY_ID + + registry = er.async_get(hass) + registry_entry = registry.async_get(LEGACY_ENTITY_ID) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == LEGACY_ENTITY_ID + + +async def test_a_legacy_entity_still_follows_a_circuit_rename( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Preserving the suffix must not cost the fix. + + Only the half the user changed moves. The circuit's new name reaches the + proposal; the suffix keeps the spelling this entity has always carried, + because renormalising it is a rename the user did not ask for and would ride + along with the one they did. + """ + install = _LegacyInstall(hass, entry) + install._seed() + + await install.load(ORIGINAL_NAME) + sensor = await install.load(RENAMED) + + assert sensor.entity_id == LEGACY_ENTITY_ID + + registry = er.async_get(hass) + registry_entry = registry.async_get(LEGACY_ENTITY_ID) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == RENAMED_LEGACY_ENTITY_ID + + +async def test_a_new_energy_sensor_gets_the_noun_last_suffix( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """A new id uses the wording the panel level and the labels use. + + Nothing is seeded, so there is no spelling to read back and the default + applies. It is the pre-preset wording, which is also what + `main_meter_consumed_energy` has always said. + """ + sensor = await _LegacyInstall(hass, entry).load(ORIGINAL_NAME) + + assert sensor.entity_id == "sensor.span_panel_refrigerator_consumed_energy" + + +async def test_a_preset_era_energy_sensor_is_offered_its_own_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """An id spelled `..._energy_consumed` keeps that spelling in the proposal.""" + registry = er.async_get(hass) + unique_id = build_circuit_unique_id(SERIAL, CIRCUIT_ID, "consumedEnergyWh") + seeded = registry.async_get_or_create( + "sensor", + DOMAIN, + unique_id, + suggested_object_id="span_panel_refrigerator_energy_consumed", + config_entry=entry, + ) + assert seeded.entity_id == PRESET_ERA_ENTITY_ID + + install = _LegacyInstall(hass, entry) + await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + registry_entry = registry.async_get(seeded.entity_id) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == seeded.entity_id + + +async def test_a_pre_preset_energy_sensor_is_offered_its_own_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The other era: `..._consumed_energy` keeps its spelling too.""" + install = _LegacyInstall(hass, entry) + install._seed() + + sensor = await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + registry = er.async_get(hass) + registry_entry = registry.async_get(sensor.entity_id) + assert registry_entry is not None + assert sensor.entity_id == LEGACY_ENTITY_ID + assert registry.async_regenerate_entity_id(registry_entry) == sensor.entity_id + + +async def test_circuit_numbers_mode_releases_the_energy_name_an_older_release_wrote( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """2.0.8 wrote " Consumed Energy" into the registry, and it must be released.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + install = _LegacyInstall(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + registry = er.async_get(hass) + registry.async_update_entity(sensor.entity_id, name=f"{ORIGINAL_NAME} Consumed Energy") + + await install.load(ORIGINAL_NAME) + + registry_entry = registry.async_get(sensor.entity_id) + assert registry_entry is not None + assert registry_entry.name is None + assert registry.async_regenerate_entity_id(registry_entry) == sensor.entity_id + + +async def test_a_name_written_under_the_beta_label_is_released_as_well( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The label read "Energy Consumed" for a few betas; that write is ours too.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + install = _LegacyInstall(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + registry = er.async_get(hass) + registry.async_update_entity(sensor.entity_id, name=f"{ORIGINAL_NAME} Energy Consumed") + + await install.load(ORIGINAL_NAME) + + registry_entry = registry.async_get(sensor.entity_id) + assert registry_entry is not None + assert registry_entry.name is None + + +async def test_an_area_the_user_assigned_reaches_the_proposal_under_default_parts( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """R2: the area is the user's global choice, and the integration does not exempt itself. + + A preset id could not carry one. Handing Core a base instead means the area + part of `entity_id_parts` applies here exactly as it does to every other + integration -- and only where the user has actually assigned an area. + """ + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + install = _SensorInstall(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + assert sensor.entity_id == CIRCUIT_NUMBERS_ENTITY_ID # no area yet: identical to today + + area = ar.async_get(hass).async_get_or_create("Basement") + device_registry = dr.async_get(hass) + device = device_registry.async_get_device_by_identifier((DOMAIN, SERIAL), entry.entry_id) + assert device is not None + device_registry.async_update_device(device.id, area_id=area.id) + + registry = er.async_get(hass) + registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID) + assert registry_entry is not None + assert registry_entry.entity_id == CIRCUIT_NUMBERS_ENTITY_ID # R5: nothing moved + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "sensor.basement_span_panel_circuit_15_power" + ) + + +async def test_an_unnamed_circuit_in_friendly_mode_still_gets_a_distinct_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """An unnamed circuit has no name half to compose from, so it falls back to its tabs. + + Letting Core compose from the display name alone would give every unnamed + circuit on the panel `sensor.span_panel_power`, and the registry would + disambiguate them with `_2`, `_3`, ... in whatever order they were added. + """ + sensor = await _SensorInstall(hass, entry).load("") + + assert sensor.entity_id == "sensor.span_panel_circuit_15_power" + + +# --- Entities composition spells differently than the deleted preset did ------ +# +# Two shapes disagree with what the old preset builder wrote: a circuit sensor +# shown on a sub-device card, where the DEVICE part is the charger rather than +# the panel, and any entity on an install that turned the device prefix off, +# which `has_entity_name` prefixes anyway. Neither is bypassed. R1 (clarified) +# asks the base to reproduce an existing id *where Home Assistant composes it +# under the options the install was built with*; where composition yields a +# different device part, that is the user's `entity_id_parts` at work and the +# offer is legitimate. R5 still holds throughout: nothing moves until Recreate +# is pressed. + +EVSE_DEVICE_INFO = DeviceInfo( + identifiers={(DOMAIN, f"{SERIAL}_evse_node-1")}, + name="SPAN Panel EV Charger", +) + +LEGACY_NAMES = {USE_DEVICE_PREFIX: False, USE_CIRCUIT_NUMBERS: False} + + +def _seed_power_entity(hass: HomeAssistant, entry: MockConfigEntry, object_id: str) -> str: + """Register the circuit power entity the way an install already has it.""" + registry = er.async_get(hass) + seeded = registry.async_get_or_create( + "sensor", + DOMAIN, + build_circuit_unique_id(SERIAL, CIRCUIT_ID, "instantPowerW"), + suggested_object_id=object_id, + config_entry=entry, + ) + assert seeded.entity_id == f"sensor.{object_id}" + return seeded.entity_id + + +async def test_an_existing_sub_device_sensor_is_offered_the_composed_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """An EVSE feed circuit's sensor lives on the charger, and composes from it. + + Its id says the panel because the deleted preset builder always did. Under + the user's parts the DEVICE half is the entity's own device -- the charger -- + and the ENTITY half is the label it carries there, which on a sub-device card + is the bare description name. So Recreate offers `sensor._power`, + the shape the charger's own sensors already have. Nothing moves until the + user accepts it (R5). + """ + seeded = _seed_power_entity(hass, entry, "span_panel_refrigerator_power") + + install = _SensorInstall(hass, entry, device_info_override=EVSE_DEVICE_INFO) + sensor = await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + assert sensor.entity_id == seeded # R5: two reloads, nothing moved + + registry = er.async_get(hass) + registry_entry = registry.async_get(seeded) + assert registry_entry is not None + assert registry_entry.entity_id == seeded + assert ( + registry.async_regenerate_entity_id(registry_entry) == "sensor.span_panel_ev_charger_power" + ) + + +async def test_a_rename_does_not_change_what_a_sub_device_sensor_composes( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The circuit's name is not in this sensor's label, so #252 cannot reach its id. + + A feed sensor on the charger's card is labelled "Power", not " + Power" -- the card already says which device it belongs to. Renaming the + circuit on the panel therefore leaves both the live id and the offer alone. + """ + seeded = _seed_power_entity(hass, entry, "span_panel_refrigerator_power") + + install = _SensorInstall(hass, entry, device_info_override=EVSE_DEVICE_INFO) + await install.load(ORIGINAL_NAME) + await install.load(RENAMED) + + registry = er.async_get(hass) + registry_entry = registry.async_get(seeded) + assert registry_entry is not None + assert registry_entry.entity_id == seeded # R5: nothing moved + assert ( + registry.async_regenerate_entity_id(registry_entry) == "sensor.span_panel_ev_charger_power" + ) + + +async def test_an_existing_sensor_on_a_no_prefix_install_is_offered_the_composed_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The pre-1.0.4 shape: no device prefix at all, which `has_entity_name` re-adds. + + The base still reads the suffix back off the bare id, so the ENTITY half is + the entity's own `refrigerator_power`; the DEVICE half is the user's + `entity_id_parts` asking for one. The offer is theirs to accept, and the live + id does not move on its own (R5). + """ + hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) + seeded = _seed_power_entity(hass, entry, "refrigerator_power") + + install = _SensorInstall(hass, entry) + sensor = await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + assert sensor.entity_id == seeded # R5: two reloads, nothing moved + + registry = er.async_get(hass) + registry_entry = registry.async_get(seeded) + assert registry_entry is not None + assert registry_entry.entity_id == seeded + assert registry.async_regenerate_entity_id(registry_entry) == ORIGINAL_ENTITY_ID + + +async def test_a_new_sensor_on_a_no_prefix_install_composes_like_every_other_entity( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """`USE_DEVICE_PREFIX` off cannot take the device out of a composed id.""" + hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) + + sensor = await _SensorInstall(hass, entry).load(ORIGINAL_NAME) + + assert sensor.entity_id == ORIGINAL_ENTITY_ID + + +async def test_an_unmapped_tab_sensor_keeps_its_prefix_on_a_no_prefix_install( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """The hidden unmapped-tab sensors were always composed, and are unaffected. + + Home Assistant has always built their ids from the display name, so they + carry a device prefix that the naming flag never reached. Turning the flag + off does not take it away. + """ + hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) + + registry = er.async_get(hass) + seeded = registry.async_get_or_create( + "sensor", + DOMAIN, + build_circuit_unique_id(SERIAL, "unmapped_tab_32", "instantPowerW"), + suggested_object_id="span_panel_unmapped_tab_32_power", + config_entry=entry, + ) + assert seeded.entity_id == "sensor.span_panel_unmapped_tab_32_power" + + circuit = SpanCircuitSnapshotFactory.create(circuit_id="unmapped_tab_32", name="", tabs=[32]) + snapshot = SpanPanelSnapshotFactory.create( + serial_number=SERIAL, circuits={"unmapped_tab_32": circuit} + ) + coordinator = _coordinator(hass, snapshot, entry) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + + platform = MockEntityPlatform(hass, domain="sensor", platform_name=DOMAIN) + platform.config_entry = entry + sensor = SpanUnmappedCircuitSensor(coordinator, UNMAPPED_SENSORS[0], snapshot, "unmapped_tab_32") + await platform.async_add_entities([sensor]) + await hass.async_block_till_done() + + assert sensor.entity_id == seeded.entity_id + + registry_entry = registry.async_get(seeded.entity_id) + assert registry_entry is not None + assert registry.async_regenerate_entity_id(registry_entry) == seeded.entity_id + + +# --- A panel device name this integration generated --------------------------- +# +# The config flow names the device: the second panel on a system becomes "Span +# Panel 2" without anyone typing it (`get_unique_device_name`). Composition +# spells the DEVICE part from that name, while every circuit id already on that +# panel says `span_panel_` -- the literal the deleted preset builder fell back to +# on every install. Recreate therefore offers `sensor.span_panel_2_...`: that is +# the device the entity is on and the parts the user chose, and the offer stands +# until they accept it. The same holds for a name the user gave the device +# themselves, which Home Assistant composes from ahead of the generated one. + +SECOND_PANEL_NAME = "Span Panel 2" + + +@pytest.fixture +def second_panel_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the config entry of a second panel, whose device name was generated.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.51", "device_name": SECOND_PANEL_NAME}, + options=dict(FRIENDLY_NAMES), + title=SECOND_PANEL_NAME, + unique_id=SERIAL, + entry_id="entry-recreate-second", + ) + config_entry.add_to_hass(hass) + return config_entry + + +async def test_an_existing_sensor_on_a_generated_second_panel_is_offered_the_composed_id( + hass: HomeAssistant, second_panel_entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The DEVICE half is the device's name, whoever wrote it.""" + seeded = _seed_power_entity(hass, second_panel_entry, "span_panel_refrigerator_power") + + install = _SensorInstall(hass, second_panel_entry) + await install.load(ORIGINAL_NAME) + sensor = await install.load(ORIGINAL_NAME) + + assert sensor.entity_id == seeded # R5: two reloads, nothing moved + + registry = er.async_get(hass) + registry_entry = registry.async_get(seeded) + assert registry_entry is not None + assert registry_entry.entity_id == seeded + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "sensor.span_panel_2_refrigerator_power" + ) + + +async def test_the_controls_on_a_generated_second_panel_are_offered_the_composed_ids( + hass: HomeAssistant, second_panel_entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The breaker switch and the priority select carry the same ids and the same rule.""" + registry = er.async_get(hass) + seeded_switch = registry.async_get_or_create( + "switch", + DOMAIN, + build_switch_unique_id(SERIAL, CIRCUIT_ID), + suggested_object_id="span_panel_refrigerator_breaker", + config_entry=second_panel_entry, + ) + seeded_select = registry.async_get_or_create( + "select", + DOMAIN, + build_select_unique_id(SERIAL, CIRCUIT_ID), + suggested_object_id="span_panel_refrigerator_circuit_priority", + config_entry=second_panel_entry, + ) + + switches = _SwitchInstall(hass, second_panel_entry) + await switches.load(ORIGINAL_NAME) + switch = await switches.load(ORIGINAL_NAME) + selects = _SelectInstall(hass, second_panel_entry) + await selects.load(ORIGINAL_NAME) + select = await selects.load(ORIGINAL_NAME) + + # R5: two reloads each, and neither live id moved. + assert switch.entity_id == seeded_switch.entity_id + assert select.entity_id == seeded_select.entity_id + + switch_entry = registry.async_get(seeded_switch.entity_id) + assert switch_entry is not None + assert ( + registry.async_regenerate_entity_id(switch_entry) + == "switch.span_panel_2_refrigerator_breaker" + ) + + select_entry = registry.async_get(seeded_select.entity_id) + assert select_entry is not None + assert ( + registry.async_regenerate_entity_id(select_entry) + == "select.span_panel_2_refrigerator_circuit_priority" + ) + + +async def test_a_new_sensor_on_a_generated_second_panel_composes_with_that_name( + hass: HomeAssistant, second_panel_entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """A new entity is spelled with its own device, as an existing one is offered.""" + sensor = await _SensorInstall(hass, second_panel_entry).load(ORIGINAL_NAME) + + assert sensor.entity_id == "sensor.span_panel_2_refrigerator_power" + + +async def test_a_panel_device_the_user_renamed_is_offered_the_renamed_id( + hass: HomeAssistant, second_panel_entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """A device the user renamed is composed from the name they gave it. + + The generated name is still "Span Panel 2" underneath. Home Assistant + composes the DEVICE half from `name_by_user` ahead of `name`, and the offer + follows it -- the integration does not read either field itself. + """ + seeded = _seed_power_entity(hass, second_panel_entry, "span_panel_refrigerator_power") + + install = _SensorInstall(hass, second_panel_entry) + await install.load(ORIGINAL_NAME) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device_by_identifier( + (DOMAIN, SERIAL), second_panel_entry.entry_id + ) + assert device is not None + device_registry.async_update_device(device.id, name_by_user="Garage Panel") + + await install.load(ORIGINAL_NAME) + + registry = er.async_get(hass) + registry_entry = registry.async_get(seeded) + assert registry_entry is not None + assert registry_entry.entity_id == seeded # R5: nothing moved + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "sensor.garage_panel_refrigerator_power" + ) + + +# --- The two circuit controls ------------------------------------------------- +# +# The breaker switch and the priority select are the other two circuit entities +# whose ids this integration used to spell out in full. They take the same route +# as the sensors, without exception: one base, and Core composes the rest. + +SWITCH_ENTITY_ID = "switch.span_panel_circuit_15_breaker" +SELECT_ENTITY_ID = "select.span_panel_circuit_15_circuit_priority" + + +async def test_a_new_switch_and_select_compose_todays_ids( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """Composition spells both exactly as the preset builder used to.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + switch = await _SwitchInstall(hass, entry).load(ORIGINAL_NAME) + select = await _SelectInstall(hass, entry).load(ORIGINAL_NAME) + + assert switch.entity_id == SWITCH_ENTITY_ID + assert select.entity_id == SELECT_ENTITY_ID + + +async def test_switch_and_select_are_offered_their_own_ids_after_a_rename_in_circuit_numbers_mode( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """#252's other half: the name follows the panel and the id does not move.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + switches = _SwitchInstall(hass, entry) + await switches.load(ORIGINAL_NAME) + switch = await switches.load(RENAMED) + selects = _SelectInstall(hass, entry) + await selects.load(ORIGINAL_NAME) + select = await selects.load(RENAMED) + + registry = er.async_get(hass) + + assert switch.entity_id == SWITCH_ENTITY_ID + switch_entry = registry.async_get(switch.entity_id) + assert switch_entry is not None + assert switch_entry.original_name == f"{RENAMED} Breaker" + assert registry.async_regenerate_entity_id(switch_entry) == switch.entity_id + + assert select.entity_id == SELECT_ENTITY_ID + select_entry = registry.async_get(select.entity_id) + assert select_entry is not None + assert select_entry.original_name == f"{RENAMED} Circuit Priority" + assert registry.async_regenerate_entity_id(select_entry) == select.entity_id + + +async def test_switch_and_select_release_the_name_an_older_release_wrote( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """2.0.8 wrote the panel's name into the registry's `name` for these two as well.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + registry = er.async_get(hass) + + switches = _SwitchInstall(hass, entry) + switch = await switches.load(ORIGINAL_NAME) + registry.async_update_entity(switch.entity_id, name=f"{ORIGINAL_NAME} Breaker") + await switches.load(ORIGINAL_NAME) + + selects = _SelectInstall(hass, entry) + select = await selects.load(ORIGINAL_NAME) + registry.async_update_entity(select.entity_id, name=f"{ORIGINAL_NAME} Circuit Priority") + await selects.load(ORIGINAL_NAME) + + switch_entry = registry.async_get(switch.entity_id) + assert switch_entry is not None + assert switch_entry.name is None + assert registry.async_regenerate_entity_id(switch_entry) == SWITCH_ENTITY_ID + + select_entry = registry.async_get(select.entity_id) + assert select_entry is not None + assert select_entry.name is None + assert registry.async_regenerate_entity_id(select_entry) == SELECT_ENTITY_ID + + +async def test_a_name_the_user_set_on_a_control_is_never_released( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """Only a name this integration would have written is handed back.""" + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + registry = er.async_get(hass) + + switches = _SwitchInstall(hass, entry) + switch = await switches.load(ORIGINAL_NAME) + registry.async_update_entity(switch.entity_id, name="Beverage Cooling") + + await switches.load(ORIGINAL_NAME) + + switch_entry = registry.async_get(switch.entity_id) + assert switch_entry is not None + assert switch_entry.name == "Beverage Cooling" + + +async def test_an_existing_switch_on_a_no_prefix_install_is_offered_the_composed_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The pre-1.0.4 shape reaches the controls too, and composition re-adds a device.""" + hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) + registry = er.async_get(hass) + seeded = registry.async_get_or_create( + "switch", + DOMAIN, + build_switch_unique_id(SERIAL, CIRCUIT_ID), + suggested_object_id="refrigerator_breaker", + config_entry=entry, + ) + assert seeded.entity_id == "switch.refrigerator_breaker" + + install = _SwitchInstall(hass, entry) + switch = await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + assert switch.entity_id == seeded.entity_id # R5: two reloads, nothing moved + + registry_entry = registry.async_get(seeded.entity_id) + assert registry_entry is not None + assert registry_entry.entity_id == seeded.entity_id + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "switch.span_panel_refrigerator_breaker" + ) + + +async def test_an_existing_select_on_a_no_prefix_install_is_offered_the_composed_id( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """The same shape, composed the same way.""" + hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) + registry = er.async_get(hass) + seeded = registry.async_get_or_create( + "select", + DOMAIN, + build_select_unique_id(SERIAL, CIRCUIT_ID), + suggested_object_id="refrigerator_circuit_priority", + config_entry=entry, + ) + assert seeded.entity_id == "select.refrigerator_circuit_priority" + + install = _SelectInstall(hass, entry) + select = await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + assert select.entity_id == seeded.entity_id # R5: two reloads, nothing moved + + registry_entry = registry.async_get(seeded.entity_id) + assert registry_entry is not None + assert registry_entry.entity_id == seeded.entity_id + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "select.span_panel_refrigerator_circuit_priority" + ) + + +async def test_a_new_control_on_a_no_prefix_install_composes_like_every_other_entity( + hass: HomeAssistant, entry: MockConfigEntry, device_and_entity_parts: None +) -> None: + """`USE_DEVICE_PREFIX` off cannot take the device out of a composed id.""" + hass.config_entries.async_update_entry(entry, options=dict(LEGACY_NAMES)) + + switch = await _SwitchInstall(hass, entry).load(ORIGINAL_NAME) + select = await _SelectInstall(hass, entry).load(ORIGINAL_NAME) + + assert switch.entity_id == "switch.span_panel_refrigerator_breaker" + assert select.entity_id == "select.span_panel_refrigerator_circuit_priority" + + +async def test_an_area_reaches_the_proposal_for_a_control_too( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """R2: the controls stop exempting themselves from the user's `entity_id_parts`. + + A preset id could not carry an area. Handing Core a base means the switch is + composed exactly like every other integration's entity -- and only where the + user has actually assigned one. + """ + hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS)) + switch = await _SwitchInstall(hass, entry).load(ORIGINAL_NAME) + assert switch.entity_id == SWITCH_ENTITY_ID # no area yet: identical to today + + area = ar.async_get(hass).async_get_or_create("Basement") + device_registry = dr.async_get(hass) + device = device_registry.async_get_device_by_identifier((DOMAIN, SERIAL), entry.entry_id) + assert device is not None + device_registry.async_update_device(device.id, area_id=area.id) + + registry = er.async_get(hass) + registry_entry = registry.async_get(SWITCH_ENTITY_ID) + assert registry_entry is not None + assert registry_entry.entity_id == SWITCH_ENTITY_ID # R5: nothing moved + assert ( + registry.async_regenerate_entity_id(registry_entry) + == "switch.basement_span_panel_circuit_15_breaker" + ) + + +# --- Phase 2: a rename in the SPAN app still reaches the display name --------- +# +# The README promises it in both modes (:594, :602): friendly names +# "automatically updates when you rename circuits in the SPAN panel", and +# circuit-numbers mode keeps ids stable while "friendly names still sync from +# SPAN panel for display". One mechanism serves both. A coordinator push +# carrying a changed circuit name asks the entry to reload, and the reload is +# what rebuilds `_attr_name` -- which Home Assistant stores as `original_name`. +# The shortcut the old scheme took, writing the registry's `name` in place, is +# exactly what made Recreate propose a friendly-name id for a circuit-numbered +# entity, so the mechanism has to keep costing a reload. +# +# The per-platform unit tests drive this against a `MagicMock` registry, which +# cannot see where the name landed. These drive it through a real +# `EntityPlatform` and the real registry, for all three circuit platforms in +# both modes, because handing composition the id moved where the name is +# written. + +type _CircuitInstall = type[_SensorInstall] | type[_SwitchInstall] | type[_SelectInstall] + +CIRCUIT_INSTALLS: list[_CircuitInstall] = [_SensorInstall, _SwitchInstall, _SelectInstall] +PLATFORM_IDS = ["sensor", "switch", "select"] + + +@pytest.mark.parametrize( + "mode", [FRIENDLY_NAMES, CIRCUIT_NUMBERS], ids=["friendly", "circuit_numbers"] +) +@pytest.mark.parametrize("make_install", CIRCUIT_INSTALLS, ids=PLATFORM_IDS) +async def test_a_rename_in_the_span_app_reaches_the_display_name_after_the_reload_it_requests( + hass: HomeAssistant, + entry: MockConfigEntry, + device_and_entity_parts: None, + mode: dict[str, bool], + make_install: _CircuitInstall, +) -> None: + """The whole promise in one case: push, reload, and the name has followed. + + Two loads before the push, because that is what a real install has done by + the time a rename arrives: the first add finds no registry entry and asks + for a reload on its first update regardless, and after the reload the entity + is on file and its remembered name is the panel's. Asserting on a + freshly-added entity would let that first-update reload stand in for the one + the rename is supposed to cause. + + So the unchanged push comes first and must ask for nothing. Only then does + the rename, and only it, buy a reload. + """ + hass.config_entries.async_update_entry(entry, options=dict(mode)) + install = make_install(hass, entry) + await install.load(ORIGINAL_NAME) + entity = await install.load(ORIGINAL_NAME) + original_entity_id = entity.entity_id + + registry = er.async_get(hass) + before = registry.async_get(original_entity_id) + assert before is not None + assert before.original_name is not None + assert before.original_name.startswith(ORIGINAL_NAME) + + await install.rename_on_the_panel(ORIGINAL_NAME) + install.coordinator.request_reload.assert_not_called() + + await install.rename_on_the_panel(RENAMED) + install.coordinator.request_reload.assert_called_once() + + reloaded = await install.load(RENAMED) + + after = registry.async_get(original_entity_id) + assert after is not None + assert after.original_name is not None + assert after.original_name.startswith(RENAMED) + assert reloaded.entity_id == original_entity_id # R5: the id did not move + assert after.name is None # R4: the registry's `name` was never written + + +@pytest.mark.parametrize("make_install", CIRCUIT_INSTALLS, ids=PLATFORM_IDS) +async def test_a_name_the_user_set_is_not_overridden_by_a_span_app_rename( + hass: HomeAssistant, + entry: MockConfigEntry, + device_and_entity_parts: None, + make_install: _CircuitInstall, +) -> None: + """A user who named an entity has said what it is called, and the panel has not. + + The reload is skipped as well as the name: reloading could not change what + is displayed while the registry holds a `name`, so asking for one would be a + whole-entry teardown that no user could see the point of. + """ + install = make_install(hass, entry) + await install.load(ORIGINAL_NAME) + entity = await install.load(ORIGINAL_NAME) + + registry = er.async_get(hass) + registry.async_update_entity(entity.entity_id, name="Beverage Cooling") + + await install.rename_on_the_panel(RENAMED) + install.coordinator.request_reload.assert_not_called() + + await install.load(RENAMED) + + after = registry.async_get(entity.entity_id) + assert after is not None + assert after.name == "Beverage Cooling" + + +@pytest.mark.parametrize( + ("mode", "expected"), + [(FRIENDLY_NAMES, RENAMED_ENTITY_ID), (CIRCUIT_NUMBERS, CIRCUIT_NUMBERS_ENTITY_ID)], + ids=["friendly", "circuit_numbers"], +) +async def test_what_recreate_offers_after_a_pushed_rename_depends_on_the_mode( + hass: HomeAssistant, + entry: MockConfigEntry, + device_and_entity_parts: None, + mode: dict[str, bool], + expected: str, +) -> None: + """The two modes part company at the offer, and nowhere earlier. + + Both followed the same push and the same reload, and both display the new + name. What differs is what Recreate proposes: friendly names offers the + renamed id, which is issue #252; circuit numbers offers the entity its own + id, because an id that follows the breaker position is the point of the + mode and a whole-panel rename would undo it. + """ + hass.config_entries.async_update_entry(entry, options=dict(mode)) + install = _SensorInstall(hass, entry) + await install.load(ORIGINAL_NAME) + await install.load(ORIGINAL_NAME) + + await install.rename_on_the_panel(RENAMED) + sensor = await install.load(RENAMED) + + registry = er.async_get(hass) + registry_entry = registry.async_get(sensor.entity_id) + assert registry_entry is not None + assert registry_entry.original_name is not None + assert registry_entry.original_name.startswith(RENAMED) + assert registry.async_regenerate_entity_id(registry_entry) == expected diff --git a/tests/test_rotate_credentials_service.py b/tests/test_rotate_credentials_service.py new file mode 100644 index 00000000..9d1a9a66 --- /dev/null +++ b/tests/test_rotate_credentials_service.py @@ -0,0 +1,757 @@ +"""Tests for the rotate_credentials service.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import Context, CoreState, HomeAssistant, ServiceResponse +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +import httpx +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser +from span_panel_api import PassphraseRotation +from span_panel_api.exceptions import ( + SpanPanelAPIError, + SpanPanelAuthError, + SpanPanelConnectionError, + SpanPanelInsufficientPrivilegeError, + SpanPanelServerError, + SpanPanelTimeoutError, + SpanPanelTLSVerificationError, +) + +from custom_components.span_panel import ( + SpanPanelRuntimeData, + _async_register_credential_services, + update_listener, +) +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_PANEL_CA_PEM, + DOMAIN, +) +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.services import ( + _ROTATION_RECONNECT_DELAYS_S, + _ROTATIONS_IN_PROGRESS, + _rotation_outcome_unknown, + rotation_in_progress, +) + +from .factories import SpanPanelSnapshotFactory, pv_binding_for + +OLD_BROKER_PASSWORD = "old-broker-password" +NEW_BROKER_PASSWORD = "new-broker-password" +# The panel reports the same value in both fields; distinct here so a test can +# tell which field the integration stored. +NEW_HOP_PASSPHRASE = "new-hop-passphrase" +ROTATION = PassphraseRotation( + ebus_broker_password=NEW_BROKER_PASSWORD, hop_passphrase=NEW_HOP_PASSPHRASE +) + + +def _raised_from( + error_type: type[SpanPanelConnectionError | SpanPanelTimeoutError], + cause_type: type[httpx.TransportError], +) -> Exception: + """Build a library transport error chained from the httpx error behind it.""" + error = error_type(cause_type.__name__) + error.__cause__ = cause_type(cause_type.__name__) + return error + + +def _add_v2_entry(hass: HomeAssistant) -> MockConfigEntry: + """Add a loaded v2 entry with runtime data attached.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=7, + data={ + CONF_HOST: "192.168.1.100", + CONF_ACCESS_TOKEN: "panel-access-token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_HOST: "192.168.1.100", + CONF_EBUS_BROKER_PORT: 8883, + CONF_EBUS_BROKER_USERNAME: "span-user", + CONF_EBUS_BROKER_PASSWORD: OLD_BROKER_PASSWORD, + }, + entry_id="span_entry", + unique_id="sp3-test-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=MagicMock(), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(SpanPanelSnapshotFactory.create()), + setup_snapshot=SpanPanelSnapshotFactory.create(), + ) + return entry + + +def _admin_context(hass: HomeAssistant) -> Context: + """Return a Context belonging to an administrator.""" + user = MockUser(is_owner=True).add_to_hass(hass) + return Context(user_id=user.id) + + +def _non_admin_context(hass: HomeAssistant) -> Context: + """Return a Context belonging to a non-administrator.""" + user = MockUser().add_to_hass(hass) + return Context(user_id=user.id) + + +async def _call_rotate(hass: HomeAssistant, context: Context | None) -> ServiceResponse: + """Call the service, blocking so exceptions propagate.""" + return await hass.services.async_call( + DOMAIN, + "rotate_credentials", + {}, + blocking=True, + context=context, + return_response=True, + ) + + +@pytest.mark.asyncio +async def test_admin_rotation_stores_the_new_password_and_reloads( + hass: HomeAssistant, +) -> None: + """An administrator gets the new broker password persisted and the entry reloaded.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + reload_mock = AsyncMock(return_value=True) + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ) as rotate, + patch.object(hass.config_entries, "async_reload", reload_mock), + ): + response = await _call_rotate(hass, _admin_context(hass)) + + assert response == {"hop_passphrase": NEW_HOP_PASSPHRASE, "reconnected": True} + assert rotate.await_count == 1 + assert rotate.await_args.args[0] == "192.168.1.100" + assert rotate.await_args.args[1] == "panel-access-token" + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == NEW_BROKER_PASSWORD + reload_mock.assert_awaited_once_with(entry.entry_id) + + +@pytest.mark.asyncio +async def test_non_admin_is_refused(hass: HomeAssistant) -> None: + """A logged-in non-admin cannot rotate, and nothing reaches the panel.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + with patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ) as rotate, pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _non_admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_requires_admin" + rotate.assert_not_awaited() + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_contextless_call_is_refused(hass: HomeAssistant) -> None: + """An automation, script or integration has no user and is refused outright.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + with patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ) as rotate, pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, None) + + assert err.value.translation_key == "rotate_credentials_requires_user" + rotate.assert_not_awaited() + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_a_deleted_user_is_refused(hass: HomeAssistant) -> None: + """A user_id that no longer resolves is not an administrator.""" + _add_v2_entry(hass) + _async_register_credential_services(hass) + + with pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, Context(user_id="user-who-no-longer-exists")) + + assert err.value.translation_key == "rotate_credentials_requires_admin" + + +@pytest.mark.asyncio +async def test_connection_failure_leaves_the_old_password_in_place( + hass: HomeAssistant, +) -> None: + """A panel that never answers must not cost the credential that still works.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + reload_mock = AsyncMock(return_value=True) + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(side_effect=_raised_from(SpanPanelConnectionError, httpx.ConnectError)), + ), + patch.object(hass.config_entries, "async_reload", reload_mock), + pytest.raises(ServiceValidationError) as err, + ): + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_failed" + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + reload_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_rejected_token_asks_for_reauthentication(hass: HomeAssistant) -> None: + """A stale access token gets its own message rather than a generic failure.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + with patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(side_effect=SpanPanelAuthError("401")), + ), pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_auth_failed" + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_missing_access_token_is_reported_before_any_call( + hass: HomeAssistant, +) -> None: + """An entry with no token cannot authenticate the rotation.""" + entry = _add_v2_entry(hass) + data = dict(entry.data) + del data[CONF_ACCESS_TOKEN] + hass.config_entries.async_update_entry(entry, data=data) + _async_register_credential_services(hass) + + with patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ) as rotate, pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_no_token" + rotate.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_no_v2_entry_is_reported(hass: HomeAssistant) -> None: + """Only v2 entries have a broker credential to rotate.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=7, + data={CONF_HOST: "192.168.1.100", CONF_API_VERSION: "v1"}, + entry_id="span_v1_entry", + unique_id="sp3-test-002", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=MagicMock(), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(SpanPanelSnapshotFactory.create()), + setup_snapshot=SpanPanelSnapshotFactory.create(), + ) + _async_register_credential_services(hass) + + with pytest.raises(ServiceValidationError) as err: + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_no_entry" + + +@pytest.mark.asyncio +async def test_config_entry_id_selects_the_named_panel(hass: HomeAssistant) -> None: + """With two panels configured, the call rotates only the one named.""" + first = _add_v2_entry(hass) + second = MockConfigEntry( + domain=DOMAIN, + version=7, + data=dict(first.data) | {CONF_HOST: "192.168.1.101"}, + entry_id="span_entry_two", + unique_id="sp3-test-003", + ) + second.add_to_hass(hass) + second.mock_state(hass, ConfigEntryState.LOADED) + second.runtime_data = SpanPanelRuntimeData( + coordinator=MagicMock(), + panel_device_id="panel-device-id-two", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(SpanPanelSnapshotFactory.create()), + setup_snapshot=SpanPanelSnapshotFactory.create(), + ) + _async_register_credential_services(hass) + + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ), + patch.object(hass.config_entries, "async_reload", AsyncMock(return_value=True)), + ): + await hass.services.async_call( + DOMAIN, + "rotate_credentials", + {"config_entry_id": second.entry_id}, + blocking=True, + context=_admin_context(hass), + return_response=True, + ) + + assert second.data[CONF_EBUS_BROKER_PASSWORD] == NEW_BROKER_PASSWORD + assert first.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_entry_that_is_not_loaded_is_rotated(hass: HomeAssistant) -> None: + """An entry that is not loaded is rotated from its stored data. + + It is the entry a rotation that did not reconnect leaves behind, or one that + restarted with a broker password the panel no longer accepts, and another + rotation is how it recovers: the panel does not revoke the stored token. + """ + entry = _add_v2_entry(hass) + entry.mock_state(hass, ConfigEntryState.SETUP_RETRY) + del entry.runtime_data + _async_register_credential_services(hass) + + reload_mock = AsyncMock(return_value=True) + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ) as rotate, + patch.object(hass.config_entries, "async_reload", reload_mock), + ): + response = await _call_rotate(hass, _admin_context(hass)) + + assert response == {"hop_passphrase": NEW_HOP_PASSPHRASE, "reconnected": True} + assert rotate.await_args.args[1] == "panel-access-token" + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == NEW_BROKER_PASSWORD + reload_mock.assert_awaited_once_with(entry.entry_id) + + +@pytest.mark.asyncio +async def test_disabled_entry_is_not_a_candidate(hass: HomeAssistant) -> None: + """A disabled entry is not rotated, and nothing reaches the panel.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=7, + data={ + CONF_HOST: "192.168.1.100", + CONF_ACCESS_TOKEN: "panel-access-token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_PASSWORD: OLD_BROKER_PASSWORD, + }, + entry_id="span_entry", + unique_id="sp3-test-001", + disabled_by=ConfigEntryDisabler.USER, + ) + entry.add_to_hass(hass) + _async_register_credential_services(hass) + + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ) as rotate, + pytest.raises(ServiceValidationError) as err, + ): + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_no_entry" + rotate.assert_not_awaited() + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_two_panels_and_no_id_refuses_rather_than_picking_one( + hass: HomeAssistant, +) -> None: + """With two panels loaded, an omitted id is ambiguous, not a default.""" + first = _add_v2_entry(hass) + second = MockConfigEntry( + domain=DOMAIN, + version=7, + data=dict(first.data) | {CONF_HOST: "192.168.1.101"}, + entry_id="span_entry_two", + unique_id="sp3-test-004", + ) + second.add_to_hass(hass) + second.mock_state(hass, ConfigEntryState.LOADED) + second.runtime_data = SpanPanelRuntimeData( + coordinator=MagicMock(), + panel_device_id="panel-device-id-two", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(SpanPanelSnapshotFactory.create()), + setup_snapshot=SpanPanelSnapshotFactory.create(), + ) + _async_register_credential_services(hass) + + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ) as rotate, + patch.object(hass.config_entries, "async_reload", AsyncMock(return_value=True)), + pytest.raises(ServiceValidationError) as err, + ): + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_multiple_panels" + rotate.assert_not_awaited() + assert first.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + assert second.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_a_reconnect_that_never_succeeds_still_returns_the_passphrase( + hass: HomeAssistant, +) -> None: + """Raising would discard the only copy of the new passphrase, so it is returned.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + reload_mock = AsyncMock(return_value=False) + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ), + patch.object(hass.config_entries, "async_reload", reload_mock), + patch("custom_components.span_panel.services.asyncio.sleep", AsyncMock()) as sleep, + ): + response = await _call_rotate(hass, _admin_context(hass)) + + assert response == {"hop_passphrase": NEW_HOP_PASSPHRASE, "reconnected": False} + assert [c.args[0] for c in sleep.await_args_list] == list(_ROTATION_RECONNECT_DELAYS_S) + assert sum(_ROTATION_RECONNECT_DELAYS_S) == pytest.approx(60.0) + assert reload_mock.await_count == len(_ROTATION_RECONNECT_DELAYS_S) + 1 + # The panel has already issued it, so the entry must keep the new one. + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == NEW_BROKER_PASSWORD + assert not rotation_in_progress(hass, entry.entry_id) + + +@pytest.mark.asyncio +async def test_a_reload_that_raises_still_returns_the_passphrase( + hass: HomeAssistant, +) -> None: + """An exception from a reload is a failed attempt, not a lost response.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + reload_mock = AsyncMock(side_effect=[RuntimeError("setup blew up"), True]) + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ), + patch.object(hass.config_entries, "async_reload", reload_mock), + patch("custom_components.span_panel.services.asyncio.sleep", AsyncMock()), + ): + response = await _call_rotate(hass, _admin_context(hass)) + + assert response == {"hop_passphrase": NEW_HOP_PASSPHRASE, "reconnected": True} + assert reload_mock.await_count == 2 + assert not rotation_in_progress(hass, entry.entry_id) + + +@pytest.mark.asyncio +async def test_reconnect_retries_until_the_broker_accepts_the_new_password( + hass: HomeAssistant, +) -> None: + """Refusals right after the rotation are retried with the new password, under the grace flag.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + seen: list[tuple[str, bool]] = [] + + async def _reload(entry_id: str) -> bool: + seen.append( + ( + hass.config_entries.async_get_entry(entry_id).data[CONF_EBUS_BROKER_PASSWORD], + rotation_in_progress(hass, entry_id), + ) + ) + return len(seen) == 3 + + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ), + patch.object(hass.config_entries, "async_reload", AsyncMock(side_effect=_reload)), + patch("custom_components.span_panel.services.asyncio.sleep", AsyncMock()) as sleep, + ): + response = await _call_rotate(hass, _admin_context(hass)) + + assert response == {"hop_passphrase": NEW_HOP_PASSPHRASE, "reconnected": True} + # Every attempt uses the new password and runs inside the grace window. + assert seen == [(NEW_BROKER_PASSWORD, True)] * 3 + assert [c.args[0] for c in sleep.await_args_list] == list(_ROTATION_RECONNECT_DELAYS_S[:2]) + assert not rotation_in_progress(hass, entry.entry_id) + assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) + + +@pytest.mark.asyncio +async def test_the_options_listener_leaves_the_reload_to_the_rotation( + hass: HomeAssistant, +) -> None: + """Storing the new password must not start a second, unguarded reload.""" + entry = _add_v2_entry(hass) + hass.set_state(CoreState.running) + hass.data.setdefault(_ROTATIONS_IN_PROGRESS, set()).add(entry.entry_id) + + with patch.object(hass.config_entries, "async_reload", AsyncMock()) as reload_mock: + await update_listener(hass, entry) + + reload_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_the_passphrase_is_returned_but_never_stored_or_logged( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """The integration keeps only the broker password; the passphrase goes to the caller.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + caplog.set_level(logging.DEBUG) + + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ), + patch.object(hass.config_entries, "async_reload", AsyncMock(return_value=True)), + ): + response = await _call_rotate(hass, _admin_context(hass)) + + assert response["hop_passphrase"] == NEW_HOP_PASSPHRASE + assert NEW_HOP_PASSPHRASE not in repr(dict(entry.data)) + assert NEW_HOP_PASSPHRASE not in repr(dict(entry.options)) + assert NEW_HOP_PASSPHRASE not in caplog.text + assert NEW_BROKER_PASSWORD not in caplog.text + + +@pytest.mark.asyncio +async def test_a_call_that_does_not_ask_for_the_response_is_refused( + hass: HomeAssistant, +) -> None: + """Without the response the new passphrase would be lost, so nothing is rotated.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ) as rotate, + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + DOMAIN, + "rotate_credentials", + {}, + blocking=True, + context=_admin_context(hass), + ) + + rotate.assert_not_awaited() + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.parametrize( + ("error", "translation_key", "validation"), + [ + ( + SpanPanelInsufficientPrivilegeError("403"), + "rotate_credentials_insufficient_privilege", + True, + ), + (SpanPanelAuthError("401"), "rotate_credentials_auth_failed", True), + (SpanPanelServerError("500", status_code=500), "rotate_credentials_outcome_unknown", False), + (SpanPanelServerError("503", status_code=503), "rotate_credentials_failed", True), + (SpanPanelServerError("502", status_code=502), "rotate_credentials_outcome_unknown", False), + ( + SpanPanelAPIError("unreadable body", status_code=200), + "rotate_credentials_outcome_unknown", + False, + ), + (SpanPanelAPIError("404", status_code=404), "rotate_credentials_failed", True), + ( + _raised_from(SpanPanelTimeoutError, httpx.ReadTimeout), + "rotate_credentials_outcome_unknown", + False, + ), + ( + _raised_from(SpanPanelTimeoutError, httpx.WriteTimeout), + "rotate_credentials_outcome_unknown", + False, + ), + ( + _raised_from(SpanPanelConnectionError, httpx.RemoteProtocolError), + "rotate_credentials_outcome_unknown", + False, + ), + ( + _raised_from(SpanPanelConnectionError, httpx.ReadError), + "rotate_credentials_outcome_unknown", + False, + ), + (SpanPanelTimeoutError("timed out"), "rotate_credentials_outcome_unknown", False), + ( + _raised_from(SpanPanelTimeoutError, httpx.ConnectTimeout), + "rotate_credentials_failed", + True, + ), + ( + _raised_from(SpanPanelTimeoutError, httpx.PoolTimeout), + "rotate_credentials_failed", + True, + ), + ( + _raised_from(SpanPanelConnectionError, httpx.ConnectError), + "rotate_credentials_failed", + True, + ), + (SpanPanelTLSVerificationError("bad certificate"), "rotate_credentials_failed", True), + ], +) +@pytest.mark.asyncio +async def test_each_rotation_error_has_its_own_message_and_stores_nothing( + hass: HomeAssistant, + error: Exception, + translation_key: str, + validation: bool, +) -> None: + """An unknown outcome is not reported as a refusal that changed nothing.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + reload_mock = AsyncMock(return_value=True) + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(side_effect=error), + ), + patch.object(hass.config_entries, "async_reload", reload_mock), + pytest.raises(HomeAssistantError) as err, + ): + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == translation_key + assert isinstance(err.value, ServiceValidationError) is validation + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + reload_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_an_unusable_stored_ca_refuses_rather_than_rotating_in_the_clear( + hass: HomeAssistant, +) -> None: + """A rotation carries the token out and the new password back; never unpinned.""" + entry = _add_v2_entry(hass) + hass.config_entries.async_update_entry( + entry, data=dict(entry.data) | {CONF_PANEL_CA_PEM: "not a certificate"} + ) + _async_register_credential_services(hass) + + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(return_value=ROTATION), + ) as rotate, + patch.object(hass.config_entries, "async_reload", AsyncMock(return_value=True)), + pytest.raises(ServiceValidationError) as err, + ): + await _call_rotate(hass, _admin_context(hass)) + + assert err.value.translation_key == "rotate_credentials_ca_unusable" + rotate.assert_not_awaited() + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == OLD_BROKER_PASSWORD + + +@pytest.mark.asyncio +async def test_overlapping_rotations_of_one_entry_run_one_after_the_other( + hass: HomeAssistant, +) -> None: + """A second call waits for the first to finish, and the later rotation's password is stored.""" + entry = _add_v2_entry(hass) + _async_register_credential_services(hass) + + second = PassphraseRotation( + ebus_broker_password="second-broker-password", hop_passphrase="second-hop" + ) + events: list[str] = [] + release_first_reload = asyncio.Event() + first_reload_started = asyncio.Event() + + async def _rotate(*_args: object, **_kwargs: object) -> PassphraseRotation: + events.append(f"put{len([e for e in events if e.startswith('put')]) + 1}") + return ROTATION if events[-1] == "put1" else second + + async def _reload(entry_id: str) -> bool: + events.append(f"reload:{rotation_in_progress(hass, entry_id)}") + if not first_reload_started.is_set(): + first_reload_started.set() + await release_first_reload.wait() + return True + + with ( + patch( + "custom_components.span_panel.services.rotate_passphrase", + AsyncMock(side_effect=_rotate), + ), + patch.object(hass.config_entries, "async_reload", AsyncMock(side_effect=_reload)), + ): + first = hass.async_create_task(_call_rotate(hass, _admin_context(hass))) + await first_reload_started.wait() + second_call = hass.async_create_task(_call_rotate(hass, _admin_context(hass))) + await asyncio.sleep(0) + await asyncio.sleep(0) + # The second PUT has not started while the first is still reconnecting. + assert events == ["put1", "reload:True"] + assert rotation_in_progress(hass, entry.entry_id) + release_first_reload.set() + first_response = await first + second_response = await second_call + + assert events == ["put1", "reload:True", "put2", "reload:True"] + assert first_response == {"hop_passphrase": NEW_HOP_PASSPHRASE, "reconnected": True} + assert second_response == {"hop_passphrase": "second-hop", "reconnected": True} + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == "second-broker-password" + assert not rotation_in_progress(hass, entry.entry_id) + + +def test_the_outcome_unknown_fallback_matches_strings_json() -> None: + """The English default message is the one in strings.json.""" + strings = json.loads( + (Path(__file__).parent.parent / "custom_components/span_panel/strings.json").read_text( + encoding="utf-8" + ) + ) + expected = strings["exceptions"]["rotate_credentials_outcome_unknown"]["message"] + assert str(_rotation_outcome_unknown("panel.local")) == expected.format(host="panel.local") diff --git a/tests/test_runtime_rest_transport.py b/tests/test_runtime_rest_transport.py new file mode 100644 index 00000000..3eeb889d --- /dev/null +++ b/tests/test_runtime_rest_transport.py @@ -0,0 +1,571 @@ +"""The runtime client rides the pinned transport, and refuses to ride without it. + +Issue #264: every other REST call site adopted `panel_rest_transport` when the +pin landed — config flow, reauth, repairs, `rotate_credentials` — and the one +that runs on every startup did not. The schema fetch inside `connect()` stayed +on plaintext HTTP with the pin sitting unused in `entry.data`, which is exactly +the condition the library's transport warning names. + +Fail closed, both ways it can fail. A stored anchor that cannot be read is not +downgraded to plaintext at the one call that runs unattended on every boot; a +certificate the pin rejects is not retried into submission. Each gets a Repair, +because both need a person: the first needs a new anchor accepted, the second is +either a legitimately rotated CA (accept it) or something standing in front of +the panel's TLS port (investigate it) — and which of those it is gets diagnosed +the same way the library diagnoses the MQTT side, by re-reading the advertised +CA and comparing fingerprints. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.config_entries import ConfigEntryError, ConfigEntryNotReady +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.httpx_client import get_async_client +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api.exceptions import ( + SpanPanelConnectionError, + SpanPanelTLSVerificationError, + SpanPanelValidationError, +) + +from custom_components.span_panel import async_remove_entry, async_setup_entry +from custom_components.span_panel.ca_repairs import ( + ca_changed_issue_id, + ca_unusable_issue_id, + rest_tls_untrusted_issue_id, +) +from custom_components.span_panel.config_flow_validation import LeafProbeResult, LeafVerdict +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_HOST, + CONF_EBUS_BROKER_PASSWORD, + CONF_EBUS_BROKER_PORT, + CONF_EBUS_BROKER_USERNAME, + CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, + DOMAIN, +) +from custom_components.span_panel.leaf_repairs import leaf_name_mismatch_issue_id + +from .factories import SpanPanelSnapshotFactory + +PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" +OTHER_PEM = "-----BEGIN CERTIFICATE-----\nb3RoZXI=\n-----END CERTIFICATE-----\n" +SERIAL = "sp3-rest-001" + + +def _entry(hass: HomeAssistant, **data_overrides: object) -> MockConfigEntry: + data: dict[str, object] = { + CONF_API_VERSION: "v2", + CONF_HOST: "192.168.1.50", + CONF_EBUS_BROKER_HOST: "span-panel.local", + CONF_EBUS_BROKER_USERNAME: "mqtt-user", + CONF_EBUS_BROKER_PASSWORD: "mqtt-pass", + CONF_EBUS_BROKER_PORT: 8883, + CONF_HTTP_PORT: 80, + } + data.update(data_overrides) + entry = MockConfigEntry( + domain=DOMAIN, data=data, entry_id="entry-rest", title=SERIAL, unique_id=SERIAL + ) + entry.add_to_hass(hass) + return entry + + +def _happy_client() -> MagicMock: + client = MagicMock() + client.connect = AsyncMock() + client.close = AsyncMock() + return client + + +def _happy_coordinator() -> MagicMock: + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number=SERIAL) + return coordinator + + +@contextmanager +def _full_setup(client: MagicMock, hass: HomeAssistant) -> Iterator[MagicMock]: + """Everything a successful setup needs mocked, yielding the client class.""" + with ( + patch("custom_components.span_panel.async_register_commands"), + patch( + "custom_components.span_panel.SpanMqttClient", return_value=client + ) as mock_client_cls, + patch( + "custom_components.span_panel.SpanPanelCoordinator", + return_value=_happy_coordinator(), + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + yield mock_client_cls + + +class TestThePinReachesTheRuntimeClient: + """The transport decision follows the entry's pin, port by port.""" + + async def test_a_pinned_entry_hands_its_anchor_to_the_runtime_client( + self, hass: HomeAssistant + ) -> None: + """The pin stored on the entry anchors the schema fetch at connect.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + context = MagicMock(name="pinned-context") + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ), + _full_setup(client, hass) as mock_client_cls, + ): + assert await async_setup_entry(hass, entry) is True + + kwargs = mock_client_cls.call_args.kwargs + assert kwargs["ssl_context"] is context + # 443 by default — the stored plaintext port is the bridge's, not TLS's. + assert kwargs["panel_https_port"] == 443 + assert kwargs["panel_http_port"] == 80 + # The shared client stays: the library ignores it under a context, and + # it is what the unpinned plaintext path uses. + assert kwargs["httpx_client"] is get_async_client(hass) + + async def test_a_pinned_entry_honours_a_configured_https_port( + self, hass: HomeAssistant + ) -> None: + """A stored HTTPS port names where the panel's TLS actually lives.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTPS_PORT: 8443}) + client = _happy_client() + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + _full_setup(client, hass) as mock_client_cls, + ): + assert await async_setup_entry(hass, entry) is True + + assert mock_client_cls.call_args.kwargs["panel_https_port"] == 8443 + + async def test_an_unpinned_entry_keeps_the_plaintext_transport( + self, hass: HomeAssistant + ) -> None: + """No pin is not a failure — it is the TOFU path, plaintext as designed.""" + entry = _entry(hass) + client = _happy_client() + + with _full_setup(client, hass) as mock_client_cls: + assert await async_setup_entry(hass, entry) is True + + kwargs = mock_client_cls.call_args.kwargs + assert kwargs["ssl_context"] is None + assert kwargs["panel_https_port"] is None + assert kwargs["panel_http_port"] == 80 + + +class TestFailingClosed: + """Both ways the pinned transport can fail end with a person, not a downgrade.""" + + async def test_an_unusable_stored_anchor_fails_setup_with_a_repair( + self, hass: HomeAssistant + ) -> None: + """A pin that cannot be read must not quietly become no pin at all.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: "not a certificate"}) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient") as mock_client_cls, + pytest.raises(ConfigEntryError), + ): + await async_setup_entry(hass, entry) + + mock_client_cls.assert_not_called() + issue = ir.async_get(hass).async_get_issue(DOMAIN, ca_unusable_issue_id(entry.entry_id)) + assert issue is not None + assert issue.is_fixable + + async def test_a_rotated_ca_behind_a_rest_failure_raises_the_ca_changed_repair( + self, hass: HomeAssistant + ) -> None: + """The common legitimate cause, given the guided re-pin it already has.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=OTHER_PEM), + ), + pytest.raises(ConfigEntryError), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + issue = ir.async_get(hass).async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) + assert issue is not None + + async def test_an_untrusted_leaf_behind_an_unchanged_ca_retries_with_a_repair( + self, hass: HomeAssistant + ) -> None: + """The CA did not rotate and the leaf does not chain to it. + + A proxy terminating the TLS port with its own certificate, or a leaf an + outage's clock reset left outside its validity window — the probe cannot + tell them apart, so the repair names both and the entry retries: the + clock case heals itself, and never plaintext either way. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ), + patch( + "custom_components.span_panel.async_leaf_probe", + new=AsyncMock(return_value=LeafProbeResult(LeafVerdict.UNTRUSTED, ())), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + issue = ir.async_get(hass).async_get_issue( + DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id) + ) + assert issue is not None + assert not issue.is_fixable + # Re-derived on every retry, so it must not outlive a restart that fixes it. + assert not issue.is_persistent + + async def test_a_moved_panel_behind_an_unchanged_ca_raises_the_leaf_repair( + self, hass: HomeAssistant + ) -> None: + """The panel is who it says it is, and not where the entry says it is. + + The leaf-mismatch repair already promises "the integration keeps + retrying", and setup keeps that promise with NotReady rather than + contradicting it with a terminal accusation of interception. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ), + patch( + "custom_components.span_panel.async_leaf_probe", + new=AsyncMock( + return_value=LeafProbeResult( + LeafVerdict.NAME_MISMATCH, ("span-panel.local", "10.0.0.7") + ) + ), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + issue = ir.async_get(hass).async_get_issue( + DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id) + ) + assert issue is not None + assert ( + ir.async_get(hass).async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) + is None + ) + + async def test_the_two_same_fingerprint_repairs_supersede_each_other( + self, hass: HomeAssistant + ) -> None: + """A panel cannot be both intercepted and merely moved; only the current verdict stands. + + Without the supersede, a panel that first probed UNTRUSTED and then + moved (or the reverse) showed both repairs at once — one saying "keep + retrying, it will recover", the other implying interception — for the + rest of the session. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + registry = ir.async_get(hass) + + async def _setup_with_verdict(result: LeafProbeResult) -> None: + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ), + patch( + "custom_components.span_panel.async_leaf_probe", + new=AsyncMock(return_value=result), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + await _setup_with_verdict(LeafProbeResult(LeafVerdict.UNTRUSTED, ())) + await _setup_with_verdict( + LeafProbeResult(LeafVerdict.NAME_MISMATCH, ("span-panel.local",)) + ) + assert registry.async_get_issue(DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) + assert ( + registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) is None + ) + + await _setup_with_verdict(LeafProbeResult(LeafVerdict.UNTRUSTED, ())) + assert registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) + assert ( + registry.async_get_issue(DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) is None + ) + + async def test_an_unreachable_tls_port_behind_an_unchanged_ca_just_retries( + self, hass: HomeAssistant + ) -> None: + """The probe reached nothing, which is a panel mid-reboot, not a verdict.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(return_value=PEM), + ), + patch( + "custom_components.span_panel.async_leaf_probe", + new=AsyncMock(return_value=LeafProbeResult(LeafVerdict.UNREACHABLE, ())), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) is None + assert registry.async_get_issue(DOMAIN, leaf_name_mismatch_issue_id(entry.entry_id)) is None + + async def test_a_pinned_entry_that_cannot_connect_names_the_https_port( + self, hass: HomeAssistant + ) -> None: + """Nothing answering the TLS port must not read as 'panel is down'. + + The deferred-pin population can have TLS living somewhere 443 is not — + behind NAT, a port forward, a proxy — and was never asked for the port. + Their failure is a plain refused connection, indistinguishable from a + reboot, so the retry message is where the remedy has to travel. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock(side_effect=SpanPanelConnectionError("connection refused")) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryNotReady, match="HTTPS port 443"), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + + async def test_a_stored_https_port_of_80_fails_setup_cleanly( + self, hass: HomeAssistant + ) -> None: + """The library refuses the plaintext port under a context; setup must not crash. + + `SpanPanelValidationError` out of connect() used to escape as a raw + traceback with the client left open. It is a stored-configuration + problem, so it ends as a clear terminal error naming the remedy. + """ + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM, CONF_HTTPS_PORT: 80}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelValidationError("port=80 was passed together with an ssl_context") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryError), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + + async def test_missing_evidence_retries_rather_than_escalating( + self, hass: HomeAssistant + ) -> None: + """A panel unreachable on its HTTP port is a panel mid-reboot, not a verdict.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + client = _happy_client() + client.connect = AsyncMock( + side_effect=SpanPanelTLSVerificationError("certificate verify failed") + ) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.async_fetch_panel_ca", + new=AsyncMock(side_effect=SpanPanelConnectionError("unreachable")), + ), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, ca_changed_issue_id(entry.entry_id)) is None + assert ( + registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) is None + ) + + async def test_a_clean_connect_clears_the_standing_repairs( + self, hass: HomeAssistant + ) -> None: + """A handshake under the current pin refutes what the issues describe.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + registry = ir.async_get(hass) + registry.async_get_or_create( + DOMAIN, + rest_tls_untrusted_issue_id(entry.entry_id), + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_rest_tls_untrusted", + ) + registry.async_get_or_create( + DOMAIN, + ca_unusable_issue_id(entry.entry_id), + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_ca_unusable", + ) + client = _happy_client() + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + _full_setup(client, hass), + ): + assert await async_setup_entry(hass, entry) is True + + assert ( + registry.async_get_issue(DOMAIN, rest_tls_untrusted_issue_id(entry.entry_id)) is None + ) + assert registry.async_get_issue(DOMAIN, ca_unusable_issue_id(entry.entry_id)) is None + + +class TestRemovalLeavesNothingBehind: + """Core deletes no issues when an entry is removed; the CA family goes here.""" + + async def test_removing_the_entry_clears_its_ca_repairs(self, hass: HomeAssistant) -> None: + """Persistent issues especially: nothing else can clear one whose entry is gone.""" + entry = _entry(hass, **{CONF_PANEL_CA_PEM: PEM}) + registry = ir.async_get(hass) + for issue_id, fixable in ( + (ca_changed_issue_id(entry.entry_id), True), + (ca_unusable_issue_id(entry.entry_id), True), + (rest_tls_untrusted_issue_id(entry.entry_id), False), + (leaf_name_mismatch_issue_id(entry.entry_id), False), + ): + registry.async_get_or_create( + DOMAIN, + issue_id, + is_fixable=fixable, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="panel_ca_changed", + ) + + with ( + patch("custom_components.span_panel.async_forget_announcements", AsyncMock()), + patch("custom_components.span_panel.async_forget", AsyncMock()), + ): + await async_remove_entry(hass, entry) + + for issue_id in ( + ca_changed_issue_id(entry.entry_id), + ca_unusable_issue_id(entry.entry_id), + rest_tls_untrusted_issue_id(entry.entry_id), + leaf_name_mismatch_issue_id(entry.entry_id), + ): + assert registry.async_get_issue(DOMAIN, issue_id) is None, issue_id diff --git a/tests/test_schema_availability.py b/tests/test_schema_availability.py new file mode 100644 index 00000000..22f05585 --- /dev/null +++ b/tests/test_schema_availability.py @@ -0,0 +1,320 @@ +"""An entity whose declared field could not be resolved reports unavailable. + +The Repairs from `schema_repairs` explain and aggregate; this is the same fact +told where a user actually looks. It also mitigates the user-visible symptom of +the "absent property parses to 0.0 rather than None" defect *for the +resolution-failure case*: a field the adapter cannot resolve at all would +otherwise render as a live zero, which for a TOTAL_INCREASING energy sensor +reads as a counter reset. It does nothing for a field that resolves but stops +being published at runtime -- that needs the snapshot model to admit None. +""" + +from __future__ import annotations + +from typing import cast +from unittest.mock import MagicMock + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import SpanMqttClient, SpanPanelSnapshot + +from custom_components.span_panel import ( + SpanPanelRuntimeData, + binary_sensor, + button, + select, + sensor_base, + switch, +) +from custom_components.span_panel.binary_sensor import ( + BESS_CONNECTED_SENSOR, + BINARY_SENSORS, + EVSE_BINARY_SENSORS, + SpanEvseBinarySensor, + SpanPanelBinarySensor, +) +from custom_components.span_panel.const import ( + CONF_DEVICE_NAME, + PANEL_STATUS, + SYSTEM_ETHERNET_LINK, +) +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.entity import SpanPanelEntity +from custom_components.span_panel.field_paths import DerivedReason +from custom_components.span_panel.schema_validation import SchemaFindings +from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor +from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS + +from .factories import ( + SpanCircuitSnapshotFactory, + SpanEvseSnapshotFactory, + SpanPanelSnapshotFactory, + pv_binding_for, +) + +_CIRCUIT_POWER_PATH = "circuit.instant_power_w" +_ETHERNET_LINK_PATH = "panel.eth0_link" + + +def _make_coordinator(hass: HomeAssistant) -> SpanPanelCoordinator: + """Return a real coordinator, so `unresolved_paths` is the real property. + + A MagicMock would answer `_findings` with a mock and `unresolved_paths` with + a mock whose `__contains__` is False -- the probe would look correct while + never firing. + """ + snapshot = SpanPanelSnapshotFactory.create( + circuits={"c1": SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen")} + ) + entry = MockConfigEntry( + domain="span_panel", + data={CONF_HOST: "192.168.1.50", CONF_DEVICE_NAME: "SPAN Panel"}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + entry.add_to_hass(hass) + coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, MagicMock()), entry) + coordinator.data = snapshot + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + return coordinator + + +def _snapshot(coordinator: SpanPanelCoordinator) -> SpanPanelSnapshot: + return coordinator.data + + +def _circuit_power_entity(coordinator: SpanPanelCoordinator) -> SpanCircuitPowerSensor: + """Build a sensor reading `circuit.instant_power_w` -- the `SpanSensorBase` branch.""" + description = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_power") + assert description.field_path == _CIRCUIT_POWER_PATH + return SpanCircuitPowerSensor(coordinator, description, _snapshot(coordinator), "c1") + + +def _ethernet_link_entity(coordinator: SpanPanelCoordinator) -> SpanPanelBinarySensor: + """Build a binary sensor reading `panel.eth0_link` -- the `SpanPanelEntity` branch. + + Binary sensors do not inherit `SpanSensorBase`, so this is the second base + class the probe has to live on. + """ + description = next(desc for desc in BINARY_SENSORS if desc.key == SYSTEM_ETHERNET_LINK) + assert description.field_path == _ETHERNET_LINK_PATH + return SpanPanelBinarySensor(coordinator, description) + + +async def test_sensor_unavailable_when_its_field_is_unresolved(hass: HomeAssistant) -> None: + """A dead field must not render as a live 0.0.""" + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_CIRCUIT_POWER_PATH}), (), frozenset()) + + assert _circuit_power_entity(coordinator).available is False + + +async def test_sensor_available_when_findings_are_clean(hass: HomeAssistant) -> None: + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset(), (), frozenset()) + + assert _circuit_power_entity(coordinator).available is True + + +async def test_sensor_available_while_findings_are_unknown(hass: HomeAssistant) -> None: + """No validation pass has completed yet; that is not a reason to go dark.""" + coordinator = _make_coordinator(hass) + assert coordinator.schema_findings is None + + assert _circuit_power_entity(coordinator).available is True + + +async def test_sensor_unavailable_when_unresolved_and_panel_offline( + hass: HomeAssistant, +) -> None: + """The probe must precede the grace-period branch. + + `SpanSensorBase.available` returns True while `panel_offline` so the sensor + can show its grace-period state. Probing after that check would let every + offline sensor report a resolved-looking value for a field that is gone. + """ + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_CIRCUIT_POWER_PATH}), (), frozenset()) + coordinator._panel_offline = True + + assert coordinator.panel_offline is True + assert _circuit_power_entity(coordinator).available is False + + +async def test_sensor_unaffected_by_an_unrelated_unresolved_field( + hass: HomeAssistant, +) -> None: + """Only the entity's own field counts; a neighbour's failure is not its own.""" + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset()) + + assert _circuit_power_entity(coordinator).available is True + + +async def test_binary_sensor_unavailable_when_its_field_is_unresolved( + hass: HomeAssistant, +) -> None: + """`SpanPanelBinarySensor` extends `SpanPanelEntity`, not `SpanSensorBase`.""" + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset()) + + assert _ethernet_link_entity(coordinator).available is False + + +async def test_binary_sensor_available_when_findings_are_clean(hass: HomeAssistant) -> None: + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset(), (), frozenset()) + + assert _ethernet_link_entity(coordinator).available is True + + +async def test_binary_sensor_unavailable_when_unresolved_and_panel_offline( + hass: HomeAssistant, +) -> None: + """`SpanPanelBinarySensor` has its own offline branch that returns True. + + The hardware-status sensors stay available while the panel is offline so + they can show Unknown. That branch bypasses `super().available`, so the + probe has to run ahead of it here too -- the same ordering `SpanSensorBase` + needs. + """ + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset()) + coordinator._panel_offline = True + + assert _ethernet_link_entity(coordinator).available is False + + +async def test_panel_status_binary_sensor_is_never_probed(hass: HomeAssistant) -> None: + """It reports coordinator reachability, not a snapshot field.""" + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset()) + description = next(desc for desc in BINARY_SENSORS if desc.key == PANEL_STATUS) + assert description.derived is DerivedReason.NO_SOURCE_FIELD + + entity = SpanPanelBinarySensor(coordinator, description) + + assert entity.available is True + + +async def test_schema_conditional_entity_goes_unavailable(hass: HomeAssistant) -> None: + """A schema-conditional entity is probed like any other. + + `bess_connected` reads exactly one field, `battery.connected`, which only + the schema_0 adapter publishes -- that, and only that, is what `derived` + says here. The adapter that does publish it still reports whether the panel + resolved it, and an unresolved field reaches this entity as a default it + would present as a reading. + + Excluding these from the probe was the `evse_ev_connected` failure reached + by a second route: an entity exempt from the producible gate silently + exempt from degradation too. + """ + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({"battery.connected"}), (), frozenset()) + assert BESS_CONNECTED_SENSOR.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD + assert BESS_CONNECTED_SENSOR.field_path == "battery.connected" + + entity = SpanPanelBinarySensor(coordinator, BESS_CONNECTED_SENSOR) + + assert entity.available is False + + +async def test_schema_conditional_entity_stays_available_when_resolved( + hass: HomeAssistant, +) -> None: + """The probe must key on the entity's own field, not on being conditional.""" + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset()) + + entity = SpanPanelBinarySensor(coordinator, BESS_CONNECTED_SENSOR) + + assert entity.available is True + + +async def test_residual_reads_still_do_not_probe(hass: HomeAssistant) -> None: + """`circuit.name` and `circuit.tabs` decorate a reading; they are not it. + + The deliberate exclusion the schema-conditional change must not regress: a + circuit power sensor reads its name and tabs for naming and attributes, and + its power reading is still true when those are gone. + """ + coordinator = _make_coordinator(hass) + coordinator._findings = SchemaFindings(frozenset({"circuit.name"}), (), frozenset()) + + entity = _circuit_power_entity(coordinator) + + assert "circuit.name" in type(entity)._residual_field_paths + assert "circuit.name" in entity._declared_field_paths() + assert entity.available is True + + +@pytest.mark.parametrize("key", ["evse_charging", "evse_ev_connected"]) +async def test_evse_binary_sensor_is_covered_by_the_base_class( + hass: HomeAssistant, key: str +) -> None: + """`SpanEvseBinarySensor` defines no `available`, so it exercises `entity.py`. + + The circuit sensor goes through `SpanSensorBase.available` and the panel + binary sensor through `SpanPanelBinarySensor.available`; neither reaches the + override on `SpanPanelEntity` itself. This one does. + + Both EVSE binary sensors read `evse.status`, so both must answer the same + way. `evse_ev_connected` used to be `derived=True` and so stayed available + while its sibling went dark on the very same dead field. + """ + coordinator = _make_coordinator(hass) + coordinator.data = SpanPanelSnapshotFactory.create( + evse={"evse-0": SpanEvseSnapshotFactory.create()} + ) + coordinator._findings = SchemaFindings(frozenset({"evse.status"}), (), frozenset()) + description = next(desc for desc in EVSE_BINARY_SENSORS if desc.key == key) + assert description.field_path == "evse.status" + assert "available" not in vars(SpanEvseBinarySensor) + + entity = SpanEvseBinarySensor(coordinator, description, "evse-0") + + assert entity.available is False + + +def test_every_available_override_is_accounted_for() -> None: + """A new `available` override must decide where the probe sits. + + `SpanSensorBase` and `SpanPanelBinarySensor` return True on their own + before delegating, so each carries the probe ahead of that branch. The + circuit switch, select and button return False when the panel is offline and + otherwise delegate, so the override on `SpanPanelEntity` covers them. + `SpanPanelControlLockSwitch` answers the question a third way — it never + delegates, because it reads no panel field and arming it is local — and is + the one control a dead transport deliberately leaves alone. Anything new in + this list has to answer the same question, and this assertion is what forces + it to be asked. + """ + overriders = { + cls.__name__ + for module in (binary_sensor, button, select, sensor_base, switch) + for cls in vars(module).values() + if isinstance(cls, type) + and issubclass(cls, SpanPanelEntity) + and cls is not SpanPanelEntity # the base itself, imported into each module + and "available" in vars(cls) + } + + assert overriders == { + "SpanSensorBase", + "SpanPanelBinarySensor", + "SpanPanelCircuitsSwitch", + "SpanPanelControlLockSwitch", + "SpanPanelGFEOverrideButton", + "SpanPanelCircuitsSelect", + } diff --git a/tests/test_schema_discovery.py b/tests/test_schema_discovery.py new file mode 100644 index 00000000..d880c3b6 --- /dev/null +++ b/tests/test_schema_discovery.py @@ -0,0 +1,373 @@ +"""What the panel declares that nothing here reads, surfaced to a maintainer. + +`test_declared_but_unread` asks this question of the reference capture and +answers it by experiment. It is the right check and it is fixture-bound: a real +panel that starts publishing a property fails nothing until somebody recaptures. +The adapter answers the same question at runtime, for the panel in front of the +user, and this module is the consumer half — the partition that keeps those rows +out of every curated inventory, and the diagnostics block that carries them to +whoever is triaging the issue. + +Two properties are load-bearing and both are asserted here rather than reviewed: + +**The partition.** Discovered rows arrive in the same map as curated ones. Every +inventory downstream — the producible gate, the unread set, the exemption +annotations, the unit vocabulary — reads "in an adapter's map" as "this +integration could read this", which a discovered path is not. One namespace test +applied once is what keeps that true, and `test_the_unread_inventory_is_deaf_to +_discovery` is the mutation proof that it is applied. + +**No value leaves.** A diagnostics payload goes into GitHub issues and forum +posts. `TO_REDACT` is key-based over the config entry and knows nothing about +wire property names, so nothing downstream can protect a value put in this +block. It carries declarations only, and that is checked against the capture's +own published values. +""" + +from __future__ import annotations + +import json +import pathlib +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api.models import ( + DiscoveredMetadata, + FieldMetadata, + SpanPanelSnapshot, + is_discovery_path, +) + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.diagnostics import async_get_config_entry_diagnostics +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + declared_field_paths, +) +from custom_components.span_panel.schema_validation import ( + SchemaFindings, + evaluate_field_metadata, + partition, +) + +from .adapter_fixtures import ( + SCHEMA_ONE_PANEL, + schema_one_discovery, + schema_one_metadata, + schema_one_metadata_raw, + schema_one_tree, +) +from .factories import pv_binding_for + +BASELINE = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json" + +_SYNTHETIC = DiscoveredMetadata(unit="°C", datatype="float", retained=True) +"""A row for a property no firmware in the fixtures declares. + +Used to prove the partition by mutation rather than by inspection: a curated +inventory that is genuinely deaf to discovery is unchanged by this, and one that +merely happens to contain nothing surprising is not. +""" +_SYNTHETIC_PATH = "discovered.distribution-enclosure/status/enclosure-temperature" + + +# --- the partition --------------------------------------------------------- + + +def test_the_adapter_emits_discovered_rows_at_all() -> None: + """The floor. Every assertion below passes trivially against an empty report.""" + raw = schema_one_metadata_raw() + namespaced = {path for path in raw if is_discovery_path(path)} + assert namespaced, ( + "schema_1 emitted no discovered rows for the reference tree, so nothing " + "below is being tested. If that is real, the adapter stopped emitting them." + ) + assert all(isinstance(raw[path], DiscoveredMetadata) for path in namespaced) + + +def test_partition_splits_the_map_and_loses_nothing() -> None: + raw = schema_one_metadata_raw() + curated, discovered = partition(raw) + + assert len(curated) + len(discovered) == len(raw) + assert not [path for path in curated if is_discovery_path(path)] + assert {entry.path for entry in discovered} == {path for path in raw if is_discovery_path(path)} + assert [entry.path for entry in discovered] == sorted(entry.path for entry in discovered) + + +def test_a_discovered_row_carries_the_declaration_and_the_retention() -> None: + by_path = {entry.path: entry for entry in schema_one_discovery()} + raw = schema_one_metadata_raw() + for path, entry in by_path.items(): + row = raw[path] + assert entry.datatype == row.datatype + assert entry.unit == row.unit + assert by_path["discovered.distribution-enclosure/status/time-zone"].retained is True + # Both retention states have to appear, or the row is not carrying retention at + # all. `lugs/connection/feeds-device-status` is the unretained one for the same + # reason `circuit/connection/count` was before the capture dropped it: the + # panel declares the property and no producer publishes a value for it. + assert by_path["discovered.lugs/connection/feeds-device-status"].retained is False + + +def test_a_namespaced_row_without_the_enriched_type_still_reports() -> None: + """The namespace is the contract; the row type is the enrichment. + + An adapter distribution built against a later library could namespace a row + and carry a plain `FieldMetadata`. Dropping it would be the worst of both — + absent from the curated inventory *and* absent from the report. + """ + _curated, discovered = partition({_SYNTHETIC_PATH: FieldMetadata(unit="°C", datatype="float")}) + assert len(discovered) == 1 + assert discovered[0].retained is None + assert discovered[0].unit == "°C" + + +# --- discovered paths reach no curated inventory --------------------------- + + +def test_no_curated_inventory_names_a_discovered_path() -> None: + """The three enumerations the conformance gate and the unread gate consult.""" + for path in declared_field_paths(): + assert not is_discovery_path(path) + for path in RESIDUAL_EXEMPT_PATHS: + assert not is_discovery_path(path) + for key in json.loads(BASELINE.read_text(encoding="utf-8")): + assert not is_discovery_path(key), ( + f"{key} is a discovered path in the unread baseline. The baseline is the " + "consumer's own backlog, decided per line; discovery is a report about " + "the panel and nothing may be written into the baseline from it." + ) + + +def test_the_curated_fixture_hands_out_no_discovered_row() -> None: + """Every other test module reads this fixture, so the guarantee lives here.""" + assert not [path for path in schema_one_metadata() if is_discovery_path(path)] + + +def test_the_unread_inventory_is_deaf_to_discovery() -> None: + """The mutation proof, and the one that matters for cost #4. + + `unread` is "we produce this and render nothing from it" — ten known entries + with reasons. A discovered row landing there would bury them under whatever a + firmware release added, and would make the count depend on the panel in front + of the user rather than on this integration's backlog. + """ + raw = schema_one_metadata_raw() + before = evaluate_field_metadata(raw) + after = evaluate_field_metadata({**raw, _SYNTHETIC_PATH: _SYNTHETIC}) + + assert after.unread == before.unread + assert after.unresolved == before.unresolved + assert after.unit_mismatches == before.unit_mismatches + assert _SYNTHETIC_PATH in {entry.path for entry in after.discovered} + assert len(after.discovered) == len(before.discovered) + 1 + + +def test_a_discovered_row_raises_no_unit_mismatch() -> None: + """A wire unit is not a claim about any sensor we declare. + + `°C` matches no sensor's declared unit, so an unpartitioned map would report + it as a mismatch and, downstream, as a Repair the user cannot act on. + """ + findings = evaluate_field_metadata({**schema_one_metadata_raw(), _SYNTHETIC_PATH: _SYNTHETIC}) + assert not [m for m in findings.unit_mismatches if is_discovery_path(m.field_path)] + + +# --- the diagnostics block ------------------------------------------------- + + +def _entry(findings: SchemaFindings | None) -> MockConfigEntry: + snapshot = _snapshot() + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.last_update_success = True + coordinator.schema_findings = findings + entry = MockConfigEntry(domain=DOMAIN, title="SPAN Panel", unique_id="example-40t-001") + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + return entry + + +def _snapshot() -> SpanPanelSnapshot: + from .adapter_fixtures import schema_one_snapshot + + return schema_one_snapshot() + + +async def test_diagnostics_carries_the_discovery_report(hass: HomeAssistant) -> None: + """The block a maintainer reads off an issue attachment.""" + findings = evaluate_field_metadata(schema_one_metadata_raw()) + result = await async_get_config_entry_diagnostics(hass, _entry(findings)) + + block = result["schema_discovery"] + assert block["available"] is True + assert block["count"] == len(findings.discovered) + assert block["count"] > 0 + # Addressed by path rather than by position. The ordering is pinned once, in + # `test_partition_splits_the_map_and_loses_nothing`; re-pinning it here meant + # that a capture dropping any earlier-sorting property failed this test with a + # diff about the wrong row, which is what happened when `connection/count` went + # away, and again when the library began reading the PV's `info/serial-number`. + by_path = {row["path"]: row for row in block["properties"]} + assert by_path["discovered.lugs/connection/feeds-device-status"] == { + "path": "discovered.lugs/connection/feeds-device-status", + "datatype": "enum", + "unit": None, + "retained": False, + } + assert {key for row in block["properties"] for key in row} == { + "path", + "datatype", + "unit", + "retained", + } + + +async def test_diagnostics_says_unavailable_rather_than_empty(hass: HomeAssistant) -> None: + """No metadata yet is a real state on a reconnect, and it is not "nothing to report".""" + block = (await async_get_config_entry_diagnostics(hass, _entry(None)))["schema_discovery"] + assert block == {"available": False, "count": 0, "properties": []} + + +async def test_no_published_value_reaches_the_discovery_block(hass: HomeAssistant) -> None: + """The privacy constraint, checked against the capture's own values. + + Diagnostics leave the house. `TO_REDACT` is key-based over the config entry + and knows nothing about wire property names, so a value added here would be + published verbatim — including the postal code and the time zone, which the + reference panel does publish and this integration deliberately does not + surface. + """ + tree = schema_one_tree() + published = { + value + for topics in tree.values() + for topic, value in topics.items() + if not topic.startswith("$") and value + } + # The declaration vocabulary a row is allowed to be built from: device type + # tails, node ids and property ids all appear inside a `$description`. A + # value that is also one of those cannot be told apart from its declaration, + # and is excluded rather than scanned for. + vocabulary: set[str] = set() + for topics in tree.values(): + for token in json.dumps(json.loads(topics["$description"])).replace('"', " ").split(): + vocabulary.update(token.split(".")) + vocabulary.update(token.split(",")) + vocabulary.add(token) + + findings = evaluate_field_metadata(schema_one_metadata_raw()) + block = (await async_get_config_entry_diagnostics(hass, _entry(findings)))["schema_discovery"] + + # `retained` is scanned by type rather than by content: it is a bool, so it + # has no room for a value, and its JSON rendering collides with the literal + # "false" a boolean property publishes. The three string fields are where a + # value could actually appear, and they are what the scan reads. + strings = [ + text + for row in block["properties"] + for text in (row["path"], row["datatype"], row["unit"]) + if isinstance(text, str) + ] + leaked = sorted( + value for value in published - vocabulary if any(value in text for text in strings) + ) + assert not leaked, f"published values reached the diagnostics discovery block: {leaked}" + for row in block["properties"]: + assert isinstance(row["retained"], bool) + assert row["path"].startswith("discovered.") + + +async def test_the_discovery_block_creates_nothing_a_user_sees(hass: HomeAssistant) -> None: + """Maintainer-facing only: no path here is one an entity reads. + + The line between step 1 and step 3. A discovered path becoming an entity's + source would mean adoption shipped by accident, and adoption is deliberately + not built — its costs (notice aggregation, a denylist, the accumulator + register) are unsettled. + """ + readable = declared_field_paths() | frozenset(RESIDUAL_EXEMPT_PATHS) + for entry in schema_one_discovery(): + assert entry.path not in readable + + +# --- it bites in both directions ------------------------------------------- + + +def _tree_declaring( + node: str, property_id: str, definition: dict[str, str], value: str +) -> dict[str, dict[str, str]]: + tree = schema_one_tree() + description: dict[str, dict[str, dict[str, dict[str, object]]]] = json.loads( + tree[SCHEMA_ONE_PANEL]["$description"] + ) + description["nodes"][node]["properties"][property_id] = dict(definition) + tree[SCHEMA_ONE_PANEL]["$description"] = json.dumps(description) + tree[SCHEMA_ONE_PANEL][f"{node}/{property_id}"] = value + return tree + + +def _discovery_for(tree: dict[str, dict[str, str]]) -> dict[str, dict[str, object]]: + from ebus_sdk.homie import DiscoveredDevice + from span_panel_api_schema_1.field_metadata import build_field_metadata + + devices: list[DiscoveredDevice] = [] + for device_id, topics in tree.items(): + device = DiscoveredDevice(device_id, "ebus") + device.update_description(topics["$description"]) + device.update_state(topics.get("$state", "ready")) + for topic, value in topics.items(): + if topic.startswith("$"): + continue + node, _, prop = topic.partition("/") + if prop: + device.update_property(node, prop, value) + devices.append(device) + findings = evaluate_field_metadata(build_field_metadata(devices)) + return { + entry.path: {"datatype": entry.datatype, "unit": entry.unit, "retained": entry.retained} + for entry in findings.discovered + } + + +def test_a_property_the_panel_adds_shows_up_with_its_declaration() -> None: + """A firmware release that starts declaring something reaches the report. + + This is the whole of what step 1 buys over the fixture-bound gate: no + recapture, no release, and the maintainer sees it on the next attachment. + """ + tree = _tree_declaring( + "status", + "enclosure-temperature", + {"name": "Enclosure temperature", "datatype": "float", "unit": "°C"}, + "41.5", + ) + reported = _discovery_for(tree) + assert reported[_SYNTHETIC_PATH] == {"datatype": "float", "unit": "°C", "retained": True} + assert "41.5" not in json.dumps(reported) + + +def test_a_property_that_becomes_read_leaves_the_report() -> None: + """The acceptance criterion for acting on a discovered row. + + Adding a `_PROPERTY_FIELD_MAP` row in the library is what a maintainer does + next, and the row leaving this report is how they know it landed. Proved by + the property that already has one: `status/cloud-connection` is declared, + mapped, and absent from the report, while `status/postal-code` beside it on + the same node is declared, unmapped, and present. + """ + reported = _discovery_for(schema_one_tree()) + assert "discovered.distribution-enclosure/status/postal-code" in reported + assert "discovered.distribution-enclosure/status/cloud-connection" not in reported + assert "panel.vendor_cloud" in schema_one_metadata() diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py new file mode 100644 index 00000000..04bc1148 --- /dev/null +++ b/tests/test_schema_repairs.py @@ -0,0 +1,951 @@ +from __future__ import annotations + +import logging + +from homeassistant.helpers import issue_registry as ir +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.span_panel.const import DOMAIN, EVENT_SCHEMA_ISSUE +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.schema_repairs import ( + async_clear_schema_issues, + async_sync_schema_issues, +) +from custom_components.span_panel.schema_validation import SchemaFindings, UnitMismatch + +from .factories import pv_binding_for + +_PATH = "circuit.instant_power_w" +_UNIT_PATH = "panel.l1_voltage" + +# Both Repairs claim something the user owns is broken, so nothing is raised for a +# field path no enabled entity reads. Every call below that expects an issue has +# to name the entities the finding took down. +_AFFECTED = {_PATH: ["sensor.a"]} +_UNIT_AFFECTED = {_UNIT_PATH: ["sensor.voltage"]} + + +@pytest.fixture +def entry(hass) -> MockConfigEntry: + """Return a config entry added to hass. No conftest fixture exists for this.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="abc123") + mock.add_to_hass(hass) + return mock + + +def _issue_id(entry: MockConfigEntry, path: str = _PATH) -> str: + return f"unresolved_{entry.entry_id}_{path}" + + +def _unit_issue_id(entry: MockConfigEntry, path: str = _PATH) -> str: + return f"unit_mismatch_{entry.entry_id}_{path}" + + +async def test_unresolved_path_raises_one_issue(hass, entry) -> None: + findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) + async_sync_schema_issues(hass, entry, findings, _AFFECTED) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) + + +async def test_issue_cleared_when_condition_resolves(hass, entry) -> None: + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), _AFFECTED + ) + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), _AFFECTED) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None + + +async def test_dismissal_survives_reconciliation(hass, entry) -> None: + """Re-raise idempotently rather than delete-then-recreate. + + Deleting is the one thing that resets a dismissal, which would turn an + accepted notice into a permanent nag. + """ + findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) + async_sync_schema_issues(hass, entry, findings, _AFFECTED) + + issue_id = _issue_id(entry) + ir.async_ignore_issue(hass, DOMAIN, issue_id, True) + registry = ir.async_get(hass) + dismissed = registry.async_get_issue(DOMAIN, issue_id).dismissed_version + assert dismissed is not None + + for _ in range(3): + async_sync_schema_issues(hass, entry, findings, _AFFECTED) + + assert registry.async_get_issue(DOMAIN, issue_id).dismissed_version == dismissed + + +async def test_dismissal_survives_a_changing_affected_entity_payload(hass, entry) -> None: + """The update branch replaces the placeholders and keeps the dismissal. + + Stronger than the identical-payload case above: `async_get_or_create` skips + the write entirely when nothing changed, so a delete-then-recreate bug could + hide there. Here the placeholders genuinely differ between passes, forcing + the update branch to run. + """ + findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) + async_sync_schema_issues(hass, entry, findings, {_PATH: ["sensor.a"]}) + + issue_id = _issue_id(entry) + ir.async_ignore_issue(hass, DOMAIN, issue_id, True) + registry = ir.async_get(hass) + dismissed = registry.async_get_issue(DOMAIN, issue_id).dismissed_version + + async_sync_schema_issues(hass, entry, findings, {_PATH: ["sensor.a", "sensor.b"]}) + + issue = registry.async_get_issue(DOMAIN, issue_id) + assert issue.dismissed_version == dismissed + assert issue.translation_placeholders["count"] == "2" + + +async def test_distinct_paths_get_distinct_issues(hass, entry) -> None: + """Dismissing one finding must not swallow a later, different one.""" + affected = {"a.one": ["sensor.one"], "b.two": ["sensor.two"]} + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), affected + ) + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), affected + ) + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry, "b.two")) + + +async def test_a_dismissed_finding_does_not_swallow_a_later_one(hass, entry) -> None: + """The reason one issue per (class, path) is not cosmetic. + + Dismissing an aggregate would silence every finding that joined it later, + because the update branch preserves `dismissed_version`. + """ + affected = {"a.one": ["sensor.one"], "b.two": ["sensor.two"]} + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), affected + ) + ir.async_ignore_issue(hass, DOMAIN, _issue_id(entry, "a.one"), True) + + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), affected + ) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry, "a.one")).dismissed_version + assert registry.async_get_issue(DOMAIN, _issue_id(entry, "b.two")).dismissed_version is None + + +async def test_one_entry_does_not_clear_another(hass) -> None: + """Two panels must not delete each other's issues on every reconcile.""" + sick = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sick") + sick.add_to_hass(hass) + well = MockConfigEntry(domain=DOMAIN, data={}, unique_id="well") + well.add_to_hass(hass) + + async_sync_schema_issues( + hass, sick, SchemaFindings(frozenset({_PATH}), (), frozenset()), _AFFECTED + ) + async_sync_schema_issues(hass, well, SchemaFindings(frozenset(), (), frozenset()), _AFFECTED) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(sick)) + + +async def test_circuit_rename_and_commissioning_raise_no_issue(hass, entry) -> None: + """Tier-1 and Tier-2 changes are handled elsewhere and must stay silent. + + Silent because of what they are, not because nothing reads them: the map + names a live entity for the field, and it still raises nothing. + """ + async_sync_schema_issues( + hass, + entry, + SchemaFindings(frozenset(), (), frozenset({"pv.model"})), + {"pv.model": ["sensor.pv_model"]}, + ) + registry = ir.async_get(hass) + assert not [k for k in registry.issues if k[0] == DOMAIN] + + +async def test_unit_mismatch_raises_its_own_issue(hass, entry) -> None: + """The second of the two user-facing defects: a reading may be wrong.""" + mismatch = UnitMismatch(_UNIT_PATH, "V", "kV") + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), _UNIT_AFFECTED + ) + + registry = ir.async_get(hass) + issue = registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) + assert issue is not None + assert issue.translation_key == "schema_unit_mismatch" + assert issue.translation_placeholders == { + "field_path": _UNIT_PATH, + "ha_unit": "V", + "schema_unit": "kV", + "count": "1", + "examples": "sensor.voltage", + } + + +async def test_unit_mismatch_issue_is_cleared_on_its_own(hass, entry) -> None: + """Reconciliation must scope both classes, not just the unresolved one.""" + mismatch = UnitMismatch(_UNIT_PATH, "V", "kV") + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), _UNIT_AFFECTED + ) + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset(), (), frozenset()), _UNIT_AFFECTED + ) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) is None + + +async def test_issues_are_not_persistent(hass, entry) -> None: + """Derived from live state, so they must be re-asserted at startup. + + A non-persistent issue reloads as a tombstone carrying only the dismissal, + which is exactly what lets re-assertion happen without resurrecting one. + """ + mismatch = UnitMismatch(_UNIT_PATH, "V", "kV") + async_sync_schema_issues( + hass, + entry, + SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()), + _AFFECTED | _UNIT_AFFECTED, + ) + + registry = ir.async_get(hass) + for issue_id in (_issue_id(entry), _unit_issue_id(entry, _UNIT_PATH)): + issue = registry.async_get_issue(DOMAIN, issue_id) + assert issue.is_persistent is False + assert issue.is_fixable is False + assert issue.severity is ir.IssueSeverity.WARNING + + +async def test_affected_entities_are_bounded_and_counted(hass, entry) -> None: + """One missing `circuit.instant_power_w` affects every circuit. + + The payload carries the full count but only a few examples, so a 40-circuit + panel does not render a wall of entity ids. + """ + affected = [f"sensor.circuit_{n}_power" for n in range(40)] + async_sync_schema_issues( + hass, + entry, + SchemaFindings(frozenset({_PATH}), (), frozenset()), + {_PATH: affected}, + ) + + registry = ir.async_get(hass) + placeholders = registry.async_get_issue(DOMAIN, _issue_id(entry)).translation_placeholders + assert placeholders["count"] == "40" + assert placeholders["examples"].count(",") < 5 + assert "sensor.circuit_0_power" in placeholders["examples"] + + +# --- Findings nobody owns ------------------------------------------------- +# +# `vendor_cloud` is `entity_registry_enabled_default=False`, so it is registered +# and never added to hass. A fresh install against the flat simulator raised +# "`panel.vendor_cloud` ... 0 entity/entities are affected (for example: none)" +# beside two genuine notices, which is how a category of Repair gets ignored. + + +async def test_a_finding_no_enabled_entity_reads_raises_no_issue(hass, entry) -> None: + """The disabled-by-default case: nothing the user owns is affected.""" + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"panel.vendor_cloud"}), (), frozenset()), {} + ) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry, "panel.vendor_cloud")) is None + assert not [k for k in registry.issues if k[0] == DOMAIN] + + +async def test_a_unit_mismatch_no_enabled_entity_reads_raises_no_issue(hass, entry) -> None: + """The same rule for the second class: no reading of the user's is wrong.""" + mismatch = UnitMismatch(_UNIT_PATH, "V", "kV") + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), {}) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) is None + assert not [k for k in registry.issues if k[0] == DOMAIN] + + +async def test_suppression_only_silences_the_path_nobody_reads(hass, entry) -> None: + """The real install: two genuine notices, one suppressed, in one pass.""" + async_sync_schema_issues( + hass, + entry, + SchemaFindings(frozenset({_PATH, "panel.vendor_cloud"}), (), frozenset()), + _AFFECTED, + ) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) + assert registry.async_get_issue(DOMAIN, _issue_id(entry, "panel.vendor_cloud")) is None + + +async def test_a_suppressed_finding_is_logged(hass, entry, caplog) -> None: + """Suppressed is not discarded: the field path stays reachable in the log.""" + with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel.schema_repairs"): + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"panel.vendor_cloud"}), (), frozenset()), {} + ) + + assert "panel.vendor_cloud" in caplog.text + assert "no enabled entity reads" in caplog.text + + +async def test_an_issue_is_deleted_when_its_last_affected_entity_goes(hass, entry) -> None: + """The transition the reconcile pass has to cover. + + A path raised while entities read it, then disabled or removed, must have its + issue deleted rather than left orphaned at "0 affected". + """ + findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) + async_sync_schema_issues(hass, entry, findings, _AFFECTED) + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) + + async_sync_schema_issues(hass, entry, findings, {}) + + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None + + +async def test_a_unit_mismatch_issue_is_deleted_when_its_entities_go(hass, entry) -> None: + """The same transition for the second class.""" + findings = SchemaFindings(frozenset(), (UnitMismatch(_UNIT_PATH, "V", "kV"),), frozenset()) + async_sync_schema_issues(hass, entry, findings, _UNIT_AFFECTED) + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) + + async_sync_schema_issues(hass, entry, findings, {}) + + assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) is None + + +async def test_a_dismissal_survives_the_entity_leaving_and_returning(hass, entry) -> None: + """Suppression deletes, and a delete is the one thing that clears a dismissal. + + That is the accepted cost of not nagging about a finding nobody owns: the + notice is genuinely new when an entity starts reading the field again. + """ + findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) + async_sync_schema_issues(hass, entry, findings, _AFFECTED) + ir.async_ignore_issue(hass, DOMAIN, _issue_id(entry), True) + + async_sync_schema_issues(hass, entry, findings, {}) + async_sync_schema_issues(hass, entry, findings, _AFFECTED) + + issue = ir.async_get(hass).async_get_issue(DOMAIN, _issue_id(entry)) + assert issue is not None + assert issue.dismissed_version is None + + +async def test_findings_fire_an_event(hass, entry) -> None: + """Matches the `span_panel_current_alert` pattern so automations can react.""" + events = [] + hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append) + + mismatch = UnitMismatch(_UNIT_PATH, "V", "kV") + async_sync_schema_issues( + hass, + entry, + SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()), + _AFFECTED | _UNIT_AFFECTED, + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data == { + "entry_id": entry.entry_id, + "unresolved": [_PATH], + "unit_mismatches": [_UNIT_PATH], + } + + +async def test_the_event_carries_only_what_the_user_was_told(hass, entry) -> None: + """A suppressed finding is not user-facing, so it is not in the event either. + + Otherwise an automation would react to a defect that took nothing down, and + an all-suppressed pass — which fires nothing at all — would disagree with a + partly-suppressed one. + """ + events = [] + hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append) + + async_sync_schema_issues( + hass, + entry, + SchemaFindings(frozenset({_PATH, "panel.vendor_cloud"}), (), frozenset()), + _AFFECTED, + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data["unresolved"] == [_PATH] + + +async def test_an_all_suppressed_pass_fires_no_event(hass, entry) -> None: + events = [] + hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append) + + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({"panel.vendor_cloud"}), (), frozenset()), {} + ) + await hass.async_block_till_done() + + assert events == [] + + +async def test_a_healthy_pass_fires_no_event(hass, entry) -> None: + events = [] + hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append) + + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset(), (), frozenset({"pv.model"})), {} + ) + await hass.async_block_till_done() + + assert events == [] + + +async def test_clearing_removes_only_this_entry(hass) -> None: + """`async_remove_entry` must not take a second panel's issues with it.""" + removed = MockConfigEntry(domain=DOMAIN, data={}, unique_id="removed") + removed.add_to_hass(hass) + kept = MockConfigEntry(domain=DOMAIN, data={}, unique_id="kept") + kept.add_to_hass(hass) + + findings = SchemaFindings(frozenset({_PATH}), (), frozenset()) + async_sync_schema_issues(hass, removed, findings, _AFFECTED) + async_sync_schema_issues(hass, kept, findings, _AFFECTED) + + async_clear_schema_issues(hass, removed) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(removed)) is None + assert registry.async_get_issue(DOMAIN, _issue_id(kept)) + + +async def test_reconciliation_leaves_our_other_issues_alone(hass, entry) -> None: + """The reconcile pass deletes every id it did not re-derive, within its own scope. + + Anything else this integration raises under the same domain -- a one-shot + notice, a condition derived somewhere other than this pass -- has to survive + it, or the pass silently owns issues it has no way to recreate. + """ + ir.async_create_issue( + hass, + DOMAIN, + f"something_else_{entry.entry_id}", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="something_else", + ) + + async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {}) + + assert ir.async_get(hass).async_get_issue(DOMAIN, f"something_else_{entry.entry_id}") + + +async def test_remove_entry_clears_this_entry_issues(hass, entry) -> None: + """Core does not delete our issues when the entry is removed.""" + from custom_components.span_panel import async_remove_entry + + async_sync_schema_issues( + hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), _AFFECTED + ) + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) + + await async_remove_entry(hass, entry) + + assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None + + +# --- The affected-entity map, built from real entities --------------------- +# +# The map is populated by the entities themselves rather than reverse-engineered +# from entity descriptions. Reverse-engineering was wrong three ways at once: +# panel-data sensors build their unique_id from `get_panel_entity_suffix`, whose +# `PANEL_ENTITY_SUFFIX_MAPPING` deliberately disagrees with the general mapping +# (`instantGridPowerW` -> "current_power", not "grid_power"); binary sensors use +# the raw camelCase key ("doorState"), which a lowercasing suffix helper can +# never match; and an `endswith("power")` test claims every power entity on the +# panel. Self-registration cannot drift from the builders because it never +# consults them. + +_STYLE_PATHS = { + # circuit style — `get_user_friendly_suffix`, and the over-match case + "circuit.instant_power_w", + # panel-data style — `get_panel_entity_suffix`, which disagrees + "panel.instant_grid_power_w", + "panel.power_flow_battery", + "panel.power_flow_pv", + "panel.power_flow_site", + # binary-sensor style — the raw camelCase description key + "panel.door_state", +} + + +async def _entities_by_declared_path(hass): + """Build the real entities for a healthy panel, grouped by declared field. + + Real platform setup, real entity classes, real unique_id builders — the + three id styles only differ because the builders differ, so anything less + faithful would not exercise the bug this replaced. + """ + from unittest.mock import AsyncMock, MagicMock + + from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered + from custom_components.span_panel.binary_sensor import ( + async_setup_entry as binary_setup, + ) + from custom_components.span_panel.coordinator import SpanPanelCoordinator + from custom_components.span_panel.field_paths import FieldPathDeclarationMixin + from custom_components.span_panel.sensor import async_setup_entry as sensor_setup + + from .factories import ( + SpanBatterySnapshotFactory, + SpanCircuitSnapshotFactory, + SpanPanelSnapshotFactory, + ) + + # Two circuits and the three power flows, so the over-match case has real + # panel power sensors to be wrongly claimed by. + snapshot = SpanPanelSnapshotFactory.create( + circuits={ + "1": SpanCircuitSnapshotFactory.create(circuit_id="1", name="Kitchen"), + "2": SpanCircuitSnapshotFactory.create(circuit_id="2", name="Garage"), + }, + battery=SpanBatterySnapshotFactory.create(soe_percentage=85.0, connected=True), + power_flow_battery=-250.0, + power_flow_pv=1250.0, + power_flow_site=3000.0, + ) + config_entry = MockConfigEntry( + domain=DOMAIN, data={}, title="SPAN Panel", unique_id=snapshot.serial_number + ) + config_entry.add_to_hass(hass) + client = MagicMock() + client.stop_streaming = AsyncMock() + client.close = AsyncMock() + coordinator = SpanPanelCoordinator(hass, client, config_entry) + coordinator.data = snapshot + # A real panel device: the BESS sub-device declares `via_device`, and HA + # refuses to add an entity whose via_device is not a registered device id. + config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id=await ensure_device_registered(hass, config_entry, snapshot, "SPAN Panel"), + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + + grouped: dict[str, dict[str, list[object]]] = {} + for platform_domain, setup in (("sensor", sensor_setup), ("binary_sensor", binary_setup)): + added = MagicMock() + await setup(hass, config_entry, added) + for entity in added.call_args.args[0]: + description = getattr(entity, "entity_description", None) + if not isinstance(description, FieldPathDeclarationMixin): + continue + if description.derived or description.field_path not in _STYLE_PATHS: + continue + grouped.setdefault(description.field_path, {}).setdefault(platform_domain, []).append( + entity + ) + + return coordinator, config_entry, grouped + + +async def _add_to_platform(hass, config_entry, entities, platform_domain: str) -> None: + """Add real entities to a real entity platform, as HA does at setup.""" + from pytest_homeassistant_custom_component.common import MockEntityPlatform + + platform = MockEntityPlatform(hass, domain=platform_domain, platform_name=DOMAIN) + platform.config_entry = config_entry + await platform.async_add_entities(entities) + await hass.async_block_till_done() + + +async def _stop_scheduling(coordinator) -> None: + """Cancel the coordinator's refresh timer and debouncer. + + `SpanPanelCoordinator.async_shutdown` releases the client but does not chain + to the base implementation; in production the timer is unscheduled when the + last entity listener goes away, which these tests deliberately do not do. + """ + from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + await DataUpdateCoordinator.async_shutdown(coordinator) + + +async def test_affected_entities_span_all_three_unique_id_styles(hass) -> None: + """A dead field must name the entities that actually died — every style. + + Three unique_id builders are in play and they do not agree, so any scheme + that re-derives entity ids from entity descriptions gets at least two of the + three wrong: it reports "0 affected" for a panel field whose sensor is dead, + and over-claims for a circuit field. + """ + coordinator, config_entry, grouped = await _entities_by_declared_path(hass) + try: + assert _STYLE_PATHS <= grouped.keys(), f"fixture missed {_STYLE_PATHS - grouped.keys()}" + + # The fixture really does cover three different builders: circuit suffix, + # panel entity suffix, raw camelCase key. + def _first(path: str, platform_domain: str): + return grouped[path][platform_domain][0] + + assert _first("circuit.instant_power_w", "sensor").unique_id.endswith("_power") + assert _first("panel.instant_grid_power_w", "sensor").unique_id.endswith("_current_power") + assert _first("panel.door_state", "binary_sensor").unique_id.endswith("doorState") + + # One platform per domain, as HA does — several platforms sharing a + # domain and platform name is not a shape the real integration produces. + for platform_domain in ("sensor", "binary_sensor"): + batch = [ + entity + for by_domain in grouped.values() + for entity in by_domain.get(platform_domain, []) + ] + await _add_to_platform(hass, config_entry, batch, platform_domain) + + affected = coordinator.entity_ids_by_field_path + + for path, by_domain in grouped.items(): + expected = sorted( + entity.entity_id for entities in by_domain.values() for entity in entities + ) + assert affected[path] == expected, path + assert all(expected), f"{path} recorded an entity with no entity_id" + + # The over-match case: a dead circuit power field must claim only circuit + # power entities, never the panel's own power sensors. + circuit_power = set(affected["circuit.instant_power_w"]) + assert circuit_power + for other in _STYLE_PATHS - {"circuit.instant_power_w"}: + assert affected[other] + assert set(affected[other]).isdisjoint(circuit_power), ( + f"{other} entities were claimed by circuit.instant_power_w" + ) + finally: + await _stop_scheduling(coordinator) + + +async def test_the_repair_payload_names_the_real_entities(hass) -> None: + """End to end: the notice a user reads carries real, resolvable entity ids.""" + coordinator, config_entry, grouped = await _entities_by_declared_path(hass) + try: + entities = grouped["panel.instant_grid_power_w"]["sensor"] + await _add_to_platform(hass, config_entry, entities, "sensor") + + async_sync_schema_issues( + hass, + config_entry, + SchemaFindings(frozenset({"panel.instant_grid_power_w"}), (), frozenset()), + coordinator.entity_ids_by_field_path, + ) + + issue = ir.async_get(hass).async_get_issue( + DOMAIN, f"unresolved_{config_entry.entry_id}_panel.instant_grid_power_w" + ) + assert issue is not None + placeholders = issue.translation_placeholders + assert placeholders["count"] == str(len(entities)) + assert placeholders["examples"] == entities[0].entity_id + assert hass.states.get(entities[0].entity_id) is not None + finally: + await _stop_scheduling(coordinator) + + +async def test_removing_an_entity_drops_it_from_the_map(hass) -> None: + """A removed entity must stop inflating the count.""" + coordinator, config_entry, grouped = await _entities_by_declared_path(hass) + try: + entities = grouped["panel.instant_grid_power_w"]["sensor"] + await _add_to_platform(hass, config_entry, entities, "sensor") + assert coordinator.entity_ids_by_field_path["panel.instant_grid_power_w"] + + for entity in entities: + await entity.async_remove() + await hass.async_block_till_done() + + assert "panel.instant_grid_power_w" not in coordinator.entity_ids_by_field_path + finally: + await _stop_scheduling(coordinator) + + +async def test_entities_with_no_source_field_are_not_tracked(hass) -> None: + """An entity must not be blamed for one of the fields it combines. + + A `MULTIPLE_FIELDS` or `NO_SOURCE_FIELD` description computes from several + fields or none, so no single field's loss can be said to have taken it + down. It names no `field_path` and carries no residual reads, and must + therefore land in no bucket at all. + """ + from unittest.mock import MagicMock + + from custom_components.span_panel.binary_sensor import async_setup_entry as binary_setup + from custom_components.span_panel.const import PANEL_STATUS + from custom_components.span_panel.field_paths import DerivedReason + + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: + added = MagicMock() + await binary_setup(hass, config_entry, added) + sourceless = [ + entity + for entity in added.call_args.args[0] + if getattr(getattr(entity, "entity_description", None), "key", None) == PANEL_STATUS + ] + assert sourceless + assert sourceless[0].entity_description.derived is DerivedReason.NO_SOURCE_FIELD + assert sourceless[0].entity_description.field_path is None + + await _add_to_platform(hass, config_entry, sourceless, "binary_sensor") + + assert coordinator.entity_ids_by_field_path == {} + finally: + await _stop_scheduling(coordinator) + + +async def test_schema_conditional_entities_are_tracked(hass) -> None: + """A schema-conditional entity must be nameable by its field's Repair. + + `bess_connected` reads exactly one field. That the *other* adapter does not + publish it is why the description is `derived`, and is no reason for the + Repair against a dead `battery.connected` to report "0 entities affected" -- + the suppression rule then throws the finding away entirely, so the user is + told nothing at all about a sensor that has gone to a default. + """ + from unittest.mock import MagicMock + + from custom_components.span_panel.binary_sensor import ( + BESS_CONNECTED_SENSOR, + async_setup_entry as binary_setup, + ) + + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: + assert BESS_CONNECTED_SENSOR.field_path == "battery.connected" + + added = MagicMock() + await binary_setup(hass, config_entry, added) + conditional = [ + entity + for entity in added.call_args.args[0] + if getattr(entity, "entity_description", None) is BESS_CONNECTED_SENSOR + ] + assert conditional + + await _add_to_platform(hass, config_entry, conditional, "binary_sensor") + + assert coordinator.entity_ids_by_field_path["battery.connected"] + finally: + await _stop_scheduling(coordinator) + + +async def test_a_platform_with_no_description_still_registers_its_residuals( + hass, +) -> None: + """The circuit switch carries no entity description at all. + + It is tracked purely through `_residual_field_paths`, which is the whole + reason that hook exists. + """ + from unittest.mock import MagicMock + + from custom_components.span_panel.switch import async_setup_entry as switch_setup + + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: + added = MagicMock() + await switch_setup(hass, config_entry, added) + switches = list(added.call_args.args[0]) + assert switches + assert not hasattr(switches[0], "entity_description") + + await _add_to_platform(hass, config_entry, switches, "switch") + + expected = sorted(s.entity_id for s in switches) + assert coordinator.entity_ids_by_field_path == { + "circuit.relay_state": expected, + "circuit.name": expected, + "circuit.tabs": expected, + } + finally: + await _stop_scheduling(coordinator) + + +# --- Residual reads ------------------------------------------------------- +# +# Five field paths are read from entity code rather than from a description's +# `field_path`: the switch's relay state, the select's priority, and the name, +# tabs and relay requester a circuit entity uses for its identity and its +# attributes. Each is declared on the entity that reads it, which is where +# `field_paths.residual_field_paths()` collects them from for the producible +# gate. Nothing declared them on the entities, so a dead `circuit.relay_state` +# reported "0 entity/entities are affected" while every breaker switch on the +# panel was out. + + +async def test_a_dead_relay_state_names_the_breaker_switches(hass) -> None: + """The residual set must not reproduce the "0 affected" lie.""" + from unittest.mock import MagicMock + + from custom_components.span_panel.switch import async_setup_entry as switch_setup + + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: + added = MagicMock() + await switch_setup(hass, config_entry, added) + switches = list(added.call_args.args[0]) + assert len(switches) == 2, "fixture should build one switch per circuit" + + await _add_to_platform(hass, config_entry, switches, "switch") + + affected = coordinator.entity_ids_by_field_path + assert affected["circuit.relay_state"] == sorted(s.entity_id for s in switches) + + async_sync_schema_issues( + hass, + config_entry, + SchemaFindings(frozenset({"circuit.relay_state"}), (), frozenset()), + affected, + ) + issue = ir.async_get(hass).async_get_issue( + DOMAIN, f"unresolved_{config_entry.entry_id}_circuit.relay_state" + ) + assert issue is not None + assert issue.translation_placeholders["count"] == "2" + assert issue.translation_placeholders["examples"] != "none" + finally: + await _stop_scheduling(coordinator) + + +async def test_a_dead_circuit_attribute_names_the_power_sensors(hass) -> None: + """The circuit power sensor's residual reads must name it, one by one. + + Every other residual is claimed by more than one entity class, so dropping + it from any single class leaves the read enumerated somewhere and the panel + still describable. `circuit.relay_requester` is claimed by this class alone: + it is republished as a state attribute here and nowhere else, so if this + entity stopped declaring it, the path would leave + `field_paths.residual_field_paths()` entirely and the producible gate would + quietly stop covering a read that is still happening. + + Stated as the Repair's own output rather than as a second copy of the + declaration, so it fails on the observable consequence -- a dead attribute + naming no entity -- instead of on a list disagreeing with a list. + """ + from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor + + coordinator, config_entry, grouped = await _entities_by_declared_path(hass) + try: + power_sensors = [ + entity + for entity in grouped["circuit.instant_power_w"]["sensor"] + if isinstance(entity, SpanCircuitPowerSensor) + ] + assert len(power_sensors) == 2, "fixture should build one power sensor per circuit" + + await _add_to_platform(hass, config_entry, power_sensors, "sensor") + + expected = sorted(sensor.entity_id for sensor in power_sensors) + assert coordinator.entity_ids_by_field_path == { + # The description's own declaration. + "circuit.instant_power_w": expected, + # Identity, read outside any value_fn. + "circuit.name": expected, + "circuit.tabs": expected, + # Republished as state attributes. + "circuit.relay_state": expected, + "circuit.relay_requester": expected, + "circuit.priority": expected, + } + finally: + await _stop_scheduling(coordinator) + + +async def test_a_dead_priority_names_the_selects(hass) -> None: + """The select's own state comes from `circuit.priority`.""" + from unittest.mock import MagicMock + + from custom_components.span_panel.select import async_setup_entry as select_setup + + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: + added = MagicMock() + await select_setup(hass, config_entry, added) + selects = list(added.call_args.args[0]) + assert selects + + await _add_to_platform(hass, config_entry, selects, "select") + + affected = coordinator.entity_ids_by_field_path + assert affected["circuit.priority"] == sorted(s.entity_id for s in selects) + finally: + await _stop_scheduling(coordinator) + + +async def test_the_wifi_link_sensor_registers_the_ssid_it_reads(hass) -> None: + """The Wi-Fi Link is the only entity a dead `panel.wifi_ssid` should name. + + The read moved off the Software Version sensor, and the declaration moved + with it. That leaves the SSID as this integration's one residual declared on + a *binary sensor* -- every other residual test here covers a switch, a select + or a circuit sensor -- so nothing but this holds the binary-sensor route into + the Repair's affected-entity map. + """ + import dataclasses + from unittest.mock import MagicMock + + from custom_components.span_panel.binary_sensor import ( + async_setup_entry as binary_setup, + ) + from custom_components.span_panel.const import SYSTEM_WIFI_LINK + from custom_components.span_panel.sensor import async_setup_entry as sensor_setup + + coordinator, config_entry, _ = await _entities_by_declared_path(hass) + try: + coordinator.data = dataclasses.replace(coordinator.data, wifi_ssid="synthetic-network") + + added_binary = MagicMock() + await binary_setup(hass, config_entry, added_binary) + wifi_link = [ + entity + for entity in added_binary.call_args.args[0] + if getattr(entity.entity_description, "key", None) == SYSTEM_WIFI_LINK + ] + assert len(wifi_link) == 1 + + added_sensors = MagicMock() + await sensor_setup(hass, config_entry, added_sensors) + software_version = [ + entity + for entity in added_sensors.call_args.args[0] + if getattr(entity.entity_description, "key", None) == "software_version" + ] + assert len(software_version) == 1 + + await _add_to_platform(hass, config_entry, wifi_link, "binary_sensor") + await _add_to_platform(hass, config_entry, software_version, "sensor") + + named = coordinator.entity_ids_by_field_path["panel.wifi_ssid"] + + assert named == [wifi_link[0].entity_id] + # The sensor that used to read it must not still be claiming it: a stale + # declaration would name an entity a dead SSID no longer affects. + assert software_version[0].entity_id not in named + assert wifi_link[0].extra_state_attributes == {"wifi_ssid": "synthetic-network"} + assert "wifi_ssid" not in (software_version[0].extra_state_attributes or {}) + finally: + await _stop_scheduling(coordinator) diff --git a/tests/test_schema_upgrade_notice.py b/tests/test_schema_upgrade_notice.py new file mode 100644 index 00000000..30785cb2 --- /dev/null +++ b/tests/test_schema_upgrade_notice.py @@ -0,0 +1,169 @@ +"""What the user is told when their panel's firmware changes data model. + +This used to be two things at once: a hardcoded English notification about the +reload, and a translated Repair about the consequences. Neither was covered, so +the pairing survived until a screenshot showed it -- an upgrade that took nothing +away, filed under Warning, offering to be ignored. + +The tests below pin the three properties that fixes it: one message, it is a +notification, and it outlives the restart that was the Repair's only advantage. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any, cast +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.util import dt as dt_util +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry, async_fire_time_changed +from span_panel_api import SpanMqttClient + +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.coordinator import SpanPanelCoordinator +from custom_components.span_panel.notices import _DATA, async_restore +from custom_components.span_panel.schema_repairs import async_clear_retired_upgrade_notice + +_RETIRED_ISSUE = "panel_upgraded_to_ebus_v1" + + +@pytest.fixture +def entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a config entry in hass.""" + mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001", title="SPAN Panel") + mock.add_to_hass(hass) + return mock + + +@pytest.fixture +async def coordinator(hass: HomeAssistant, entry: MockConfigEntry) -> SpanPanelCoordinator: + """Return a coordinator whose notices are tracked, as setup would leave it.""" + await async_restore(hass, entry) + return SpanPanelCoordinator(hass, cast(SpanMqttClient, MagicMock()), entry) + + +def _notice(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, Any] | None: + standing = hass.data.get("persistent_notification", {}) + return standing.get(f"{DOMAIN}_panel_upgraded_{entry.entry_id}") + + +async def test_the_upgrade_is_a_notification( + hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator +) -> None: + await coordinator._explain_the_upgrade(None, "1.0") + + notice = _notice(hass, entry) + assert notice is not None + assert "flat" in notice["message"] and "1.0" in notice["message"] + + +async def test_the_upgrade_is_not_a_repair( + hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator +) -> None: + """Nothing is broken, so nothing belongs in the list of things that are.""" + await coordinator._explain_the_upgrade(None, "1.0") + + assert not [issue_id for (domain, issue_id) in ir.async_get(hass).issues if domain == DOMAIN] + + +async def test_the_upgrade_is_told_in_one_message_not_two( + hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator +) -> None: + """The reload and its consequences are one event and read as one. + + Two rows for one event is what teaches somebody to skim past both. + """ + await coordinator._explain_the_upgrade(None, "1.0") + + ours = [key for key in hass.data.get("persistent_notification", {}) if DOMAIN in key] + assert len(ours) == 1 + + +async def test_the_message_says_what_changed_and_what_did_not( + hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator +) -> None: + """A firmware upgrade invites the assumption that something was lost.""" + await coordinator._explain_the_upgrade(None, "1.0") + + message = _notice(hass, entry)["message"] # type: ignore[index] + assert "DSM Grid State" in message + assert "Grid Islandable" in message + assert "Microgrid Interconnect Device" in message + assert "has gone away" in message + + +async def test_the_notice_survives_a_restart( + hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator +) -> None: + """Durability was the Repair's one real advantage, and it has to be kept. + + A panel upgrades on its own schedule. Somebody away for the weekend must + still find out that a device appeared and why a sensor changed provenance. + """ + await coordinator._explain_the_upgrade(None, "1.0") + + # Writes are delayed so a burst of them collapses into one; Home Assistant + # flushes at shutdown, and a restart that skipped it would test nothing. + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + hass.data[_DATA].pop(entry.entry_id) + hass.data["persistent_notification"].clear() + await async_restore(hass, entry) + + assert _notice(hass, entry) is not None + + +async def test_a_downgrade_says_nothing( + hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator +) -> None: + """Panel firmware does not roll back; the upgrade rehearsal swaps simulators. + + Announcing a retirement there would be noise about a transition no user has. + """ + await coordinator._explain_the_upgrade("1.0", None) + + assert _notice(hass, entry) is None + + +async def test_the_retired_repair_is_cleared_from_an_upgraded_install( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """It is on screen now, and its owner has no reason to restart to be rid of it.""" + ir.async_create_issue( + hass, + DOMAIN, + f"{_RETIRED_ISSUE}_{entry.entry_id}", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=_RETIRED_ISSUE, + ) + + async_clear_retired_upgrade_notice(hass, entry) + + assert ir.async_get(hass).async_get_issue(DOMAIN, f"{_RETIRED_ISSUE}_{entry.entry_id}") is None + + +async def test_clearing_the_retired_repair_leaves_another_panels_alone( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Two panels share the domain, and one upgrading is not both.""" + other = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-002") + other.add_to_hass(hass) + for target in (entry, other): + ir.async_create_issue( + hass, + DOMAIN, + f"{_RETIRED_ISSUE}_{target.entry_id}", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=_RETIRED_ISSUE, + ) + + async_clear_retired_upgrade_notice(hass, entry) + + registry = ir.async_get(hass) + assert registry.async_get_issue(DOMAIN, f"{_RETIRED_ISSUE}_{entry.entry_id}") is None + assert registry.async_get_issue(DOMAIN, f"{_RETIRED_ISSUE}_{other.entry_id}") diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py index 9bd2ade3..1bfab80b 100644 --- a/tests/test_schema_validation.py +++ b/tests/test_schema_validation.py @@ -1,269 +1,356 @@ -"""Tests for schema validation and sensor-to-field mapping.""" - from __future__ import annotations -import logging -from unittest.mock import MagicMock +from collections.abc import Callable import pytest -from span_panel_api import ( - SpanBatterySnapshot, - SpanCircuitSnapshot, - SpanEvseSnapshot, - SpanPanelSnapshot, - SpanPVSnapshot, -) - -from custom_components.span_panel.schema_expectations import ( - SENSOR_FIELD_MAP, - all_referenced_field_paths, +from span_panel_api.models import FieldMetadata + +from custom_components.span_panel import sensor_definitions +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + DerivedReason, + FieldPathDeclarationMixin, + Producibility, + conditional_field_paths, + declared_field_paths, ) from custom_components.span_panel.schema_validation import ( - validate_field_metadata, + SchemaFindings, + evaluate_field_metadata, ) from custom_components.span_panel.sensor_definitions import ( - BATTERY_POWER_SENSOR, - BATTERY_SENSOR, - BESS_METADATA_SENSORS, - CIRCUIT_BREAKER_RATING_SENSOR, - CIRCUIT_CURRENT_SENSOR, - CIRCUIT_SENSORS, - DOWNSTREAM_L1_CURRENT_SENSOR, - DOWNSTREAM_L2_CURRENT_SENSOR, - EVSE_SENSORS, - GRID_POWER_FLOW_SENSOR, - L1_VOLTAGE_SENSOR, - L2_VOLTAGE_SENSOR, - MAIN_BREAKER_RATING_SENSOR, - PANEL_DATA_STATUS_SENSORS, - PANEL_ENERGY_SENSORS, - PANEL_POWER_SENSORS, - PV_METADATA_SENSORS, - PV_POWER_SENSOR, - SITE_POWER_SENSOR, - STATUS_SENSORS, - UNMAPPED_SENSORS, - UPSTREAM_L1_CURRENT_SENSOR, - UPSTREAM_L2_CURRENT_SENSOR, + all_sensor_descriptions, + sensor_descriptions_by_field_path, ) +from tests.adapter_fixtures import ( + schema_one_metadata, + schema_one_metadata_batteryless, + schema_zero_metadata, +) + +MetadataFn = Callable[[], dict[str, FieldMetadata]] + + +def test_unresolved_entry_is_degradation() -> None: + findings = evaluate_field_metadata( + {"circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False)}, + sensor_defs={}, + ) + assert "circuit.instant_power_w" in findings.unresolved -_LOGGER_NAME = "custom_components.span_panel.schema_validation" - - -# --------------------------------------------------------------------------- -# Sensor field mapping tests -# --------------------------------------------------------------------------- - - -class TestSensorFieldMap: - """Tests for the sensor-to-snapshot-field mapping.""" - - def test_no_empty_keys_or_paths(self) -> None: - """Every entry must have non-empty sensor key and field path.""" - for sensor_key, field_path in SENSOR_FIELD_MAP.items(): - assert sensor_key, "Empty sensor key in SENSOR_FIELD_MAP" - assert field_path, f"Empty field path for sensor key '{sensor_key}'" - - def test_field_paths_follow_convention(self) -> None: - """All field paths must be {snapshot_type}.{field_name}.""" - valid_prefixes = {"panel", "circuit", "battery", "pv", "evse"} - for sensor_key, field_path in SENSOR_FIELD_MAP.items(): - parts = field_path.split(".", 1) - assert len(parts) == 2, ( - f"Field path '{field_path}' for sensor '{sensor_key}' " - f"does not follow 'type.field' convention" - ) - assert parts[0] in valid_prefixes, ( - f"Field path '{field_path}' for sensor '{sensor_key}' " - f"has unknown prefix '{parts[0]}'" - ) - - def test_sensor_keys_exist_in_definitions(self) -> None: - """Every sensor key should match a real sensor definition.""" - all_defs = [ - *PANEL_DATA_STATUS_SENSORS, - *STATUS_SENSORS, - *UNMAPPED_SENSORS, - BATTERY_SENSOR, - L1_VOLTAGE_SENSOR, - L2_VOLTAGE_SENSOR, - UPSTREAM_L1_CURRENT_SENSOR, - UPSTREAM_L2_CURRENT_SENSOR, - DOWNSTREAM_L1_CURRENT_SENSOR, - DOWNSTREAM_L2_CURRENT_SENSOR, - MAIN_BREAKER_RATING_SENSOR, - CIRCUIT_CURRENT_SENSOR, - CIRCUIT_BREAKER_RATING_SENSOR, - *BESS_METADATA_SENSORS, - *PV_METADATA_SENSORS, - *PANEL_POWER_SENSORS, - BATTERY_POWER_SENSOR, - PV_POWER_SENSOR, - GRID_POWER_FLOW_SENSOR, - SITE_POWER_SENSOR, - *PANEL_ENERGY_SENSORS, - *CIRCUIT_SENSORS, - *EVSE_SENSORS, - ] - known_keys = {d.key for d in all_defs} - - for sensor_key in SENSOR_FIELD_MAP: - assert sensor_key in known_keys, ( - f"Sensor key '{sensor_key}' in SENSOR_FIELD_MAP not found in sensor definitions" - ) - - def test_field_paths_match_snapshot_attrs(self) -> None: - """Field names should match actual snapshot dataclass attributes.""" - snapshot_classes = { - "panel": SpanPanelSnapshot, - "circuit": SpanCircuitSnapshot, - "battery": SpanBatterySnapshot, - "pv": SpanPVSnapshot, - "evse": SpanEvseSnapshot, - } - - for sensor_key, field_path in SENSOR_FIELD_MAP.items(): - prefix, field_name = field_path.split(".", 1) - cls = snapshot_classes[prefix] - assert hasattr(cls, field_name) or field_name in { - f.name for f in cls.__dataclass_fields__.values() - }, ( - f"Field '{field_name}' from path '{field_path}' " - f"(sensor '{sensor_key}') not found on {cls.__name__}" - ) - - def test_all_referenced_field_paths(self) -> None: - """all_referenced_field_paths should return all unique values.""" - paths = all_referenced_field_paths() - assert paths == frozenset(SENSOR_FIELD_MAP.values()) - - -# --------------------------------------------------------------------------- -# Unit cross-check tests -# --------------------------------------------------------------------------- - - -def _make_sensor_def(key: str, unit: str | None) -> MagicMock: - """Create a minimal mock SensorEntityDescription with key and unit.""" - mock = MagicMock(spec=["key", "native_unit_of_measurement"]) - mock.key = key - mock.native_unit_of_measurement = unit - return mock - - -class TestUnitCrossCheck: - """Tests for field metadata unit vs sensor definition unit cross-checking.""" - - def test_matching_units_no_cross_check_message( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Matching units should produce no cross-check log messages.""" - metadata = {"panel.instant_grid_power_w": {"unit": "W", "datatype": "float"}} - sensor_defs = {"instantGridPowerW": _make_sensor_def("instantGridPowerW", "W")} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata, sensor_defs=sensor_defs) - assert not any("cross-check" in r.lower() for r in caplog.messages) - - def test_mismatched_units_logs_debug( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Unit mismatch should produce a debug message naming both units.""" - metadata = {"panel.instant_grid_power_w": {"unit": "kW", "datatype": "float"}} - sensor_defs = {"instantGridPowerW": _make_sensor_def("instantGridPowerW", "W")} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata, sensor_defs=sensor_defs) - cross_msgs = [m for m in caplog.messages if "cross-check" in m.lower()] - assert len(cross_msgs) == 1 - assert "'kW'" in cross_msgs[0] - assert "'W'" in cross_msgs[0] - - def test_missing_metadata_logs_debug( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Sensor reading a field with no metadata should log debug.""" - metadata: dict[str, dict[str, object]] = {} - sensor_defs = {"l1_voltage": _make_sensor_def("l1_voltage", "V")} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata, sensor_defs=sensor_defs) - assert any("no metadata" in m for m in caplog.messages) - - def test_missing_schema_unit_logs_debug( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Field with no unit in metadata but unit in sensor def should log debug.""" - metadata = {"panel.l1_voltage": {"datatype": "float"}} - sensor_defs = {"l1_voltage": _make_sensor_def("l1_voltage", "V")} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata, sensor_defs=sensor_defs) - assert any("no unit" in m for m in caplog.messages) - - def test_sensor_without_unit_skipped( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Sensor with no native_unit_of_measurement should be skipped.""" - metadata = {"panel.main_relay_state": {"datatype": "enum"}} - sensor_defs = {"main_relay_state": _make_sensor_def("main_relay_state", None)} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata, sensor_defs=sensor_defs) - assert not any("cross-check" in m.lower() for m in caplog.messages) - - def test_all_output_is_debug_level(self, caplog: pytest.LogCaptureFixture) -> None: - """All schema validation output should be DEBUG — never visible to users.""" - metadata = { - "panel.instant_grid_power_w": {"unit": "kW", "datatype": "float"}, - "panel.l1_voltage": {"datatype": "float"}, - "panel.new_fancy_field": {"unit": "W", "datatype": "float"}, - } - sensor_defs = { - "instantGridPowerW": _make_sensor_def("instantGridPowerW", "W"), - "l1_voltage": _make_sensor_def("l1_voltage", "V"), - } - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata, sensor_defs=sensor_defs) - above_debug = [r for r in caplog.records if r.levelno > logging.DEBUG] - assert len(above_debug) == 0, ( - f"Expected all DEBUG, got: {[(r.levelname, r.getMessage()) for r in above_debug]}" - ) - - -# --------------------------------------------------------------------------- -# Unmapped field detection tests -# --------------------------------------------------------------------------- - - -class TestUnmappedFields: - """Tests for detecting fields the integration doesn't consume.""" - - def test_unmapped_field_logs_debug(self, caplog: pytest.LogCaptureFixture) -> None: - """Field not in SENSOR_FIELD_MAP values should log at DEBUG.""" - metadata = {"panel.new_fancy_field": {"unit": "W", "datatype": "float"}} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata) - assert any( - r.levelno == logging.DEBUG and "new_fancy_field" in r.getMessage() - for r in caplog.records - ) - - def test_mapped_field_not_reported(self, caplog: pytest.LogCaptureFixture) -> None: - """Field that IS in SENSOR_FIELD_MAP should not be reported as unmapped.""" - metadata = {"panel.instant_grid_power_w": {"unit": "W", "datatype": "float"}} - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(metadata) - assert not any("not mapped" in m for m in caplog.messages) - - -# --------------------------------------------------------------------------- -# No-op when metadata unavailable -# --------------------------------------------------------------------------- - - -class TestNoOp: - """Tests for graceful handling when library doesn't expose metadata.""" - - def test_none_metadata_is_noop(self, caplog: pytest.LogCaptureFixture) -> None: - """None metadata should produce no output above DEBUG.""" - with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME): - validate_field_metadata(None) - assert any("skipped" in m for m in caplog.messages) - above_debug = [r for r in caplog.records if r.levelno > logging.DEBUG] - assert len(above_debug) == 0 + +def test_absent_entry_is_not_degradation() -> None: + """No entry means the hardware is not installed — not a defect.""" + findings = evaluate_field_metadata({}, sensor_defs={}) + assert findings.unresolved == frozenset() + + +def test_unit_mismatch_is_reported() -> None: + """Deliberately not `circuit.instant_power_w`/"kW". + + That pair is the one entry in `KNOWN_BAD_SCHEMA_UNITS`, so it would prove the + exception rather than the check. + """ + from homeassistant.components.sensor import SensorEntityDescription + from homeassistant.const import UnitOfElectricPotential + + description = SensorEntityDescription( + key="l1_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT + ) + findings = evaluate_field_metadata( + {"panel.l1_voltage": FieldMetadata("kV", "float")}, + sensor_defs={"panel.l1_voltage": description}, + ) + assert findings.unit_mismatches[0].field_path == "panel.l1_voltage" + assert findings.unit_mismatches[0].schema_unit == "kV" + + +def test_unitless_sensor_still_checked_for_resolution() -> None: + """Resolution is checked before the unit is. + + The old code short-circuited on `ha_unit is None` BEFORE the lookup, so enum + and string sensors could go dead with no signal. + """ + from homeassistant.components.sensor import SensorEntityDescription + + description = SensorEntityDescription(key="evse_status") + findings = evaluate_field_metadata( + {"evse.status": FieldMetadata(None, "unknown", resolved=False)}, + sensor_defs={"evse.status": description}, + ) + assert "evse.status" in findings.unresolved + + +def test_unresolved_entry_is_never_a_unit_mismatch() -> None: + """An unresolved entry carries `unit=None` by construction. + + Comparing that against a declared unit would raise a false mismatch on every + affected sensor, so resolution must be branched on first. + """ + from homeassistant.components.sensor import SensorEntityDescription + from homeassistant.const import UnitOfPower + + description = SensorEntityDescription( + key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT + ) + findings = evaluate_field_metadata( + {"circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False)}, + sensor_defs={"circuit.instant_power_w": description}, + ) + assert findings.unresolved == frozenset({"circuit.instant_power_w"}) + assert findings.unit_mismatches == () + + +def test_matching_unit_is_not_a_mismatch() -> None: + from homeassistant.components.sensor import SensorEntityDescription + from homeassistant.const import UnitOfPower + + description = SensorEntityDescription( + key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT + ) + findings = evaluate_field_metadata( + {"circuit.instant_power_w": FieldMetadata("W", "float")}, + sensor_defs={"circuit.instant_power_w": description}, + ) + assert findings.unit_mismatches == () + assert findings.unresolved == frozenset() + + +def test_produced_but_unread_fields_are_inventoried() -> None: + """An addition is legal within a major version — inventory, not a defect.""" + findings = evaluate_field_metadata( + {"panel.some_future_field": FieldMetadata("W", "float")}, sensor_defs={} + ) + assert findings.unread == frozenset({"panel.some_future_field"}) + assert findings.unresolved == frozenset() + assert findings.unit_mismatches == () + + +def test_declared_and_resolved_fields_are_not_unread() -> None: + findings = evaluate_field_metadata( + {"circuit.instant_power_w": FieldMetadata("W", "float")}, sensor_defs={} + ) + assert findings.unread == frozenset() + + +def test_empty_metadata_is_healthy_not_unknown() -> None: + """A pass over a healthy panel is expressible and is not the None sentinel. + + Task 7 needs three distinct states; this is the middle one. "Unknown" is the + coordinator's `schema_findings is None`, which `evaluate_field_metadata` can + no longer produce — it no longer accepts the sentinel at all. + """ + findings = evaluate_field_metadata({}) + assert findings == SchemaFindings(frozenset(), (), frozenset()) + + +def test_every_declared_field_path_keys_a_sensor_or_a_residual_reader() -> None: + """`sensor_descriptions_by_field_path` must not drop descriptions. + + Keys such as "model" and "serial_number" repeat across device classes, so a + dict keyed on `description.key` would silently collapse them. + """ + by_path = sensor_descriptions_by_field_path() + assert by_path.keys() <= declared_field_paths() | conditional_field_paths() + assert {"battery.model", "pv.model"} <= by_path.keys() + for field_path, description in by_path.items(): + assert description.field_path == field_path + # A schema-conditional description is included: its unit is as + # checkable as any other's. What it must never be is a description + # that names no field at all. + assert description.derived in (None, DerivedReason.SCHEMA_CONDITIONAL_FIELD) + + +def test_resolved_unitless_sensor_yields_no_mismatch() -> None: + """A resolved field read by an enum or string sensor has nothing to compare.""" + from homeassistant.components.sensor import SensorEntityDescription + + description = SensorEntityDescription(key="evse_status") + findings = evaluate_field_metadata( + {"evse.status": FieldMetadata(None, "enum")}, + sensor_defs={"evse.status": description}, + ) + assert findings.unresolved == frozenset() + assert findings.unit_mismatches == () + + +def test_descriptions_without_a_declaration_raise( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Both consumers of the shared traversal reject an undeclared description. + + Skipping it would drop the sensor from the unit cross-check silently, which + is the drift `field_paths` exists to prevent. `declared_field_paths` raises + on the same input, and both now do so from one place. + """ + from homeassistant.components.sensor import SensorEntityDescription + + monkeypatch.setattr( + sensor_definitions, + "all_sensor_descriptions", + lambda: (SensorEntityDescription(key="undeclared"),), + ) + with pytest.raises(TypeError, match="carries no field-path declaration"): + sensor_definitions.sensor_descriptions_by_field_path() + + +@pytest.mark.parametrize( + "metadata_fn", + [ + pytest.param(schema_zero_metadata, id="schema_0"), + pytest.param(schema_one_metadata, id="schema_1"), + ], +) +def test_real_adapter_metadata_produces_no_findings(metadata_fn: MetadataFn) -> None: + """A healthy panel of either generation must be finding-free. + + The rest of this file drives the evaluator with synthetic single-entry dicts, + which cannot show what real firmware actually declares. This is the standing + guard against a day-one Repair that no user can act on. + """ + findings = evaluate_field_metadata(metadata_fn(), sensor_descriptions_by_field_path()) + assert findings.unresolved == frozenset() + assert findings.unit_mismatches == () + + +def test_a_schema_conditional_field_can_be_unresolved() -> None: + """The evaluator must ask about the paths the producible gate cannot. + + A `SCHEMA_CONDITIONAL_FIELD` entity reads a field that only one adapter + publishes a metadata row for. That row still carries the adapter's + three-way answer, so it can come back `resolved=False` -- the panel + declares the node and omits the property. Iterating `declared_field_paths()` + alone never asked, so such an entity got no Repair and no unavailability, + however dead its field was. + """ + path = "panel.shed_time_to_priority_shed_min" + assert path in conditional_field_paths() + assert path not in declared_field_paths() + + findings = evaluate_field_metadata({path: FieldMetadata(None, "unknown", resolved=False)}) + + assert findings.unresolved == frozenset({path}) + + +def test_a_schema_conditional_unit_is_still_compared() -> None: + """Being one adapter short of the gate does not excuse a wrong unit.""" + findings = evaluate_field_metadata( + {"panel.shed_total_time_remaining_min": FieldMetadata("h", "integer")}, + sensor_descriptions_by_field_path(), + ) + assert [m.field_path for m in findings.unit_mismatches] == [ + "panel.shed_total_time_remaining_min" + ] + + +def test_a_decorating_exempt_path_is_not_probed() -> None: + """Exempt is not the same as read-as-a-value. + + `evse.vendor_name` builds a DeviceInfo and `circuit.always_on` is an + attribute; neither is any entity's reading, so neither belongs in the + unresolved set. Widening the evaluator to every non-`NEITHER` exemption + would have swept both in. + """ + for path in ("evse.vendor_name", "circuit.always_on"): + assert RESIDUAL_EXEMPT_PATHS[path] is not Producibility.NEITHER + assert path not in conditional_field_paths() + findings = evaluate_field_metadata({path: FieldMetadata(None, "unknown", resolved=False)}) + assert findings.unresolved == frozenset() + + +def test_known_bad_schema_unit_exception_is_narrow() -> None: + """Only the exact known-bad unit is excused; anything else is new information.""" + from homeassistant.components.sensor import SensorEntityDescription + from homeassistant.const import UnitOfPower + + description = SensorEntityDescription( + key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT + ) + findings = evaluate_field_metadata( + {"circuit.instant_power_w": FieldMetadata("MW", "float")}, + sensor_defs={"circuit.instant_power_w": description}, + ) + assert [m.schema_unit for m in findings.unit_mismatches] == ["MW"] + + +def test_absent_hardware_on_real_metadata_is_not_degradation() -> None: + """A batteryless panel simply omits the battery rows — nothing is wrong. + + Stronger than the empty-dict case, which passes whether or not the + `entry is None` arm exists: here 8 `battery.*` paths are declared and read, + and every one of them is missing from the adapter's output. + """ + metadata = schema_one_metadata_batteryless() + battery_paths = {p for p in declared_field_paths() if p.startswith("battery.")} + assert battery_paths + assert battery_paths.isdisjoint(metadata) + + findings = evaluate_field_metadata(metadata, sensor_descriptions_by_field_path()) + assert findings.unresolved == frozenset() + assert findings.unit_mismatches == () + + +@pytest.mark.parametrize( + "metadata_fn", + [ + pytest.param(schema_zero_metadata, id="schema_0"), + pytest.param(schema_one_metadata, id="schema_1"), + ], +) +def test_unread_excludes_readers_exempt_from_the_producible_gate( + metadata_fn: MetadataFn, +) -> None: + """`RESIDUAL_EXEMPT_PATHS` are read, just not required of both adapters. + + They are absent from `declared_field_paths()`, so a plain set difference + reports them as produced-but-unread — false for 10 of schema_0's 17. + """ + findings = evaluate_field_metadata(metadata_fn(), sensor_descriptions_by_field_path()) + assert findings.unread.isdisjoint(RESIDUAL_EXEMPT_PATHS) + + +def test_readers_of_the_same_field_path_agree_on_unit() -> None: + """`sensor_descriptions_by_field_path` keeps one reader per path. + + Several field paths are read by two descriptions (an unmapped-circuit raw + key and its named-circuit twin). Dropping one is only safe while they agree + on what the unit check would compare, so pin that here rather than trusting + it. + """ + from collections import defaultdict + + by_path: defaultdict[str, list[object]] = defaultdict(list) + for description in all_sensor_descriptions(): + if not isinstance(description, FieldPathDeclarationMixin): + continue + if description.derived or description.field_path is None: + continue + by_path[description.field_path].append(description) + + colliding = {path: ds for path, ds in by_path.items() if len(ds) > 1} + assert colliding, "expected at least one field path with two readers" + for path, descriptions in colliding.items(): + units = {d.native_unit_of_measurement for d in descriptions} + assert len(units) == 1, f"readers of {path} disagree on unit: {units}" + + +def test_known_bad_schema_unit_exception_is_keyed_on_the_field_path() -> None: + """The other half of the pair: only `circuit.instant_power_w` is excused. + + `test_known_bad_schema_unit_exception_is_narrow` pins the unit half — a + different unit on the same path is still reported. Without this, widening + the check to a unit-only membership test ("is kW ever known-bad?") would + pass the whole suite while silently excusing every field that declares kW. + """ + from homeassistant.components.sensor import SensorEntityDescription + from homeassistant.const import UnitOfPower + + description = SensorEntityDescription( + key="grid_power", native_unit_of_measurement=UnitOfPower.WATT + ) + findings = evaluate_field_metadata( + {"panel.instant_grid_power_w": FieldMetadata("kW", "float")}, + sensor_defs={"panel.instant_grid_power_w": description}, + ) + assert [m.field_path for m in findings.unit_mismatches] == ["panel.instant_grid_power_w"] + assert findings.unit_mismatches[0].schema_unit == "kW" diff --git a/tests/test_select.py b/tests/test_select.py index 54af76ed..21bc6a4b 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -3,17 +3,19 @@ from dataclasses import replace from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import pytest +from span_panel_api import PublishOutcome, PublishState from span_panel_api.exceptions import SpanPanelServerError from custom_components.span_panel.const import CircuitPriority +from custom_components.span_panel.control_gate import ControlPolicy from custom_components.span_panel.select import ( CIRCUIT_PRIORITY_DESCRIPTION, SpanPanelCircuitsSelect, async_setup_entry, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory @@ -60,64 +62,69 @@ def test_select_init_missing_circuit() -> None: coordinator.config_entry.options = {} with pytest.raises(ValueError): - SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "bad_id", "name", "Test Device" - ) + SpanPanelCircuitsSelect(coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "bad_id", "Test Device") @pytest.mark.asyncio -async def test_async_select_option_service_not_found() -> None: - """Test that ServiceNotFound triggers a notification.""" +async def test_async_select_option_refusal_is_raised_at_the_caller() -> None: + """A refused priority reaches the person who chose it, carrying the reason. + + The panel refuses this for two different situations -- the circuit is + commissioned never-backup, or the panel could not carry the change out -- + and the exception distinguishes them only in its message. So the message is + passed through rather than diagnosed here, and the entity contributes the + name the user knows the circuit by. + """ coordinator = _make_coordinator_with_circuit() circuit = coordinator.data.circuits["id"] - with patch( - "custom_components.span_panel.select.async_create_span_notification", - new_callable=AsyncMock, - ) as mock_notification: - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "name", "Test Device" - ) - select.coordinator = coordinator - select.hass = MagicMock() + select = SpanPanelCircuitsSelect(coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Test Device") + select.coordinator = coordinator + select.hass = MagicMock() - # Make the client's set_circuit_priority raise ServiceNotFound - coordinator.client = AsyncMock() - coordinator.client.set_circuit_priority = AsyncMock( - side_effect=ServiceNotFound("test_domain", "test_service") - ) + coordinator.client = AsyncMock() + coordinator.client.set_circuit_priority = AsyncMock( + side_effect=SpanPanelServerError("Circuit 'id' declares its shed priority not settable") + ) - select._get_circuit = MagicMock(return_value=circuit) + select._get_circuit = MagicMock(return_value=circuit) + with pytest.raises(HomeAssistantError) as raised: await select.async_select_option(CircuitPriority.SOC_THRESHOLD.value) - mock_notification.assert_called_once() + assert raised.value.translation_key == "circuit_priority_failed" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["circuit"] == "name" + assert placeholders["reason"] == "Circuit 'id' declares its shed priority not settable" + coordinator.async_request_refresh.assert_not_called() @pytest.mark.asyncio -async def test_async_select_option_server_error() -> None: - """Test that SpanPanelServerError triggers a notification.""" +async def test_async_select_option_undelivered_is_raised_at_the_caller() -> None: + """A `FAILED` outcome is the promise this change will not arrive later.""" coordinator = _make_coordinator_with_circuit() circuit = coordinator.data.circuits["id"] - with patch( - "custom_components.span_panel.select.async_create_span_notification", - new_callable=AsyncMock, - ) as mock_notification: - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "name", "Test Device" - ) - select.coordinator = coordinator - select.hass = MagicMock() + select = SpanPanelCircuitsSelect(coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Test Device") + select.coordinator = coordinator + select.hass = MagicMock() - coordinator.client = AsyncMock() - coordinator.client.set_circuit_priority = AsyncMock( - side_effect=SpanPanelServerError("test error") + coordinator.client = AsyncMock() + coordinator.client.set_circuit_priority = AsyncMock( + return_value=PublishOutcome( + state=PublishState.FAILED, topic=None, value="NEVER", detail="transport is closed" ) + ) - select._get_circuit = MagicMock(return_value=circuit) + select._get_circuit = MagicMock(return_value=circuit) + with pytest.raises(HomeAssistantError) as raised: await select.async_select_option(CircuitPriority.SOC_THRESHOLD.value) - mock_notification.assert_called_once() + assert raised.value.translation_key == "circuit_priority_not_delivered" + placeholders = raised.value.translation_placeholders + assert placeholders is not None + assert placeholders["reason"] == "transport is closed" + coordinator.async_request_refresh.assert_not_called() @pytest.mark.asyncio @@ -134,7 +141,7 @@ async def test_async_select_option_success_refreshes_coordinator() -> None: registry.async_get_entity_id.return_value = None mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) coordinator.client = MagicMock() @@ -149,37 +156,13 @@ async def test_async_select_option_success_refreshes_coordinator() -> None: coordinator.async_request_refresh.assert_awaited_once() -@pytest.mark.asyncio -async def test_async_select_option_without_priority_support_returns_early( - caplog: pytest.LogCaptureFixture, -) -> None: - """Clients without priority support should log and return without refresh.""" - coordinator = _make_coordinator_with_circuit() - coordinator.hass = MagicMock() - coordinator.async_request_refresh = AsyncMock() - - with patch( - "custom_components.span_panel.select.er.async_get" - ) as mock_async_get: - registry = MagicMock() - registry.async_get_entity_id.return_value = None - mock_async_get.return_value = registry - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" - ) - - coordinator.client = object() - select.hass = MagicMock() - caplog.set_level("WARNING") - - await select.async_select_option(CircuitPriority.SOC_THRESHOLD.value) - - assert "Client does not support priority control" in caplog.text - coordinator.async_request_refresh.assert_not_awaited() - +def test_select_uses_circuit_numbers_for_the_base_when_the_option_is_enabled() -> None: + """The option decides the id base; the displayed name is the panel's regardless. -def test_select_uses_circuit_number_name_when_option_enabled() -> None: - """Number-based naming should use breaker tabs when configured.""" + It used to decide the name too, on a first install only, because the name was + the only thing an id could be composed from. The base is composed from the + option directly now. + """ coordinator = _make_coordinator_with_circuit() coordinator.config_entry.options = {"use_circuit_numbers": True} coordinator.hass = MagicMock() @@ -192,14 +175,20 @@ def test_select_uses_circuit_number_name_when_option_enabled() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) - assert select.name == "Circuit 1 Circuit Priority" + assert select.name == "name Circuit Priority" + assert select.suggested_object_id == "Circuit 1 circuit priority" + +def test_select_unnamed_friendly_mode_falls_back_to_the_tabs() -> None: + """An unnamed circuit is named for its breaker position, not left to HA. -def test_select_unnamed_friendly_mode_leaves_name_none() -> None: - """Unnamed selects in friendly-name mode should defer to HA naming.""" + Deferring gave every unnamed circuit on the panel the same name and the same + id stem, which the registry then disambiguated with `_2`, `_3`, ... in + whatever order the platform happened to add them. + """ coordinator = _make_coordinator_with_circuit(circuit_name="") coordinator.hass = MagicMock() @@ -211,10 +200,11 @@ def test_select_unnamed_friendly_mode_leaves_name_none() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) - assert select.name is None + assert select.name == "Circuit 1 Circuit Priority" + assert select.suggested_object_id == "Circuit 1 circuit priority" def test_select_existing_entity_uses_solar_fallback_name() -> None: @@ -245,7 +235,7 @@ def test_select_existing_entity_uses_solar_fallback_name() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "pv-1", "", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "pv-1", "SPAN Panel" ) assert select.name == "Solar Circuit Priority" @@ -256,9 +246,7 @@ def test_select_available_false_when_panel_offline() -> None: coordinator = _make_coordinator_with_circuit() coordinator.panel_offline = True - select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" - ) + select = SpanPanelCircuitsSelect(coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel") assert select.available is False @@ -275,7 +263,7 @@ def test_select_extra_state_attributes_include_tabs_and_voltage() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) assert select.extra_state_attributes == {"tabs": "tabs [1]", "voltage": 120} @@ -294,7 +282,7 @@ def test_handle_coordinator_update_requests_reload_on_first_sync() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) select.hass = MagicMock() select.async_write_ha_state = MagicMock() @@ -317,7 +305,7 @@ def test_handle_coordinator_update_user_override_skips_reload() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) updated_circuit = replace(coordinator.data.circuits["id"], name="Renamed Kitchen") @@ -352,7 +340,7 @@ def test_handle_coordinator_update_requests_reload_on_name_change() -> None: mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) updated_circuit = replace(coordinator.data.circuits["id"], name="Renamed Kitchen") @@ -385,7 +373,7 @@ def test_handle_coordinator_update_skips_when_circuit_missing_from_snapshot() -> mock_async_get.return_value = registry select = SpanPanelCircuitsSelect( - coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "Kitchen", "SPAN Panel" + coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "id", "SPAN Panel" ) # Simulate a partial snapshot missing this circuit @@ -449,7 +437,9 @@ async def test_async_setup_entry_filters_supported_circuits() -> None: config_entry = MagicMock() config_entry.title = "SPAN Panel" config_entry.data = {} - config_entry.runtime_data = MagicMock(coordinator=coordinator) + config_entry.runtime_data = MagicMock( + control_policy=ControlPolicy.default(), coordinator=coordinator, setup_snapshot=coordinator.data + ) async_add_entities = MagicMock() await async_setup_entry(MagicMock(), config_entry, async_add_entities) @@ -459,10 +449,58 @@ async def test_async_setup_entry_filters_supported_circuits() -> None: assert {entity.id for entity in entities} == {"main-1", "pv-1"} +async def test_async_setup_entry_skips_circuits_whose_priority_is_not_settable() -> None: + """A never-backup circuit gets no priority select, even with a controllable relay. + + Relay controllability and priority settability are two independent + commissioning flags. Under v1.0 the panel expresses never-backup as the + absence of `$settable` on `load-shed/priority`, which the adapter carries as + `is_never_backup` -- a different property from `switch/relay-controllable`, + which it carries as `is_user_controllable`. + + Gating this platform on the relay flag alone offers a priority control on a + circuit whose priority the panel will refuse to change. The panels we have + captures from declare every circuit's priority settable, so hardware does not + exercise this today; the flag exists because a panel may say otherwise, and + it is the only thing that says so. + """ + settable_priority = SpanCircuitSnapshotFactory.create( + circuit_id="main-1", + name="Kitchen", + is_user_controllable=True, + is_never_backup=False, + tabs=[1], + ) + locked_priority = SpanCircuitSnapshotFactory.create( + circuit_id="main-2", + name="Water Heater", + is_user_controllable=True, + is_never_backup=True, + tabs=[2], + ) + + coordinator = MagicMock() + coordinator.data = SpanPanelSnapshotFactory.create( + circuits={"main-1": settable_priority, "main-2": locked_priority} + ) + config_entry = MagicMock() + config_entry.title = "SPAN Panel" + config_entry.data = {} + config_entry.runtime_data = MagicMock( + control_policy=ControlPolicy.default(), coordinator=coordinator, setup_snapshot=coordinator.data + ) + async_add_entities = MagicMock() + + await async_setup_entry(MagicMock(), config_entry, async_add_entities) + + entities = async_add_entities.call_args.args[0] + assert {entity.id for entity in entities} == {"main-1"} + + def test_select_circuit_numbers_entity_id_stable_after_reload( hass: HomeAssistant, ) -> None: - """Entity_id must stay circuit-based after name sync sets friendly display name.""" + """The base must stay circuit-based after name sync sets the friendly display name.""" circuit = SpanCircuitSnapshotFactory.create( circuit_id="2", name="Air Conditioner", @@ -490,12 +528,11 @@ def test_select_circuit_numbers_entity_id_stable_after_reload( coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "2", - "Air Conditioner", "SPAN Panel", ) - assert select.name == "Circuit 15 17 Circuit Priority" - assert select.entity_id == "select.span_panel_circuit_15_17_circuit_priority" + assert select.name == "Air Conditioner Circuit Priority" + assert select.suggested_object_id == "Circuit 15 17 circuit priority" # --- After reload: entity EXISTS in registry --- with pytest.MonkeyPatch.context() as mp: @@ -511,19 +548,19 @@ def test_select_circuit_numbers_entity_id_stable_after_reload( coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "2", - "Air Conditioner", "SPAN Panel", ) - # Entity_id must still be circuit-based - assert select2.name == "Circuit 15 17 Circuit Priority" - assert select2.entity_id == "select.span_panel_circuit_15_17_circuit_priority" + # The panel's name, carried by original_name rather than the registry's + # `name`, which would outrank the base. + assert select2.name == "Air Conditioner Circuit Priority" + assert select2.suggested_object_id == "Circuit 15 17 circuit priority" def test_select_circuit_numbers_entity_id_120v_single_tab( hass: HomeAssistant, ) -> None: - """120V single-tab circuit should produce entity_id with one tab number.""" + """120V single-tab circuit should produce a base with one tab number.""" circuit = SpanCircuitSnapshotFactory.create( circuit_id="5", name="Kitchen Outlets", @@ -550,15 +587,14 @@ def test_select_circuit_numbers_entity_id_120v_single_tab( coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "5", - "Kitchen Outlets", "SPAN Panel", ) - assert select.name == "Circuit 10 Circuit Priority" - assert select.entity_id == "select.span_panel_circuit_10_circuit_priority" + assert select.name == "Kitchen Outlets Circuit Priority" + assert select.suggested_object_id == "Circuit 10 circuit priority" -def test_select_coordinator_update_circuit_numbers_updates_registry( +def test_select_coordinator_update_circuit_numbers_requests_reload( hass: HomeAssistant, ) -> None: """In circuit-numbers mode, a name change should update the registry display name.""" @@ -596,7 +632,6 @@ def test_select_coordinator_update_circuit_numbers_updates_registry( coordinator, CIRCUIT_PRIORITY_DESCRIPTION, "2", - "Air Conditioner", "SPAN Panel", ) @@ -610,9 +645,8 @@ def test_select_coordinator_update_circuit_numbers_updates_registry( with pytest.MonkeyPatch.context() as mp: runtime_registry = MagicMock() runtime_entry = MagicMock() - type(runtime_entry).name = PropertyMock( - return_value="Air Conditioner Circuit Priority" - ) + # Released at construction, so nothing occupies the field any more. + type(runtime_entry).name = PropertyMock(return_value=None) runtime_registry.async_get.return_value = runtime_entry mp.setattr( "custom_components.span_panel.select.er.async_get", @@ -620,8 +654,5 @@ def test_select_coordinator_update_circuit_numbers_updates_registry( ) select._handle_coordinator_update() - runtime_registry.async_update_entity.assert_called_once_with( - "select.span_panel_circuit_15_17_circuit_priority", - name="Kitchen AC Circuit Priority", - ) - coordinator.request_reload.assert_not_called() + coordinator.request_reload.assert_called_once() + runtime_registry.async_update_entity.assert_not_called() diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index 61def958..73f8dbbf 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -3,20 +3,37 @@ from __future__ import annotations import asyncio +from collections.abc import Callable +from dataclasses import replace from datetime import UTC, datetime, timedelta +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import CONF_HOST, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import State import pytest -from span_panel_api import SpanPVSnapshot +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import SpanPanelSnapshot, SpanPVSnapshot -from homeassistant.components.sensor import SensorDeviceClass +from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.const import ( ENABLE_ENERGY_DIP_COMPENSATION, USE_CIRCUIT_NUMBERS, ) -from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.energy_orientation import ( + CircuitMeter, + EnergyBinding, + EnergyCounter, + EnergyMeter, + EnergyRole, + PanelMeter, +) +from custom_components.span_panel.options import ENERGY_REPORTING_GRACE_PERIOD, option_bool from custom_components.span_panel.sensor_base import ( SpanEnergyExtraStoredData, + SpanEnergySensorBase, _parse_numeric_state, ) from custom_components.span_panel.sensor_circuit import ( @@ -39,6 +56,7 @@ PV_METADATA_SENSORS, STATUS_SENSORS, UNMAPPED_SENSORS, + SpanPanelCircuitsSensorEntityDescription, SpanPanelDataSensorEntityDescription, ) from custom_components.span_panel.sensor_evse import SpanEvseSensor @@ -51,18 +69,16 @@ SpanPanelStatus, SpanPVMetadataSensor, ) -from homeassistant.const import CONF_HOST, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import State +from .adapter_fixtures import schema_one_snapshot, schema_one_tree, schema_zero_snapshot from .factories import ( SpanBatterySnapshotFactory, SpanCircuitSnapshotFactory, SpanEvseSnapshotFactory, SpanPanelSnapshotFactory, + pv_binding_for, ) -from pytest_homeassistant_custom_component.common import MockConfigEntry - @pytest.fixture(autouse=True) def _mock_entity_registry(): @@ -76,12 +92,23 @@ def _mock_entity_registry(): yield registry -def _make_coordinator(snapshot, *, options: dict | None = None) -> MagicMock: - """Create a coordinator-like mock for direct sensor tests.""" +def _make_coordinator( + snapshot, *, options: dict | None = None, schema_major: str | None = "schema_1" +) -> MagicMock: + """Create a coordinator-like mock for direct sensor tests. + + `schema_major` is the running adapter's key, as `SpanMqttClient.schema_major` + reports it. Defaulted to the parent/child adapter because a `MagicMock` + attribute answers every comparison with an object that is equal to nothing, + and a sensor asking which adapter produced a field would then read every + coordinator in this module as an adapter it has never heard of. + """ coordinator = MagicMock() coordinator.data = snapshot + coordinator.client.schema_major = schema_major coordinator.hass = MagicMock() coordinator.panel_offline = False + coordinator.transport_dead = False coordinator.config_entry = MockConfigEntry( domain="span_panel", data={CONF_HOST: "192.168.1.50"}, @@ -89,9 +116,16 @@ def _make_coordinator(snapshot, *, options: dict | None = None) -> MagicMock: title="SPAN Panel", unique_id=snapshot.serial_number, ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) coordinator.request_reload = MagicMock() - coordinator.register_circuit_energy_sensor = MagicMock() - coordinator.get_circuit_dip_offset = MagicMock(return_value=0.0) + coordinator.register_energy_sensor = MagicMock() + coordinator.dip_offset = MagicMock(return_value=0.0) return coordinator @@ -106,7 +140,11 @@ def test_panel_power_sensor_extra_state_attributes_include_amperage() -> None: sensor._update_native_value() assert sensor.native_value == 480.0 - assert sensor.extra_state_attributes == {"voltage": 240, "amperage": 2.0} + assert sensor.extra_state_attributes == { + "voltage": 240, + "amperage": 2.0, + "at_service_entrance": True, + } def test_panel_power_sensor_defaults_amperage_when_value_not_numeric() -> None: @@ -119,11 +157,149 @@ def test_panel_power_sensor_defaults_amperage_when_value_not_numeric() -> None: sensor._attr_native_value = STATE_UNKNOWN - assert sensor.extra_state_attributes == {"voltage": 240, "amperage": 0.0} + assert sensor.extra_state_attributes == { + "voltage": 240, + "amperage": 0.0, + "at_service_entrance": True, + } + + +def test_grid_power_says_when_the_lugs_are_not_the_utility_connection() -> None: + """The attribute exists for the case where the two grid figures disagree. + + A BESS ahead of the main lugs, or a panel fed by another panel, leaves this + sensor metering that panel's own feed while `power_flow_grid` stays + site-level. Both readings are right and they stop being the same number, and + without this the user cannot tell that from a fault. + """ + snapshot = SpanPanelSnapshotFactory.create( + instant_grid_power_w=480.0, lugs_at_service_entrance=False + ) + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + sensor._update_native_value() + + assert sensor.extra_state_attributes["at_service_entrance"] is False + # The label is conditional; the measurement is not. + assert sensor.native_value == 480.0 + + +def test_only_the_grid_sensor_carries_the_topology_attribute() -> None: + """The same class backs four sensors and only one reads a topology-dependent meter. + + Feedthrough, battery and PV are what they say they are wherever the panel + sits, so an attribute qualifying the grid label would be noise on them -- + and worse, would read as qualifying *their* value. + """ + snapshot = SpanPanelSnapshotFactory.create( + instant_grid_power_w=480.0, feedthrough_power_w=120.0, lugs_at_service_entrance=False + ) + coordinator = _make_coordinator(snapshot) + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "feedthroughPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + sensor._update_native_value() + + assert "at_service_entrance" not in (sensor.extra_state_attributes or {}) + + +def test_flat_firmware_publishes_no_topology_it_was_never_told() -> None: + """The flat adapter never writes the field, so its value is a library default. + + `lugs_at_service_entrance` is a plain `bool` defaulting to True and + `span_panel_api_schema_0` does not reference it, so a flat panel with a BESS + ahead of its main lugs -- the very topology the attribute exists to report -- + would publish `at_service_entrance: True`. That is not a wrong reading, which + a user could at least argue with; it is the absence of one wearing a + reading's clothes. + + Driven through the real flat adapter rather than the snapshot factory: the + factory takes whatever a test hands it, and the claim under test is about + what the adapter does. + """ + flat = schema_zero_snapshot() + assert flat.lugs_at_service_entrance is True, ( + "the library default moved; this test's premise is stale" + ) + + coordinator = _make_coordinator( + replace(flat, instant_grid_power_w=480.0), schema_major="schema_0" + ) + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, flat) + sensor._update_native_value() + + assert sensor.native_value == 480.0 + assert "at_service_entrance" not in (sensor.extra_state_attributes or {}) + + +def test_the_topology_attribute_reports_what_the_parent_child_panel_published() -> None: + """schema_1 resolves the field, so the attribute is a reading and is published. + + The reference capture's upstream lugs carry `connection/fed-by-device-id = + bess`: a real panel with a battery ahead of the main lugs, which is the case + the flat adapter cannot see and this one can. + """ + snapshot = schema_one_snapshot() + coordinator = _make_coordinator(snapshot, schema_major="schema_1") + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + sensor._update_native_value() + assert sensor.extra_state_attributes["at_service_entrance"] is False -def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None: - """Panel sensor classes should return fallback names when descriptions are unnamed.""" + +def test_the_topology_attribute_still_says_true_when_true_is_a_reading() -> None: + """Omission must turn on where the value came from, never on what it is. + + Republishing the capture without the upstream lugs' `fed-by-device-id` makes + a panel that *is* at the service entrance, and schema_1 reads True off it. + Suppressing True as "probably a default" would lose that. + """ + tree = schema_one_tree() + del tree["lugs-upstream"]["connection/fed-by-device-id"] + snapshot = schema_one_snapshot(tree) + coordinator = _make_coordinator(snapshot, schema_major="schema_1") + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + sensor._update_native_value() + + assert sensor.extra_state_attributes["at_service_entrance"] is True + + +def test_the_topology_attribute_is_omitted_before_an_adapter_is_known() -> None: + """`schema_major` is None until the client has dispatched one. + + Unknown is not "flat" and not "at the service entrance"; it is unknown, and + the attribute's whole job is to be a reading. + """ + snapshot = SpanPanelSnapshotFactory.create(instant_grid_power_w=480.0) + coordinator = _make_coordinator(snapshot, schema_major=None) + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + sensor._update_native_value() + + assert "at_service_entrance" not in (sensor.extra_state_attributes or {}) + + +def test_a_panel_sensor_with_a_translation_key_falls_back_to_the_neutral_label() -> None: + """One shared answer replaces the per-class fallbacks, and it is name-only. + + `_generate_panel_name` returns the description's own label and nothing else; + the per-class fallbacks that used to answer here ("Battery", "Status", ...) + are gone. A description that declares a `translation_key` declares no name, + so the shared answer can only offer the neutral word -- which is why the + constructor does not ask it for those at all (`sensor_base.py`: `_attr_name` + is set only `if not ... translation_key`) and their real label reaches the UI + from `translations/en.json`. This case pins what the layer says when asked, + not what such a sensor displays. + """ battery = SpanBatterySnapshotFactory.create(soe_percentage=77.0) snapshot = SpanPanelSnapshotFactory.create( battery=battery, @@ -131,34 +307,24 @@ def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None: ) coordinator = _make_coordinator(snapshot) - panel_data_desc = next( - desc for desc in PANEL_DATA_STATUS_SENSORS if desc.key == "main_relay_state" - ) status_desc = next(desc for desc in STATUS_SENSORS if desc.key == "software_version") - panel_power_desc = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") - panel_energy_desc = next( - desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" + assert not isinstance(status_desc.name, str), ( + "this case is only interesting for a description that declares no name" ) - panel_data_sensor = SpanPanelPanelStatus(coordinator, panel_data_desc, snapshot) + status_sensor = SpanPanelStatus(coordinator, status_desc, snapshot) - battery_sensor = SpanPanelBattery(coordinator, BATTERY_SENSOR, snapshot) - power_sensor = SpanPanelPowerSensor(coordinator, panel_power_desc, snapshot) - energy_sensor = SpanPanelEnergySensor(coordinator, panel_energy_desc, snapshot) - bess_sensor = SpanBessMetadataSensor( - coordinator, - BESS_METADATA_SENSORS[0], - snapshot, - {"identifiers": {("span_panel", "bess")}}, - ) - pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot) + assert status_sensor._generate_panel_name(snapshot, status_desc) == "Sensor" + - assert panel_data_sensor._generate_friendly_name(snapshot, panel_data_desc) == "Sensor" - assert status_sensor._generate_friendly_name(snapshot, status_desc) == "Status" - assert battery_sensor._generate_friendly_name(snapshot, BATTERY_SENSOR) == "Battery" - assert power_sensor._generate_friendly_name(snapshot, panel_power_desc) == "Power" - assert energy_sensor._generate_friendly_name(snapshot, panel_energy_desc) == "Energy" - assert bess_sensor._generate_friendly_name(snapshot, BESS_METADATA_SENSORS[0]) == "BESS Sensor" - assert pv_sensor._generate_friendly_name(snapshot, PV_METADATA_SENSORS[0]) == "PV Sensor" +def test_a_panel_sensor_with_a_name_shows_it() -> None: + """The other half of the same answer, with the label spelled on the description.""" + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot) + unnamed = next(desc for desc in PANEL_DATA_STATUS_SENSORS if desc.key == "main_relay_state") + described = replace(unnamed, name="Main Relay") + + sensor = SpanPanelPanelStatus(coordinator, described, snapshot) + assert sensor._generate_panel_name(snapshot, described) == "Main Relay" def test_panel_metadata_sensors_return_expected_data_sources() -> None: @@ -174,10 +340,16 @@ def test_panel_metadata_sensors_return_expected_data_sources() -> None: snapshot, {"identifiers": {("span_panel", "bess")}}, ) - pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot) + pv_sensor = SpanPVMetadataSensor( + coordinator, + PV_METADATA_SENSORS[0], + snapshot, + {"identifiers": {("span_panel", "pv")}}, + pv_binding_for(snapshot), + ) assert bess_sensor.get_data_source(snapshot) is battery - assert pv_sensor.get_data_source(snapshot) is snapshot + assert pv_sensor.get_data_source(snapshot) is pv_snapshot def test_panel_energy_sensor_extra_attributes_include_voltage_and_grace() -> None: @@ -269,15 +441,20 @@ def test_circuit_power_sensor_extra_attributes_include_circuit_metadata() -> Non } -def test_circuit_power_sensor_returns_none_name_for_unnamed_friendly_mode() -> None: - """Unnamed circuits in friendly-name mode should let HA provide the default name.""" +def test_circuit_power_sensor_names_an_unnamed_circuit_after_its_tab() -> None: + """An unnamed circuit in friendly-name mode falls back to its breaker position. + + Answering `None` here let Home Assistant name every unnamed circuit on the + panel alike, and the id followed: they collided and were disambiguated with + `_2`, `_3`, ... in whatever order they happened to be added. + """ circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name=None, tabs=[7]) snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) coordinator = _make_coordinator(snapshot, options={"use_circuit_numbers": False}) sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_BREAKER_RATING_SENSOR, snapshot, "c1") - assert sensor.name is None + assert sensor.name == "Circuit 7 Breaker Rating" def test_unnamed_circuit_fallback_uses_solar_and_evse_labels() -> None: @@ -310,7 +487,6 @@ def test_circuit_power_sensor_subdevice_uses_short_name() -> None: device_info_override={"identifiers": {("span_panel", "evse")}}, ) - assert sensor._generate_friendly_name(snapshot, sensor.entity_description) == "Current" assert sensor._generate_panel_name(snapshot, sensor.entity_description) == "Current" @@ -323,10 +499,6 @@ def test_circuit_power_sensor_missing_circuit_uses_unmapped_fallback_name() -> N coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "missing_circuit" ) - assert ( - sensor._generate_friendly_name(snapshot, sensor.entity_description) - == "Unmapped Tab missing_circuit Current" - ) assert ( sensor._generate_panel_name(snapshot, sensor.entity_description) == "Unmapped Tab missing_circuit Current" @@ -364,27 +536,290 @@ def test_circuit_energy_sensor_registers_consumed_sensor_on_add() -> None: ): asyncio.run(sensor.async_added_to_hass()) - coordinator.register_circuit_energy_sensor.assert_called_once_with("c1", "consumed", sensor) + coordinator.register_energy_sensor.assert_called_once_with( + CircuitMeter("c1"), EnergyCounter.CONSUMED, sensor + ) + + +def _offsets(meter: EnergyMeter, *, produced: float, consumed: float) -> Callable[[EnergyMeter, EnergyCounter], float]: + """`coordinator.dip_offset` for one meter, keyed by counter, never by call order. + + Asking for any other meter raises, so a sensor that reads the wrong meter fails. + """ + table = {(meter, EnergyCounter.PRODUCED): produced, (meter, EnergyCounter.CONSUMED): consumed} + return lambda asked_meter, counter: table[(asked_meter, counter)] + + +def _circuit_description(key: str) -> SpanPanelCircuitsSensorEntityDescription: + return next(desc for desc in CIRCUIT_SENSORS if desc.key == key) + + +def _panel_description(key: str) -> SpanPanelDataSensorEntityDescription: + return next(desc for desc in PANEL_ENERGY_SENSORS if desc.key == key) + + +def _added(sensor: SpanCircuitEnergySensor | SpanPanelEnergySensor) -> None: + """Run `async_added_to_hass` with nothing restored, as the registration tests here do.""" + sensor.async_get_last_extra_data = AsyncMock(return_value=None) + sensor.async_get_last_state = AsyncMock(return_value=None) + sensor.hass = MagicMock() + sensor.entity_id = "sensor.under_test" + with patch( + "homeassistant.helpers.restore_state.async_get", + return_value=MagicMock(async_restore_entity_added=MagicMock(return_value=None)), + ): + asyncio.run(sensor.async_added_to_hass()) -def test_circuit_net_energy_sensor_applies_dip_offset_adjustment() -> None: - """Net energy sensors should add coordinator-provided dip compensation offsets.""" +@pytest.mark.parametrize("device_type", ["pv", "circuit"]) +@pytest.mark.parametrize( + ("produced_offset", "consumed_offset"), + [(950.0, 0.0), (0.0, 36.0), (950.0, 36.0), (36.0, 950.0)], + ids=["produced-only", "consumed-only", "produced-larger", "consumed-larger"], +) +def test_circuit_net_is_compensated_credit_minus_compensated_debit( + device_type: str, produced_offset: float, consumed_offset: float +) -> None: + raw_produced, raw_consumed = 50.0, 4.0 circuit = SpanCircuitSnapshotFactory.create( circuit_id="c1", - name="Kitchen", - consumed_energy_wh=10.0, - produced_energy_wh=2.0, + name="Garage", + produced_energy_wh=raw_produced, + consumed_energy_wh=raw_consumed, + device_type=device_type, ) snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) coordinator = _make_coordinator(snapshot) - coordinator.get_circuit_dip_offset.side_effect = [5.0, 2.0] - description = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_energy_net") + coordinator.dip_offset.side_effect = _offsets( + CircuitMeter("c1"), produced=produced_offset, consumed=consumed_offset + ) + sensor = SpanCircuitEnergySensor(coordinator, _circuit_description("circuit_energy_net"), snapshot, "c1") + + sensor._handle_online_state() + + produced = raw_produced + produced_offset + consumed = raw_consumed + consumed_offset + expected = produced - consumed if device_type == "pv" else consumed - produced + assert sensor.native_value == pytest.approx(expected) + + +def test_a_circuit_retyped_to_pv_flips_its_value_and_its_adjustment_together() -> None: + load = SpanCircuitSnapshotFactory.create( + circuit_id="c1", name="Inverter 2", produced_energy_wh=50.0, consumed_energy_wh=4.0 + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": load}) + coordinator = _make_coordinator(snapshot) + coordinator.dip_offset.side_effect = _offsets(CircuitMeter("c1"), produced=950.0, consumed=36.0) + sensor = SpanCircuitEnergySensor(coordinator, _circuit_description("circuit_energy_net"), snapshot, "c1") + + sensor._handle_online_state() + assert sensor.native_value == pytest.approx(40.0 - 1000.0) + + coordinator.data = SpanPanelSnapshotFactory.create(circuits={"c1": replace(load, device_type="pv")}) + sensor._handle_online_state() + assert sensor.native_value == pytest.approx(1000.0 - 40.0) + + +def test_net_stays_unknown_while_a_counter_is_unreported() -> None: + """Review Focus 2: an offset on one counter never stands in for the missing other.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="c1", name="Solar", produced_energy_wh=None, consumed_energy_wh=4.0, device_type="pv" + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + coordinator.dip_offset.side_effect = _offsets(CircuitMeter("c1"), produced=950.0, consumed=0.0) + sensor = SpanCircuitEnergySensor(coordinator, _circuit_description("circuit_energy_net"), snapshot, "c1") + + sensor._handle_online_state() + + assert sensor.native_value is None + + +def test_offline_grace_holds_the_compensated_net() -> None: + """Review Focus 3: the offline path neither re-reads nor re-applies the adjustment.""" + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="c1", name="Solar", produced_energy_wh=50.0, consumed_energy_wh=4.0, device_type="pv" + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + coordinator.dip_offset.side_effect = _offsets(CircuitMeter("c1"), produced=950.0, consumed=36.0) + sensor = SpanCircuitEnergySensor(coordinator, _circuit_description("circuit_energy_net"), snapshot, "c1") + sensor._update_native_value() + assert sensor.native_value == pytest.approx(1000.0 - 40.0) + reads = coordinator.dip_offset.call_count + + coordinator.panel_offline = True + sensor._update_native_value() + assert sensor.native_value == pytest.approx(1000.0 - 40.0) + assert coordinator.dip_offset.call_count == reads + + +def test_a_net_sensor_never_registers_as_an_offset_source() -> None: + circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen") + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + sensor = SpanCircuitEnergySensor(coordinator, _circuit_description("circuit_energy_net"), snapshot, "c1") + + _added(sensor) + + coordinator.register_energy_sensor.assert_not_called() + + +@pytest.mark.parametrize( + ("key", "meter", "counter"), + [ + ("mainMeterEnergyProducedWh", PanelMeter.MAIN_METER, EnergyCounter.PRODUCED), + ("mainMeterEnergyConsumedWh", PanelMeter.MAIN_METER, EnergyCounter.CONSUMED), + ("feedthroughEnergyProducedWh", PanelMeter.FEEDTHROUGH, EnergyCounter.PRODUCED), + ("feedthroughEnergyConsumedWh", PanelMeter.FEEDTHROUGH, EnergyCounter.CONSUMED), + ], +) +def test_panel_counters_register_under_their_meter(key: str, meter: PanelMeter, counter: EnergyCounter) -> None: + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot) + sensor = SpanPanelEnergySensor(coordinator, _panel_description(key), snapshot) + + _added(sensor) + + coordinator.register_energy_sensor.assert_called_once_with(meter, counter, sensor) + + +def test_main_meter_net_is_compensated_consumed_minus_compensated_produced() -> None: + snapshot = SpanPanelSnapshotFactory.create( + main_meter_energy_consumed_wh=100.0, main_meter_energy_produced_wh=10.0 + ) + coordinator = _make_coordinator(snapshot) + coordinator.dip_offset.side_effect = _offsets(PanelMeter.MAIN_METER, produced=290.0, consumed=2400.0) + sensor = SpanPanelEnergySensor(coordinator, _panel_description("mainMeterNetEnergyWh"), snapshot) + + sensor._handle_online_state() + + assert sensor.native_value == pytest.approx((100.0 + 2400.0) - (10.0 + 290.0)) + + +# --------------------------------------------------------------------------- +# Every Net's adjustment is oriented as its value is +# --------------------------------------------------------------------------- + +# Asymmetric on purpose: an adjustment oriented against its value lands on a +# different number from the value of the compensated counters. +RAW: Final = {EnergyCounter.CONSUMED: 30.0, EnergyCounter.PRODUCED: 1000.0} +OFFSET: Final = {EnergyCounter.CONSUMED: 7.0, EnergyCounter.PRODUCED: 50.0} +COMPENSATED: Final = {counter: RAW[counter] + OFFSET[counter] for counter in EnergyCounter} + +PANEL_COUNTER_FIELDS: Final = { + PanelMeter.MAIN_METER: { + EnergyCounter.CONSUMED: "main_meter_energy_consumed_wh", + EnergyCounter.PRODUCED: "main_meter_energy_produced_wh", + }, + PanelMeter.FEEDTHROUGH: { + EnergyCounter.CONSUMED: "feedthrough_energy_consumed_wh", + EnergyCounter.PRODUCED: "feedthrough_energy_produced_wh", + }, +} + + +def _offset_of(meter: EnergyMeter) -> Callable[[EnergyMeter, EnergyCounter], float]: + return _offsets( + meter, + produced=OFFSET[EnergyCounter.PRODUCED], + consumed=OFFSET[EnergyCounter.CONSUMED], + ) + + +@pytest.mark.parametrize("device_type", ["pv", "circuit", "evse"]) +def test_a_circuit_nets_adjustment_is_oriented_as_its_value(device_type: str) -> None: + """Net under offsets is the value function of the compensated counters, never the other sign.""" + description = _circuit_description("circuit_energy_net") + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="c1", + device_type=device_type, + consumed_energy_wh=RAW[EnergyCounter.CONSUMED], + produced_energy_wh=RAW[EnergyCounter.PRODUCED], + ) + snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) + coordinator = _make_coordinator(snapshot) + coordinator.dip_offset.side_effect = _offset_of(CircuitMeter("c1")) sensor = SpanCircuitEnergySensor(coordinator, description, snapshot, "c1") - sensor._process_raw_value(20.0) + sensor._handle_online_state() + + compensated = replace( + circuit, + consumed_energy_wh=COMPENSATED[EnergyCounter.CONSUMED], + produced_energy_wh=COMPENSATED[EnergyCounter.PRODUCED], + ) + assert sensor.native_value == pytest.approx(description.value_fn(compensated)) + + +@pytest.mark.parametrize( + "description", + [d for d in PANEL_ENERGY_SENSORS if d.energy_role is EnergyRole.NET], + ids=lambda d: d.key, +) +def test_a_panel_nets_adjustment_is_oriented_as_its_value( + description: SpanPanelDataSensorEntityDescription, +) -> None: + """Main Meter Net and Feed Through Net, through the same path a circuit's Net takes.""" + assert description.panel_meter is not None + fields = PANEL_COUNTER_FIELDS[description.panel_meter] + snapshot = replace( + SpanPanelSnapshotFactory.create(), + **{fields[counter]: RAW[counter] for counter in EnergyCounter}, + ) + coordinator = _make_coordinator(snapshot) + coordinator.dip_offset.side_effect = _offset_of(description.panel_meter) + sensor = SpanPanelEnergySensor(coordinator, description, snapshot) + + sensor._handle_online_state() + + compensated = replace(snapshot, **{fields[counter]: COMPENSATED[counter] for counter in EnergyCounter}) + assert sensor.native_value == pytest.approx(description.value_fn(compensated)) + + +# --------------------------------------------------------------------------- +# The binding is made exactly once +# --------------------------------------------------------------------------- + + +def test_an_energy_sensor_that_never_binds_its_energy_fails_at_construction() -> None: + class _Forgetful(SpanPanelEnergySensor): + def __init__( + self, + data_coordinator: MagicMock, + description: SpanPanelDataSensorEntityDescription, + snapshot: SpanPanelSnapshot, + ) -> None: + SpanEnergySensorBase.__init__(self, data_coordinator, description, snapshot) + + snapshot = SpanPanelSnapshotFactory.create() + + with pytest.raises(TypeError, match="_bind_energy"): + _Forgetful(_make_coordinator(snapshot), _panel_description("mainMeterNetEnergyWh"), snapshot) + + +def test_an_energy_sensor_cannot_be_bound_twice() -> None: + snapshot = SpanPanelSnapshotFactory.create() + sensor = SpanPanelEnergySensor( + _make_coordinator(snapshot), _panel_description("mainMeterEnergyConsumedWh"), snapshot + ) + + with pytest.raises(RuntimeError, match="twice"): + sensor._bind_energy(EnergyBinding(meter=None, role=None, net=None)) + + +def test_feed_through_net_reads_its_own_meter_and_equals_the_raw_difference() -> None: + snapshot = SpanPanelSnapshotFactory.create( + feedthrough_energy_consumed_wh=5.0, feedthrough_energy_produced_wh=1.0 + ) + coordinator = _make_coordinator(snapshot) + coordinator.dip_offset.side_effect = _offsets(PanelMeter.FEEDTHROUGH, produced=0.0, consumed=0.0) + sensor = SpanPanelEnergySensor(coordinator, _panel_description("feedthroughNetEnergyWh"), snapshot) + + sensor._handle_online_state() - assert sensor.native_value == 23.0 + assert sensor.native_value == pytest.approx(4.0) def test_circuit_energy_sensor_missing_circuit_uses_fallback_names() -> None: @@ -395,10 +830,6 @@ def test_circuit_energy_sensor_missing_circuit_uses_fallback_names() -> None: sensor = SpanCircuitEnergySensor(coordinator, description, snapshot, "c9") - assert ( - sensor._generate_friendly_name(snapshot, sensor.entity_description) - == "Circuit c9 Consumed Energy" - ) assert ( sensor._generate_panel_name(snapshot, sensor.entity_description) == "Circuit c9 Consumed Energy" @@ -420,7 +851,6 @@ def test_circuit_energy_sensor_subdevice_uses_description_only() -> None: device_info_override={"identifiers": {("span_panel", "evse")}}, ) - assert sensor._generate_friendly_name(snapshot, sensor.entity_description) == "Consumed Energy" assert sensor._generate_panel_name(snapshot, sensor.entity_description) == "Consumed Energy" @@ -452,7 +882,7 @@ def test_unmapped_circuit_sensor_generates_unmapped_friendly_name() -> None: sensor = SpanUnmappedCircuitSensor(coordinator, UNMAPPED_SENSORS[0], snapshot, "unmapped_tab_7") assert ( - sensor._generate_friendly_name(snapshot, sensor.entity_description) + sensor._generate_panel_name(snapshot, sensor.entity_description) == "Unmapped Tab 7 Power" ) @@ -589,6 +1019,13 @@ def test_energy_sensor_coerces_invalid_grace_period_value() -> None: title="SPAN Panel", unique_id=snapshot.serial_number, ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) description = next( desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh" ) @@ -658,6 +1095,13 @@ def test_evse_sensor_uses_evse_subdevice_info_and_name() -> None: title="SPAN Panel", unique_id=snapshot.serial_number, ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) description = next(desc for desc in EVSE_SENSORS if desc.key == "evse_status") sensor = SpanEvseSensor(coordinator, description, snapshot, "evse-0") @@ -1129,8 +1573,8 @@ def test_energy_sensor_name_change_requests_reload() -> None: assert sensor._previous_circuit_name == "Renamed Kitchen" -def test_circuit_sensor_entity_id_stable_in_circuit_numbers_mode() -> None: - """Entity name should be circuit-based in circuit-numbers mode for entity_id stability.""" +def test_circuit_sensor_takes_the_panel_name_in_circuit_numbers_mode() -> None: + """The name follows the panel; the entity_id does not follow the name.""" circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen", tabs=[5]) snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) coordinator = _make_coordinator(snapshot, options={USE_CIRCUIT_NUMBERS: True}) @@ -1145,14 +1589,14 @@ def test_circuit_sensor_entity_id_stable_in_circuit_numbers_mode() -> None: sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1") - # In circuit-numbers mode, _attr_name should be circuit-based (contains "Circuit") - assert sensor._attr_name is not None - assert "Circuit" in sensor._attr_name + assert sensor._attr_name == "Kitchen Current" + assert sensor.suggested_object_id == "Circuit 5 current" + assert sensor.entity_id is None # Core assigns it when the platform adds the entity assert sensor._previous_circuit_name == "Kitchen" -def test_circuit_sensor_name_change_updates_registry_in_circuit_numbers_mode() -> None: - """In circuit-numbers mode, name changes update registry display name without reload.""" +def test_circuit_sensor_name_change_requests_reload_in_circuit_numbers_mode() -> None: + """A renamed circuit reloads, which is what rebuilds original_name.""" circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen", tabs=[5]) snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit}) coordinator = _make_coordinator(snapshot, options={USE_CIRCUIT_NUMBERS: True}) @@ -1179,15 +1623,47 @@ def test_circuit_sensor_name_change_updates_registry_in_circuit_numbers_mode() - with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get: runtime_registry = MagicMock() runtime_entry = MagicMock() - runtime_entry.name = "Kitchen Current" + # Released at construction, so nothing occupies the field any more. + runtime_entry.name = None runtime_registry.async_get.return_value = runtime_entry mock_async_get.return_value = runtime_registry sensor._handle_coordinator_update() - # Registry should be updated with the new display name - runtime_registry.async_update_entity.assert_called_once_with( - "sensor.circuit_5_current", name="Renamed Kitchen Current" - ) - # No reload should be requested in circuit-numbers mode - coordinator.request_reload.assert_not_called() + coordinator.request_reload.assert_called_once() + runtime_registry.async_update_entity.assert_not_called() assert sensor._previous_circuit_name == "Renamed Kitchen" + + +# --------------------------------------------------------------------------- +# The compensation option is read as a bool, never as whatever was stored +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize(("stored", "expected"), [(True, True), (False, False), (None, False), ("false", False), (1, False)]) +def test_option_bool_reads_only_a_bool(stored: object, expected: bool) -> None: + assert option_bool(stored, False) is expected + + +def test_a_non_bool_compensation_option_compensates_nothing() -> None: + """A truthy string is not "on": the sensor and its Net would otherwise disagree with the option.""" + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot, options={ENABLE_ENERGY_DIP_COMPENSATION: "false"}) + + sensor = SpanPanelEnergySensor(coordinator, _panel_description("mainMeterEnergyConsumedWh"), snapshot) + + assert sensor._dip_compensation_enabled is False + + +def test_the_compensation_option_is_reread_as_a_bool_on_each_update() -> None: + snapshot = SpanPanelSnapshotFactory.create() + coordinator = _make_coordinator(snapshot, options={ENABLE_ENERGY_DIP_COMPENSATION: True}) + sensor = SpanPanelEnergySensor(coordinator, _panel_description("mainMeterEnergyConsumedWh"), snapshot) + assert sensor._dip_compensation_enabled is True + + coordinator.config_entry = MockConfigEntry( + domain="span_panel", options={ENABLE_ENERGY_DIP_COMPENSATION: "false"} + ) + with patch.object(sensor, "async_write_ha_state"): + sensor._handle_coordinator_update() + + assert sensor._dip_compensation_enabled is False diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index 1bb17f56..a3825e4a 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -7,6 +7,8 @@ import pytest +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.control_gate import ControlPolicy from custom_components.span_panel.const import ( DOMAIN, ENABLE_CIRCUIT_NET_ENERGY_SENSORS, @@ -14,6 +16,7 @@ ENABLE_UNMAPPED_CIRCUIT_SENSORS, USE_CIRCUIT_NUMBERS, ) +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.sensor import ( _build_evse_device_info_map, async_setup_entry, @@ -25,14 +28,16 @@ create_power_flow_sensors, create_unmapped_circuit_sensors, ) +from custom_components.span_panel.sensor_definitions import EVSE_SENSORS from homeassistant.core import HomeAssistant from .factories import ( SpanBatterySnapshotFactory, SpanCircuitSnapshotFactory, SpanEvseSnapshotFactory, - SpanPanelSnapshotFactory, SpanPVSnapshot, + SpanPanelSnapshotFactory, + pv_binding_for, ) from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -47,7 +52,12 @@ async def test_sensor_async_setup_entry_adds_entities_and_refreshes( coordinator.data = snapshot coordinator.async_request_refresh = AsyncMock() entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") - entry.runtime_data = MagicMock(coordinator=coordinator) + entry.runtime_data = MagicMock( + control_policy=ControlPolicy.default(), + coordinator=coordinator, + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) entities = [MagicMock(), MagicMock()] async_add_entities = MagicMock() @@ -66,10 +76,16 @@ async def test_sensor_async_setup_entry_logs_and_reraises_errors( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Setup errors should be logged and re-raised.""" + snapshot = SpanPanelSnapshotFactory.create() coordinator = MagicMock() - coordinator.data = SpanPanelSnapshotFactory.create() + coordinator.data = snapshot entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") - entry.runtime_data = MagicMock(coordinator=coordinator) + entry.runtime_data = MagicMock( + control_policy=ControlPolicy.default(), + coordinator=coordinator, + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) caplog.set_level(logging.ERROR) @@ -213,6 +229,12 @@ def test_create_panel_sensors_filters_net_energy_and_adds_diagnostics() -> None: assert "downstream_l1_current" in keys assert "downstream_l2_current" in keys assert "main_breaker_rating" in keys + assert { + "mainMeterEnergyProducedWh", + "mainMeterEnergyConsumedWh", + "feedthroughEnergyProducedWh", + "feedthroughEnergyConsumedWh", + } <= set(keys) def test_build_evse_device_info_map_uses_feed_circuit_and_display_suffix() -> None: @@ -236,6 +258,13 @@ def test_build_evse_device_info_map_uses_feed_circuit_and_display_suffix() -> No title="SPAN Panel", options={USE_CIRCUIT_NUMBERS: False}, ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) mapping = _build_evse_device_info_map(coordinator, snapshot) @@ -274,6 +303,13 @@ def test_create_circuit_sensors_skips_unmapped_and_optional_net_sensors() -> Non unique_id=snapshot.serial_number, ) coordinator.config_entry = entry + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) entities = create_circuit_sensors(coordinator, snapshot, entry) keys = [entity.original_key for entity in entities] @@ -284,6 +320,7 @@ def test_create_circuit_sensors_skips_unmapped_and_optional_net_sensors() -> Non assert "circuit_energy_net" not in keys assert "circuit_current" in keys assert "circuit_breaker_rating" in keys + assert {"circuit_power", "circuit_energy_produced", "circuit_energy_consumed"} <= set(keys) assert any( entity.device_info["name"] == "Main House SPAN Drive (Kitchen)" for entity in entities @@ -306,6 +343,13 @@ def test_create_unmapped_circuit_sensors_only_creates_unmapped_entities() -> Non coordinator.config_entry = MockConfigEntry( domain=DOMAIN, data={}, title="SPAN Panel" ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) entities = create_unmapped_circuit_sensors(coordinator, snapshot) @@ -317,7 +361,7 @@ def test_create_battery_sensors_returns_expected_entities_when_bess_present() -> """Battery helpers should create battery power, SoE, and metadata sensors.""" snapshot = SpanPanelSnapshotFactory.create( battery=SpanBatterySnapshotFactory.create( - soe_percentage=75.0, vendor_name="Tesla", product_name="Powerwall" + soe_percentage=75.0, vendor_name="Tesla", model="Powerwall" ) ) coordinator = MagicMock() @@ -328,14 +372,22 @@ def test_create_battery_sensors_returns_expected_entities_when_bess_present() -> data={"device_name": "Main House"}, title="SPAN Panel", ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) entities = create_battery_sensors(coordinator, snapshot) keys = [entity.entity_description.key for entity in entities] - assert len(entities) == 8 + assert len(entities) == 9 assert "batteryPowerW" in keys assert "storage_battery_percentage" in keys assert "vendor" in keys + assert "part_number" in keys def test_create_power_flow_sensors_gate_pv_and_site_flow() -> None: @@ -352,6 +404,13 @@ def test_create_power_flow_sensors_gate_pv_and_site_flow() -> None: coordinator.config_entry = MockConfigEntry( domain=DOMAIN, data={}, title="SPAN Panel" ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) entities = create_power_flow_sensors(coordinator, snapshot) keys = [entity.entity_description.key for entity in entities] @@ -376,8 +435,15 @@ def test_create_evse_sensors_creates_all_descriptions_for_each_charger() -> None coordinator.config_entry = MockConfigEntry( domain=DOMAIN, data={}, title="SPAN Panel" ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) entities = create_evse_sensors(coordinator, snapshot) - assert len(entities) == 6 + assert len(entities) == len(EVSE_SENSORS) * 2 assert {entity._evse_id for entity in entities} == {"evse-0", "evse-1"} diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index 2152db52..d7a4f1eb 100644 --- a/tests/test_setup_entry.py +++ b/tests/test_setup_entry.py @@ -2,10 +2,22 @@ from __future__ import annotations +from dataclasses import replace from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant.config_entries import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.httpx_client import get_async_client import pytest -from span_panel_api.exceptions import SpanPanelAuthError +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import SpanPanelSnapshot, SpanPVSnapshot +from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelServerError from custom_components.span_panel import SpanPanelRuntimeData, async_setup_entry from custom_components.span_panel.const import ( @@ -17,17 +29,14 @@ CONF_HTTP_PORT, DOMAIN, ) -from homeassistant.config_entries import ( - ConfigEntryAuthFailed, - ConfigEntryError, - ConfigEntryNotReady, -) -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant +from custom_components.span_panel.control_gate import ControlPolicy +from custom_components.span_panel.curation import CurationOverlay, CurationRecord +from custom_components.span_panel.id_builder import build_pv_inverter_unique_id +from custom_components.span_panel.options import CONTROL_LOCK_TIMEOUT +from custom_components.span_panel.pv_binding import PvBinding +from custom_components.span_panel.services import _ROTATIONS_IN_PROGRESS -from .factories import SpanPanelSnapshotFactory - -from pytest_homeassistant_custom_component.common import MockConfigEntry +from .factories import SpanPanelSnapshotFactory, pv_binding_for def _create_v2_entry(**data_overrides) -> MockConfigEntry: @@ -76,7 +85,7 @@ async def test_async_setup_entry_v2_success_sets_runtime_data_and_title( ) as mock_coordinator_cls, patch( "custom_components.span_panel.ensure_device_registered", - AsyncMock(), + AsyncMock(return_value="panel-device-id"), ) as mock_ensure_device, patch.object( hass.config_entries, "async_forward_entry_setups", AsyncMock() @@ -85,7 +94,14 @@ async def test_async_setup_entry_v2_success_sets_runtime_data_and_title( ): assert await async_setup_entry(hass, entry) is True - assert entry.runtime_data == SpanPanelRuntimeData(coordinator=coordinator) + # The panel's registry id is carried forward, not recomputed: every sub-device + # links to it with `via_device_id`, and registration is the only place it is known. + assert isinstance(entry.runtime_data, SpanPanelRuntimeData) + assert entry.runtime_data.coordinator is coordinator + assert entry.runtime_data.panel_device_id == "panel-device-id" + # The control lock is one object per entry, shared with the gate, so it is + # compared by identity elsewhere and never by value. + assert entry.runtime_data.control_lock.armed is False assert hass.data[DOMAIN]["websocket_registered"] is True mock_ws.assert_called_once_with(hass) mock_client_cls.assert_called_once() @@ -98,6 +114,82 @@ async def test_async_setup_entry_v2_success_sets_runtime_data_and_title( mock_update_entry.assert_called_once_with(entry, title="SPAN Panel") +async def test_the_panel_client_is_given_home_assistants_shared_http_client( + hass: HomeAssistant, +) -> None: + """Not a client of its own, and not a copy: the one instance HA hands out. + + Without this the library builds a throwaway client per schema read -- once at + connect, and once per retry while a panel finishes rebooting after a firmware + upgrade. `quality_scale.yaml` declares `inject-websession: done`, and that was + true of the config flow and of nothing that ran afterwards. + + Asserted by identity rather than by type. A test that only checked something + was passed would pass just as well for a fresh client built here, which is + the thing being removed -- HA owns this one and closes it at shutdown. + """ + entry = _create_v2_entry() + entry.add_to_hass(hass) + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch( + "custom_components.span_panel.SpanMqttClient", return_value=client + ) as mock_client_cls, + patch( + "custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + assert mock_client_cls.call_args.kwargs["httpx_client"] is get_async_client(hass) + + +async def test_a_panel_that_is_not_ready_yet_retries_rather_than_dying( + hass: HomeAssistant, +) -> None: + """A rebooting panel answers rather than refusing, and that is not a broken install. + + 5xx from its front end while the application behind it starts, or a 200 with + nothing usable in it, both arrive as `SpanPanelServerError`. Uncaught they + produced SETUP_ERROR with a traceback and no automatic retry — a human needed, + for a condition that clears itself in minutes. + + The two conditions correlate more than they look: one power event takes out + the house's electrical panel and the Home Assistant host watching it, and they + race each other back up. `ConfigEntryNotReady` is what makes the race + survivable. + """ + entry = _create_v2_entry() + entry.add_to_hass(hass) + client = MagicMock() + client.connect = AsyncMock( + side_effect=SpanPanelServerError("Panel not ready: HTTP 502", 502) + ) + client.close = AsyncMock() + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + pytest.raises(ConfigEntryNotReady), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + + async def test_async_setup_entry_v2_missing_mqtt_credentials_raises_auth_failed( hass: HomeAssistant, ) -> None: @@ -165,6 +257,33 @@ async def test_async_setup_entry_v2_auth_error_closes_client( client.close.assert_awaited_once() +async def test_broker_auth_refusal_during_a_rotation_is_not_ready_not_reauth( + hass: HomeAssistant, +) -> None: + """Right after a rotation the broker may still refuse the new password. + + `rotate_credentials` retries the reload, so the refusal must not start a + reauth flow while it does. + """ + entry = _create_v2_entry() + entry.add_to_hass(hass) + client = MagicMock() + client.connect = AsyncMock(side_effect=SpanPanelAuthError("bad auth")) + client.close = AsyncMock() + hass.data.setdefault(_ROTATIONS_IN_PROGRESS, set()).add(entry.entry_id) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch( + "custom_components.span_panel.SpanMqttClient", return_value=client + ), + pytest.raises(ConfigEntryNotReady, match="rotated password"), + ): + await async_setup_entry(hass, entry) + + client.close.assert_awaited_once() + + async def test_async_setup_entry_v1_requires_reauth( hass: HomeAssistant, ) -> None: @@ -284,3 +403,351 @@ async def test_async_setup_entry_shutdowns_coordinator_on_forward_failure( await async_setup_entry(hass, entry) coordinator.async_shutdown.assert_awaited_once() + + +async def test_setup_syncs_schema_repairs_after_the_platforms( + hass: HomeAssistant, +) -> None: + """Repairs must be reconciled after the platforms, never before. + + A schema Repair names the entities an unresolved field took down, and those + entities record themselves only once their platform has added them. Schema + validation itself runs on the first refresh, which setup awaits well before + forwarding the platforms — reconciling there would report every dead field + as affecting zero entities. + """ + entry = _create_v2_entry() + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = snapshot + + order: list[str] = [] + coordinator.async_sync_schema_repairs = MagicMock( + side_effect=lambda: order.append("sync") + ) + + async def _forward(*_args, **_kwargs) -> None: + order.append("forward") + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.SpanPanelCoordinator", + return_value=coordinator, + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object( + hass.config_entries, "async_forward_entry_setups", AsyncMock(side_effect=_forward) + ), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + assert order == ["forward", "sync"] + + +async def test_setup_announces_additions_after_the_platforms( + hass: HomeAssistant, +) -> None: + """The announcement has to run after the forward, and that is the whole ordering. + + A newly added entity is only in the registry once its platform has added it, + so announcing before the forward would announce nothing, every time. The old + mechanism also needed a *probe* before the forward, because it diffed the + registry across it; the announcement record replaced that, which is what makes + the answer survive a restart landing between the two. + """ + entry = _create_v2_entry() + entry.add_to_hass(hass) + snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = snapshot + + registry = er.async_get(hass) + registry.async_get_or_create("sensor", DOMAIN, "already-there", config_entry=entry) + + order: list[str] = [] + coordinator.async_sync_schema_repairs = MagicMock(side_effect=lambda: order.append("sync")) + + async def _forward(*_args, **_kwargs) -> None: + order.append("forward") + registry.async_get_or_create( + "sensor", + DOMAIN, + "added-by-the-forward", + config_entry=entry, + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + ) + + async def _announce(_hass, _entry) -> None: + order.append("announce") + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.SpanPanelCoordinator", + return_value=coordinator, + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object( + hass.config_entries, "async_forward_entry_setups", AsyncMock(side_effect=_forward) + ), + patch.object(hass.config_entries, "async_update_entry"), + patch( + "custom_components.span_panel.async_announce_new_entities", + side_effect=_announce, + ), + ): + assert await async_setup_entry(hass, entry) is True + + assert order == ["forward", "sync", "announce"] + + +async def test_an_enabled_control_lock_is_armed_before_the_platforms_are_forwarded( + hass: HomeAssistant, +) -> None: + """The gate consults the lock long before the switch exists to restore it. + + `set_control_interceptor` happens above the first refresh; the platform + forward is the last thing setup does. A lock built disarmed would leave + that whole window -- a first refresh, a streaming start, a device + registration -- with the feature nominally on and nothing refusing. + + Asserted here rather than through the entity because the entity is exactly + what has not been created yet at the moment this matters. + """ + entry = _create_v2_entry() + entry.add_to_hass(hass) + hass.config_entries.async_update_entry(entry, options={CONTROL_LOCK_TIMEOUT: 0}) + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + assert entry.runtime_data.control_lock.armed is True + + +def test_runtime_data_defaults_its_lock_to_the_default_policys_answer() -> None: + """The dataclass default may not contradict the policy it stands in for. + + `control_lock` is defaulted rather than required, so an entry built without + one gets whatever this class decides -- and a bare `ControlLock()` would be + disarmed, a promise about a feature the class has no way to check. Deriving + it from `ControlPolicy.default()` is what keeps it honest, and this pins the + two together so a change to the default policy cannot silently unlock the + entries that never named a lock. + """ + runtime_data = SpanPanelRuntimeData( + coordinator=MagicMock(), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(SpanPanelSnapshotFactory.create()), + setup_snapshot=SpanPanelSnapshotFactory.create(), + ) + + assert runtime_data.control_lock.armed == ControlPolicy.default().lock_enabled + + +def test_runtime_data_refuses_to_be_built_without_an_overlay() -> None: + """`curation` is required so a setup path that forgets the load fails loudly. + + Defaulting it to an empty overlay would make a missed load indistinguishable + from a user who has curated nothing -- every adopted entity born bare, with + the user's stored assertions still on disk and nothing saying why they stopped + being applied. + """ + with pytest.raises(TypeError, match="curation"): + SpanPanelRuntimeData(coordinator=MagicMock(), panel_device_id="panel-device-id") + + +async def test_setup_hands_the_platforms_the_overlay_that_was_on_disk( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + """Loaded before the platforms are forwarded, so adopted entities are born curated. + + Applying it afterwards would mean every adopted entity exists uncurated for + the length of a setup, and a `state_class` that arrives after the first state + is written is a statistics reset rather than a metadata change. + """ + hass_storage["span_panel.curation.entry-setup"] = { + "version": 1, + "data": {"records": {"bess/battery-2/cell-voltage": {"device_class": "voltage"}}}, + } + entry = _create_v2_entry() + entry.add_to_hass(hass) + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001") + forwarded_overlay: list[CurationOverlay] = [] + + async def _capture(*args: object, **kwargs: object) -> None: + forwarded_overlay.append(entry.runtime_data.curation) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch( + "custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator + ), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object(hass.config_entries, "async_forward_entry_setups", _capture), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + + assert forwarded_overlay[0].record_for("bess/battery-2/cell-voltage") == CurationRecord( + device_class="voltage" + ) + + +async def _forwarded_pv_binding(hass: HomeAssistant, entry: MockConfigEntry, snapshot: SpanPanelSnapshot) -> PvBinding: + """Run `async_setup_entry` over a coordinator publishing `snapshot`; return the binding the platforms were handed.""" + client = MagicMock() + client.connect = AsyncMock() + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.async_setup_streaming = AsyncMock() + coordinator.data = snapshot + forwarded: list[PvBinding] = [] + + async def _capture(*args: object, **kwargs: object) -> None: + forwarded.append(entry.runtime_data.pv_binding) + + with ( + patch("custom_components.span_panel.async_register_commands"), + patch("custom_components.span_panel.SpanMqttClient", return_value=client), + patch("custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator), + patch( + "custom_components.span_panel.ensure_device_registered", + AsyncMock(return_value="panel-device-id"), + ), + patch.object(hass.config_entries, "async_forward_entry_setups", _capture), + patch.object(hass.config_entries, "async_update_entry"), + ): + assert await async_setup_entry(hass, entry) is True + (binding,) = forwarded + return binding + + +SETUP_CIRCUIT = "c-setup" + + +def _one_fed_inverter() -> SpanPanelSnapshot: + return replace( + SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001"), + pv_inverters={ + SETUP_CIRCUIT: SpanPVSnapshot(device_id="pv", node_id=SETUP_CIRCUIT, feed_circuit_id=SETUP_CIRCUIT) + }, + ) + + +async def test_setup_resolves_the_pv_binding_before_the_platforms( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + """The platforms are handed the identity the record on disk decides, not one derived after them.""" + hass_storage["span_panel.pv_binding.entry-setup"] = {"version": 1, "data": {"circuit_id": "c-bound"}} + entry = _create_v2_entry() + entry.add_to_hass(hass) + + binding = await _forwarded_pv_binding(hass, entry, SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001")) + + assert binding.bound_key == "c-bound" + assert binding.mode == "inverter" + + +async def test_setup_decides_the_pv_binding_from_the_coordinators_snapshot( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + """No record: the one inverter `coordinator.data` publishes is bound, and the record is written through setup.""" + entry = _create_v2_entry() + entry.add_to_hass(hass) + + binding = await _forwarded_pv_binding(hass, entry, _one_fed_inverter()) + + assert binding.mode == "inverter" + assert binding.bound_key == SETUP_CIRCUIT + stored = hass_storage["span_panel.pv_binding.entry-setup"] + assert isinstance(stored, dict) + assert stored["data"] == {"circuit_id": SETUP_CIRCUIT} + + +async def test_setup_reads_the_registry_for_cards_the_inverter_already_holds( + hass: HomeAssistant, hass_storage: dict[str, object] +) -> None: + """No record, but the inverter already has a card in this entry: it keeps it, and the record is unbound.""" + entry = _create_v2_entry() + entry.add_to_hass(hass) + er.async_get(hass).async_get_or_create( + "sensor", + DOMAIN, + build_pv_inverter_unique_id("sp3-setup-001", SETUP_CIRCUIT, "pv_vendor"), + config_entry=entry, + ) + + binding = await _forwarded_pv_binding(hass, entry, _one_fed_inverter()) + + assert binding.mode == "unbound" + assert binding.has_own_card(SETUP_CIRCUIT) + stored = hass_storage["span_panel.pv_binding.entry-setup"] + assert isinstance(stored, dict) + assert stored["data"] == {"circuit_id": None} + + +def test_runtime_data_refuses_to_be_built_without_a_setup_snapshot() -> None: + """Required like `pv_binding`: the platforms must build from the snapshot setup decided from.""" + snapshot = SpanPanelSnapshotFactory.create() + with pytest.raises(TypeError, match="setup_snapshot"): + SpanPanelRuntimeData( + coordinator=MagicMock(), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + ) + + +def test_runtime_data_refuses_to_be_built_without_a_pv_binding() -> None: + """Required like `curation`: a setup path that forgets to resolve it must fail here.""" + with pytest.raises(TypeError, match="pv_binding"): + SpanPanelRuntimeData( + coordinator=MagicMock(), panel_device_id="panel-device-id", curation=CurationOverlay.empty() + ) diff --git a/tests/test_shed_forecast.py b/tests/test_shed_forecast.py new file mode 100644 index 00000000..67ea5368 --- /dev/null +++ b/tests/test_shed_forecast.py @@ -0,0 +1,481 @@ +"""The backup-planning forecast, surfaced as two sensors and three attributes. + +Every assertion here runs against a real snapshot built by the real schema_1 +adapter over the reference capture, and every expected value is read out of that +capture rather than written as a literal. That is deliberate and it is the whole +design of this module: a test that pins the same constant the code pins passes +whether or not the wire is ever read, so each reading is proved by republishing +it — a different value, or none — and asserting the entity moved. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.field_paths import ( + RESIDUAL_EXEMPT_PATHS, + Producibility, +) +from custom_components.span_panel.helpers import detect_capabilities, has_shed_forecast +from custom_components.span_panel.sensor import create_shed_forecast_sensors +from custom_components.span_panel.sensor_definitions import SHED_FORECAST_SENSORS +from custom_components.span_panel.sensor_panel import SpanShedForecastSensor +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import CONF_HOST, UnitOfTime +from homeassistant.helpers.entity import EntityCategory + +from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree +from .factories import SpanPanelSnapshotFactory, pv_binding_for + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +NODE = "shed-forecast" + +TIME_TO_PRIORITY_SHED = "time-to-priority-shed" +TOTAL_TIME_REMAINING = "total-time-remaining" +FULL_CHARGE_TIME_TO_PRIORITY_SHED = "full-charge-time-to-priority-shed" +FULL_CHARGE_TOTAL_TIME_REMAINING = "full-charge-total-time-remaining" +CONFIDENCE = "confidence" + +TIME_TO_PRIORITY_SHED_KEY = "time_to_priority_shed" +TOTAL_TIME_REMAINING_KEY = "shed_total_time_remaining" + + +@pytest.fixture(autouse=True) +def _mock_entity_registry() -> Any: + """Patch entity registry lookups used during sensor construction.""" + registry = MagicMock() + registry.async_get_entity_id.return_value = None + with patch( + "custom_components.span_panel.sensor_base.er.async_get", + return_value=registry, + ): + yield registry + + +def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock: + """A coordinator-like mock carrying one snapshot.""" + coordinator = MagicMock() + coordinator.data = snapshot + coordinator.hass = MagicMock() + coordinator.panel_offline = False + coordinator.transport_dead = False + coordinator.unresolved_paths = frozenset() + coordinator.config_entry = MockConfigEntry( + domain="span_panel", + data={CONF_HOST: "192.168.1.50"}, + options={}, + title="SPAN Panel", + unique_id=snapshot.serial_number, + ) + coordinator.config_entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + return coordinator + + +def _published(property_id: str) -> str: + """What the capture publishes for one forecast property.""" + return schema_one_tree()[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"] + + +def _republishing(**topics: str) -> SpanPanelSnapshot: + """A snapshot from the capture with some forecast topics rewritten.""" + tree = schema_one_tree() + for property_id, value in topics.items(): + tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id.replace('_', '-')}"] = value + return schema_one_snapshot(tree) + + +def _without(*property_ids: str) -> SpanPanelSnapshot: + """A snapshot from a capture that stopped publishing (and declaring) properties.""" + tree = schema_one_tree() + description = json.loads(tree[SCHEMA_ONE_PANEL]["$description"]) + for property_id in property_ids: + del tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"] + del description["nodes"][NODE]["properties"][property_id] + tree[SCHEMA_ONE_PANEL]["$description"] = json.dumps(description) + return schema_one_snapshot(tree) + + +def _without_node() -> SpanPanelSnapshot: + """A snapshot from a capture with no `shed-forecast` node at all.""" + tree = schema_one_tree() + for topic in [t for t in tree[SCHEMA_ONE_PANEL] if t.startswith(f"{NODE}/")]: + del tree[SCHEMA_ONE_PANEL][topic] + description = json.loads(tree[SCHEMA_ONE_PANEL]["$description"]) + del description["nodes"][NODE] + tree[SCHEMA_ONE_PANEL]["$description"] = json.dumps(description) + return schema_one_snapshot(tree) + + +def _sensors(snapshot: SpanPanelSnapshot) -> dict[str, SpanShedForecastSensor]: + """Whatever the platform creates for this snapshot, keyed by description key.""" + created = create_shed_forecast_sensors(_coordinator(snapshot), snapshot) + return {sensor.entity_description.key: sensor for sensor in created} + + +def _state(snapshot: SpanPanelSnapshot, key: str) -> float | int | str | None: + """The state one forecast sensor reports for a snapshot.""" + sensor = _sensors(snapshot)[key] + sensor._update_native_value() + return sensor.native_value + + +def _attributes(snapshot: SpanPanelSnapshot, key: str) -> dict[str, Any]: + return _sensors(snapshot)[key].extra_state_attributes or {} + + +# --------------------------------------------------------------------------- +# The premise: the capture publishes the capability +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_the_whole_capability() -> None: + """Guard the premise for every test below, all of which read the capture for + their expected value: a capture that stopped publishing the node would make + them vacuously true rather than failing.""" + panel = schema_one_tree()[SCHEMA_ONE_PANEL] + + for property_id in ( + TIME_TO_PRIORITY_SHED, + TOTAL_TIME_REMAINING, + FULL_CHARGE_TIME_TO_PRIORITY_SHED, + FULL_CHARGE_TOTAL_TIME_REMAINING, + CONFIDENCE, + ): + assert f"{NODE}/{property_id}" in panel + + +# --------------------------------------------------------------------------- +# States follow the wire +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("key", "property_id"), + [ + (TIME_TO_PRIORITY_SHED_KEY, TIME_TO_PRIORITY_SHED), + (TOTAL_TIME_REMAINING_KEY, TOTAL_TIME_REMAINING), + ], +) +def test_each_sensor_reports_the_value_the_panel_published(key: str, property_id: str) -> None: + assert _state(schema_one_snapshot(), key) == float(_published(property_id)) + + +def test_the_two_sensors_do_not_report_the_same_reading() -> None: + """The capture publishes different values for the two estimates, so a wiring + that crossed them would fail here rather than looking plausible.""" + snapshot = schema_one_snapshot() + + assert _state(snapshot, TIME_TO_PRIORITY_SHED_KEY) != _state( + snapshot, TOTAL_TIME_REMAINING_KEY + ) + + +@pytest.mark.parametrize( + ("key", "property_id", "republished"), + [ + (TIME_TO_PRIORITY_SHED_KEY, TIME_TO_PRIORITY_SHED, "17"), + (TOTAL_TIME_REMAINING_KEY, TOTAL_TIME_REMAINING, "1440"), + ], +) +def test_republishing_an_estimate_moves_its_sensor( + key: str, property_id: str, republished: str +) -> None: + """The mutation proof. Each republished value differs from every value the + capture carries, so a sensor pinned to a constant — or wired to the wrong + property — cannot report it.""" + snapshot = _republishing(**{property_id.replace("-", "_"): republished}) + + assert _state(snapshot, key) == float(republished) + assert _state(snapshot, key) != float(_published(property_id)) + + +@pytest.mark.parametrize( + ("key", "property_id"), + [ + (TIME_TO_PRIORITY_SHED_KEY, TIME_TO_PRIORITY_SHED), + (TOTAL_TIME_REMAINING_KEY, TOTAL_TIME_REMAINING), + ], +) +def test_zero_minutes_is_a_state_and_not_an_absence(key: str, property_id: str) -> None: + """Shedding starts now is a reading, and the most important one the capability + reports. A gate that treated zero as absence would delete the entity exactly + when a user needs it.""" + snapshot = _republishing(**{property_id.replace("-", "_"): "0"}) + + assert key in _sensors(snapshot) + assert _state(snapshot, key) == 0.0 + + +# --------------------------------------------------------------------------- +# Attributes follow the wire +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("key", "attribute", "property_id"), + [ + ( + TIME_TO_PRIORITY_SHED_KEY, + "full_charge_time_to_priority_shed", + FULL_CHARGE_TIME_TO_PRIORITY_SHED, + ), + ( + TOTAL_TIME_REMAINING_KEY, + "full_charge_total_time_remaining", + FULL_CHARGE_TOTAL_TIME_REMAINING, + ), + ], +) +def test_each_sensor_carries_its_own_full_charge_twin( + key: str, attribute: str, property_id: str +) -> None: + """The pairing, not just the presence: each estimate carries the + full-charge figure that refines *it*.""" + attributes = _attributes(schema_one_snapshot(), key) + + assert attributes[attribute] == int(_published(property_id)) + + +def test_no_sensor_carries_the_other_sensors_twin() -> None: + """The capture publishes 3038 and 4320, and the second happens to equal the + live total — so only the pairing check above plus this one can tell a correct + wiring from a crossed one.""" + assert "full_charge_total_time_remaining" not in _attributes( + schema_one_snapshot(), TIME_TO_PRIORITY_SHED_KEY + ) + assert "full_charge_time_to_priority_shed" not in _attributes( + schema_one_snapshot(), TOTAL_TIME_REMAINING_KEY + ) + + +@pytest.mark.parametrize( + ("key", "attribute", "property_id"), + [ + ( + TIME_TO_PRIORITY_SHED_KEY, + "full_charge_time_to_priority_shed", + FULL_CHARGE_TIME_TO_PRIORITY_SHED, + ), + ( + TOTAL_TIME_REMAINING_KEY, + "full_charge_total_time_remaining", + FULL_CHARGE_TOTAL_TIME_REMAINING, + ), + ], +) +def test_republishing_a_full_charge_figure_moves_its_attribute( + key: str, attribute: str, property_id: str +) -> None: + snapshot = _republishing(**{property_id.replace("-", "_"): "999"}) + + assert _attributes(snapshot, key)[attribute] == 999 + + +@pytest.mark.parametrize( + ("key", "attribute", "property_id"), + [ + ( + TIME_TO_PRIORITY_SHED_KEY, + "full_charge_time_to_priority_shed", + FULL_CHARGE_TIME_TO_PRIORITY_SHED, + ), + ( + TOTAL_TIME_REMAINING_KEY, + "full_charge_total_time_remaining", + FULL_CHARGE_TOTAL_TIME_REMAINING, + ), + ], +) +def test_a_full_charge_figure_the_panel_drops_takes_its_attribute_with_it( + key: str, attribute: str, property_id: str +) -> None: + """Absent, not `None`. An attribute that is present and empty reads as a + reading the panel failed to produce; this is firmware that does not carry the + property at all.""" + snapshot = _without(property_id) + + assert attribute not in _attributes(snapshot, key) + # The sensor itself is unaffected: the estimate it reads is still published. + assert _state(snapshot, key) == float( + _published(TIME_TO_PRIORITY_SHED if key == TIME_TO_PRIORITY_SHED_KEY else TOTAL_TIME_REMAINING) + ) + + +@pytest.mark.parametrize("key", [TIME_TO_PRIORITY_SHED_KEY, TOTAL_TIME_REMAINING_KEY]) +def test_confidence_rides_on_both_sensors(key: str) -> None: + """It qualifies both estimates, so it appears on both.""" + assert _attributes(schema_one_snapshot(), key)["forecast_confidence"] == _published( + CONFIDENCE + ) + + +@pytest.mark.parametrize("key", [TIME_TO_PRIORITY_SHED_KEY, TOTAL_TIME_REMAINING_KEY]) +def test_republishing_confidence_moves_the_attribute(key: str) -> None: + snapshot = _republishing(confidence="LOW") + + assert _attributes(snapshot, key)["forecast_confidence"] == "LOW" + assert _attributes(snapshot, key)["forecast_confidence"] != _published(CONFIDENCE) + + +@pytest.mark.parametrize("key", [TIME_TO_PRIORITY_SHED_KEY, TOTAL_TIME_REMAINING_KEY]) +def test_confidence_the_panel_drops_takes_its_attribute_with_it(key: str) -> None: + assert "forecast_confidence" not in _attributes(_without(CONFIDENCE), key) + + +def test_a_sensor_with_neither_refinement_publishes_no_attributes_at_all() -> None: + """`None` rather than an empty dict, which is what the entity contract asks + for and what stops an empty attribute block rendering.""" + snapshot = _without(FULL_CHARGE_TIME_TO_PRIORITY_SHED, CONFIDENCE) + + assert _sensors(snapshot)[TIME_TO_PRIORITY_SHED_KEY].extra_state_attributes is None + + +# --------------------------------------------------------------------------- +# Creation is gated on what the panel publishes +# --------------------------------------------------------------------------- + + +def test_the_capture_creates_both_sensors() -> None: + assert set(_sensors(schema_one_snapshot())) == { + TIME_TO_PRIORITY_SHED_KEY, + TOTAL_TIME_REMAINING_KEY, + } + + +def test_a_panel_with_no_forecast_node_gets_no_sensors() -> None: + """The absence test. A dead entity stuck at unknown is worse than no entity: + it occupies the entity list, breaks a dashboard card, and cannot be told + apart from a panel whose forecast has failed.""" + snapshot = _without_node() + + assert has_shed_forecast(snapshot) is False + assert create_shed_forecast_sensors(_coordinator(snapshot), snapshot) == [] + + +def test_a_flat_panel_gets_no_sensors() -> None: + """The same absence by the other route: flat firmware publishes no such + capability, so the factory's default snapshot carries none of the fields.""" + snapshot = SpanPanelSnapshotFactory.create() + + assert has_shed_forecast(snapshot) is False + assert create_shed_forecast_sensors(_coordinator(snapshot), snapshot) == [] + + +@pytest.mark.parametrize( + ("dropped", "surviving"), + [ + (TIME_TO_PRIORITY_SHED, TOTAL_TIME_REMAINING_KEY), + (TOTAL_TIME_REMAINING, TIME_TO_PRIORITY_SHED_KEY), + ], +) +def test_a_partial_node_creates_only_the_sensor_it_can_fill( + dropped: str, surviving: str +) -> None: + """The catalog marks all four times SHOULD, not MUST, so a partial node is + legal firmware rather than a defect — and the half it omits must produce no + entity rather than one permanently unknown.""" + snapshot = _without(dropped) + + assert has_shed_forecast(snapshot) is True + assert set(_sensors(snapshot)) == {surviving} + + +def test_the_forecast_appearing_is_a_capability_change() -> None: + """Which is how a panel that gains the node mid-life gets the sensors: the + coordinator reloads on a new capability.""" + assert "shed_forecast" not in detect_capabilities(SpanPanelSnapshotFactory.create()) + assert "shed_forecast" in detect_capabilities(schema_one_snapshot()) + assert "shed_forecast" not in detect_capabilities(_without_node()) + + +# --------------------------------------------------------------------------- +# Shape of the entities +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("description", SHED_FORECAST_SENSORS, ids=lambda d: d.key) +def test_both_are_duration_sensors_in_the_unit_the_capability_declares( + description: Any, +) -> None: + assert description.device_class is SensorDeviceClass.DURATION + assert description.state_class is SensorStateClass.MEASUREMENT + assert description.native_unit_of_measurement == UnitOfTime.MINUTES + + +@pytest.mark.parametrize("description", SHED_FORECAST_SENSORS, ids=lambda d: d.key) +def test_both_are_enabled_by_default_and_not_filed_under_diagnostics( + description: Any, +) -> None: + """These are the numbers a user plans a backup around, which is the whole + argument for surfacing them ahead of the rest of the unread v1.0 surface. A + disabled or diagnostic sensor would surface them in name only.""" + assert description.entity_registry_enabled_default is True + assert description.entity_category is not EntityCategory.DIAGNOSTIC + + +def test_the_declared_unit_matches_what_the_panel_declares() -> None: + """HA's unit against the tree's, for the two paths schema_1 carries metadata + for. A disagreement here is what the integration's unit-mismatch Repair + reports at runtime; catching it in the suite is cheaper.""" + from .adapter_fixtures import schema_one_metadata + + metadata = schema_one_metadata() + for description, field_path in ( + (SHED_FORECAST_SENSORS[0], "panel.shed_time_to_priority_shed_min"), + (SHED_FORECAST_SENSORS[1], "panel.shed_total_time_remaining_min"), + ): + assert metadata[field_path].unit == description.native_unit_of_measurement + + +def test_the_two_sensors_get_distinct_unique_ids() -> None: + """They live on the same device and differ only by description key.""" + sensors = _sensors(schema_one_snapshot()) + unique_ids = {sensor.unique_id for sensor in sensors.values()} + + assert len(unique_ids) == len(sensors) + for unique_id in unique_ids: + assert schema_one_snapshot().serial_number.lower() in unique_id + + +# --------------------------------------------------------------------------- +# Conformance annotations +# --------------------------------------------------------------------------- + + +def test_the_two_live_estimates_are_exempt_as_schema_1_only() -> None: + """Pinned here as well as in the conformance suite, because the reason is + specific to this capability: no flat panel publishes it, so the producible + gate cannot be satisfied and the descriptions must stay derived.""" + assert ( + RESIDUAL_EXEMPT_PATHS["panel.shed_time_to_priority_shed_min"] + is Producibility.SCHEMA_1_ONLY + ) + assert ( + RESIDUAL_EXEMPT_PATHS["panel.shed_total_time_remaining_min"] + is Producibility.SCHEMA_1_ONLY + ) + + +def test_the_three_refinements_are_exempt_as_neither() -> None: + """No adapter maps them, by design — they qualify the two estimates rather + than being readings of their own.""" + for path in ( + "panel.shed_full_charge_time_to_priority_shed_min", + "panel.shed_full_charge_total_time_remaining_min", + "panel.shed_forecast_confidence", + ): + assert RESIDUAL_EXEMPT_PATHS[path] is Producibility.NEITHER diff --git a/tests/test_solar_topology.py b/tests/test_solar_topology.py new file mode 100644 index 00000000..fbb27eb6 --- /dev/null +++ b/tests/test_solar_topology.py @@ -0,0 +1,251 @@ +"""The topology command's `solar` block: which inverter a PV device shows and what its power is.""" + +from __future__ import annotations + +from typing import Final + +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel.helpers import has_pv +from custom_components.span_panel.pv_binding import PvBinding +from custom_components.span_panel.solar_topology import SolarTopology, solar_topology +from custom_components.span_panel.util import PvDeviceRef, pv_device_ref + +from .adapter_fixtures import schema_one_snapshot, schema_one_tree +from .factories import SpanPanelSnapshotFactory, pv_binding_for +from .test_pv_binding import NEW_CIRCUIT, _gateway_tree, _unfed_tree +from .test_pv_device import PV_DEVICE, SOLAR_CIRCUIT +from .test_pv_inverters import FIRST_PV, SECOND_PV, SECOND_SOLAR_CIRCUIT, UNFED_PV, _tree + +PANEL = "sp3-solar-001" + + +def test_the_solar_device_is_the_panels_pv_identifier() -> None: + assert pv_device_ref(f"{PANEL}_pv", PANEL) == PvDeviceRef(None) + + +def test_an_inverter_device_carries_its_key() -> None: + assert pv_device_ref(f"{PANEL}_pv_5be1d2c3a4f5061728394a5b6c7d8e9f", PANEL) == PvDeviceRef( + "5be1d2c3a4f5061728394a5b6c7d8e9f" + ) + assert pv_device_ref(f"{PANEL}_pv_panel-se7600h-us-1", PANEL) == PvDeviceRef( + "panel-se7600h-us-1" + ) + + +def test_nothing_else_is_a_pv_device() -> None: + assert pv_device_ref(f"{PANEL}_bess", PANEL) is None + assert pv_device_ref(f"{PANEL}_evse_node_pv_1", PANEL) is None + assert pv_device_ref(f"{PANEL}_adopted_pv", PANEL) is None + assert pv_device_ref(f"{PANEL}_pv_", PANEL) is None + assert pv_device_ref("other-panel_pv", PANEL) is None + + +C: Final = SOLAR_CIRCUIT +C2: Final = SECOND_SOLAR_CIRCUIT +POWER: Final = { + C: "sensor.span_panel_commissioned_pv_system_power", + C2: "sensor.span_panel_garage_solar_power", +} +SITE: Final = "sensor.span_panel_pv_power" + + +def _bound( + key: str, *, legacy: str | None = None, withheld: frozenset[str] = frozenset() +) -> PvBinding: + return PvBinding( + "inverter", key, legacy, withheld, solar_link=False, inverter_links=frozenset() + ) + + +def _site(binding: PvBinding, snapshot: SpanPanelSnapshot) -> SolarTopology | None: + return solar_topology(None, binding, snapshot, snapshot, POWER, SITE) + + +def _no_inverter_published() -> SpanPanelSnapshot: + """PV commissioned -- the capture publishes `power-flows/pv` -- with no inverter published.""" + tree = _unfed_tree() + tree.pop(PV_DEVICE) + snapshot = schema_one_snapshot(tree) + assert has_pv(snapshot) and not snapshot.pv_inverters + return snapshot + + +def test_one_bound_inverter_puts_its_circuit_on_the_site_tile() -> None: + snapshot = schema_one_snapshot() + site = _site(pv_binding_for(snapshot), snapshot) + + assert site == SolarTopology( + role="site", + vendor=snapshot.pv_inverters[C].vendor_name, + model=snapshot.pv_inverters[C].model, + feed_circuit_id=C, + power_entity_id=POWER[C], + site_power_entity_id=SITE, + ) + + +def test_each_other_inverter_gets_its_own_circuit() -> None: + snapshot = schema_one_snapshot(_tree()) + binding = _bound(C, legacy=C) + + site = _site(binding, snapshot) + second = solar_topology(C2, binding, snapshot, snapshot, POWER, SITE) + + assert site is not None and site["power_entity_id"] == POWER[C] + assert second == SolarTopology( + role="inverter", + vendor=snapshot.pv_inverters[C2].vendor_name, + model=snapshot.pv_inverters[C2].model, + feed_circuit_id=C2, + power_entity_id=POWER[C2], + site_power_entity_id=None, + ) + assert solar_topology(C, binding, snapshot, snapshot, POWER, SITE) is None + + +def test_unbound_over_several_leaves_the_site_tile_on_the_site_total() -> None: + snapshot = schema_one_snapshot(_tree()) + binding = pv_binding_for(snapshot) + assert binding.mode == "unbound" + + site = _site(binding, snapshot) + + assert site is not None + assert site["feed_circuit_id"] is None + assert site["power_entity_id"] is None + assert site["site_power_entity_id"] == SITE + first = solar_topology(C, binding, snapshot, snapshot, POWER, SITE) + assert first is not None and first["power_entity_id"] == POWER[C] + + +def test_an_inverter_no_circuit_feeds_has_no_individual_reading() -> None: + snapshot = schema_one_snapshot( + _gateway_tree( + ("panel-se7600h-us-1", "SE7600H-US", "11680"), + ("panel-use7600h-us-2", "USE7600H-US", "11680"), + ) + ) + binding = pv_binding_for(snapshot) + + site = _site(binding, snapshot) + inverter = solar_topology("panel-use7600h-us-2", binding, snapshot, snapshot, POWER, SITE) + + assert site is not None and site["vendor"] == "SolarEdge" and site["model"] is None + assert site["power_entity_id"] is None + assert inverter is not None + assert inverter["feed_circuit_id"] is None and inverter["power_entity_id"] is None + assert inverter["model"] == "USE7600H-US" + + +def test_a_pending_record_keeps_the_bound_circuit_on_the_site_tile() -> None: + snapshot = schema_one_snapshot(_unfed_tree()) + binding = _bound(C, legacy=C, withheld=frozenset({PV_DEVICE})) + + site = _site(binding, snapshot) + + assert site is not None + assert site["feed_circuit_id"] == C and site["power_entity_id"] == POWER[C] + assert site["vendor"] is None + assert solar_topology(PV_DEVICE, binding, snapshot, snapshot, POWER, SITE) is None + + +def test_a_bound_circuit_that_is_gone_leaves_the_site_total() -> None: + tree = schema_one_tree() + tree[NEW_CIRCUIT] = tree.pop(SOLAR_CIRCUIT) + snapshot = schema_one_snapshot(tree) + + site = _site(_bound(C, legacy=C), snapshot) + + assert site is not None and site["feed_circuit_id"] is None and site["power_entity_id"] is None + + +def test_a_bound_key_holding_a_card_leaves_the_site_tile_on_the_site_total() -> None: + """A restored store: the bound inverter has its own tile, so the site tile must not draw its circuit too.""" + snapshot = schema_one_snapshot(_tree()) + binding = _bound(C, legacy=None) + + site = _site(binding, snapshot) + own = solar_topology(C, binding, snapshot, snapshot, POWER, SITE) + + assert site is not None and site["power_entity_id"] is None + assert own is not None and own["power_entity_id"] == POWER[C] + + +def test_pv_commissioned_with_no_inverter_published_heads_the_site_tile_with_the_total() -> None: + """The Solar device exists whenever PV is commissioned, so its block does too (review I-1).""" + snapshot = _no_inverter_published() + + assert _site(pv_binding_for(snapshot), snapshot) == SolarTopology( + role="site", + vendor=None, + model=None, + feed_circuit_id=None, + power_entity_id=None, + site_power_entity_id=SITE, + ) + + +def test_a_pending_record_with_no_inverter_published_keeps_the_bound_circuit() -> None: + snapshot = _no_inverter_published() + + site = _site(_bound(C, legacy=C), snapshot) + + assert site is not None and site["feed_circuit_id"] == C and site["power_entity_id"] == POWER[C] + + +def test_without_pv_there_is_no_block() -> None: + snapshot = SpanPanelSnapshotFactory.create() + assert not has_pv(snapshot) + + assert _site(pv_binding_for(snapshot), snapshot) is None + assert ( + solar_topology("anything", pv_binding_for(snapshot), snapshot, snapshot, POWER, SITE) + is None + ) + + +def _without_identity(tree: dict[str, dict[str, str]], *devices: str) -> dict[str, dict[str, str]]: + """Return the tree with these PV devices' vendor and model not yet published.""" + for device in devices: + tree[device].pop("info/vendor-name") + tree[device].pop("info/model") + return tree + + +def test_identity_is_read_live_and_structure_as_of_setup() -> None: + """A vendor or model published after setup is not a capability change, so nothing reloads; the tile reads it as the sensors do.""" + setup = schema_one_snapshot(_without_identity(_tree(), FIRST_PV, SECOND_PV)) + live = schema_one_snapshot(_tree(unfed=True)) + binding = _bound(C, legacy=C) + + site = solar_topology(None, binding, setup, live, POWER, SITE) + second = solar_topology(C2, binding, setup, live, POWER, SITE) + + assert setup.pv_inverters[C].vendor_name is None + assert site is not None and second is not None + assert (site["vendor"], site["model"]) == ( + live.pv_inverters[C].vendor_name, + live.pv_inverters[C].model, + ) + assert (second["vendor"], second["model"]) == ("Second Vendor", live.pv_inverters[C2].model) + assert site["vendor"] is not None and second["model"] is not None + assert solar_topology(UNFED_PV, binding, setup, live, POWER, SITE) is None + + +def test_an_inverter_gone_from_the_live_snapshot_keeps_its_block_with_no_identity() -> None: + """Structure is as of setup, so the block stays until the reload the departure causes; identity reads empty, as its sensors do.""" + setup = schema_one_snapshot(_tree()) + live = schema_one_snapshot(_tree(second=False)) + assert C2 in setup.pv_inverters and C2 not in live.pv_inverters + + second = solar_topology(C2, _bound(C, legacy=C), setup, live, POWER, SITE) + + assert second == SolarTopology( + role="inverter", + vendor=None, + model=None, + feed_circuit_id=C2, + power_entity_id=POWER[C2], + site_power_entity_id=None, + ) diff --git a/tests/test_suffix_mappings_are_closed.py b/tests/test_suffix_mappings_are_closed.py new file mode 100644 index 00000000..9d0b16a7 --- /dev/null +++ b/tests/test_suffix_mappings_are_closed.py @@ -0,0 +1,179 @@ +"""The suffix mappings are a closed compatibility shim, and this is what closes them. + +`get_user_friendly_suffix` and `get_panel_entity_suffix` translate legacy camelCase +description keys into the suffixes their entities have carried since before 2.0.8. +That suffix is shared by the `unique_id` **and** the `entity_id`, so a changed entry +moves both on every installed panel: the `unique_id` costs the long-term statistics, +and the `entity_id` breaks whatever templates and automations a user wrote against it. + +Three edits move a live id and all three fail here: + +- **adding** a key, which silently reroutes a description that previously resolved + verbatim -- the failure mode that prompted this file +- **removing** a key, which sends a legacy description back to its raw camelCase +- **changing** a value, which is the most direct version of the same thing + +The rule for anything new is verbatim, which is what the sub-device builders have +always done. Nothing here needs extending to add a sensor. +""" + +from __future__ import annotations + +from custom_components.span_panel.id_builder import ( + ALL_SUFFIX_MAPPINGS, + CIRCUIT_SUFFIX_MAPPING, + PANEL_ENTITY_SUFFIX_MAPPING, + PANEL_SUFFIX_MAPPING, + build_bess_unique_id, + build_circuit_unique_id, + build_evse_unique_id, + build_mid_unique_id, + build_panel_unique_id, + get_panel_entity_suffix, + get_user_friendly_suffix, +) +from custom_components.span_panel.naming import ENTITY_ID_SUFFIX_FORMS + +SERIAL = "sp3-001" +CIRCUIT = "0dad2f16cd514812ae1807b0457d473e" + +_CIRCUIT_SUFFIXES = { + "instantPowerW": "power", + "producedEnergyWh": "energy_produced", + "consumedEnergyWh": "energy_consumed", + "netEnergyWh": "energy_net", + "importedEnergyWh": "energy_imported", + "exportedEnergyWh": "energy_exported", + "circuit_priority": "priority", + "current": "current", + "breaker_rating": "breaker_rating", +} + +_PANEL_SUFFIXES = { + "instantGridPowerW": "grid_power", + "feedthroughPowerW": "feed_through_power", + "batteryPowerW": "battery_power", + "pvPowerW": "pv_power", + "gridPowerFlowW": "grid_power_flow", + "sitePowerW": "site_power", + "mainMeterEnergyProducedWh": "main_meter_energy_produced", + "mainMeterEnergyConsumedWh": "main_meter_energy_consumed", + "mainMeterNetEnergyWh": "main_meter_energy_net", + "feedthroughEnergyProducedWh": "feed_through_energy_produced", + "feedthroughEnergyConsumedWh": "feed_through_energy_consumed", + "feedthroughNetEnergyWh": "feed_through_energy_net", + "batteryPercentage": "battery_percentage", +} + +_PANEL_ENTITY_SUFFIXES = { + "instantGridPowerW": "current_power", + "feedthroughPowerW": "feed_through_power", + "batteryPowerW": "battery_power", + "pvPowerW": "pv_power", + "gridPowerFlowW": "grid_power_flow", + "sitePowerW": "site_power", + "mainMeterEnergyProducedWh": "main_meter_produced_energy", + "mainMeterEnergyConsumedWh": "main_meter_consumed_energy", + "mainMeterNetEnergyWh": "main_meter_net_energy", + "feedthroughEnergyProducedWh": "feed_through_produced_energy", + "feedthroughEnergyConsumedWh": "feed_through_consumed_energy", + "feedthroughNetEnergyWh": "feed_through_net_energy", + "batteryPercentage": "battery_level", +} + +# The entity-id table, closed for a related but distinct reason. The mappings +# above translate a description key into a canonical suffix; this records every +# *wording* of that suffix an entity id has ever shipped with, so an existing +# entity can be proposed the id it already has. An added entry claims a spelling +# shipped that never did, and Recreate then offers a rename nobody asked for. +_ENTITY_ID_SUFFIX_FORMS = { + "power": frozenset({"power", "current_power"}), + "energy_produced": frozenset({"produced_energy", "energy_produced"}), + "energy_consumed": frozenset({"consumed_energy", "energy_consumed"}), + "energy_net": frozenset({"net_energy", "energy_net"}), + "current": frozenset({"current"}), + "breaker_rating": frozenset({"breaker_rating"}), + "breaker": frozenset({"breaker"}), + "circuit_priority": frozenset({"circuit_priority"}), +} + +_CLOSED = ( + "This mapping is closed. Adding, removing or changing an entry moves a live " + "unique_id and entity_id on every installed panel -- statistics and the user's " + "templates both. A new description key needs no entry: it resolves verbatim." +) + +_CLOSED_FORMS = ( + "This table is historical fact, not a style. An entry is added only when " + "another wording is discovered to have shipped -- never to introduce one, " + "which would offer every circuit on the panel a rename." +) + + +def test_the_circuit_suffix_mapping_is_frozen() -> None: + assert CIRCUIT_SUFFIX_MAPPING == _CIRCUIT_SUFFIXES, _CLOSED + + +def test_the_panel_suffix_mapping_is_frozen() -> None: + assert PANEL_SUFFIX_MAPPING == _PANEL_SUFFIXES, _CLOSED + + +def test_the_panel_entity_suffix_mapping_is_frozen() -> None: + assert PANEL_ENTITY_SUFFIX_MAPPING == _PANEL_ENTITY_SUFFIXES, _CLOSED + + +def test_the_entity_id_suffix_forms_are_frozen() -> None: + """The wordings an entity id has shipped with, which R1 turns on. + + `naming.circuit_object_id_base` reads an existing id's suffix back through + this table so the entity is proposed its own id. A wording missing from it + reads as unknown and the entity is offered the new spelling instead; a + wording invented into it is offered to entities that never had it. + """ + assert ENTITY_ID_SUFFIX_FORMS == _ENTITY_ID_SUFFIX_FORMS, _CLOSED_FORMS + + +def test_the_combined_mapping_is_exactly_its_two_halves() -> None: + """`ALL_SUFFIX_MAPPINGS` is derived, so it cannot gain an entry of its own. + + Pinned because it is the one a caller reaches for, and a hand-added entry here + would route a key without appearing in either half above. + """ + assert ALL_SUFFIX_MAPPINGS == {**_CIRCUIT_SUFFIXES, **_PANEL_SUFFIXES} + + +def test_a_key_the_shim_does_not_carry_resolves_to_itself() -> None: + """The rule for everything added from here on. + + Sampled from keys real sub-device sensors use, so this fails if the fallback + is ever changed to normalise, prefix or otherwise reshape an unmapped key. + """ + for key in ("soe_kwh", "meter_power", "mid_grid_state", "evse_status", "grid_islandable"): + assert get_user_friendly_suffix(key) == key + assert get_panel_entity_suffix(key) == key + + +def test_the_sub_device_builders_never_consult_the_shim() -> None: + """Verbatim by construction, which is why they are safe from an edit above. + + A BESS, MID or EVSE id is its description key. Even if somebody added a + mapping entry for one of these keys, these builders would not read it -- and + the frozen dictionaries above are what stops the attempt reaching review. + """ + assert build_bess_unique_id(SERIAL, "soe_kwh") == f"span_{SERIAL}_bess_soe_kwh" + assert build_mid_unique_id(SERIAL, "mid_grid_state") == f"span_{SERIAL}_mid_mid_grid_state" + assert build_evse_unique_id(SERIAL, "evse-1", "evse_status") == f"span_{SERIAL}_evse_evse-1_evse_status" + + +def test_the_legacy_ids_the_shim_exists_to_preserve() -> None: + """What the frozen entries actually buy, spelled out as ids rather than suffixes. + + These are the strings on installed panels. If one of them changes, an upgrade + orphans the entity, Home Assistant registers a replacement with a `_2` suffix, + and the history stays on the entity nobody is looking at any more. + """ + assert build_circuit_unique_id(SERIAL, CIRCUIT, "instantPowerW") == f"span_{SERIAL}_{CIRCUIT}_power" + assert build_circuit_unique_id(SERIAL, CIRCUIT, "producedEnergyWh") == f"span_{SERIAL}_{CIRCUIT}_energy_produced" + assert build_circuit_unique_id(SERIAL, "unmapped_tab_32", "instantPowerW") == f"span_{SERIAL}_unmapped_tab_32_power" + assert build_panel_unique_id(SERIAL, "instantGridPowerW") == f"span_{SERIAL}_current_power" + assert build_panel_unique_id(SERIAL, "doorState") == f"span_{SERIAL}_doorstate" diff --git a/tests/test_tabs_attribute.py b/tests/test_tabs_attribute.py index f1358ac6..27b747ef 100644 --- a/tests/test_tabs_attribute.py +++ b/tests/test_tabs_attribute.py @@ -8,9 +8,7 @@ ) -def _make_circuit( - tabs: list[int], instant_power_w: float = 100.0 -) -> SpanCircuitSnapshot: +def _make_circuit(tabs: list[int], instant_power_w: float = 100.0) -> SpanCircuitSnapshot: """Create a minimal SpanCircuitSnapshot for tab/voltage tests.""" return SpanCircuitSnapshot( circuit_id="test", @@ -38,8 +36,27 @@ def test_tabs_attribute_construction() -> None: # No tabs assert construct_tabs_attribute(_make_circuit([])) is None - # More than 2 tabs (invalid for US electrical system) - assert construct_tabs_attribute(_make_circuit([1, 3, 5])) is None + # Unsorted input is ordered, whatever the pole count + assert construct_tabs_attribute(_make_circuit([32, 30])) == "tabs [30:32]" + + +def test_tabs_attribute_names_every_position_of_a_multipole_breaker() -> None: + """A 3- or 4-pole breaker keeps every position it occupies. + + Defensive rather than observed: SPAN has stated its panels "are split-phase + and publish only 1- or 2-pole breakers", and no circuit on any captured + panel occupies more than two positions. The `1:4:1` range on `breaker/poles` + belongs to the generic eBus catalog, which covers load centres that are not + SPAN. + + What is tested here is that unexpected input degrades instead of failing. + Three positions used to drop the attribute entirely and log that the + hardware was "not valid for US electrical system". + """ + assert construct_tabs_attribute(_make_circuit([17, 19, 21])) == "tabs [17:19:21]" + assert construct_tabs_attribute(_make_circuit([2, 4, 6, 8])) == "tabs [2:4:6:8]" + # Ordering is by position, not by arrival. + assert construct_tabs_attribute(_make_circuit([21, 17, 19])) == "tabs [17:19:21]" def test_voltage_attribute_construction() -> None: @@ -47,7 +64,22 @@ def test_voltage_attribute_construction() -> None: assert construct_voltage_attribute(_make_circuit([28])) == 120 assert construct_voltage_attribute(_make_circuit([30, 32])) == 240 assert construct_voltage_attribute(_make_circuit([])) is None - assert construct_voltage_attribute(_make_circuit([1, 3, 5])) is None + + +def test_voltage_is_not_claimed_for_a_multipole_breaker() -> None: + """Three or more poles is not a split-phase circuit, so we do not guess. + + 208V line-to-line on a three-phase wye service and 240V on a high-leg delta + are both plausible and nothing published distinguishes them. The position + numbers cannot settle it either: `spaces` is specified as identifying every + occupied slot "without assuming a numbering convention". + + This is the half that must NOT follow the tabs fix. Naming three positions + is reporting what the panel published; naming a voltage for them would be + inventing one. + """ + assert construct_voltage_attribute(_make_circuit([17, 19, 21])) is None + assert construct_voltage_attribute(_make_circuit([2, 4, 6, 8])) is None def test_end_to_end_tabs_workflow() -> None: diff --git a/tests/test_topology_panel_entities.py b/tests/test_topology_panel_entities.py index d7bf2e00..35b4a792 100644 --- a/tests/test_topology_panel_entities.py +++ b/tests/test_topology_panel_entities.py @@ -25,8 +25,9 @@ def mock_get_entity_id(domain, integration, unique_id): return mapping.get(unique_id) mock_registry.async_get_entity_id = mock_get_entity_id + mock_registry.async_get = MagicMock(return_value=MagicMock(config_entry_id="entry")) - result = _build_panel_entity_map(serial, mock_registry) + result = _build_panel_entity_map(serial, mock_registry, "entry") assert result["current_power"] == "sensor.my_current_power" assert result["site_power"] == "sensor.custom_site" @@ -40,7 +41,7 @@ def test_empty_when_no_entities_found(self): mock_registry = MagicMock() mock_registry.async_get_entity_id = MagicMock(return_value=None) - result = _build_panel_entity_map("unknown-serial", mock_registry) + result = _build_panel_entity_map("unknown-serial", mock_registry, "entry") assert result == {} def test_all_panel_sensor_keys_attempted(self): @@ -54,7 +55,7 @@ def test_all_panel_sensor_keys_attempted(self): mock_registry = MagicMock() mock_registry.async_get_entity_id = MagicMock(return_value=None) - _build_panel_entity_map(serial, mock_registry) + _build_panel_entity_map(serial, mock_registry, "entry") # Should have been called once per key assert mock_registry.async_get_entity_id.call_count == len(_PANEL_SENSOR_KEYS) diff --git a/tests/test_unit_vocabulary.py b/tests/test_unit_vocabulary.py new file mode 100644 index 00000000..a9f90e02 --- /dev/null +++ b/tests/test_unit_vocabulary.py @@ -0,0 +1,69 @@ +"""Schema unit strings must be recognisable HA units.""" + +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.const import ( + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfTime, +) +import pytest +from span_panel_api.models import FieldMetadata + +from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata + +MetadataFn = Callable[[], dict[str, FieldMetadata]] + +_KNOWN: set[str] = { + *(u.value for u in UnitOfPower), + *(u.value for u in UnitOfEnergy), + *(u.value for u in UnitOfElectricCurrent), + *(u.value for u in UnitOfElectricPotential), + # `shed-forecast` declares its four estimates in `min`, which is + # `UnitOfTime.MINUTES` — the first non-electrical quantity a SPAN panel + # publishes, and the reason a time vocabulary belongs here at all. + *(u.value for u in UnitOfTime), + PERCENTAGE, +} + +_MIN_SAMPLED_UNITS = 5 +"""Floor on distinct unit strings an adapter must emit for the check to mean anything.""" + +UNIT_TRANSLATIONS: dict[str, str] = {} +"""Schema unit string -> HA unit, for units the panel spells differently. + +Empty today. An entry here is a deliberate statement that the panel's spelling +differs from HA's, not a licence to paper over a firmware bug. +""" + + +@pytest.mark.parametrize( + ("adapter", "metadata_fn"), + [("schema_0", schema_zero_metadata), ("schema_1", schema_one_metadata)], +) +def test_units_are_recognised(adapter: str, metadata_fn: MetadataFn) -> None: + sampled = {e.unit for e in metadata_fn().values() if e.resolved and e.unit is not None} + + # The check below is monotone: it passes for any subset, including the empty + # one. Pin the sample so a fixture path change, a fixture regenerated without + # units, or an adapter that stops populating `unit` cannot retire it silently. + # schema_0 emits 7 distinct strings and schema_1 emits 6 over 27 entries each, + # so 5 leaves room for one unit to be legitimately retired and still fails on + # a collapse. + assert len(sampled) >= _MIN_SAMPLED_UNITS, ( + f"{adapter} emitted only {sorted(sampled)} — the vocabulary check has nothing to check" + ) + + unknown = sorted( + unit for unit in sampled if unit not in _KNOWN and unit not in UNIT_TRANSLATIONS + ) + assert not unknown, ( + f"{adapter} declares unit strings HA does not recognise: {unknown}. " + "Add a UNIT_TRANSLATIONS entry, or fix the mapping — do not let this " + "reach the unit check, where it becomes one Repair per sensor." + ) diff --git a/tests/test_unreported_readings_stay_unknown.py b/tests/test_unreported_readings_stay_unknown.py new file mode 100644 index 00000000..560a174e --- /dev/null +++ b/tests/test_unreported_readings_stay_unknown.py @@ -0,0 +1,151 @@ +"""A derived sensor whose inputs are unreported is unknown, not zero. + +`span-panel-api` reports a reading the panel has not sent as `None` rather than +fabricating `0.0` (`SpanPanel/span#259`). The derived sensors here compose two +such readings, and each was coalescing with `or 0` — so a circuit that had +reported nothing produced a confident net energy of `0`, and the fabrication the +library stopped making reappeared one layer out. + +`None` in, `None` out. `_process_raw_value` already maps that to the sensor's +unknown value and, on a `TOTAL_INCREASING` sensor, skips dip compensation +entirely — leaving `_last_panel_reading` untouched, which is what keeps the +absent reading from being mistaken for a counter reset when the real value +lands. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest +from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot + +from custom_components.span_panel.sensor_definitions import ( + CIRCUIT_SENSORS, + PANEL_ENERGY_SENSORS, +) +from tests.factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory + +_NET_ENERGY_SENSORS = [ + ("mainMeterNetEnergyWh", "main_meter_energy_consumed_wh", "main_meter_energy_produced_wh"), + ("feedthroughNetEnergyWh", "feedthrough_energy_consumed_wh", "feedthrough_energy_produced_wh"), +] + + +def _circuit_value_fn(key: str) -> Callable[[SpanCircuitSnapshot], float | None]: + """Return the value function of the named circuit sensor description.""" + return next(desc for desc in CIRCUIT_SENSORS if desc.key == key).value_fn + + +def _panel_value_fn(key: str) -> Callable[[SpanPanelSnapshot], float | str | None]: + """Return the value function of the named panel energy sensor description.""" + return next(desc for desc in PANEL_ENERGY_SENSORS if desc.key == key).value_fn + + +class TestCircuitNetEnergy: + """`circuit_energy_net` composes the two circuit counters.""" + + def test_unknown_while_neither_counter_has_reported(self) -> None: + """A circuit that has published nothing knows nothing about its net.""" + circuit = SpanCircuitSnapshotFactory.create( + consumed_energy_wh=None, produced_energy_wh=None + ) + + assert _circuit_value_fn("circuit_energy_net")(circuit) is None + + def test_unknown_while_only_one_counter_has_reported(self) -> None: + """Half a difference is not a smaller difference, it is no answer.""" + circuit = SpanCircuitSnapshotFactory.create( + consumed_energy_wh=1500.0, produced_energy_wh=None + ) + + assert _circuit_value_fn("circuit_energy_net")(circuit) is None + + def test_reported_zeros_still_compute(self) -> None: + """Two counters that have published zero have a net of zero.""" + circuit = SpanCircuitSnapshotFactory.create(consumed_energy_wh=0.0, produced_energy_wh=0.0) + + assert _circuit_value_fn("circuit_energy_net")(circuit) == 0.0 + + def test_the_arithmetic_is_unchanged_when_both_report(self) -> None: + """Consumption less generation, exactly as before.""" + circuit = SpanCircuitSnapshotFactory.create( + consumed_energy_wh=1500.0, produced_energy_wh=400.0 + ) + + assert _circuit_value_fn("circuit_energy_net")(circuit) == pytest.approx(1100.0) + + def test_a_pv_circuit_keeps_its_inverted_sign(self) -> None: + """A PV circuit nets generation less consumption, the other way round.""" + circuit = SpanCircuitSnapshotFactory.create( + device_type="pv", consumed_energy_wh=400.0, produced_energy_wh=1500.0 + ) + + assert _circuit_value_fn("circuit_energy_net")(circuit) == pytest.approx(1100.0) + + +class TestCircuitPower: + """`circuit_power` negates for PV, which cannot be done to `None`.""" + + def test_unknown_while_the_meter_has_not_reported(self) -> None: + """An unreported power reading passes straight through as unknown.""" + circuit = SpanCircuitSnapshotFactory.create(instant_power_w=None) + + assert _circuit_value_fn("circuit_power")(circuit) is None + + def test_unknown_for_a_pv_circuit_that_has_not_reported(self) -> None: + """The negation is why this one raised rather than lying. + + `-None` is a `TypeError`, caught upstream and logged as a value-function + failure on every PV circuit, on every replay. + """ + circuit = SpanCircuitSnapshotFactory.create(device_type="pv", instant_power_w=None) + + assert _circuit_value_fn("circuit_power")(circuit) is None + + def test_a_pv_circuit_still_reports_generation_as_positive(self) -> None: + """Backfeed is negative on the wire and positive on the sensor.""" + circuit = SpanCircuitSnapshotFactory.create(device_type="pv", instant_power_w=-1500.0) + + assert _circuit_value_fn("circuit_power")(circuit) == pytest.approx(1500.0) + + def test_a_pv_circuit_at_zero_does_not_report_negative_zero(self) -> None: + """Preserved from the original expression. + + `-0.0` compares equal to `0.0` but renders as "-0.0" in the UI. + """ + circuit = SpanCircuitSnapshotFactory.create(device_type="pv", instant_power_w=0.0) + + result = _circuit_value_fn("circuit_power")(circuit) + + assert result == 0.0 + assert str(result) == "0.0" + + +class TestPanelNetEnergy: + """The two panel-level net sensors compose the lugs counters. + + Worse exposed than a circuit's: both lugs devices are told apart by a single + `direction` property, so until it arrives neither role resolves and all four + inputs are unreported at once. + """ + + @pytest.mark.parametrize(("key", "consumed_field", "produced_field"), _NET_ENERGY_SENSORS) + def test_unknown_while_the_lugs_have_not_reported( + self, key: str, consumed_field: str, produced_field: str + ) -> None: + """Neither lugs role resolved means no net to report.""" + snapshot = SpanPanelSnapshotFactory.create(**{consumed_field: None, produced_field: None}) + + assert _panel_value_fn(key)(snapshot) is None + + @pytest.mark.parametrize(("key", "consumed_field", "produced_field"), _NET_ENERGY_SENSORS) + def test_the_arithmetic_is_unchanged_when_both_report( + self, key: str, consumed_field: str, produced_field: str + ) -> None: + """Import less export, exactly as before.""" + snapshot = SpanPanelSnapshotFactory.create( + **{consumed_field: 5000.0, produced_field: 1200.0} + ) + + assert _panel_value_fn(key)(snapshot) == pytest.approx(3800.0) diff --git a/tests/test_upgrade_scenarios.py b/tests/test_upgrade_scenarios.py index 04cde7b7..0c841e51 100644 --- a/tests/test_upgrade_scenarios.py +++ b/tests/test_upgrade_scenarios.py @@ -13,12 +13,6 @@ USE_DEVICE_PREFIX, EntityNamingPattern, ) -from custom_components.span_panel.helpers import ( - construct_multi_circuit_entity_id, - construct_single_circuit_entity_id, -) - -from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory @pytest.fixture @@ -45,23 +39,6 @@ def __init__(self) -> None: return MockConfigEntry() -@pytest.fixture -def mock_coordinator(): - """Create a mock coordinator.""" - coordinator = MagicMock() - coordinator.config_entry = None # Will be set in tests - coordinator.hass = MagicMock() # Mock hass for entity registry access - return coordinator - - -@pytest.fixture -def mock_span_panel(): - """Create a mock span panel.""" - panel = MagicMock() - panel.status.serial_number = "TEST123" - return panel - - class TestUpgradeScenarios: """Test upgrade scenarios for different installation types.""" @@ -130,167 +107,6 @@ def test_new_installation_gets_modern_defaults(self, mock_config_entry): ) -class TestEntityIdConstructionUpgradeScenarios: - """Test entity ID construction preserves existing patterns during upgrades.""" - - def _make_coordinator_and_snapshot(self, options, circuit_tabs=None): - """Build a coordinator mock and snapshot with a single circuit.""" - circuit = SpanCircuitSnapshotFactory.create( - circuit_id="15", - name="Kitchen Outlets", - tabs=circuit_tabs or [15], - ) - snapshot = SpanPanelSnapshotFactory.create(circuits={"15": circuit}) - coordinator = MagicMock() - coordinator.data = snapshot - coordinator.config_entry = MagicMock() - coordinator.config_entry.title = "Span Panel" - coordinator.config_entry.data = {"device_name": "Span Panel"} - coordinator.config_entry.options = options - coordinator.hass = MagicMock() - return coordinator, snapshot, circuit - - def test_legacy_entity_id_construction_preserved(self): - """Test that legacy entity ID construction is preserved.""" - coordinator, snapshot, circuit = self._make_coordinator_and_snapshot( - {USE_DEVICE_PREFIX: False, USE_CIRCUIT_NUMBERS: False} - ) - entity_id = construct_single_circuit_entity_id( - coordinator, - snapshot, - "sensor", - "power", - circuit, - ) - assert entity_id == "sensor.kitchen_outlets_power" - - def test_post_104_friendly_names_entity_id_construction_preserved(self): - """Test that post-1.0.4 friendly names entity ID construction is preserved.""" - coordinator, snapshot, circuit = self._make_coordinator_and_snapshot( - {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: False} - ) - entity_id = construct_single_circuit_entity_id( - coordinator, - snapshot, - "sensor", - "power", - circuit, - ) - assert entity_id == "sensor.span_panel_kitchen_outlets_power" - - def test_modern_circuit_numbers_120v_entity_id_construction_preserved(self): - """Test that modern circuit numbers entity ID for 120V is preserved.""" - coordinator, snapshot, circuit = self._make_coordinator_and_snapshot( - {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: True}, - circuit_tabs=[15], - ) - entity_id = construct_single_circuit_entity_id( - coordinator, - snapshot, - "sensor", - "power", - circuit, - ) - assert entity_id == "sensor.span_panel_circuit_15_power" - - def test_modern_circuit_numbers_240v_entity_id_includes_both_tabs(self): - """Test that 240V circuits include both tabs in entity ID.""" - coordinator, snapshot, circuit = self._make_coordinator_and_snapshot( - {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: True}, - circuit_tabs=[15, 17], - ) - entity_id = construct_single_circuit_entity_id( - coordinator, - snapshot, - "sensor", - "power", - circuit, - ) - assert entity_id == "sensor.span_panel_circuit_15_17_power" - - -class TestSyntheticEntityUpgradeScenarios: - """Test synthetic entity construction preserves existing patterns during upgrades.""" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_legacy_synthetic_entity_construction_preserved( - self, mock_registry, mock_coordinator, mock_span_panel - ): - """Test that legacy synthetic entity construction is preserved.""" - mock_registry.return_value = None - - mock_config_entry = MagicMock() - mock_config_entry.options = { - USE_DEVICE_PREFIX: False, - USE_CIRCUIT_NUMBERS: False, - } - mock_config_entry.title = "Span Panel" - mock_config_entry.data = {"device_name": "Span Panel"} - mock_coordinator.config_entry = mock_config_entry - - entity_id = construct_multi_circuit_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert entity_id == "sensor.solar_inverter_power" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_post_104_synthetic_entity_construction_preserved( - self, mock_registry, mock_coordinator, mock_span_panel - ): - """Test that post-1.0.4 synthetic entity construction is preserved.""" - mock_registry.return_value = None - - mock_config_entry = MagicMock() - mock_config_entry.options = { - USE_DEVICE_PREFIX: True, - USE_CIRCUIT_NUMBERS: False, - } - mock_config_entry.title = "Span Panel" - mock_config_entry.data = {"device_name": "Span Panel"} - mock_coordinator.config_entry = mock_config_entry - - entity_id = construct_multi_circuit_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert entity_id == "sensor.span_panel_solar_inverter_power" - - @patch("custom_components.span_panel.helpers.er.async_get") - def test_modern_synthetic_entity_construction_preserved( - self, mock_registry, mock_coordinator, mock_span_panel - ): - """Test that modern synthetic entity construction is preserved.""" - mock_registry.return_value = None - - mock_config_entry = MagicMock() - mock_config_entry.options = { - USE_DEVICE_PREFIX: True, - USE_CIRCUIT_NUMBERS: True, - } - mock_config_entry.title = "Span Panel" - mock_config_entry.data = {"device_name": "Span Panel"} - mock_coordinator.config_entry = mock_config_entry - - entity_id = construct_multi_circuit_entity_id( - mock_coordinator, - mock_span_panel, - "sensor", - "power", - circuit_numbers=[30, 32], - friendly_name="Solar Inverter", - ) - assert entity_id == "sensor.span_panel_circuit_30_32_power" - - class TestGeneralOptionsPreservesNamingFlags: """Test that general options flow preserves naming flags.""" diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index 5ec001e2..8d4a0040 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -2,23 +2,40 @@ from __future__ import annotations +from collections.abc import Callable +import dataclasses import ipaddress +import logging +import ssl from unittest.mock import AsyncMock, MagicMock, patch +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntriesFlowManager, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import DetectionResult, V2AuthResponse, V2StatusInfo -from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelConnectionError +from span_panel_api.exceptions import ( + SpanPanelAuthError, + SpanPanelConnectionError, + SpanPanelPassphraseUnavailableError, + SpanPanelServerError, + SpanPanelTimeoutError, +) -from homeassistant import config_entries from custom_components.span_panel import ( CURRENT_CONFIG_VERSION, async_migrate_entry, - async_setup_entry, ) from custom_components.span_panel.config_flow import ( SpanPanelConfigFlow, TriggerFlowType, ) +from custom_components.span_panel.config_flow_validation import PanelRestTransport from custom_components.span_panel.const import ( CONF_API_VERSION, CONF_EBUS_BROKER_HOST, @@ -27,17 +44,16 @@ CONF_EBUS_BROKER_USERNAME, CONF_HOP_PASSPHRASE, CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, CONF_PANEL_SERIAL, CONF_REGISTERED_FQDN, DOMAIN, + PANEL_CA_PENDING, ) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.service_info.hassio import HassioServiceInfo -from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo - -from pytest_homeassistant_custom_component.common import MockConfigEntry +from custom_components.span_panel.control_gate import ControlPolicy +from custom_components.span_panel.migrations import CURRENT_CONFIG_MINOR_VERSION +from custom_components.span_panel.options import ALLOW_CONTEXTLESS_CONTROL # Shared mock detection for a different panel (used in reconfigure/duplicate tests) MOCK_V2_DETECTION_OTHER = DetectionResult( @@ -50,6 +66,70 @@ # ---------- helpers ---------- +FAKE_CA_PEM = "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n" +FAKE_CA_FINGERPRINT = "0" * 64 + +#: An anchor already stored on an entry that `ssl` will not load. Named apart +#: from `FAKE_CA_PEM` — which happens to be unreadable too, hence the autouse +#: fixture — because a test about an unusable pin should say which property of +#: the PEM it is about. `build_panel_ssl_context` is left unpatched wherever +#: this is used, so the refusal comes from the real one. +UNREADABLE_CA_PEM = "-----BEGIN CERTIFICATE-----\nbm90LWEtY2VydA==\n-----END CERTIFICATE-----\n" + + +@pytest.fixture(autouse=True) +def panel_ca_available(): + """Answer the CA step without touching the network. + + Autouse because every flow that reaches entry creation now passes through + it, and a test that did not patch it would try to open a socket. + """ + with ( + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=AsyncMock(return_value=FAKE_CA_PEM), + ), + patch( + "custom_components.span_panel.config_flow.async_leaf_chains_to_ca", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.ca_fingerprint", + return_value=FAKE_CA_FINGERPRINT, + ), + # The fake PEM is not a certificate the ssl module will load, and the + # accepted anchor is turned into a real context before authentication + # runs over it. + patch( + "custom_components.span_panel.config_flow.build_panel_ssl_context", + return_value=MagicMock(), + ), + # Every panel here is reached at the host it was given: no DNS (the + # harness refuses it outright) and no address substituted for a name, + # which is what every assertion in this module was written against. The + # resolved-address bootstrap is exercised for real against a live + # listener in `test_v2_config_flow_tls.py`. + patch( + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=AsyncMock(side_effect=lambda _hass, host, _port, _pem: host), + ), + ): + yield + + +async def _submit_host_and_pin(hass: HomeAssistant, flow_id: str, data: dict): + """Submit the host form and accept the CA the panel serves. + + The CA step sits between the host form and the authentication menu, because + registration is the exchange that carries the passphrase and it runs over the + pin. Tests that are not about the CA itself elide it here rather than + repeating the same two lines twenty times. + """ + result = await hass.config_entries.flow.async_configure(flow_id, data) + assert result["step_id"] == "choose_v2_auth", result["step_id"] + return result + + MOCK_HOST = "192.168.1.100" MOCK_PASSPHRASE = "correct-horse-battery-staple" @@ -122,10 +202,7 @@ async def test_user_flow_detects_v2_and_shows_auth_choice(hass: HomeAssistant) - assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) assert result2["type"] == FlowResultType.MENU assert result2["step_id"] == "choose_v2_auth" @@ -215,10 +292,7 @@ async def test_passphrase_auth_success(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) assert result2["step_id"] == "choose_v2_auth" # Select passphrase auth from the menu @@ -258,10 +332,7 @@ async def test_passphrase_auth_bad_passphrase(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) # Select passphrase auth from the menu result2b = await hass.config_entries.flow.async_configure( @@ -300,10 +371,7 @@ async def test_passphrase_auth_connection_error(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) # Select passphrase auth from the menu result2b = await hass.config_entries.flow.async_configure( @@ -324,10 +392,14 @@ async def test_passphrase_auth_connection_error(hass: HomeAssistant) -> None: # ---------- v2 entry creation ---------- -@pytest.mark.usefixtures("socket_enabled") -@pytest.mark.asyncio -async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: - """A completed v2 flow should create an entry with MQTT broker fields.""" +async def _create_v2_entry(hass: HomeAssistant) -> ConfigFlowResult: + """Drive a passphrase flow through to entry creation and return that result. + + The manager is named with its type because mypy reads the attributes core + assigns in `ConfigEntries.__init__` as untyped, which would make every + result here `Any`. + """ + flow: ConfigEntriesFlowManager = hass.config_entries.flow with ( patch( "custom_components.span_panel.config_flow.detect_api_version", @@ -342,45 +414,216 @@ async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: return_value=MOCK_V2_AUTH, ), ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + result = await flow.async_init(DOMAIN, context={"source": config_entries.SOURCE_USER}) # Step 1: submit host - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) # Step 2: choose auth method (passphrase) - result2b = await hass.config_entries.flow.async_configure( + result2b = await flow.async_configure( result2["flow_id"], {"next_step_id": "auth_passphrase"}, ) # Step 3: submit passphrase - result3 = await hass.config_entries.flow.async_configure( + result3 = await flow.async_configure( result2b["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) # Step 4: choose entity naming pattern (accept default) - result4 = await hass.config_entries.flow.async_configure( + return await flow.async_configure( result3["flow_id"], {"entity_naming_pattern": "friendly_names"}, ) - assert result4["type"] == FlowResultType.CREATE_ENTRY - data = result4["data"] - assert data[CONF_API_VERSION] == "v2" - assert data[CONF_HOST] == MOCK_HOST - assert data[CONF_ACCESS_TOKEN] == "v2-token-abc" - assert data[CONF_EBUS_BROKER_HOST] == "192.168.1.100" - assert data[CONF_EBUS_BROKER_PORT] == 8883 - assert data[CONF_EBUS_BROKER_USERNAME] == "span-user" - assert data[CONF_EBUS_BROKER_PASSWORD] == "mqtt-secret" - assert data[CONF_HOP_PASSPHRASE] == MOCK_PASSPHRASE - assert data[CONF_PANEL_SERIAL] == "SPAN-V2-001" + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: + """A completed v2 flow should create an entry with MQTT broker fields.""" + result = await _create_v2_entry(hass) + + assert result["type"] == FlowResultType.CREATE_ENTRY + data = result["data"] + assert data[CONF_API_VERSION] == "v2" + assert data[CONF_HOST] == MOCK_HOST + assert data[CONF_ACCESS_TOKEN] == "v2-token-abc" + assert data[CONF_EBUS_BROKER_HOST] == "192.168.1.100" + assert data[CONF_EBUS_BROKER_PORT] == 8883 + assert data[CONF_EBUS_BROKER_USERNAME] == "span-user" + assert data[CONF_EBUS_BROKER_PASSWORD] == "mqtt-secret" + # The passphrase is a registration input, never entry data. + assert CONF_HOP_PASSPHRASE not in data + assert data[CONF_PANEL_SERIAL] == "SPAN-V2-001" + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_a_new_entry_refuses_control_without_a_logged_in_user( + hass: HomeAssistant, +) -> None: + """A new install starts with contextless control off, and says so in its options. + + Stored rather than left absent, so an older release reading the entry after + a downgrade does not take the missing key for its own default of on. + """ + result = await _create_v2_entry(hass) + + assert result["type"] == FlowResultType.CREATE_ENTRY + entry = result["result"] + assert (entry.version, entry.minor_version) == ( + CURRENT_CONFIG_VERSION, + CURRENT_CONFIG_MINOR_VERSION, + ) + assert entry.options[ALLOW_CONTEXTLESS_CONTROL] is False + assert ControlPolicy.from_options(entry.options).allow_contextless is False + + +async def _reach_the_ca_step(hass: HomeAssistant): + """Drive a user flow as far as the CA step and return that result. + + That is only two steps now: the CA is fetched before authentication, so the + host form leads straight into it. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + return await hass.config_entries.flow.async_configure(result["flow_id"], {CONF_HOST: MOCK_HOST}) + + +@pytest.mark.asyncio +async def test_the_ca_is_pinned_before_the_passphrase_is_ever_sent( + hass: HomeAssistant, +) -> None: + """Registration carries the passphrase and returns both credentials. + + It is the one exchange most worth protecting, so the anchor has to be + accepted before the authentication menu is even offered. + """ + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch("custom_components.span_panel.config_flow.validate_host", return_value=True), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ) as register, + ): + menu = await _reach_the_ca_step(hass) + + # The CA step passes straight through on success, so the menu is the + # first thing shown after the host form. What matters is not which + # screen appears but that the panel has been asked nothing yet. + assert menu["step_id"] == "choose_v2_auth" + register.assert_not_called() + + picked = await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "auth_passphrase"} + ) + await hass.config_entries.flow.async_configure( + picked["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + + # And when it was, it went over the accepted anchor rather than plaintext. + # The transport is the third positional argument and has no default, so + # there is no shape of this call that sends the passphrase unpinned. + transport = register.call_args.args[2] + assert transport.ssl_context is not None + assert transport.httpx_client is None + assert transport.port == 443 + + +@pytest.mark.asyncio +async def test_a_ca_that_does_not_sign_what_the_panel_serves_is_not_offered( + hass: HomeAssistant, +) -> None: + """The user is never shown a fingerprint that already failed validation.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch("custom_components.span_panel.config_flow.validate_host", return_value=True), + patch( + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=AsyncMock(return_value=None), + ), + ): + result = await _reach_the_ca_step(hass) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "panel_ca" + assert result["errors"] == {"base": "ca_leaf_mismatch"} + + +@pytest.mark.asyncio +async def test_a_fetch_failure_is_a_flow_error_with_no_way_past( + hass: HomeAssistant, +) -> None: + """There is deliberately no "carry on unpinned" option here. + + The next thing this flow does is send the panel passphrase. An opt-out would + quietly restore the plaintext credential exchange that pinning before + registration exists to remove, at the moment a user is least likely to weigh + it. Resubmitting the form retries the fetch, which is the recovery a + transient network failure actually needs. + """ + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch("custom_components.span_panel.config_flow.validate_host", return_value=True), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ) as register, + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=AsyncMock(side_effect=SpanPanelConnectionError("unreachable")), + ) as fetch, + ): + failed = await _reach_the_ca_step(hass) + assert failed["type"] == FlowResultType.FORM + assert failed["step_id"] == "panel_ca" + assert failed["errors"] == {"base": "ca_unavailable"} + # No menu, so no option that leads anywhere but back through the fetch. + assert "menu_options" not in failed + + # Resubmitting retries rather than continuing. + retried = await hass.config_entries.flow.async_configure(failed["flow_id"], {}) + assert retried["step_id"] == "panel_ca" + assert fetch.await_count == 2 + + # The panel was never asked to register anything. + register.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_recovered_panel_can_be_retried_into_a_successful_pin( + hass: HomeAssistant, +) -> None: + """The error form is a retry, so a panel that comes back needs no restart.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch("custom_components.span_panel.config_flow.validate_host", return_value=True), + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=AsyncMock(side_effect=[SpanPanelConnectionError("unreachable"), FAKE_CA_PEM]), + ), + ): + failed = await _reach_the_ca_step(hass) + assert failed["errors"] == {"base": "ca_unavailable"} + + recovered = await hass.config_entries.flow.async_configure(failed["flow_id"], {}) + + assert recovered["step_id"] == "choose_v2_auth" # ---------- config entry migration (2.0.4 baseline) ---------- @@ -391,13 +634,14 @@ async def test_config_flow_uses_current_config_entry_version() -> None: """New core entries should use the current storage version.""" assert SpanPanelConfigFlow.VERSION == CURRENT_CONFIG_VERSION + assert SpanPanelConfigFlow.MINOR_VERSION == CURRENT_CONFIG_MINOR_VERSION @pytest.mark.asyncio async def test_migration_updates_older_entry_to_current_version( hass: HomeAssistant, ) -> None: - """v1.3.1 entries (version 2) should migrate through v3→v4→v5→v6.""" + """v1.3.1 entries (version 2) should migrate through to the current version.""" entry = MockConfigEntry( version=2, minor_version=1, @@ -416,14 +660,17 @@ async def test_migration_updates_older_entry_to_current_version( result = await async_migrate_entry(hass, entry) assert result is True - assert entry.version == 6 + assert entry.version == CURRENT_CONFIG_VERSION + assert entry.minor_version == CURRENT_CONFIG_MINOR_VERSION # v2→v3 migration adds api_version field assert entry.data[CONF_API_VERSION] == "v1" + # An entry from before the default changed keeps the control it had. + assert entry.options[ALLOW_CONTEXTLESS_CONTROL] is True @pytest.mark.asyncio async def test_simulation_entry_migrates_normally(hass: HomeAssistant) -> None: - """Simulation entries migrate to v6; setup will fail naturally at connection time.""" + """Simulation entries migrate forward; setup will fail naturally at connection time.""" entry = MockConfigEntry( version=5, minor_version=1, @@ -443,7 +690,180 @@ async def test_simulation_entry_migrates_normally(hass: HomeAssistant) -> None: result = await async_migrate_entry(hass, entry) assert result is True - assert entry.version == 6 + assert entry.version == CURRENT_CONFIG_VERSION + + +@pytest.mark.asyncio +async def test_v6_migration_drops_the_stored_passphrase(hass: HomeAssistant) -> None: + """v6 entries lose the persisted passphrase and keep everything else.""" + entry = MockConfigEntry( + version=6, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "v2-token-abc", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_HOST: MOCK_HOST, + CONF_EBUS_BROKER_PORT: 8883, + CONF_EBUS_BROKER_USERNAME: "span-user", + CONF_EBUS_BROKER_PASSWORD: "mqtt-secret", + CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE, + CONF_PANEL_SERIAL: "SPAN-V2-001", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.version == 7 + assert CONF_HOP_PASSPHRASE not in entry.data + assert entry.data[CONF_ACCESS_TOKEN] == "v2-token-abc" + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == "mqtt-secret" + + +@pytest.mark.asyncio +async def test_home_assistant_actually_runs_the_v7_migration(hass: HomeAssistant) -> None: + """Core must decide to migrate a v6 entry. + + `CURRENT_CONFIG_VERSION` drives the migration body, but it is + `SpanPanelConfigFlow.VERSION` that core compares against `entry.version` to + decide whether to call `async_migrate_entry` at all. Calling the migration + directly cannot catch the two drifting apart, so this goes through + `async_setup` with the integration's own entry setup stubbed out. + """ + entry = MockConfigEntry( + version=6, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "v2-token-abc", + CONF_API_VERSION: "v2", + CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + with patch( + "custom_components.span_panel.async_setup_entry", + AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) is True + + assert entry.version == CURRENT_CONFIG_VERSION + assert CONF_HOP_PASSPHRASE not in entry.data + + +def _v7_1_entry(options: dict[str, object]) -> MockConfigEntry: + """Build a v2 entry as the release before contextless control defaulted off left it.""" + return MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "v2-token-abc", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options=options, + unique_id="SPAN-V2-001", + ) + + +@pytest.mark.asyncio +async def test_v7_1_migration_keeps_control_without_a_logged_in_user( + hass: HomeAssistant, +) -> None: + """An entry that never set the option read it as on, and still does.""" + entry = _v7_1_entry({}) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.minor_version == CURRENT_CONFIG_MINOR_VERSION + assert entry.options[ALLOW_CONTEXTLESS_CONTROL] is True + assert ControlPolicy.from_options(entry.options).allow_contextless is True + + +@pytest.mark.asyncio +async def test_v7_1_migration_keeps_an_explicit_off(hass: HomeAssistant) -> None: + """A user who already turned it off made a choice the migration must not undo.""" + entry = _v7_1_entry({ALLOW_CONTEXTLESS_CONTROL: False}) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.minor_version == CURRENT_CONFIG_MINOR_VERSION + assert entry.options[ALLOW_CONTEXTLESS_CONTROL] is False + assert ControlPolicy.from_options(entry.options).allow_contextless is False + + +@pytest.mark.asyncio +async def test_v7_1_migration_reads_an_unreadable_value_as_it_was_read( + hass: HomeAssistant, +) -> None: + """Anything but a bool resolved to on before, so the migration writes on.""" + entry = _v7_1_entry({ALLOW_CONTEXTLESS_CONTROL: "no"}) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.options[ALLOW_CONTEXTLESS_CONTROL] is True + + +@pytest.mark.asyncio +async def test_migration_leaves_an_entry_from_a_newer_minor_version_alone( + hass: HomeAssistant, +) -> None: + """Core calls the migration on a minor downgrade, which it otherwise allows.""" + entry = MockConfigEntry( + version=CURRENT_CONFIG_VERSION, + minor_version=CURRENT_CONFIG_MINOR_VERSION + 1, + domain=DOMAIN, + title="Span Panel", + data={CONF_HOST: MOCK_HOST, CONF_API_VERSION: "v2"}, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.minor_version == CURRENT_CONFIG_MINOR_VERSION + 1 + assert ALLOW_CONTEXTLESS_CONTROL not in entry.options + + +@pytest.mark.asyncio +async def test_home_assistant_actually_runs_the_v7_2_migration(hass: HomeAssistant) -> None: + """Core must decide to migrate a v7.1 entry. + + The same drift as the v7 case, one level down: core compares + `SpanPanelConfigFlow.MINOR_VERSION` against the entry's, and a flow left at + the old minor version would never run the step. + """ + entry = _v7_1_entry({}) + entry.add_to_hass(hass) + + with patch( + "custom_components.span_panel.async_setup_entry", + AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) is True + + assert entry.minor_version == CURRENT_CONFIG_MINOR_VERSION + assert entry.options[ALLOW_CONTEXTLESS_CONTROL] is True # ---------- zeroconf v2 discovery ---------- @@ -607,11 +1027,426 @@ async def test_reauth_v2_success_updates_entry(hass: HomeAssistant) -> None: assert entry.data[CONF_ACCESS_TOKEN] == "v2-token-abc" assert entry.data[CONF_EBUS_BROKER_USERNAME] == "span-user" assert entry.data[CONF_EBUS_BROKER_PASSWORD] == "mqtt-secret" + # This entry had no anchor. Reauth acquires one before the passphrase goes + # out and keeps it, so the entry comes back pinned. + assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM + + +# ---------- reauth: the CA an unpinned entry has never had ---------- + +# The three ways an entry reaches reauth with nothing pinned. (a) a v1 entry: +# setup raises ConfigEntryAuthFailed before the deferred fetch can run, and the +# v7 migration flags only v2 entries, so it carries neither a CA nor the flag. +# (b) a v2 entry missing its broker credentials: setup raises before the fetch +# too, so the flag it was migrated with is never settled. (c) a v2 entry whose +# deferred fetch failed: the flag survives for the next setup, and reauth may +# well come first. +UNPINNED_REAUTH_POPULATIONS = [ + pytest.param({CONF_API_VERSION: "v1"}, id="v1_entry"), + pytest.param( + {CONF_API_VERSION: "v2", PANEL_CA_PENDING: True}, + id="missing_credentials", + ), + pytest.param( + { + CONF_API_VERSION: "v2", + PANEL_CA_PENDING: True, + CONF_EBUS_BROKER_HOST: "old-host", + CONF_EBUS_BROKER_PORT: 8883, + CONF_EBUS_BROKER_USERNAME: "old-user", + CONF_EBUS_BROKER_PASSWORD: "old-pass", + }, + id="failed_deferred_fetch", + ), +] + + +def _unpinned_entry(hass: HomeAssistant, extra_data: dict[str, object]) -> MockConfigEntry: + """Add an entry that carries no pinned CA.""" + entry = MockConfigEntry( + version=CURRENT_CONFIG_VERSION, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-token", + **extra_data, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + assert CONF_PANEL_CA_PEM not in entry.data + return entry + + +@pytest.mark.parametrize("extra_data", UNPINNED_REAUTH_POPULATIONS) +@pytest.mark.asyncio +async def test_reauth_of_an_unpinned_entry_pins_before_the_passphrase_is_sent( + hass: HomeAssistant, extra_data: dict[str, object] +) -> None: + """Reauth is where fresh credentials cross the wire; it must not cross in the clear.""" + entry = _unpinned_entry(hass, extra_data) + + order: list[str] = [] + captured: dict[str, object] = {} + + async def _fetch_ca(*_args: object, **_kwargs: object) -> str: + order.append("fetch_ca") + return FAKE_CA_PEM + + async def _register(*_args: object, **kwargs: object) -> V2AuthResponse: + order.append("register_v2") + captured.update(kwargs) + return MOCK_V2_AUTH + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=_fetch_ca, + ), + # The boundary the flow actually reaches: `validate_v2_passphrase` is + # real here, so what it hands the library is what the panel would see. + patch( + "custom_components.span_panel.config_flow_validation.register_v2", + new=_register, + ), + patch.object(hass.config_entries, "async_reload", return_value=True), + ): + result = await entry.start_reauth_flow(hass) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reauth_confirm" + + result1 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + assert result1["step_id"] == "auth_passphrase" + + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + # Registration went over a verified connection, and the anchor it used was + # acquired first rather than after the fact. + assert captured["ssl_context"] is not None + assert captured["httpx_client"] is None + assert order == ["fetch_ca", "register_v2"] + + # And the anchor survives the reauth, so the next connect is pinned too. + assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM + assert PANEL_CA_PENDING not in entry.data + assert entry.data[CONF_API_VERSION] == "v2" + + +@pytest.mark.asyncio +async def test_replacing_an_unusable_stored_anchor_says_what_it_is_now_pinned_to( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """A stored PEM this system cannot load is replaced, and that is worth a line. + + Silently swapping what an entry trusts is the one change a user needs a + record of, and the new fingerprint is the value to compare against the one + logged at install — the only trace of it once the old PEM is gone. + """ + entry = MockConfigEntry( + version=CURRENT_CONFIG_VERSION, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: "not-a-certificate", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + def _build(pem: str, *, check_hostname: bool = True) -> MagicMock: + # `check_hostname` is accepted and ignored: an anchor that will not load + # fails the same way whichever question the caller was going to ask of it. + if pem == "not-a-certificate": + raise ssl.SSLError("not a certificate") + return MagicMock() + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + side_effect=_build, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + patch.object(hass.config_entries, "async_reload", return_value=True), + caplog.at_level(logging.WARNING), + ): + menu = await entry.start_reauth_flow(hass) + form = await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "auth_passphrase"} + ) + done = await hass.config_entries.flow.async_configure( + form["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + + assert done["reason"] == "reauth_successful" + assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM + assert "Replaced the stored certificate authority" in caplog.text + assert FAKE_CA_FINGERPRINT in caplog.text + + +@pytest.mark.asyncio +async def test_reauth_refuses_to_send_the_passphrase_when_the_leaf_does_not_chain( + hass: HomeAssistant, +) -> None: + """A CA that cannot validate what the panel serves is no reason to fall back.""" + entry = _unpinned_entry(hass, {CONF_API_VERSION: "v2"}) + + register = AsyncMock(return_value=MOCK_V2_AUTH) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=AsyncMock(return_value=None), + ), + patch( + "custom_components.span_panel.config_flow_validation.register_v2", + new=register, + ), + ): + result = await entry.start_reauth_flow(hass) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "panel_ca" + assert result["errors"] == {"base": "ca_leaf_mismatch"} + register.assert_not_awaited() + assert CONF_PANEL_CA_PEM not in entry.data + + +@pytest.mark.asyncio +async def test_reauth_by_proximity_also_registers_over_the_pin( + hass: HomeAssistant, +) -> None: + """The door bypass sends no passphrase but is handed the same broker password back.""" + entry = _unpinned_entry(hass, {CONF_API_VERSION: "v2"}) + + captured: dict[str, object] = {} + + async def _register(*_args: object, **kwargs: object) -> V2AuthResponse: + captured.update(kwargs) + return MOCK_V2_AUTH + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow_validation.register_v2", + new=_register, + ), + patch.object(hass.config_entries, "async_reload", return_value=True), + ): + result = await entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + + proximity = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_proximity"} + ) + done = await hass.config_entries.flow.async_configure( + proximity["flow_id"], {"next_step_id": "auth_proximity_confirm"} + ) + + assert done["reason"] == "reauth_successful" + assert captured["ssl_context"] is not None + assert captured["httpx_client"] is None + assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM + + +@pytest.mark.asyncio +async def test_reauth_of_a_pinned_entry_does_not_go_back_for_a_ca( + hass: HomeAssistant, +) -> None: + """An entry with a usable anchor already has the protection; leave it alone.""" + entry = MockConfigEntry( + version=CURRENT_CONFIG_VERSION, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + fetch = AsyncMock(return_value=FAKE_CA_PEM) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch("custom_components.span_panel.config_flow.async_fetch_panel_ca", new=fetch), + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + ): + result = await entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + fetch.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_a_full_reauth_leaves_a_pinned_entry_pinned_to_the_same_thing( + hass: HomeAssistant, +) -> None: + """Reauth replaces credentials. It must not quietly move the trust anchor. + + The anchor and the port it was checked against are what a user compared a + fingerprint to and what a CA-changed repair compares against next time. A + reauth that rewrote either would make the repair fire on a panel that never + rotated anything, or hide one that did. + """ + entry = MockConfigEntry( + version=CURRENT_CONFIG_VERSION, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_HOST: MOCK_HOST, + CONF_EBUS_BROKER_PORT: 8883, + CONF_EBUS_BROKER_USERNAME: "old-user", + CONF_EBUS_BROKER_PASSWORD: "old-secret", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + CONF_HTTPS_PORT: 9443, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + fetch = AsyncMock(return_value="a-different-pem") + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch("custom_components.span_panel.config_flow.async_fetch_panel_ca", new=fetch), + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ) as register, + patch.object(hass.config_entries, "async_reload", return_value=True), + ): + menu = await entry.start_reauth_flow(hass) + form = await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "auth_passphrase"} + ) + done = await hass.config_entries.flow.async_configure( + form["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + + assert done["reason"] == "reauth_successful" + # Credentials moved on; the anchor and its port did not. + assert entry.data[CONF_ACCESS_TOKEN] == MOCK_V2_AUTH.access_token + assert entry.data[CONF_EBUS_BROKER_PASSWORD] == MOCK_V2_AUTH.ebus_broker_password + assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM + assert entry.data[CONF_HTTPS_PORT] == 9443 + # Nothing was fetched to replace it with, and registration ran over the + # stored pin on the stored port. + fetch.assert_not_awaited() + assert register.call_args.args[2].port == 9443 + assert register.call_args.args[2].ca_pem == FAKE_CA_PEM + + +@pytest.mark.asyncio +async def test_reauth_asks_where_a_moved_panel_serves_tls_and_keeps_the_answer( + hass: HomeAssistant, +) -> None: + """An entry behind a proxy has to say where the leaf is before it can be checked.""" + entry = _unpinned_entry(hass, {CONF_API_VERSION: "v2", CONF_HTTP_PORT: 8080}) + + leaf_host = AsyncMock(side_effect=lambda _hass, host, _port, _pem: host) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=leaf_host, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + patch.object(hass.config_entries, "async_reload", return_value=True), + ): + result = await entry.start_reauth_flow(hass) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "panel_https_port" + + menu = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HTTPS_PORT: 9443} + ) + assert menu["step_id"] == "reauth_confirm" + + form = await hass.config_entries.flow.async_configure( + menu["flow_id"], {"next_step_id": "auth_passphrase"} + ) + done = await hass.config_entries.flow.async_configure( + form["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + + assert done["reason"] == "reauth_successful" + # Checked on the port the user named, and stored so the pin keeps pointing there. + assert leaf_host.await_args is not None + assert leaf_host.await_args.args[2] == 9443 + assert entry.data[CONF_HTTPS_PORT] == 9443 + assert entry.data[CONF_PANEL_CA_PEM] == FAKE_CA_PEM + + +# ---------- user flow error paths ---------- -# ---------- user flow error paths ---------- - - @pytest.mark.asyncio async def test_user_flow_empty_host(hass: HomeAssistant) -> None: """Submitting an empty host should re-show the form with host_required error.""" @@ -709,10 +1544,7 @@ async def test_user_flow_recovery_after_bad_host(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} # Second attempt succeeds - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_HOST: MOCK_HOST}, - ) + result3 = await _submit_host_and_pin(hass, result2["flow_id"], {CONF_HOST: MOCK_HOST}) assert result3["type"] == FlowResultType.MENU assert result3["step_id"] == "choose_v2_auth" @@ -737,10 +1569,7 @@ async def test_passphrase_auth_empty_passphrase(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -778,10 +1607,7 @@ async def test_passphrase_auth_recovery_after_error(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -828,10 +1654,7 @@ async def test_proximity_auth_success(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) assert result2["step_id"] == "choose_v2_auth" result2b = await hass.config_entries.flow.async_configure( @@ -867,10 +1690,7 @@ async def test_proximity_not_proven_returns_to_menu(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, - ) + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -883,14 +1703,169 @@ async def test_proximity_not_proven_returns_to_menu(hass: HomeAssistant) -> None {"next_step_id": "auth_proximity_confirm"}, ) - # Should return to the proximity menu - assert result3["type"] == FlowResultType.MENU - assert result3["step_id"] == "auth_proximity" + # Should return to the proximity menu + assert result3["type"] == FlowResultType.MENU + assert result3["step_id"] == "auth_proximity" + + +@pytest.mark.asyncio +async def test_proximity_switch_to_passphrase(hass: HomeAssistant) -> None: + """User should be able to switch from proximity menu to passphrase.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) + + result2b = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"next_step_id": "auth_proximity"}, + ) + assert result2b["step_id"] == "auth_proximity" + + # User picks "Use passphrase instead" + result3 = await hass.config_entries.flow.async_configure( + result2b["flow_id"], + {"next_step_id": "auth_passphrase"}, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["step_id"] == "auth_passphrase" + + +# ---------- registration a panel cannot complete yet ---------- + +#: What each registration failure that is not the user's doing shows. Both +#: steps share the mapping; neither is `invalid_auth`. +NOT_YET_REGISTERABLE = [ + pytest.param( + SpanPanelServerError("HTTP 503", status_code=503), "panel_not_ready", id="server_503" + ), + pytest.param(SpanPanelTimeoutError("timed out"), "panel_not_ready", id="timeout"), + pytest.param( + SpanPanelPassphraseUnavailableError("unavailable", status_code=422), + "passphrase_unavailable", + id="passphrase_unavailable", + ), +] + +#: A registration that succeeded but carried no broker password. +MOCK_V2_AUTH_NO_BROKER_PASSWORD = dataclasses.replace( + MOCK_V2_AUTH, ebus_broker_password=None, hop_passphrase=None +) + + +async def _passphrase_form(hass: HomeAssistant): + """Run a user flow to the passphrase form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + assert result["step_id"] == "auth_passphrase" + return result + + +async def _confirm_proximity(hass: HomeAssistant): + """Run a user flow through the door-challenge confirmation.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await _submit_host_and_pin(hass, result["flow_id"], {CONF_HOST: MOCK_HOST}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_proximity"} + ) + return await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_proximity_confirm"} + ) + + +@pytest.mark.parametrize(("error", "expected"), NOT_YET_REGISTERABLE) +@pytest.mark.asyncio +async def test_passphrase_registration_the_panel_cannot_complete_yet( + hass: HomeAssistant, error: Exception, expected: str +) -> None: + """The passphrase form comes back with a retryable error, then succeeds.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + side_effect=[error, MOCK_V2_AUTH], + ), + ): + form = await _passphrase_form(hass) + failed = await hass.config_entries.flow.async_configure( + form["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + assert failed["type"] == FlowResultType.FORM + assert failed["step_id"] == "auth_passphrase" + assert failed["errors"] == {"base": expected} + + retried = await hass.config_entries.flow.async_configure( + failed["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + assert retried["step_id"] == "choose_entity_naming_initial" + + +@pytest.mark.parametrize(("error", "expected"), NOT_YET_REGISTERABLE) +@pytest.mark.asyncio +async def test_proximity_registration_the_panel_cannot_complete_yet( + hass: HomeAssistant, error: Exception, expected: str +) -> None: + """A proven door challenge that cannot register yet shows why, and can be retried.""" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=[ + MOCK_V2_DETECTION, + MOCK_V2_DETECTION_PROXIMITY_PROVEN, + MOCK_V2_DETECTION_PROXIMITY_PROVEN, + ], + ), + patch( + "custom_components.span_panel.config_flow.validate_host", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_proximity", + side_effect=[error, MOCK_V2_AUTH], + ), + ): + failed = await _confirm_proximity(hass) + assert failed["type"] == FlowResultType.FORM + assert failed["step_id"] == "auth_proximity_confirm" + assert failed["errors"] == {"base": expected} + + retried = await hass.config_entries.flow.async_configure(failed["flow_id"], {}) + assert retried["step_id"] == "choose_entity_naming_initial" @pytest.mark.asyncio -async def test_proximity_switch_to_passphrase(hass: HomeAssistant) -> None: - """User should be able to switch from proximity menu to passphrase.""" +async def test_no_entry_is_created_without_a_broker_password(hass: HomeAssistant) -> None: + """A registration with a null broker password stops at the form. + + Patched below the validator, at the library call, so the guard that turns + the missing password into an error is the real one. + """ with ( patch( "custom_components.span_panel.config_flow.detect_api_version", @@ -900,30 +1875,75 @@ async def test_proximity_switch_to_passphrase(hass: HomeAssistant) -> None: "custom_components.span_panel.config_flow.validate_host", return_value=True, ), + patch( + "custom_components.span_panel.config_flow_validation.register_v2", + new=AsyncMock(return_value=MOCK_V2_AUTH_NO_BROKER_PASSWORD), + ), ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + form = await _passphrase_form(hass) + failed = await hass.config_entries.flow.async_configure( + form["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: MOCK_HOST}, - ) + assert failed["type"] == FlowResultType.FORM + assert failed["errors"] == {"base": "passphrase_unavailable"} + assert hass.config_entries.async_entries(DOMAIN) == [] - result2b = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {"next_step_id": "auth_proximity"}, - ) - assert result2b["step_id"] == "auth_proximity" - # User picks "Use passphrase instead" - result3 = await hass.config_entries.flow.async_configure( - result2b["flow_id"], - {"next_step_id": "auth_passphrase"}, - ) +@pytest.mark.parametrize("method", ["auth_passphrase", "auth_proximity"]) +@pytest.mark.asyncio +async def test_reauth_keeps_the_stored_password_when_none_comes_back( + hass: HomeAssistant, method: str +) -> None: + """Reauth by either method never overwrites a broker password with nothing.""" + entry = MockConfigEntry( + version=3, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "old-token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_HOST: "old-host", + CONF_EBUS_BROKER_PORT: 8883, + CONF_EBUS_BROKER_USERNAME: "old-user", + CONF_EBUS_BROKER_PASSWORD: "old-pass", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + before = dict(entry.data) - assert result3["type"] == FlowResultType.FORM - assert result3["step_id"] == "auth_passphrase" + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=[MOCK_V2_DETECTION, MOCK_V2_DETECTION_PROXIMITY_PROVEN], + ), + patch( + "custom_components.span_panel.config_flow_validation.register_v2", + new=AsyncMock(return_value=MOCK_V2_AUTH_NO_BROKER_PASSWORD), + ), + patch.object(hass.config_entries, "async_reload", return_value=True), + ): + result = await entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": method} + ) + if method == "auth_passphrase": + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + else: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_proximity_confirm"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "passphrase_unavailable"} + assert dict(entry.data) == before # ---------- duplicate entry prevention ---------- @@ -1118,11 +2138,8 @@ async def test_zeroconf_end_to_end_entry_creation(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm_discovery" - # Step 2: confirm → auth choice - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) + # Step 2: confirm → accept the panel's CA → auth choice + result2 = await _submit_host_and_pin(hass, result["flow_id"], {}) assert result2["type"] == FlowResultType.MENU assert result2["step_id"] == "choose_v2_auth" @@ -1538,28 +2555,53 @@ async def test_hassio_discovery_routes_to_confirm(hass: HomeAssistant) -> None: @pytest.mark.asyncio -async def test_hassio_dedup_by_serial(hass: HomeAssistant) -> None: - """Hassio discovery of an already-configured serial should abort and update host/port.""" - existing = MockConfigEntry( +def _hassio_configured_entry(hass: HomeAssistant, **data: object) -> MockConfigEntry: + """Add an entry for the simulator's serial, configured at a host of its own.""" + entry = MockConfigEntry( version=3, minor_version=1, domain=DOMAIN, title="Span Panel", data={ CONF_HOST: "192.168.1.40", + CONF_EBUS_BROKER_HOST: "192.168.1.40", CONF_ACCESS_TOKEN: "existing-token", CONF_API_VERSION: "v2", CONF_HTTP_PORT: 80, + **data, }, source=config_entries.SOURCE_USER, options={}, unique_id="SPAN-SIM-001", ) - existing.add_to_hass(hass) + entry.add_to_hass(hass) + return entry + + +def _detection_by_host(*answering: str) -> Callable[..., DetectionResult]: + """Answer as the simulator for the named hosts and as nothing for the rest. + + Keyed by host because that is the whole question here: the discovered host + and the configured one are two different addresses, and which of them + answers is what decides whether the entry moves. + """ + + def detect(host: str, **_kwargs: object) -> DetectionResult: + if host in answering: + return MOCK_V2_DETECTION_SIM + return DetectionResult(api_version="v1", probe_failed=True) + + return detect + + +@pytest.mark.asyncio +async def test_hassio_dedup_by_serial(hass: HomeAssistant) -> None: + """A discovery of an already-configured serial aborts rather than adding a second entry.""" + existing = _hassio_configured_entry(hass) with patch( "custom_components.span_panel.config_flow.detect_api_version", - return_value=MOCK_V2_DETECTION_SIM, + side_effect=_detection_by_host("192.168.1.50", "192.168.1.40"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -1569,11 +2611,111 @@ async def test_hassio_dedup_by_serial(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - # Host and port should be updated to the new values + assert existing.data[CONF_HTTP_PORT] == 9090 + + +@pytest.mark.asyncio +async def test_hassio_keeps_a_configured_host_that_still_answers(hass: HomeAssistant) -> None: + """The defect: an add-on restart overwrote a working host with its own hostname. + + The add-on republishes the container hostname it answers on, which is not a + claim that the panel moved and is generally not a name the panel's + certificate carries. Writing it over the configured host broke the entry + seconds after it was created — every connection afterwards failed + verification against the entry's own pin. + """ + existing = _hassio_configured_entry(hass) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=_detection_by_host("192.168.1.50", "192.168.1.40"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + assert result["reason"] == "already_configured" + assert existing.data[CONF_HOST] == "192.168.1.40" + assert existing.data[CONF_EBUS_BROKER_HOST] == "192.168.1.40" + # The ports are the add-on's own and are taken as published either way. + assert existing.data[CONF_HTTP_PORT] == 9090 + + +@pytest.mark.asyncio +async def test_hassio_probes_the_configured_host_on_the_newly_published_port( + hass: HomeAssistant, +) -> None: + """A reallocated port on the same machine is the case the ports are unguarded for. + + Probing the configured host on its *stored* port would report it dead every + time the add-on moved, which is the move this is meant to prevent. + """ + _hassio_configured_entry(hass) + probe = AsyncMock(side_effect=_detection_by_host("192.168.1.50", "192.168.1.40")) + + with patch("custom_components.span_panel.config_flow.detect_api_version", probe): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + probed = [(call.args[0], call.kwargs["port"]) for call in probe.call_args_list] + assert ("192.168.1.40", 9090) in probed + + +@pytest.mark.asyncio +async def test_hassio_moves_host_and_broker_host_together_when_the_old_one_is_dead( + hass: HomeAssistant, +) -> None: + """A configured host that has stopped answering is a panel that really moved. + + Both keys move or neither does: they are the same address for the same + panel, and moving one without the other left the entry naming two different + machines. + """ + existing = _hassio_configured_entry(hass) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=_detection_by_host("192.168.1.50"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + assert result["reason"] == "already_configured" assert existing.data[CONF_HOST] == "192.168.1.50" + assert existing.data[CONF_EBUS_BROKER_HOST] == "192.168.1.50" assert existing.data[CONF_HTTP_PORT] == 9090 +@pytest.mark.asyncio +async def test_hassio_takes_both_ports_whichever_way_the_host_goes( + hass: HomeAssistant, +) -> None: + """The published ports are the add-on's own and are never held to stored values.""" + kept = _hassio_configured_entry(hass, **{CONF_HTTPS_PORT: 443}) + + with patch( + "custom_components.span_panel.config_flow.detect_api_version", + side_effect=_detection_by_host("192.168.1.50", "192.168.1.40"), + ): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info({**MOCK_HASSIO_CONFIG, "https_port": 10090}), + ) + + assert kept.data[CONF_HOST] == "192.168.1.40" + assert kept.data[CONF_HTTP_PORT] == 9090 + assert kept.data[CONF_HTTPS_PORT] == 10090 + + @pytest.mark.usefixtures("socket_enabled") @pytest.mark.asyncio async def test_hassio_end_to_end_entry_creation(hass: HomeAssistant) -> None: @@ -1597,11 +2739,10 @@ async def test_hassio_end_to_end_entry_creation(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm_discovery" - # Step 2: confirm -> auth choice - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) + # Step 2: confirm -> HTTPS port (this panel moved its HTTP port) -> CA + port_step = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert port_step["step_id"] == "panel_https_port" + result2 = await _submit_host_and_pin(hass, port_step["flow_id"], {CONF_HTTPS_PORT: 9443}) assert result2["type"] == FlowResultType.MENU assert result2["step_id"] == "choose_v2_auth" @@ -1617,17 +2758,189 @@ async def test_hassio_end_to_end_entry_creation(hass: HomeAssistant) -> None: result3["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) + # Step 5: accept naming default -> entry created assert result4["step_id"] == "choose_entity_naming_initial" + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + {"entity_naming_pattern": "friendly_names"}, + ) + assert result5["type"] == FlowResultType.CREATE_ENTRY + assert result5["data"][CONF_API_VERSION] == "v2" + assert result5["data"][CONF_HOST] == "192.168.1.50" + assert result5["data"][CONF_HTTP_PORT] == 9090 + assert result5["data"][CONF_HTTPS_PORT] == 9443 + assert result5["data"][CONF_PANEL_CA_PEM] == FAKE_CA_PEM + + +@pytest.mark.usefixtures("socket_enabled") +@pytest.mark.asyncio +async def test_hassio_published_https_port_is_used_without_asking(hass: HomeAssistant) -> None: + """A discovery record naming the TLS port skips the prompt and is believed. + + The add-on allocates a TLS port per panel and reallocates it across + restarts, so it is the only party that knows the answer. Asking the user + for a number they would have to go read out of an add-on log is a question + with a worse answer available. + """ + leaf_host = AsyncMock(side_effect=lambda _hass, host, _port, _pem: host) + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION_SIM, + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + return_value=MOCK_V2_AUTH, + ), + patch( + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=leaf_host, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info({**MOCK_HASSIO_CONFIG, "https_port": 10090}), + ) + assert result["step_id"] == "confirm_discovery" + + # Confirm goes straight past the port question to the auth menu. + result2 = await _submit_host_and_pin(hass, result["flow_id"], {}) + assert result2["step_id"] == "choose_v2_auth" + + # The published port is the one the CA was checked against, not 443. + assert leaf_host.await_args.args[2] == 10090 + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE} + ) + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], {"entity_naming_pattern": "friendly_names"} + ) + + assert result5["type"] == FlowResultType.CREATE_ENTRY + assert result5["data"][CONF_HTTP_PORT] == 9090 + assert result5["data"][CONF_HTTPS_PORT] == 10090 + + +@pytest.mark.asyncio +async def test_zeroconf_https_port_txt_record_is_used_without_asking( + hass: HomeAssistant, +) -> None: + """The same holds over mDNS, where the panel publishes the port in TXT.""" + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={"httpPort": "8081", "httpsPort": "9081"}, + type="_ebus._tcp.local.", + ) + + leaf_host = AsyncMock(side_effect=lambda _hass, host, _port, _pem: host) + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + patch( + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=leaf_host, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["step_id"] == "confirm_discovery" + + result2 = await _submit_host_and_pin(hass, result["flow_id"], {}) + assert result2["step_id"] == "choose_v2_auth" + assert leaf_host.await_args.args[2] == 9081 + + +@pytest.mark.asyncio +async def test_moved_http_port_alone_still_asks_for_the_https_one( + hass: HomeAssistant, +) -> None: + """A panel that publishes no TLS port is still asked about, as before. + + Hardware behind a reverse proxy advertises ``httpPort`` and nothing else. + Reading a port from discovery must not become a way for that install to + silently end up checking the CA against 443. + """ + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={"httpPort": "8080"}, + type="_ebus._tcp.local.", + ) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + port_step = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert port_step["step_id"] == "panel_https_port" + + +@pytest.mark.asyncio +async def test_unreadable_https_port_in_discovery_falls_back_to_asking( + hass: HomeAssistant, +) -> None: + """A TXT record that cannot be read is not a port, so the question stands.""" + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={"httpPort": "8080", "httpsPort": "not-a-port"}, + type="_ebus._tcp.local.", + ) - # Step 5: accept naming default -> entry created - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - {"entity_naming_pattern": "friendly_names"}, + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, ) - assert result5["type"] == FlowResultType.CREATE_ENTRY - assert result5["data"][CONF_API_VERSION] == "v2" - assert result5["data"][CONF_HOST] == "192.168.1.50" - assert result5["data"][CONF_HTTP_PORT] == 9090 + port_step = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert port_step["step_id"] == "panel_https_port" # ---------- user flow: null status_info ---------- @@ -1736,9 +3049,7 @@ async def test_zeroconf_invalid_http_port_defaults_to_80(hass: HomeAssistant) -> assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm_discovery" - mock_detect.assert_awaited_once_with( - "192.168.1.200", port=80, httpx_client=fake_client - ) + mock_detect.assert_awaited_once_with("192.168.1.200", port=80, httpx_client=fake_client) @pytest.mark.usefixtures("socket_enabled") @@ -1776,9 +3087,8 @@ async def test_user_flow_fqdn_registration_progress_then_naming( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "panel.example.com"}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: "panel.example.com"} ) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -1788,8 +3098,52 @@ async def test_user_flow_fqdn_registration_progress_then_naming( result2b["flow_id"], {CONF_HOP_PASSPHRASE: MOCK_PASSPHRASE}, ) + # The progress step carries its triggering input through to the naming step, + # which takes the default and creates the entry in one go. assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["data"][CONF_REGISTERED_FQDN] == "panel.example.com" + # Registration itself ran over this anchor, not just what followed it. + assert result3["data"][CONF_PANEL_CA_PEM] == FAKE_CA_PEM + + +@pytest.mark.asyncio +async def test_an_anchorless_readiness_wait_fetches_the_ca_once( + hass: HomeAssistant, +) -> None: + """The leaf changes while the panel regenerates it; the anchor does not. + + An entry whose CA fetch never succeeded has no anchor of its own, so the + wait has to fetch one. It used to fetch a fresh one on every poll -- thirty + plaintext trust decisions, two seconds apart, each against whatever + answered. One fetch, held for the rest of the wait. + """ + flow = SpanPanelConfigFlow() + flow.hass = hass + flow.host = "panel.example.com" + flow.access_token = "token" + flow._http_port = 80 + flow._rest_transport = PanelRestTransport( + port=80, ssl_context=None, httpx_client=None, ca_pem=None + ) + + # False twice, then ready: the poll runs more than once, which is the only + # way a per-poll fetch would show up. + ready = AsyncMock(side_effect=[False, False, True]) + download = AsyncMock(return_value="fetched-pem") + + with ( + patch("custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock()), + patch("custom_components.span_panel.config_flow.async_download_ca_or_none", new=download), + patch("custom_components.span_panel.config_flow.check_fqdn_tls_ready", new=ready), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + patch.object(flow, "_async_verify_host_over_pin", new=AsyncMock()), + ): + await flow._async_register_fqdn_and_wait() + + assert ready.await_count == 3 + download.assert_awaited_once() + # Every poll checked against the anchor that single fetch produced. + assert [call.args[2] for call in ready.await_args_list] == ["fetched-pem"] * 3 @pytest.mark.usefixtures("socket_enabled") @@ -1827,9 +3181,8 @@ async def test_user_flow_fqdn_registration_failure_can_continue( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "panel.example.com"}, + result2 = await _submit_host_and_pin( + hass, result["flow_id"], {CONF_HOST: "panel.example.com"} ) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -1890,10 +3243,13 @@ async def test_fqdn_entry_creation_sets_registered_fqdn_and_unique_title( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( + port_step = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "panel.example.com", CONF_HTTP_PORT: 8080}, ) + assert port_step["step_id"] == "panel_https_port" + # Left at the default, so it is not written to the entry. + result2 = await _submit_host_and_pin(hass, port_step["flow_id"], {CONF_HTTPS_PORT: 443}) result2b = await hass.config_entries.flow.async_configure( result2["flow_id"], {"next_step_id": "auth_passphrase"}, @@ -1907,6 +3263,7 @@ async def test_fqdn_entry_creation_sets_registered_fqdn_and_unique_title( assert result3["data"][CONF_HOST] == "panel.example.com" assert result3["data"][CONF_REGISTERED_FQDN] == "panel.example.com" assert result3["data"][CONF_HTTP_PORT] == 8080 + assert CONF_HTTPS_PORT not in result3["data"] @pytest.mark.asyncio @@ -1922,7 +3279,7 @@ async def test_update_v2_entry_missing_entry_aborts_with_reauth_failed( flow.serial_number = "SPAN-V2-001" flow.access_token = MOCK_V2_AUTH.access_token flow._is_flow_setup = True - flow._store_v2_auth_result(MOCK_V2_AUTH, MOCK_PASSPHRASE) + flow._store_v2_auth_result(MOCK_V2_AUTH) result = await flow._async_finalize_v2_auth() @@ -1961,6 +3318,11 @@ async def test_reconfigure_to_fqdn_registers_and_updates_registered_fqdn( "custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock(), ), + # This entry pins no CA, so the readiness wait fetches one itself. + patch( + "custom_components.span_panel.config_flow.async_download_ca_or_none", + new=AsyncMock(return_value=FAKE_CA_PEM), + ), patch( "custom_components.span_panel.config_flow.check_fqdn_tls_ready", new=AsyncMock(return_value=True), @@ -2014,6 +3376,11 @@ async def test_reconfigure_fqdn_failure_can_continue_without_registration( "custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock(), ), + # This entry pins no CA, so the readiness wait fetches one itself. + patch( + "custom_components.span_panel.config_flow.async_download_ca_or_none", + new=AsyncMock(return_value=FAKE_CA_PEM), + ), patch( "custom_components.span_panel.config_flow.check_fqdn_tls_ready", new=AsyncMock(return_value=False), @@ -2060,8 +3427,10 @@ async def test_reconfigure_switch_from_fqdn_to_ip_clears_registration( fake_client = MagicMock() with ( + # The transport is resolved in config_flow_validation, which is where the + # shared client is taken from when the entry has no pinned CA. patch( - "custom_components.span_panel.config_flow.get_async_client", + "custom_components.span_panel.config_flow_validation.get_async_client", return_value=fake_client, ), patch( @@ -2081,8 +3450,313 @@ async def test_reconfigure_switch_from_fqdn_to_ip_clears_registration( assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "reconfigure_successful" + # Unpinned entry: the plaintext port and the shared client, and no context. mock_delete.assert_awaited_once_with( - "192.168.1.201", "token", port=80, httpx_client=fake_client + "192.168.1.201", "token", port=80, httpx_client=fake_client, ssl_context=None ) assert entry.data[CONF_HOST] == "192.168.1.201" assert entry.data[CONF_REGISTERED_FQDN] == "" + + +@pytest.mark.asyncio +async def test_reconfigure_refuses_to_downgrade_an_unreadable_pin( + hass: HomeAssistant, +) -> None: + """A pin that cannot be read is not permission to reconfigure over plaintext. + + This flow carries the entry's access token to the panel — `delete_fqdn` on + this branch, `register_fqdn` on the other — and checks the new host against + the anchor before writing it. With no usable anchor it can do neither, so it + refuses instead of falling back to the transport the entry had before it was + pinned. Nothing reaches the network and nothing is written. + """ + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="SPAN Panel", + data={ + CONF_HOST: "panel.example.com", + CONF_REGISTERED_FQDN: "panel.example.com", + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: UNREADABLE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + detect = AsyncMock(return_value=MOCK_V2_DETECTION) + delete = AsyncMock() + with ( + patch("custom_components.span_panel.config_flow.detect_api_version", new=detect), + patch("custom_components.span_panel.config_flow.delete_fqdn", new=delete), + ): + result = await entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.201"}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "reconfigure" + assert result2["errors"] == {"base": "ca_unusable"} + detect.assert_not_awaited() + delete.assert_not_awaited() + assert entry.data[CONF_HOST] == "panel.example.com" + assert entry.data[CONF_REGISTERED_FQDN] == "panel.example.com" + assert entry.data[CONF_PANEL_CA_PEM] == UNREADABLE_CA_PEM + + +@pytest.mark.asyncio +async def test_reconfigure_offers_the_https_port_to_a_pinned_entry( + hass: HomeAssistant, +) -> None: + """A pinned entry's REST rides the HTTPS port, so reconfigure must let it be set. + + The population this exists for was never asked: TLS behind a port forward + with the HTTP port still on 80, which the CA step's gating skips. Before + this field, such an entry was enforced against port 443 with no in-product + way to say otherwise. + """ + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert CONF_HTTPS_PORT in result["data_schema"].schema + + +@pytest.mark.asyncio +async def test_reconfigure_keeps_the_host_only_form_for_an_unpinned_entry( + hass: HomeAssistant, +) -> None: + """No pin, no HTTPS transport — a TLS-port question would answer nothing.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.FORM + assert CONF_HTTPS_PORT not in result["data_schema"].schema + + +@pytest.mark.asyncio +async def test_reconfigure_rejects_the_plaintext_port_as_https( + hass: HomeAssistant, +) -> None: + """80 is the plaintext default; storing it as the TLS port bricks every setup.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + detect = AsyncMock(return_value=MOCK_V2_DETECTION) + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch("custom_components.span_panel.config_flow.detect_api_version", new=detect), + ): + result = await entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_HTTPS_PORT: 80}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "https_port_is_plaintext"} + detect.assert_not_awaited() + assert CONF_HTTPS_PORT not in entry.data + + +@pytest.mark.asyncio +async def test_reconfigure_stores_a_corrected_https_port(hass: HomeAssistant) -> None: + """The submitted port is both probed against and persisted.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="Span Panel", + data={ + CONF_HOST: MOCK_HOST, + CONF_ACCESS_TOKEN: "token", + CONF_API_VERSION: "v2", + CONF_PANEL_CA_PEM: FAKE_CA_PEM, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id="SPAN-V2-001", + ) + entry.add_to_hass(hass) + + leaf_host = AsyncMock(side_effect=lambda _hass, host, _port, _pem: host) + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=MagicMock(), + ), + patch( + "custom_components.span_panel.config_flow.async_panel_leaf_host", + new=leaf_host, + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + ): + result = await entry.start_reconfigure_flow(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_HTTPS_PORT: 8443}, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert entry.data[CONF_HTTPS_PORT] == 8443 + # The probe ran against the port the user submitted, not the stored default. + assert leaf_host.await_args.args[2] == 8443 + + +@pytest.mark.asyncio +async def test_the_https_port_step_refuses_the_plaintext_port( + hass: HomeAssistant, +) -> None: + """80 stored as the TLS port fails every later setup; refuse it at the form.""" + discovery_info = ZeroconfServiceInfo( + ip_address=ipaddress.IPv4Address("192.168.1.200"), + ip_addresses=[ipaddress.IPv4Address("192.168.1.200")], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={"httpPort": "8080"}, + type="_ebus._tcp.local.", + ) + + with ( + patch( + "custom_components.span_panel.config_flow.detect_api_version", + return_value=MOCK_V2_DETECTION, + ), + patch( + "custom_components.span_panel.config_flow.is_ipv4_address", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + port_step = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert port_step["step_id"] == "panel_https_port" + + refused = await hass.config_entries.flow.async_configure( + port_step["flow_id"], {CONF_HTTPS_PORT: 80} + ) + + assert refused["type"] == FlowResultType.FORM + assert refused["step_id"] == "panel_https_port" + assert refused["errors"] == {"base": "https_port_is_plaintext"} + + +@pytest.mark.asyncio +async def test_hassio_probes_a_pinned_entry_over_its_pin(hass: HomeAssistant) -> None: + """The identity read that decides a host move must not travel plaintext under a pin. + + The probe reads the serial out of the status answer, and 3.4.1's warning + exemption means nothing in the log says it went unverified — so the probe + owes a pinned entry its own transport. On the add-on's newly published TLS + port, because a reallocated port is the very case this probe exists for. + """ + _hassio_configured_entry(hass, **{CONF_PANEL_CA_PEM: FAKE_CA_PEM}) + context = MagicMock(name="pinned-context") + probe = AsyncMock(side_effect=_detection_by_host("192.168.1.50", "192.168.1.40")) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ), + patch("custom_components.span_panel.config_flow.detect_api_version", probe), + ): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info({**MOCK_HASSIO_CONFIG, "https_port": 10090}), + ) + + call = next(c for c in probe.call_args_list if c.args[0] == "192.168.1.40") + assert call.kwargs["ssl_context"] is context + assert call.kwargs["port"] == 10090 + + +@pytest.mark.asyncio +async def test_hassio_pinned_probe_takes_the_stored_tls_port_when_none_is_published( + hass: HomeAssistant, +) -> None: + """No published TLS port means the entry's own is the only address for the pin.""" + _hassio_configured_entry(hass, **{CONF_PANEL_CA_PEM: FAKE_CA_PEM, CONF_HTTPS_PORT: 8443}) + context = MagicMock(name="pinned-context") + probe = AsyncMock(side_effect=_detection_by_host("192.168.1.50", "192.168.1.40")) + + with ( + patch( + "custom_components.span_panel.config_flow_validation.build_panel_ssl_context", + return_value=context, + ), + patch("custom_components.span_panel.config_flow.detect_api_version", probe), + ): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HASSIO}, + data=_hassio_service_info(MOCK_HASSIO_CONFIG), + ) + + call = next(c for c in probe.call_args_list if c.args[0] == "192.168.1.40") + assert call.kwargs["ssl_context"] is context + assert call.kwargs["port"] == 8443 diff --git a/tests/test_v2_config_flow_tls.py b/tests/test_v2_config_flow_tls.py new file mode 100644 index 00000000..06b3e046 --- /dev/null +++ b/tests/test_v2_config_flow_tls.py @@ -0,0 +1,1040 @@ +"""Config flow tests that run against a real TLS handshake. + +The rest of the v2 config-flow suite stubs `async_leaf_chains_to_ca` and +`build_panel_ssl_context`, so hostname verification is never exercised there. +These tests live in their own module precisely so that autouse fixture does not +reach them: the synthetic panel in `tls_panel` generates a certificate authority +and a leaf, serves that leaf from a small HTTPS listener on loopback, and the +flow's own TLS code runs against it unmodified. + +Everything is synthetic — a throwaway CA, a self-signed panel leaf, and the name +`panel.home.lan` resolved to 127.0.0.1 for the duration of one test. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +import contextlib +import ipaddress +from typing import Any +from unittest.mock import AsyncMock, patch + +from cryptography import x509 +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import DetectionResult, V2AuthResponse, V2StatusInfo + +from custom_components.span_panel.const import ( + CONF_API_VERSION, + CONF_EBUS_BROKER_PORT, + CONF_HOP_PASSPHRASE, + CONF_HTTP_PORT, + CONF_HTTPS_PORT, + CONF_PANEL_CA_PEM, + CONF_REGISTERED_FQDN, + DOMAIN, +) + +from .tls_panel import ( + LOOPBACK_NAMES, + PANEL_FQDN, + PANEL_LOOPBACK, + PANEL_SERIAL, + PANEL_SHORTNAME, + Panel, + issue_ca, + issue_leaf, + resolving_to_loopback, + unrelated_ca_pem, +) + +PASSPHRASE = "hunter2-synthetic" + + +@pytest.fixture +def panel(tmp_path: Any) -> Iterator[Panel]: + """Serve a leaf naming the panel's IP only, as one does before registration.""" + instance = Panel(tmp_path) + instance.present([x509.IPAddress(ipaddress.ip_address(PANEL_LOOPBACK))]) + yield instance + instance.close() + + +@pytest.fixture +def resolves_to_loopback() -> Iterator[None]: + """Point the panel's names at the loopback listener, and nothing else.""" + with resolving_to_loopback(*LOOPBACK_NAMES): + yield + + +async def _finish_progress(hass: HomeAssistant, result: dict[str, Any]) -> Any: + """Drive a `async_show_progress` step to whatever it resolves into. + + The registration task does real TLS work, so it is never done on the turn + that starts it — unlike the rest of the suite, where every call inside it is + a mock that completes immediately. + """ + while result["type"] == FlowResultType.SHOW_PROGRESS: + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + if result["type"] == FlowResultType.SHOW_PROGRESS_DONE: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + return result + + +def _auth_response(mqtts_port: int) -> V2AuthResponse: + return V2AuthResponse( + access_token="synthetic-token", + token_type="bearer", + iat_ms=1700000000000, + ebus_broker_host=PANEL_LOOPBACK, + ebus_broker_mqtts_port=mqtts_port, + ebus_broker_ws_port=8080, + ebus_broker_wss_port=8443, + ebus_broker_username="span-user", + ebus_broker_password="mqtt-secret", + hostname="span-panel.local", + serial_number=PANEL_SERIAL, + hop_passphrase=PASSPHRASE, + ) + + +@contextlib.contextmanager +def _plaintext_bootstrap_stubbed(panel: Panel) -> Iterator[None]: + """Stub everything that is not TLS: the probe, the reachability check, the CA fetch. + + The CA fetch is plain HTTP and the probe answers over it, so neither is what + these tests are about. Everything downstream of them — context building, + hostname verification, the handshake — runs for real against the listener. + """ + with ( + patch( + "custom_components.span_panel.config_flow.validate_host", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + new=AsyncMock( + return_value=DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number=PANEL_SERIAL, firmware_version="2.0.0-synthetic" + ), + ) + ), + ), + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ), + ): + yield + + +async def _reach_the_auth_menu(hass: HomeAssistant, host: str, panel: Panel) -> Any: + """Submit the host form and the TLS port, landing wherever the CA step lands. + + A non-default HTTP port is what makes the flow ask where TLS lives, which is + how the listener's ephemeral port reaches it. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: host, CONF_HTTP_PORT: 8080} + ) + assert result["step_id"] == "panel_https_port", result + return await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HTTPS_PORT: panel.port} + ) + + +def _registration_adds_the_fqdn(panel: Panel) -> Callable[..., Any]: + """Return a `register_fqdn` stand-in with the side effect the real panel has.""" + + async def _register(*args: Any, **kwargs: Any) -> None: + panel.present( + [ + x509.IPAddress(ipaddress.ip_address(PANEL_LOOPBACK)), + x509.DNSName(PANEL_FQDN), + ] + ) + + return _register + + +# ---------- Case A: a fresh install named by FQDN ---------- + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_fresh_fqdn_install_pins_and_completes(hass: HomeAssistant, panel: Panel) -> None: + """A fresh install by FQDN must reach entry creation with the CA pinned. + + The panel's leaf names its address, not the FQDN — the FQDN only lands in + the SAN once `register_fqdn` has run, which is after authentication. So the + pre-registration leaf check has to be made over the address the leaf names. + """ + register = AsyncMock(side_effect=_registration_adds_the_fqdn(panel)) + fetch_ca = AsyncMock(return_value=panel.ca_pem) + with ( + patch( + "custom_components.span_panel.config_flow.validate_host", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + new=AsyncMock( + return_value=DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number=PANEL_SERIAL, firmware_version="2.0.0-synthetic" + ), + ) + ), + ), + patch("custom_components.span_panel.config_flow.async_fetch_panel_ca", new=fetch_ca), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + new=AsyncMock(return_value=_auth_response(panel.port)), + ) as validate, + patch("custom_components.span_panel.config_flow.register_fqdn", new=register), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # A non-default HTTP port is what makes the flow ask where TLS lives, + # which is how the listener's ephemeral port reaches it. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN, CONF_HTTP_PORT: 8080} + ) + assert result["step_id"] == "panel_https_port", result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HTTPS_PORT: panel.port} + ) + assert result["type"] == FlowResultType.MENU, result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: PASSPHRASE} + ) + # Registration runs as a background task behind a progress screen, and + # the TLS work in it is real, so the flow is driven on from there. + assert result["type"] == FlowResultType.SHOW_PROGRESS, result + assert result["progress_action"] == "registering_fqdn" + result = await _finish_progress(hass, result) + assert result["step_id"] == "choose_entity_naming_initial", result + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY, result + assert result["data"][CONF_HOST] == PANEL_FQDN + assert result["data"][CONF_REGISTERED_FQDN] == PANEL_FQDN + assert result["data"][CONF_PANEL_CA_PEM] == panel.ca_pem + # The bootstrap ran over the address the leaf names, and the FQDN is what + # was registered — not the other way round. The CA fetch is the exception + # and stays on the name: it is plain HTTP, so which address answers it makes + # no difference to what may be trusted. + assert fetch_ca.await_args.args[1] == PANEL_FQDN + assert validate.await_args.args[0] == PANEL_LOOPBACK + assert register.await_args.args[0] == PANEL_LOOPBACK + assert register.await_args.args[2] == PANEL_FQDN + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_fresh_install_still_refuses_a_leaf_the_ca_does_not_sign( + hass: HomeAssistant, panel: Panel +) -> None: + """Bootstrapping over the IP must not turn the leaf check off.""" + stranger_cert, stranger_key = issue_leaf(*issue_ca(), [x509.DNSName("elsewhere.invalid")]) + panel.present_pem(stranger_cert, stranger_key) + with ( + patch( + "custom_components.span_panel.config_flow.validate_host", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + new=AsyncMock( + return_value=DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number=PANEL_SERIAL, firmware_version="2.0.0-synthetic" + ), + ) + ), + ), + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN, CONF_HTTP_PORT: 8080} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HTTPS_PORT: panel.port} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "panel_ca" + assert result["errors"] == {"base": "ca_leaf_mismatch"} + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_registration_that_never_lands_does_not_report_success( + hass: HomeAssistant, panel: Panel +) -> None: + """A panel that accepts the registration but never serves the FQDN must not pass. + + Also pins down where the readiness poll gets its anchor: from the CA this + flow already accepted, not from a fresh plaintext fetch every two seconds. + """ + download = AsyncMock(return_value=panel.ca_pem) + with ( + patch( + "custom_components.span_panel.config_flow.validate_host", + new=AsyncMock(return_value=True), + ), + patch( + "custom_components.span_panel.config_flow.detect_api_version", + new=AsyncMock( + return_value=DetectionResult( + api_version="v2", + status_info=V2StatusInfo( + serial_number=PANEL_SERIAL, firmware_version="2.0.0-synthetic" + ), + ) + ), + ), + patch( + "custom_components.span_panel.config_flow.async_fetch_panel_ca", + new=AsyncMock(return_value=panel.ca_pem), + ), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + new=AsyncMock(return_value=_auth_response(panel.port)), + ), + # The panel says yes and does nothing: its leaf still names only the IP. + patch("custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock()), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + patch( + "custom_components.span_panel.config_flow_validation.download_ca_cert", + new=download, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN, CONF_HTTP_PORT: 8080} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HTTPS_PORT: panel.port} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: PASSPHRASE} + ) + result = await _finish_progress(hass, result) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "fqdn_failed" + assert result["errors"] == {"base": "fqdn_registration_failed"} + download.assert_not_awaited() + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_continuing_past_a_failed_registration_uses_the_panels_address( + hass: HomeAssistant, panel: Panel +) -> None: + """Continuing anyway must not pin the entry to the name that just failed. + + Registration is what would have made the panel serve the domain, so once it + has failed the domain is a host the pinned anchor rejects — and an entry + recording it could not reach its own panel. The address the panel was + reached by is recorded instead, and no FQDN registration is claimed. + """ + with ( + _plaintext_bootstrap_stubbed(panel), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + new=AsyncMock(return_value=_auth_response(panel.port)), + ), + # The panel says yes and does nothing: its leaf still names only the IP. + patch("custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock()), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await _reach_the_auth_menu(hass, PANEL_FQDN, panel) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: PASSPHRASE} + ) + result = await _finish_progress(hass, result) + assert result["step_id"] == "fqdn_failed", result + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["step_id"] == "choose_entity_naming_initial", result + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY, result + assert result["data"][CONF_HOST] == PANEL_LOOPBACK + assert CONF_REGISTERED_FQDN not in result["data"] + assert result["data"][CONF_PANEL_CA_PEM] == panel.ca_pem + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_name_nothing_will_register_is_refused_rather_than_pinned( + hass: HomeAssistant, panel: Panel +) -> None: + """A single-label host is never registered, so it has to be named already. + + `is_fqdn` is False for it, so `_async_finalize_v2_auth` goes straight to + entry creation with no registration in between. Bootstrapping over the + resolved address gets the flow that far; persisting the name would leave an + entry pinned to a host its own anchor rejects, so it fails closed instead. + """ + with ( + _plaintext_bootstrap_stubbed(panel), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + new=AsyncMock(return_value=_auth_response(panel.port)), + ), + ): + result = await _reach_the_auth_menu(hass, PANEL_SHORTNAME, panel) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: PASSPHRASE} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "panel_ca" + assert result["errors"] == {"base": "ca_leaf_mismatch"} + assert not hass.config_entries.async_entries(DOMAIN) + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_name_the_leaf_already_covers_is_kept_and_never_swapped_for_an_address( + hass: HomeAssistant, panel: Panel +) -> None: + """The host the user gave is tried first, so a panel that names it keeps it. + + This is the add-on and search-domain case: the panel serves under a + hostname, its certificate says so, and there is nothing to work around. + Preferring the resolved address here would have swapped a working host for + one the certificate may not name at all. + """ + panel.present( + [ + x509.DNSName(PANEL_SHORTNAME), + x509.IPAddress(ipaddress.ip_address(PANEL_LOOPBACK)), + ] + ) + with ( + _plaintext_bootstrap_stubbed(panel), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + new=AsyncMock(return_value=_auth_response(panel.port)), + ) as validate, + ): + result = await _reach_the_auth_menu(hass, PANEL_SHORTNAME, panel) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: PASSPHRASE} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY, result + assert result["data"][CONF_HOST] == PANEL_SHORTNAME + assert result["data"][CONF_PANEL_CA_PEM] == panel.ca_pem + # Registration ran over the name too, not over an address substituted for it. + assert validate.await_args.args[0] == PANEL_SHORTNAME + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_reauth_of_an_unpinned_fqdn_entry_will_not_pin_an_unnamed_host( + hass: HomeAssistant, panel: Panel +) -> None: + """Reauth acquires the anchor, and the entry's own host has to survive it. + + An entry recorded against an FQDN the panel does not name reaches reauth + unpinned, gets bootstrapped over the address like any other, and would then + have had the anchor written beside a host that anchor rejects. Nothing here + registers the name, so the reauth fails closed and the entry is left as it + was. + """ + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="SPAN Panel", + data={ + CONF_HOST: PANEL_FQDN, + CONF_ACCESS_TOKEN: "synthetic-token", + CONF_API_VERSION: "v2", + CONF_HTTPS_PORT: panel.port, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id=PANEL_SERIAL, + ) + entry.add_to_hass(hass) + + with ( + _plaintext_bootstrap_stubbed(panel), + patch( + "custom_components.span_panel.config_flow.validate_v2_passphrase", + new=AsyncMock(return_value=_auth_response(panel.port)), + ), + ): + result = await entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm", result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "auth_passphrase"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOP_PASSPHRASE: PASSPHRASE} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "panel_ca" + assert result["errors"] == {"base": "ca_leaf_mismatch"} + # Nothing was written: no anchor beside a host it rejects, no new token. + assert CONF_PANEL_CA_PEM not in entry.data + assert entry.data[CONF_HOST] == PANEL_FQDN + assert entry.data[CONF_ACCESS_TOKEN] == "synthetic-token" + + +# ---------- Case B: a pinned IP entry reconfigured to an FQDN ---------- + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_reconfigure_pinned_ip_entry_to_fqdn(hass: HomeAssistant, panel: Panel) -> None: + """A pinned entry moved to an FQDN must probe over the address the leaf names.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="SPAN Panel", + data={ + CONF_HOST: PANEL_LOOPBACK, + CONF_ACCESS_TOKEN: "synthetic-token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_PORT: panel.port, + CONF_HTTPS_PORT: panel.port, + CONF_PANEL_CA_PEM: panel.ca_pem, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id=PANEL_SERIAL, + ) + entry.add_to_hass(hass) + + register = AsyncMock(side_effect=_registration_adds_the_fqdn(panel)) + with ( + patch("custom_components.span_panel.config_flow.register_fqdn", new=register), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS, result + result = await _finish_progress(hass, result) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == PANEL_FQDN + assert entry.data[CONF_REGISTERED_FQDN] == PANEL_FQDN + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + assert register.await_args.args[0] == PANEL_LOOPBACK + assert register.await_args.args[2] == PANEL_FQDN + + +def _pinned_entry(hass: HomeAssistant, panel: Panel) -> MockConfigEntry: + """Add an entry already pinned to this panel and reaching it by address.""" + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="SPAN Panel", + data={ + CONF_HOST: PANEL_LOOPBACK, + CONF_ACCESS_TOKEN: "synthetic-token", + CONF_API_VERSION: "v2", + CONF_EBUS_BROKER_PORT: panel.port, + CONF_HTTPS_PORT: panel.port, + CONF_PANEL_CA_PEM: panel.ca_pem, + }, + source=config_entries.SOURCE_USER, + options={}, + unique_id=PANEL_SERIAL, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_continuing_past_a_failed_reconfigure_keeps_the_panels_address( + hass: HomeAssistant, panel: Panel +) -> None: + """The reconfigure "continue anyway" makes the same trade as the install one. + + An entry that was reaching its panel by address stays that way. Writing the + domain would move a working entry onto a host the anchor it is already + pinned to rejects, which is a worse outcome than the move simply not + happening. + """ + entry = _pinned_entry(hass, panel) + + with ( + # The panel says yes and does nothing: its leaf still names only the IP. + patch("custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock()), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN} + ) + result = await _finish_progress(hass, result) + assert result["step_id"] == "reconfigure_fqdn_failed", result + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + assert CONF_REGISTERED_FQDN not in entry.data + assert entry.data[CONF_PANEL_CA_PEM] == panel.ca_pem + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_reconfigure_refuses_a_name_the_pinned_certificate_does_not_cover( + hass: HomeAssistant, panel: Panel +) -> None: + """A move to a single-label name is never registered, so it must already be named. + + The address bootstrap is what lets the probe reach the panel at all; it is + not permission to store a host the pinned certificate rejects, and nothing + on this branch will ask the panel to start serving it. + + Refused as a *naming* failure, not a signing one. The panel answered and its + certificate chains to the pinned anchor -- nothing else holds a key that + anchor signed -- so telling the user it "is not signed by the authority it + published" would accuse the network of interception over a name the panel + simply has not been told about. The remedies differ too, and only the + naming message can name them. + """ + entry = _pinned_entry(hass, panel) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_SHORTNAME} + ) + + assert result["type"] == FlowResultType.FORM, result + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "ca_name_mismatch"} + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + + +# ---------- Case C: an already-configured entry follows a host only under its pin ---------- + +#: Where the pinned entry already reaches its panel. RFC 5737 TEST-NET-1, so +#: nothing routes there: the guard is about the *candidate* host, and the host +#: the entry already holds is never dialled. +PANEL_ADDRESS = "192.0.2.10" + + +def _entry_reached_at_the_address( + hass: HomeAssistant, ca_pem: str | None, tls_port: int +) -> MockConfigEntry: + """Add an entry for this panel at `PANEL_ADDRESS`, pinned to `ca_pem`. + + `None` is the entry that never acquired an anchor. It is what proves the + guard leaves that install exactly as it was rather than freezing its host. + """ + data: dict[str, Any] = { + CONF_HOST: PANEL_ADDRESS, + CONF_ACCESS_TOKEN: "synthetic-token", + CONF_API_VERSION: "v2", + CONF_HTTPS_PORT: tls_port, + } + if ca_pem is not None: + data[CONF_PANEL_CA_PEM] = ca_pem + entry = MockConfigEntry( + version=7, + minor_version=1, + domain=DOMAIN, + title="SPAN Panel", + data=data, + source=config_entries.SOURCE_USER, + options={}, + unique_id=PANEL_SERIAL, + ) + entry.add_to_hass(hass) + return entry + + +async def _announce_from(hass: HomeAssistant, host: str) -> Any: + """Run an `_ebus._tcp` announcement claiming this panel's serial, from `host`.""" + address = ipaddress.IPv4Address(host) + return await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=address, + ip_addresses=[address], + hostname="span-panel.local.", + name="SPAN Panel._ebus._tcp.local.", + port=8883, + properties={}, + type="_ebus._tcp.local.", + ), + ) + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_zeroconf_will_not_move_a_pinned_entry_to_a_host_its_anchor_rejects( + hass: HomeAssistant, panel: Panel +) -> None: + """Anything on the LAN can announce this serial; only the pin decides who gets the entry. + + The listener here is the impostor: it answers on the port the entry uses, + serves a perfectly valid leaf, and the certificate is signed by an authority + the entry is not pinned to. Following it would point the entry at a host its + own anchor rejects and hand the user a CA-changed repair inviting them to + accept the impostor's fingerprint. + """ + entry = _entry_reached_at_the_address(hass, unrelated_ca_pem(), panel.port) + + with _plaintext_bootstrap_stubbed(panel): + result = await _announce_from(hass, PANEL_LOOPBACK) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == PANEL_ADDRESS + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_zeroconf_moves_a_pinned_entry_to_a_host_the_pin_validates( + hass: HomeAssistant, panel: Panel +) -> None: + """A panel that moved address is still the panel, and the entry has to follow it. + + The whole point of the host update: DHCP hands the panel a new lease, mDNS + announces it, and the entry has to end up there. The pin is what tells the + two cases apart, so the check has to pass here or the guard has simply + turned discovery off. + """ + entry = _entry_reached_at_the_address(hass, panel.ca_pem, panel.port) + + with _plaintext_bootstrap_stubbed(panel): + result = await _announce_from(hass, PANEL_LOOPBACK) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_zeroconf_still_moves_an_unpinned_entry(hass: HomeAssistant, panel: Panel) -> None: + """An entry with no anchor has nothing to check against, and keeps what it had. + + There is no pin to protect and no check that could be made, so refusing the + move would only stop an unpinned entry following its panel — a regression + against the behaviour that shipped, bought with no security at all. + """ + entry = _entry_reached_at_the_address(hass, None, panel.port) + + with _plaintext_bootstrap_stubbed(panel): + result = await _announce_from(hass, PANEL_LOOPBACK) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_re_adding_a_pinned_panel_by_an_unserved_name_leaves_its_host_alone( + hass: HomeAssistant, panel: Panel +) -> None: + """Re-adding by hand goes through the same update, and reaches it before the CA step. + + A user who types a single-label name for a panel already set up gets + `already_configured` either way — but the abort used to rewrite the entry's + host on the way out, to a name the panel's certificate does not carry. The + entry then could not connect to its own panel and there was no flow left to + correct it. + """ + entry = _pinned_entry(hass, panel) + + with _plaintext_bootstrap_stubbed(panel): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_SHORTNAME, CONF_HTTP_PORT: 8080} + ) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_re_adding_a_pinned_panel_by_a_name_it_serves_moves_the_entry( + hass: HomeAssistant, panel: Panel +) -> None: + """The same path must still let a user correct the host to a name that works.""" + panel.present( + [ + x509.DNSName(PANEL_SHORTNAME), + x509.IPAddress(ipaddress.ip_address(PANEL_LOOPBACK)), + ] + ) + entry = _pinned_entry(hass, panel) + + with _plaintext_bootstrap_stubbed(panel): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_SHORTNAME, CONF_HTTP_PORT: 8080} + ) + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == PANEL_SHORTNAME + + +@pytest.fixture +def moved_panel(tmp_path: Any) -> Iterator[Panel]: + """Serve a leaf naming an address the panel no longer has, as a moved lease does. + + The listener is on loopback, so the flow reaches it while its certificate + names 10.0.0.99 and nothing else. That is what a DHCP move looks like from + the integration's side, and it is chain-valid throughout -- the panel is + still the panel, and still the only holder of a key the pinned anchor + signed. + """ + instance = Panel(tmp_path) + instance.present([x509.IPAddress(ipaddress.ip_address("10.0.0.99"))]) + yield instance + instance.close() + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_moved_panel_can_be_reconfigured_to_an_fqdn( + hass: HomeAssistant, moved_panel: Panel +) -> None: + """The case that had no way out before this. + + The panel's certificate names neither the address it now answers on nor the + name being moved to, so every probe over the pinned transport failed + hostname verification and the flow reported it unreachable. Reconfigure was + the documented remedy and refused for the same reason, the mDNS move-guard + refused, and reauth offers no host field -- deleting the entry was the only + route left. + + Registration is what repairs it: the flow probes over a transport that keeps + the pin and drops only the name binding, asks the panel to regenerate its + certificate around the FQDN, and stores the name once the panel serves it. + """ + entry = _pinned_entry(hass, moved_panel) + + register = AsyncMock(side_effect=_registration_adds_the_fqdn(moved_panel)) + with ( + patch("custom_components.span_panel.config_flow.register_fqdn", new=register), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN} + ) + result = await _finish_progress(hass, result) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT, result + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == PANEL_FQDN + assert entry.data[CONF_REGISTERED_FQDN] == PANEL_FQDN + # The anchor is untouched throughout: nothing here is a CA change. + assert entry.data[CONF_PANEL_CA_PEM] == moved_panel.ca_pem + assert register.await_args.args[2] == PANEL_FQDN + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_moved_panel_is_still_refused_at_a_bare_address( + hass: HomeAssistant, moved_panel: Panel +) -> None: + """Reaching the panel is not permission to store the address it was reached at. + + Nothing on this branch asks the panel to start naming the address, so the + entry would be stored pointing somewhere the coordinator and the broker both + reject -- they verify the hostname and cannot be relaxed the way one flow's + own probe can. + """ + entry = _pinned_entry(hass, moved_panel) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_LOOPBACK} + ) + + assert result["type"] == FlowResultType.FORM, result + assert result["errors"] == {"base": "ca_name_mismatch"} + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_an_impostor_is_still_refused_as_a_signing_failure( + hass: HomeAssistant, panel: Panel +) -> None: + """The guarantee the relaxed probe must not weaken. + + Relaxing the name binding leaves the chain, the signature and the expiry + verified against the pinned anchor, so a host without a key that anchor + signed fails exactly as it did before -- and is reported as what it is, + rather than as a naming problem. + """ + entry = _pinned_entry(hass, panel) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_PANEL_CA_PEM: unrelated_ca_pem()} + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_LOOPBACK} + ) + + assert result["type"] == FlowResultType.FORM, result + assert result["errors"] == {"base": "ca_leaf_mismatch"} + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_an_unreachable_host_is_not_accused_of_interception( + hass: HomeAssistant, panel: Panel +) -> None: + """An unplugged cable used to be reported as an unsigned certificate. + + `async_leaf_chains_to_ca` answered False for a timeout and a verification + failure alike, so a host that never answered produced "the certificate this + panel serves is not signed by the authority it published" -- an accusation + about a certificate nothing ever presented. + """ + entry = _pinned_entry(hass, panel) + + # A port on loopback with nothing listening: connection refused, no TLS. + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_HTTPS_PORT: _closed_port()} + ) + + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_LOOPBACK} + ) + + assert result["type"] == FlowResultType.FORM, result + assert result["errors"] == {"base": "cannot_connect"} + + +def _closed_port() -> int: + """Return a port nothing is listening on, by binding and immediately closing.""" + import socket as _socket + + with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +@pytest.mark.usefixtures("socket_enabled", "resolves_to_loopback") +@pytest.mark.asyncio +async def test_a_moved_panel_whose_registration_fails_has_nothing_to_continue_to( + hass: HomeAssistant, moved_panel: Panel +) -> None: + """"Continue anyway" must not store a name the panel never started serving. + + On every other path there is a verified address to fall back to -- the one + the flow reached the panel by. On this one there is not: the certificate + names neither the FQDN nor anything reachable, which is why the flow relaxed + the name binding for its own probe in the first place, and registration was + the single thing that would have fixed that. It failed. + + Falling back therefore has nowhere to fall back to, and continuing would + write the unserved name and report success -- stranding every runtime + connection on the hostname check, which the coordinator and the broker apply + and cannot relax. The entry is left exactly as it was. + """ + entry = _pinned_entry(hass, moved_panel) + + with ( + # The panel accepts the call and regenerates nothing, so its leaf still + # names the address it no longer has. + patch("custom_components.span_panel.config_flow.register_fqdn", new=AsyncMock()), + patch("custom_components.span_panel.config_flow.asyncio.sleep", new=AsyncMock()), + ): + result = await entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_FQDN} + ) + result = await _finish_progress(hass, result) + assert result["step_id"] == "reconfigure_fqdn_failed", result + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM, result + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "ca_name_mismatch"} + # Untouched: the entry still points where it did, and is still pinned. + assert entry.data[CONF_HOST] == PANEL_LOOPBACK + assert CONF_REGISTERED_FQDN not in entry.data + assert entry.data[CONF_PANEL_CA_PEM] == moved_panel.ca_pem + + # And the form it lands on is live, not a dead end: submitting it re-enters + # the reconfigure step and is judged again, rather than wedging the flow. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: PANEL_LOOPBACK} + ) + assert result["type"] == FlowResultType.FORM, result + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "ca_name_mismatch"} + assert entry.data[CONF_HOST] == PANEL_LOOPBACK diff --git a/tests/test_websocket.py b/tests/test_websocket.py index ea45eb5a..2614d4d7 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -2,41 +2,41 @@ from __future__ import annotations -import inspect +from dataclasses import replace from unittest.mock import MagicMock import pytest from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.control_gate import ControlMode, ControlPolicy +from custom_components.span_panel.curation import CurationOverlay from custom_components.span_panel.websocket import ( _build_circuit_entity_map, _classify_sensor_role, _classify_sub_device, - _find_config_entry_id, + _offered_roles, async_register_commands, handle_panel_topology, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from span_panel_api import SpanCircuitSnapshot from .factories import ( SpanBatterySnapshotFactory, SpanCircuitSnapshotFactory, SpanEvseSnapshotFactory, SpanPanelSnapshotFactory, + pv_binding_for, ) +from .helpers import unwrap_websocket_command from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser from pytest_homeassistant_custom_component.typing import WebSocketGenerator -# The command stack includes wrappers such as @async_response and -# @require_admin. Unwrap until we reach the original async handler so the -# direct-call tests can await it. -_handle_panel_topology_inner = handle_panel_topology -while not inspect.iscoroutinefunction(_handle_panel_topology_inner): - _handle_panel_topology_inner = _handle_panel_topology_inner.__wrapped__ +_handle_panel_topology_inner = unwrap_websocket_command(handle_panel_topology) # --------------------------------------------------------------------------- # Helpers @@ -184,6 +184,12 @@ def test_evse(self): device.identifiers = {(DOMAIN, "sp3-242424-001_evse_0")} assert _classify_sub_device(device) == "evse" + def test_pv(self): + """Classify the solar inverter sub-device from its identifier.""" + device = MagicMock() + device.identifiers = {(DOMAIN, "sp3-242424-001_pv")} + assert _classify_sub_device(device) == "pv" + def test_unknown(self): """Treat the panel device itself as an unknown sub-device type.""" device = MagicMock() @@ -191,31 +197,6 @@ def test_unknown(self): assert _classify_sub_device(device) == "unknown" -class TestFindConfigEntryId: - """Tests for _find_config_entry_id.""" - - def test_finds_span_entry(self): - """Return the config entry id for SPAN panel devices.""" - device = MagicMock() - device.identifiers = {(DOMAIN, "sp3-242424-001")} - device.config_entries = {"entry_123"} - assert _find_config_entry_id(device) == "entry_123" - - def test_non_span_device(self): - """Ignore devices that do not belong to the SPAN domain.""" - device = MagicMock() - device.identifiers = {("other_domain", "some_id")} - device.config_entries = {"entry_123"} - assert _find_config_entry_id(device) is None - - def test_no_config_entries(self): - """Return None when a SPAN device has no linked config entries.""" - device = MagicMock() - device.identifiers = {(DOMAIN, "sp3-242424-001")} - device.config_entries = set() - assert _find_config_entry_id(device) is None - - class TestBuildCircuitEntityMap: """Tests for _build_circuit_entity_map.""" @@ -380,9 +361,34 @@ async def test_non_span_device(self, hass: HomeAssistant): 1, "not_span_panel", "Device is not a SPAN Panel device" ) + @pytest.mark.asyncio + async def test_a_span_identifier_another_entry_owns(self, hass: HomeAssistant): + """Refused as `not_span_panel`, exactly as the adopted commands refuse it. + + A device belongs to the one entry that owns it, and that entry's domain is + what makes it a SPAN panel or not -- an identifier in SPAN's domain on a + device another entry owns does not. Both commands resolve the panel through + one function, so they refuse it with one code. + """ + other = MockConfigEntry(domain="other_domain", data={}, entry_id="other_entry") + other.add_to_hass(hass) + device = dr.async_get(hass).async_get_or_create( + config_entry_id="other_entry", + identifiers={(DOMAIN, "sp3-242424-001")}, + ) + + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": device.id} + + await _handle_panel_topology_inner(hass, connection, msg) + + connection.send_error.assert_called_once_with( + 1, "not_span_panel", "Device is not a SPAN Panel device" + ) + @pytest.mark.asyncio async def test_sub_device_id_rejected(self, hass: HomeAssistant): - """Error when device_id is a BESS/EVSE sub-device, not the panel.""" + """Error when device_id is a sub-device, not the panel.""" entry = MockConfigEntry( domain=DOMAIN, data={}, @@ -391,8 +397,13 @@ async def test_sub_device_id_rejected(self, hass: HomeAssistant): ) entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) + snapshot = SpanPanelSnapshotFactory.create() entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(SpanPanelSnapshotFactory.create()) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) panel_device = _register_panel_device( @@ -418,7 +429,7 @@ async def test_sub_device_id_rejected(self, hass: HomeAssistant): connection.send_error.assert_called_once_with( 1, "not_panel_device", - "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.", + "Use the SPAN panel device registry ID, not a sub-device.", ) @pytest.mark.asyncio @@ -479,7 +490,11 @@ async def test_successful_topology_basic(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) device = _register_panel_device(hass, "span_entry", serial="sp3-test-001") @@ -509,6 +524,10 @@ async def test_successful_topology_basic(self, hass: HomeAssistant): assert result["firmware"] == "spanos2/r202603/05" assert result["panel_size"] == 32 assert result["device_name"] == "SPAN Panel" + # The panel's current device and its entry, which a consumer reads from + # here rather than looking the id it holds up in the device list. + assert result["panel_device_id"] == device.id + assert result["config_entry_id"] == "span_entry" # Kitchen circuit (240V). kitchen_data = result["circuits"]["uuid_kitchen"] @@ -542,7 +561,11 @@ async def test_unmapped_circuits_excluded(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) device = _register_panel_device(hass, "span_entry") @@ -571,7 +594,11 @@ async def test_sub_devices_included(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) panel_device = _register_panel_device(hass, "span_entry", serial="sp3-sub-001") @@ -649,7 +676,11 @@ async def test_evse_feed_circuit_entities_found(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) panel_device = _register_panel_device(hass, "span_entry", serial="sp3-evse-001") @@ -722,7 +753,11 @@ async def test_topology_includes_always_on_and_priority(self, hass: HomeAssistan entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) device = _register_panel_device(hass, "span_entry", serial="sp3-prio-001") @@ -744,6 +779,135 @@ async def test_topology_includes_always_on_and_priority(self, hass: HomeAssistan assert hvac_data["always_on"] is False assert hvac_data["priority"] == "SOC_THRESHOLD" + @pytest.mark.asyncio + async def test_topology_circuit_record_carries_the_documented_keys(self, hass: HomeAssistant): + """The circuit record's key set is a contract, so pin it. + + A consumer rendering from this payload -- the card -- decides what to + show from these keys, and it lives in another repository that no test + here can reach. Adding or removing a key silently changes what it + renders, so the set is asserted exactly rather than by presence: a + deliberate change updates this list, an accidental one fails. + """ + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_kitchen", + name="Kitchen", + tabs=[3], + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-contract-001", + circuits={"uuid_kitchen": circuit}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + entry_id="span_entry", + unique_id="sp3-contract-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + + device = _register_panel_device(hass, "span_entry", serial="sp3-contract-001") + + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": device.id} + + await _handle_panel_topology_inner(hass, connection, msg) + + result = connection.send_result.call_args[0][1] + + assert set(result["circuits"]["uuid_kitchen"]) == { + "tabs", + "name", + "voltage", + "device_type", + "relay_state", + "relay_state_target", + "is_user_controllable", + "breaker_rating_a", + "always_on", + "priority", + "priority_target", + "is_never_backup", + "entities", + } + + @pytest.mark.asyncio + async def test_topology_reports_priority_settability_apart_from_the_relay( + self, hass: HomeAssistant + ): + """`is_never_backup` is carried, and is independent of the relay flag. + + The two are separate commissioning flags: a never-backup circuit has a + working relay and a priority the panel pins, and a relay-locked circuit + may have a priority it will happily accept. No entity is created for a + circuit whose priority is pinned, so this record is the only thing that + can tell a consumer the difference between a pinned priority and an + absent one. + """ + pinned_priority = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_pinned", + name="Well Pump", + tabs=[11], + is_user_controllable=True, + is_never_backup=True, + priority="OFF_GRID", + ) + locked_relay = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_locked", + name="Networking", + tabs=[13], + is_user_controllable=False, + is_never_backup=False, + priority="NEVER", + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-backup-001", + circuits={"uuid_pinned": pinned_priority, "uuid_locked": locked_relay}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + entry_id="span_entry", + unique_id="sp3-backup-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + + device = _register_panel_device(hass, "span_entry", serial="sp3-backup-001") + + connection = _make_mock_connection() + msg = {"id": 1, "type": "span_panel/panel_topology", "device_id": device.id} + + await _handle_panel_topology_inner(hass, connection, msg) + + circuits = connection.send_result.call_args[0][1]["circuits"] + + pinned = circuits["uuid_pinned"] + assert pinned["is_never_backup"] is True + assert pinned["is_user_controllable"] is True + assert pinned["priority"] == "OFF_GRID" + + locked = circuits["uuid_locked"] + assert locked["is_never_backup"] is False + assert locked["is_user_controllable"] is False + @pytest.mark.asyncio async def test_topology_includes_panel_status_entity(self, hass: HomeAssistant): """panel_status binary sensor entity_id is included in the topology panel_entities map.""" @@ -758,7 +922,11 @@ async def test_topology_includes_panel_status_entity(self, hass: HomeAssistant): entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) panel_device = _register_panel_device(hass, "span_entry", serial="sp3-242424-001") @@ -797,7 +965,11 @@ async def test_topology_omits_panel_status_when_entity_missing(self, hass: HomeA entry.add_to_hass(hass) entry.mock_state(hass, ConfigEntryState.LOADED) entry.runtime_data = SpanPanelRuntimeData( - coordinator=_make_coordinator(snapshot) + coordinator=_make_coordinator(snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, ) panel_device = _register_panel_device(hass, "span_entry", serial="sp3-242424-001") @@ -819,3 +991,100 @@ async def test_topology_omits_panel_status_when_entity_missing(self, hass: HomeA async def test_registration(self, hass: HomeAssistant): """WebSocket commands can be registered without error.""" async_register_commands(hass) + + +# --------------------------------------------------------------------------- +# Controls are offered only while the integration provides them (spec §4.3) +# --------------------------------------------------------------------------- + +ROLES = {"power": "sensor.kitchen_power", "switch": "switch.kitchen_breaker", "select": "select.kitchen_priority"} + + +class TestOfferedControls: + """`switch` and `select` appear only while the circuit qualifies and the mode is not Nobody.""" + + @pytest.mark.parametrize("mode", list(ControlMode)) + @pytest.mark.parametrize( + ("circuit_kwargs", "switch", "select"), + [ + ({"is_user_controllable": True}, True, True), + ({"is_user_controllable": True, "is_never_backup": True}, True, False), + ({"is_user_controllable": False, "is_never_backup": True, "priority": "NEVER"}, False, False), + ({"is_user_controllable": True, "device_type": "pv"}, False, False), + ], + ids=["controllable", "never-backup", "locked", "pv"], + ) + def test_offered_roles(self, mode: ControlMode, circuit_kwargs: dict[str, object], switch: bool, select: bool) -> None: + circuit = SpanCircuitSnapshotFactory.create(**circuit_kwargs) + + offered = _offered_roles(ROLES, circuit, mode) + + expected = {"power": ROLES["power"]} + if mode is not ControlMode.DISABLED and switch: + expected["switch"] = ROLES["switch"] + if mode is not ControlMode.DISABLED and select: + expected["select"] = ROLES["select"] + assert offered == expected + + async def _circuit_entities( + self, + hass: HomeAssistant, + *, + setup: SpanCircuitSnapshot, + live: SpanCircuitSnapshot, + mode: ControlMode = ControlMode.ALL_USERS, + ) -> dict[str, object]: + """The topology's record for one circuit with a registered power sensor, switch and select.""" + serial = "sp3-offered-001" + setup_snapshot = SpanPanelSnapshotFactory.create(serial_number=serial, circuits={"uuid_kitchen": setup}) + live_snapshot = SpanPanelSnapshotFactory.create(serial_number=serial, circuits={"uuid_kitchen": live}) + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="span_entry", unique_id=serial) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=_make_coordinator(live_snapshot), + panel_device_id="panel-device-id", + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(setup_snapshot), + setup_snapshot=setup_snapshot, + control_policy=replace(ControlPolicy.default(), mode=mode), + ) + device = _register_panel_device(hass, "span_entry", serial=serial) + for domain, unique_id, entity_id in ( + ("sensor", f"span_{serial}_uuid_kitchen_power", "sensor.kitchen_power"), + ("switch", f"span_{serial}_relay_uuid_kitchen", "switch.kitchen_breaker"), + ("select", f"span_{serial}_select_uuid_kitchen", "select.kitchen_priority"), + ): + _register_entity(hass, "span_entry", device.id, domain, unique_id, entity_id) + connection = _make_mock_connection() + await _handle_panel_topology_inner( + hass, connection, {"id": 1, "type": "span_panel/panel_topology", "device_id": device.id} + ) + connection.send_error.assert_not_called() + record = connection.send_result.call_args[0][1]["circuits"]["uuid_kitchen"] + assert isinstance(record, dict) + return record + + async def test_a_qualifying_circuit_keeps_both_roles(self, hass: HomeAssistant) -> None: + kitchen = SpanCircuitSnapshotFactory.create(circuit_id="uuid_kitchen", name="Kitchen") + record = await self._circuit_entities(hass, setup=kitchen, live=kitchen) + assert set(record["entities"]) == {"power", "switch", "select"} + + async def test_an_orphans_roles_are_absent(self, hass: HomeAssistant) -> None: + locked = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_kitchen", name="Kitchen", is_user_controllable=False, is_never_backup=True, priority="NEVER" + ) + record = await self._circuit_entities(hass, setup=locked, live=locked) + assert set(record["entities"]) == {"power"} + + async def test_the_live_snapshot_withdraws_a_role_before_the_reload(self, hass: HomeAssistant) -> None: + kitchen = SpanCircuitSnapshotFactory.create(circuit_id="uuid_kitchen", name="Kitchen") + locked = replace(kitchen, is_user_controllable=False, is_never_backup=True, priority="NEVER") + record = await self._circuit_entities(hass, setup=kitchen, live=locked) + assert set(record["entities"]) == {"power"} + + async def test_nobody_offers_no_control_and_keeps_the_panels_truth(self, hass: HomeAssistant) -> None: + kitchen = SpanCircuitSnapshotFactory.create(circuit_id="uuid_kitchen", name="Kitchen") + record = await self._circuit_entities(hass, setup=kitchen, live=kitchen, mode=ControlMode.DISABLED) + assert set(record["entities"]) == {"power"} + assert record["is_user_controllable"] is True diff --git a/tests/test_websocket_adopted.py b/tests/test_websocket_adopted.py new file mode 100644 index 00000000..b83fb1a2 --- /dev/null +++ b/tests/test_websocket_adopted.py @@ -0,0 +1,1316 @@ +"""The adopted commands: list reports every curatable row, curate stores one. + +Three things carry the list surface and each fails loudly here if it stops +holding: a row's key is the one the curate command will be handed back, the +allowed choices are computed from the wire rather than offered blind, and a +stored record that no longer fits its declaration is *shown* rather than silently +sanitised -- the editor is where a user finds out their assertion went stale. + +Curate is tested against the same fixtures for the reason that matters most about +it: the keys it accepts and the choices it admits are the ones list offered, and +a second derivation of either would let the editor save something the entities +never read. Its refusals are asserted by code, its side effects are asserted to +be exactly three -- the store, the reload, the reply -- and the registry is +asserted to be untouched. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +from homeassistant.components.sensor import SensorStateClass +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockUser +from pytest_homeassistant_custom_component.typing import WebSocketGenerator +from span_panel_api import ( + AdoptedDevice, + AdoptedProperty, + ExtensionProperty, + ExtensionSubject, + SpanPVSnapshot, +) + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.adoption import ( + adopted_curation_key, + adopted_identifier, + adopted_unique_id, + async_register_adopted_devices, +) +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import ( + CurationOverlay, + CurationRecord, + async_load_curation, +) +from custom_components.span_panel.extension import extension_curation_key, extension_unique_id +from custom_components.span_panel.pv_binding import PvBinding, StoredPvBinding, resolve +from custom_components.span_panel.runtime import loaded_runtime_data +from custom_components.span_panel.util import SUB_DEVICE_BESS, SUB_DEVICE_PV +from custom_components.span_panel.websocket import async_register_commands + +from .factories import SpanPanelSnapshotFactory, pv_binding_for + +if TYPE_CHECKING: + from span_panel_api import SpanPanelSnapshot + +PANEL_SERIAL = "sp3-242424-001" +ENTRY_ID = "span_entry" +ADOPTED_ANCHOR = "generator-1" +ADOPTED_IDENTIFIER = adopted_identifier(PANEL_SERIAL, ADOPTED_ANCHOR) +BESS_IDENTIFIER = f"{PANEL_SERIAL}_{SUB_DEVICE_BESS}" + +# One reading, one control and one string, so the three shapes the allowed-choice +# helpers answer differently are all on one device. +POWER = AdoptedProperty( + node_id="meter", property_id="active-power", datatype="float", unit="W", value="2400" +) +SETPOINT = AdoptedProperty( + node_id="control", + property_id="power-setpoint", + datatype="float", + unit="W", + format="0:5000:100", + settable=True, + value="1000", +) +LABEL = AdoptedProperty( + node_id="status", property_id="mode-label", datatype="string", value="idle" +) + +GENERATOR = AdoptedDevice( + device_id=ADOPTED_ANCHOR, + device_type="energy.ebus.device.generator", + name="Backup Generator", + model="GEN-9000", + properties=(POWER, SETPOINT, LABEL), +) + +# Two wire addresses that flatten to one adopted unique_id -- the collision +# `adopted_unique_id` documents as permanent, spelled out. `battery-2/...` sorts +# first because `-` precedes `/`, which is what makes it the claim. +FLATTENS_FIRST = AdoptedProperty( + node_id="battery-2", + property_id="cell-temperature", + datatype="float", + unit="°C", + value="31.4", +) +FLATTENS_SECOND = AdoptedProperty( + node_id="battery", + property_id="2-cell-temperature", + datatype="float", + unit="°C", + value="31.5", +) +FLATTENS_SECOND_AS_SWITCH = AdoptedProperty( + node_id="battery", + property_id="2-cell-temperature", + datatype="boolean", + settable=True, + value="true", +) + +CELL_TEMPERATURE = ExtensionProperty( + subject=ExtensionSubject(kind="battery"), + node_id="battery-2", + property_id="cell-temperature", + datatype="float", + unit="°C", + value="31.4", +) + +POWER_KEY = adopted_curation_key(ADOPTED_IDENTIFIER, POWER) +LABEL_KEY = adopted_curation_key(ADOPTED_IDENTIFIER, LABEL) +SETPOINT_KEY = adopted_curation_key(ADOPTED_IDENTIFIER, SETPOINT) +CELL_TEMPERATURE_KEY = extension_curation_key(CELL_TEMPERATURE.subject, CELL_TEMPERATURE.path) + + +def _snapshot( + *, + devices: tuple[AdoptedDevice, ...] = (GENERATOR,), + rows: tuple[ExtensionProperty, ...] = (CELL_TEMPERATURE,), +) -> SpanPanelSnapshot: + """Return a curated snapshot carrying the given adopted devices and extension rows.""" + return replace( + SpanPanelSnapshotFactory.create_complete(serial_number=PANEL_SERIAL), + adopted_devices=devices, + extension_properties=rows, + ) + + +def _register_cards(hass: HomeAssistant) -> dr.DeviceEntry: + """Register the panel and its BESS card, as setup leaves them, and return the panel.""" + registry = dr.async_get(hass) + panel = registry.async_get_or_create( + config_entry_id=ENTRY_ID, + identifiers={(DOMAIN, PANEL_SERIAL)}, + name="Span Panel", + ) + registry.async_get_or_create( + config_entry_id=ENTRY_ID, + identifiers={(DOMAIN, BESS_IDENTIFIER)}, + name="Span Panel Battery", + via_device_id=panel.id, + ) + return panel + + +def _setup( + hass: HomeAssistant, + snapshot: SpanPanelSnapshot | None = None, + *, + overlay: CurationOverlay | None = None, + register_adopted: bool = True, + pv_binding: PvBinding | None = None, +) -> dr.DeviceEntry: + """Bring an entry up the way setup does, and return the panel's device entry. + + In setup's own order: the panel card, then the entry's runtime data, then the + adopted devices -- `async_register_adopted_devices` is called rather than + hand-registering, so the identifiers under test are the ones production mints. + """ + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id=ENTRY_ID, unique_id=PANEL_SERIAL) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + panel = _register_cards(hass) + published = snapshot if snapshot is not None else _snapshot() + coordinator = MagicMock() + coordinator.data = published + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id=panel.id, + curation=overlay if overlay is not None else CurationOverlay.empty(), + pv_binding=pv_binding if pv_binding is not None else pv_binding_for(published), + setup_snapshot=published, + ) + if register_adopted: + async_register_adopted_devices( + hass, ENTRY_ID, coordinator.data, panel_device_id=panel.id + ) + return panel + + +async def _list( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, device_id: str +) -> dict[str, Any]: + """Send one adopted/list request over a real websocket and return the reply.""" + async_register_commands(hass) + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "span_panel/adopted/list", "device_id": device_id}) + reply: dict[str, Any] = await client.receive_json() + return reply + + +async def _curate( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_id: str, + key: str, + record: dict[str, Any], + *, + scheduled_reload: MagicMock | None = None, +) -> dict[str, Any]: + """Send one adopted/curate request over a real websocket and return the reply. + + The reload a successful save schedules is patched out in every call. Letting + it run would set the integration up for real against a panel no test has, and + what the handler owes is that it *asked* for one -- which the `scheduled_reload` + a test passes in is how that is asserted. + """ + async_register_commands(hass) + client = await hass_ws_client(hass) + with patch.object( + hass.config_entries, + "async_schedule_reload", + MagicMock() if scheduled_reload is None else scheduled_reload, + ): + await client.send_json_auto_id( + { + "type": "span_panel/adopted/curate", + "device_id": device_id, + "key": key, + "record": record, + } + ) + reply: dict[str, Any] = await client.receive_json() + return reply + + +async def _reload_overlay(hass: HomeAssistant) -> None: + """Re-resolve the entry's overlay from disk, standing in for the patched-out reload. + + A save writes the store and schedules the reload; the overlay every read goes + through is resolved once per setup and never re-reads the disk. Doing that one + step by hand is what keeps a follow-up assertion about what reached the store + rather than about what a handler happened to leave in memory. + """ + entry = hass.config_entries.async_get_entry(ENTRY_ID) + assert entry is not None + runtime_data = loaded_runtime_data(entry) + assert runtime_data is not None + runtime_data.curation = await async_load_curation(hass, entry) + + +def _group(reply: dict[str, Any], name: str) -> dict[str, Any]: + """Return the one device group with this name, failing the test if it is absent.""" + matched = [group for group in reply["result"]["devices"] if group["name"] == name] + assert len(matched) == 1, f"expected exactly one {name!r} group in {reply['result']}" + return matched[0] + + +def _row(group: dict[str, Any], key: str) -> dict[str, Any]: + """Return the one row in this group carrying this curation key.""" + matched = [row for row in group["rows"] if row["key"] == key] + assert len(matched) == 1, f"expected exactly one row keyed {key!r} in {group}" + return matched[0] + + +# --- grouping --------------------------------------------------------------- + + +async def test_rows_are_grouped_by_the_device_they_render_on( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """An adopted device and a curated card are two groups, each flagged for what it is. + + The flag is what the editor decides from: an adopted device is a card this + integration minted from the wire, a curated one is a card the user already + knows, and their rows are curated identically but presented differently. + """ + panel = _setup(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert reply["success"] is True + generator = _group(reply, "Backup Generator") + battery = _group(reply, "Span Panel Battery") + + assert generator["adopted_device"] is True + assert battery["adopted_device"] is False + + registry = dr.async_get(hass) + adopted_card = registry.async_get_device_by_identifier((DOMAIN, ADOPTED_IDENTIFIER), ENTRY_ID) + assert adopted_card is not None + assert generator["device_id"] == adopted_card.id + bess_card = registry.async_get_device_by_identifier((DOMAIN, BESS_IDENTIFIER), ENTRY_ID) + assert bess_card is not None + assert battery["device_id"] == bess_card.id + + assert [row["key"] for row in generator["rows"]] == [SETPOINT_KEY, POWER_KEY, LABEL_KEY] + assert [row["key"] for row in battery["rows"]] == [CELL_TEMPERATURE_KEY] + + +async def test_one_declaration_claims_a_flattened_id_and_the_other_is_not_listed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The row that cannot become an entity is not offered as one. + + `adopted_unique_id` is deliberately non-injective, so two wire addresses can + flatten onto one id; `adoption._create` gives it to the lexically first path + and skips the other. Listing both would be worse than cosmetic: the editor + resolves `entity_id` by (platform, unique_id), so the skipped row would + report the *winner's* entity while carrying its own curation key -- a record + the user saves against an entity that will never read it, beside a live + entity_id saying it will. + + The declarations are handed over in the other order, so what decides is the + sort rather than the order the publisher happened to emit. + """ + unique_id = adopted_unique_id(ADOPTED_IDENTIFIER, FLATTENS_FIRST) + assert unique_id == adopted_unique_id(ADOPTED_IDENTIFIER, FLATTENS_SECOND) + panel = _setup( + hass, + _snapshot( + devices=(replace(GENERATOR, properties=(FLATTENS_SECOND, FLATTENS_FIRST)),), + rows=(), + ), + ) + er.async_get(hass).async_get_or_create( + "sensor", + DOMAIN, + unique_id, + suggested_object_id="backup_generator_battery_2_cell_temperature", + ) + + reply = await _list(hass, hass_ws_client, panel.id) + rows = _group(reply, "Backup Generator")["rows"] + + assert [row["key"] for row in rows] == [ + adopted_curation_key(ADOPTED_IDENTIFIER, FLATTENS_FIRST) + ] + assert rows[0]["path"] == "battery-2/cell-temperature" + assert rows[0]["entity_id"] == "sensor.backup_generator_battery_2_cell_temperature" + + +async def test_one_id_on_two_platforms_is_two_entities_and_two_rows( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The claim is per platform, because the registry's own uniqueness is. + + An entity is unique by (domain, integration, unique_id), so the same id under + `sensor` and under `switch` is two entities rather than a collision -- + `adoption._create` claims per platform for exactly that reason. A claim + scoped to the id alone would drop a row that really does become an entity. + """ + panel = _setup( + hass, + _snapshot( + devices=(replace(GENERATOR, properties=(FLATTENS_FIRST, FLATTENS_SECOND_AS_SWITCH)),), + rows=(), + ), + ) + + reply = await _list(hass, hass_ws_client, panel.id) + rows = _group(reply, "Backup Generator")["rows"] + + assert [row["platform"] for row in rows] == ["sensor", "switch"] + assert [row["key"] for row in rows] == [ + adopted_curation_key(ADOPTED_IDENTIFIER, FLATTENS_FIRST), + adopted_curation_key(ADOPTED_IDENTIFIER, FLATTENS_SECOND_AS_SWITCH), + ] + + +async def test_a_row_carries_the_documented_keys( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The row record's key set is a contract with a card in another repository. + + Asserted exactly rather than by presence, exactly as the topology command's + circuit record is: a deliberate change updates this list, an accidental one + fails here rather than in a renderer no test in this repository can reach. + """ + panel = _setup(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert set(_row(_group(reply, "Backup Generator"), POWER_KEY)) == { + "key", + "path", + "platform", + "entity_id", + "datatype", + "unit", + "settable", + "name", + "curation", + "allowed_device_classes", + "allowed_state_classes", + "stale_fields", + } + + +async def test_a_row_reports_the_declaration_it_is_curated_against( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Path, platform, datatype, unit, settability and name, from the wire.""" + panel = _setup(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + generator = _group(reply, "Backup Generator") + + power = _row(generator, POWER_KEY) + assert power["path"] == "meter/active-power" + assert power["platform"] == "sensor" + assert power["datatype"] == "float" + assert power["unit"] == "W" + assert power["settable"] is False + assert power["name"] == "Active Power" + + setpoint = _row(generator, SETPOINT_KEY) + assert setpoint["platform"] == "number" + assert setpoint["settable"] is True + + temperature = _row(_group(reply, "Span Panel Battery"), CELL_TEMPERATURE_KEY) + assert temperature["path"] == "battery-2/cell-temperature" + assert temperature["platform"] == "sensor" + assert temperature["unit"] == "°C" + # Node-prefixed, exactly as the entity is: a vendor reading sits beside + # curated ones on the same card and has to disambiguate itself. + assert temperature["name"] == "Battery 2 Cell Temperature" + + +# --- the allowed choices ---------------------------------------------------- + + +async def test_a_string_row_admits_no_state_class( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A state class needs a numeric sensor, so a string row offers none. + + Computed server-side so the card never renders an option the curate command + would refuse. + """ + panel = _setup(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + generator = _group(reply, "Backup Generator") + + assert _row(generator, LABEL_KEY)["allowed_state_classes"] == [] + assert _row(generator, POWER_KEY)["allowed_state_classes"] == [ + state_class.value for state_class in SensorStateClass + ] + + +async def test_a_control_row_admits_neither_class( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A settable numeric surfaces as a control, and a control carries prominence only.""" + panel = _setup(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + setpoint = _row(_group(reply, "Backup Generator"), SETPOINT_KEY) + + assert setpoint["allowed_state_classes"] == [] + assert setpoint["allowed_device_classes"] == [] + + +async def test_the_declared_unit_constrains_the_device_classes( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Core's own unit map decides, so a watt row offers power and never temperature.""" + panel = _setup(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + + power = _row(_group(reply, "Backup Generator"), POWER_KEY) + assert "power" in power["allowed_device_classes"] + assert "temperature" not in power["allowed_device_classes"] + + temperature = _row(_group(reply, "Span Panel Battery"), CELL_TEMPERATURE_KEY) + assert "temperature" in temperature["allowed_device_classes"] + assert "power" not in temperature["allowed_device_classes"] + + +# --- what the store holds --------------------------------------------------- + + +async def test_a_stored_record_is_reported_as_stored( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The editor opens on what the user asserted, field for field.""" + panel = _setup( + hass, + overlay=CurationOverlay( + { + POWER_KEY: CurationRecord( + state_class=SensorStateClass.MEASUREMENT, + device_class="power", + promote=True, + ) + } + ), + ) + + reply = await _list(hass, hass_ws_client, panel.id) + generator = _group(reply, "Backup Generator") + + assert _row(generator, POWER_KEY)["curation"] == { + "state_class": "measurement", + "device_class": "power", + "entity_category": "none", + } + assert _row(generator, POWER_KEY)["stale_fields"] == [] + # A row nobody has curated carries an empty record rather than a null. + assert _row(generator, LABEL_KEY)["curation"] == {} + + +async def test_a_record_the_wire_outgrew_is_shown_and_named_stale( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Shown as stored *and* marked, because the editor is where the user finds out. + + Entity construction reads the same record through `for_row`, which drops what + no longer fits. The editor must not: a silently sanitised record would show + the user an assertion they never made, with no way to see that theirs was + dropped. So the stored fields are reported verbatim beside the names of the + ones the current declaration refuses. + """ + panel = _setup( + hass, + overlay=CurationOverlay( + {LABEL_KEY: CurationRecord(state_class=SensorStateClass.TOTAL_INCREASING)} + ), + ) + + reply = await _list(hass, hass_ws_client, panel.id) + label = _row(_group(reply, "Backup Generator"), LABEL_KEY) + + assert label["curation"] == {"state_class": "total_increasing"} + assert label["stale_fields"] == ["state_class"] + + +# --- the registry side ------------------------------------------------------ + + +async def test_entity_id_is_the_registry_row_or_null( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A row not yet in the registry reports null rather than a guessed id. + + Which is an ordinary state for both halves: an adopted entity is created + disabled and a vendor extension arrives on the setup after its card does, so + a row the user can curate now may have no entity until the next reload. + """ + panel = _setup(hass) + er.async_get(hass).async_get_or_create( + "sensor", + DOMAIN, + adopted_unique_id(ADOPTED_IDENTIFIER, POWER), + suggested_object_id="backup_generator_active_power", + ) + + reply = await _list(hass, hass_ws_client, panel.id) + generator = _group(reply, "Backup Generator") + + assert _row(generator, POWER_KEY)["entity_id"] == "sensor.backup_generator_active_power" + assert _row(generator, LABEL_KEY)["entity_id"] is None + battery = _group(reply, "Span Panel Battery") + assert _row(battery, CELL_TEMPERATURE_KEY)["entity_id"] is None + + +async def test_the_platform_a_row_already_holds_decides_its_entity( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """An extension row resolves its platform through the registry, never the datatype alone. + + The domain is baked into `entity_id`, so a row born a sensor stays one however + the publisher relabels it -- and the list has to report the platform the + entity actually has, or the editor offers choices for a row that is not there. + """ + unique_id = extension_unique_id( + PANEL_SERIAL, + CELL_TEMPERATURE.subject, + CELL_TEMPERATURE.node_id, + CELL_TEMPERATURE.property_id, + ) + assert unique_id is not None + panel = _setup( + hass, + _snapshot(rows=(replace(CELL_TEMPERATURE, datatype="boolean", unit=None),)), + ) + er.async_get(hass).async_get_or_create( + "sensor", DOMAIN, unique_id, suggested_object_id="battery_2_cell_temperature" + ) + + reply = await _list(hass, hass_ws_client, panel.id) + temperature = _row(_group(reply, "Span Panel Battery"), CELL_TEMPERATURE_KEY) + + assert temperature["platform"] == "sensor" + assert temperature["entity_id"] == "sensor.battery_2_cell_temperature" + + +async def test_a_device_with_no_card_yet_is_grouped_under_a_null_id( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A device that arrived after setup has no card until the next reload, and still lists. + + Its rows are curatable now -- the store is keyed on the wire address, not on a + registry id -- so hiding them would make the user wait a reload to assert + something the store would happily hold. + """ + panel = _setup(hass, register_adopted=False) + + reply = await _list(hass, hass_ws_client, panel.id) + generator = _group(reply, "Backup Generator") + + assert generator["device_id"] is None + assert generator["adopted_device"] is True + assert [row["key"] for row in generator["rows"]] == [SETPOINT_KEY, POWER_KEY, LABEL_KEY] + + +async def test_an_extension_row_waits_for_the_card_it_belongs_on( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """No card, no group -- the same deferral the entity builders make. + + An extension property hangs off a device this integration models, so a + subject whose card is not registered yet has nowhere to render and no name to + group under. It appears at the next reload, exactly as its entity does. + """ + panel = _setup( + hass, + _snapshot(rows=(replace(CELL_TEMPERATURE, subject=ExtensionSubject(kind="pv")),)), + ) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert [group["name"] for group in reply["result"]["devices"]] == ["Backup Generator"] + + +# --- refusals --------------------------------------------------------------- + + +async def test_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, +) -> None: + """Curation is an admin act, refused before any device is resolved.""" + hass_admin_user.groups = [] + async_register_commands(hass) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + {"type": "span_panel/adopted/list", "device_id": "any-device-id"} + ) + + reply = await client.receive_json() + assert reply["success"] is False + assert reply["error"]["code"] == "unauthorized" + + +async def test_device_not_found( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """An id the registry does not hold is refused by that name.""" + _setup(hass) + + reply = await _list(hass, hass_ws_client, "nonexistent") + + assert reply["error"]["code"] == "device_not_found" + + +async def test_not_span_panel(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: + """Another integration's device is refused before its entry is touched.""" + other = MockConfigEntry(domain="other_domain", data={}, entry_id="other_entry") + other.add_to_hass(hass) + device = dr.async_get(hass).async_get_or_create( + config_entry_id="other_entry", + identifiers={("other_domain", "other_serial")}, + ) + + reply = await _list(hass, hass_ws_client, device.id) + + assert reply["error"]["code"] == "not_span_panel" + + +async def test_a_span_identifier_no_span_entry_owns( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A SPAN identifier on a device another entry owns is refused, not followed. + + A device belongs to the one entry that owns it, and that entry's domain is + what makes it a SPAN panel -- an identifier in SPAN's domain does not. Topology + refuses it with the same code, because both commands resolve the panel through + `resolve_panel_device`. + """ + other = MockConfigEntry(domain="other_domain", data={}, entry_id="other_entry") + other.add_to_hass(hass) + device = dr.async_get(hass).async_get_or_create( + config_entry_id="other_entry", + identifiers={(DOMAIN, PANEL_SERIAL)}, + ) + + reply = await _list(hass, hass_ws_client, device.id) + + assert reply["error"]["code"] == "not_span_panel" + + +async def test_not_panel_device(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: + """A sub-device id is refused: the panel is the handle for the whole entry. + + Sub-devices are exactly what this command *reports*, so passing one is the + ordinary mistake, and it earns its own code rather than an empty list. + """ + _setup(hass) + bess = dr.async_get(hass).async_get_device_by_identifier((DOMAIN, BESS_IDENTIFIER), ENTRY_ID) + assert bess is not None + + reply = await _list(hass, hass_ws_client, bess.id) + + assert reply["error"]["code"] == "not_panel_device" + + +async def test_not_loaded(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: + """An entry that is not set up has no overlay and no snapshot to report.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id=ENTRY_ID, unique_id=PANEL_SERIAL) + entry.add_to_hass(hass) + panel = _register_cards(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert reply["error"]["code"] == "not_loaded" + + +async def test_runtime_data_is_not_ours( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A LOADED entry whose runtime data is not ours answers not_loaded, not an attribute error. + + `loaded_runtime_data` is the one place that decides this, per AGENTS.md's + runtime-data guard: core deletes the attribute on unload, and what is there + is whatever put it there. + """ + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id=ENTRY_ID, unique_id=PANEL_SERIAL) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = object() + panel = _register_cards(hass) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert reply["error"]["code"] == "not_loaded" + + +async def test_no_data(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: + """A panel that has not answered yet has nothing to list.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id=ENTRY_ID, unique_id=PANEL_SERIAL) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + panel = _register_cards(hass) + coordinator = MagicMock() + coordinator.data = None + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id=panel.id, + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(SpanPanelSnapshotFactory.create()), + setup_snapshot=SpanPanelSnapshotFactory.create(), + ) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert reply["error"]["code"] == "no_data" + + +async def test_a_panel_with_nothing_adopted_lists_nothing( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """An empty list, not a refusal: most panels publish neither kind of row.""" + panel = _setup(hass, _snapshot(devices=(), rows=())) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert reply["success"] is True + assert reply["result"] == {"devices": []} + + +async def test_device_id_is_required_by_the_schema( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Constrained at the schema rather than checked in the handler. + + A missing device id is refused by the websocket layer, so the handler never + runs and never has to answer for a request shape voluptuous can reject. + """ + async_register_commands(hass) + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "span_panel/adopted/list"}) + + reply = await client.receive_json() + assert reply["success"] is False + assert reply["error"]["code"] == "invalid_format" + + +# --- curate: what a save leaves behind -------------------------------------- + + +async def test_a_saved_record_is_stored_under_the_key_the_list_offered( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The key round-trips: what list offered is what curate resolves and the store holds. + + The whole editor rests on the two commands deriving the same rows, so the + assertion is deliberately end-to-end -- save through the websocket, re-read + the overlay off disk, and find the record on the row list reports it against. + """ + panel = _setup(hass) + + reply = await _curate( + hass, + hass_ws_client, + panel.id, + POWER_KEY, + {"state_class": "measurement", "device_class": "power", "entity_category": "none"}, + ) + + assert reply["success"] is True + assert reply["result"] == { + "record": { + "state_class": "measurement", + "device_class": "power", + "entity_category": "none", + }, + "warnings": [], + } + + await _reload_overlay(hass) + listed = await _list(hass, hass_ws_client, panel.id) + assert _row(_group(listed, "Backup Generator"), POWER_KEY)["curation"] == { + "state_class": "measurement", + "device_class": "power", + "entity_category": "none", + } + + +async def test_a_vendor_row_on_a_modelled_device_is_curated_the_same_way( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Both halves of adoption are one command, because they are one row derivation. + + An extension property hangs off a device this integration models and is keyed + by subject rather than by an adopted identifier, so a lookup built from only + the adopted half would refuse a row the editor is showing. + """ + panel = _setup(hass) + assert CELL_TEMPERATURE_KEY is not None + + reply = await _curate( + hass, hass_ws_client, panel.id, CELL_TEMPERATURE_KEY, {"device_class": "temperature"} + ) + + assert reply["result"] == {"record": {"device_class": "temperature"}, "warnings": []} + + await _reload_overlay(hass) + listed = await _list(hass, hass_ws_client, panel.id) + assert _row(_group(listed, "Span Panel Battery"), CELL_TEMPERATURE_KEY)["curation"] == { + "device_class": "temperature" + } + + +async def test_a_save_schedules_exactly_one_reload( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Curated metadata reaches an entity by rebuilding it, so a save has to ask for that. + + An entity description is fixed at construction, so the record the user just + stored does not reach the entity until the entry is set up again. One reload + per save: the handler neither skips it nor asks twice for one write. + """ + panel = _setup(hass) + scheduled = MagicMock() + + reply = await _curate( + hass, + hass_ws_client, + panel.id, + POWER_KEY, + {"device_class": "power"}, + scheduled_reload=scheduled, + ) + + assert reply["success"] is True + scheduled.assert_called_once_with(ENTRY_ID) + + +async def test_a_refused_record_schedules_no_reload( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Nothing was written, so there is nothing for a reload to pick up.""" + panel = _setup(hass) + scheduled = MagicMock() + + reply = await _curate( + hass, + hass_ws_client, + panel.id, + POWER_KEY, + {"device_class": "temperature"}, + scheduled_reload=scheduled, + ) + + assert reply["error"]["code"] == "incompatible_device_class" + scheduled.assert_not_called() + + +async def test_a_save_writes_no_registry_state( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The boundary this module is built on, asserted rather than described. + + A device class and a promotion out of diagnostics both *look* like registry + acts, and writing them there is how this command would quietly become a + second, weaker version of Core's own entity-registry update -- one with no + undo and no user override. The registry hands back the very object it holds, + and any update replaces that object, so identity is the exact assertion. + """ + panel = _setup(hass) + registry = er.async_get(hass) + entity = registry.async_get_or_create( + "sensor", + DOMAIN, + adopted_unique_id(ADOPTED_IDENTIFIER, POWER), + suggested_object_id="backup_generator_active_power", + ) + before = registry.async_get(entity.entity_id) + + reply = await _curate( + hass, + hass_ws_client, + panel.id, + POWER_KEY, + {"state_class": "measurement", "device_class": "power", "entity_category": "none"}, + ) + + assert reply["success"] is True + assert registry.async_get(entity.entity_id) is before + + +# --- curate: the warnings ---------------------------------------------------- + + +async def test_clearing_a_record_that_carried_a_state_class_warns_the_statistics_go( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Statistics are compiled off the state class, so clearing one stops them. + + Advisory rather than a refusal -- the save has already happened and the user + asked for it -- but it is a consequence the record does not show on its face, + and core will raise its own `state_class_removed` repair against the + statistics already collected, so the reply says so first. + """ + panel = _setup(hass) + await _curate( + hass, hass_ws_client, panel.id, POWER_KEY, {"state_class": "total", "device_class": "power"} + ) + await _reload_overlay(hass) + + reply = await _curate(hass, hass_ws_client, panel.id, POWER_KEY, {}) + + assert reply["result"] == {"record": {}, "warnings": ["statistics_removed"]} + + await _reload_overlay(hass) + listed = await _list(hass, hass_ws_client, panel.id) + assert _row(_group(listed, "Backup Generator"), POWER_KEY)["curation"] == {} + + +async def test_narrowing_a_record_off_its_state_class_warns_the_same_way( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The warning is about what the save leaves, not about how it was spelled. + + A record narrowed to its other fields drops the state class exactly as + clearing the whole record does -- the same stopped statistics and the same + repair -- and it is the likelier route, because a user editing one field of a + record still sends the whole record back. + """ + panel = _setup(hass) + await _curate( + hass, + hass_ws_client, + panel.id, + POWER_KEY, + {"state_class": "measurement", "device_class": "power"}, + ) + await _reload_overlay(hass) + + reply = await _curate(hass, hass_ws_client, panel.id, POWER_KEY, {"device_class": "power"}) + + assert reply["result"] == { + "record": {"device_class": "power"}, + "warnings": ["statistics_removed"], + } + + await _reload_overlay(hass) + listed = await _list(hass, hass_ws_client, panel.id) + assert _row(_group(listed, "Backup Generator"), POWER_KEY)["curation"] == { + "device_class": "power" + } + + +async def test_clearing_a_record_that_never_carried_one_warns_nothing( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """No state class was in force, so no statistics existed to lose.""" + panel = _setup(hass) + await _curate(hass, hass_ws_client, panel.id, POWER_KEY, {"device_class": "power"}) + await _reload_overlay(hass) + + reply = await _curate(hass, hass_ws_client, panel.id, POWER_KEY, {}) + + assert reply["result"] == {"record": {}, "warnings": []} + + +async def test_clearing_a_row_nobody_curated_is_accepted_and_warns_nothing( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Clearing what was never set is a no-op rather than a refusal. + + An editor that opens on an uncurated row and saves it unchanged is the + ordinary case, and it must not be told it did something wrong. + """ + panel = _setup(hass) + + reply = await _curate(hass, hass_ws_client, panel.id, LABEL_KEY, {}) + + assert reply["result"] == {"record": {}, "warnings": []} + + +async def test_asserting_total_increasing_is_warned_about( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The one state class that reinterprets the reading rather than describing it. + + `total_increasing` has the recorder read a drop of more than a tenth as a + meter reset and start a new cycle, so asserting it on a reading that + legitimately falls manufactures consumption. Saved as asked, and flagged. + """ + panel = _setup(hass) + + reply = await _curate( + hass, hass_ws_client, panel.id, POWER_KEY, {"state_class": "total_increasing"} + ) + + assert reply["result"] == { + "record": {"state_class": "total_increasing"}, + "warnings": ["total_increasing"], + } + + +# --- curate: refusals -------------------------------------------------------- + + +async def test_a_key_the_panel_does_not_publish_is_refused( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A well-formed key for a row that does not exist earns its own code. + + The store would hold anything -- its keys are wire addresses, not registry + ids -- so nothing but this check stops a typo becoming a record no entity + will ever read and no list will ever show. + """ + panel = _setup(hass) + + reply = await _curate( + hass, + hass_ws_client, + panel.id, + adopted_curation_key(ADOPTED_IDENTIFIER, FLATTENS_FIRST), + {"device_class": "temperature"}, + ) + + assert reply["success"] is False + assert reply["error"]["code"] == "unknown_key" + + +async def test_the_row_the_list_skipped_cannot_be_curated( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """One row derivation, so a row list declines to offer is one curate declines to save. + + The loser of a flattened-id collision never becomes an entity. A record saved + against it would sit on disk unread forever, which is worse than a refusal + because the editor would report it back as an assertion in force. + """ + panel = _setup( + hass, + _snapshot( + devices=(replace(GENERATOR, properties=(FLATTENS_SECOND, FLATTENS_FIRST)),), rows=() + ), + ) + + reply = await _curate( + hass, + hass_ws_client, + panel.id, + adopted_curation_key(ADOPTED_IDENTIFIER, FLATTENS_SECOND), + {"device_class": "temperature"}, + ) + + assert reply["error"]["code"] == "unknown_key" + + +async def test_a_cross_field_refusal_surfaces_the_validators_own_code( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """A device class the declared unit does not admit is refused, by name. + + Membership in the enum is all the schema can know; whether `temperature` + admits watts is a fact about this row, so `curation` decides it and its code + reaches the editor unchanged -- the card renders the refusal it can explain, + not a generic one. + """ + panel = _setup(hass) + + reply = await _curate( + hass, hass_ws_client, panel.id, POWER_KEY, {"device_class": "temperature"} + ) + + assert reply["success"] is False + assert reply["error"]["code"] == "incompatible_device_class" + + +async def test_the_schemas_alphabet_is_wider_than_any_one_row_admits( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """What the schema cannot know without the row is what the handler answers for. + + The schema takes both platforms' device classes because it has no row to + narrow them with, and every state class because whether a row is a numeric + sensor is a fact about the wire. So a binary-only class on a sensor row and a + state class on a string row both pass the schema and are refused here, each + by its own code. + """ + panel = _setup(hass) + + binary_only = await _curate( + hass, hass_ws_client, panel.id, POWER_KEY, {"device_class": "motion"} + ) + off_a_string = await _curate( + hass, hass_ws_client, panel.id, LABEL_KEY, {"state_class": "measurement"} + ) + + assert binary_only["error"]["code"] == "invalid_device_class" + assert off_a_string["error"]["code"] == "invalid_state_class" + + +async def test_a_control_row_accepts_prominence_only( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """What list reports as an empty choice list, curate refuses -- the same answer twice. + + A settable numeric surfaces as a control, which carries neither class. The + editor is told so by the empty `allowed_*` lists; a card that ignored them + must still be refused rather than storing metadata the entity cannot hold. + """ + panel = _setup(hass) + + refused = await _curate(hass, hass_ws_client, panel.id, SETPOINT_KEY, {"device_class": "power"}) + accepted = await _curate( + hass, hass_ws_client, panel.id, SETPOINT_KEY, {"entity_category": "none"} + ) + + assert refused["error"]["code"] == "invalid_field_for_platform" + assert accepted["result"] == {"record": {"entity_category": "none"}, "warnings": []} + + +async def test_the_schema_refuses_what_it_can_decide_without_the_row( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Enum membership, the one storable category, and unknown fields never reach the handler. + + Everything statically expressible is constrained at the schema, so the + handler answers only for what needs the row's declaration. All three come + back as the websocket layer's own `invalid_format`. + """ + panel = _setup(hass) + + for record in ( + {"state_class": "invented"}, + {"device_class": "invented"}, + {"entity_category": "diagnostic"}, + {"nonsense": "1"}, + ): + reply = await _curate(hass, hass_ws_client, panel.id, POWER_KEY, record) + assert reply["success"] is False, record + assert reply["error"]["code"] == "invalid_format", record + + +async def test_the_schema_refuses_a_key_no_curation_scheme_mints( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Keys are wire addresses and become storage keys, so their shape is constrained. + + Both minting schemes produce identifier and path segments only. A key outside + that alphabet, or an unbounded one, is refused before the handler sees it. + """ + panel = _setup(hass) + + for key in ("has spaces", "has\\backslash", "x" * 257, ""): + reply = await _curate(hass, hass_ws_client, panel.id, key, {}) + assert reply["success"] is False, key + assert reply["error"]["code"] == "invalid_format", key + + +async def test_curate_requires_admin( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, +) -> None: + """A write is an admin act, refused before any device or key is resolved.""" + hass_admin_user.groups = [] + _setup(hass) + + reply = await _curate(hass, hass_ws_client, "any-device-id", POWER_KEY, {}) + + assert reply["success"] is False + assert reply["error"]["code"] == "unauthorized" + + +async def test_curate_takes_the_panel_handle( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The same handle and the same refusals as list, because they are one resolution. + + A consumer that learned list's codes must not meet a second set on the + command it calls next, so curate resolves the panel through the same helper. + """ + _setup(hass) + bess = dr.async_get(hass).async_get_device_by_identifier((DOMAIN, BESS_IDENTIFIER), ENTRY_ID) + assert bess is not None + + reply = await _curate(hass, hass_ws_client, bess.id, POWER_KEY, {}) + + assert reply["error"]["code"] == "not_panel_device" + + +async def test_a_card_another_entry_owns_is_not_reported_as_this_panels( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The editor groups rows under a card this entry owns, or under no card at all. + + Identifiers are unique within a config entry and nowhere else, so an unscoped + lookup can resolve an adopted device to a card another entry owns. The group + then carries a stranger's name and a stranger's `device_id` -- pointing the + user at a device that has nothing to do with this panel, and offering to + curate rows against it. Ours is not registered yet here, so the group falls + back to the wire's own label with no id, exactly as it does before the first + reload. + """ + other = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-other", unique_id="other") + other.add_to_hass(hass) + dr.async_get(hass).async_get_or_create( + config_entry_id=other.entry_id, + identifiers={(DOMAIN, ADOPTED_IDENTIFIER)}, + name="Somebody else's generator", + ) + panel = _setup(hass, register_adopted=False) + + reply = await _list(hass, hass_ws_client, panel.id) + + names = [group["name"] for group in reply["result"]["devices"]] + assert "Somebody else's generator" not in names + assert _group(reply, "Backup Generator")["device_id"] is None + + +async def test_the_bound_inverters_keyed_reading_is_listed_on_the_solar_card( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """The editor files the bound inverter's reading where its entity is: the Solar card, keyed `pv/...`.""" + bound, other = "c-bound", "c-other" + row = ExtensionProperty( + subject=ExtensionSubject(kind="pv", instance_key=bound), + node_id="acme", + property_id="string-voltage", + datatype="float", + unit="V", + value="412.0", + ) + snapshot = replace( + _snapshot(devices=(), rows=(row,)), + pv_inverters={ + key: SpanPVSnapshot(device_id=f"device-{key}", node_id=key, feed_circuit_id=key) + for key in (bound, other) + }, + ) + binding = resolve(snapshot, StoredPvBinding(circuit_id=bound), frozenset(), link_held=False, inverter_links_held=frozenset())[0] + panel = _setup(hass, snapshot, pv_binding=binding) + solar = dr.async_get(hass).async_get_or_create( + config_entry_id=ENTRY_ID, + identifiers={(DOMAIN, f"{PANEL_SERIAL}_{SUB_DEVICE_PV}")}, + name="Span Panel Solar", + via_device_id=panel.id, + ) + + reply = await _list(hass, hass_ws_client, panel.id) + + assert reply["success"] is True + group = _group(reply, "Span Panel Solar") + assert group["device_id"] == solar.id + assert _row(group, "pv/acme/string-voltage")["path"] == "acme/string-voltage" diff --git a/tests/test_websocket_panel.py b/tests/test_websocket_panel.py new file mode 100644 index 00000000..028b3d82 --- /dev/null +++ b/tests/test_websocket_panel.py @@ -0,0 +1,230 @@ +"""A device id saved before Home Assistant 2026.8 still names this panel. + +2026.8 gave every device a single owning config entry. A device that belonged to +several was split into one device per entry, and every split was given a new id. +A SPAN panel was such a device whenever a helper built on one of its sensors -- a +utility meter, a Riemann integral -- had linked itself to the panel's device, which +helpers did until that release. The dashboard card keeps the device id it was +configured with, so it can send the old one. + +The registry here is reached the way a real install reaches it: a pre-2026.8 store +is handed to Home Assistant's own migration, which performs the split and remaps +the battery's `via_device_id` onto the split its own entry owns. Both commands +must then resolve the old id to the panel device SPAN owns, whichever entry was +the old device's primary. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.span_panel import SpanPanelRuntimeData +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.util import SUB_DEVICE_BESS +from custom_components.span_panel.websocket import handle_panel_topology +from custom_components.span_panel.websocket_adopted import handle_adopted_list + +from .factories import SpanPanelSnapshotFactory, pv_binding_for +from .helpers import unwrap_websocket_command + +# The registry is loaded from the store below rather than started empty. +pytestmark = pytest.mark.parametrize("load_registries", [False]) + +SERIAL = "sp3-242424-001" +SPAN_ENTRY_ID = "span_entry" +HELPER_ENTRY_ID = "helper_entry" +OLD_PANEL_ID = "0ld0pane1000000000000000000000ab" +BATTERY_ID = "batt000000000000000000000000000c" + + +def _device(**fields: Any) -> dict[str, Any]: + """Return one device as a v1.10 store records it.""" + return { + "area_id": None, + "configuration_url": None, + "connections": [], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "labels": [], + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "serial_number": None, + "sw_version": None, + **fields, + } + + +def _pre_2026_8_store(*, primary: str) -> dict[str, Any]: + """Return a device registry store from before devices were split. + + The panel's device is shared with a helper entry, as a helper linked to one of + its sensors left it, and the battery hangs off the panel's old id. + """ + return { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + _device( + config_entries=[SPAN_ENTRY_ID, HELPER_ENTRY_ID], + config_entries_subentries={SPAN_ENTRY_ID: [None], HELPER_ENTRY_ID: [None]}, + id=OLD_PANEL_ID, + identifiers=[[DOMAIN, SERIAL]], + manufacturer="Span", + model="SPAN Panel", + name="SPAN Panel", + primary_config_entry=primary, + via_device_id=None, + ), + _device( + config_entries=[SPAN_ENTRY_ID], + config_entries_subentries={SPAN_ENTRY_ID: [None]}, + id=BATTERY_ID, + identifiers=[[DOMAIN, f"{SERIAL}_{SUB_DEVICE_BESS}"]], + manufacturer="Span", + model="Battery", + name="SPAN Panel Battery", + primary_config_entry=SPAN_ENTRY_ID, + via_device_id=OLD_PANEL_ID, + ), + ], + "deleted_devices": [], + }, + } + + +async def _split_panel( + hass: HomeAssistant, hass_storage: dict[str, Any], *, primary: str +) -> dr.DeviceEntry: + """Load the pre-2026.8 store through the migration and return the panel SPAN owns.""" + span = MockConfigEntry(domain=DOMAIN, data={}, entry_id=SPAN_ENTRY_ID, unique_id=SERIAL) + span.add_to_hass(hass) + MockConfigEntry(domain="utility_meter", data={}, entry_id=HELPER_ENTRY_ID).add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = _pre_2026_8_store(primary=primary) + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + + registry = dr.async_get(hass) + panel = registry.async_get_device_by_identifier((DOMAIN, SERIAL), SPAN_ENTRY_ID) + assert panel is not None + # The split this test is about: the old id names no device of its own any more, + # only the devices it was split into -- the panel SPAN owns among them. + assert panel.id != OLD_PANEL_ID + splits = registry.async_get_devices_for_composite_device_id(OLD_PANEL_ID) + assert panel.id in {split.id for split in splits} + + span.mock_state(hass, ConfigEntryState.LOADED) + snapshot = SpanPanelSnapshotFactory.create(serial_number=SERIAL) + coordinator = MagicMock() + coordinator.data = snapshot + # Unloaded at teardown, which awaits the coordinator's shutdown. + coordinator.async_shutdown = AsyncMock() + span.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id=panel.id, + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + ) + return panel + + +@pytest.mark.parametrize("primary", [SPAN_ENTRY_ID, HELPER_ENTRY_ID]) +async def test_topology_answers_an_old_panel_id_with_the_panel_it_became( + hass: HomeAssistant, hass_storage: dict[str, Any], primary: str +) -> None: + """The battery is found under the old id, which is echoed, beside the device it became. + + The migration remapped the battery onto the new panel device, so matching + sub-devices against the id the card sent finds none, and the old device's + entries include the helper's, which is no SPAN panel. + """ + panel = await _split_panel(hass, hass_storage, primary=primary) + connection = MagicMock() + + await unwrap_websocket_command(handle_panel_topology)( + hass, connection, {"id": 1, "type": "span_panel/panel_topology", "device_id": OLD_PANEL_ID} + ) + + connection.send_error.assert_not_called() + result = connection.send_result.call_args.args[1] + assert result["device_id"] == OLD_PANEL_ID + assert list(result["sub_devices"]) == [BATTERY_ID] + # The device the old id became, and its entry: what the card needs and cannot + # find by looking the old id up in the device list, which no longer holds it. + assert result["panel_device_id"] == panel.id + assert result["config_entry_id"] == SPAN_ENTRY_ID + + +@pytest.mark.parametrize("primary", [SPAN_ENTRY_ID, HELPER_ENTRY_ID]) +async def test_the_adopted_list_answers_an_old_panel_id( + hass: HomeAssistant, hass_storage: dict[str, Any], primary: str +) -> None: + """Resolved to SPAN's entry even when the old device's primary was the helper's. + + The old device reports its former primary as `config_entry_id`, so asking that + field alone refuses the id whenever the helper was primary. + """ + await _split_panel(hass, hass_storage, primary=primary) + connection = MagicMock() + + await unwrap_websocket_command(handle_adopted_list)( + hass, connection, {"id": 1, "type": "span_panel/adopted/list", "device_id": OLD_PANEL_ID} + ) + + connection.send_error.assert_not_called() + connection.send_result.assert_called_once() + + +async def test_an_old_id_split_into_devices_span_owns_none_of_is_not_a_panel( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """An old id is resolved only to a split a SPAN entry owns, never to another's.""" + other_a = MockConfigEntry(domain="utility_meter", data={}, entry_id="other_a") + other_a.add_to_hass(hass) + other_b = MockConfigEntry(domain="integration", data={}, entry_id="other_b") + other_b.add_to_hass(hass) + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "data": { + "devices": [ + _device( + config_entries=["other_a", "other_b"], + config_entries_subentries={"other_a": [None], "other_b": [None]}, + id=OLD_PANEL_ID, + identifiers=[["utility_meter", "meter-1"]], + manufacturer=None, + model=None, + name="Somebody else's device", + primary_config_entry="other_a", + via_device_id=None, + ) + ], + "deleted_devices": [], + }, + } + dr.async_setup(hass) + await dr.async_load(hass) + await er.async_load(hass) + connection = MagicMock() + + await unwrap_websocket_command(handle_panel_topology)( + hass, connection, {"id": 1, "type": "span_panel/panel_topology", "device_id": OLD_PANEL_ID} + ) + + connection.send_error.assert_called_once_with( + 1, "not_span_panel", "Device is not a SPAN Panel device" + ) diff --git a/tests/test_websocket_solar.py b/tests/test_websocket_solar.py new file mode 100644 index 00000000..11f71a1f --- /dev/null +++ b/tests/test_websocket_solar.py @@ -0,0 +1,368 @@ +"""`span_panel/panel_topology` tells the card each inverter's identity and power, over a real registry.""" + +from __future__ import annotations + +from typing import Final +from unittest.mock import MagicMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from pytest_homeassistant_custom_component.common import MockConfigEntry +from span_panel_api import SpanPanelSnapshot + +from custom_components.span_panel.const import DOMAIN, PV_PANEL_LINK_KEY +from custom_components.span_panel.entity_resolver import entity_id_in_entry +from custom_components.span_panel.id_builder import ( + build_binary_sensor_unique_id, + build_panel_unique_id, + build_pv_inverter_unique_id, +) +from custom_components.span_panel.sensor_definitions import PV_POWER_SENSOR +from custom_components.span_panel.websocket import handle_panel_topology + +from .adapter_fixtures import schema_one_snapshot +from .factories import SpanPanelSnapshotFactory +from .helpers import unwrap_websocket_command +from .test_pv_binding import _gateway_tree, _unfed_tree +from .test_pv_device import PV_DEVICE, SOLAR_CIRCUIT, _entry +from .test_pv_inverters import ( + FIRST_PV, + SECOND_PV, + SECOND_SOLAR_CIRCUIT, + _setup, + _tree, + _unload, +) + +_inner = unwrap_websocket_command(handle_panel_topology) + +C: Final = SOLAR_CIRCUIT +C2: Final = SECOND_SOLAR_CIRCUIT + + +async def _topology(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, object]: + entry.mock_state(hass, ConfigEntryState.LOADED) + connection = MagicMock() + await _inner( + hass, + connection, + { + "id": 1, + "type": "span_panel/panel_topology", + "device_id": entry.runtime_data.panel_device_id, + }, + ) + connection.send_error.assert_not_called() + result: object = connection.send_result.call_args.args[1] + assert isinstance(result, dict) + return result + + +def _solar_by_identifier(hass: HomeAssistant, result: dict[str, object]) -> dict[str, object]: + """`{device identifier: solar block}` for every sub-device that carries one.""" + sub_devices = result["sub_devices"] + assert isinstance(sub_devices, dict) + registry = dr.async_get(hass) + found: dict[str, object] = {} + for device_id, record in sub_devices.items(): + assert isinstance(record, dict) + device = registry.async_get(device_id) + assert device is not None + identifier = next(name for domain, name in device.identifiers if domain == DOMAIN) + if "solar" in record: + found[identifier] = record["solar"] + return found + + +def _circuit_power(hass: HomeAssistant, entry: MockConfigEntry, circuit_id: str) -> str: + matches = [ + row.entity_id + for row in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id) + if row.domain == "sensor" and row.unique_id.endswith(f"_{circuit_id}_power") + ] + assert len(matches) == 1, matches + return matches[0] + + +def _site_power(hass: HomeAssistant, entry: MockConfigEntry, snapshot: SpanPanelSnapshot) -> str: + """PV Power, found on the Solar device itself rather than by the handler's own lookup. + + Required to exist, so that a lookup that found nothing cannot pass by + matching a handler that also found nothing. + """ + serial = snapshot.serial_number + solar = dr.async_get(hass).async_get_device_by_identifier( + (DOMAIN, f"{serial}_pv"), entry.entry_id + ) + assert solar is not None + unique_id = build_panel_unique_id(serial, PV_POWER_SENSOR.key) + matches = [ + row.entity_id + for row in er.async_entries_for_device(er.async_get(hass), solar.id) + if row.domain == "sensor" and row.unique_id == unique_id + ] + assert len(matches) == 1, matches + return matches[0] + + +def _no_inverter_published() -> SpanPanelSnapshot: + tree = _unfed_tree() + tree.pop(PV_DEVICE) + return schema_one_snapshot(tree) + + +async def test_the_bound_inverter_is_the_site_tile_and_the_other_has_its_own( + hass: HomeAssistant, +) -> None: + one = schema_one_snapshot() + entry = _entry(hass, "entry-ws-a", one.serial_number) + await _unload(await _setup(hass, entry, one)) + two = schema_one_snapshot(_tree()) + await _setup(hass, entry, two) + + solar = _solar_by_identifier(hass, await _topology(hass, entry)) + + serial = two.serial_number + assert solar == { + f"{serial}_pv": { + "role": "site", + "vendor": two.pv_inverters[C].vendor_name, + "model": two.pv_inverters[C].model, + "feed_circuit_id": C, + "power_entity_id": _circuit_power(hass, entry, C), + "site_power_entity_id": _site_power(hass, entry, two), + }, + f"{serial}_pv_{C2}": { + "role": "inverter", + "vendor": two.pv_inverters[C2].vendor_name, + "model": two.pv_inverters[C2].model, + "feed_circuit_id": C2, + "power_entity_id": _circuit_power(hass, entry, C2), + "site_power_entity_id": None, + }, + } + + +async def test_inverters_behind_a_gateway_have_identity_and_no_individual_reading( + hass: HomeAssistant, +) -> None: + snapshot = schema_one_snapshot( + _gateway_tree( + ("panel-se7600h-us-1", "SE7600H-US", "11680"), + ("panel-use7600h-us-2", "USE7600H-US", "11680"), + ) + ) + entry = _entry(hass, "entry-ws-b", snapshot.serial_number) + await _setup(hass, entry, snapshot) + + solar = _solar_by_identifier(hass, await _topology(hass, entry)) + + serial = snapshot.serial_number + site = solar[f"{serial}_pv"] + assert isinstance(site, dict) + assert site["power_entity_id"] is None and site["site_power_entity_id"] == _site_power( + hass, entry, snapshot + ) + for key in ("panel-se7600h-us-1", "panel-use7600h-us-2"): + block = solar[f"{serial}_pv_{key}"] + assert isinstance(block, dict) + assert block["role"] == "inverter" and block["power_entity_id"] is None + + +async def test_a_single_tab_inverter_circuit_resolves_its_power(hass: HomeAssistant) -> None: + one = schema_one_snapshot() + entry = _entry(hass, "entry-ws-tab", one.serial_number) + await _unload(await _setup(hass, entry, one)) + tree = _tree() + tree[C2]["info/spaces"] = "5" + two = schema_one_snapshot(tree) + assert len(two.circuits[C2].tabs) == 1 + await _setup(hass, entry, two) + + block = _solar_by_identifier(hass, await _topology(hass, entry))[f"{two.serial_number}_pv_{C2}"] + + assert isinstance(block, dict) + assert block["power_entity_id"] == _circuit_power(hass, entry, C2) + + +async def test_inverter_card_entities_never_take_the_circuits_power_role( + hass: HomeAssistant, +) -> None: + """Their unique ids embed the circuit id (spec F8); only the circuit's own sensor is its power.""" + one = schema_one_snapshot() + entry = _entry(hass, "entry-ws-guard", one.serial_number) + await _unload(await _setup(hass, entry, one)) + two = schema_one_snapshot(_tree()) + await _setup(hass, entry, two) + registry = er.async_get(hass) + serial = two.serial_number + for domain, unique_id in ( + ("sensor", build_pv_inverter_unique_id(serial, C2, "pv_vendor")), + ("binary_sensor", build_pv_inverter_unique_id(serial, C2, PV_PANEL_LINK_KEY)), + ("sensor", f"span_{serial}_adopted_pv_{C2}/inverter/power"), + ): + registry.async_get_or_create(domain, DOMAIN, unique_id, config_entry=entry) + + result = await _topology(hass, entry) + + circuits = result["circuits"] + assert isinstance(circuits, dict) + entities = circuits[C2]["entities"] + assert entities["power"] == _circuit_power(hass, entry, C2) + block = _solar_by_identifier(hass, result)[f"{serial}_pv_{C2}"] + assert isinstance(block, dict) and block["power_entity_id"] == _circuit_power(hass, entry, C2) + + +async def test_pv_with_no_inverter_published_still_gets_a_site_block(hass: HomeAssistant) -> None: + """The Solar device exists whenever PV is commissioned, so the card must never meet it without a block (review I-1).""" + snapshot = _no_inverter_published() + entry = _entry(hass, "entry-ws-none", snapshot.serial_number) + await _setup(hass, entry, snapshot) + + solar = _solar_by_identifier(hass, await _topology(hass, entry)) + + assert solar == { + f"{snapshot.serial_number}_pv": { + "role": "site", + "vendor": None, + "model": None, + "feed_circuit_id": None, + "power_entity_id": None, + "site_power_entity_id": _site_power(hass, entry, snapshot), + } + } + + +async def test_a_pending_record_with_no_inverter_published_keeps_the_bound_circuit( + hass: HomeAssistant, +) -> None: + one = schema_one_snapshot() + entry = _entry(hass, "entry-ws-pending", one.serial_number) + await _unload(await _setup(hass, entry, one)) + none = _no_inverter_published() + await _setup(hass, entry, none) + assert entry.runtime_data.pv_binding.bound_key == C + + site = _solar_by_identifier(hass, await _topology(hass, entry))[f"{none.serial_number}_pv"] + + assert isinstance(site, dict) + assert site["feed_circuit_id"] == C and site["power_entity_id"] == _circuit_power( + hass, entry, C + ) + + +async def test_pv_power_another_entry_registered_is_not_this_panels(hass: HomeAssistant) -> None: + """Every registry lookup is scoped to the entry: a unique id another entry holds says nothing about this one.""" + snapshot = SpanPanelSnapshotFactory.create() + entry = _entry(hass, "entry-ws-scoped", snapshot.serial_number) + await _setup(hass, entry, snapshot) + other = MockConfigEntry(domain=DOMAIN, entry_id="entry-ws-other", unique_id="another-panel") + other.add_to_hass(hass) + er.async_get(hass).async_get_or_create( + "sensor", + DOMAIN, + build_panel_unique_id(snapshot.serial_number, PV_POWER_SENSOR.key), + config_entry=other, + ) + + result = await _topology(hass, entry) + + panel_entities = result["panel_entities"] + assert isinstance(panel_entities, dict) + assert "pv_power" not in panel_entities + assert "current_power" in panel_entities + + +def _entity_state(hass: HomeAssistant, entry: MockConfigEntry, domain: str, unique_id: str) -> str: + entity_id = entity_id_in_entry(er.async_get(hass), entry.entry_id, domain, unique_id) + assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + return state.state + + +async def test_a_vendor_published_after_setup_reaches_the_tile_as_it_reaches_the_sensor( + hass: HomeAssistant, +) -> None: + """Identity is read live, as the PV Vendor and PV Product sensors read it; nothing reloads for it.""" + one = schema_one_snapshot() + entry = _entry(hass, "entry-ws-late-vendor", one.serial_number) + await _unload(await _setup(hass, entry, one)) + late = _tree() + for device in (FIRST_PV, SECOND_PV): + late[device].pop("info/vendor-name") + late[device].pop("info/model") + live = schema_one_snapshot(_tree()) + platforms = await _setup(hass, entry, schema_one_snapshot(late), published=live) + # The coordinator's next refresh, which delivers `live` to every entity. + for platform in platforms: + for entity in platform.entities.values(): + if isinstance(entity, CoordinatorEntity): + entity._handle_coordinator_update() + + solar = _solar_by_identifier(hass, await _topology(hass, entry)) + + serial = live.serial_number + site = solar[f"{serial}_pv"] + second = solar[f"{serial}_pv_{C2}"] + assert isinstance(site, dict) and isinstance(second, dict) + assert site["vendor"] == live.pv_inverters[C].vendor_name == "Enphase" + assert site["model"] == live.pv_inverters[C].model + assert second["vendor"] == "Second Vendor" + assert second["model"] == live.pv_inverters[C2].model + assert site["vendor"] == _entity_state( + hass, entry, "sensor", build_panel_unique_id(serial, "pv_vendor") + ) + assert second["vendor"] == _entity_state( + hass, entry, "sensor", build_pv_inverter_unique_id(serial, C2, "pv_vendor") + ) + + +async def test_a_restored_store_draws_the_bound_circuit_once_on_its_own_tile( + hass: HomeAssistant, +) -> None: + """A bound inverter that already holds a card keeps it, so its circuit is drawn there and not on the Solar tile too.""" + one = schema_one_snapshot() + entry = _entry(hass, "entry-ws-restored", one.serial_number) + await _unload(await _setup(hass, entry, one)) + assert entry.runtime_data.pv_binding.bound_key == C + serial = one.serial_number + er.async_get(hass).async_get_or_create( + "sensor", DOMAIN, build_pv_inverter_unique_id(serial, C, "pv_vendor"), config_entry=entry + ) + await _setup(hass, entry, schema_one_snapshot(_tree())) + + solar = _solar_by_identifier(hass, await _topology(hass, entry)) + + site = solar[f"{serial}_pv"] + own = solar[f"{serial}_pv_{C}"] + assert isinstance(site, dict) and isinstance(own, dict) + assert site["feed_circuit_id"] is None and site["power_entity_id"] is None + assert own["feed_circuit_id"] == C + assert own["power_entity_id"] == _circuit_power(hass, entry, C) + + +async def test_a_panel_status_another_entry_holds_is_not_this_panels(hass: HomeAssistant) -> None: + """The panel status the card watches is looked up in this entry too.""" + snapshot = SpanPanelSnapshotFactory.create() + entry = _entry(hass, "entry-ws-scoped-status", snapshot.serial_number) + await _setup(hass, entry, snapshot) + other = MockConfigEntry( + domain=DOMAIN, entry_id="entry-ws-other-status", unique_id="another-panel" + ) + other.add_to_hass(hass) + registry = er.async_get(hass) + unique_id = build_binary_sensor_unique_id(snapshot.serial_number, "panel_status") + own = registry.async_get_entity_id("binary_sensor", DOMAIN, unique_id) + assert own is not None + registry.async_remove(own) + registry.async_get_or_create("binary_sensor", DOMAIN, unique_id, config_entry=other) + + result = await _topology(hass, entry) + + panel_entities = result["panel_entities"] + assert isinstance(panel_entities, dict) + assert "panel_status" not in panel_entities + assert "current_power" in panel_entities diff --git a/tests/test_withdrawn_controls.py b/tests/test_withdrawn_controls.py new file mode 100644 index 00000000..a7d78aed --- /dev/null +++ b/tests/test_withdrawn_controls.py @@ -0,0 +1,398 @@ +"""Controls the panel withdraws are removed at setup, in this entry only, and return with their identity. + +The panel decides which circuits take a breaker switch and a shed-priority +select, and `helpers.circuit_has_a_breaker_switch` and +`circuit_has_a_priority_select` read its answer. Firmware r202639 locks the +circuits SPAN adds for a commissioned PV or backup system, so a switch and a +select that existed before are no longer offered. Setup removes their registry +entries instead of leaving them orphaned and unavailable. Core keeps each one's +tombstone while the config entry exists, so a control the panel offers again +returns with its entity id, its registry id and the user's customisations. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +import logging +from typing import Final +from unittest.mock import MagicMock + +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, entity_registry as er, label_registry as lr +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry, MockEntityPlatform +from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot + +from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered +from custom_components.span_panel.const import DOMAIN +from custom_components.span_panel.control_gate import ControlMode, ControlPolicy +from custom_components.span_panel.curation import CurationOverlay +from custom_components.span_panel.helpers import ( + circuit_has_a_breaker_switch, + remove_withdrawn_controls, +) +from custom_components.span_panel.id_builder import build_select_unique_id, build_switch_unique_id +from custom_components.span_panel.select import async_setup_entry as select_setup +from custom_components.span_panel.switch import async_setup_entry as switch_setup + +from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory, pv_binding_for +from .test_pv_device import PANEL_NAME, _coordinator, _entry + +SERIAL: Final = "sp3-withdrawn-001" +BACKUP: Final = "backup-system" +INVERTER: Final = "second-inverter" +KITCHEN: Final = "kitchen" +WELL: Final = "well-pump" +GONE: Final = "removed-from-panel" +UNMAPPED: Final = "unmapped_tab_32" + + +@pytest.fixture(autouse=True) +def expected_lingering_timers() -> bool: + """The switch platform's relock and debounce timers may outlive a test.""" + return True + + +def _circuit( + circuit_id: str, + name: str, + tabs: list[int], + *, + locked: bool = False, + device_type: str = "circuit", + never_backup: bool = False, +) -> SpanCircuitSnapshot: + """A circuit as r202633 publishes it, or as r202639 locks it: not controllable, priority NEVER (spec §1 B).""" + return SpanCircuitSnapshotFactory.create( + circuit_id=circuit_id, + name=name, + tabs=tabs, + is_user_controllable=not locked, + is_never_backup=locked or never_backup, + priority="NEVER" if locked else "SOC_THRESHOLD", + device_type=device_type, + ) + + +def _panel(*circuits: SpanCircuitSnapshot) -> SpanPanelSnapshot: + return SpanPanelSnapshotFactory.create( + serial_number=SERIAL, circuits={c.circuit_id: c for c in circuits} + ) + + +KITCHEN_CIRCUIT: Final = _circuit(KITCHEN, "Kitchen", [2]) +R202633: Final = _panel( + _circuit(BACKUP, "Commissioned Backup System", [5, 7]), + _circuit(INVERTER, "Solar Inverter 2", [24, 26]), + KITCHEN_CIRCUIT, +) +R202639: Final = _panel( + _circuit(BACKUP, "Commissioned Backup System", [5, 7], locked=True), + _circuit(INVERTER, "Solar Inverter 2", [24, 26], locked=True, device_type="pv"), + KITCHEN_CIRCUIT, +) +WITHDRAWN: Final = ( + ("switch", build_switch_unique_id(SERIAL, BACKUP)), + ("select", build_select_unique_id(SERIAL, BACKUP)), + ("switch", build_switch_unique_id(SERIAL, INVERTER)), + ("select", build_select_unique_id(SERIAL, INVERTER)), +) +KEPT: Final = ( + ("switch", build_switch_unique_id(SERIAL, KITCHEN)), + ("select", build_select_unique_id(SERIAL, KITCHEN)), +) + + +@dataclass +class _Controls: + coordinator: MagicMock + platforms: list[MockEntityPlatform] + + async def unload(self) -> None: + for platform in self.platforms: + await platform.async_reset() + + def entity(self, entity_id: str) -> object: + for platform in self.platforms: + if entity_id in platform.entities: + return platform.entities[entity_id] + raise AssertionError(f"{entity_id} is not on a platform") + + +async def _set_up( + hass: HomeAssistant, + entry: MockConfigEntry, + snapshot: SpanPanelSnapshot, + *, + mode: ControlMode = ControlMode.ALL_USERS, +) -> _Controls: + """Run the switch and select platforms' real `async_setup_entry` through real `EntityPlatform`s.""" + coordinator = _coordinator(hass, entry, snapshot) + entry.runtime_data = SpanPanelRuntimeData( + coordinator=coordinator, + panel_device_id=await ensure_device_registered(hass, entry, snapshot, PANEL_NAME), + curation=CurationOverlay.empty(), + pv_binding=pv_binding_for(snapshot), + setup_snapshot=snapshot, + control_policy=replace(ControlPolicy.default(), mode=mode), + ) + platforms: list[MockEntityPlatform] = [] + for domain, setup in (("switch", switch_setup), ("select", select_setup)): + added: list[object] = [] + await setup(hass, entry, lambda entities, **_: added.extend(entities)) + platform = MockEntityPlatform( + hass, domain=domain, platform_name=DOMAIN, logger=logging.getLogger(__name__) + ) + platform.config_entry = entry + await platform.async_add_entities(added) + platforms.append(platform) + await hass.async_block_till_done() + return _Controls(coordinator, platforms) + + +def _seed(hass: HomeAssistant, entry: MockConfigEntry, domain: str, unique_id: str, object_id: str) -> str: + """Register a control as an earlier release left it.""" + return er.async_get(hass).async_get_or_create( + domain, DOMAIN, unique_id, config_entry=entry, suggested_object_id=object_id + ).entity_id + + +SEEDED_OBJECT_IDS: Final = { + build_switch_unique_id(SERIAL, BACKUP): "span_panel_commissioned_backup_system_breaker", + build_select_unique_id(SERIAL, BACKUP): "span_panel_commissioned_backup_system_circuit_priority", + build_switch_unique_id(SERIAL, INVERTER): "span_panel_solar_inverter_2_breaker", + build_select_unique_id(SERIAL, INVERTER): "span_panel_solar_inverter_2_circuit_priority", + build_switch_unique_id(SERIAL, KITCHEN): "span_panel_kitchen_breaker", + build_select_unique_id(SERIAL, KITCHEN): "span_panel_kitchen_circuit_priority", +} + + +def _seed_a_2_1_1_registry(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, str]: + """2.1.1 on r202639: both locked circuits' switch and select orphaned, the kitchen's live (spec §5.3, review H1).""" + return { + unique_id: _seed(hass, entry, domain, unique_id, SEEDED_OBJECT_IDS[unique_id]) + for domain, unique_id in (*WITHDRAWN, *KEPT) + } + + +def _assert_tombstoned(hass: HomeAssistant, entry: MockConfigEntry, domain: str, unique_id: str) -> None: + registry = er.async_get(hass) + assert registry.async_get_entity_id(domain, DOMAIN, unique_id) is None + deleted = registry.deleted_entities[(domain, DOMAIN, unique_id)] + assert deleted.config_entry_id == entry.entry_id + assert deleted.orphaned_timestamp is None + + +# --------------------------------------------------------------------------- +# 2.1.1 on r202639, then this release (spec §5.3, §6.1 B) +# --------------------------------------------------------------------------- + + +async def test_setup_before_start_removes_the_orphans_and_start_restores_no_state(hass: HomeAssistant) -> None: + entry = _entry(hass, "entry-before-start", SERIAL) + seeded = _seed_a_2_1_1_registry(hass, entry) + + await _set_up(hass, entry, R202639) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + for domain, unique_id in WITHDRAWN: + _assert_tombstoned(hass, entry, domain, unique_id) + assert hass.states.get(seeded[unique_id]) is None + for domain, unique_id in KEPT: + assert er.async_get(hass).async_get_entity_id(domain, DOMAIN, unique_id) == seeded[unique_id] + + +async def test_setup_after_start_removes_the_restored_unavailable_states(hass: HomeAssistant) -> None: + """The setup-retry path: Core has already written `restored: true` states (spec F10).""" + entry = _entry(hass, "entry-after-start", SERIAL) + seeded = _seed_a_2_1_1_registry(hass, entry) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + for _, unique_id in WITHDRAWN: + restored = hass.states.get(seeded[unique_id]) + assert restored is not None and restored.attributes.get("restored") is True + + await _set_up(hass, entry, R202639) + + for domain, unique_id in WITHDRAWN: + _assert_tombstoned(hass, entry, domain, unique_id) + assert hass.states.get(seeded[unique_id]) is None + + +# --------------------------------------------------------------------------- +# What is judged, and what is not +# --------------------------------------------------------------------------- + + +async def test_a_never_backup_circuit_keeps_its_switch_and_loses_its_select(hass: HomeAssistant) -> None: + entry = _entry(hass, "entry-never-backup", SERIAL) + switch_id = _seed(hass, entry, "switch", build_switch_unique_id(SERIAL, WELL), "span_panel_well_pump_breaker") + _seed(hass, entry, "select", build_select_unique_id(SERIAL, WELL), "span_panel_well_pump_circuit_priority") + + await _set_up(hass, entry, _panel(_circuit(WELL, "Well Pump", [9], never_backup=True))) + + assert er.async_get(hass).async_get_entity_id("switch", DOMAIN, build_switch_unique_id(SERIAL, WELL)) == switch_id + _assert_tombstoned(hass, entry, "select", build_select_unique_id(SERIAL, WELL)) + + +async def test_a_circuit_absent_from_the_setup_snapshot_is_never_judged(hass: HomeAssistant) -> None: + entry = _entry(hass, "entry-absent", SERIAL) + gone = _seed(hass, entry, "switch", build_switch_unique_id(SERIAL, GONE), "span_panel_gone_breaker") + unmapped = SpanCircuitSnapshotFactory.create(circuit_id=UNMAPPED, tabs=[32], is_user_controllable=False) + + await _set_up(hass, entry, _panel(KITCHEN_CIRCUIT, unmapped)) + + assert er.async_get(hass).async_get_entity_id("switch", DOMAIN, build_switch_unique_id(SERIAL, GONE)) == gone + + +async def test_another_entrys_registration_of_the_same_unique_id_is_untouched(hass: HomeAssistant) -> None: + other = _entry(hass, "entry-other", "sp3-other-002") + unique_id = build_switch_unique_id(SERIAL, BACKUP) + held = _seed(hass, other, "switch", unique_id, "span_panel_other_breaker") + entry = _entry(hass, "entry-mine", SERIAL) + + await _set_up(hass, entry, R202639) + + assert er.async_get(hass).async_get_entity_id("switch", DOMAIN, unique_id) == held + + +async def test_nobody_removes_nothing_and_administrators_only_then_removes_the_withdrawn(hass: HomeAssistant) -> None: + entry = _entry(hass, "entry-nobody", SERIAL) + seeded = _seed_a_2_1_1_registry(hass, entry) + + controls = await _set_up(hass, entry, R202639, mode=ControlMode.DISABLED) + for domain, unique_id in (*WITHDRAWN, *KEPT): + assert er.async_get(hass).async_get_entity_id(domain, DOMAIN, unique_id) == seeded[unique_id] + await controls.unload() + + await _set_up(hass, entry, R202639, mode=ControlMode.ADMIN_ONLY) + for domain, unique_id in WITHDRAWN: + _assert_tombstoned(hass, entry, domain, unique_id) + + +async def test_the_function_reports_and_logs_what_it_removed( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + entry = _entry(hass, "entry-direct", SERIAL) + backup = _seed(hass, entry, "switch", build_switch_unique_id(SERIAL, BACKUP), "span_panel_backup_breaker") + _seed(hass, entry, "switch", build_switch_unique_id(SERIAL, KITCHEN), "span_panel_kitchen_breaker") + + with caplog.at_level(logging.INFO): + removed = remove_withdrawn_controls( + er.async_get(hass), + entry.entry_id, + "switch", + R202639.circuits, + circuit_has_a_breaker_switch, + lambda circuit_id: build_switch_unique_id(SERIAL, circuit_id), + ) + + assert removed == [backup] + assert backup in caplog.text + + +# --------------------------------------------------------------------------- +# Restoration (spec §3.4, F9, F11) +# --------------------------------------------------------------------------- + + +async def test_a_restored_control_returns_with_its_identity_and_customisations(hass: HomeAssistant) -> None: + entry = _entry(hass, "entry-restore", SERIAL) + registry = er.async_get(hass) + controls = await _set_up(hass, entry, R202633) + switch_uid = build_switch_unique_id(SERIAL, BACKUP) + select_uid = build_select_unique_id(SERIAL, BACKUP) + switch_id = registry.async_get_entity_id("switch", DOMAIN, switch_uid) + select_id = registry.async_get_entity_id("select", DOMAIN, select_uid) + assert switch_id is not None and select_id is not None + area = ar.async_get(hass).async_create("Garage") + label = lr.async_get(hass).async_create("Backup loads") + registry.async_update_entity( + switch_id, + name="Backup Panel Feed", + area_id=area.id, + labels={label.label_id}, + hidden_by=er.RegistryEntryHider.USER, + ) + before = registry.async_get(switch_id) + select_before = registry.async_get(select_id) + assert before is not None and select_before is not None + await controls.unload() + + controls = await _set_up(hass, entry, R202639) + _assert_tombstoned(hass, entry, "switch", switch_uid) + _assert_tombstoned(hass, entry, "select", select_uid) + await controls.unload() + + controls = await _set_up(hass, entry, R202633) + + after = registry.async_get(switch_id) + assert after is not None + assert (after.id, after.unique_id, after.name, after.area_id, after.labels, after.hidden_by) == ( + before.id, + before.unique_id, + "Backup Panel Feed", + area.id, + {label.label_id}, + er.RegistryEntryHider.USER, + ) + select_after = registry.async_get(select_id) + assert select_after is not None and select_after.id == select_before.id + # A named control asks for no reload; an unnamed one asks once, to sync its name. + request_reload = controls.coordinator.request_reload + request_reload.reset_mock() + controls.entity(switch_id)._handle_coordinator_update() + assert request_reload.call_count == 0 + controls.entity(select_id)._handle_coordinator_update() + assert request_reload.call_count == 1 + + +async def test_a_user_disabled_control_comes_back_disabled(hass: HomeAssistant) -> None: + entry = _entry(hass, "entry-disabled", SERIAL) + registry = er.async_get(hass) + controls = await _set_up(hass, entry, R202633) + switch_id = registry.async_get_entity_id("switch", DOMAIN, build_switch_unique_id(SERIAL, BACKUP)) + assert switch_id is not None + registry.async_update_entity(switch_id, disabled_by=er.RegistryEntryDisabler.USER) + await controls.unload() + + await (await _set_up(hass, entry, R202639)).unload() + await _set_up(hass, entry, R202633) + + restored = registry.async_get(switch_id) + assert restored is not None + assert restored.disabled_by is er.RegistryEntryDisabler.USER + + +async def test_the_entity_id_survives_every_withdrawal_and_return(hass: HomeAssistant) -> None: + entry = _entry(hass, "entry-permanence", SERIAL) + registry = er.async_get(hass) + await (await _set_up(hass, entry, R202633)).unload() + original = registry.async_get_entity_id("switch", DOMAIN, build_switch_unique_id(SERIAL, BACKUP)) + + for snapshot in (R202639, R202633, R202639, R202633): + await (await _set_up(hass, entry, snapshot)).unload() + + assert registry.async_get_entity_id("switch", DOMAIN, build_switch_unique_id(SERIAL, BACKUP)) == original + + +async def test_cores_exception_another_entity_taking_the_id_moves_the_restored_one(hass: HomeAssistant) -> None: + """Pinned, not endorsed: only a newly created collision or a user rename can do this (spec §3.4).""" + entry = _entry(hass, "entry-collision", SERIAL) + registry = er.async_get(hass) + await (await _set_up(hass, entry, R202633)).unload() + unique_id = build_switch_unique_id(SERIAL, BACKUP) + original = registry.async_get_entity_id("switch", DOMAIN, unique_id) + assert original is not None + await (await _set_up(hass, entry, R202639)).unload() + + squatter = registry.async_get_or_create( + "switch", "another_integration", "squatter", suggested_object_id=original.removeprefix("switch.") + ) + assert squatter.entity_id == original + await _set_up(hass, entry, R202633) + + restored = registry.async_get_entity_id("switch", DOMAIN, unique_id) + assert restored is not None and restored != original diff --git a/tests/tls_panel.py b/tests/tls_panel.py new file mode 100644 index 00000000..e554c157 --- /dev/null +++ b/tests/tls_panel.py @@ -0,0 +1,222 @@ +"""A synthetic SPAN panel that serves a real certificate over a real handshake. + +Most of the suite stubs `async_leaf_chains_to_ca` and `build_panel_ssl_context`, +so nothing there ever verifies a certificate. The tests that exist to prove the +integration refuses the wrong anchor cannot use those stubs — the refusal *is* +the verification — so they need a listener with a genuine leaf on it. + +Everything here is synthetic: a throwaway CA and a self-signed panel leaf, +torn down with the test that raised them. This is the machinery only; each +suite wraps it in its own fixture, because a fixture imported into a test +module collides with every parameter that requests it. +""" + +from __future__ import annotations + +from collections.abc import Iterator +import contextlib +import datetime +import http.server +import json +import socket +import socketserver +import ssl +import threading +from typing import Any +from unittest.mock import patch + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +PANEL_FQDN = "panel.home.lan" +# A single-label name, as a search domain or an add-on's container hostname +# supplies. `is_fqdn` is False for it, so nothing in the flow ever registers it. +PANEL_SHORTNAME = "spanpanel" +PANEL_LOOPBACK = "127.0.0.1" +PANEL_SERIAL = "sp3-synthetic-0001" + +#: The names a config-flow test expects to reach the listener. +LOOPBACK_NAMES = frozenset({PANEL_FQDN, PANEL_SHORTNAME}) + + +@contextlib.contextmanager +def resolving_to_loopback(*names: str) -> Iterator[None]: + """Answer `names` with the test listener's address, and nothing else. + + A name arrives as `bytes` from anyio's resolver and as `str` from + `socket.create_connection`, so both spellings are matched. What is + replaced is already a patched resolver -- the Home Assistant test plugin + refuses real DNS -- so every other name keeps that refusal. + """ + real_getaddrinfo = socket.getaddrinfo + wanted = frozenset(names) + + def _fake(host: Any, port: Any, *args: Any, **kwargs: Any) -> Any: + name = host.decode("ascii") if isinstance(host, bytes) else host + if name in wanted: + return real_getaddrinfo(PANEL_LOOPBACK, port, *args, **kwargs) + return real_getaddrinfo(host, port, *args, **kwargs) + + with patch("socket.getaddrinfo", side_effect=_fake): + yield + + +# ---------- certificate generation ---------- + + +def issue_ca() -> tuple[x509.Certificate, rsa.RSAPrivateKey]: + """Issue the throwaway CA that stands in for the one a panel publishes.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "SPAN Panel Test CA")]) + now = datetime.datetime.now(datetime.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(key, hashes.SHA256()) + ) + return cert, key + + +def issue_leaf( + ca_cert: x509.Certificate, + ca_key: rsa.RSAPrivateKey, + names: list[x509.GeneralName], +) -> tuple[str, str]: + """Issue a server certificate naming exactly `names`, returning PEM cert and key.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = datetime.datetime.now(datetime.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "SPAN Panel")])) + .issuer_name(ca_cert.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension(x509.SubjectAlternativeName(names), critical=False) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .sign(ca_key, hashes.SHA256()) + ) + cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode() + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + return cert_pem, key_pem + + +def ca_pem_of(ca_cert: x509.Certificate) -> str: + """Return the PEM text of a certificate, as the panel's CA endpoint serves it.""" + return ca_cert.public_bytes(serialization.Encoding.PEM).decode() + + +def unrelated_ca_pem() -> str: + """Return a CA that signs nothing this panel serves. + + The proxy case, in one call: something on the path answers the plaintext CA + fetch with an authority of its own, and the certificate the panel actually + serves is not signed by it. + """ + ca_cert, _key = issue_ca() + return ca_pem_of(ca_cert) + + +# ---------- a listener that serves the panel's leaf ---------- + + +class PanelStatusHandler(http.server.BaseHTTPRequestHandler): + """Answer the one unauthenticated probe `detect_api_version` makes.""" + + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + """Serve the status document, whatever path was asked for.""" + body = json.dumps( + { + "serialNumber": PANEL_SERIAL, + "firmwareVersion": "2.0.0-synthetic", + "proximityProven": True, + } + ).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + """Keep the test output free of one line per handshake.""" + + +class PanelTLSListener(socketserver.ThreadingMixIn, http.server.HTTPServer): + """Serve HTTPS on loopback with a certificate the test can swap at will. + + Swappable because that is what the panel does: `register_fqdn` makes it + regenerate its leaf to add the FQDN, and the flow is supposed to notice. + """ + + daemon_threads = True + tls_context: ssl.SSLContext + + def get_request(self) -> tuple[socket.socket, Any]: + """Wrap each accepted connection in whatever certificate is current.""" + sock, addr = super().get_request() + return self.tls_context.wrap_socket(sock, server_side=True), addr + + def handle_error(self, request: Any, client_address: Any) -> None: + """Stay quiet: the leaf check hangs up right after the handshake.""" + + +def server_context(cert_pem: str, key_pem: str, tmp_path: Any) -> ssl.SSLContext: + """Build a server-side context from PEM text (`load_cert_chain` wants files).""" + cert_file = tmp_path / f"leaf-{abs(hash(cert_pem))}.pem" + key_file = tmp_path / f"key-{abs(hash(cert_pem))}.pem" + cert_file.write_text(cert_pem) + key_file.write_text(key_pem) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(str(cert_file), str(key_file)) + return ctx + + +class Panel: + """The synthetic panel: a CA, a leaf, and the port it serves TLS on.""" + + def __init__(self, tmp_path: Any) -> None: + """Mint this panel's authority; nothing is served until `present`.""" + self._tmp_path = tmp_path + self._ca_cert, self._ca_key = issue_ca() + self.ca_pem = ca_pem_of(self._ca_cert) + self._listener: PanelTLSListener | None = None + self.port = 0 + + def present(self, names: list[x509.GeneralName]) -> None: + """Serve a freshly issued leaf naming exactly `names`.""" + cert_pem, key_pem = issue_leaf(self._ca_cert, self._ca_key, names) + self.present_pem(cert_pem, key_pem) + + def present_pem(self, cert_pem: str, key_pem: str) -> None: + """Serve this certificate, whoever signed it.""" + context = server_context(cert_pem, key_pem, self._tmp_path) + if self._listener is None: + self._listener = PanelTLSListener((PANEL_LOOPBACK, 0), PanelStatusHandler) + self._listener.tls_context = context + self.port = self._listener.server_port + threading.Thread(target=self._listener.serve_forever, daemon=True).start() + else: + self._listener.tls_context = context + + def close(self) -> None: + """Stop serving and release the port.""" + if self._listener is not None: + self._listener.shutdown() + self._listener.server_close() + self._listener = None diff --git a/tests/yaml_factories.py b/tests/yaml_factories.py deleted file mode 100644 index e69de29b..00000000 diff --git a/uv.lock b/uv.lock index 195f6f51..ab4b65e4 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.14.3, <3.15" [[package]] name = "acme" -version = "5.4.0" +version = "5.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, @@ -13,21 +13,21 @@ dependencies = [ { name = "pyrfc3339" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/4f/813bc8c11a2b705e9c18d0e806aa8f069aa8faca58188500c781a793b364/acme-5.4.0.tar.gz", hash = "sha256:906e6cca7f58b5526c0ddfe3d71a7a41f8fa10acf3b083dd35cf619b5b015ca8", size = 90596, upload-time = "2026-03-10T19:06:17.383Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/53/2e79e7d1c5384414d4670f02729601cf23e61fe13b7b99471d02c620ddbe/acme-5.7.0.tar.gz", hash = "sha256:4be4fe6cf3809c3988d75fa1213ce8e784d9d5d8380cac63c2dca555e105653d", size = 90640, upload-time = "2026-07-15T17:40:26.01Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/f6/2a35be29ce639e7cc86c95fc67f98cd5fb93d51c6cd68aa8c07a89de40db/acme-5.4.0-py3-none-any.whl", hash = "sha256:e05c50f64958fe26475df21e3a18949993eff9d68a7e771f215c56b83e9e66cc", size = 94962, upload-time = "2026-03-10T19:05:49.192Z" }, + { url = "https://files.pythonhosted.org/packages/0b/af/b5f590040cd2efae675ff5c9da533abcf3011c6d0b7b67e118aa86c14ceb/acme-5.7.0-py3-none-any.whl", hash = "sha256:a736f88e975f0ebe8c6fcabd3db164d1071b4f9e1a56d1319a9f6997e0a64a0a", size = 95023, upload-time = "2026-07-15T17:40:07.404Z" }, ] [[package]] name = "aiodns" -version = "4.0.0" +version = "4.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycares" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/da/97235e953109936bfeda62c1f9f1a7c5652d4dc49f2b5911f9ae1043afa9/aiodns-4.0.0.tar.gz", hash = "sha256:17be26a936ba788c849ba5fd20e0ba69d8c46e6273e846eb5430eae2630ce5b1", size = 26204, upload-time = "2026-01-10T22:33:27.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/22/a2d928e0e42baad0471d12ec44c71152ac870486e8298dddb2893b888c29/aiodns-4.0.4.tar.gz", hash = "sha256:cb10e0c0d2591636716ad2fe402e977c16d71bdaf76bb8cb49e8a6633596f736", size = 29918, upload-time = "2026-05-20T01:54:15.557Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/60/14ac40c03e8a26216e4f2642497b776e52f9e3214e4fd537628829bbb082/aiodns-4.0.0-py3-none-any.whl", hash = "sha256:a188a75fb8b2b7862ac8f84811a231402fb74f5b4e6f10766dc8a4544b0cf989", size = 11334, upload-time = "2026-01-10T22:33:25.65Z" }, + { url = "https://files.pythonhosted.org/packages/7f/70/72e4ab117425ccdc4d10bd523a94c1baa051a15586057d64a4c6888f9e3f/aiodns-4.0.4-py3-none-any.whl", hash = "sha256:c24dd605bac70a1676ce503f967a98483ff163507198557d8e9db16267e6cfd2", size = 12696, upload-time = "2026-05-20T01:54:14.134Z" }, ] [[package]] @@ -54,21 +54,21 @@ wheels = [ [[package]] name = "aiohasupervisor" -version = "0.4.3" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "mashumaro" }, { name = "orjson" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/74/529860af5e305647c469e6ec2ac78f4d61b17349d201ab03a8073e683656/aiohasupervisor-0.4.3.tar.gz", hash = "sha256:8b6e56f385879616a6e520a092f57f41c8ba3bd3cd05b33a87e89b62604d14b8", size = 46583, upload-time = "2026-03-24T08:41:45.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/76/24e578dcf937b27017befb2fc2402565810a7716cdd43abbb8cefc2f425c/aiohasupervisor-0.6.0.tar.gz", hash = "sha256:debee1dc3b99114f5b653959cb828a74a13181011cae3ac013ae9aefb0cda062", size = 47453, upload-time = "2026-07-20T15:15:23.821Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/bf/0a61a4632930a19601c99044c2f293f98ee44c0b4a9907a7bc83e70307aa/aiohasupervisor-0.4.3-py3-none-any.whl", hash = "sha256:ae05a59e890c6ed83dd801e9ebb7e12ca67ac5aed7cef29315f5422fdf7022b5", size = 42070, upload-time = "2026-03-24T08:41:44.177Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0d/71721a72bf81c00039c147b5f2dba913213de526940f82c697fbeed65ee7/aiohasupervisor-0.6.0-py3-none-any.whl", hash = "sha256:6f78f474160e6f90fda1879f0fe7d341d8cfd5f69eec4cb0cb60483816f73d60", size = 42596, upload-time = "2026-07-20T15:15:22.604Z" }, ] [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -79,56 +79,63 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] name = "aiohttp-asyncmdnsresolver" -version = "0.1.1" +version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiodns" }, { name = "aiohttp" }, { name = "zeroconf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/83/09fb97705e7308f94197a09b486669696ea20f28074c14b5811a38bdedc3/aiohttp_asyncmdnsresolver-0.1.1.tar.gz", hash = "sha256:8c65d4b08b42c8a260717a2766bd5967a1d437cee852a9b21f3928b5171a7c81", size = 36129, upload-time = "2025-02-14T14:46:44.402Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/86/1b94f5669df188f5267e26d48c4cc734b704310e53d695af5f39ee1af67d/aiohttp_asyncmdnsresolver-0.2.0.tar.gz", hash = "sha256:aab2cef7ef8ae45b94abf1fc92aa9f4d70cbef7ac0a3d495a86fb96fce357262", size = 37744, upload-time = "2026-05-20T20:18:27.183Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/d1/4f61508a43de82bb5c60cede3bb89cc57c5e8af7978d93ca03ad60b99368/aiohttp_asyncmdnsresolver-0.1.1-py3-none-any.whl", hash = "sha256:d04ded993e9f0e07c07a1bc687cde447d9d32e05bcf55ecbf94f63b33dcab93e", size = 13582, upload-time = "2025-02-14T14:46:41.985Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d5/c08b4e242d50d2aded314a16105ee708946fa3dfd37b837b1ca625d26397/aiohttp_asyncmdnsresolver-0.2.0-py3-none-any.whl", hash = "sha256:1e6dc01719dc563346ae051122fa7dc3a757317e3e6bf4ef8b23cfd034089c22", size = 10806, upload-time = "2026-05-20T20:18:25.58Z" }, ] [[package]] @@ -235,6 +242,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" }, + { url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" }, + { url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" }, + { url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" }, + { url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" }, + { url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" }, + { url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" }, + { url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" }, + { url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" }, + { url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" }, + { url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" }, + { url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" }, + { url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" }, + { url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, +] + [[package]] name = "astral" version = "2.2" @@ -249,11 +319,11 @@ wheels = [ [[package]] name = "astroid" -version = "4.0.4" +version = "4.3.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/7e/7c85d2b8549730e089bd984678a7efb64c510a5f09b4f0d9987a8354a80c/astroid-4.3.3.tar.gz", hash = "sha256:d03854b09d92c08e18d8e7d9185d393961186ed0747136d8fcb2d1c008a504ec", size = 441522, upload-time = "2026-09-30T06:03:04.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, + { url = "https://files.pythonhosted.org/packages/23/be/54ffbdd679e2ea9f36f1923edd867c39fc1190b3810f111c8282a3f0b608/astroid-4.3.3-py3-none-any.whl", hash = "sha256:366c99c2907b6407869b1bbfd8abb10f3aea5818309e9c93c9b9c93ecf14bc4f", size = 287404, upload-time = "2026-09-30T06:03:02.038Z" }, ] [[package]] @@ -276,35 +346,51 @@ wheels = [ [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "audioop-lts" -version = "0.2.1" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/3b/69ff8a885e4c1c42014c2765275c4bd91fe7bc9847e9d8543dbcbb09f820/audioop_lts-0.2.1.tar.gz", hash = "sha256:e81268da0baa880431b68b1308ab7257eb33f356e57a5f9b1f915dfb13dd1387", size = 30204, upload-time = "2024-08-04T21:14:43.957Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/91/a219253cc6e92db2ebeaf5cf8197f71d995df6f6b16091d1f3ce62cb169d/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a", size = 46252, upload-time = "2024-08-04T21:13:56.209Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f6/3cb21e0accd9e112d27cee3b1477cd04dafe88675c54ad8b0d56226c1e0b/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e", size = 27183, upload-time = "2024-08-04T21:13:59.966Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7e/f94c8a6a8b2571694375b4cf94d3e5e0f529e8e6ba280fad4d8c70621f27/audioop_lts-0.2.1-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:4a8dd6a81770f6ecf019c4b6d659e000dc26571b273953cef7cd1d5ce2ff3ae6", size = 26726, upload-time = "2024-08-04T21:14:00.846Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f8/a0e8e7a033b03fae2b16bc5aa48100b461c4f3a8a38af56d5ad579924a3a/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cd3c0b6f2ca25c7d2b1c3adeecbe23e65689839ba73331ebc7d893fcda7ffe", size = 80718, upload-time = "2024-08-04T21:14:01.989Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ea/a98ebd4ed631c93b8b8f2368862cd8084d75c77a697248c24437c36a6f7e/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff3f97b3372c97782e9c6d3d7fdbe83bce8f70de719605bd7ee1839cd1ab360a", size = 88326, upload-time = "2024-08-04T21:14:03.509Z" }, - { url = "https://files.pythonhosted.org/packages/33/79/e97a9f9daac0982aa92db1199339bd393594d9a4196ad95ae088635a105f/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a351af79edefc2a1bd2234bfd8b339935f389209943043913a919df4b0f13300", size = 80539, upload-time = "2024-08-04T21:14:04.679Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d3/1051d80e6f2d6f4773f90c07e73743a1e19fcd31af58ff4e8ef0375d3a80/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aeb6f96f7f6da80354330470b9134d81b4cf544cdd1c549f2f45fe964d28059", size = 78577, upload-time = "2024-08-04T21:14:09.038Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/54f4c58bae8dc8c64a75071c7e98e105ddaca35449376fcb0180f6e3c9df/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c589f06407e8340e81962575fcffbba1e92671879a221186c3d4662de9fe804e", size = 82074, upload-time = "2024-08-04T21:14:09.99Z" }, - { url = "https://files.pythonhosted.org/packages/36/89/2e78daa7cebbea57e72c0e1927413be4db675548a537cfba6a19040d52fa/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fbae5d6925d7c26e712f0beda5ed69ebb40e14212c185d129b8dfbfcc335eb48", size = 84210, upload-time = "2024-08-04T21:14:11.468Z" }, - { url = "https://files.pythonhosted.org/packages/a5/57/3ff8a74df2ec2fa6d2ae06ac86e4a27d6412dbb7d0e0d41024222744c7e0/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_i686.whl", hash = "sha256:d2d5434717f33117f29b5691fbdf142d36573d751716249a288fbb96ba26a281", size = 85664, upload-time = "2024-08-04T21:14:12.394Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/21cc4e5878f6edbc8e54be4c108d7cb9cb6202313cfe98e4ece6064580dd/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f626a01c0a186b08f7ff61431c01c055961ee28769591efa8800beadd27a2959", size = 93255, upload-time = "2024-08-04T21:14:13.707Z" }, - { url = "https://files.pythonhosted.org/packages/3e/28/7f7418c362a899ac3b0bf13b1fde2d4ffccfdeb6a859abd26f2d142a1d58/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:05da64e73837f88ee5c6217d732d2584cf638003ac72df124740460531e95e47", size = 87760, upload-time = "2024-08-04T21:14:14.74Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d8/577a8be87dc7dd2ba568895045cee7d32e81d85a7e44a29000fe02c4d9d4/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:56b7a0a4dba8e353436f31a932f3045d108a67b5943b30f85a5563f4d8488d77", size = 84992, upload-time = "2024-08-04T21:14:19.155Z" }, - { url = "https://files.pythonhosted.org/packages/ef/9a/4699b0c4fcf89936d2bfb5425f55f1a8b86dff4237cfcc104946c9cd9858/audioop_lts-0.2.1-cp313-abi3-win32.whl", hash = "sha256:6e899eb8874dc2413b11926b5fb3857ec0ab55222840e38016a6ba2ea9b7d5e3", size = 26059, upload-time = "2024-08-04T21:14:20.438Z" }, - { url = "https://files.pythonhosted.org/packages/3a/1c/1f88e9c5dd4785a547ce5fd1eb83fff832c00cc0e15c04c1119b02582d06/audioop_lts-0.2.1-cp313-abi3-win_amd64.whl", hash = "sha256:64562c5c771fb0a8b6262829b9b4f37a7b886c01b4d3ecdbae1d629717db08b4", size = 30412, upload-time = "2024-08-04T21:14:21.342Z" }, - { url = "https://files.pythonhosted.org/packages/c4/e9/c123fd29d89a6402ad261516f848437472ccc602abb59bba522af45e281b/audioop_lts-0.2.1-cp313-abi3-win_arm64.whl", hash = "sha256:c45317debeb64002e980077642afbd977773a25fa3dfd7ed0c84dccfc1fafcb0", size = 23578, upload-time = "2024-08-04T21:14:22.193Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, ] [[package]] @@ -647,33 +733,41 @@ wheels = [ [[package]] name = "coverage" -version = "7.10.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" }, - { url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" }, - { url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" }, - { url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" }, - { url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" }, - { url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" }, - { url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" }, - { url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" }, - { url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" }, - { url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" }, - { url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" }, - { url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" }, - { url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" }, - { url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" }, - { url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" }, - { url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" }, +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, ] [[package]] @@ -686,55 +780,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "48.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, - { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, - { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, ] [[package]] @@ -763,6 +857,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] +[[package]] +name = "ebus-mqtt-client" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "paho-mqtt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/05/43c255aac2fe76e51642080315897306751fee1d4414fc6a099a1a5d9af5/ebus_mqtt_client-0.4.0.tar.gz", hash = "sha256:83ac9cfe4672fbbc1622d46ad7fe53d345654ca0dd85109c0894cb9fea8c73b1", size = 27690, upload-time = "2026-08-03T19:58:51.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/f3/e5549b9d340c958bf9ee8927b23ce724b7296a76b395ff0f5cbe55be1f77/ebus_mqtt_client-0.4.0-py3-none-any.whl", hash = "sha256:d64d6ac7f39f42791a59c932ce1cebefadb35accf88b1b3367258fa5fb7f54ff", size = 16625, upload-time = "2026-08-03T19:58:50.606Z" }, +] + +[[package]] +name = "ebus-sdk" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ebus-mqtt-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/45/044c4cd557850d7dc76e7ce664fedb5ce3ee4dbc2a58e97fb17e24991993/ebus_sdk-0.19.0.tar.gz", hash = "sha256:7987d3cae7c86e31656df9cd6e31e5a2ef950c757ba24f433adf019ca9aaa51c", size = 151155, upload-time = "2026-08-07T14:42:08.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/86/aad23b5bd10abb72c3d6bc659ffd67f6b19384aa9b5507050df707ee6cbb/ebus_sdk-0.19.0-py3-none-any.whl", hash = "sha256:33aeec8d61b88373b8d1902bb8338449644d0d5aa4d75835567ad76ffebefd10", size = 95231, upload-time = "2026-08-07T14:42:07.211Z" }, +] + [[package]] name = "envs" version = "1.4" @@ -783,14 +901,28 @@ wheels = [ [[package]] name = "fnv-hash-fast" -version = "2.0.0" +version = "2.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fnvhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/d3/a7af16d8e1d43d3c1d1cbde7901229773b62696e377f89c6834183680ede/fnv_hash_fast-2.0.0.tar.gz", hash = "sha256:e830b6316be36b2aa629f8a2c6b83833f1f3cc8ecf1da93c7e9e934844826646", size = 5718, upload-time = "2026-03-15T00:17:39.143Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/ef/7d94271729042f4905800f9034adb149f2a716af26f2cfe9c46343637322/fnv_hash_fast-2.0.3.tar.gz", hash = "sha256:57b499a80ea8f7daf901aff047377264ef21577b40575183807dba37bcc00d6f", size = 5765, upload-time = "2026-05-16T23:52:11.417Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/6e/03f61d06f3ed61b10d0a07ac921d077045052db6fdd6847107e4c3405727/fnv_hash_fast-2.0.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:ddf25f8f8b5cf54f068eaf5e3700c96f6c8da679e6efaf2084e57bdbad9d92ee", size = 7756, upload-time = "2026-03-15T00:17:37.392Z" }, + { url = "https://files.pythonhosted.org/packages/03/35/7c68c9f8d51e31200a1371b396b772da4d32f269e02cdff7cada22594656/fnv_hash_fast-2.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:381df4fd4891fb20483673d7ff1973395a5a81c4fa736a685ad05e74653c4dbf", size = 7457, upload-time = "2026-05-16T23:58:42.243Z" }, + { url = "https://files.pythonhosted.org/packages/bc/14/5b5cea95c30a51bb76b84661be9de3cc7625fb0edc3252b7509864d36f4d/fnv_hash_fast-2.0.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2f50c18835bdc7c92a62801778dd5fdc6593c603b4cbadd7adae9d5f17305a52", size = 8374, upload-time = "2026-05-16T23:58:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/00/c9/1d0a2299ddcbcc9168c3bdc99dcae08de1dae276705baddab323b912468a/fnv_hash_fast-2.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45838d0383438696d815b6de92c841215d4f9f39b8dbaf58071a1af84ea35fc", size = 9207, upload-time = "2026-05-16T23:58:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/18/8b/356df1dc62dd05cfdc3fec24e8e66101be1544f03a388a8043f7eb6d9577/fnv_hash_fast-2.0.3-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:cabf3cc36b0f5eb26eb19716842edf709b032a54e1d18a72981c21458c52c632", size = 7745, upload-time = "2026-05-16T23:52:09.972Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3c/c740792c230608204a4dac1ba2aaaec1615115778ba33836143a937c7996/fnv_hash_fast-2.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe4da9446b554365a24d026c0f08b6ee96e30f31b8db7eb683ea167c923755e6", size = 9117, upload-time = "2026-05-16T23:58:45.351Z" }, + { url = "https://files.pythonhosted.org/packages/f0/09/f859b56caab1e3b564d9ec05771f380f7965e39a1b8c2e3fdaa341655a26/fnv_hash_fast-2.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c89aa84df2bfe2cc280694a8cf9fca3955640ce5f5c6f30f6b1bb020b30f2175", size = 8609, upload-time = "2026-05-16T23:58:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/1e/fa/3f7aeae26885762d7ad20c77a643c8671f7ae63069dc70cddc77483eb5ee/fnv_hash_fast-2.0.3-cp314-cp314-win32.whl", hash = "sha256:f25e37526b2898677f0b6a99a441bf0a86eab9e354d5d9222c4960f49e3795d7", size = 9813, upload-time = "2026-05-16T23:58:47.436Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/c9ed5f7e54889103de1754f53d8d294510d7e7f04c1075ec5dcaeee1562a/fnv_hash_fast-2.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:7a45f7050c4e99e2f04122508ec4efb568c93fcbece8482371a177ac89e6db8c", size = 10052, upload-time = "2026-05-16T23:58:48.47Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a3/a54bdc9a843e3d2988c8c35224ae51cdcffd8e55d9031039ccbf51b8e2f6/fnv_hash_fast-2.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:642bc26dbc906f86ee7a888cb66c149455c1a2a841a7ea72f19ffb969d0f0b14", size = 9085, upload-time = "2026-05-16T23:58:49.54Z" }, + { url = "https://files.pythonhosted.org/packages/75/45/bbb6e0741d945569ae13e91d774b613e06fb184841e25bfc3f188b98888f/fnv_hash_fast-2.0.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d63dcae2fd15c307f8a333f77aa6a5146538e9e9ef606b1883c8b5f06d5f86e8", size = 10910, upload-time = "2026-05-16T23:58:50.572Z" }, + { url = "https://files.pythonhosted.org/packages/37/84/e95767cbba632270b6d7f14108c800bd9883b3596b939c52c457b671b779/fnv_hash_fast-2.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a852d5c3278153378c04dcf5e45514477beeccd4268ceb775a3cf553dc5102b0", size = 12580, upload-time = "2026-05-16T23:58:51.691Z" }, + { url = "https://files.pythonhosted.org/packages/ef/37/51d20e8444eb87a6396762d0bc160c6698963753e3cff6f765f76a58dbec/fnv_hash_fast-2.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a805cc43c45e3452ff4a4b76e77fb40b452c795bf04d0f6f76c06fb7b02f755", size = 12409, upload-time = "2026-05-16T23:58:52.827Z" }, + { url = "https://files.pythonhosted.org/packages/28/37/0d6a033e957cc2976cc453db5271976107168ddf0a49361f4b4187a1fc47/fnv_hash_fast-2.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd413f1b171188cbf8ea6bb10f1e8c3aed3a88d239a682870e3727021b246700", size = 11380, upload-time = "2026-05-16T23:58:53.86Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/962407e8d475f8b502c5df7f8f9bcc4d73386628b76cf836f6a739d9982a/fnv_hash_fast-2.0.3-cp314-cp314t-win32.whl", hash = "sha256:13f4284c5521df53b2fc54e2f595e8534fba03656ef3d92f099503980c09b15b", size = 14180, upload-time = "2026-05-16T23:58:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/84/19/e0b9e51d9f69efb6a6d155c2a5aa489f4366e34211ca2e843954c9053b44/fnv_hash_fast-2.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b4e44c9764bf0fa8387251bb806538976b0c641b89bfb6de3c8f242eb6bffdda", size = 14645, upload-time = "2026-05-16T23:58:56.033Z" }, ] [[package]] @@ -804,14 +936,14 @@ wheels = [ [[package]] name = "freezegun" -version = "1.5.2" +version = "1.5.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/75/0455fa5029507a2150da59db4f165fbc458ff8bb1c4f4d7e8037a14ad421/freezegun-1.5.2.tar.gz", hash = "sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181", size = 34855, upload-time = "2025-05-24T12:38:47.051Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/b2/68d4c9b6431121b6b6aa5e04a153cac41dcacc79600ed6e2e7c3382156f5/freezegun-1.5.2-py3-none-any.whl", hash = "sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b", size = 18715, upload-time = "2025-05-24T12:38:45.274Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, ] [[package]] @@ -855,6 +987,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, +] + [[package]] name = "grpcio" version = "1.78.0" @@ -923,7 +1078,7 @@ wheels = [ [[package]] name = "hass-nabucasa" -version = "2.2.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "acme" }, @@ -944,26 +1099,26 @@ dependencies = [ { name = "webrtc-models" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/40/53b290cecc71e7b12a4d8ce8b07c6a6c2da5c93ada8e19432cca0d6dde1f/hass_nabucasa-2.2.0.tar.gz", hash = "sha256:7bfaca35cf854197cdecfd2c1e41b263e3224e1abafbb58457552021bbbed6fc", size = 118895, upload-time = "2026-03-23T09:53:53.368Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/a4/e38702be5091a6a6a8bbffcb193e91ad86a3a47382c611ad42f743f32a5f/hass_nabucasa-2.7.0.tar.gz", hash = "sha256:fb741a2b6447250e5fc6ff07c09184e3afaf8b1d0028833e58b6a652b26965e3", size = 133280, upload-time = "2026-08-27T13:19:56.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/d2/997801ecfe37c620b5f980516b5b2be18b6e74f406e8e401273c777dc299/hass_nabucasa-2.2.0-py3-none-any.whl", hash = "sha256:366620278baed52e5fbf078d5262e84d401a938e14ca5c30d1aa280848ba865d", size = 90827, upload-time = "2026-03-23T09:53:51.865Z" }, + { url = "https://files.pythonhosted.org/packages/5d/04/810a2557a51f4c9563fdf07ab77ab6d5d2a61c3bcc0c12ff0aa4388b8f0b/hass_nabucasa-2.7.0-py3-none-any.whl", hash = "sha256:a07f0dba540e53204363c1ad762967c715ca7bcebc175fa2a50a4d8721c7ce43", size = 98072, upload-time = "2026-08-27T13:19:55.189Z" }, ] [[package]] name = "home-assistant-bluetooth" -version = "1.13.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "habluetooth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/0e/c05ee603cab1adb847a305bc8f1034cbdbc0a5d15169fcf68c0d6d21e33f/home_assistant_bluetooth-1.13.1.tar.gz", hash = "sha256:0ae0e2a8491cc762ee9e694b8bc7665f1e2b4618926f63969a23a2e3a48ce55e", size = 7607, upload-time = "2025-02-04T16:11:15.259Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/6f/914fd8086f502748907751766e26e3f7fe5204347a679703ee9954e55e4d/home_assistant_bluetooth-2.0.0.tar.gz", hash = "sha256:3febd16b194812b156024d9bc8ce8ec7622cdd8d159047cac504cc77ca034ea1", size = 7604, upload-time = "2025-07-09T18:01:47.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/9b/9904cec885cc32c45e8c22cd7e19d9c342e30074fdb7c58f3d5b33ea1adb/home_assistant_bluetooth-1.13.1-py3-none-any.whl", hash = "sha256:cdf13b5b45f7744165677831e309ee78fbaf0c2866c6b5931e14d1e4e7dae5d7", size = 7915, upload-time = "2025-02-04T16:11:13.163Z" }, + { url = "https://files.pythonhosted.org/packages/15/3e/2ed9e59da8fc26c43cbd9d6abbd27217138a31c608b1d334cc5e680e4069/home_assistant_bluetooth-2.0.0-py3-none-any.whl", hash = "sha256:8d7c2ac48c303adacad73cb6cdfe7710f2fccd2a367d4c3106acf301b0d22b91", size = 7907, upload-time = "2025-07-09T18:01:46.506Z" }, ] [[package]] name = "homeassistant" -version = "2026.4.4" +version = "2026.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiodns" }, @@ -995,6 +1150,7 @@ dependencies = [ { name = "orjson" }, { name = "packaging" }, { name = "pillow" }, + { name = "probatio" }, { name = "propcache" }, { name = "psutil-home-assistant" }, { name = "pyjwt" }, @@ -1010,28 +1166,13 @@ dependencies = [ { name = "ulid-transform" }, { name = "urllib3" }, { name = "uv" }, - { name = "voluptuous" }, - { name = "voluptuous-openapi" }, - { name = "voluptuous-serialize" }, { name = "webrtc-models" }, { name = "yarl" }, { name = "zeroconf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/3d/041a66485642537286c4b1a3ee66ace7ce43cfb61737e224cf01e6b59c97/homeassistant-2026.4.4.tar.gz", hash = "sha256:10f997fb7c00b2f8abbe30f343469428665b68d5a1e665feee2b827d9d815212", size = 32333699, upload-time = "2026-04-24T18:58:32.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/cb/120eb528c6eb5a21bc726aff1bc47898e5353a677ec18d33d65b11b8f3f8/homeassistant-2026.4.4-py3-none-any.whl", hash = "sha256:cd83240d320e9842822810b4c4aa13a132dbf7954d10576f406e66fca645467f", size = 53499095, upload-time = "2026-04-24T18:58:24.834Z" }, -] - -[[package]] -name = "homeassistant-stubs" -version = "2026.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "homeassistant" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/2cc8df737b883498964e1a96d00497b3dfb04de5aa0ef17b567973584837/homeassistant_stubs-2026.4.4.tar.gz", hash = "sha256:5bfd79ba36b87d594d403185fbf7885b81f5f6967cc3ea4d641e825a8b469b8f", size = 1310585, upload-time = "2026-04-25T03:36:20.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/f3/61da25fb8ea0d2d707f2c26f4b096638006c5d47b12d94fdb2f7fbfd6d10/homeassistant-2026.9.4.tar.gz", hash = "sha256:30a6fd7b63499c9959be9567c4b7dfb8c1731466de67ab26e5598abad1abbff7", size = 37796697, upload-time = "2026-09-27T10:05:19.882Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/d2/7bf64e4a1cd1fa6f188e5378cd72339de1b6424676000279561a1d3ae598/homeassistant_stubs-2026.4.4-py3-none-any.whl", hash = "sha256:53ecbe27c5428dc21506c111a9710d7f8c48ed8c112404f2c41d132f4896b88d", size = 3700990, upload-time = "2026-04-25T03:36:17.791Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/50f99aa1d5b88b6da5743656bb054c4ea26ee101486c6c04d8e9f8161b0d/homeassistant-2026.9.4-py3-none-any.whl", hash = "sha256:2a0ab1910af7b701cf1c8e5ed1d4b7caaa010f89e807c25d824a5805c91abb2c", size = 61559157, upload-time = "2026-09-27T10:05:13.359Z" }, ] [[package]] @@ -1142,36 +1283,41 @@ wheels = [ [[package]] name = "librt" -version = "0.8.1" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, ] [[package]] @@ -1188,9 +1334,36 @@ wheels = [ [[package]] name = "lru-dict" -version = "1.3.0" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/e3/42c87871920602a3c8300915bd0292f76eccc66c38f782397acbf8a62088/lru-dict-1.3.0.tar.gz", hash = "sha256:54fd1966d6bd1fcde781596cb86068214edeebff1db13a2cea11079e3fd07b6b", size = 13123, upload-time = "2023-11-06T01:40:12.951Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/0a/dec86efe38b350314c49a8d39ef01ba7cf8bbbef1d177646320eedea7159/lru_dict-1.4.1.tar.gz", hash = "sha256:cc518ff2d38cc7a8ab56f9a6ae557f91e2e1524b57ed8e598e97f45a2bd708fc", size = 13439, upload-time = "2025-11-02T10:02:13.548Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/02/8e04a8d744b466d4153502e2d92b453c2e5a549d49bf7fabfdca1621828a/lru_dict-1.4.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b770c7db258625e57b6ea8e2e0503ba0fbbdcde374baacf9adb256eb9c5adfa", size = 11119, upload-time = "2025-11-02T10:01:36.239Z" }, + { url = "https://files.pythonhosted.org/packages/50/f8/ee96f30127ff47c29966603f040e0485700fe0ca0e7d7b1ecbc9bf999eea/lru_dict-1.4.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:45d4dc338237cedcbacedab1afd9707b8f9867d8b601ec04e0395ec73f57405c", size = 11435, upload-time = "2025-11-02T10:01:36.857Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/897b33ba1974b6487848cafa5de7e93a7c4f5d9d3f43319ee010f6882830/lru_dict-1.4.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:5b31e9b6636f8945ad69c630c1891d810d62a91d99e792ef0b9ca865b6c26745", size = 10988, upload-time = "2025-11-02T10:01:37.531Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/b87d0f2bfcad0169afc00e23e014bad9af252206ec2cbc6079f12bece58e/lru_dict-1.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:f9335d46c83882a1b5deffed8098a2dd9ad66d2bd6263f416fc4c73f63e26904", size = 16733, upload-time = "2025-11-02T10:01:38.194Z" }, + { url = "https://files.pythonhosted.org/packages/ab/19/d2384266864b1e5b1cc20527ae468550d3b23a71636371b40e4663276294/lru_dict-1.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:17844b4f8dd996144d53380395d73832e2508159ad49ed4fbcb62f1787a5feaf", size = 11222, upload-time = "2025-11-02T10:01:39.093Z" }, + { url = "https://files.pythonhosted.org/packages/66/8a/94dec42ae6b5c8bdc53a86867924fa22634516434f129dca187ccc0853b8/lru_dict-1.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2b569c7813adb753b7b631097c34e6dbc194cb1814f22299c2d2a94894779877", size = 11733, upload-time = "2025-11-02T10:01:39.739Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/0b6de1db804cf094e201c5541d58e6a96359eb5beed048fa64d0589b6520/lru_dict-1.4.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:33cf1eb368d3989b8f00945937cfbfc2095d8ad2b1d2274ce1bde0af6f6d1e66", size = 32251, upload-time = "2025-11-02T10:01:40.421Z" }, + { url = "https://files.pythonhosted.org/packages/97/38/89d9425dde436b9bd894234171988289b259aeeab5965bd2c21d5104cb41/lru_dict-1.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22d5879ec5d5955f9dde105997bdf7ec9e0522bf99612a80b55b09f356a08368", size = 33405, upload-time = "2025-11-02T10:01:41.917Z" }, + { url = "https://files.pythonhosted.org/packages/0e/24/f1a189399ee107a64f955c9d6c84d3b0aee9b64b31fc5684b1eaeb3a6fc0/lru_dict-1.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2084363e4488aa5b4f8b26bd3cc148d70a15be92e3d347621a5b830b2b1e0a82", size = 35135, upload-time = "2025-11-02T10:01:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/f8/57/58e9dcf0853d639e2995e5d9f84649ff8d6792a04a418628672a130137f4/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8198ab8ad7cc81b86340243ddd5cca882ead87daed0c9fa6cce377a10a7f2e47", size = 32620, upload-time = "2025-11-02T10:01:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bb/664922f0cf076b1e3c2e43e8258582d507b07c19bd441a72dd5547a483e9/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1f4ae6967d5873e684ce8b986e2e43985d0a1be735b09584737ad5634ff48f3", size = 34077, upload-time = "2025-11-02T10:01:44.694Z" }, + { url = "https://files.pythonhosted.org/packages/58/38/b7a6fa85b150232cada26a50c89dc4bcf9acd6ada00e987b074c3b4e57f2/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a9bb130b5eaddd6453ca3dc38ce4a75f743512ad135b6f3994999dde0680bd79", size = 31905, upload-time = "2025-11-02T10:01:45.668Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/9c86393946d621f4aec852d543df4023241d85106e9e1e2a0e4057861f71/lru_dict-1.4.1-cp314-cp314-win32.whl", hash = "sha256:5534c69a52add5757714456d08ce3831d36b86c98972394ba900493bb0bd97f8", size = 13435, upload-time = "2025-11-02T10:01:46.397Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/b017cbeea55a8a1d18037840a8a9c9cdae29554e9985b55d4e8694305035/lru_dict-1.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:96fd677b6d912229f2d02ba61a5a1210176963c4770c1bb765b8da937cec3834", size = 14423, upload-time = "2025-11-02T10:01:47.473Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/13132af7a5155edde66979b53eb509465304e6e5a2b00769246448479c73/lru_dict-1.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6699bfebbf11dd9ff1387be7996fac6d1009fe6a6f48091ef6e069e6f19c7bce", size = 17184, upload-time = "2025-11-02T10:01:48.161Z" }, + { url = "https://files.pythonhosted.org/packages/ef/82/094985beb3e49461bf65a3c40df8de2018b8484e4ef129295090460ca5d9/lru_dict-1.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a276f8f6f43861c3f05986824741d00e3133a973c3396598375310129535382d", size = 11459, upload-time = "2025-11-02T10:01:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/14/29/836abc49f8c2b6c2efccd2ac2b2c0ad3e55b7d75a05a20cc061f17871e39/lru_dict-1.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:090c7b6a3d54fa7f3d69ba4802abe2f33c9583b16b33f52bcb521c701f7ea46c", size = 11938, upload-time = "2025-11-02T10:01:49.448Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/1bb4e8fbc0b753fea825564d9d96180813a71715d46a9b6bb30a6dea4ce0/lru_dict-1.4.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b21d06dec64fb1952385262d9fcefaec147921dc0b55210007091a79da440d93", size = 36389, upload-time = "2025-11-02T10:01:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/54/f2/7df3b6d0dbc66f3be9aa6261750967cdc5619c89a563420c52200d2dd547/lru_dict-1.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9613908a38cf8aa47f6c138ba031a8ac4ed38460299e84a2b07dba7b3b45aae", size = 38706, upload-time = "2025-11-02T10:01:51.197Z" }, + { url = "https://files.pythonhosted.org/packages/97/5f/e3ba3eeb9b864a09b92e24fbf179aef4ef48588e763a9d8d2bc10bd2c6f8/lru_dict-1.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7558302ce8bbfcd29f08e695e07bf7a0d799c2979636d6a6a0b4e207f840969f", size = 38892, upload-time = "2025-11-02T10:01:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e3/12e0888aab0bf3ab9ce35e9849f239994a0feff6fe49380859bf57124a17/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3910396142322fb2718546115bb2a56f50ebc9144b5140327053cca084e0d375", size = 37254, upload-time = "2025-11-02T10:01:52.587Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/06cd981718d039eb07a9c03263094aca6269721c470f765a292b24381a20/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f3f4fad5c4a9458954b275de6a6e31c67a26fbef7037c6a7354e22523a77db26", size = 37422, upload-time = "2025-11-02T10:01:53.271Z" }, + { url = "https://files.pythonhosted.org/packages/e8/82/ea88e618f39d78ff3c15b71f01a0b1a6c6ac2034ce5c6428ae41b1c30ea5/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85fc29363e2d3ba0a5f87b5e17f54b1078aea6d24c6dfc792725854b9d0f8d17", size = 35909, upload-time = "2025-11-02T10:01:54.356Z" }, + { url = "https://files.pythonhosted.org/packages/89/36/1dd91c602f623839cec24d6c77fa3fd1a8878bf2d716871197cd3bf084dc/lru_dict-1.4.1-cp314-cp314t-win32.whl", hash = "sha256:b3853518dfa50f28af0d6e2dcf8bb8b0a1687c5f4eb913c0b35b0da5c6d276ce", size = 13816, upload-time = "2025-11-02T10:01:55.417Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/113410f7b2e61e9d6f13f1f17c584dbd08b5796e65d772ecd5b063fab3af/lru_dict-1.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ff3af42922205620fdc920dcdf580c4c16b32c84a537a03b04b523e5c641a8a9", size = 15204, upload-time = "2025-11-02T10:01:56.06Z" }, +] [[package]] name = "mando" @@ -1329,31 +1502,31 @@ wheels = [ [[package]] name = "mypy" -version = "1.20.2" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, - { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, - { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, - { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, - { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, - { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, - { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, ] [[package]] @@ -1406,25 +1579,25 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.7" +version = "3.11.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, - { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, - { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, - { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, - { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, - { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, - { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, - { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, - { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, - { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, ] [[package]] @@ -1456,35 +1629,31 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, ] [[package]] @@ -1529,26 +1698,19 @@ wheels = [ [[package]] name = "prek" -version = "0.4.0" +version = "0.5.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/68/00050a4184f038a622855b1989b013d0ac5bfc0a29bf3cdbd1ed823595d8/prek-0.4.0.tar.gz", hash = "sha256:47f42477c8453c7440e4e656e5ab0c2a1e4c25daa5ed441a9ac1a2b7634abc12", size = 446399, upload-time = "2026-05-14T10:50:35.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/7b/11538b8e6cd7e269261feae62a2d08818fdcf8f43c3b1a253cec01a5488d/prek-0.5.5.tar.gz", hash = "sha256:16bf596768bb754779c7d42317e9ca16ac609c0cce7d13bae0829944ab40a26a", size = 570428, upload-time = "2026-10-05T06:08:59.165Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/83/e4f5f574b8b3a80305a638e0cb2d46e3aa9596ac2b1e2dd6c910fedea376/prek-0.4.0-py3-none-linux_armv6l.whl", hash = "sha256:f4cba2132e038349b4b0a00a73b300e4192bae9f78fff8df0365c00bd19140e7", size = 5509604, upload-time = "2026-05-14T10:50:39.306Z" }, - { url = "https://files.pythonhosted.org/packages/05/de/ee9b8648944d44a8403ca49172d60936f5476196a7baf38c86df4aec7243/prek-0.4.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:944670565dfb3800465355f299effa31572822566d10c6210e6f76bf399ddf53", size = 5877244, upload-time = "2026-05-14T10:50:54.213Z" }, - { url = "https://files.pythonhosted.org/packages/33/f8/2e6021993332a249667102de0960160aa942880b0634d3e8920d062ebdb4/prek-0.4.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a8272f32e698eae514556086ad73d02026ec00bbd4d26c420f761ea857cfd795", size = 5435063, upload-time = "2026-05-14T10:50:31.75Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8b/98fef34684f52c7f5b7b56bce14aec2b24b7abbcd7b6523cc83a63f74c58/prek-0.4.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a860fda0f27f872622689358a583e5f2a5771241331848233274f4cfeb8ae9bb", size = 5690323, upload-time = "2026-05-14T10:50:42.583Z" }, - { url = "https://files.pythonhosted.org/packages/00/18/bf18cf0d3ab5b5eddadc6ade754ba6269ae857b32a4605036397ec56ccc1/prek-0.4.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:473ce0262e36b8e77b327941ad6d5747ac451b4c441cdc86dab95640b68fff72", size = 5423877, upload-time = "2026-05-14T10:50:36.434Z" }, - { url = "https://files.pythonhosted.org/packages/ce/1e/881f18b3cbf6fdca4ec8eb62d2c2e0cb9458fd4cebd136f5e0f76c7aa8d2/prek-0.4.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e46277b577991ccdc7b13e2cfa7d260ae13e77f5af543e50e188799f649f0ad8", size = 5830461, upload-time = "2026-05-14T10:50:45.885Z" }, - { url = "https://files.pythonhosted.org/packages/91/ea/1623391bdf1e103d7959cc7d41caedcd1ab982412c7db9f9f9ee5c0e09f4/prek-0.4.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b60bd4fdc323896a5bada2708734d16ff59ee5e32b8c390ae2ebe328426964a3", size = 6717949, upload-time = "2026-05-14T10:50:51.133Z" }, - { url = "https://files.pythonhosted.org/packages/da/56/59d73f20d3bf5ec1d1e88f24473cf77fb485acefd285c1eb79d2f102e8dc/prek-0.4.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7203bff21e622a5482e956384c43990b0092faab02f72118275839a77a45e939", size = 6104294, upload-time = "2026-05-14T10:50:57.251Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7f/c03e17c06069b96805c3579c00f830bad0e700f30aa3fed08c78958be77a/prek-0.4.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:44e3716a3e2983add2c094775d564536342011e84f770132389819c965513d6d", size = 5703788, upload-time = "2026-05-14T10:50:55.717Z" }, - { url = "https://files.pythonhosted.org/packages/93/6f/f5664a1712ba214f67a0e93411dc62893a02165b5f04f3182e2c188d0623/prek-0.4.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6e3a0e43d7f345a5e32962736c335b5fc466d499f2556fb6f24028dd08108618", size = 5538311, upload-time = "2026-05-14T10:50:33.859Z" }, - { url = "https://files.pythonhosted.org/packages/d9/af/8a2aa0e02363e96d05a21ca73b4ea862459535bf6cf293710594396df450/prek-0.4.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0d19efff4537e4a68b5bd61dcbf5ccb2dd8343df3ea9482aabe37d241ff18ddf", size = 5398617, upload-time = "2026-05-14T10:50:37.828Z" }, - { url = "https://files.pythonhosted.org/packages/6e/06/2b175e2ef812a3ab6ebab2dcd7aa0d03aea24852b73c6c4a30882800a01c/prek-0.4.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:0d9483404e5b8bf65111ee6b821e6cf7a5ba9c3a2065e7a0b7a39a17daaffcc9", size = 5685656, upload-time = "2026-05-14T10:50:40.781Z" }, - { url = "https://files.pythonhosted.org/packages/39/00/aee25867a6e88bb5cadee41dd83745876a273d3c149612424cf068239a8e/prek-0.4.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:01d69684306c67917ed69497d0f523216f13f2306723b62e1ace454209477b45", size = 6217097, upload-time = "2026-05-14T10:50:52.903Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d2/e0cca68af94b7639badd3967fc37ae6159979baf6cc3c7f1d68f0a966bdf/prek-0.4.0-py3-none-win32.whl", hash = "sha256:ce2a8feb5dc1f1e748879d0e14c2145353059b830ac5e3f64d92127ab16efc78", size = 5204721, upload-time = "2026-05-14T10:50:49.269Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/462c907502e7b9f634c73de4e6d36fa44b5d652a52a9ddd51811b2030ca7/prek-0.4.0-py3-none-win_amd64.whl", hash = "sha256:b218d92ad5d2ff0b59240d7d837fa9edc61849894958acb3a225efff7a2faa65", size = 5595119, upload-time = "2026-05-14T10:50:47.482Z" }, - { url = "https://files.pythonhosted.org/packages/0d/a0/a0be2f73cbc8fd65a5e1de576aa5a5324339c7cc9f7dd328009a5e1a3ae1/prek-0.4.0-py3-none-win_arm64.whl", hash = "sha256:ff1f1fa311023418d40a443bca6928a9f5ce073d0b9130b641954183aa31736d", size = 5423774, upload-time = "2026-05-14T10:50:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/2e/77/189e554ea92baf6c1d33733b3e9d1ed819ab7d77e57eb2e3ead0bb1d8060/prek-0.5.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:50fbceb0f728b504023f6e6c9037ac0656436949d94f7a85bac5542e6c1a4a75", size = 6038669, upload-time = "2026-10-05T06:08:39.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/ea/fc857c6b3f1e3fdb4d81bdca2051076de82e986cb1455a01b351ac7a5cd1/prek-0.5.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2f610927b95e84b28173a4c810892a7b5d5bb55bee792ddf97178fa97654633e", size = 5548955, upload-time = "2026-10-05T06:08:41.428Z" }, + { url = "https://files.pythonhosted.org/packages/47/c3/2bab54f6393fb9f247225681d476086e24fa6242f329e9ca1e5bd638ec50/prek-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:120ef7d4b6bb58155e74827743c266e48501efb0e4c37ff726b14fe6e86a126b", size = 5848228, upload-time = "2026-10-05T06:08:44.051Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f9/1279e1c83819de5904db1a09f4afc589932a2e62c7063713b1fe3a54ee46/prek-0.5.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f6c453a12c21757b23bbbc97415aef78527841ad3f9e61f52cb35cf012f93e5", size = 6371296, upload-time = "2026-10-05T06:08:46.278Z" }, + { url = "https://files.pythonhosted.org/packages/93/fc/e566be5919eaffef4cc22488f16e4bbfd08961734bd67c06f85d405e4cbc/prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73d509c0d9556171de2502bada4962be7a25871e7b23e782549074e1416dc850", size = 6269922, upload-time = "2026-10-05T06:08:48.645Z" }, + { url = "https://files.pythonhosted.org/packages/d2/39/a48d8e6203055bff7bb734b1f2f9aede4a4539bee6ab4c9273dbc01fa546/prek-0.5.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:76c867fdbd4e4f19e6b6b2988bd1e1407d1324170ebcf1428e4e34f8f0751a99", size = 5861518, upload-time = "2026-10-05T06:08:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/b7/03/690fba9543033e25ea2e7b2ae1cca1ae8d8c2e32295f0d66f4b50934b8c7/prek-0.5.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:c69f60c2b3b4d481e38d0eab3b97ac05ec05d7648ae690925ae95ca9d22c7643", size = 6383685, upload-time = "2026-10-05T06:08:52.868Z" }, + { url = "https://files.pythonhosted.org/packages/8c/04/1e9ccf3a61a3bfd4107b83973f7afde9177b6ff8f2d5d4b00125e77c2100/prek-0.5.5-py3-none-win_amd64.whl", hash = "sha256:15bff9039349466ea9bf6b0c173b822ff08f8ae4cfbfdea782492e519c06d874", size = 5784759, upload-time = "2026-10-05T06:08:55.056Z" }, + { url = "https://files.pythonhosted.org/packages/41/d9/bb6811e438d3c74d26cb5d72e6343606e0a7a6283fee87521189f4ba1191/prek-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3db3191d2509830c75438e20956a47c2259867d18a4106064ed75088b968a0c6", size = 5517355, upload-time = "2026-10-05T06:08:57.193Z" }, ] [[package]] @@ -1561,42 +1723,55 @@ wheels = [ ] [[package]] -name = "propcache" -version = "0.4.1" +name = "probatio" +version = "0.11.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ae/69/ba688575e1fb405bb552528b5da73a51061c19b2a8a8adcf29358997ef36/probatio-0.11.4.tar.gz", hash = "sha256:7c407497ff22dd6133321fa3315ed394f564a83d642c1498848993cd87093be3", size = 368768, upload-time = "2026-08-29T16:31:45.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/1c/728ee57e0ae8d7fce6c7afb5b5d2ca43d38098c2b5e756c251c1a79c0574/probatio-0.11.4-py3-none-any.whl", hash = "sha256:39edf5370c2ebe4b06bc0d78b53c97fe23c05d5266e599cf40660ce90d88b270", size = 222133, upload-time = "2026-08-29T16:31:42.686Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] [[package]] @@ -1694,7 +1869,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.2" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1702,39 +1877,66 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/35/d319ed522433215526689bad428a94058b6dd12190ce7ddd78618ac14b28/pydantic-2.12.2.tar.gz", hash = "sha256:7b8fa15b831a4bbde9d5b84028641ac3080a4ca2cbd4a621a661687e741624fd", size = 816358, upload-time = "2025-10-14T15:02:21.842Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/98/468cb649f208a6f1279448e6e5247b37ae79cf5e4041186f1e2ef3d16345/pydantic-2.12.2-py3-none-any.whl", hash = "sha256:25ff718ee909acd82f1ff9b1a4acfd781bb23ab3739adaa7144f19a6a4e231ae", size = 460628, upload-time = "2025-10-14T15:02:19.623Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.4" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, - { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, - { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, - { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, - { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, - { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, - { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, - { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, - { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, - { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, - { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pygithub" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/c3/8465a311197e16cf5ab68789fe689535e90f6b61ab524cc32a39e67237ae/pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c", size = 2594989, upload-time = "2026-04-14T07:26:13.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/aa/81a5506f089a26338bff17535e4339b3b22049ebd1bcdeff756c4d7a7559/pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9", size = 449710, upload-time = "2026-04-14T07:26:12.382Z" }, ] [[package]] @@ -1748,11 +1950,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -1762,7 +1964,7 @@ crypto = [ [[package]] name = "pylint" -version = "4.0.5" +version = "4.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "astroid" }, @@ -1773,18 +1975,9 @@ dependencies = [ { name = "platformdirs" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, -] - -[[package]] -name = "pylint-per-file-ignores" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/3d/21bec2f2f432519616c34a64ba0766ef972fdfb6234a86bb1b8baf4b0c7c/pylint_per_file_ignores-1.4.0.tar.gz", hash = "sha256:c0de7b3d0169571aefaa1ac3a82a265641b8825b54a0b6f5ef27c3b76b988609", size = 4419, upload-time = "2025-01-17T21:35:02.383Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/3c/be4bb2d62e3d1bee3f3aa14af5b294813ad53351cacb3b03fbaf3f851e03/pylint-4.1.2.tar.gz", hash = "sha256:235f13dc418c0041c649b42a5c35c99f2ffc6ca8b6a7574958eac5335906a68a", size = 1739885, upload-time = "2026-10-02T22:49:42.64Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/0e/bf3473d86648a17e6dd6ee9e6abce526b077169031177f4f2031368f864a/pylint_per_file_ignores-1.4.0-py3-none-any.whl", hash = "sha256:0cd82d22551738b4e63a0aa1dab2a1fc4016e8f27f1429159616483711e122fd", size = 4888, upload-time = "2025-01-17T21:35:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/90/61/b76698429225f848921f4d620f5c7b16f616daf53f99cc25afd1c86496fc/pylint-4.1.2-py3-none-any.whl", hash = "sha256:e34c8636edc7d60a2eaadca4ad6c18a49e202361987a26e81b2a17fa92be28e1", size = 585512, upload-time = "2026-10-02T22:49:40.451Z" }, ] [[package]] @@ -1875,14 +2068,14 @@ wheels = [ [[package]] name = "pyopenssl" -version = "26.0.0" +version = "26.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, ] [[package]] @@ -1902,20 +2095,20 @@ sdist = { url = "https://files.pythonhosted.org/packages/08/64/a99f27d3b4347486c [[package]] name = "pyright" -version = "1.1.409" +version = "1.1.414" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, ] [[package]] name = "pytest" -version = "9.0.0" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1924,49 +2117,49 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764, upload-time = "2025-11-08T17:25:33.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364, upload-time = "2025-11-08T17:25:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "pytest-aiohttp" -version = "1.1.0" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "pytest" }, { name = "pytest-asyncio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/4d/c6621fc79022f6c84a806e23d9b7eca24fae4f3ee779219bbe524339d666/pytest_aiohttp-1.1.1.tar.gz", hash = "sha256:3aa9c9fe26e543eaccc7eb0add381c685ba3ed3e2fed0af74540f63bcd31458d", size = 13704, upload-time = "2026-06-07T23:56:34.173Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b0/5056ed4c3f68a4db2b4a39fb0ec61b1e4cf1d89ee14effe5261cc587264c/pytest_aiohttp-1.1.1-py3-none-any.whl", hash = "sha256:f293441ad4f8446a1e12257130c26c7de03a615c2a5572a8cb046e5b3b4e5211", size = 9007, upload-time = "2026-06-07T23:56:33.333Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -1984,19 +2177,19 @@ wheels = [ [[package]] name = "pytest-github-actions-annotate-failures" -version = "0.3.0" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/d4/c54ee6a871eee4a7468e3a8c0dead28e634c0bc2110c694309dcb7563a66/pytest_github_actions_annotate_failures-0.3.0.tar.gz", hash = "sha256:d4c3177c98046c3900a7f8ddebb22ea54b9f6822201b5d3ab8fcdea51e010db7", size = 11248, upload-time = "2025-01-17T22:39:32.722Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/a0/bdb91581b03c41016c78e16b8ec36c34d8508206fcb30f1951c9cdff2e97/pytest_github_actions_annotate_failures-0.4.2.tar.gz", hash = "sha256:5dd18304512361788bc7b5c5c805db853f03f4950c6be09b088de6bab8e2e6c9", size = 12158, upload-time = "2026-06-19T15:59:17.445Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/73/7b0b15cb8605ee967b34aa1d949737ab664f94e6b0f1534e8339d9e64ab2/pytest_github_actions_annotate_failures-0.3.0-py3-none-any.whl", hash = "sha256:41ea558ba10c332c0bfc053daeee0c85187507b2034e990f21e4f7e5fef044cf", size = 6030, upload-time = "2025-01-17T22:39:31.701Z" }, + { url = "https://files.pythonhosted.org/packages/3c/09/c44e658f3a27c588c2017d858c8b0fa962612af9b74326beabbf010c839c/pytest_github_actions_annotate_failures-0.4.2-py3-none-any.whl", hash = "sha256:02911cd3b55f235328a334f8ca6037a89944398b8e8b028c82de97111eff4071", size = 6151, upload-time = "2026-06-19T15:59:16.486Z" }, ] [[package]] name = "pytest-homeassistant-custom-component" -version = "0.13.325" +version = "0.13.367" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohasupervisor" }, @@ -2009,7 +2202,7 @@ dependencies = [ { name = "paho-mqtt" }, { name = "pipdeptree" }, { name = "pydantic" }, - { name = "pylint-per-file-ignores" }, + { name = "pygithub" }, { name = "pytest" }, { name = "pytest-aiohttp" }, { name = "pytest-asyncio" }, @@ -2021,15 +2214,17 @@ dependencies = [ { name = "pytest-timeout" }, { name = "pytest-unordered" }, { name = "pytest-xdist" }, + { name = "requests" }, { name = "requests-mock" }, { name = "respx" }, { name = "sqlalchemy" }, { name = "syrupy" }, { name = "tqdm" }, + { name = "unidiff" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/40/e14846938decc2073b78d2c88a08444973aae82b7fb263ba0073aca89793/pytest_homeassistant_custom_component-0.13.325.tar.gz", hash = "sha256:12924ad407b6601748d3a3c7883423fd0d34b1f782f14b615d1b61d994f371fc", size = 69950, upload-time = "2026-04-25T05:37:23.337Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/50/99ef9de1b1caab015377175b6ee9cd5a8d410403d0e23e98558a7f74de70/pytest_homeassistant_custom_component-0.13.367.tar.gz", hash = "sha256:f807c86373ca7adbef77245c6518447fbf83d766ae1e675f240a3e824515b148", size = 78538, upload-time = "2026-09-27T10:21:08.576Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/c7/51cac710f1b6679550d04e162899dbdf4e7289ba715e5c4a9bff6bec7cfa/pytest_homeassistant_custom_component-0.13.325-py3-none-any.whl", hash = "sha256:9b758a092f74722e060d252ac80d71b6ae1ec2a712d55d0c9d378266e74e2c05", size = 75792, upload-time = "2026-04-25T05:37:21.529Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f2/70d6c7fe1439431cb199de24449594bab9143c700e41010835da46afe352/pytest_homeassistant_custom_component-0.13.367-py3-none-any.whl", hash = "sha256:9697d8b36a4d2a27605a1269ec34df0002173fb0c2c215d46b540e4d2515bacd", size = 84452, upload-time = "2026-09-27T10:21:07.081Z" }, ] [[package]] @@ -2046,14 +2241,14 @@ wheels = [ [[package]] name = "pytest-socket" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/ff/90c7e1e746baf3d62ce864c479fd53410b534818b9437413903596f81580/pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3", size = 12389, upload-time = "2024-01-28T20:17:23.177Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/3c/f9b58e57830e58980dbe8867d0e348f45701d3f3ea065672d448f4366da5/pytest_socket-0.8.0.tar.gz", hash = "sha256:af9bb5f487da72be63573a6194cfac033b6c7a1c1561e150521105970f9e99f2", size = 13912, upload-time = "2026-05-21T16:50:22.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754, upload-time = "2024-01-28T20:17:22.105Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e8/4a8568580bae3dcd678599ed8e86a82d505a44df71c1ced4246c1aa14b4b/pytest_socket-0.8.0-py3-none-any.whl", hash = "sha256:81821ba59f07d7600fe2b551d8714f40b068bd46e8b6704c48664e9d60cdacb8", size = 8414, upload-time = "2026-05-21T16:50:21.022Z" }, ] [[package]] @@ -2070,14 +2265,14 @@ wheels = [ [[package]] name = "pytest-unordered" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/3e/6ec9ec74551804c9e005d5b3cbe1fd663f03ed3bd4bdb1ce764c3d334d8e/pytest_unordered-0.7.0.tar.gz", hash = "sha256:0f953a438db00a9f6f99a0f4727f2d75e72dd93319b3d548a97ec9db4903a44f", size = 7930, upload-time = "2025-06-03T12:56:04.289Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0c/ab409d508c92ea6737875cfc39a696a0e0bd44359915e1376831dfb5a456/pytest_unordered-0.8.0.tar.gz", hash = "sha256:3c369ed86919d3eb35e11fd27bb679c1e3506ead9327e25aa8a07307256be65a", size = 7978, upload-time = "2026-06-16T02:48:53.209Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/95/ae2875e19472797e9672b65412858ab6639d8e55defd9859241e5ff80d02/pytest_unordered-0.7.0-py3-none-any.whl", hash = "sha256:486b26d24a2d3b879a275c3d16d14eda1bd9c32aafddbb17b98ac755daba7584", size = 6210, upload-time = "2025-06-03T12:36:06.66Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/2f63db7b6292856f51a76e3eb0acb5e53fd63ae6b6ec4dd11af24985f5b0/pytest_unordered-0.8.0-py3-none-any.whl", hash = "sha256:c792b879032b33141205e1a38a80234eb0117474bf9459ae65557e29837c2a1c", size = 6280, upload-time = "2026-06-16T02:48:52.399Z" }, ] [[package]] @@ -2216,7 +2411,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -2224,9 +2419,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -2243,14 +2438,14 @@ wheels = [ [[package]] name = "respx" -version = "0.22.0" +version = "0.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, ] [[package]] @@ -2268,27 +2463,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, ] [[package]] @@ -2305,15 +2500,15 @@ wheels = [ [[package]] name = "securetar" -version = "2026.4.0" +version = "2026.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pynacl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/25/138e19209d8f04765e6e51f94997d7e1141448103bae8ba30af98a755595/securetar-2026.4.0.tar.gz", hash = "sha256:d6e8d8c747655ac4564543f35f7fc70a2ebc7426f23c77f16238e7cb763691e8", size = 28254, upload-time = "2026-04-07T13:25:20.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/54/c7084733ce23f33c5f863734d6fd0c1789ba9ec771257cc54529c7a999ac/securetar-2026.4.1.tar.gz", hash = "sha256:536108c7cec0bdd6fb51abd8d293deba02930124d76ec5dd4599ae5e0c755638", size = 28289, upload-time = "2026-04-07T17:02:07.231Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/16/47a417d1264ad238191185ddd1a1d6585c6f33462e60408be09aee74147b/securetar-2026.4.0-py3-none-any.whl", hash = "sha256:72e09a864282ec96b05b5a61cc4febca7a1a7978b4083687369b6eb4835abe27", size = 19176, upload-time = "2026-04-07T13:25:19.803Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/4ef84868d1ff2f77ac080929117283ac9d18f7d018ae64fa1e7f957a8d20/securetar-2026.4.1-py3-none-any.whl", hash = "sha256:7cf66c204d678b9d1e2d17814c83048a6468e928ea00edb7e6c1efe72aab0540", size = 19339, upload-time = "2026-04-07T17:02:05.635Z" }, ] [[package]] @@ -2339,30 +2534,31 @@ wheels = [ [[package]] name = "snitun" -version = "0.45.1" +version = "0.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/e2/b5bbf04971d1c3e07a3e16a706ea3c1a4b711c6d8c9566e8012772d3351a/snitun-0.45.1.tar.gz", hash = "sha256:d76d48cf4190ea59e8f63892da9c18499bfc6ca796220a463c6f3b32099d661c", size = 43335, upload-time = "2025-09-25T05:24:07.89Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/8c/1ad04f715ee3cb75d4d1236f4f1b8ac63ee245959847220aa484445d7301/snitun-0.47.0.tar.gz", hash = "sha256:264975472011b7a211e44880c614d5534f6fe953c125e0cec365bae3a169c578", size = 64346, upload-time = "2026-06-29T08:12:49.395Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/1b/83ff83003994bc8b56483c75a710de588896c167c7c42d66d059a2eb48dc/snitun-0.45.1-py3-none-any.whl", hash = "sha256:c1fa4536320ec3126926ade775c429e20664db1bc61d8fec0e181dc393d36ab4", size = 51236, upload-time = "2025-09-25T05:24:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/bf/69/c7ba5437d10ae7a6446169a1528c50ec38936455fed23480dde36902a6fe/snitun-0.47.0-py3-none-any.whl", hash = "sha256:57eb4710f13a03360c07527c8e2d442b8c90e8ff7b94c71228cde5da8dbb755a", size = 72315, upload-time = "2026-06-29T08:12:48.231Z" }, ] [[package]] name = "span" -version = "2.0.7" +version = "2.1.2" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, { name = "span-panel-api" }, + { name = "span-panel-api-schema-0" }, + { name = "span-panel-api-schema-1" }, ] [package.dev-dependencies] dev = [ { name = "bandit" }, - { name = "homeassistant-stubs" }, { name = "isort" }, { name = "mypy" }, { name = "prek" }, @@ -2381,34 +2577,35 @@ dev = [ [package.metadata] requires-dist = [ - { name = "homeassistant", specifier = "==2026.4.4" }, - { name = "span-panel-api", editable = "../span-panel-api" }, + { name = "homeassistant", specifier = "==2026.9.4" }, + { name = "span-panel-api", editable = "../../span/span-panel-api" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api/packages/schema-1" }, ] [package.metadata.requires-dev] dev = [ { name = "bandit", extras = ["toml"], specifier = "==1.9.4" }, - { name = "homeassistant-stubs", specifier = "==2026.4.4" }, { name = "isort" }, - { name = "mypy", specifier = "==1.20.2" }, - { name = "prek", specifier = ">=0.3.11" }, + { name = "mypy", specifier = "==2.4.0" }, + { name = "prek", specifier = ">=0.5.4" }, { name = "prettier" }, - { name = "pylint", specifier = "==4.0.5" }, - { name = "pyright", specifier = "==1.1.409" }, - { name = "pytest-homeassistant-custom-component", specifier = ">=0.13.325" }, + { name = "pylint", specifier = "==4.1.2" }, + { name = "pyright", specifier = "==1.1.414" }, + { name = "pytest-homeassistant-custom-component", specifier = "==0.13.367" }, { name = "python-direnv" }, { name = "radon", specifier = "==6.0.1" }, - { name = "ruff", specifier = "==0.15.12" }, + { name = "ruff", specifier = "==0.16.10" }, { name = "types-pyyaml" }, { name = "types-requests" }, { name = "voluptuous-stubs" }, - { name = "vulture", specifier = ">=2.14" }, + { name = "vulture", specifier = ">=2.16" }, ] [[package]] name = "span-panel-api" -version = "2.6.4" -source = { editable = "../span-panel-api" } +version = "3.6.2" +source = { editable = "../../span/span-panel-api" } dependencies = [ { name = "httpx" }, { name = "paho-mqtt" }, @@ -2417,16 +2614,21 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "httpx", specifier = ">=0.28.1" }, + { name = "httpx", specifier = ">=0.28.1,<1.0" }, { name = "paho-mqtt", specifier = ">=2.0.0,<3.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "../../span/span-panel-api/packages/schema-0" }, + { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "../../span/span-panel-api/packages/schema-1" }, ] +provides-extras = ["schema-0", "schema-1"] [package.metadata.requires-dev] dev = [ { name = "bandit", specifier = ">=1.9.4" }, { name = "black" }, { name = "coverage" }, + { name = "cryptography", specifier = ">=50.0.0" }, + { name = "ebus-panel-sim", specifier = "==0.8.0" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pylint" }, @@ -2435,21 +2637,58 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff", specifier = ">=0.15.5" }, - { name = "twine" }, + { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api/packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api/packages/schema-1" }, + { name = "twine", specifier = ">=7.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, ] +[[package]] +name = "span-panel-api-schema-0" +version = "1.2.0" +source = { editable = "../../span/span-panel-api/packages/schema-0" } +dependencies = [ + { name = "span-panel-api" }, +] + +[package.metadata] +requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api" }] + +[[package]] +name = "span-panel-api-schema-1" +version = "1.2.2" +source = { editable = "../../span/span-panel-api/packages/schema-1" } +dependencies = [ + { name = "ebus-sdk" }, + { name = "span-panel-api" }, +] + +[package.metadata] +requires-dist = [ + { name = "ebus-sdk", specifier = ">=0.19.0,<0.24" }, + { name = "span-panel-api", editable = "../../span/span-panel-api" }, +] + [[package]] name = "sqlalchemy" -version = "2.0.41" +version = "2.0.52" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, + { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, ] [[package]] @@ -2494,14 +2733,14 @@ wheels = [ [[package]] name = "syrupy" -version = "5.0.0" +version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/90/1a442d21527009d4b40f37fe50b606ebb68a6407142c2b5cc508c34b696b/syrupy-5.0.0.tar.gz", hash = "sha256:3282fe963fa5d4d3e47231b16d1d4d0f4523705e8199eeb99a22a1bc9f5942f2", size = 48881, upload-time = "2025-09-28T21:15:12.783Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/a7/3c797c76994dc817582abdda90ee31867a9883567b0add47dbc0ccb12ce4/syrupy-6.0.0.tar.gz", hash = "sha256:bf4f5f662a7b23a9e5711bd5597e301cd09c00f31be5fa4095f79da7b0585f3d", size = 99435, upload-time = "2026-08-22T20:48:39.493Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/9a/6c68aad2ccfce6e2eeebbf5bb709d0240592eb51ff142ec4c8fbf3c2460a/syrupy-5.0.0-py3-none-any.whl", hash = "sha256:c848e1a980ca52a28715cd2d2b4d434db424699c05653bd1158fb31cf56e9546", size = 49087, upload-time = "2025-09-28T21:15:11.639Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ff/4d8ae6d6070ea73ee9d466701584a75db9586ce09de98f8f1b3276efad5f/syrupy-6.0.0-py3-none-any.whl", hash = "sha256:3efa16d767bd8614413cc20057552e4aec14428c6e5ea788c1f7e03047e8a5e4", size = 58047, upload-time = "2026-08-22T20:48:38.213Z" }, ] [[package]] @@ -2557,11 +2796,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -2596,29 +2835,38 @@ wheels = [ [[package]] name = "ulid-transform" -version = "2.2.0" +version = "2.2.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/c2/8548ec850a03fdfbc84ba4728bed5d3b5570ec913502954b40830e84f5aa/ulid_transform-2.2.0.tar.gz", hash = "sha256:cef07d688483e7573b835423f242d0d6700d92429abc438c0941de61eacdf215", size = 15159, upload-time = "2026-03-18T18:18:14.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/47/f2d1b8c04a5b7276198ff4f0dadfea463fd6f73aa4e1ddc3589aff073ca3/ulid_transform-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae98a8510ea6498c67d7810d9030c36dfd3e56d79232144fdd9e9430db8827a9", size = 23114, upload-time = "2026-03-18T18:25:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/91/d3/479dddda0e7446a1f444c5df72941dd88611dcfc8fb3f8a1056d54be68fd/ulid_transform-2.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d44baef447136f0d3216dc63066f4999c4e81fe10d08620300685d59155c9a17", size = 25359, upload-time = "2026-03-18T18:25:17.719Z" }, - { url = "https://files.pythonhosted.org/packages/63/e0/3ffd17d17d78aceb903d3a11fcc494b97f0e313e3692b7bdac43fcb18972/ulid_transform-2.2.0-cp314-cp314-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dc6b2c01b6922f8c7c75d6504d428d66d0caabbcafbb4c5083d42aec23bf3a93", size = 25745, upload-time = "2026-03-18T18:25:19.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/1c/60c4cb168710bc51f896d532a37e6d2c291d2eba7e5997ffaf532abfdf2a/ulid_transform-2.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4e96f23690491397218ecff013d44efa5b868ca393c497c83cd1d6844d29e14", size = 24344, upload-time = "2026-03-18T18:25:20.65Z" }, - { url = "https://files.pythonhosted.org/packages/2a/7a/8b6b7216aca6102fbede41249b6dbd203d17eccaef221e36440307948bb9/ulid_transform-2.2.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:0faf6937d2a36392af323292819aeaca3ce12161ba414a265fd1f5ea13f5efef", size = 15101, upload-time = "2026-03-18T18:18:12.987Z" }, - { url = "https://files.pythonhosted.org/packages/f0/27/54063f2df35713bc0f5e70dad04ff609e788ef8109583a6666e08a48149d/ulid_transform-2.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41d05fd5191c01a751d222586a88945806573b036a4a31b14952834c3e085654", size = 1005342, upload-time = "2026-03-18T18:25:22.02Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2b/6909e1f875eb88612718eaad0259831d1d17908c38047e710d451b0a52b7/ulid_transform-2.2.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:e01a2168b702277c78b313bd126c34165ba8f6b4def3db389de3a125d1348521", size = 875203, upload-time = "2026-03-18T18:25:23.461Z" }, - { url = "https://files.pythonhosted.org/packages/9f/60/a1217d58e8591f6d76d6ef05d8296abc734acf61e1a8aef3ae09a2d1f933/ulid_transform-2.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8779b38f6a0c788087990c4c54ae9034beef4117936a5dd1a2d43b8cbb4f3203", size = 1056867, upload-time = "2026-03-18T18:25:24.955Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/cc26752afdf2e8cc643f1aa6b4514715d249d9ebf5c57185c6f68ba1dc51/ulid_transform-2.2.0-cp314-cp314-win32.whl", hash = "sha256:b0782a5a399b44d65ad2fc9c8f8b61f455af5d4cca335eafb2927e3dac6e92e6", size = 24255, upload-time = "2026-03-18T18:25:26.35Z" }, - { url = "https://files.pythonhosted.org/packages/a3/36/0cd5c74a77e72f80e2ffbf4d08fb89fe641b1f6e683421a335774731db6f/ulid_transform-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:b904d40eab4f3efced841cfb5b80777dd36c0db99a66261fc1b37e0b98d68167", size = 24280, upload-time = "2026-03-18T18:25:27.519Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ac/44abd959a306c84a8917a65e731e2b25d4aaeafa29a2b15deaa1a62041d2/ulid_transform-2.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e737a0b06be45585806bd71f91a63c846b0aa3bf90dd787544d5d8482f324c61", size = 30697, upload-time = "2026-03-18T18:25:28.632Z" }, - { url = "https://files.pythonhosted.org/packages/88/cd/f4625a6c4be6f6b217c9116ede638fd969a1a27fc11be76997c0381e3f20/ulid_transform-2.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61b636694420afd6e5a2050e4fecca4f1ca17ef0673a9d4653522900b2d7132", size = 35171, upload-time = "2026-03-18T18:25:29.919Z" }, - { url = "https://files.pythonhosted.org/packages/e0/3e/cddcccf159527d028c49b2cd88abc9d323fd9fd6ef8fcc1960f9c57bb417/ulid_transform-2.2.0-cp314-cp314t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a815c51ef310c4594792864fbd8845cfe935005b631ae1c099fd3b2d1404062c", size = 35956, upload-time = "2026-03-18T18:25:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/f67e94817dcfea4699cf764db8ba427675c879bcc53610a490fcc5ab38b8/ulid_transform-2.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1628ca45e5ea9ec79bf90f9f7e3a2f036faca7bb081db143f7a36e54c1ef184", size = 33127, upload-time = "2026-03-18T18:25:32.162Z" }, - { url = "https://files.pythonhosted.org/packages/29/64/8c94866f6cf635437c6c106548f7b70023e0d4e82f0c95503551c3c3c27b/ulid_transform-2.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0c56296f82be1e272d58e53dfe231a789458758ce01addeca47a1671d73bfaa9", size = 1015637, upload-time = "2026-03-18T18:25:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3a/6beaccdf3acb27a2197cbb1fff44391ff92100ffe0ad0ae3273cb827cdf5/ulid_transform-2.2.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9fd78e7002a10a47ceb2ede42f0aaca428d0ae7ed712fd6bacf46406f806c3f4", size = 885015, upload-time = "2026-03-18T18:25:36.043Z" }, - { url = "https://files.pythonhosted.org/packages/55/b1/258765b8d209187a19228c89733400b751db223b7b68c7b79e4c924962a9/ulid_transform-2.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3adf5a417a2250264c6b6b290f1393d41efff0ac692ea19d4fa677bf29a67137", size = 1066233, upload-time = "2026-03-18T18:25:38.194Z" }, - { url = "https://files.pythonhosted.org/packages/21/f5/c29d4e164f5a6302b3f2025d16dfde9a6a8704efaf01c7d3c70ee69a4a12/ulid_transform-2.2.0-cp314-cp314t-win32.whl", hash = "sha256:66b66189322bccb54491b91a9c0e25c9240156d5d5671ca59860d8db1ce4ff9f", size = 33249, upload-time = "2026-03-18T18:25:39.673Z" }, - { url = "https://files.pythonhosted.org/packages/c6/45/f9c7400646f89d71dd5c04693ed259feb459e13b036c98193ecc8467cb4d/ulid_transform-2.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:212ba7218ea61cafb06772cd3967f60f16d4b028869a470cddcffad3ca5fac1c", size = 33277, upload-time = "2026-03-18T18:25:41.235Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d4/69/80e64aff3bbfd719ef3519774cf489cd972aac89cf8f85cd52009757be71/ulid_transform-2.2.9.tar.gz", hash = "sha256:6a8b573de0158ab4bd0424f26ed4d1cef42f15afaa718c924a8427fe24a3838f", size = 17886, upload-time = "2026-05-25T05:39:04.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/2e/6351361aefb2bfe44036519bb60ef9689511c7351c42e2edb7f86e28cb0a/ulid_transform-2.2.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b64afbdb9143210e7f9f240a91fa8fd54a559c6627e8f4cdb9f40771f8b8a498", size = 25212, upload-time = "2026-05-25T05:45:49.977Z" }, + { url = "https://files.pythonhosted.org/packages/7e/2c/db41412391950083c387c8185641db32e6f67862b52391c992cdd1824b5c/ulid_transform-2.2.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fcbef4c1c537da14fa098529167f64f7d351e7aebeeeafd6779ed2ffe10f4649", size = 27462, upload-time = "2026-05-25T05:45:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/71/aa/8a2b3b3b6fd638f88117e6852f5b1aaaf5f73de25e2d7a8bb483a9adaa58/ulid_transform-2.2.9-cp314-cp314-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:73fef8bdfae646ec4c54f5cc96dc27510fdb55c1aaed3c20d6661e8b51d01bd9", size = 27809, upload-time = "2026-05-25T05:45:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/78/f5/520fc50bfd97a616e5c367cd820eee65ffc3537acc68fdacb09f31fef149/ulid_transform-2.2.9-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6db97794ce8638ee2bd11ace5ddc9e23e450e971b264556c2c42ab43fc3d580", size = 26406, upload-time = "2026-05-25T05:45:54.1Z" }, + { url = "https://files.pythonhosted.org/packages/9a/33/27c3f9ed60426b514b1501c8acd47ab05a305fbb1e294dce6a13c52a88be/ulid_transform-2.2.9-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:f62d23bf7ed08e086e27dc5f5c89f9aec4d79316174529070073505bc367d8fa", size = 16808, upload-time = "2026-05-25T05:39:02.653Z" }, + { url = "https://files.pythonhosted.org/packages/5f/43/b246e2c3e4936789f9797c5e61ebde1d1c6fecbc9a1d6cdf6c427518006e/ulid_transform-2.2.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae8473948da794f27ecf800511ff7e9bc340e1e5d07f28f1f640a860240d1d39", size = 1007530, upload-time = "2026-05-25T05:45:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/c1ab2d3901899962afcbddce47dc796f7eaa6694f96c3f4977ab43883712/ulid_transform-2.2.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a978e55081259c95a14bd7d48db6540645ca228c2d07da6c176c291cd955e85b", size = 877349, upload-time = "2026-05-25T05:45:57.625Z" }, + { url = "https://files.pythonhosted.org/packages/af/b1/2bd4509651123caa37b447e0d4e5bb321067fd79a1bcaefa2dfc7fe41206/ulid_transform-2.2.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:364651ca7bf17e56b7570855d185e10ae461d5bfe15943936ee6f79fde71bb02", size = 1058955, upload-time = "2026-05-25T05:45:59.075Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d6/e48135a3992562630043f57a85ba36109787b91d27da842ae8e7136fd24f/ulid_transform-2.2.9-cp314-cp314-win32.whl", hash = "sha256:6daaaadfe2d7e465f0850c05dfcd5a785426571361433e84fc3f5a8ed78321ae", size = 25741, upload-time = "2026-05-25T05:46:00.438Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8e/ff96df79000ed277a9fc73e8825f27c81fba2446acb7fe33f53a1489ba70/ulid_transform-2.2.9-cp314-cp314-win_amd64.whl", hash = "sha256:f586c25a2457b88511e1acceae9aeb90f002aee06a4d37e68280ba0ff6af4b9d", size = 25837, upload-time = "2026-05-25T05:46:01.955Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c6/59879019153dbbd34eff5f3c633f329b5d75519c1a95f3271948ea51715f/ulid_transform-2.2.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a873a51c8ec7f3d51c6d3d6ecdf3720d29637d5619e7c7df16dbd451f19ceb82", size = 25198, upload-time = "2026-05-25T05:46:03.022Z" }, + { url = "https://files.pythonhosted.org/packages/51/c6/293ba411f0e32e17086baed7c955e4287e368275f31f59ef7a005d401eac/ulid_transform-2.2.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65711af19211c89c115954aab2965abad5287596d24f5754fcd142e80dd7656c", size = 27474, upload-time = "2026-05-25T05:46:04.058Z" }, + { url = "https://files.pythonhosted.org/packages/57/17/2eabe1ba6e1ed341d372c996380a812a5278217582349fc609d61fb57342/ulid_transform-2.2.9-cp314-cp314t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9456b9bd8a572448cbddd9f3c5266d7354c68a3c0dc25209108ea904846fac3", size = 27826, upload-time = "2026-05-25T05:46:05.166Z" }, + { url = "https://files.pythonhosted.org/packages/65/36/1f3f2e6c1243cafd75d9c2736b6b73e5530d96b49f955493e8917b2fe716/ulid_transform-2.2.9-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12639c5dda80d6d90437e9427dd87c6bb71bb291dcfe8d6063703394c2e15e3a", size = 26405, upload-time = "2026-05-25T05:46:06.268Z" }, + { url = "https://files.pythonhosted.org/packages/00/6b/200c79dc081b4c9673923dd9d9bc9dc0e47ccdc90eeae8e85acbb1914751/ulid_transform-2.2.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8cf746c642569dfe3efd94fe45672954f6257ff910f993bca80ab6dcb5a34f92", size = 1007528, upload-time = "2026-05-25T05:46:07.417Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/228a44957e37a3c7541989d47c481c9f51ae045ad07fdacc0deb49d4e120/ulid_transform-2.2.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5c19d10c7ad9a24c456aba5cbd74aeeca84a4b33cd35077b4bec61d2cf92e30c", size = 877361, upload-time = "2026-05-25T05:46:09.145Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7e/4beaa80ac820b3243e376f79fde4a8bca2b477d8b6529ff5afcfd83f5f74/ulid_transform-2.2.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35084b9ade29b462472485966789e5edc66dcbc376ffaf89759e7ada0c7d1f6c", size = 1058912, upload-time = "2026-05-25T05:46:10.736Z" }, + { url = "https://files.pythonhosted.org/packages/39/f8/ab3af64aa6ced7c65b09aa4b1c33d2cd4bf48646c9957a17659cea69e1c5/ulid_transform-2.2.9-cp314-cp314t-win32.whl", hash = "sha256:ccf6b173ae1e90a589935fd99176dc9d71d461c9f413b1a754d542ba2d399860", size = 25725, upload-time = "2026-05-25T05:46:12.117Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c8/cbb9374e8a0e26d18e94ffb3e1f861beab5312b3e0be4fbad1bf60cdb47c/ulid_transform-2.2.9-cp314-cp314t-win_amd64.whl", hash = "sha256:fed352965a8682a156000d29e0efff8b85e7c2b8cb74f2e953a1e6d0157648b9", size = 25827, upload-time = "2026-05-25T05:46:13.164Z" }, +] + +[[package]] +name = "unidiff" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/48/6ebfbda867e1a07bab3bbffe820e980bff8262c97ff77d1496a4fa15e711/unidiff-1.0.0.tar.gz", hash = "sha256:5e5d5cfab2dc98be819b74747ab7d9f5af8695369ec8710b93f9ab0f0ae6a449", size = 29365, upload-time = "2026-07-25T19:13:59.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/ca/860142913b2fee25c78b3af733054e248c488bd83cf6cfb97969e98e3bcf/unidiff-1.0.0-py3-none-any.whl", hash = "sha256:2e1fb4eebe2354a26a1f3d51efe2e5d504cae5764b98ed8bdbb4e7a000baff28", size = 18279, upload-time = "2026-07-25T19:13:58.797Z" }, ] [[package]] @@ -2641,28 +2889,28 @@ wheels = [ [[package]] name = "uv" -version = "0.11.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" }, - { url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" }, - { url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" }, - { url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" }, - { url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" }, - { url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" }, - { url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" }, - { url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" }, - { url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" }, - { url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" }, - { url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" }, - { url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" }, - { url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" }, - { url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" }, +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/b0/3085b844fe59aa319a3f94a5cca9938fffecc82705aa9c2762a749f7095c/uv-0.12.5.tar.gz", hash = "sha256:442a21d181faae21742aaaf6d2091a0d27755d3eac344061a9a00c90169b7524", size = 7101936, upload-time = "2026-08-14T19:56:57.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/4c/6412d4a618230db699118b362ec41c54795f93992b43c53e225bd0213501/uv-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:2bd62134e56af35b9cf017aaf8ae41a605d6501dd49afc35b70b544a45dd8354", size = 23310055, upload-time = "2026-08-14T19:55:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/d76387b388fa21620088b89b9c67f2596a707add585104e0cb5e8abf55f2/uv-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1a06c8bc4d43b5f6c1e3f2ae3d0f6455b07515f762516f95e52e6c0cbccedf15", size = 21401335, upload-time = "2026-08-14T19:55:55.371Z" }, + { url = "https://files.pythonhosted.org/packages/6d/bc/81ab953b7261ae6be40874b1f283a10873871e02eb353d354614dd8da96b/uv-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d87156bc174d94fae890bb7a261e2867140abb9fe1e9de81a5295e582fb9d0f5", size = 19290641, upload-time = "2026-08-14T19:55:58.998Z" }, + { url = "https://files.pythonhosted.org/packages/7d/13/07585043c10e648820bf826474dac46864ce6691da5dc52fee43c5c7523a/uv-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d65b7b3bc3fd28678f62aa7fb5d90f106ad9782c1354af60b6cecdf9ea9ecd9", size = 22245569, upload-time = "2026-08-14T19:56:02.729Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6d/310f8f56f8d001b4000112a09d7b7de80fb2024a90208fabb9ddc457c123/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:712624b62e25c84e5a10fc6aa144d8a81b685fdc067a54a7ca4367d75d2cf791", size = 22745152, upload-time = "2026-08-14T19:56:06.426Z" }, + { url = "https://files.pythonhosted.org/packages/92/da/7922b67eec5ee03e94333c5841b682c335033ee80acac17c3417bd752656/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9656ac7a00fd4314980fb0f790df1c1f3fa9cbcf9af9c6f611b19448b9da687", size = 22787947, upload-time = "2026-08-14T19:56:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/5dbaed832a4b36809ef8a07c8e56e9fee0dedb0aa0454f6d232b6e468f2c/uv-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:568485b44e848eb3693f85d6b00299ccd8fc4d26902030dbf24f549c276db9ca", size = 23367616, upload-time = "2026-08-14T19:56:13.768Z" }, + { url = "https://files.pythonhosted.org/packages/11/77/baf761d12bb66efb01706e3bbb5926ed0d13cb0a40539a661fcfffd46de4/uv-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd08c82831b0033330f8eeeb0d90f938a4d999f25569bee68a975c736142d795", size = 24586263, upload-time = "2026-08-14T19:56:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a8/76c1031c4834c959bb8a8059c9feabeaa77488ce8b6a3529d6d929ae81cf/uv-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd9ff6154b891146a342c143cd29b330ad97ac6a4b20ff4a99a20a4da84ceca", size = 24160655, upload-time = "2026-08-14T19:56:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/dacc9a0bc8604187a1ba954a3aef8329e4104eb0af772d2c3c634893bd9b/uv-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e195ccf1ed60c8bb24a6447ce306441a4181d54b602407e09bc56e963911c15", size = 23657089, upload-time = "2026-08-14T19:56:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/39/98/e8f9c071622f2cb4072d8b587d27b27d23cf0d3ebf8b3687f5af6030f587/uv-0.12.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:58abfb0f658b39a834307a11223bc170294ea214263b4c99ecc7663720d43544", size = 22379954, upload-time = "2026-08-14T19:56:28.789Z" }, + { url = "https://files.pythonhosted.org/packages/73/95/4c3f060e95f7cbe9177b4ab361f0cbfc4ae22e5a49b22e73eee9f0d0a6ca/uv-0.12.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:6ad2c455f1fe4d2962f6fd7ccb3b1f61c61856681c9d99f40e170b2074353fa3", size = 23318163, upload-time = "2026-08-14T19:56:32.504Z" }, + { url = "https://files.pythonhosted.org/packages/a0/96/ca0497ef8912ef48dbbc9982a8b4212260c34d56bfd0d45fe67b31942121/uv-0.12.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a05b497c2a948c8600f4c831a89852b4d2514b7f561074225cc9edd0cc4811e2", size = 23470437, upload-time = "2026-08-14T19:56:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/60/e7/8bdc37669a6cd2b46a2ec08ccbb58c61395ec84a073e199f5a4a64bb998f/uv-0.12.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7817f8e957960f9ddc452ea353f283c0d6393e2e31b400276485adced5b1f371", size = 22545803, upload-time = "2026-08-14T19:56:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/37/cc/01e39e1dbeb838a6b3c26bf97c867d6f366459b22a38bea691af8c6c94c0/uv-0.12.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:dc14e4f81a99b585a891350c60d1ff4557d54cb3c3c81fa45fd4e0dd512ba752", size = 23874113, upload-time = "2026-08-14T19:56:44.193Z" }, + { url = "https://files.pythonhosted.org/packages/0a/38/9053599a73a351d1cd34195c7a48c1db4d4d51b57b543607fad7ecf9354c/uv-0.12.5-py3-none-win32.whl", hash = "sha256:39bb102766c95571781a7b4c611675ea213e08df5c680f3936279b3c0d1f6c3c", size = 20744641, upload-time = "2026-08-14T19:56:47.689Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f6/a9af9311c7f5640ca2bfcfdedb7aca37fa6d1d9f5c981fb50c5be02b7477/uv-0.12.5-py3-none-win_amd64.whl", hash = "sha256:455c3e57602e2141e66e2f0bf685898c9c5e5a70377d14c9a71554a3baf3ddbf", size = 21621812, upload-time = "2026-08-14T19:56:51.126Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fb/e1266399f755f97a0783de379f2fed6dae0a2a240db32fe5a2eb976fec8a/uv-0.12.5-py3-none-win_arm64.whl", hash = "sha256:bea86f27a027e0e3af908db4bdd4f1ceef3ca2bd47673b5ccca7f550e325b1b4", size = 20381876, upload-time = "2026-08-14T19:56:54.883Z" }, ] [[package]] @@ -2674,30 +2922,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/a8/8f9cc6749331186e6a513bfe3745454f81d25f6e34c6024f88f80c71ed28/voluptuous-0.15.2-py3-none-any.whl", hash = "sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566", size = 31349, upload-time = "2024-07-02T19:09:58.125Z" }, ] -[[package]] -name = "voluptuous-openapi" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "voluptuous" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/15/ac7a98afd478e9afc804354fe9d9715e0e560a590fdd425b22b65a152bb3/voluptuous_openapi-0.2.0.tar.gz", hash = "sha256:2366be934c37bb5fd8ed6bd5a2a46b1079b57dfbdf8c6c02e88f4ca13e975073", size = 15789, upload-time = "2025-08-21T04:49:16.755Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/eb/2ae58431a078318f03267f196137282ea6f01ea7f7e0fcba2b25a30b0bf2/voluptuous_openapi-0.2.0-py3-none-any.whl", hash = "sha256:d51f07be8af44b11570b7366785d90daa716b7fd11ea2845803763ae551f35cf", size = 10180, upload-time = "2025-08-21T04:49:15.885Z" }, -] - -[[package]] -name = "voluptuous-serialize" -version = "2.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "voluptuous" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/53/70/03a9b61324e1bb8b16682455b8b953bccd1001a28e43478c86f539e26285/voluptuous_serialize-2.7.0.tar.gz", hash = "sha256:d0da959f2fd93c8f1eb779c5d116231940493b51020c2c1026bab76eb56cd09e", size = 9202, upload-time = "2025-08-17T10:43:04.31Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/41/d536d9cf39821c35cc13aff403728e60e32b2fd711c240b6b9980af1c03f/voluptuous_serialize-2.7.0-py3-none-any.whl", hash = "sha256:ee3ebecace6136f38d0bf8c20ee97155db2486c6b2d0795563fafd04a519e76f", size = 7850, upload-time = "2025-08-17T10:43:03.498Z" }, -] - [[package]] name = "voluptuous-stubs" version = "0.1.1" @@ -2861,81 +3085,82 @@ wheels = [ [[package]] name = "yarl" -version = "1.23.0" +version = "1.24.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, - { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, - { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, - { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, - { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, - { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, - { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, - { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, - { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, - { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, - { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, - { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, - { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, - { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, - { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, - { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, - { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, - { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, - { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, - { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, - { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, ] [[package]] name = "zeroconf" -version = "0.148.0" +version = "0.151.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ifaddr" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/46/10db987799629d01930176ae523f70879b63577060d63e05ebf9214aba4b/zeroconf-0.148.0.tar.gz", hash = "sha256:03fcca123df3652e23d945112d683d2f605f313637611b7d4adf31056f681702", size = 164447, upload-time = "2025-10-05T00:21:19.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/46/ac86e3a3ff355058cd0818b01a3a97ca3f2abc0a034f1edb8eea27cea65c/zeroconf-0.148.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:2158d8bfefcdb90237937df65b2235870ccef04644497e4e29d3ab5a4b3199b6", size = 1714870, upload-time = "2025-10-05T01:08:47.624Z" }, - { url = "https://files.pythonhosted.org/packages/de/02/c5e8cd8dfda0ca16c7309c8d12c09a3114e5b50054bce3c93da65db8b8e4/zeroconf-0.148.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:695f6663bf8df30fe1826a2c4d5acd8213d9cbd9111f59d375bf1ad635790e98", size = 1697756, upload-time = "2025-10-05T01:08:49.472Z" }, - { url = "https://files.pythonhosted.org/packages/63/04/a66c1011d05d7bb8ae6a847d41ac818271a942390f3d8c83c776389ca094/zeroconf-0.148.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa65a24ec055be0a1cba2b986ac3e1c5d97a40abe164991aabc6a6416cc9df02", size = 2146784, upload-time = "2025-10-05T01:08:51.766Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d4/2239d87c3f60f886bd2dd299e9c63b811efd58b8b6fc659d8fd0900db3bc/zeroconf-0.148.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79890df4ff696a5cdc4a59152957be568bea1423ed13632fc09e2a196c6721d5", size = 1899394, upload-time = "2025-10-05T01:08:53.457Z" }, - { url = "https://files.pythonhosted.org/packages/fb/60/534a4b576a8f9f5edff648ac9a5417323bef3086a77397f2f2058125a3c8/zeroconf-0.148.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c0ca6e8e063eb5a385469bb8d8dec12381368031cb3a82c446225511863ede3", size = 2221319, upload-time = "2025-10-05T01:08:55.271Z" }, - { url = "https://files.pythonhosted.org/packages/b5/8c/1c8e9b7d604910830243ceb533d796dae98ed0c72902624a642487edfd61/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ece6f030cc7a771199760963c11ce4e77ed95011eedffb1ca5186247abfec24a", size = 2178586, upload-time = "2025-10-05T01:08:56.966Z" }, - { url = "https://files.pythonhosted.org/packages/16/55/178c4b95840dc687d45e413a74d2236a25395ab036f4813628271306ab9d/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c3f860ad0003a8999736fa2ae4c2051dd3c2e5df1bc1eaea2f872f5fcbd1f1c1", size = 1972371, upload-time = "2025-10-05T01:08:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/fb/86/b599421fe634d9f3a2799f69e6e7db9f13f77d326331fa2bb5982e936665/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ab8e687255cf54ebeae7ede6a8be0566aec752c570e16dbea84b3f9b149ba829", size = 2244286, upload-time = "2025-10-05T01:09:01.029Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cb/a30c42057be5da6bb4cbe1ab53bc3a7d9a29cd59caae097d3072a9375c14/zeroconf-0.148.0-cp314-cp314-win32.whl", hash = "sha256:6b1a6ddba3328d741798c895cecff21481863eb945c3e5d30a679461f4435684", size = 1321693, upload-time = "2025-10-05T01:09:02.715Z" }, - { url = "https://files.pythonhosted.org/packages/2c/38/06873cdf769130af463ef5acadbaf4a50826a7274374bc3b9a4ec5d32678/zeroconf-0.148.0-cp314-cp314-win_amd64.whl", hash = "sha256:2588f1ca889f57cdc09b3da0e51175f1b6153ce0f060bf5eb2a8804c5953b135", size = 1563980, upload-time = "2025-10-05T01:09:04.857Z" }, - { url = "https://files.pythonhosted.org/packages/36/fb/53d749793689279bc9657d818615176577233ad556d62f76f719e86ead1d/zeroconf-0.148.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:40fe100381365c983a89e4b219a7ececcc2a789ac179cd26d4a6bbe00ae3e8fe", size = 3418152, upload-time = "2025-10-05T01:09:06.71Z" }, - { url = "https://files.pythonhosted.org/packages/b9/19/5eb647f7277378cbfdb6943dc8e60c3b17cdd1556f5082ccfdd6813e1ce8/zeroconf-0.148.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b9c7bcae8af8e27593bad76ee0f0c21d43c6a2324cd1e34d06e6e08cb3fd922", size = 3389671, upload-time = "2025-10-05T01:09:08.903Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/3134aa54d30a9ae2e2473212eab586fe1779f845bf241e68729eca63d2ab/zeroconf-0.148.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf8ba75dacd58558769afb5da24d83da4fdc2a5c43a52f619aaa107fa55d3fdc", size = 4123125, upload-time = "2025-10-05T01:09:11.064Z" }, - { url = "https://files.pythonhosted.org/packages/12/23/4a0284254ebce373ff1aee7240932a0599ecf47e3c711f93242a861aa382/zeroconf-0.148.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75f9a8212c541a4447c064433862fd4b23d75d47413912a28204d2f9c4929a59", size = 3651426, upload-time = "2025-10-05T01:09:13.725Z" }, - { url = "https://files.pythonhosted.org/packages/76/9a/7b79ef986b5467bb8f17b9a9e6eea887b0b56ecafc00515c81d118e681b4/zeroconf-0.148.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be64c0eb48efa1972c13f7f17a7ac0ed7932ebb9672e57f55b17536412146206", size = 4263151, upload-time = "2025-10-05T01:09:15.732Z" }, - { url = "https://files.pythonhosted.org/packages/dd/0a/caa6d05548ca7cf28a0b8aa20a9dbb0f8176172f28799e53ea11f78692a3/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac1d4ee1d5bac71c27aea6d1dc1e1485423a1631a81be1ea65fb45ac280ade96", size = 4191717, upload-time = "2025-10-05T01:09:18.071Z" }, - { url = "https://files.pythonhosted.org/packages/46/f6/dbafa3b0f2d7a09315ed3ad588d36de79776ce49e00ec945c6195cad3f18/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8da9bdb39ead9d5971136046146cd5e11413cb979c011e19f717b098788b5c37", size = 3793490, upload-time = "2025-10-05T01:09:20.045Z" }, - { url = "https://files.pythonhosted.org/packages/c4/05/f8b88937659075116c122355bdd9ce52376cc46e2269d91d7d4f10c9a658/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6e3dd22732df47a126aefb5ca4b267e828b47098a945d4468d38c72843dd6df", size = 4311455, upload-time = "2025-10-05T01:09:22.042Z" }, - { url = "https://files.pythonhosted.org/packages/58/c0/359bdb3b435d9c573aec1f877f8a63d5e81145deb6c160de89647b237363/zeroconf-0.148.0-cp314-cp314t-win32.whl", hash = "sha256:cdc8083f0b5efa908ab6c8e41687bcb75fd3d23f49ee0f34cbc58422437a456f", size = 2755961, upload-time = "2025-10-05T01:09:24.041Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ab/7b487afd5d1fd053c5a018565be734ac6d5e554bce938c7cc126154adcfc/zeroconf-0.148.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f72c1f77a89638e87f243a63979f0fd921ce391f83e18e17ec88f9f453717701", size = 3309977, upload-time = "2025-10-05T01:09:26.039Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/48/5a/097d33c95850cd9880aaf9dec51c1854f24e03542af47b82afe0e6cf281d/zeroconf-0.151.1.tar.gz", hash = "sha256:2594832108af9a74eeebe87af96666ae24a006cb7eb8ba80073144dca8291610", size = 226627, upload-time = "2026-08-28T23:39:38.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/72/ad628f357f02fbe960ff50f9af04f69df134635e2057d8398eb1ad6af18b/zeroconf-0.151.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3dd3460e31949f740ceb11245a197f9c7510be0a72bb7c977a02a4b4b9b4498f", size = 1219503, upload-time = "2026-08-28T23:59:44.338Z" }, + { url = "https://files.pythonhosted.org/packages/19/56/418740deb1a7ed4977b8250bcb17c43f3188fe8f708508e16381488b5686/zeroconf-0.151.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d213b1445fe05cb8ef073e0c96fb9ef88c55e6194a896b527a25cc8bea0c5538", size = 1390635, upload-time = "2026-08-28T23:59:46.153Z" }, + { url = "https://files.pythonhosted.org/packages/c6/65/690111f1c7246f833271db69eef23aae692154687dc866f571022ad4e8c4/zeroconf-0.151.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:125203ce7c8ca310599feeef5e24a83aea73d5100dfcb6bfe250ae0578c3cb41", size = 1305187, upload-time = "2026-08-28T23:59:47.826Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1f/cb88beada221dff13ee268bf3944a79d92a846104bbba308f40eeec28ce0/zeroconf-0.151.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebfb7143610f4c0443c983c50b9ce6474bd785ecf8f2075df8164b1f9fb660ad", size = 1429073, upload-time = "2026-08-28T23:59:49.621Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6b/0238da6f3a053e566a5cbccf118ba971e4294fe1e827cfdbe1b5d3d619f5/zeroconf-0.151.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19848ee652ca2b3b849b6ad6c082c4ac43591c6450d5329dd2bf3b1da074454b", size = 1404408, upload-time = "2026-08-28T23:59:51.477Z" }, + { url = "https://files.pythonhosted.org/packages/b1/94/08e6e6faa61ab3753a1e035c26349eeefc425204dc7d882e832351ec8c7a/zeroconf-0.151.1-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:fbfe2d2e644cfefea21c87b4b91de3b85ade52a26ceb53146cb537a3326b3c42", size = 1426402, upload-time = "2026-08-28T23:39:36.432Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/431757f5f6dff4162cc795359ddeea73255870ee1f03f3d23ffa5411e056/zeroconf-0.151.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e45080633cab548401805c91f2f1d50ecc5d60b4952a631e660ab5c620565d70", size = 1410578, upload-time = "2026-08-28T23:59:53.755Z" }, + { url = "https://files.pythonhosted.org/packages/d1/37/40fcce919ee7ccf38919e4c5aefe92b8b02981d08616ba53915007ca58e8/zeroconf-0.151.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:55cc03275b0e05c947280453671fb2cea118fbbd14b9cec6dc156ce8560aaf0d", size = 1317148, upload-time = "2026-08-28T23:59:55.505Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ea/520d7bde140e7de02075b8fa83b7d5d5a795554a2a08b38303a47c2d63d8/zeroconf-0.151.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:abe69cb51b7d0533c45b352c9132d6e033214deaa334a21091a2ed10cdae1dea", size = 1410723, upload-time = "2026-08-28T23:59:57.435Z" }, + { url = "https://files.pythonhosted.org/packages/a5/32/0e0bfdcb7e0a2e7364945a4724c35d80249ce8f76760e6b2558f4a6e2f42/zeroconf-0.151.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c23f00508fa0494352ba46b8c410fe39b195e7f720765e551d937149b18c48b9", size = 1447198, upload-time = "2026-08-28T23:59:59.233Z" }, + { url = "https://files.pythonhosted.org/packages/75/b4/1f56fe65666667c1f861f3afe3c2ef06cad451466fabd479fec0b81ff2bc/zeroconf-0.151.1-cp314-cp314-win32.whl", hash = "sha256:a3f69d9a58b9f28a208cbec2608135134f47a202ae160d9a762ab84c2ad53d05", size = 982150, upload-time = "2026-08-29T00:00:01.088Z" }, + { url = "https://files.pythonhosted.org/packages/9c/53/c6d605990cb17eb8ed9d56bf653dd83d4df1d6481c69e008a1b7bd6fc025/zeroconf-0.151.1-cp314-cp314-win_amd64.whl", hash = "sha256:62907c52238c3c88deaeaeacf0da2c6f9fb31e18bfb648a6dd00c0e951735b0a", size = 1103403, upload-time = "2026-08-29T00:00:03.256Z" }, + { url = "https://files.pythonhosted.org/packages/b5/55/1851330f4df15b280fd41d3baa3d673835a1a9733f8a666b727ba2167cdc/zeroconf-0.151.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76cbc5ba8dd141ef9af3f275e2820a01140bb32a8a079e663e951a0e40bef5f0", size = 2396631, upload-time = "2026-08-29T00:00:05.241Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/b56972dcbbb9902a19b9b12359209ae8f33b471973630f5990fcc1c949e0/zeroconf-0.151.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819be9bc551369606b13e06b1f280baf3a81a71da1fbc90603b802e28126751d", size = 2672746, upload-time = "2026-08-29T00:00:07.139Z" }, + { url = "https://files.pythonhosted.org/packages/71/57/481fcdec07f6b0d5901b3efd1e5fb1c1e2ffa2e0fab56b8a5d45a4e788a2/zeroconf-0.151.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4b225f030c98d5ff3b72926d68a320adafb785437943a6b405cfa012c9cacd62", size = 2479424, upload-time = "2026-08-29T00:00:09.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/37/65203025ef01c44cb51ee1811e3cce77d0435509d5a03d0c837934ad2995/zeroconf-0.151.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d8e8e647b75e84db5aeb36a3c8e92affea258da407e664b66e7c3a09126fbe", size = 2737584, upload-time = "2026-08-29T00:00:11.199Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7a/82c98947bd7860edb7f02950835ffdf00ac2847b2c2b53e95d486d6a4379/zeroconf-0.151.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb58c6345221b18e3d5f344a2f753b0098f3f764ccb031f2aeef4805b349b7c6", size = 2689206, upload-time = "2026-08-29T00:00:13.286Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/cc6b1c112807a3b8306bdc1dff784b363f5739114f63110588ad97a6cc9d/zeroconf-0.151.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ef3c51a642b19b22b794fea3c7e88d35ebfd5cb8ff16162840c56c20d5f4e229", size = 2716601, upload-time = "2026-08-29T00:00:15.511Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c6/d08f811f70937452c2b278d33effb0b1843aadea04840f9209d5f11c5415/zeroconf-0.151.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d51539015ff87c7ae19188221df1fff0375aafb546bf6b963655b72019045496", size = 2521764, upload-time = "2026-08-29T00:00:17.611Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/1636ff37296612dab7a43afe00459790e9b0999ce134cb22aebd0e7fe933/zeroconf-0.151.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:643b79661a9894fe1c77c7daf9175b1c2800f536a39d71557c0a488f70005189", size = 2711400, upload-time = "2026-08-29T00:00:19.796Z" }, + { url = "https://files.pythonhosted.org/packages/14/b8/6ae6ac3133bd161f5491544fda0ec1a7153940a07170a7110d1961daaa00/zeroconf-0.151.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4755b8363d48fe78d313ae336a7ebd0d8d85f70ed5a76192189edc3eb3b55693", size = 2776889, upload-time = "2026-08-29T00:00:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1b/252eabd1bb1639759de438ed997b397731c18e7ee51e11bcd488a34185dd/zeroconf-0.151.1-cp314-cp314t-win32.whl", hash = "sha256:73f7ed206f4f05f63b45eb3ce01faf0f0de7806e852cf98fad201a8245efbf1d", size = 1920865, upload-time = "2026-08-29T00:00:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/b8/64/e6ae1c91e0844473331ef9cc0407382b880a7576dde9f7248ecfbfd4b57d/zeroconf-0.151.1-cp314-cp314t-win_amd64.whl", hash = "sha256:998c313a2e6388fd3612fc9b11e7d4257e273a7add757d665e62d154dab7b2f2", size = 2162731, upload-time = "2026-08-29T00:00:26.231Z" }, ] diff --git a/websocket-api.md b/websocket-api.md index 79e95fc7..4d994cf4 100644 --- a/websocket-api.md +++ b/websocket-api.md @@ -6,7 +6,7 @@ AppDaemon scripts, or any WebSocket client connected to Home Assistant. ## `span_panel/panel_topology` Returns the full physical layout of a SPAN panel in a single call — circuits with their breaker slot positions, entity IDs grouped by role (power, energy, -switch, select), and sub-devices (BESS, EVSE) with their entities. +switch, select), and sub-devices (BESS, MID, EVSE, PV) with their entities. A custom card rendering the physical panel needs to know which breaker slot each circuit occupies, which entity provides its power reading, which switch controls its relay, and so on. Without this command, the card would need to query the device registry, entity registry, and individual entity states in separate @@ -27,6 +27,11 @@ relationships explicitly, keyed by circuit UUID, so the card reads a single stru | ----------- | ------ | ------------------------------------------------------------------------------------------------------- | | `device_id` | string | The Home Assistant device registry ID for the SPAN panel. Found in the URL when viewing the device page | +A device ID saved before Home Assistant 2026.8 is still accepted. That release split every device belonging to several config entries into one device per entry, +each with a new ID, so an older card configuration can hold the panel's previous ID; it resolves to the panel device SPAN owns now, and `device_id` in the +response echoes the ID as sent. Take the panel's identity from `panel_device_id` and `config_entry_id` in the response rather than by looking `device_id` up in +Home Assistant's device list, which no longer holds a pre-2026.8 ID. + ### Response ```json @@ -35,6 +40,8 @@ relationships explicitly, keyed by circuit UUID, so the card reads a single stru "firmware": "spanos2/r202603/05", "panel_size": 32, "device_id": "abc123def456", + "panel_device_id": "abc123def456", + "config_entry_id": "e5f6a7b8c9d0", "device_name": "SPAN Panel", "circuits": { "a1b2c3d4e5f6": { @@ -43,8 +50,13 @@ relationships explicitly, keyed by circuit UUID, so the card reads a single stru "voltage": 240, "device_type": "circuit", "relay_state": "CLOSED", + "relay_state_target": null, "is_user_controllable": true, "breaker_rating_a": 30, + "always_on": false, + "priority": "SOC_THRESHOLD", + "priority_target": null, + "is_never_backup": false, "entities": { "power": "sensor.span_panel_kitchen_power", "produced_energy": "sensor.span_panel_kitchen_produced_energy", @@ -62,8 +74,13 @@ relationships explicitly, keyed by circuit UUID, so the card reads a single stru "voltage": 120, "device_type": "circuit", "relay_state": "CLOSED", + "relay_state_target": null, "is_user_controllable": true, "breaker_rating_a": 15, + "always_on": false, + "priority": "NEVER", + "priority_target": null, + "is_never_backup": true, "entities": { "power": "sensor.span_panel_master_bedroom_power", "switch": "switch.span_panel_master_bedroom_breaker" @@ -100,6 +117,52 @@ relationships explicitly, keyed by circuit UUID, so the card reads a single stru "unique_id": "..." } } + }, + "device_id_solar": { + "name": "SPAN Panel Solar", + "type": "pv", + "manufacturer": "Enphase", + "model": "IQ8PLUS-72-2-US", + "serial_number": null, + "sw_version": null, + "entities": { + "sensor.span_panel_solar_pv_power": { + "domain": "sensor", + "original_name": "PV Power", + "unique_id": "..." + } + }, + "solar": { + "role": "site", + "vendor": "Enphase", + "model": "IQ8PLUS-72-2-US", + "feed_circuit_id": "c1d2e3f4a5b6", + "power_entity_id": "sensor.span_panel_solar_inverter_power", + "site_power_entity_id": "sensor.span_panel_solar_pv_power" + } + }, + "device_id_solar_inverter": { + "name": "SPAN Panel Solar Inverter (Garage Solar)", + "type": "pv", + "manufacturer": "SolarEdge", + "model": "SE7600H-US", + "serial_number": null, + "sw_version": null, + "entities": { + "sensor.span_panel_solar_inverter_garage_solar_pv_vendor": { + "domain": "sensor", + "original_name": "PV Vendor", + "unique_id": "..." + } + }, + "solar": { + "role": "inverter", + "vendor": "SolarEdge", + "model": "SE7600H-US", + "feed_circuit_id": "b6a5f4e3d2c1", + "power_entity_id": "sensor.span_panel_garage_solar_power", + "site_power_entity_id": null + } } } } @@ -109,28 +172,35 @@ relationships explicitly, keyed by circuit UUID, so the card reads a single stru #### Top Level -| Field | Type | Description | -| ------------- | ----------- | ----------------------------------------------- | -| `serial` | string | Panel serial number | -| `firmware` | string | Panel firmware version | -| `panel_size` | int or null | Total breaker spaces (e.g., 32, 40) | -| `device_id` | string | HA device registry ID (echoed from request) | -| `device_name` | string | HA device display name | -| `circuits` | object | Circuit UUID keyed map (see below) | -| `sub_devices` | object | HA device ID keyed map of BESS/EVSE (see below) | +| Field | Type | Description | +| ----------------- | ----------- | ------------------------------------------------------ | +| `serial` | string | Panel serial number | +| `firmware` | string | Panel firmware version | +| `panel_size` | int or null | Total breaker spaces (e.g., 32, 40) | +| `device_id` | string | HA device registry ID (echoed from request) | +| `panel_device_id` | string | The panel's current HA device registry ID | +| `config_entry_id` | string | The config entry that owns the panel | +| `device_name` | string | HA device display name | +| `circuits` | object | Circuit UUID keyed map (see below) | +| `sub_devices` | object | HA device ID keyed map of BESS/MID/EVSE/PV (see below) | #### Circuit Object -| Field | Type | Description | -| ---------------------- | ----------- | ---------------------------------------------- | -| `tabs` | int[] | Sorted breaker slot positions (1-indexed) | -| `name` | string/null | Circuit name from the panel (null if unnamed) | -| `voltage` | int | 120 (single tab) or 240 (double tab) | -| `device_type` | string | `circuit`, `pv`, or `evse` | -| `relay_state` | string | `CLOSED`, `OPEN`, or `UNKNOWN` | -| `is_user_controllable` | bool | Whether the circuit relay can be toggled | -| `breaker_rating_a` | float/null | Breaker amperage rating (null if not reported) | -| `entities` | object | Role-keyed map of entity IDs (see below) | +| Field | Type | Description | +| ---------------------- | ----------- | ------------------------------------------------------------------------------------------ | +| `tabs` | int[] | Sorted breaker slot positions (1-indexed) | +| `name` | string/null | Circuit name from the panel (null if unnamed) | +| `voltage` | int/null | 120 (single tab), 240 (double tab), or null where the pole count does not say | +| `device_type` | string | `circuit`, `pv`, or `evse` | +| `relay_state` | string | `CLOSED`, `OPEN`, or `UNKNOWN` | +| `relay_state_target` | string/null | The relay state last commanded and not yet reached, or null | +| `always_on` | bool | Whether the panel keeps this circuit on and offers no relay control | +| `priority` | string | Shed priority as the panel publishes it: `NEVER`, `SOC_THRESHOLD`, `OFF_GRID` or `UNKNOWN` | +| `priority_target` | string/null | The priority last commanded and not yet reached, or null | +| `is_never_backup` | bool | Whether the panel pins the priority, so it cannot be set | +| `is_user_controllable` | bool | Whether the circuit relay can be toggled | +| `breaker_rating_a` | float/null | Breaker amperage rating (null if not reported) | +| `entities` | object | Role-keyed map of entity IDs (see below) | #### Circuit Entity Roles @@ -145,20 +215,38 @@ relationships explicitly, keyed by circuit UUID, so the card reads a single stru | `switch` | switch | Relay on/off control | | `select` | select | Shed priority control | -Not all roles are present on every circuit. Roles are omitted when the entity does not exist (e.g., `current` is absent if the panel does not report per-circuit -current, `switch` is absent for always-on circuits). +Not all roles are present on every circuit. A role is omitted when its entity does not exist: `current` is absent if the panel does not report per-circuit +current. `switch` and `select` are present only while the integration provides that control, meaning the live circuit qualifies for it and **Who may operate the +panel** is not **Nobody**. An unavailable state on a present `switch` or `select` is transient. #### Sub-Device Object -| Field | Type | Description | -| --------------- | ----------- | ------------------------------------- | -| `name` | string | HA device display name | -| `type` | string | `bess`, `evse`, or `unknown` | -| `manufacturer` | string/null | Device manufacturer | -| `model` | string/null | Device model | -| `serial_number` | string/null | Device serial number | -| `sw_version` | string/null | Device firmware/software version | -| `entities` | object | Entity ID keyed map with domain, name | +| Field | Type | Description | +| --------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | string | HA device display name | +| `type` | string | `bess`, `mid`, `evse`, `pv`, or `unknown` | +| `manufacturer` | string/null | Device manufacturer | +| `model` | string/null | Device model | +| `serial_number` | string/null | Device serial number | +| `sw_version` | string/null | Device firmware/software version | +| `entities` | object | Entity ID keyed map with domain, name | +| `solar` | object | PV devices only, and optional: absent on an inverter's device the panel did not report at setup, and on a Solar device when PV was not commissioned at setup. See Solar Object. | + +#### Solar Object + +| Field | Type | Description | +| ---------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `role` | string | `site` for the Solar device, `inverter` for an additional inverter's own device | +| `vendor` | string/null | Vendor of what this device describes. On `site`, what the Solar device's PV entities read: the bound inverter, or the inverters together, where `null` means they differ. On `inverter`, that inverter's. `null` where nothing is published. | +| `model` | string/null | Model of what this device describes, as `vendor` is. | +| `feed_circuit_id` | string/null | The circuit that feeds the inverter this device describes. On `inverter`, null when no circuit feeds it. On `site`, null when no single circuit-fed inverter is drawn on this device: the inverters are read together, its lone inverter is fed by no circuit (an upstream inverter), none is published or bound, the bound inverter's circuit is gone, or the inverter has its own device. While a bound inverter's record has not yet arrived, it is the bound circuit. | +| `power_entity_id` | string/null | That inverter's own power reading: the `power` entity of `feed_circuit_id`. Null when `feed_circuit_id` is, and for a moment while that circuit leaves the panel. | +| `site_power_entity_id` | string/null | `site` only: PV Power, the site's total; null on `inverter` | + +`power_entity_id` is an entity id rather than a circuit id, so a consumer that merges several panels' circuits, as the card's favorites view does, can still +resolve it. `vendor` and `model` are what the panel publishes, `null` where it publishes nothing, whereas the device's `manufacturer` and `model` carry Home +Assistant's display placeholders. Which devices carry a block, and each block's circuit, are as of the integration's last setup, which the panel reporting a new +inverter repeats; `vendor` and `model` are read live, as the PV Vendor and PV Product sensors are. ### Errors @@ -166,6 +254,7 @@ current, `switch` is absent for always-on circuits). | ---------------- | --------------------------------------------- | | device_not_found | The device_id does not exist in HA | | not_span_panel | The device is not a SPAN Panel device | +| not_panel_device | The device_id is a sub-device, not the panel | | not_loaded | The integration or config entry is not loaded | | no_data | The coordinator has no panel data yet | @@ -189,3 +278,196 @@ for (const [circuitId, circuit] of Object.entries(topology.circuits)) { Each panel is a separate config entry with its own device ID. To render multiple panels, call `span_panel/panel_topology` once per panel device ID. The response is scoped to a single panel — circuits, sub-devices, and entity mappings from other panels are never included. + +## `span_panel/adopted/list` + +Returns every adopted row on a panel, grouped by the device card it renders on. An adopted row is a property the panel publishes that this integration models no +field for — a whole device nobody has modeled, or a vendor extension on a device it does model — surfaced as a disabled diagnostic entity in plain wire +vocabulary. + +Those entities carry deliberately minimal metadata, because a state class is not declared on the wire and is not derivable from one, and a device class guessed +off a unit mislabels as often as it helps. The owner of the vendor device is not guessing, so this command is the input to an editor where they can say what the +integration refuses to infer. Each row therefore carries not only what the wire declares but the choices Core's own maps admit for that declaration, computed +server-side so a card never offers an option that would be refused on save. + +Admin only, like every command here. + +### Request + +```json +{ + "type": "span_panel/adopted/list", + "device_id": "" +} +``` + +| Field | Type | Description | +| ----------- | ------ | ------------------------------------------------------------------------------------------ | +| `device_id` | string | The device registry ID for the **main SPAN panel**, the same handle `panel_topology` takes | + +### Response + +```json +{ + "devices": [ + { + "device_id": "abc123def456", + "name": "Backup Generator", + "adopted_device": true, + "rows": [ + { + "key": "nj-2316-005k6_adopted_generator-1/meter/active-power", + "path": "meter/active-power", + "platform": "sensor", + "entity_id": "sensor.backup_generator_active_power", + "datatype": "float", + "unit": "W", + "settable": false, + "name": "Active Power", + "curation": { "state_class": "measurement", "device_class": "power" }, + "allowed_device_classes": ["power"], + "allowed_state_classes": ["measurement", "total", "total_increasing"], + "stale_fields": [] + } + ] + } + ] +} +``` + +#### Device Object + +| Field | Type | Description | +| ---------------- | ----------- | ---------------------------------------------------------------------------------- | +| `device_id` | string/null | HA device registry ID, or null for an adopted device whose card is not created yet | +| `name` | string | The card's display name, or the wire label when there is no card yet | +| `adopted_device` | bool | Whether the card is one adoption minted, rather than a curated SPAN device | +| `rows` | object[] | The curatable rows on that card (see below) | + +#### Row Object + +| Field | Type | Description | +| ------------------------ | ----------- | ----------------------------------------------------------------------------- | +| `key` | string | The curation key for this row — what a save is keyed on | +| `path` | string | The `{node}/{property}` wire address | +| `platform` | string | `sensor`, `binary_sensor`, `switch`, `select`, or `number` | +| `entity_id` | string/null | Null when the entity is not in the registry yet | +| `datatype` | string | The declared Homie datatype | +| `unit` | string/null | The declared unit, verbatim | +| `settable` | bool | Whether the panel accepts a write to this property | +| `name` | string | The entity's name in wire vocabulary | +| `curation` | object | The stored record, as stored; `{}` when the row has never been curated | +| `allowed_device_classes` | string[] | Device classes admissible for this platform and unit; empty for a control row | +| `allowed_state_classes` | string[] | State classes admissible for this row; empty off a numeric sensor | +| `stale_fields` | string[] | Stored fields the current declaration no longer supports | + +`curation` reports what is stored rather than what would be applied. A field named in `stale_fields` is one the wire has outgrown since it was asserted — the +entity is built without it, and the editor shows it so the user can see their assertion was dropped rather than silently losing it. + +A row is listed whether or not its entity exists yet: adopted entities are created disabled, and a vendor extension appears on the setup after its device card +does. Curation is keyed on the wire address rather than on a registry ID, so a row can be curated before its entity exists. + +### Errors + +| Code | Description | +| ---------------- | --------------------------------------------- | +| device_not_found | The device_id does not exist in HA | +| not_span_panel | The device is not a SPAN Panel device | +| not_panel_device | The device_id is a sub-device, not the panel | +| not_loaded | The integration or config entry is not loaded | +| no_data | The coordinator has no panel data yet | + +## `span_panel/adopted/curate` + +Stores the metadata a user asserts for one adopted row, or clears it. The `key` is one `adopted/list` reported: the rows this command accepts are derived the +same way and from the same snapshot, so a key that command did not offer is refused rather than stored. + +**This command writes no registry state.** Enabling an entity, renaming it, giving it an icon or an area, and choosing a display unit are all Core's own +websocket commands, which already ask for admin and already carry the undo. What is here is only what Core has nowhere to put — a state class, a device class, +and prominence for an entity built from a vendor declaration. + +A successful save has three effects and no others: the record is written to the integration's own store, the config entry is scheduled for reload, and the +result is returned. The reload is not incidental. An entity description is fixed when the entity is constructed, so a record reaches its entity only by that +entity being built again — and being built _with_ it, since a state class that first appears after states have been recorded is a statistics reset rather than a +metadata change. + +Admin only, like every command here. + +### Request + +```json +{ + "type": "span_panel/adopted/curate", + "device_id": "", + "key": "nj-2316-005k6_adopted_generator-1/meter/active-power", + "record": { + "state_class": "measurement", + "device_class": "power", + "entity_category": "none" + } +} +``` + +| Field | Type | Description | +| ----------- | ------ | ------------------------------------------------------------------------------------------ | +| `device_id` | string | The device registry ID for the **main SPAN panel**, the same handle `panel_topology` takes | +| `key` | string | The `key` of the row being curated, exactly as `adopted/list` reported it | +| `record` | object | The full record to store; an empty object clears the row | + +#### Record Object + +| Field | Type | Description | +| ----------------- | ------ | -------------------------------------------------------------------------------------- | +| `state_class` | string | `measurement`, `total`, or `total_increasing` — numeric sensor rows only | +| `device_class` | string | A sensor or binary-sensor device class the row's platform and declared unit admit | +| `entity_category` | string | `none`, the one storable value — it promotes the entity out of the diagnostic category | + +`record` replaces the stored record rather than merging into it: a field left out is a field cleared. The values admissible for a given row are exactly the +`allowed_state_classes` and `allowed_device_classes` that `adopted/list` reported for it, so a card built from that response never offers a value this command +refuses. + +### Response + +```json +{ + "record": { + "state_class": "measurement", + "device_class": "power", + "entity_category": "none" + }, + "warnings": [] +} +``` + +| Field | Type | Description | +| ---------- | -------- | ------------------------------------------------------------- | +| `record` | object | The record now stored; `{}` when the row was cleared | +| `warnings` | string[] | Advisory consequences of the save, which has already happened | + +#### Warnings + +| Code | Description | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `statistics_removed` | The save leaves the row without a state class it previously had — cleared outright or narrowed to the other fields — so long-term statistics stop being compiled and HA raises its own `state_class_removed` repair | +| `total_increasing` | The recorder reads a drop of more than a tenth as a meter reset and starts a new cycle, so a reading that legitimately falls manufactures consumption | + +Warnings are never refusals. They name effects the stored record does not show on its face, because both are about the recorder rather than about the entity. + +### Errors + +| Code | Description | +| -------------------------- | ------------------------------------------------------------------------------------- | +| device_not_found | The device_id does not exist in HA | +| not_span_panel | The device is not a SPAN Panel device | +| not_panel_device | The device_id is a sub-device, not the panel | +| not_loaded | The integration or config entry is not loaded | +| no_data | The coordinator has no panel data yet | +| unknown_key | No curatable row on this panel carries that key | +| invalid_state_class | A state class was asserted on a row that is not a numeric sensor | +| invalid_device_class | The value is not a device class for this row's platform | +| incompatible_device_class | The device class does not admit the unit the row declares | +| invalid_field_for_platform | A control row accepts prominence only, not a state class or a device class | +| invalid_format | The request failed the command schema — an unknown value or field, or a malformed key | + +The first five are the codes `adopted/list` answers, from the same resolution: a consumer that learned them for one command does not meet a second set on the +next.