diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0f0cf58..e9ce831c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,12 @@
All notable changes to this project will be documented in this file.
-## [2.1.1]
+## [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
@@ -19,7 +24,7 @@ All notable changes to this project will be documented in this file.
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 modelled no longer waits for a release.
+- **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.
@@ -89,7 +94,7 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol
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, normalised in the library on both
+- **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.
@@ -247,9 +252,9 @@ This release requires Home Assistant 2026.8 to avoid a deprecated API — the ol
### 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
diff --git a/README.md b/README.md
index 6350117a..cd2361dc 100644
--- a/README.md
+++ b/README.md
@@ -44,44 +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.
-## ⚠️ Backup and Upgrade to v2.1.x before your panel's firmware updates, or the integration will stop working (upgrade only from v2.0.8!)
-
-**SPAN firmware `r202633` changes the API in a way that is not backward compatible.** When your panel takes that update, 2.0.8 stops being able to read it. The
-integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go blank.
-It does not fail loudly. It goes quiet.
-
-**Nobody outside SPAN knows when your panel will update, and you cannot defer it from Home Assistant.** Panels update on SPAN's timing. There is no schedule to
-plan around, which is why the safe move is to be on 2.1.x already rather than to wait for the signal, which is your panel going quiet.
-
-**Upgrading first costs you nothing.** On your current firmware, 2.1.x reads your panel exactly as 2.0.8 does.
-
-**The changeover is designed to be seamless.** The integration detects the new format on the wire, reloads itself, and carries on:
-
-- No re-pairing, no re-authentication.
-- Entity ids, unique ids and long-term statistics survive. Dashboards, automations and history follow.
-- New entities appear because the new firmware genuinely publishes more. Nothing you already had is removed.
-
-Upgrade afterwards instead and you reach the same place — after however long it takes you to notice, and to work out that a firmware update is the reason your
-panel went silent. The worst case is a reload.
-
-Take another backup after the upgrade.
-
-
-What changes in the firmware
-
-The firmware upgrade `r202633` rewrites _how_ the panel publishes its self-describing BOM. The MQTT topic structure changes, and the panel stops presenting
-itself as a long list of properties. It presents a tree that can proxy other devices instead. Every topic moves.
-
-The old format is retired in the same update that introduces the new one — there is no overlap and no setting to keep the old behaviour.
-
-
-
## 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 v2.0.8 specifically, if you are upgrading to 2.1.x
- Panel passphrase (found via the SPAN app) **or** physical access to the panel door
## Installation
@@ -100,15 +67,6 @@ The old format is retired in the same update that introduces the new one — the
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
@@ -454,53 +412,56 @@ Applies to Main Meter and Feed Through energy sensors.
| ---------------------------- | ------ | ----------------------------------------------------------------- |
| GFE Override: Grid Connected | Button | Tell the panel the grid is up when BESS communication interrupted |
-### Adopted Devices
+### Adopted Devices and Readings
-The eBus schema is vendor-extensible, so your panel can publish a device type this integration has never modelled. 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.
+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 diagnostic**, so nothing reaches a dashboard uninvited, and 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.
+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.
-Two things worth knowing before you build on one:
+Three things worth knowing before you build on one:
-- **Nothing adopted enters long-term statistics.** No adopted entity carries a `state_class`, because the correct one is not published on the wire and guessing
- wrong writes corrupt statistics that fixing the panel afterwards does not repair. If you want statistics from an adopted reading, wrap it in a template
- sensor, a Riemann-sum integration or a utility meter — a deliberate choice on an entity you enabled.
+- **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. See [Adopted Vendor Readings](#adopted-vendor-readings) below.
+ own.
+- **What the delete button does**, since it is not quite what you would expect:
-### Adopted Vendor Readings
+ - 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.
-The other half of vendor extensibility. A publisher can add a property to a device this integration already models — the battery, a charger, the solar inverter,
-a circuit or the panel itself — and until 2.1.x that reading went nowhere: it appeared in the diagnostics download and in no entity list. It now becomes an
-entity on that device's own card. The wire already says which device and node it belongs to, so it has a home; what it does not say is how important it is.
+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.
-They behave like adopted devices in the ways that matter, and differ as follows:
+**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.
-- **They arrive switched off and filed as diagnostics**, so nothing lands on a dashboard uninvited. The new-entity notification names each one, up to five per
- device; beyond that it gives the device and a count instead, because fifteen new vendor readings at once would otherwise cost you the curated additions in the
- same message.
-- **They are readings only — never switches, selects or number boxes**, even where the panel says the property accepts writes. These sit beside curated controls
- that do real work, such as the charge limit that refuses a value above what your charger was commissioned for, and a generic control would sit there with none
- of that. If a control is worth having, it arrives curated in a release.
-- **They keep the panel's own wording**, so a vendor property on the battery reads `Battery 2 Cell Temperature` rather than something tidied up. Deliberately
- plainer than a curated entity's name: it is how you tell at a glance which entities this integration designed and which it is passing through.
-- **What the delete button does**, since it is not quite what you would expect:
+### The Adopted Tab
- - Delete one while your panel is still publishing that property and it comes back — switched off — at the next reload. There is no setting to suppress it,
- 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.
+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.
-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. A property your panel stops
-publishing is left in place reading unknown rather than removed: silence on the wire does not distinguish a property that is gone from one that has not arrived
-yet, and deleting your entity on a guess is not something an upgrade should do.
+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.
-**These entities are permanent in id, not in identity.** If one of these readings is later curated properly (delivered as an official part of the integration),
-the curated entity is a new entity with its own id and its own history — the adopted one is not renamed into it. That is the trade for surfacing a reading the
-moment it appears rather than waiting for a release to model it, and it is why a vendor reading you have come to depend on is worth raising in an issue:
-curation is what turns it into something with a real name, a proper category and statistics.
+**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
@@ -566,7 +527,7 @@ even after the grid is restored. Manual confirmation or an external sensor is re
When `bess_connected` returns to on, no action is needed — firmware resumes normal GFE management automatically.
-For a detailed discussion of failure scenarios, the MID topology, generator and non-integrated BESS behaviour, and `/set` risk analysis, see
+For a detailed discussion of failure scenarios, the MID topology, generator and non-integrated BESS behavior, and `/set` risk analysis, see
[BESS & Grid Management Deep Dive](bess-grid-management.md).
## Configuration Options
@@ -630,7 +591,6 @@ so a reading that drops and then returns to where it was is a transport artifact
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.
@@ -666,13 +626,13 @@ because it _is_ the anchor everything else is checked against:
Comparing the fingerprint against another source closes the second case. It is in diagnostics under `panel_ca`, in the setup log, and reported identically by
another install of this integration on the same panel. 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, and one that chains but is not named is your panel — 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. |
-| A panel announced by an **add-on** | The add-on's ports are taken as published, because add-ons reallocate their own. The address you configured is kept while it still answers for that panel, and replaced only when it has stopped answering. |
-| The entry has no anchor, or a stored one won't load | **Reauthenticate** acquires one before either sign-in method is offered. |
+| 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, and one that chains but is not named is your panel — 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. |
+| A panel announced by an**add-on** | The add-on's ports are taken as published, because add-ons reallocate their own. The address you configured is kept while it still answers for that panel, and replaced only when it has stopped answering. |
+| The entry has no anchor, or a stored one won't load | **Reauthenticate** acquires one before either sign-in method is offered. |
**Entries from before pinning** pin at the first startup that reaches the panel, logged at `WARNING` with the fingerprint. Until that succeeds the authority is
re-fetched over plaintext on every connection and whatever answers is trusted. If the panel is unreachable the integration starts anyway and retries, because
@@ -686,7 +646,7 @@ the HTTP port off 80, and **Reconfigure** offers the HTTPS port to any pinned en
### Restricting who can operate the panel
-Four options in **Settings → Devices & Services → Span Panel → Configure**. **Every one defaults to the behaviour your panel already has**, so upgrading changes
+Four options in **Settings → Devices & Services → Span Panel → Configure**. **Every one defaults to the behavior your panel already has**, so upgrading changes
nothing until you choose otherwise.
| Option | What it does | What it does not do |
diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py
index cf4a03c7..ff68f348 100644
--- a/custom_components/span_panel/__init__.py
+++ b/custom_components/span_panel/__init__.py
@@ -77,6 +77,7 @@
)
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 (
@@ -677,6 +678,7 @@ def _on_fatal_transport_error(error: SpanPanelError) -> None:
panel_device_id=await ensure_device_registered(
hass, entry, snapshot, smart_device_name
),
+ curation=await async_load_curation(hass, entry),
)
# Before the forward, because a sub-device's `via_device_id` has to name a
@@ -750,6 +752,11 @@ async def async_remove_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -
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
@@ -760,6 +767,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -
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(hass, entry)
diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py
index ad4c8af3..a2a4060d 100644
--- a/custom_components/span_panel/adoption.py
+++ b/custom_components/span_panel/adoption.py
@@ -14,16 +14,23 @@
controls do real work, so `extension.py` mints a terminal identity in plain wire
vocabulary and surfaces even a settable property as a reading.
-**Nothing adopted enters long-term statistics.** No adopted entity carries a
-`state_class`, ever. Three reasons, and the third is the one that shapes the
+**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. A user who wants statistics from an adopted reading
-can wrap it in a template sensor, a Riemann sum or a utility meter, which is
-their call to make on an entity they chose to enable.
+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
@@ -43,17 +50,31 @@
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, SensorEntityDescription
+from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.components.switch import SwitchEntity
-from homeassistant.const import EntityCategory, Platform
+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
+from .util import (
+ ADOPTED_IDENTIFIER_TOKEN,
+ BOOLEAN_DATATYPE,
+ ENUM_DATATYPE,
+ NUMERIC_DATATYPES,
+ declares_a_number,
+)
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
@@ -63,10 +84,6 @@
_LOGGER = logging.getLogger(__name__)
-BOOLEAN_DATATYPE: Final = "boolean"
-ENUM_DATATYPE: Final = "enum"
-NUMERIC_DATATYPES: Final = frozenset({"float", "integer"})
-
MAX_STATE_LENGTH: Final = 255
"""Home Assistant's hard limit on a state string.
@@ -299,6 +316,17 @@ def adopted_unique_id(identifier: str, declaration: AdoptedProperty) -> str:
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,
@@ -386,13 +414,15 @@ def async_register_adopted_devices(
class AdoptedEntity(SpanPanelEntity):
"""Base for an entity built from a declaration rather than from a description.
- Disabled and diagnostic without exception. 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.
+ 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
- _attr_entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC
def __init__(
self,
@@ -402,6 +432,7 @@ def __init__(
declaration: AdoptedProperty,
*,
panel_device_id: str,
+ record: CurationRecord | None = None,
) -> None:
"""Bind this entity to one property of one adopted device."""
super().__init__(coordinator)
@@ -412,6 +443,7 @@ def __init__(
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.
@@ -431,7 +463,7 @@ def _published(self) -> str | None:
class AdoptedSensor(AdoptedEntity, SensorEntity):
- """A reading from an adopted device, with no `state_class`."""
+ """A reading from an adopted device, described by its declaration and its record."""
def __init__(
self,
@@ -441,34 +473,58 @@ def __init__(
declaration: AdoptedProperty,
*,
panel_device_id: str,
+ record: CurationRecord | None = None,
) -> None:
- """Take the unit and device class from what the panel declared."""
+ """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
+ coordinator,
+ identifier,
+ device,
+ declaration,
+ panel_device_id=panel_device_id,
+ record=record,
)
- self.entity_description = SensorEntityDescription(
- key=declaration.path,
- device_class=DEVICE_CLASS_BY_UNIT.get(declaration.unit or ""),
- native_unit_of_measurement=declaration.unit,
+ 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 only where one is declared.
+ """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: the entity has a unit and a device class, and
- putting a string behind those would be a worse lie than reporting
- nothing.
+ than as its raw text: putting a string behind a unit and a device class
+ would be a worse lie than reporting nothing.
- An undeclared one 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.
+ 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 self.entity_description.native_unit_of_measurement is None:
+ if not self._numeric:
return clamp_state(raw, f"Adopted {self._declaration_path}")
try:
return float(raw)
@@ -482,6 +538,34 @@ def native_value(self) -> str | float | 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."""
@@ -505,10 +589,22 @@ def __init__(
declaration: AdoptedProperty,
*,
panel_device_id: str,
+ record: CurationRecord | None = None,
) -> None:
- """Remember the wire address this control publishes to."""
+ """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
+ coordinator,
+ identifier,
+ device,
+ declaration,
+ panel_device_id=panel_device_id,
+ record=record,
)
self._node_id = declaration.node_id
self._property_id = declaration.property_id
@@ -578,10 +674,16 @@ def __init__(
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
+ coordinator,
+ identifier,
+ device,
+ declaration,
+ panel_device_id=panel_device_id,
+ record=record,
)
self._attr_options = parse_enum_format(declaration.format)
@@ -613,6 +715,7 @@ def __init__(
declaration: AdoptedProperty,
*,
panel_device_id: str,
+ record: CurationRecord | None = None,
) -> None:
"""Take the bounds from the declaration, which is what makes this a number.
@@ -632,7 +735,12 @@ def __init__(
"whose format cannot be read is classified as a sensor"
)
super().__init__(
- coordinator, identifier, device, declaration, panel_device_id=panel_device_id
+ 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
@@ -748,6 +856,7 @@ def create_adopted_sensors(
registry: DeviceRegistry,
*,
panel_device_id: str,
+ overlay: CurationOverlay,
) -> list[AdoptedSensor]:
"""Every adopted property that is not a declared boolean.
@@ -761,6 +870,7 @@ def create_adopted_sensors(
registry,
Platform.SENSOR,
panel_device_id=panel_device_id,
+ overlay=overlay,
)
@@ -770,6 +880,7 @@ def create_adopted_binary_sensors(
registry: DeviceRegistry,
*,
panel_device_id: str,
+ overlay: CurationOverlay,
) -> list[AdoptedBinarySensor]:
"""Every adopted property declared `boolean` that the panel accepts no write to."""
return _create(
@@ -779,6 +890,7 @@ def create_adopted_binary_sensors(
registry,
Platform.BINARY_SENSOR,
panel_device_id=panel_device_id,
+ overlay=overlay,
)
@@ -788,6 +900,7 @@ def create_adopted_switches(
registry: DeviceRegistry,
*,
panel_device_id: str,
+ overlay: CurationOverlay,
) -> list[AdoptedSwitch]:
"""Every adopted property declared `boolean` and settable."""
return _create(
@@ -797,6 +910,7 @@ def create_adopted_switches(
registry,
Platform.SWITCH,
panel_device_id=panel_device_id,
+ overlay=overlay,
)
@@ -806,6 +920,7 @@ def create_adopted_selects(
registry: DeviceRegistry,
*,
panel_device_id: str,
+ overlay: CurationOverlay,
) -> list[AdoptedSelect]:
"""Every adopted `enum` that is settable and declares its option list."""
return _create(
@@ -815,6 +930,7 @@ def create_adopted_selects(
registry,
Platform.SELECT,
panel_device_id=panel_device_id,
+ overlay=overlay,
)
@@ -824,6 +940,7 @@ def create_adopted_numbers(
registry: DeviceRegistry,
*,
panel_device_id: str,
+ overlay: CurationOverlay,
) -> list[AdoptedNumber]:
"""Every adopted numeric that is settable and declares its bounds."""
return _create(
@@ -833,6 +950,7 @@ def create_adopted_numbers(
registry,
Platform.NUMBER,
panel_device_id=panel_device_id,
+ overlay=overlay,
)
@@ -844,6 +962,7 @@ def _create[AdoptedT: AdoptedEntity](
platform: Platform,
*,
panel_device_id: str,
+ overlay: CurationOverlay,
) -> list[AdoptedT]:
"""Build one platform's share of the adopted properties, one entity per id.
@@ -877,6 +996,13 @@ def _create[AdoptedT: AdoptedEntity](
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] = {}
@@ -899,9 +1025,18 @@ def _create[AdoptedT: AdoptedEntity](
)
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
+ coordinator,
+ identifier,
+ device,
+ declaration,
+ panel_device_id=panel_device_id,
+ record=record,
)
)
return built
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index 6d6268eb..f260c689 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -691,6 +691,7 @@ async def async_setup_entry(
snapshot,
dr.async_get(hass),
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
@@ -701,6 +702,7 @@ async def async_setup_entry(
snapshot,
dr.async_get(hass),
er.async_get(hass),
+ overlay=config_entry.runtime_data.curation,
),
]
)
diff --git a/custom_components/span_panel/curation.py b/custom_components/span_panel/curation.py
new file mode 100644
index 00000000..16356d85
--- /dev/null
+++ b/custom_components/span_panel/curation.py
@@ -0,0 +1,416 @@
+"""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)
+ stored = await store.async_load()
+ 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)
+ 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})
+
+
+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 9b86bb9f..565e194d 100644
--- a/custom_components/span_panel/diagnostics.py
+++ b/custom_components/span_panel/diagnostics.py
@@ -335,4 +335,8 @@ async def async_get_config_entry_diagnostics(
},
"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/extension.py b/custom_components/span_panel/extension.py
index 4e0eca55..5c949a20 100644
--- a/custom_components/span_panel/extension.py
+++ b/custom_components/span_panel/extension.py
@@ -13,6 +13,15 @@
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.
@@ -29,16 +38,15 @@
from typing import Final
from homeassistant.components.binary_sensor import BinarySensorEntity
-from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
+from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import EntityCategory, Platform
+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 (
- BOOLEAN_DATATYPE,
DEVICE_CLASS_BY_UNIT,
clamp_state,
homie_boolean,
@@ -46,14 +54,24 @@
)
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,
)
_LOGGER = logging.getLogger(__name__)
@@ -192,6 +210,18 @@ def is_extension_unique_id(unique_id: str) -> bool:
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.
@@ -247,10 +277,14 @@ def resolve_platform(registry: EntityRegistry, unique_id: str, datatype: str) ->
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 -- and safely, because these entities carry no
- `state_class` and so have no statistics for a unit change to corrupt. The
- platform is the exception, and the exception is enforced here rather than
- remembered: whatever domain the id is already registered under wins.
+ 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:
@@ -262,10 +296,11 @@ 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. `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.
+ 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:
@@ -316,13 +351,15 @@ def prominence_hint(row: ExtensionProperty) -> str:
class ExtensionEntity(SpanPanelEntity):
"""Base for an entity built from a vendor extension on a curated device.
- Disabled and diagnostic without exception, exactly as `AdoptedEntity` is: the
- integration's job here is to make the reading reachable, not to put it on
- somebody's dashboard.
+ 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
- _attr_entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC
def __init__(
self,
@@ -331,6 +368,7 @@ def __init__(
row: ExtensionProperty,
*,
device_identifier: str,
+ record: CurationRecord | None = None,
) -> None:
"""Bind this entity to one extension property of one curated device."""
super().__init__(coordinator)
@@ -353,6 +391,7 @@ def __init__(
"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.
@@ -380,11 +419,16 @@ def _published(self) -> str | None:
class ExtensionSensor(ExtensionEntity, SensorEntity):
- """A vendor reading on a curated device, with no `state_class`.
-
- No `state_class`, ever, and that is what makes the rest of this safe: an
- entity writing no long-term statistics has nothing for a later unit or
- device-class change to corrupt, so metadata may reshape it freely.
+ """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__(
@@ -394,27 +438,47 @@ def __init__(
row: ExtensionProperty,
*,
device_identifier: str,
+ record: CurationRecord | None = None,
) -> None:
- """Take the unit and device class from what the publisher declared."""
- super().__init__(coordinator, unique_id, row, device_identifier=device_identifier)
- self.entity_description = SensorEntityDescription(
- key=row.path,
- device_class=DEVICE_CLASS_BY_UNIT.get(row.unit or ""),
- native_unit_of_measurement=row.unit,
+ """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 only where one is declared.
-
- An undeclared one is text, and a vendor string is unbounded on the wire,
- so it goes through the clamp `adoption` holds for both halves of vendor
+ """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 self.entity_description.native_unit_of_measurement is None:
+ if not self._numeric:
return clamp_state(raw, f"Extension {self._declaration_path}")
try:
return float(raw)
@@ -428,6 +492,28 @@ def native_value(self) -> str | float | 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."""
@@ -439,10 +525,18 @@ def create_extension_sensors(
snapshot: SpanPanelSnapshot,
device_registry: DeviceRegistry,
entity_registry: EntityRegistry,
+ *,
+ overlay: CurationOverlay,
) -> list[ExtensionSensor]:
"""Every extension property that is not a declared boolean."""
return _create(
- ExtensionSensor, coordinator, snapshot, device_registry, entity_registry, Platform.SENSOR
+ ExtensionSensor,
+ coordinator,
+ snapshot,
+ device_registry,
+ entity_registry,
+ Platform.SENSOR,
+ overlay=overlay,
)
@@ -451,6 +545,8 @@ def create_extension_binary_sensors(
snapshot: SpanPanelSnapshot,
device_registry: DeviceRegistry,
entity_registry: EntityRegistry,
+ *,
+ overlay: CurationOverlay,
) -> list[ExtensionBinarySensor]:
"""Every extension property declared `boolean`."""
return _create(
@@ -460,6 +556,7 @@ def create_extension_binary_sensors(
device_registry,
entity_registry,
Platform.BINARY_SENSOR,
+ overlay=overlay,
)
@@ -470,6 +567,8 @@ def _create[ExtensionT: ExtensionEntity](
device_registry: DeviceRegistry,
entity_registry: EntityRegistry,
platform: Platform,
+ *,
+ overlay: CurationOverlay,
) -> list[ExtensionT]:
"""Build one platform's share of the extension properties.
@@ -477,12 +576,33 @@ def _create[ExtensionT: ExtensionEntity](
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 row, unique_id, device_identifier in adoptable(snapshot, device_registry, entity_registry):
if resolve_platform(entity_registry, unique_id, row.datatype) is not platform:
continue
- built.append(entity_class(coordinator, unique_id, row, device_identifier=device_identifier))
+ key = extension_curation_key(row.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, unique_id, row, device_identifier=device_identifier, record=record
+ )
+ )
return built
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 1e875aac..3eee3e88 100644
--- a/custom_components/span_panel/frontend/dist/span-panel-card.js
+++ b/custom_components/span_panel/frontend/dist/span-panel-card.js
@@ -1,26 +1,26 @@
-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","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","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","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","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","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":"設定","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":"電力","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","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","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",d="bess",h="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 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,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 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 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:A,defineProperty:I,getOwnPropertyDescriptor:P,getOwnPropertyNames:L,getOwnPropertySymbols:E,getPrototypeOf:O}=Object,z=globalThis,N=z.trustedTypes,R=N?N.emptyScript:"",H=z.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)=>!A(t,e),V={attribute:!0,type:String,converter:F,reflect:!1,useDefault:!1,hasChanged:$};
/**
* @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"),z.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&&I(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=O(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=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??$)(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}),(z.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?"":"")),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,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}),dt=ut(1),ht=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?"":"")),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 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 It 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}}It._$litElement$=!0,It.finalized=!0,At.litElementHydrateSupport?.({LitElement:It});const Pt=At.litElementPolyfillSupport;Pt?.({LitElement:It}),(At.litElementVersions??=[]).push("4.2.2");
/**
* @license
* Copyright 2017 Google LLC
@@ -31,12 +31,12 @@ const Lt={attribute:!0,type:String,converter:F,reflect:!1,hasChanged:$},Et=(t=Lt
* @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 Ot(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 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