diff --git a/.github/workflows/ci-simulation-example.yml b/.github/workflows/ci-simulation-example.yml deleted file mode 100644 index 83a6353a..00000000 --- a/.github/workflows/ci-simulation-example.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: CI with Simulation Tests - -on: - workflow_call: - -jobs: - lint-and-test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.13", "3.14"] - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - allow-prereleases: true - - - name: Install Poetry - uses: snok/install-poetry@v1 - - - name: Install dependencies - run: | - # Replace path dependencies with PyPI versions for CI - sed -i 's/span-panel-api = {path = "..\/span-panel-api", develop = true}/span-panel-api = ">=2.2.1"/' pyproject.toml - sed -i 's/ha-synthetic-sensors = {path = "..\/ha-synthetic-sensors", develop = true}/ha-synthetic-sensors = "^1.1.13"/' pyproject.toml - # Regenerate lock file with the modified dependencies - poetry lock - poetry install --with dev - # Install bandit with TOML support - poetry run pip install 'bandit[toml]' - - - name: Format check with ruff - run: poetry run ruff format --check custom_components/span_panel - - - name: Lint with ruff - run: poetry run ruff check custom_components/span_panel - - - name: Type check with mypy - run: poetry run mypy custom_components/span_panel - - - name: Security check with bandit - run: poetry run bandit -c pyproject.toml -r custom_components/span_panel - - - name: Check poetry configuration - run: poetry check - - - name: Run pre-commit hooks (for extra validation) - run: poetry run pre-commit run --all-files --show-diff-on-failure - - # Regular tests - simulation tests are automatically skipped - - name: Run tests with coverage - run: poetry run pytest tests/ --cov=custom_components/span_panel --cov-report=xml --cov-report=term-missing - - # Optional: Run simulation tests separately if desired - - name: Run simulation tests - env: - SPAN_USE_REAL_SIMULATION: 1 - run: poetry run pytest tests/test_solar_configuration_with_simulator.py -v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 400d2504..d883c305 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Install dependencies run: | # Replace path dependencies with PyPI versions for CI - sed -i 's/span-panel-api = {path = "..\/span-panel-api", develop = true}/span-panel-api = ">=2.2.4"/' pyproject.toml + sed -i 's/span-panel-api = {path = "..\/span-panel-api", develop = true}/span-panel-api = "==2.3.2"/' pyproject.toml sed -i 's/ha-synthetic-sensors = {path = "..\/ha-synthetic-sensors", develop = true}/ha-synthetic-sensors = "^1.1.13"/' pyproject.toml # Regenerate lock file with the modified dependencies poetry lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e2e0a2..5dd0afbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ All notable changes to this project will be documented in this file. +## [2.0.4] - 3/2026 + +**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding: + +- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) +- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading + +### Added + +- **Grid Power sensor** — New `Grid Power`. Previously only `Current Power` (upstream lugs measurement) was available; the new sensor surfaces the panel's own + grid power accounting alongside Battery Power, PV Power, and Site Power. Without BESS `Grid Power` is the same as `Current Power`. +- **FQDN registration support** — Config flow detects FQDN-based connections and registers the domain with the panel for TLS certificate SAN inclusion. + Blocked by an upstream API permission issue ([SPAN-API-Client-Docs#10](https://github.com/spanio/SPAN-API-Client-Docs/issues/10)); the integration falls back + to IP-based connections until resolved. + +### 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 add-on. + +### Fixed + +- **MQTT broker hostname resolution across VLANs** — The panel advertises its own mDNS hostname (`.local`) as the MQTT broker address, but mDNS does not resolve + across VLAN boundaries. The integration now uses the user-configured panel host (IP or FQDN) for the MQTT broker connection, since the broker runs on the + panel itself. (#193) +- **PV nameplate capacity unit** — Corrected the PV nameplate capacity sensor unit to watts. + +## [2.0.3] - 3/2026 + +**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding: + +- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT) +- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading + +### Fixed + +- **Force dependency re-resolution** — Version bump to ensure HACS re-installs `span-panel-api` for users who had the earlier 2.0.2 release. Users upgrading HA + without re-downloading the integration could be left with a stale library missing required imports. (#191) + ## [2.0.2] - 3/2026 **Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding: @@ -12,15 +51,15 @@ All notable changes to this project will be documented in this file. ### Fixed - **Panel size always available** — `panel_size` is now sourced from the Homie schema by the underlying `span-panel-api` Previously some users could see fewer - unmapped sensors when trailing breaker positions were empty. Topology service reflects panel size. + unmapped sensors when trailing breaker positions were empty. Topology service reflects panel size. - **Battery power sign inverted** — Battery power sensor now uses the correct sign convention. Previously, charging was reported as positive and discharging as negative, which caused HA energy cards to show the battery discharging when it was actually charging. The panel reports power from its own perspective; the sensor now negates the value to match HA conventions (positive = discharging), consistent with how PV power is already handled. (#184) - **Idle circuits showing -0W** — Power sensors that negate values (PV circuits, battery, PV power) could produce IEEE 754 negative zero (`-0.0`) when the circuit was idle, causing HA to display `-0W` instead of `0W`. All negation sites now normalize zero to positive. (#185) -- **Net energy inconsistent with dip-compensated consumed/produced** — When energy dip compensation was enabled, consumed and produced sensors applied an - offset but net energy computed from raw snapshot values, causing a visible mismatch. Net energy now reads dip offsets from its sibling sensors so the - displayed value always equals compensated consumed minus compensated produced. +- **Net energy inconsistent with dip-compensated consumed/produced** — When energy dip compensation was enabled, consumed and produced sensors applied an offset + but net energy computed from raw snapshot values, causing a visible mismatch. Net energy now reads dip offsets from its sibling sensors so the displayed value + always equals compensated consumed minus compensated produced. ## [2.0.1] - 3/2026 diff --git a/README.md b/README.md index 6e26ffb2..98977510 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,13 @@ execute these actions without user presence; design them with the same care you safety device and must not be relied upon for life-safety applications. Use this software at your own risk. If you cannot accept that risk, do not use this software. See [LICENSE](LICENSE) for the full warranty disclaimer. +The SPAN Client documentation has warnings regarding the use of the API (the API used by this integration) which should be heeded just as if you were using that +API directly: + +> An API client that attempts to implement its own load-shedding decisions, grid-state detection, or other critical automation is operating outside the scope of +> what SPAN API was designed and engineered for. Such use is entirely at the client developer's and homeowner's own risk and may void the SPAN Panel Limited +> Warranty. See the SPAN API Scope & Responsibility Model in the [SPAN API documentation](https://github.com/spanio/SPAN-API-Client-Docs). + This integration provides sensors and controls for understanding an installation's power consumption, energy usage, and controlling user-manageable panel circuits. You can optionally use the [span-card](https://github.com/SpanPanel/span-card) Lovelace card for visualization and switch control. @@ -126,6 +133,7 @@ If you encounter issues, restore from your backup or check the [troubleshooting | ------------- | ------------ | ---- | --------------------------------------------------------------------------- | | Battery Power | Power | W | Battery charge/discharge (+discharge, -charge). Only when BESS commissioned | | PV Power | Power | W | PV generation (+producing). Only when PV commissioned | +| Grid Power | Power | W | Computed grid power flow. Only when power-flows node active | | Site Power | Power | W | Total site power (grid + PV + battery). Only when power-flows node active | ### PV Metadata Sensors (v2 only, on main panel device) @@ -144,7 +152,7 @@ If you encounter issues, restore from your backup or check the [troubleshooting ### Power Sensor Attributes -Applies to Current Power, Feed Through Power, Battery Power, PV Power, and Site Power sensors. +Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Power, and Site Power sensors. | Attribute | Type | Notes | | ---------- | ------ | ------------------------------------ | diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 027b90d2..3872eaa5 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -4,24 +4,28 @@ import asyncio from dataclasses import dataclass -from datetime import datetime import logging -import os -from pathlib import Path +from typing import cast from homeassistant.components.persistent_notification import async_create as pn_create from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.core import ( + CoreState, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, ConfigEntryNotReady, + ServiceValidationError, ) from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import slugify +from homeassistant.helpers.typing import ConfigType from span_panel_api import ( - DynamicSimulationEngine, SpanMqttClient, SpanPanelSnapshot, detect_api_version, @@ -37,18 +41,16 @@ from . import config_flow # noqa: F401 # type: ignore[misc] from .const import ( CONF_API_VERSION, - CONF_DEVICE_NAME, CONF_EBUS_BROKER_HOST, CONF_EBUS_BROKER_PASSWORD, CONF_EBUS_BROKER_PORT, CONF_EBUS_BROKER_USERNAME, - CONF_SIMULATION_CONFIG, - CONF_SIMULATION_OFFLINE_MINUTES, - CONF_SIMULATION_START_TIME, + CONF_HTTP_PORT, DEFAULT_SNAPSHOT_INTERVAL, DOMAIN, ) from .coordinator import SpanPanelCoordinator +from .helpers import build_circuit_unique_id from .migration import migrate_config_entry_sensors from .options import SNAPSHOT_UPDATE_INTERVAL from .util import snapshot_to_device_info @@ -74,8 +76,17 @@ class SpanPanelRuntimeData: _LOGGER = logging.getLogger(__name__) -# Config entry version — bumped to 5 for v2 sensor alignment (remove wwanLink binary) -CURRENT_CONFIG_VERSION = 5 +# Config entry version — bumped to 6 for simulation removal +CURRENT_CONFIG_VERSION = 6 + +# Map internal device_type values to external manifest format +_DEVICE_TYPE_MAP: dict[str, str] = {"bess": "battery"} + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Span Panel integration (domain-level, called once).""" + _async_register_services(hass) + return True async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -237,6 +248,31 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) _LOGGER.debug("Migrated config entry %s to version 5", config_entry.entry_id) + # --- v5 → v6: reject simulation entries --- + if config_entry.version < 6: + if config_entry.data.get(CONF_API_VERSION) == "simulation" or config_entry.data.get( + "simulation_mode", False + ): + pn_create( + hass, + "This SPAN Panel config entry was a **built-in simulator** which " + "has been removed in this version. Please remove this entry and " + "use the standalone SPAN simulator instead.", + title="SPAN Panel: Simulation Entry Removed", + notification_id=f"span_simulation_removed_{config_entry.entry_id}", + ) + _LOGGER.warning( + "Config entry %s is a simulation entry — rejecting migration", + config_entry.entry_id, + ) + return False + + hass.config_entries.async_update_entry( + config_entry, + version=6, + ) + _LOGGER.debug("Migrated config entry %s to version 6", config_entry.entry_id) + return True @@ -283,13 +319,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> if not serial_number: raise ConfigEntryNotReady("Config entry has no unique_id (serial number)") + # The MQTT broker runs on the panel itself. The panel advertises + # its own mDNS hostname (.local) as ebusBrokerHost, but mDNS + # does not resolve across VLAN boundaries. Use the user-configured + # panel host (IP or FQDN) which is known reachable. + advertised_broker = config[CONF_EBUS_BROKER_HOST] + if advertised_broker != host: + _LOGGER.debug( + "Panel advertised broker host '%s' differs from configured " + "host '%s'; using configured host for MQTT connection", + advertised_broker, + host, + ) + broker_config = MqttClientConfig( - broker_host=config[CONF_EBUS_BROKER_HOST], + broker_host=host, username=config[CONF_EBUS_BROKER_USERNAME], password=config[CONF_EBUS_BROKER_PASSWORD], mqtts_port=int(config[CONF_EBUS_BROKER_PORT]), ) + panel_http_port = int(config.get(CONF_HTTP_PORT, 80)) + snapshot_interval = entry.options.get( SNAPSHOT_UPDATE_INTERVAL, DEFAULT_SNAPSHOT_INTERVAL ) @@ -298,6 +349,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> serial_number, broker_config, snapshot_interval=snapshot_interval, + panel_http_port=panel_http_port, ) try: await client.connect() @@ -312,44 +364,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> await coordinator.async_config_entry_first_refresh() await coordinator.async_setup_streaming() - # --- Simulation entries --- - elif api_version == "simulation": - selected_config = config.get(CONF_SIMULATION_CONFIG, "simulation_config_32_circuit") - current_dir = os.path.dirname(__file__) - config_path = Path(current_dir) / "simulation_configs" / f"{selected_config}.yaml" - - serial_number = entry.unique_id or f"SPAN-SIM-{entry.entry_id[:8]}" - - engine = DynamicSimulationEngine( - serial_number=serial_number, - config_path=config_path, - ) - await engine.initialize_async() - - # Apply simulation start time override if configured - simulation_start_time_str = config.get(CONF_SIMULATION_START_TIME) or entry.options.get( - CONF_SIMULATION_START_TIME - ) - if simulation_start_time_str: - try: - datetime.fromisoformat(simulation_start_time_str) - engine.override_simulation_start_time(simulation_start_time_str) - _LOGGER.debug("Using simulation start time: %s", simulation_start_time_str) - except (ValueError, TypeError) as e: - _LOGGER.warning( - "Invalid simulation start time '%s': %s", - simulation_start_time_str, - e, - ) - - coordinator = SpanPanelCoordinator(hass, engine, entry) - await coordinator.async_config_entry_first_refresh() - - # Apply simulation offline mode if configured - simulation_offline_minutes = entry.options.get(CONF_SIMULATION_OFFLINE_MINUTES, 0) - if simulation_offline_minutes > 0: - coordinator.set_simulation_offline_mode(simulation_offline_minutes) - else: raise ConfigEntryError(f"Unknown api_version: {api_version}") @@ -362,10 +376,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> snapshot: SpanPanelSnapshot = coordinator.data serial_number = snapshot.serial_number - is_simulator = api_version == "simulation" - - # Create smart default name - base_name = "SPAN Simulator" if is_simulator else "SPAN Panel" + base_name = "SPAN Panel" # Check existing config entries to avoid conflicts existing_entries = hass.config_entries.async_entries(DOMAIN) @@ -424,10 +435,7 @@ async def async_remove_config_entry_device( snapshot = coordinator.data # Identify the main panel device identifier - serial_number = snapshot.serial_number - is_simulator = config_entry.data.get(CONF_API_VERSION) == "simulation" - device_name = config_entry.data.get(CONF_DEVICE_NAME, config_entry.title) - panel_identifier = slugify(device_name) if is_simulator and device_name else serial_number + panel_identifier = snapshot.serial_number # Prevent removal of the main panel device for identifier in device_entry.identifiers: @@ -445,24 +453,8 @@ async def update_listener(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> N if hass.state is not CoreState.running: return - coordinator = entry.runtime_data.coordinator - - # Update simulation offline mode if this is a simulation entry - api_version = entry.data.get(CONF_API_VERSION) - if api_version == "simulation": - simulation_offline_minutes = entry.options.get(CONF_SIMULATION_OFFLINE_MINUTES, 0) - _LOGGER.info( - "Update listener: processing simulation_offline_minutes = %s", - simulation_offline_minutes, - ) - coordinator.set_simulation_offline_mode(simulation_offline_minutes) - - if hass.state is not CoreState.running: - return - - if _requires_full_reload(entry): - await hass.config_entries.async_reload(entry.entry_id) - _LOGGER.debug("Successfully reloaded SPAN Panel integration") + await hass.config_entries.async_reload(entry.entry_id) + _LOGGER.debug("Successfully reloaded SPAN Panel integration") except asyncio.CancelledError: raise @@ -470,20 +462,6 @@ async def update_listener(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> N _LOGGER.error("Failed to reload SPAN Panel integration: %s", e, exc_info=True) -def _requires_full_reload(entry: ConfigEntry) -> bool: - """Determine if a full integration reload is required. - - Simulation-only option changes (offline minutes, start time) are applied - in-place via the coordinator and do not require a reload. - """ - has_simulation_flag = entry.options.get("_simulation_only_change", False) - if has_simulation_flag: - _LOGGER.debug("Simulation-only change detected - no reload needed") - return False - - return True - - async def ensure_device_registered( hass: HomeAssistant, entry: SpanPanelConfigEntry, @@ -493,40 +471,90 @@ async def ensure_device_registered( """Register or reconcile the HA Device before creating sensors. Ensures the device exists in the device registry with proper naming and - identifiers. For simulators, moves existing entities to the correct device - if the identifier changed due to a name change. + identifiers. """ device_registry = dr.async_get(hass) serial_number = snapshot.serial_number - is_simulator = entry.data.get(CONF_API_VERSION) == "simulation" host = entry.data.get(CONF_HOST) - desired_identifier = slugify(device_name) if is_simulator and device_name else serial_number - existing_device = device_registry.async_get_device(identifiers={(DOMAIN, desired_identifier)}) + existing_device = device_registry.async_get_device(identifiers={(DOMAIN, serial_number)}) if existing_device: if existing_device.name == serial_number: device_registry.async_update_device(existing_device.id, name=device_name) - target_device = existing_device else: - device_info = snapshot_to_device_info(snapshot, device_name, is_simulator, host) - device = device_registry.async_get_or_create(config_entry_id=entry.entry_id, **device_info) - target_device = device + device_info = snapshot_to_device_info(snapshot, device_name, host=host) + device_registry.async_get_or_create(config_entry_id=entry.entry_id, **device_info) - # For simulators: move entities to the target device if their current device differs - try: - if is_simulator: - entity_registry = er.async_get(hass) - entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - for ent in entries: - if ent.device_id != target_device.id: - _LOGGER.debug( - "Moving entity %s from device %s to %s", - ent.entity_id, - ent.device_id, - target_device.id, - ) - entity_registry.async_update_entity(ent.entity_id, device_id=target_device.id) - except (KeyError, ValueError, AttributeError) as err: - _LOGGER.warning("Failed to reassign entities to target device: %s", err) + +def _async_register_services(hass: HomeAssistant) -> None: + """Register domain-level services (called once per HA instance).""" + + async def async_handle_export_manifest( + _call: ServiceCall, + ) -> ServiceResponse: + """Export circuit manifest for all configured SPAN panels.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + raise ServiceValidationError( + "No SPAN panel config entries are loaded. " + "Add and configure a SPAN panel before calling this service." + ) + + entity_reg = er.async_get(hass) + panels = [] + + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if not hasattr(entry, "runtime_data") or not isinstance( + entry.runtime_data, SpanPanelRuntimeData + ): + continue + + snapshot = entry.runtime_data.coordinator.data + if snapshot is None: + continue + + serial = snapshot.serial_number + circuits = [] + + for circuit_id, circuit in snapshot.circuits.items(): + if circuit_id.startswith("unmapped_tab_"): + continue + + tabs = getattr(circuit, "tabs", None) + if not tabs: + continue + + unique_id = build_circuit_unique_id(serial, circuit_id, "instantPowerW") + entity_id = entity_reg.async_get_entity_id("sensor", DOMAIN, unique_id) + if entity_id is None: + continue + + raw_type = getattr(circuit, "device_type", "circuit") + + circuits.append( + { + "entity_id": entity_id, + "template": f"clone_{min(tabs)}", + "device_type": _DEVICE_TYPE_MAP.get(raw_type, raw_type), + "tabs": list(tabs), + } + ) + + if circuits: + panels.append( + { + "serial": serial, + "host": entry.data[CONF_HOST], + "circuits": circuits, + } + ) + + return cast(ServiceResponse, {"panels": panels}) + + hass.services.async_register( + DOMAIN, + "export_circuit_manifest", + async_handle_export_manifest, + supports_response=SupportsResponse.ONLY, + ) diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index cef353fb..dda0c853 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -15,12 +15,10 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify from span_panel_api import SpanEvseSnapshot, SpanPanelSnapshot from . import SpanPanelConfigEntry from .const import ( - CONF_API_VERSION, CONF_DEVICE_NAME, PANEL_STATUS, SYSTEM_DOOR_STATE, @@ -301,17 +299,13 @@ def __init__( self._value_fn = description.value_fn # Build EVSE sub-device info - is_simulator = data_coordinator.config_entry.data.get(CONF_API_VERSION) == "simulation" panel_name = ( data_coordinator.config_entry.data.get( CONF_DEVICE_NAME, data_coordinator.config_entry.title ) or "Span Panel" ) - if is_simulator: - panel_identifier = slugify(panel_name) - else: - panel_identifier = snapshot.serial_number + panel_identifier = snapshot.serial_number evse = snapshot.evse.get(evse_id, _EMPTY_EVSE) use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) @@ -365,17 +359,12 @@ async def async_setup_entry( # Add BESS connected sensor on the BESS sub-device when battery is commissioned if has_bess(snapshot): - is_simulator = coordinator.config_entry.data.get(CONF_API_VERSION) == "simulation" panel_name = ( coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title) or "Span Panel" ) - if is_simulator: - panel_identifier = slugify(panel_name) - else: - panel_identifier = snapshot.serial_number - bess_info = bess_device_info(panel_identifier, snapshot.battery, panel_name) + bess_info = bess_device_info(snapshot.serial_number, snapshot.battery, panel_name) entities.append( SpanPanelBinarySensor( coordinator, BESS_CONNECTED_SENSOR, device_info_override=bess_info diff --git a/custom_components/span_panel/button.py b/custom_components/span_panel/button.py index 08e3c0da..604081b2 100644 --- a/custom_components/span_panel/button.py +++ b/custom_components/span_panel/button.py @@ -66,7 +66,7 @@ async def async_press(self) -> None: """Publish the GFE override to the panel.""" client = self.coordinator.client if not hasattr(client, "set_dominant_power_source"): - _LOGGER.warning("GFE override not available in simulation mode") + _LOGGER.warning("Client does not support GFE override") return try: diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 72247ee6..71423572 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -2,13 +2,11 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping import enum import logging -from pathlib import Path -import shutil -from time import time -from typing import Any, cast +from typing import Any from homeassistant import config_entries from homeassistant.config_entries import ( @@ -18,23 +16,20 @@ ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import callback -from homeassistant.helpers.selector import selector from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.network import is_ipv4_address -from span_panel_api import V2AuthResponse, detect_api_version +from span_panel_api import V2AuthResponse, delete_fqdn, detect_api_version, register_fqdn from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelConnectionError -from span_panel_api.simulation import DynamicSimulationEngine, SimulationConfig import voluptuous as vol -import yaml from .config_flow_utils import ( build_general_options_schema, - get_available_simulation_configs, + check_fqdn_tls_ready, get_general_options_defaults, + is_fqdn, process_general_options_input, validate_auth_token, validate_host, - validate_simulation_time, validate_v2_passphrase, validate_v2_proximity, ) @@ -45,10 +40,9 @@ CONF_EBUS_BROKER_PORT, CONF_EBUS_BROKER_USERNAME, CONF_HOP_PASSPHRASE, + CONF_HTTP_PORT, CONF_PANEL_SERIAL, - CONF_SIMULATION_CONFIG, - CONF_SIMULATION_OFFLINE_MINUTES, - CONF_SIMULATION_START_TIME, + CONF_REGISTERED_FQDN, DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION, ENTITY_NAMING_PATTERN, @@ -56,24 +50,16 @@ USE_DEVICE_PREFIX, EntityNamingPattern, ) -from .helpers import generate_unique_simulator_serial_number from .options import ( ENERGY_DISPLAY_PRECISION, ENERGY_REPORTING_GRACE_PERIOD, POWER_DISPLAY_PRECISION, SNAPSHOT_UPDATE_INTERVAL, ) -from .simulation_utils import clone_panel_to_simulation _LOGGER = logging.getLogger(__name__) -# Simulation config import/export option keys -SIM_FILE_KEY = "simulation_config_file" -SIM_EXPORT_PATH = "simulation_export_path" -SIM_IMPORT_PATH = "simulation_import_path" - - class ConfigFlowError(Exception): """Custom exception for config flow internal errors.""" @@ -83,7 +69,7 @@ def get_user_data_schema(default_host: str = "") -> vol.Schema: return vol.Schema( { vol.Optional(CONF_HOST, default=default_host): str, - vol.Optional("simulator_mode", default=False): bool, + vol.Optional(CONF_HTTP_PORT, default=80): int, vol.Optional(POWER_DISPLAY_PRECISION, default=0): int, vol.Optional(ENERGY_DISPLAY_PRECISION, default=2): int, vol.Optional(ENABLE_ENERGY_DIP_COMPENSATION, default=True): bool, @@ -145,8 +131,12 @@ def __init__(self) -> None: self._v2_broker_password: str | None = None self._v2_passphrase: str | None = None self._v2_panel_serial: str | None = None + self._http_port: int = 80 # Energy dip compensation default for fresh installs self._enable_dip_compensation: bool = True + # FQDN registration task (async_show_progress) + self._fqdn_task: asyncio.Task[None] | None = None + self._reconfigure_fqdn_task: asyncio.Task[None] | None = None async def setup_flow(self, trigger_type: TriggerFlowType, host: str) -> None: """Set up the flow by detecting the panel API version and serial number.""" @@ -155,7 +145,7 @@ async def setup_flow(self, trigger_type: TriggerFlowType, host: str) -> None: _LOGGER.error("Flow setup attempted when already set up") raise ConfigFlowError("Flow is already set up") - result = await detect_api_version(host) + result = await detect_api_version(host, port=self._http_port) self.api_version = result.api_version self.trigger_flow_type = trigger_type @@ -181,12 +171,14 @@ def ensure_flow_is_set_up(self) -> None: _LOGGER.error("Flow method called before setup") raise ConfigFlowError("Flow is not set up") - async def ensure_not_already_configured(self) -> None: + async def ensure_not_already_configured(self, raise_on_progress: bool = True) -> None: """Ensure the panel is not already configured.""" self.ensure_flow_is_set_up() - # Abort if we had already set this panel up - await self.async_set_unique_id(self.serial_number) + # Abort if we had already set this panel up. + # User-initiated flows pass raise_on_progress=False so they can + # proceed when a zeroconf discovery flow is already running. + await self.async_set_unique_id(self.serial_number, raise_on_progress=raise_on_progress) self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> ConfigFlowResult: @@ -212,7 +204,16 @@ async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> Conf if is_v2_service: # v2 panels discovered via eBus / secure-mqtt service types - detection = await detect_api_version(discovery_info.host) + # Read optional httpPort from mDNS TXT records (non-standard port) + props = discovery_info.properties or {} + http_port_str = props.get("httpPort", props.get("httpport", "")) + try: + http_port = int(http_port_str) if http_port_str else 80 + except (ValueError, TypeError): + http_port = 80 + self._http_port = http_port + + detection = await detect_api_version(discovery_info.host, port=http_port) if detection.api_version != "v2" or detection.status_info is None: # The v2 endpoint did not respond — this IP is not a valid # v2 panel (e.g., an internal link address we didn't filter). @@ -257,12 +258,8 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con user_input, ) - # Check if simulator mode is enabled - if user_input.get("simulator_mode", False): - return await self._handle_simulator_setup(user_input) - - # For non-simulator mode, host is required host: str = user_input.get(CONF_HOST, "").strip() + self._http_port = int(user_input.get(CONF_HTTP_PORT, 80)) if not host: return self.async_show_form( step_id="user", @@ -271,7 +268,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con ) # Validate host before setting up flow - if not await validate_host(self.hass, host): + if not await validate_host(self.hass, host, port=self._http_port): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, @@ -279,7 +276,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con ) # Detect v2 API before setting up the v1 flow - detection = await detect_api_version(host) + detection = await detect_api_version(host, port=self._http_port) self.api_version = detection.api_version if self.api_version == "v2": @@ -296,161 +293,23 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con }, } self._is_flow_setup = True - await self.ensure_not_already_configured() + await self.ensure_not_already_configured(raise_on_progress=False) return await self.async_step_choose_v2_auth() # v1 path: probe via the existing setup_flow if not self._is_flow_setup: await self.setup_flow(TriggerFlowType.CREATE_ENTRY, host) - await self.ensure_not_already_configured() + await self.ensure_not_already_configured(raise_on_progress=False) return await self.async_step_choose_auth_type() - async def _handle_simulator_setup(self, user_input: dict[str, Any]) -> ConfigFlowResult: - """Handle simulator mode setup.""" - # Precision settings already stored in async_step_user - - # Check if this is the initial simulator selection or the config selection - if CONF_SIMULATION_CONFIG not in user_input: - # Show simulator configuration selection - return await self.async_step_simulator_config() - - # Get the simulation config and host - simulation_config = user_input[CONF_SIMULATION_CONFIG] - host = user_input.get(CONF_HOST, "").strip() - simulation_start_time = user_input.get(CONF_SIMULATION_START_TIME, "").strip() - - # Generate unique simulator serial number first - simulator_serial = generate_unique_simulator_serial_number(self.hass) - - # Use the generated simulator serial number as the host - # This ensures the span panel API uses the correct serial number - host = simulator_serial - - # Create entry for simulator mode - base_name = "Span Simulator" - device_name = self.get_unique_device_name(base_name) - - # Prepare config data - config_data: dict[str, Any] = { - CONF_HOST: host, # This is now the simulator serial number (sim-nnn) - CONF_ACCESS_TOKEN: "simulator_token", - CONF_API_VERSION: "simulation", - "simulation_mode": True, - CONF_SIMULATION_CONFIG: simulation_config, - "device_name": device_name, - "simulator_serial_number": simulator_serial, - } - - # Add simulation start time if provided - if simulation_start_time: - try: - validated_time = validate_simulation_time(simulation_start_time) - config_data[CONF_SIMULATION_START_TIME] = validated_time - except ValueError as e: - return self.async_show_form( - step_id="simulator_config", - data_schema=self.add_suggested_values_to_schema( - vol.Schema( - { - vol.Required( - CONF_SIMULATION_CONFIG, default="simulation_config_32_circuit" - ): vol.In(get_available_simulation_configs()), - vol.Optional(CONF_HOST, default=""): str, - vol.Optional(CONF_SIMULATION_START_TIME, default=""): str, - } - ), - user_input, - ), - errors={"base": str(e)}, - ) - - _LOGGER.debug( - "SIMULATOR_CONFIG_DEBUG: Creating simulator entry with precision - power: %s, energy: %s", - self.power_display_precision, - self.energy_display_precision, - ) - # Determine simulator naming flags based on selection (default Friendly Names) - selected_pattern = user_input.get( - ENTITY_NAMING_PATTERN, EntityNamingPattern.FRIENDLY_NAMES.value - ) - sim_use_device_prefix = True - sim_use_circuit_numbers = selected_pattern == EntityNamingPattern.CIRCUIT_NUMBERS.value - - return self.async_create_entry( - title=device_name, - data=config_data, - options={ - USE_DEVICE_PREFIX: sim_use_device_prefix, - USE_CIRCUIT_NUMBERS: sim_use_circuit_numbers, - POWER_DISPLAY_PRECISION: self.power_display_precision, - ENERGY_DISPLAY_PRECISION: self.energy_display_precision, - ENABLE_ENERGY_DIP_COMPENSATION: self._enable_dip_compensation, - }, - ) - - async def async_step_simulator_config( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle simulator configuration selection.""" - if user_input is None: - # Discover files dynamically and build dropdown options - available_configs = get_available_simulation_configs() - options_list = [ - {"value": key, "label": label} for key, label in available_configs.items() - ] - - # Choose a sensible default - default_key = ( - "simulation_config_32_circuit" - if "simulation_config_32_circuit" in available_configs - else next(iter(available_configs.keys())) - ) - - # Create schema with forced dropdown for simulation configuration - schema = vol.Schema( - { - vol.Required(CONF_SIMULATION_CONFIG, default=default_key): selector( - { - "select": { - "options": options_list, - "mode": "dropdown", - } - } - ), - vol.Optional(CONF_HOST, default=""): str, - vol.Optional(CONF_SIMULATION_START_TIME, default=""): str, - vol.Required( - ENTITY_NAMING_PATTERN, default=EntityNamingPattern.FRIENDLY_NAMES.value - ): vol.In( - { - EntityNamingPattern.FRIENDLY_NAMES.value: "Circuit Friendly Names", - EntityNamingPattern.CIRCUIT_NUMBERS.value: "Tab Based Names", - } - ), - } - ) - - return self.async_show_form( - step_id="simulator_config", - data_schema=schema, - description_placeholders={ - "config_count": str(len(available_configs)), - }, - ) - - # Continue with simulator setup using the selected config - # Ensure simulator_mode is set since it's not in the form data - user_input_with_sim_mode = dict(user_input) - user_input_with_sim_mode["simulator_mode"] = True - return await self._handle_simulator_setup(user_input_with_sim_mode) - async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowResult: """Handle a flow initiated by re-auth.""" host = entry_data[CONF_HOST] + self._http_port = int(entry_data.get(CONF_HTTP_PORT, 80)) # Detect current API version of the panel - detection = await detect_api_version(host) + detection = await detect_api_version(host, port=self._http_port) self.api_version = detection.api_version if self.api_version == "v2": @@ -535,7 +394,7 @@ async def async_step_auth_proximity( return self.async_abort(reason="host_not_set") try: - result = await validate_v2_proximity(self.host) + result = await validate_v2_proximity(self.host, port=self._http_port) except SpanPanelAuthError: return self.async_show_form( step_id="auth_proximity", @@ -617,7 +476,7 @@ async def async_step_auth_passphrase( return self.async_abort(reason="host_not_set") try: - result = await validate_v2_passphrase(self.host, passphrase) + result = await validate_v2_passphrase(self.host, passphrase, port=self._http_port) except SpanPanelAuthError: return self.async_show_form( step_id="auth_passphrase", @@ -650,8 +509,71 @@ async def _async_finalize_v2_auth(self) -> ConfigFlowResult: if "entry_id" not in self.context: raise ValueError("Entry ID is missing from context") return self._update_v2_entry(self.context["entry_id"]) + # If host is an FQDN, register it with the panel for TLS cert SAN inclusion + if self.host and is_fqdn(self.host): + return await self.async_step_register_fqdn() return await self.async_step_choose_entity_naming_initial() + async def async_step_register_fqdn( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Register FQDN with the panel and wait for TLS certificate update.""" + if not self._fqdn_task: + self._fqdn_task = self.hass.async_create_task( + self._async_register_fqdn_and_wait(), + "span_panel_register_fqdn", + ) + + if not self._fqdn_task.done(): + return self.async_show_progress( + step_id="register_fqdn", + progress_action="registering_fqdn", + progress_task=self._fqdn_task, + ) + + try: + self._fqdn_task.result() + except Exception: + _LOGGER.exception("FQDN registration failed for %s", self.host) + self._fqdn_task = None + return self.async_show_progress_done(next_step_id="fqdn_failed") + + self._fqdn_task = None + return self.async_show_progress_done(next_step_id="choose_entity_naming_initial") + + async def _async_register_fqdn_and_wait(self) -> None: + """Register the FQDN and poll until the TLS cert includes it.""" + if not self.host or not self.access_token: + raise ConfigFlowError("Host and access token required for FQDN registration") + + await register_fqdn(self.host, self.access_token, self.host, port=self._http_port) + + mqtts_port = self._v2_broker_port or 8883 + max_attempts = 30 + for attempt in range(max_attempts): + await asyncio.sleep(2) + if await check_fqdn_tls_ready(self.host, mqtts_port, http_port=self._http_port): + _LOGGER.debug( + "FQDN %s found in TLS cert SAN after %d attempts", + self.host, + attempt + 1, + ) + return + + raise ConfigFlowError(f"Timed out waiting for TLS certificate to include FQDN {self.host}") + + async def async_step_fqdn_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle FQDN registration failure — user may continue without it.""" + if user_input is not None: + return await self.async_step_choose_entity_naming_initial() + return self.async_show_form( + step_id="fqdn_failed", + data_schema=vol.Schema({}), + errors={"base": "fqdn_registration_failed"}, + ) + async def async_step_resolve_entity( self, entry_data: dict[str, Any] | None = None, @@ -720,6 +642,10 @@ def create_new_entry( entry_data[CONF_EBUS_BROKER_PASSWORD] = self._v2_broker_password entry_data[CONF_HOP_PASSPHRASE] = self._v2_passphrase entry_data[CONF_PANEL_SERIAL] = self._v2_panel_serial + if self._http_port != 80: + entry_data[CONF_HTTP_PORT] = self._http_port + if is_fqdn(host): + entry_data[CONF_REGISTERED_FQDN] = host return self.async_create_entry( title=device_name, @@ -749,6 +675,8 @@ def _update_v2_entry(self, entry_id: str) -> ConfigFlowResult: updated_data[CONF_EBUS_BROKER_PASSWORD] = self._v2_broker_password updated_data[CONF_HOP_PASSPHRASE] = self._v2_passphrase updated_data[CONF_PANEL_SERIAL] = self._v2_panel_serial + if self._http_port != 80: + updated_data[CONF_HTTP_PORT] = self._http_port self.hass.config_entries.async_update_entry(entry, data=updated_data) self.hass.async_create_task(self.hass.config_entries.async_reload(entry_id)) @@ -847,8 +775,9 @@ async def async_step_reconfigure( ) # Validate the host is reachable and is a v2 panel + http_port = int(reconfigure_entry.data.get(CONF_HTTP_PORT, 80)) try: - detection = await detect_api_version(host) + detection = await detect_api_version(host, port=http_port) except (SpanPanelConnectionError, Exception): return self.async_show_form( step_id="reconfigure", @@ -867,9 +796,83 @@ async def async_step_reconfigure( await self.async_set_unique_id(detection.status_info.serial_number) self._abort_if_unique_id_mismatch(reason="unique_id_mismatch") + if is_fqdn(host): + # New host is FQDN — register it (replaces any existing FQDN on the panel) + self.host = host + self.access_token = str(reconfigure_entry.data.get(CONF_ACCESS_TOKEN, "")) + self._http_port = http_port + self._v2_broker_port = int(reconfigure_entry.data.get(CONF_EBUS_BROKER_PORT, 8883)) + return await self.async_step_reconfigure_register_fqdn() + + # New host is not an FQDN — simple update + data_updates: dict[str, Any] = {CONF_HOST: host} + old_fqdn = str(reconfigure_entry.data.get(CONF_REGISTERED_FQDN, "")) + if old_fqdn: + # Switching from FQDN to IP — clean up old registration + access_token = str(reconfigure_entry.data.get(CONF_ACCESS_TOKEN, "")) + try: + await delete_fqdn(host, access_token, port=http_port) + except Exception: + _LOGGER.warning("Failed to delete old FQDN registration: %s", old_fqdn) + data_updates[CONF_REGISTERED_FQDN] = "" + + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=data_updates, + ) + + async def async_step_reconfigure_register_fqdn( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Register FQDN during reconfiguration and wait for TLS cert update.""" + if not self._reconfigure_fqdn_task: + self._reconfigure_fqdn_task = self.hass.async_create_task( + self._async_register_fqdn_and_wait(), + "span_panel_reconfigure_fqdn", + ) + + if not self._reconfigure_fqdn_task.done(): + return self.async_show_progress( + step_id="reconfigure_register_fqdn", + progress_action="registering_fqdn", + progress_task=self._reconfigure_fqdn_task, + ) + + try: + self._reconfigure_fqdn_task.result() + except Exception: + _LOGGER.exception("FQDN registration failed during reconfigure for %s", self.host) + self._reconfigure_fqdn_task = None + return self.async_show_progress_done(next_step_id="reconfigure_fqdn_failed") + + self._reconfigure_fqdn_task = None + return self.async_show_progress_done(next_step_id="reconfigure_fqdn_done") + + async def async_step_reconfigure_fqdn_done( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Complete reconfiguration after successful FQDN registration.""" + reconfigure_entry = self._get_reconfigure_entry() return self.async_update_reload_and_abort( reconfigure_entry, - data_updates={CONF_HOST: host}, + data_updates={CONF_HOST: self.host or "", CONF_REGISTERED_FQDN: self.host or ""}, + ) + + async def async_step_reconfigure_fqdn_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle FQDN registration failure during reconfigure.""" + if user_input is not None: + # User chose to continue anyway — update host without FQDN registration + reconfigure_entry = self._get_reconfigure_entry() + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_HOST: self.host or ""}, + ) + return self.async_show_form( + step_id="reconfigure_fqdn_failed", + data_schema=vol.Schema({}), + errors={"base": "fqdn_registration_failed"}, ) @staticmethod @@ -896,27 +899,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Handle the options flow for Span Panel.""" async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: - """Show the main options menu.""" - if user_input is None: - menu_options = { - "general_options": "General Options", - } - - # Add simulation options if this is a simulation mode integration - if self.config_entry.data.get("simulation_mode", False): - menu_options["simulation_start_time"] = "Simulation Start Time" - menu_options["simulation_offline_minutes"] = "Simulation Offline Minutes" - else: - # Live panel: offer cloning into a simulation config - menu_options["clone_panel_to_simulation"] = "Clone Panel To Simulation" - - return self.async_show_menu( - step_id="init", - menu_options=menu_options, - ) - - # This shouldn't be reached since we're showing a menu - return self.async_abort(reason="unknown") + """Start the options flow with general options directly.""" + return await self.async_step_general_options(user_input) async def async_step_general_options( self, user_input: dict[str, Any] | None = None @@ -942,328 +926,6 @@ async def async_step_general_options( errors=errors, ) - async def async_step_simulation_start_time( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Edit simulation start time settings.""" - if user_input is not None: - simulation_start_time = user_input.get(CONF_SIMULATION_START_TIME, "").strip() - - _LOGGER.info("Edit simulation start time - start_time: %s", simulation_start_time) - - if simulation_start_time: - try: - simulation_start_time = validate_simulation_time(simulation_start_time) - user_input[CONF_SIMULATION_START_TIME] = simulation_start_time - except ValueError as e: - return self.async_show_form( - step_id="simulation_start_time", - data_schema=self.add_suggested_values_to_schema( - self._get_simulation_start_time_schema(), - self._get_simulation_start_time_defaults(), - ), - errors={"base": str(e)}, - ) - - # Merge with existing options to preserve other settings - merged_options = dict(self.config_entry.options) - merged_options.update(user_input) - - # Clean up any simulation-only change flag since this will trigger a reload - merged_options.pop("_simulation_only_change", None) - - _LOGGER.info("Saving simulation start time: %s", user_input) - _LOGGER.info("Merged options: %s", merged_options) - - return self.async_create_entry(title="", data=merged_options) - - return self.async_show_form( - step_id="simulation_start_time", - data_schema=self.add_suggested_values_to_schema( - self._get_simulation_start_time_schema(), - self._get_simulation_start_time_defaults(), - ), - ) - - async def async_step_simulation_offline_minutes( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Edit simulation offline minutes settings.""" - if user_input is not None: - offline_minutes = user_input.get(CONF_SIMULATION_OFFLINE_MINUTES, 0) - - _LOGGER.info("Edit simulation offline minutes - offline_minutes: %s", offline_minutes) - - # Merge with existing options to preserve other settings - merged_options = dict(self.config_entry.options) - merged_options.update(user_input) - - # Add a flag to indicate this is a simulation-only change - merged_options["_simulation_only_change"] = True - - # Add a timestamp to force change detection even when offline_minutes value is the same - # This ensures the update listener is called to restart the offline timer - merged_options["_simulation_timestamp"] = int(time()) - - return self.async_create_entry(title="", data=merged_options) - - return self.async_show_form( - step_id="simulation_offline_minutes", - data_schema=self.add_suggested_values_to_schema( - self._get_simulation_offline_minutes_schema(), - self._get_simulation_offline_minutes_defaults(), - ), - ) - - async def async_step_simulation_export( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle simulation config export.""" - errors: dict[str, str] = {} - - if user_input is not None: - config_key = user_input.get(SIM_FILE_KEY, "") - export_path_raw = str(user_input.get(SIM_EXPORT_PATH, "")).strip() - - if not config_key: - errors[SIM_FILE_KEY] = "Please select a simulation config to export" - elif not export_path_raw: - errors[SIM_EXPORT_PATH] = "Export path is required" - else: - try: - current_file = Path(__file__) - config_dir = current_file.parent / "simulation_configs" - src_yaml = config_dir / f"{config_key}.yaml" - - export_path = Path(export_path_raw) - await self.hass.async_add_executor_job( - lambda: export_path.parent.mkdir(parents=True, exist_ok=True) - ) - if not await self.hass.async_add_executor_job(src_yaml.exists): - raise FileNotFoundError(f"Source simulation file not found: {src_yaml}") - await self.hass.async_add_executor_job(shutil.copyfile, src_yaml, export_path) - _LOGGER.info("Exported simulation config '%s' to %s", config_key, export_path) - - # Build friendly name for confirmation - friendly = get_available_simulation_configs().get(config_key, config_key) - return self.async_create_entry( - title="", - data={}, - description=f"Exported '{friendly}' to {export_path}", - ) - - except Exception as e: - _LOGGER.error("Simulation config export error: %s", e) - errors["base"] = f"Export failed: {e}" - - # Show export form - available_configs = get_available_simulation_configs() - options_list = [{"value": k, "label": v} for k, v in available_configs.items()] - current_config_key = self.config_entry.data.get( - CONF_SIMULATION_CONFIG, "simulation_config_32_circuit" - ) - default_export = f"/tmp/{current_config_key}.yaml" # nosec - - export_schema = vol.Schema( - { - vol.Required(SIM_FILE_KEY, default=current_config_key): selector( - { - "select": { - "options": options_list, - "mode": "dropdown", - } - } - ), - vol.Required(SIM_EXPORT_PATH, default=default_export): str, - } - ) - - return self.async_show_form( - step_id="simulation_export", - data_schema=export_schema, - errors=errors, - ) - - async def async_step_simulation_import( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle simulation config import.""" - errors: dict[str, str] = {} - - if user_input is not None: - import_path_raw = str(user_input.get(SIM_IMPORT_PATH, "")).strip() - - if not import_path_raw: - errors[SIM_IMPORT_PATH] = "Import path is required" - else: - try: - import_path = Path(import_path_raw) - if not await self.hass.async_add_executor_job(import_path.exists): - raise FileNotFoundError(f"Import file not found: {import_path}") - - # Load and validate YAML using span-panel-api's validator - def load_yaml_file() -> dict[str, Any]: - with import_path.open("r", encoding="utf-8") as f: - result = yaml.safe_load(f) - if result is None: - return {} - if isinstance(result, dict): - return result - return {} - - loaded_yaml = await self.hass.async_add_executor_job(load_yaml_file) - # Use DynamicSimulationEngine internal validation - config: SimulationConfig = cast(SimulationConfig, loaded_yaml) - engine = DynamicSimulationEngine(config_data=config) - await engine.initialize_async() - - # Copy to simulation_configs directory - current_file = Path(__file__) - config_dir = current_file.parent / "simulation_configs" - dest_name = ( - import_path.name if import_path.suffix else f"{import_path.name}.yaml" - ) - dest_yaml = config_dir / dest_name - await self.hass.async_add_executor_job( - lambda: dest_yaml.parent.mkdir(parents=True, exist_ok=True) - ) - await self.hass.async_add_executor_job(shutil.copyfile, import_path, dest_yaml) - _LOGGER.info("Imported and validated simulation config to %s", dest_yaml) - - # Update config entry to point to the imported simulation config - try: - new_data = dict(self.config_entry.data) - new_data[CONF_SIMULATION_CONFIG] = dest_yaml.stem - self.hass.config_entries.async_update_entry( - self.config_entry, data=new_data - ) - _LOGGER.debug("Set CONF_SIMULATION_CONFIG to %s", dest_yaml.stem) - except Exception as update_err: - _LOGGER.warning( - "Failed to set CONF_SIMULATION_CONFIG to %s: %s", - dest_yaml.stem, - update_err, - ) - - return self.async_create_entry( - title="", - data={}, - description=f"Imported '{dest_yaml.stem}' into simulation configurations", - ) - - except Exception as e: - _LOGGER.error("Simulation config import error: %s", e) - errors["base"] = f"Import failed: {e}" - - # Show import form - import_schema = vol.Schema( - { - vol.Required(SIM_IMPORT_PATH, default=""): str, - } - ) - - return self.async_show_form( - step_id="simulation_import", - data_schema=import_schema, - errors=errors, - ) - - async def async_step_clone_panel_to_simulation( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Clone the live panel into a simulation YAML stored in simulation_configs.""" - result = await clone_panel_to_simulation(self.hass, self.config_entry, user_input) - - # If result is a ConfigFlowResult, return it directly - if hasattr(result, "type"): - return result # type: ignore[return-value] - - # Otherwise, result is (dest_path, errors) for the form - if isinstance(result, tuple) and len(result) == 2: - dest_path, errors = result - if not isinstance(errors, dict): - errors = {} - else: - # Fallback if result format is unexpected - _LOGGER.error( - "Unexpected result format from clone_panel_to_simulation: %s", type(result) - ) - return self.async_abort(reason="unknown") - - # If user_input was provided and there are no errors, the operation succeeded - if user_input is not None and not errors: - return self.async_create_entry( - title="Simulation Created", - data={}, - description=f"Cloned panel to {dest_path.name} in simulation_configs", - ) - - # Compute device name for form display - device_name = self.config_entry.data.get("device_name", self.config_entry.title) - - # Confirm form with destination field - schema = vol.Schema( - { - vol.Required("destination", default=str(dest_path)): selector( - {"text": {"multiline": False}} - ) - } - ) - return self.async_show_form( - step_id="clone_panel_to_simulation", - data_schema=schema, - description_placeholders={ - "panel": device_name or "Span Panel", - }, - errors=errors, - ) - - async def async_step_manage_simulation_configs( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Menu to import or export simulation configs.""" - if user_input is None: - return self.async_show_menu( - step_id="manage_simulation_configs", - menu_options={ - "simulation_import": "Import Simulation Config", - "simulation_export": "Export Simulation Config", - }, - ) - return self.async_abort(reason="unknown") - - def _get_simulation_start_time_schema(self) -> vol.Schema: - """Get the simulation start time schema.""" - return vol.Schema( - { - vol.Optional(CONF_SIMULATION_START_TIME): str, - } - ) - - def _get_simulation_start_time_defaults(self) -> dict[str, Any]: - """Get the simulation start time defaults.""" - return { - CONF_SIMULATION_START_TIME: self.config_entry.options.get( - CONF_SIMULATION_START_TIME, "" - ), - } - - def _get_simulation_offline_minutes_schema(self) -> vol.Schema: - """Get the simulation offline minutes schema.""" - return vol.Schema( - { - vol.Optional(CONF_SIMULATION_OFFLINE_MINUTES): int, - } - ) - - def _get_simulation_offline_minutes_defaults(self) -> dict[str, Any]: - """Get the simulation offline minutes defaults.""" - return { - CONF_SIMULATION_OFFLINE_MINUTES: self.config_entry.options.get( - CONF_SIMULATION_OFFLINE_MINUTES, 0 - ), - } - # Register the config flow handler config_entries.HANDLERS.register(DOMAIN)(SpanPanelConfigFlow) diff --git a/custom_components/span_panel/config_flow_utils/__init__.py b/custom_components/span_panel/config_flow_utils/__init__.py index 6976b421..919d3cc9 100644 --- a/custom_components/span_panel/config_flow_utils/__init__.py +++ b/custom_components/span_panel/config_flow_utils/__init__.py @@ -4,47 +4,30 @@ build_general_options_schema, get_current_naming_pattern, get_general_options_defaults, - get_simulation_offline_minutes_defaults, - get_simulation_offline_minutes_schema, - get_simulation_start_time_defaults, - get_simulation_start_time_schema, process_general_options_input, ) -from .simulation import ( - extract_serial_from_config, - get_available_simulation_configs, - get_simulation_config_path, - validate_yaml_config, -) from .validation import ( + check_fqdn_tls_ready, + is_fqdn, validate_auth_token, validate_host, validate_ipv4_address, - validate_simulation_time, validate_v2_passphrase, validate_v2_proximity, ) __all__ = [ # Validation + "check_fqdn_tls_ready", + "is_fqdn", "validate_auth_token", "validate_host", "validate_ipv4_address", - "validate_simulation_time", "validate_v2_passphrase", "validate_v2_proximity", - # Simulation - "extract_serial_from_config", - "get_available_simulation_configs", - "get_simulation_config_path", - "validate_yaml_config", # Options "build_general_options_schema", "get_current_naming_pattern", "get_general_options_defaults", - "get_simulation_offline_minutes_defaults", - "get_simulation_offline_minutes_schema", - "get_simulation_start_time_defaults", - "get_simulation_start_time_schema", "process_general_options_input", ] diff --git a/custom_components/span_panel/config_flow_utils/options.py b/custom_components/span_panel/config_flow_utils/options.py index f2a16bb9..7e0739a1 100644 --- a/custom_components/span_panel/config_flow_utils/options.py +++ b/custom_components/span_panel/config_flow_utils/options.py @@ -8,8 +8,6 @@ import voluptuous as vol from custom_components.span_panel.const import ( - CONF_SIMULATION_OFFLINE_MINUTES, - CONF_SIMULATION_START_TIME, DEFAULT_SNAPSHOT_INTERVAL, ENABLE_CIRCUIT_NET_ENERGY_SENSORS, ENABLE_ENERGY_DIP_COMPENSATION, @@ -118,9 +116,6 @@ def process_general_options_input( filtered_input[USE_DEVICE_PREFIX] = use_prefix filtered_input[USE_CIRCUIT_NUMBERS] = use_circuit_numbers - # Clean up any simulation-only change flag since this will trigger a reload - filtered_input.pop("_simulation_only_change", None) - return filtered_input, errors @@ -135,37 +130,3 @@ def get_current_naming_pattern(config_entry: ConfigEntry) -> str: return EntityNamingPattern.FRIENDLY_NAMES.value else: return EntityNamingPattern.LEGACY_NAMES.value - - -def get_simulation_start_time_schema() -> vol.Schema: - """Get the simulation start time schema.""" - return vol.Schema( - { - vol.Optional(CONF_SIMULATION_START_TIME): str, - } - ) - - -def get_simulation_start_time_defaults(config_entry: ConfigEntry) -> dict[str, Any]: - """Get the simulation start time defaults.""" - return { - CONF_SIMULATION_START_TIME: config_entry.options.get(CONF_SIMULATION_START_TIME, ""), - } - - -def get_simulation_offline_minutes_schema() -> vol.Schema: - """Get the simulation offline minutes schema.""" - return vol.Schema( - { - vol.Optional(CONF_SIMULATION_OFFLINE_MINUTES): int, - } - ) - - -def get_simulation_offline_minutes_defaults(config_entry: ConfigEntry) -> dict[str, Any]: - """Get the simulation offline minutes defaults.""" - return { - CONF_SIMULATION_OFFLINE_MINUTES: config_entry.options.get( - CONF_SIMULATION_OFFLINE_MINUTES, 0 - ), - } diff --git a/custom_components/span_panel/config_flow_utils/simulation.py b/custom_components/span_panel/config_flow_utils/simulation.py deleted file mode 100644 index 46c4a60e..00000000 --- a/custom_components/span_panel/config_flow_utils/simulation.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Simulation utilities for Span Panel config flow.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import yaml - - -def get_available_simulation_configs() -> dict[str, str]: - """Get available simulation configuration files. - - Returns: - Dictionary mapping config keys to display names - - """ - configs = {} - - # Get the integration's simulation_configs directory - current_file = Path(__file__) - config_dir = current_file.parent.parent / "simulation_configs" - - if config_dir.exists(): - for yaml_file in config_dir.glob("*.yaml"): - config_key = yaml_file.stem - - # Create user-friendly display names from filename - display_name = config_key.replace("simulation_config_", "").replace("_", " ").title() - - configs[config_key] = display_name - - # If no configs found, provide a default - if not configs: - configs["simulation_config_32_circuit"] = "32-Circuit Residential Panel (Default)" - - return configs - - -def extract_serial_from_config(config_path: Path) -> str: - """Extract serial number from simulation config file. - - Args: - config_path: Path to the simulation config YAML file - - Returns: - Serial number from the config, or default if not found - - """ - try: - if config_path.exists(): - with config_path.open("r", encoding="utf-8") as f: - config_data = yaml.safe_load(f) - if isinstance(config_data, dict): - # Try to extract serial from various possible locations - if "serial_number" in config_data: - return str(config_data["serial_number"]) - if "panel" in config_data and isinstance(config_data["panel"], dict): - if "serial_number" in config_data["panel"]: - return str(config_data["panel"]["serial_number"]) - if "status" in config_data and isinstance(config_data["status"], dict): - if "serial_number" in config_data["status"]: - return str(config_data["status"]["serial_number"]) - except (FileNotFoundError, yaml.YAMLError, KeyError, ValueError): - pass - - # Fallback to a default - return "span-sim-001" - - -def get_simulation_config_path(config_key: str) -> Path: - """Get the path to a simulation config file. - - Args: - config_key: The config key (filename without extension) - - Returns: - Path to the simulation config file - - """ - current_file = Path(__file__) - config_dir = current_file.parent.parent / "simulation_configs" - return config_dir / f"{config_key}.yaml" - - -def validate_yaml_config(yaml_path: Path) -> dict[str, Any]: - """Validate and load a YAML configuration file. - - Args: - yaml_path: Path to the YAML file - - Returns: - Loaded YAML data as dictionary - - Raises: - FileNotFoundError: If file doesn't exist - yaml.YAMLError: If YAML is invalid - ValueError: If YAML doesn't contain expected structure - - """ - if not yaml_path.exists(): - raise FileNotFoundError(f"Configuration file not found: {yaml_path}") - - with yaml_path.open("r", encoding="utf-8") as f: - data = yaml.safe_load(f) - - if data is None: - return {} - - if not isinstance(data, dict): - raise TypeError("Configuration file must contain a YAML dictionary") - - return data diff --git a/custom_components/span_panel/config_flow_utils/validation.py b/custom_components/span_panel/config_flow_utils/validation.py index 72362599..3d9aa42e 100644 --- a/custom_components/span_panel/config_flow_utils/validation.py +++ b/custom_components/span_panel/config_flow_utils/validation.py @@ -2,17 +2,17 @@ from __future__ import annotations -from datetime import datetime +import asyncio +import ipaddress import logging +from pathlib import Path +import socket +import ssl +import tempfile from homeassistant.core import HomeAssistant from homeassistant.util.network import is_ipv4_address -from span_panel_api import V2AuthResponse, detect_api_version, register_v2 - -from custom_components.span_panel.const import ( - ISO_DATETIME_FORMAT, - TIME_ONLY_FORMATS, -) +from span_panel_api import V2AuthResponse, detect_api_version, download_ca_cert, register_v2 _LOGGER = logging.getLogger(__name__) @@ -21,10 +21,11 @@ async def validate_host( hass: HomeAssistant, host: str, access_token: str | None = None, + port: int = 80, ) -> bool: """Validate the host connection by probing the panel's status endpoint.""" try: - result = await detect_api_version(host) + result = await detect_api_version(host, port=port) return result.api_version in ("v1", "v2") except Exception: return False @@ -47,69 +48,76 @@ def validate_ipv4_address(host: str) -> bool: return is_ipv4_address(host) -def validate_simulation_time(time_input: str) -> str: - """Validate and convert simulation time input. - - Supports: - - Time-only formats: "17:30", "5:30" (24-hour and 12-hour) - - Full ISO datetime: "2024-06-15T17:30:00" - - Returns: - ISO datetime string with current date if time-only, or original if full datetime +async def validate_v2_passphrase(host: str, passphrase: str, port: int = 80) -> V2AuthResponse: + """Validate a v2 panel passphrase and return MQTT credentials. Raises: - ValueError: If the time format is invalid + SpanPanelAuthError: on invalid passphrase (401/403). + SpanPanelConnectionError: on network/timeout failures. + SpanPanelTimeoutError: on request timeout. """ - if not time_input.strip(): - return "" + return await register_v2(host, "Home Assistant", passphrase, port=port) + - time_input = time_input.strip() +def is_fqdn(host: str) -> bool: + """Determine if host is a Fully Qualified Domain Name (not IP, not mDNS). - # Check if it's a full ISO datetime first + Returns True for domain names like 'span.home.lan' or 'panel.example.com'. + Returns False for IP addresses, mDNS (.local) names, and single-label hostnames. + """ + if is_ipv4_address(host): + return False try: - datetime.fromisoformat(time_input) - return time_input # Valid ISO datetime, return as-is + ipaddress.ip_address(host) + return False except ValueError: - pass # Not a full datetime, try time-only formats + pass + if host.endswith(".local") or host.endswith(".local."): + return False + return "." in host - # Try time-only formats (HH:MM or H:MM) - try: - if ":" in time_input: - parts = time_input.split(":") - if len(parts) == 2: - hour = int(parts[0]) - minute = int(parts[1]) - - # Validate hour and minute ranges - if 0 <= hour <= 23 and 0 <= minute <= 59: - # Convert to current date with the specified time - now = datetime.now() - time_only = now.replace(hour=hour, minute=minute, second=0, microsecond=0) - return time_only.isoformat() - - raise ValueError( - f"Invalid time format. Use {', '.join(TIME_ONLY_FORMATS)} or {ISO_DATETIME_FORMAT}" - ) - except (ValueError, IndexError) as e: - raise ValueError( - f"Invalid time format. Use {', '.join(TIME_ONLY_FORMATS)} or {ISO_DATETIME_FORMAT}" - ) from e - - -async def validate_v2_passphrase(host: str, passphrase: str) -> V2AuthResponse: - """Validate a v2 panel passphrase and return MQTT credentials. - Raises: - SpanPanelAuthError: on invalid passphrase (401/403). - SpanPanelConnectionError: on network/timeout failures. - SpanPanelTimeoutError: on request timeout. +async def check_fqdn_tls_ready(fqdn: str, mqtts_port: int, http_port: int = 80) -> bool: + """Check if the MQTTS server certificate includes the FQDN in its SAN. + Downloads the CA certificate from the panel via HTTP, then attempts + a TLS connection to the MQTTS port using the FQDN as server_hostname. + If the TLS handshake succeeds with hostname verification, the panel + has regenerated its certificate to include the FQDN. """ - return await register_v2(host, "Home Assistant", passphrase) - + try: + ca_pem = await download_ca_cert(fqdn, port=http_port) + except Exception: + return False -async def validate_v2_proximity(host: str) -> V2AuthResponse: + loop = asyncio.get_running_loop() + + def _check() -> bool: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = True + ctx.verify_mode = ssl.CERT_REQUIRED + + tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) # noqa: SIM115 + tmp.write(ca_pem) + tmp.close() + ca_path = Path(tmp.name) + try: + ctx.load_verify_locations(str(ca_path)) + with ( + socket.create_connection((fqdn, mqtts_port), timeout=5) as sock, + ctx.wrap_socket(sock, server_hostname=fqdn), + ): + return True + except (ssl.SSLCertVerificationError, ssl.SSLError, OSError, TimeoutError): + return False + finally: + ca_path.unlink(missing_ok=True) + + return await loop.run_in_executor(None, _check) + + +async def validate_v2_proximity(host: str, port: int = 80) -> V2AuthResponse: """Validate v2 panel proximity (door bypass) and return MQTT credentials. Calls register_v2 without a passphrase, which triggers door-bypass @@ -122,4 +130,4 @@ async def validate_v2_proximity(host: str) -> V2AuthResponse: SpanPanelTimeoutError: on request timeout. """ - return await register_v2(host, "Home Assistant") + return await register_v2(host, "Home Assistant", port=port) diff --git a/custom_components/span_panel/const.py b/custom_components/span_panel/const.py index 1ffe63ba..39ff4a62 100644 --- a/custom_components/span_panel/const.py +++ b/custom_components/span_panel/const.py @@ -4,7 +4,6 @@ from typing import Final DOMAIN: Final = "span_panel" -COORDINATOR = "coordinator" CONF_SERIAL_NUMBER = "serial_number" CONF_USE_SSL = "use_ssl" @@ -17,16 +16,9 @@ CONF_EBUS_BROKER_PASSWORD = "ebus_broker_password" CONF_EBUS_BROKER_PORT = "ebus_broker_mqtts_port" CONF_HOP_PASSPHRASE = "hop_passphrase" +CONF_HTTP_PORT = "http_port" CONF_PANEL_SERIAL = "panel_serial" - -# Simulation configuration -CONF_SIMULATION_CONFIG = "simulation_config" -CONF_SIMULATION_START_TIME = "simulation_start_time" -CONF_SIMULATION_OFFLINE_MINUTES = "simulation_offline_minutes" - -# Time format constants for simulation -TIME_ONLY_FORMATS = ["HH:MM", "H:MM"] # 24-hour and 12-hour formats -ISO_DATETIME_FORMAT = "YYYY-MM-DDTHH:MM:SS" # Full ISO datetime format +CONF_REGISTERED_FQDN = "registered_fqdn" # Binary sensor / status field keys (used in entity definitions) SYSTEM_DOOR_STATE = "doorState" diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py index 96669582..5cd5259c 100644 --- a/custom_components/span_panel/coordinator.py +++ b/custom_components/span_panel/coordinator.py @@ -19,7 +19,6 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from span_panel_api import ( - DynamicSimulationEngine, SpanMqttClient, SpanPanelSnapshot, ) @@ -34,6 +33,7 @@ from .const import DOMAIN from .helpers import build_circuit_unique_id from .options import ENERGY_REPORTING_GRACE_PERIOD +from .schema_validation import collect_sensor_definitions, validate_field_metadata class SpanCircuitEnergySensorProtocol(Protocol): @@ -64,9 +64,6 @@ def filter(self, record: logging.LogRecord) -> bool: # Fallback poll interval for MQTT streaming mode (push is the primary update path) _STREAMING_FALLBACK_INTERVAL = timedelta(seconds=60) -# Poll interval for simulation mode (no streaming, coordinator polls get_snapshot) -_SIMULATION_POLL_INTERVAL = timedelta(seconds=5) - class SpanPanelCoordinator(DataUpdateCoordinator[SpanPanelSnapshot]): """Coordinator for managing Span Panel data updates and entity migrations.""" @@ -74,7 +71,7 @@ class SpanPanelCoordinator(DataUpdateCoordinator[SpanPanelSnapshot]): def __init__( self, hass: HomeAssistant, - client: SpanMqttClient | DynamicSimulationEngine, + client: SpanMqttClient, config_entry: ConfigEntry, ) -> None: """Initialize the coordinator.""" @@ -92,14 +89,13 @@ def __init__( # Streaming state self._unregister_streaming: Callable[[], None] | None = None - # Simulation offline mode - self._simulation_offline_minutes: int = 0 - self._offline_start_time: float | None = None - # Hardware capability tracking — detect when BESS/PV are commissioned # and trigger a reload so the factory creates the appropriate sensors. self._known_capabilities: frozenset[str] | None = None + # Schema validation — run once after first successful refresh + self._schema_validated = False + # Energy dip compensation — sensors append events here during updates; # drained and surfaced as a persistent notification after each cycle. self._pending_dip_events: list[tuple[str, float, float]] = [] @@ -108,12 +104,7 @@ def __init__( # here so net energy sensors can read their dip offsets directly. self._circuit_energy_sensors: dict[tuple[str, str], SpanCircuitEnergySensorProtocol] = {} - # MQTT streaming: push is the primary update path; poll is a safety net. - # Simulation: poll is the only update path; use the snapshot interval. - if isinstance(client, SpanMqttClient): - update_interval = _STREAMING_FALLBACK_INTERVAL - else: - update_interval = _SIMULATION_POLL_INTERVAL + update_interval = _STREAMING_FALLBACK_INTERVAL _LOGGER.info( "Span Panel coordinator: poll interval %s seconds", @@ -132,7 +123,7 @@ def __init__( self.config_entry = config_entry @property - def client(self) -> SpanMqttClient | DynamicSimulationEngine: + def client(self) -> SpanMqttClient: """Return the underlying panel client for entity control.""" return self._client @@ -202,10 +193,7 @@ async def _fire_dip_notification(self) -> None: # --- Streaming --- async def async_setup_streaming(self) -> None: - """Set up push streaming if the client supports it.""" - if not isinstance(self._client, SpanMqttClient): - return - + """Set up push streaming.""" self._unregister_streaming = self._client.register_snapshot_callback(self._on_snapshot_push) await self._client.start_streaming() _LOGGER.info("MQTT push streaming started") @@ -223,40 +211,34 @@ async def async_shutdown(self) -> None: self._unregister_streaming() self._unregister_streaming = None - if isinstance(self._client, SpanMqttClient): - await self._client.stop_streaming() - await self._client.close() + await self._client.stop_streaming() + await self._client.close() _LOGGER.info("Coordinator shutdown complete") - # --- Simulation offline mode --- + # --- Schema validation --- - def set_simulation_offline_mode(self, minutes: int) -> None: - """Configure simulation offline mode duration. + def _run_schema_validation(self) -> None: + """Run schema field metadata validation once at startup. - When minutes > 0, the coordinator will raise SpanPanelConnectionError - during data updates for the specified duration, triggering the energy - grace period path in entity base classes. + Compares the library's schema-derived field metadata against the + integration's sensor definitions to detect unit mismatches. Also + reports fields the integration doesn't map to any sensor. """ - self._simulation_offline_minutes = minutes - if minutes > 0: - self._offline_start_time = _epoch_time() - else: - self._offline_start_time = None - - def _is_simulation_offline(self) -> bool: - """Check if the simulation is currently in offline mode.""" - if self._simulation_offline_minutes <= 0 or self._offline_start_time is None: - return False - - elapsed = _epoch_time() - self._offline_start_time - if elapsed >= self._simulation_offline_minutes * 60: - # Window expired — resume normal operation - self._simulation_offline_minutes = 0 - self._offline_start_time = None - return False - - return True + field_metadata: dict[str, dict[str, object]] | None = None + if isinstance(self._client, SpanMqttClient): + raw = self._client.field_metadata + if raw is not None: + field_metadata = { + k: {"unit": v.unit, "datatype": v.datatype} for k, v in raw.items() + } + + if field_metadata is None: + _LOGGER.debug("Schema validation skipped — no field metadata available") + return + + sensor_defs = collect_sensor_definitions() + validate_field_metadata(field_metadata, sensor_defs=sensor_defs) # --- Hardware capability detection --- @@ -308,6 +290,11 @@ async def _run_post_update_tasks(self, snapshot: SpanPanelSnapshot) -> None: ensures reload requests and pending migrations are processed regardless of transport mode. """ + # One-shot schema validation after first successful refresh + if not self._schema_validated: + self._schema_validated = True + self._run_schema_validation() + # Fire persistent notification for any energy dips detected this cycle await self._fire_dip_notification() @@ -332,10 +319,6 @@ async def _async_update_data(self) -> SpanPanelSnapshot: cycle_start = _epoch_time() self._last_tick_epoch = cycle_start - # Simulation offline mode: raise before fetching to trigger grace period - if self._is_simulation_offline(): - raise SpanPanelConnectionError("Panel is offline in simulation mode") - fetch_start = _epoch_time() snapshot = await self._client.get_snapshot() fetch_duration = _epoch_time() - fetch_start diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py index ebd1d207..afc2590f 100644 --- a/custom_components/span_panel/entity.py +++ b/custom_components/span_panel/entity.py @@ -7,7 +7,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from span_panel_api import SpanPanelSnapshot -from .const import CONF_API_VERSION, CONF_DEVICE_NAME +from .const import CONF_DEVICE_NAME from .coordinator import SpanPanelCoordinator from .util import snapshot_to_device_info @@ -26,6 +26,5 @@ def _build_device_info( device_name = coordinator.config_entry.data.get( CONF_DEVICE_NAME, coordinator.config_entry.title ) - is_simulator = coordinator.config_entry.data.get(CONF_API_VERSION) == "simulation" host = coordinator.config_entry.data.get(CONF_HOST) - return snapshot_to_device_info(snapshot, device_name, is_simulator, host) + return snapshot_to_device_info(snapshot, device_name, host=host) diff --git a/custom_components/span_panel/entity_summary.py b/custom_components/span_panel/entity_summary.py index c21b3b97..6343e976 100644 --- a/custom_components/span_panel/entity_summary.py +++ b/custom_components/span_panel/entity_summary.py @@ -65,6 +65,7 @@ def log_entity_summary(coordinator: SpanPanelCoordinator) -> None: if pv_present: power_flow_sensors += 1 # PV power if power_flows_present: + power_flow_sensors += 1 # Grid power flow power_flow_sensors += 1 # Site power # Synthetic sensors (now handled by template system - counts are estimates) diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py index 94e29234..28b4b660 100644 --- a/custom_components/span_panel/helpers.py +++ b/custom_components/span_panel/helpers.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any from homeassistant.components.persistent_notification import async_create -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify @@ -46,6 +45,7 @@ "feedthroughPowerW": "feed_through_power", "batteryPowerW": "battery_power", "pvPowerW": "pv_power", + "gridPowerFlowW": "grid_power_flow", "sitePowerW": "site_power", "mainMeterEnergyProducedWh": "main_meter_energy_produced", # Consistent naming "mainMeterEnergyConsumedWh": "main_meter_energy_consumed", # Consistent naming @@ -64,6 +64,7 @@ "feedthroughPowerW": "feed_through_power", "batteryPowerW": "battery_power", "pvPowerW": "pv_power", + "gridPowerFlowW": "grid_power_flow", "sitePowerW": "site_power", "mainMeterEnergyProducedWh": "main_meter_produced_energy", "mainMeterEnergyConsumedWh": "main_meter_consumed_energy", @@ -637,43 +638,6 @@ def construct_sensor_set_id(device_identifier: str) -> str: return f"{device_identifier}_sensors" -def generate_unique_simulator_serial_number(hass: HomeAssistant) -> str: - """Generate a unique simulator serial number in the format sim-nnn. - - Args: - hass: Home Assistant instance - - Returns: - Unique serial number in format sim-nnn (e.g., sim-001, sim-002, etc.) - - """ - - # Get all existing span panel config entries - existing_entries = hass.config_entries.async_entries(DOMAIN) - - # Find existing simulator serial numbers - existing_serials = set() - for entry in existing_entries: - if entry.data.get("simulation_mode", False): - # Check both simulator_serial_number and CONF_HOST fields - # simulator_serial_number is the newer field - serial = entry.data.get("simulator_serial_number") - if serial and serial.startswith("sim-"): - existing_serials.add(serial) - - # CONF_HOST may contain the serial for existing simulator configurations - host_serial = entry.data.get(CONF_HOST) - if host_serial and host_serial.startswith("sim-"): - existing_serials.add(host_serial) - - # Find the next available number - counter = 1 - while f"sim-{counter:03d}" in existing_serials: - counter += 1 - - return f"sim-{counter:03d}" - - def _get_device_identifier_for_unique_ids( coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot, diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json index 60ffe3da..8330205e 100644 --- a/custom_components/span_panel/icons.json +++ b/custom_components/span_panel/icons.json @@ -62,6 +62,9 @@ "pv_power": { "default": "mdi:solar-power" }, + "grid_power_flow": { + "default": "mdi:transmission-tower" + }, "site_power": { "default": "mdi:home-lightning-bolt" }, diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 05dcdea7..123f1cb8 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -1,9 +1,6 @@ { "domain": "span_panel", "name": "Span Panel", - "after_dependencies": [ - "recorder" - ], "codeowners": [ "@SpanPanel" ], @@ -12,9 +9,9 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/SpanPanel/span/issues", "requirements": [ - "span-panel-api>=2.2.4" + "span-panel-api==2.3.2" ], - "version": "2.0.2", + "version": "2.0.4", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/custom_components/span_panel/schema_expectations.py b/custom_components/span_panel/schema_expectations.py new file mode 100644 index 00000000..ed4d2050 --- /dev/null +++ b/custom_components/span_panel/schema_expectations.py @@ -0,0 +1,102 @@ +"""Sensor-to-snapshot-field mapping for schema validation. + +Maps integration sensor definition keys to snapshot field paths. This is the +integration's declaration of which snapshot fields it reads, expressed in +transport-agnostic terms. + +The integration does NOT know about Homie, MQTT, node types, or property IDs. +The ``span-panel-api`` library owns that knowledge and exposes field-level +metadata keyed by snapshot field paths. This module bridges from sensor +definitions (HA side) to field paths (library side). + +Field path convention: ``{snapshot_type}.{field_name}`` + - ``panel`` — SpanPanelSnapshot fields + - ``circuit`` — SpanCircuitSnapshot fields + - ``battery`` — SpanBatterySnapshot fields + - ``pv`` — SpanPVSnapshot fields + - ``evse`` — SpanEvseSnapshot fields + +Derived sensors (net energy, dsm_state, current_run_config) that compute +values from multiple fields have no single source field and are excluded. +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Sensor definition key → snapshot field path +# +# Every sensor the integration creates that reads a single snapshot field +# should appear here. The sensor definition provides the HA unit; the +# library's field metadata provides the schema-declared unit. The validation +# module compares them. +# +# Entries are grouped by snapshot type for readability. +# --------------------------------------------------------------------------- + +SENSOR_FIELD_MAP: dict[str, str] = { + # --- Panel power sensors ------------------------------------------------- + "instantGridPowerW": "panel.instant_grid_power_w", + "feedthroughPowerW": "panel.feedthrough_power_w", + "batteryPowerW": "panel.power_flow_battery", + "pvPowerW": "panel.power_flow_pv", + "gridPowerFlowW": "panel.power_flow_grid", + "sitePowerW": "panel.power_flow_site", + # --- Panel energy sensors ------------------------------------------------ + "mainMeterEnergyProducedWh": "panel.main_meter_energy_produced_wh", + "mainMeterEnergyConsumedWh": "panel.main_meter_energy_consumed_wh", + "feedthroughEnergyProducedWh": "panel.feedthrough_energy_produced_wh", + "feedthroughEnergyConsumedWh": "panel.feedthrough_energy_consumed_wh", + # --- Panel diagnostic sensors -------------------------------------------- + "l1_voltage": "panel.l1_voltage", + "l2_voltage": "panel.l2_voltage", + "upstream_l1_current": "panel.upstream_l1_current_a", + "upstream_l2_current": "panel.upstream_l2_current_a", + "downstream_l1_current": "panel.downstream_l1_current_a", + "downstream_l2_current": "panel.downstream_l2_current_a", + "main_breaker_rating": "panel.main_breaker_rating_a", + # --- Panel status sensors (enum/string — no unit, but tracked) ----------- + "main_relay_state": "panel.main_relay_state", + "grid_forming_entity": "panel.dominant_power_source", + "vendor_cloud": "panel.vendor_cloud", + "software_version": "panel.firmware_version", + # --- Circuit sensors ----------------------------------------------------- + "circuit_power": "circuit.instant_power_w", + "circuit_energy_produced": "circuit.produced_energy_wh", + "circuit_energy_consumed": "circuit.consumed_energy_wh", + "circuit_current": "circuit.current_a", + "circuit_breaker_rating": "circuit.breaker_rating_a", + # --- Unmapped circuit sensors (same fields, different sensor keys) -------- + "instantPowerW": "circuit.instant_power_w", + "producedEnergyWh": "circuit.produced_energy_wh", + "consumedEnergyWh": "circuit.consumed_energy_wh", + # --- Battery sensors ----------------------------------------------------- + "storage_battery_percentage": "battery.soe_percentage", + "nameplate_capacity": "battery.nameplate_capacity_kwh", + "soe_kwh": "battery.soe_kwh", + # --- BESS metadata sensors ----------------------------------------------- + "vendor": "battery.vendor_name", + "model": "battery.product_name", + "serial_number": "battery.serial_number", + "firmware_version": "battery.software_version", + # --- PV metadata sensors ------------------------------------------------- + "pv_vendor": "pv.vendor_name", + "pv_product": "pv.product_name", + "pv_nameplate_capacity": "pv.nameplate_capacity_w", + # --- EVSE sensors -------------------------------------------------------- + "evse_status": "evse.status", + "evse_advertised_current": "evse.advertised_current_a", + "evse_lock_state": "evse.lock_state", +} + +# Derived sensors excluded from the map (computed from multiple fields): +# dsm_state — multi-signal heuristic +# dsm_grid_state — deprecated alias for dsm_state +# current_run_config — tri-state derivation +# mainMeterNetEnergyWh — consumed_wh - produced_wh +# feedthroughNetEnergyWh — consumed_wh - produced_wh +# circuit_energy_net — consumed_wh - produced_wh (or inverse for PV) + + +def all_referenced_field_paths() -> frozenset[str]: + """Return the set of all snapshot field paths referenced by any sensor.""" + return frozenset(SENSOR_FIELD_MAP.values()) diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py new file mode 100644 index 00000000..4f39724e --- /dev/null +++ b/custom_components/span_panel/schema_validation.py @@ -0,0 +1,176 @@ +"""Schema validation — cross-check field metadata against sensor definitions. + +Compares the ``span-panel-api`` library's field metadata (schema-derived units +and datatypes keyed by snapshot field paths) against the integration's sensor +definitions. All Homie/MQTT knowledge stays in the library; this module only +sees snapshot field paths and HA sensor metadata. + +Schema drift detection (diffing schema versions between firmware updates) is +the library's responsibility. The integration only consumes the result. + +All output is log-only. No entity creation or sensor behavior changes. + +Phase 1 of the schema-driven changes plan (see docs/Dev/schema_driven_changes.md). + +Usage: + Called from the coordinator after the first successful data refresh. + Requires ``span-panel-api`` to expose field metadata via the client protocol. + Until that library change lands, ``validate_field_metadata()`` is a safe no-op. +""" + +from __future__ import annotations + +import logging + +from homeassistant.components.sensor import SensorEntityDescription + +from .schema_expectations import SENSOR_FIELD_MAP, all_referenced_field_paths +from .sensor_definitions import ( + BATTERY_POWER_SENSOR, + BATTERY_SENSOR, + BESS_METADATA_SENSORS, + CIRCUIT_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_SENSORS, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + EVSE_SENSORS, + GRID_POWER_FLOW_SENSOR, + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + MAIN_BREAKER_RATING_SENSOR, + PANEL_DATA_STATUS_SENSORS, + PANEL_ENERGY_SENSORS, + PANEL_POWER_SENSORS, + PV_METADATA_SENSORS, + PV_POWER_SENSOR, + SITE_POWER_SENSOR, + STATUS_SENSORS, + UNMAPPED_SENSORS, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, +) + +_LOGGER = logging.getLogger(__name__) + + +def _cross_check_units( + field_metadata: dict[str, dict[str, object]], + sensor_defs: dict[str, SensorEntityDescription], +) -> None: + """Compare library-reported units against sensor definition units. + + For each sensor in SENSOR_FIELD_MAP that has a ``native_unit_of_measurement``, + look up the corresponding field path in the library's metadata and compare + the declared unit. + """ + for sensor_key, field_path in SENSOR_FIELD_MAP.items(): + sensor_def = sensor_defs.get(sensor_key) + if sensor_def is None: + continue + + ha_unit = sensor_def.native_unit_of_measurement + if ha_unit is None: + # Sensor has no unit (enum, string) — nothing to cross-check + continue + + field_info = field_metadata.get(field_path) + if field_info is None: + _LOGGER.debug( + "Schema cross-check: sensor '%s' reads field '%s' but " + "library reports no metadata for it", + sensor_key, + field_path, + ) + continue + + schema_unit = field_info.get("unit") + if schema_unit is None: + _LOGGER.debug( + "Schema cross-check: field '%s' (sensor '%s') has no unit " + "in library metadata, integration expects '%s'", + field_path, + sensor_key, + ha_unit, + ) + elif str(schema_unit) != str(ha_unit): + _LOGGER.debug( + "Schema cross-check: field '%s' (sensor '%s') unit is '%s' " + "in library metadata, integration expects '%s'", + field_path, + sensor_key, + schema_unit, + ha_unit, + ) + + +def _report_unmapped_fields( + field_metadata: dict[str, dict[str, object]], +) -> None: + """Log fields in library metadata that no sensor definition references.""" + referenced = all_referenced_field_paths() + for field_path in sorted(set(field_metadata) - referenced): + _LOGGER.debug( + "Schema: field '%s' in library metadata is not mapped to any sensor", + field_path, + ) + + +def validate_field_metadata( + field_metadata: dict[str, dict[str, object]] | None, + sensor_defs: dict[str, SensorEntityDescription] | None = None, +) -> None: + """Run integration-side schema validation checks. + + Args: + field_metadata: The library's field metadata, keyed by snapshot field + path (e.g. ``"panel.instant_grid_power_w"``). Each value is a dict + with at least ``"unit"`` and ``"datatype"`` keys. None if the + library does not yet expose metadata. + sensor_defs: Dict of sensor_key → SensorEntityDescription for unit + cross-checking. None skips the cross-check. + + """ + if field_metadata is None: + _LOGGER.debug("Schema validation skipped — library does not expose field metadata") + return + + if sensor_defs is not None: + _cross_check_units(field_metadata, sensor_defs) + + _report_unmapped_fields(field_metadata) + + +def collect_sensor_definitions() -> dict[str, SensorEntityDescription]: + """Collect all sensor definitions into a dict keyed by sensor key. + + Only includes sensors that appear in SENSOR_FIELD_MAP (i.e. sensors + that read a single snapshot field and are eligible for cross-checking). + """ + all_defs: list[SensorEntityDescription] = [ + *PANEL_DATA_STATUS_SENSORS, + *STATUS_SENSORS, + *UNMAPPED_SENSORS, + BATTERY_SENSOR, + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + MAIN_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_BREAKER_RATING_SENSOR, + *BESS_METADATA_SENSORS, + *PV_METADATA_SENSORS, + *PANEL_POWER_SENSORS, + BATTERY_POWER_SENSOR, + PV_POWER_SENSOR, + GRID_POWER_FLOW_SENSOR, + SITE_POWER_SENSOR, + *PANEL_ENERGY_SENSORS, + *CIRCUIT_SENSORS, + *EVSE_SENSORS, + ] + mapped_keys = set(SENSOR_FIELD_MAP.keys()) + return {d.key: d for d in all_defs if d.key in mapped_keys} diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py index bbe146c7..8b561a38 100644 --- a/custom_components/span_panel/select.py +++ b/custom_components/span_panel/select.py @@ -167,7 +167,7 @@ async def async_select_option(self, option: str) -> None: _LOGGER.debug("Selecting option: %s", option) client = self.coordinator.client if not hasattr(client, "set_circuit_priority"): - _LOGGER.warning("Circuit priority control not available in simulation mode") + _LOGGER.warning("Client does not support priority control") return priority = CircuitPriority(option) diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index e8f8a3c1..622db17c 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -10,12 +10,10 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify from span_panel_api import SpanPanelSnapshot from . import SpanPanelConfigEntry from .const import ( - CONF_API_VERSION, CONF_DEVICE_NAME, ENABLE_CIRCUIT_NET_ENERGY_SENSORS, ENABLE_PANEL_NET_ENERGY_SENSORS, @@ -39,6 +37,7 @@ DOWNSTREAM_L1_CURRENT_SENSOR, DOWNSTREAM_L2_CURRENT_SENSOR, EVSE_SENSORS, + GRID_POWER_FLOW_SENSOR, L1_VOLTAGE_SENSOR, L2_VOLTAGE_SENSOR, MAIN_BREAKER_RATING_SENSOR, @@ -176,15 +175,11 @@ def _build_evse_device_info_map( if not snapshot.evse: return {} - is_simulator = coordinator.config_entry.data.get(CONF_API_VERSION) == "simulation" panel_name = ( coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title) or "Span Panel" ) - if is_simulator: - panel_identifier = slugify(panel_name) - else: - panel_identifier = snapshot.serial_number + panel_identifier = snapshot.serial_number use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) @@ -293,17 +288,12 @@ def _build_bess_device_info( coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot ) -> DeviceInfo: """Build BESS sub-device info, resolving the panel identifier.""" - is_simulator = coordinator.config_entry.data.get(CONF_API_VERSION) == "simulation" panel_name = ( coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title) or "Span Panel" ) - if is_simulator: - panel_identifier = slugify(panel_name) - else: - panel_identifier = snapshot.serial_number - return bess_device_info(panel_identifier, snapshot.battery, panel_name) + return bess_device_info(snapshot.serial_number, snapshot.battery, panel_name) def create_battery_sensors( @@ -352,6 +342,7 @@ def create_power_flow_sensors( entities.append(SpanPVMetadataSensor(coordinator, desc, snapshot)) if has_power_flows(snapshot): + entities.append(SpanPanelPowerSensor(coordinator, GRID_POWER_FLOW_SENSOR, snapshot)) entities.append(SpanPanelPowerSensor(coordinator, SITE_POWER_SENSOR, snapshot)) return entities diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 19611cb7..612db880 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -443,9 +443,9 @@ class SpanPVMetadataSensorEntityDescription( key="pv_nameplate_capacity", translation_key="pv_nameplate_capacity", device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=UnitOfPower.KILO_WATT, + native_unit_of_measurement=UnitOfPower.WATT, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda s: s.pv.nameplate_capacity_kw, + value_fn=lambda s: s.pv.nameplate_capacity_w, ), ) @@ -497,6 +497,17 @@ class SpanPVMetadataSensorEntityDescription( value_fn=lambda s: (-s.power_flow_pv or 0.0) if s.power_flow_pv is not None else 0.0, ) +# Grid power flow sensor (conditionally created when power-flows data is available) +GRID_POWER_FLOW_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( + key="gridPowerFlowW", + translation_key="grid_power_flow", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.POWER, + value_fn=lambda s: (-s.power_flow_grid or 0.0) if s.power_flow_grid is not None else 0.0, +) + # Site power sensor (conditionally created when power-flows data is available) SITE_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription( key="sitePowerW", diff --git a/custom_components/span_panel/sensor_evse.py b/custom_components/span_panel/sensor_evse.py index 513f3cb3..e7d89a78 100644 --- a/custom_components/span_panel/sensor_evse.py +++ b/custom_components/span_panel/sensor_evse.py @@ -4,11 +4,9 @@ import logging -from homeassistant.util import slugify from span_panel_api import SpanEvseSnapshot, SpanPanelSnapshot from .const import ( - CONF_API_VERSION, CONF_DEVICE_NAME, USE_CIRCUIT_NUMBERS, ) @@ -42,17 +40,13 @@ def __init__( super().__init__(data_coordinator, description, snapshot) # Override device_info to point to EVSE sub-device instead of panel - is_simulator = data_coordinator.config_entry.data.get(CONF_API_VERSION) == "simulation" panel_name = ( data_coordinator.config_entry.data.get( CONF_DEVICE_NAME, data_coordinator.config_entry.title ) or "Span Panel" ) - if is_simulator: - panel_identifier = slugify(panel_name) - else: - panel_identifier = snapshot.serial_number + panel_identifier = snapshot.serial_number evse = snapshot.evse.get(evse_id, _EMPTY_EVSE) use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) diff --git a/custom_components/span_panel/services.yaml b/custom_components/span_panel/services.yaml index 3f875223..783a238c 100644 --- a/custom_components/span_panel/services.yaml +++ b/custom_components/span_panel/services.yaml @@ -1,11 +1,5 @@ -export_synthetic_config: - name: Export Synthetic Sensor Config - description: Export the current synthetic sensor configuration to a YAML file. You can specify either a directory (file will be named automatically) or a full file path. - fields: - directory: - name: File Path - description: Either a directory path (file will be named automatically) or a full file path ending in .yaml where the configuration will be saved. - required: true - example: "/config/span_panel_sensor_config.yaml" - selector: - text: +export_circuit_manifest: + description: >- + Export an authoritative circuit-to-entity manifest for all configured + SPAN panels. Returns panel serial numbers, circuit power sensor entity + IDs, simulator template names, device types, and breaker tab numbers. diff --git a/custom_components/span_panel/simulation_configs/migration_test_config.yaml b/custom_components/span_panel/simulation_configs/migration_test_config.yaml deleted file mode 100644 index a002c35e..00000000 --- a/custom_components/span_panel/simulation_configs/migration_test_config.yaml +++ /dev/null @@ -1,236 +0,0 @@ -# Migration test simulation configuration using actual registry circuit IDs -# This matches the real circuit IDs from the 1.0.10 entity registry - -panel_config: - serial_number: "nj-2316-005k6" - total_tabs: 32 - main_size: 200 # Main breaker size in Amps - -# Circuit templates define reusable behavior patterns -circuit_templates: - # Lighting circuits - lighting: - energy_profile: - mode: "consumer" - power_range: [0.0, 500.0] - typical_power: 300.0 - power_variation: 0.05 - relay_behavior: "controllable" - priority: "OFF_GRID" - - # Outlet circuits - outlets: - energy_profile: - mode: "consumer" - power_range: [0.0, 1800.0] - typical_power: 150.0 - power_variation: 0.4 - relay_behavior: "controllable" - priority: "NEVER" - - # Kitchen specific outlets (higher capacity) - kitchen_outlets: - energy_profile: - mode: "consumer" - power_range: [0.0, 2400.0] - typical_power: 300.0 - power_variation: 0.5 - relay_behavior: "controllable" - priority: "NEVER" - - # Major appliances - major_appliance: - energy_profile: - mode: "consumer" - power_range: [0.0, 2500.0] - typical_power: 800.0 - power_variation: 0.3 - relay_behavior: "controllable" - priority: "OFF_GRID" - - # Large appliances (240V) - large_appliance_240v: - energy_profile: - mode: "consumer" - power_range: [0.0, 4000.0] - typical_power: 2000.0 - power_variation: 0.15 - relay_behavior: "controllable" - priority: "OFF_GRID" - - # HVAC/AC systems - hvac: - energy_profile: - mode: "consumer" - power_range: [500.0, 4000.0] - typical_power: 2800.0 - power_variation: 0.25 - relay_behavior: "controllable" - priority: "OFF_GRID" - - # EV chargers - ev_charger: - energy_profile: - mode: "consumer" - power_range: [0.0, 11500.0] - typical_power: 7200.0 - power_variation: 0.05 - relay_behavior: "controllable" - priority: "OFF_GRID" - - # Special equipment - special_equipment: - energy_profile: - mode: "consumer" - power_range: [0.0, 1200.0] - typical_power: 400.0 - power_variation: 0.2 - relay_behavior: "controllable" - priority: "OFF_GRID" - -circuits: - # Using actual circuit IDs from the registry - - id: "0dad2f16cd514812ae1807b0457d473e" - name: "Lights Dining Room" - template: "lighting" - tabs: [1] - - - id: "11a47a0f69d54e12b7200f730c2ffda1" - name: "Lights-Outlets Bedroom" - template: "outlets" - tabs: [2] - - - id: "12ce227695cd44338864b0ef2ec4168b" - name: "Dining Room Wine Fridge" - template: "major_appliance" - tabs: [3] - overrides: - typical_power: 150.0 - - - id: "1ad73b0eb44e4022bb6270c76baed0c1" - name: "Dryer" - template: "large_appliance_240v" - tabs: [4, 6] # 240V appliance - overrides: - typical_power: 3000.0 - - - id: "31b36cde0fc642b39eec515267707a6f" - name: "Outlets / Kitchen" - template: "kitchen_outlets" - tabs: [5] - - - id: "3a847fe6eb374ec3bf92cee1ffaf2eda" - name: "Microwave & Oven" - template: "large_appliance_240v" - tabs: [7, 9] # 240V appliance - overrides: - typical_power: 2500.0 - - - id: "497ae7ebb2844772bf926f2f094c81bc" - name: "Dishwasher" - template: "major_appliance" - tabs: [8] - overrides: - typical_power: 1200.0 - - - id: "617059df47bb49bd8545a36a6b6b23d2" - name: "Spa" - template: "special_equipment" - tabs: [10, 12] # 240V equipment - overrides: - typical_power: 1500.0 - - - id: "6ad65cf2ad6443448e33973f26f7df3e" - name: "Kitchen Disposal" - template: "major_appliance" - tabs: [11] - overrides: - typical_power: 800.0 - - - id: "795e8eddb4f448af9625130332a41df8" - name: "Fountain" - template: "special_equipment" - tabs: [13] - overrides: - typical_power: 200.0 - - - id: "82a0888dc072416598d99f8b74ee3d8d" - name: "Internet Living room" - template: "outlets" - tabs: [14] - overrides: - typical_power: 100.0 - - - id: "8a2ffda9dbd24bada9a01b880e910612" - name: "Large Garage EV" - template: "ev_charger" - tabs: [15, 17] # 240V EV charger - overrides: - typical_power: 7200.0 - - - id: "914943d4798c462cadf5e5144989dd5b" - name: "Other Outlets" - template: "outlets" - tabs: [16] - - - id: "926441605113492189f9aa13dac356cd" - name: "Master bedroom" - template: "outlets" - tabs: [18] - - - id: "941d6a8b41ab4c57a6b8be14b5981fe6" - name: "Air Conditioner" - template: "hvac" - tabs: [19, 21] # 240V AC unit - overrides: - typical_power: 3500.0 - - - id: "988af7bb1fc04aac8bb3b45c660d920a" - name: "Garage Outlets" - template: "outlets" - tabs: [20] - - - id: "9941b417bfa046ef908d97c998c54c50" - name: "Refrigerator" - template: "major_appliance" - tabs: [22] - overrides: - typical_power: 150.0 - - - id: "cbe98d7307ca42658983b9f673211e14" - name: "Furnace" - template: "hvac" - tabs: [23, 25] # 240V furnace - overrides: - typical_power: 2800.0 - - - id: "d88c0c0e7c584472b2ec7e4d3c53c3b8" - name: "Small Garage EV" - template: "ev_charger" - tabs: [24, 26] # 240V EV charger - overrides: - typical_power: 3600.0 - - - id: "e3cb78d38dae41cdb50faee7bfa4ab80" - name: "Washing Machine Area" - template: "major_appliance" - tabs: [27] - overrides: - typical_power: 1000.0 - - - id: "ece352c3dc5c417e80757b716f24ba90" - name: "Kitchen and Master Bath" - template: "outlets" - tabs: [28] - - - id: "f19d909abcc4421f9ec630f82458c7ac" - name: "Lights Kitchen Master bathroom" - template: "lighting" - tabs: [29] - -# Global simulation parameters -simulation_params: - update_interval: 5 - time_acceleration: 1.0 - noise_factor: 0.02 - enable_realistic_behaviors: true diff --git a/custom_components/span_panel/simulation_configs/simple_test_config.yaml b/custom_components/span_panel/simulation_configs/simple_test_config.yaml deleted file mode 100644 index 96b8e302..00000000 --- a/custom_components/span_panel/simulation_configs/simple_test_config.yaml +++ /dev/null @@ -1,73 +0,0 @@ -# Simple test configuration with minimal circuits for testing -# Demonstrates basic YAML simulation functionality - -panel_config: - serial_number: "SPAN-TEST-001" - total_tabs: 8 - main_size: 100 - -circuit_templates: - lighting: - energy_profile: - mode: "consumer" - power_range: [5.0, 50.0] - typical_power: 25.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "NEVER" - - outlets: - energy_profile: - mode: "consumer" - power_range: [0.0, 1800.0] - typical_power: 150.0 - power_variation: 0.3 - relay_behavior: "controllable" - priority: "NEVER" - - hvac: - energy_profile: - mode: "consumer" - power_range: [0.0, 3000.0] - typical_power: 2000.0 - power_variation: 0.15 - relay_behavior: "controllable" - priority: "OFF_GRID" - - solar: - energy_profile: - mode: "producer" - power_range: [-5000.0, 0.0] - typical_power: -2500.0 - power_variation: 0.2 - relay_behavior: "non_controllable" - priority: "NEVER" - -circuits: - - id: "living_room_lights" - name: "Living Room Lights" - template: "lighting" - tabs: [1, 3] # L1 + L2 = Valid 240V - - - id: "kitchen_outlets" - name: "Kitchen Outlets" - template: "outlets" - tabs: [2, 4] # L1 + L2 = Valid 240V - - - id: "main_hvac" - name: "Main HVAC" - template: "hvac" - tabs: [5, 7] # L1 + L2 = Valid 240V - - - id: "solar_inverter" - name: "Solar Inverter" - template: "solar" - tabs: [6, 8] # L1 + L2 = Valid 240V - -unmapped_tabs: [] - -simulation_params: - update_interval: 5 - time_acceleration: 1.0 - noise_factor: 0.02 - enable_realistic_behaviors: true diff --git a/custom_components/span_panel/simulation_configs/simulation_config_32_circuit.yaml b/custom_components/span_panel/simulation_configs/simulation_config_32_circuit.yaml deleted file mode 100644 index 837e9a3b..00000000 --- a/custom_components/span_panel/simulation_configs/simulation_config_32_circuit.yaml +++ /dev/null @@ -1,444 +0,0 @@ -# Example YAML simulation configuration for a 32-circuit residential panel -# This demonstrates the new flexible simulation system - -panel_config: - serial_number: "SPAN-32-SIM-001" - total_tabs: 32 - main_size: 200 # Main breaker size in Amps - -# Circuit templates define reusable behavior patterns -circuit_templates: - # Always-on base load - always_on: - energy_profile: - mode: "consumer" # Consumes power only - power_range: [40.0, 100.0] - typical_power: 60.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "NEVER" - - # Exterior lighting with time-based patterns - lighting: - energy_profile: - mode: "consumer" - power_range: [0.0, 500.0] - typical_power: 300.0 - power_variation: 0.05 - relay_behavior: "controllable" - priority: "OFF_GRID" - time_of_day_profile: - enabled: true - peak_hours: [18, 19, 20, 21, 22] # Evening hours - - # HVAC with cycling behavior - hvac: - energy_profile: - mode: "consumer" - power_range: [0.0, 3500.0] - typical_power: 2800.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "NEVER" - cycling_pattern: - on_duration: 1200 # 20 minutes - off_duration: 2400 # 40 minutes - - # Large appliances (dishwasher, laundry, etc.) - large_appliance: - energy_profile: - mode: "consumer" - power_range: [0.0, 2500.0] - typical_power: 1800.0 - power_variation: 0.15 - relay_behavior: "controllable" - priority: "OFF_GRID" - - # Refrigerator with compressor cycling - refrigerator: - energy_profile: - mode: "consumer" - power_range: [50.0, 200.0] - typical_power: 120.0 - power_variation: 0.2 - relay_behavior: "non_controllable" - priority: "NEVER" - cycling_pattern: - on_duration: 600 # 10 minutes - off_duration: 1800 # 30 minutes - - # EV charger (SPAN Drive) with smart grid response - ev_charger: - energy_profile: - mode: "consumer" - power_range: [0.0, 11500.0] - typical_power: 7200.0 # Level 2 charging (240V @ 30A) - power_variation: 0.05 - relay_behavior: "controllable" - priority: "OFF_GRID" - device_type: "evse" # Generates EVSE (SPAN Drive) snapshot - smart_behavior: - responds_to_grid: true - max_power_reduction: 0.6 # Can reduce to 40% during grid stress - - # Pool equipment - pool_equipment: - energy_profile: - mode: "consumer" - power_range: [0.0, 1200.0] - typical_power: 800.0 - power_variation: 0.1 - relay_behavior: "controllable" - priority: "OFF_GRID" - cycling_pattern: - on_duration: 7200 # 2 hours on - off_duration: 14400 # 4 hours off - - # Solar production system - generic producer - solar_production: - energy_profile: - mode: "producer" # Produces power (negative values) - power_range: [-4000.0, 0.0] # 4kW peak production - typical_power: -2500.0 # Average production - power_variation: 0.3 - efficiency: 0.85 # 85% efficiency - relay_behavior: "non_controllable" - priority: "NEVER" - time_of_day_profile: - enabled: true - peak_hours: [11, 12, 13, 14, 15] # Peak production 11 AM - 3 PM - peak_multiplier: 1.0 - off_peak_multiplier: 0.0 # No production at night - hourly_multipliers: - 6: 0.1 # Dawn - 10% production - 7: 0.2 # Early morning - 8: 0.4 # Morning - 9: 0.6 # Mid morning - 10: 0.8 # Late morning - 11: 1.0 # Peak hours - 12: 1.0 - 13: 1.0 - 14: 1.0 - 15: 1.0 # End peak - 16: 0.8 # Afternoon - 17: 0.6 # Late afternoon - 18: 0.4 # Evening - 19: 0.2 # Dusk - 20: 0.0 # Night starts - - # Backup generator - another producer type - backup_generator: - energy_profile: - mode: "producer" - power_range: [-8000.0, 0.0] # 8kW backup generator - typical_power: -6000.0 - power_variation: 0.05 # Generators are very stable - efficiency: 0.92 # 92% fuel efficiency - relay_behavior: "controllable" - priority: "NEVER" - - # Battery storage - bidirectional - battery_storage: - energy_profile: - mode: "bidirectional" # Can charge or discharge - power_range: [-5000.0, 5000.0] # ±5kW battery - typical_power: 0.0 # Neutral when idle - power_variation: 0.02 # Very stable - efficiency: 0.95 # 95% round-trip efficiency - relay_behavior: "controllable" - priority: "NEVER" - battery_behavior: - enabled: true - charge_power: 3000.0 - discharge_power: -3000.0 - idle_power: 0.0 - - # Wind turbine - variable producer - wind_production: - energy_profile: - mode: "producer" - power_range: [-2000.0, 0.0] # 2kW small wind turbine - typical_power: -800.0 - power_variation: 0.5 # Very variable based on wind - efficiency: 0.75 # 75% efficiency - relay_behavior: "non_controllable" - priority: "NEVER" - - # General outlet circuits - outlets: - energy_profile: - mode: "consumer" - power_range: [0.0, 1800.0] - typical_power: 150.0 - power_variation: 0.4 # Very variable loads - relay_behavior: "controllable" - priority: "NEVER" - - # Kitchen specific outlets (higher capacity) - kitchen_outlets: - energy_profile: - mode: "consumer" - power_range: [0.0, 2400.0] - typical_power: 300.0 - power_variation: 0.5 # Very variable - appliances - relay_behavior: "controllable" - priority: "NEVER" - - # Heat pump with seasonal efficiency - heat_pump: - energy_profile: - mode: "consumer" - power_range: [500.0, 4000.0] - typical_power: 2800.0 - power_variation: 0.25 # Efficiency varies with temperature - relay_behavior: "controllable" - priority: "OFF_GRID" - cycling_pattern: - on_duration: 900 # 15 minutes on - off_duration: 1800 # 30 minutes off - - # Major appliances (dishwasher, laundry, etc.) - major_appliance: - energy_profile: - mode: "consumer" - power_range: [0.0, 2500.0] - typical_power: 800.0 - power_variation: 0.3 - relay_behavior: "controllable" - priority: "OFF_GRID" - -circuits: - # Lighting circuits (tabs 1-6) - - id: "master_bedroom_lights" - name: "Master Bedroom Lights" - template: "lighting" - tabs: [1] - overrides: - typical_power: 35.0 # Slightly higher for master - - - id: "living_room_lights" - name: "Living Room Lights" - template: "lighting" - tabs: [2] - overrides: - typical_power: 45.0 # Higher for main living area - - - id: "kitchen_lights" - name: "Kitchen Lights" - template: "lighting" - tabs: [3] - overrides: - typical_power: 40.0 - - - id: "bedroom_lights" - name: "Bedroom Lights" - template: "lighting" - tabs: [4] - - - id: "bathroom_lights" - name: "Bathroom Lights" - template: "lighting" - tabs: [5] - overrides: - typical_power: 30.0 - - - id: "exterior_lights" - name: "Exterior Lights" - template: "lighting" - tabs: [6] - overrides: - typical_power: 60.0 - time_of_day_profile: - enabled: true - peak_hours: [18, 19, 20, 21, 22, 23, 0, 1, 2, 3, 4, 5, 6] # Nighttime - - # Outlet circuits (tabs 7-14) - - id: "master_bedroom_outlets" - name: "Master Bedroom Outlets" - template: "outlets" - tabs: [7] - - - id: "living_room_outlets" - name: "Living Room Outlets" - template: "outlets" - tabs: [8] - overrides: - typical_power: 200.0 # TV, entertainment system - - - id: "kitchen_outlets_1" - name: "Kitchen Outlets 1" - template: "kitchen_outlets" - tabs: [9] # 120V circuit - - - id: "kitchen_outlets_2" - name: "Kitchen Outlets 2" - template: "kitchen_outlets" - tabs: [12] # 120V circuit - - - id: "office_outlets" - name: "Office Outlets" - template: "outlets" - tabs: [11] - overrides: - typical_power: 300.0 # Computers, monitors - - - id: "garage_outlets" - name: "Garage Outlets" - template: "outlets" - tabs: [10] - - - id: "laundry_outlets" - name: "Laundry Room Outlets" - template: "outlets" - tabs: [13] - - - id: "guest_room_outlets" - name: "Guest Room Outlets" - template: "outlets" - tabs: [14] - - # Major appliances (tabs 15-22) - - id: "refrigerator" - name: "Refrigerator" - template: "always_on" - tabs: [15] - overrides: - typical_power: 150.0 - - - id: "dishwasher" - name: "Dishwasher" - template: "major_appliance" - tabs: [16] - overrides: - typical_power: 1200.0 - - - id: "washing_machine" - name: "Washing Machine" - template: "major_appliance" - tabs: [17] - overrides: - typical_power: 1000.0 - - - id: "dryer" - name: "Electric Dryer" - template: "major_appliance" - tabs: [18, 20] # 240V appliance - overrides: - typical_power: 3000.0 - power_range: [0.0, 4000.0] - - - id: "oven" - name: "Electric Oven" - template: "major_appliance" - tabs: [19, 21] # 240V appliance - overrides: - typical_power: 2500.0 - power_range: [0.0, 3500.0] - - - id: "microwave" - name: "Microwave" - template: "major_appliance" - tabs: [22] - overrides: - typical_power: 1000.0 - - # HVAC systems (tabs 23-26) - - id: "main_hvac" - name: "Main HVAC Unit" - template: "hvac" - tabs: [23, 25] # 240V system - - - id: "heat_pump_backup" - name: "Heat Pump Backup" - template: "heat_pump" - tabs: [24, 26] # 240V system - - # EV charging (tabs 27-29) - - id: "ev_charger_garage" - name: "Garage EV Charger" - template: "ev_charger" - tabs: [27, 29] # 240V Level 2 charger - -# Configuration for unmapped tabs with specific behaviors -# Tabs 30 and 32: Solar production tabs for integration testing -# These tabs remain unmapped (no circuits) but have synchronized behavior -unmapped_tab_templates: - "30": - energy_profile: - mode: "producer" - power_range: [-2000.0, 0.0] # 2kW production capacity per phase - typical_power: -1500.0 # 1.5kW average per phase - power_variation: 0.2 - efficiency: 0.85 - relay_behavior: "non_controllable" - priority: "NEVER" - time_of_day_profile: - enabled: true - peak_hours: [11, 12, 13, 14, 15] # Peak production 11 AM - 3 PM - peak_multiplier: 1.0 - off_peak_multiplier: 0.0 # No production at night - hourly_multipliers: - 6: 0.1 # Dawn - 10% production - 7: 0.2 # Early morning - 8: 0.4 # Morning - 9: 0.6 # Mid morning - 10: 0.8 # Late morning - 11: 1.0 # Peak hours - 12: 1.0 - 13: 1.0 - 14: 1.0 - 15: 1.0 # End peak - 16: 0.8 # Afternoon - 17: 0.6 # Late afternoon - 18: 0.4 # Evening - 19: 0.2 # Dusk - 20: 0.0 # Night starts - - "32": - energy_profile: - mode: "producer" - power_range: [-2000.0, 0.0] # 2kW production capacity per phase - typical_power: -1500.0 # 1.5kW average per phase - power_variation: 0.2 - efficiency: 0.85 - relay_behavior: "non_controllable" - priority: "NEVER" - time_of_day_profile: - enabled: true - peak_hours: [11, 12, 13, 14, 15] # Peak production 11 AM - 3 PM - peak_multiplier: 1.0 - off_peak_multiplier: 0.0 # No production at night - hourly_multipliers: - 6: 0.1 # Dawn - 10% production - 7: 0.2 # Early morning - 8: 0.4 # Morning - 9: 0.6 # Mid morning - 10: 0.8 # Late morning - 11: 1.0 # Peak hours - 12: 1.0 - 13: 1.0 - 14: 1.0 - 15: 1.0 # End peak - 16: 0.8 # Afternoon - 17: 0.6 # Late afternoon - 18: 0.4 # Evening - 19: 0.2 # Dusk - 20: 0.0 # Night starts - -# Tab synchronization for coordinated behavior (e.g., 240V loads, multi-phase production) -tab_synchronizations: - - tabs: [30, 32] - behavior: "240v_split_phase" # Two phases of same 240V system - power_split: "equal" # Equal power on both phases - energy_sync: true # Synchronized energy accumulation - template: "solar_production" # Generic production template - -# Unmapped tabs that should have behavior but remain unmapped (no circuits created) -unmapped_tabs: [30, 32] - -# Global simulation parameters -simulation_params: - update_interval: 5 # Update every 5 seconds - time_acceleration: 1.0 # Real-time simulation - noise_factor: 0.02 # ±2% random noise on all values - enable_realistic_behaviors: true diff --git a/custom_components/span_panel/simulation_configs/simulation_config_40_circuit_with_battery.yaml b/custom_components/span_panel/simulation_configs/simulation_config_40_circuit_with_battery.yaml deleted file mode 100644 index 5d930bf8..00000000 --- a/custom_components/span_panel/simulation_configs/simulation_config_40_circuit_with_battery.yaml +++ /dev/null @@ -1,325 +0,0 @@ -# 40-tab panel configuration with battery storage and unmapped tabs -# This config intentionally leaves some tabs unmapped to test edge cases - -panel_config: - serial_number: "SPAN-40-BATTERY-001" - total_tabs: 40 - main_size: 400 # Larger main breaker for 40-tab panel - -circuit_templates: - # Standard templates - lighting: - energy_profile: - mode: "consumer" - power_range: [5.0, 50.0] - typical_power: 25.0 - power_variation: 0.15 - relay_behavior: "controllable" - priority: "NEVER" - - outlets: - energy_profile: - mode: "consumer" - power_range: [0.0, 1800.0] - typical_power: 150.0 - power_variation: 0.4 - relay_behavior: "controllable" - priority: "NEVER" - - # EV charger (SPAN Drive) with smart grid response - ev_charger: - energy_profile: - mode: "consumer" - power_range: [0.0, 9600.0] - typical_power: 7200.0 # Level 2 charging (240V @ 30A) - power_variation: 0.05 - relay_behavior: "controllable" - priority: "OFF_GRID" - device_type: "evse" # Generates EVSE (SPAN Drive) snapshot - smart_behavior: - responds_to_grid: true - max_power_reduction: 0.6 # Can reduce to 40% during grid stress - - hvac: - energy_profile: - mode: "consumer" - power_range: [0.0, 3000.0] - typical_power: 1500.0 - power_variation: 0.2 - relay_behavior: "controllable" - priority: "NEVER" - cycling_pattern: - on_duration: 900 # 15 minutes - off_duration: 1800 # 30 minutes - - solar: - energy_profile: - mode: "producer" - power_range: [-8000.0, 0.0] - typical_power: -4000.0 - power_variation: 0.4 - relay_behavior: "non_controllable" - priority: "NEVER" - - battery: - energy_profile: - mode: "bidirectional" - power_range: [-5000.0, 5000.0] # Can charge or discharge - typical_power: 0.0 # Neutral when idle - power_variation: 0.1 - relay_behavior: "non_controllable" - priority: "NEVER" - battery_behavior: - enabled: true - charge_efficiency: 0.95 # 95% efficient charging - discharge_efficiency: 0.95 # 95% efficient discharging - charge_hours: [9, 10, 11, 12, 13, 14, 15, 16] # Solar hours - discharge_hours: [17, 18, 19, 20, 21] # Peak demand hours - max_charge_power: -3000.0 # Max charging power (negative) - max_discharge_power: 2500.0 # Max discharge power (positive) - idle_hours: [0, 1, 2, 3, 4, 5, 6, 7, 8, 22, 23] # Low activity hours - idle_power_range: [-100.0, 100.0] # Random power during idle hours - # Solar intensity profile for charging (hour: intensity_factor) - solar_intensity_profile: - 9: 0.2 - 10: 0.4 - 11: 0.7 - 12: 1.0 # Peak solar - 13: 1.0 - 14: 0.8 - 15: 0.6 - 16: 0.3 - # Demand factor profile for discharging (hour: demand_factor) - demand_factor_profile: - 17: 0.6 # Early evening - 18: 0.8 # Peak start - 19: 1.0 # Peak demand - 20: 0.9 # High demand - 21: 0.7 # Demand decreasing - -circuits: - # 38 circuits for a 40-tab panel (tab 39 unmapped) - - # Main lighting circuits (tabs 1-8) - - id: "kitchen_lights" - name: "Kitchen Lights" - template: "lighting" - tabs: [1] - - - id: "living_room_lights" - name: "Living Room Lights" - template: "lighting" - tabs: [2] - - - id: "bedroom_lights" - name: "Bedroom Lights" - template: "lighting" - tabs: [3] - - - id: "bathroom_lights" - name: "Bathroom Lights" - template: "lighting" - tabs: [4] - - - id: "outdoor_lights" - name: "Outdoor Lights" - template: "lighting" - tabs: [5] - - - id: "garage_lights" - name: "Garage Lights" - template: "lighting" - tabs: [6] - - - id: "basement_lights" - name: "Basement Lights" - template: "lighting" - tabs: [7] - - - id: "office_lights" - name: "Office Lights" - template: "lighting" - tabs: [8] - - # Outlet circuits (tabs 9-16) - - id: "kitchen_outlets" - name: "Kitchen Outlets" - template: "outlets" - tabs: [9] - - - id: "living_room_outlets" - name: "Living Room Outlets" - template: "outlets" - tabs: [10] - - - id: "bedroom_outlets" - name: "Bedroom Outlets" - template: "outlets" - tabs: [11] - - - id: "bathroom_outlets" - name: "Bathroom Outlets" - template: "outlets" - tabs: [12] - - - id: "garage_outlets" - name: "Garage Outlets" - template: "outlets" - tabs: [13] - - - id: "basement_outlets" - name: "Basement Outlets" - template: "outlets" - tabs: [14] - - - id: "office_outlets" - name: "Office Outlets" - template: "outlets" - tabs: [15] - - - id: "outdoor_outlets" - name: "Outdoor Outlets" - template: "outlets" - tabs: [16] - - # HVAC systems (240V, using opposing tabs) - - id: "main_hvac" - name: "Main HVAC System" - template: "hvac" - tabs: [17, 19] - - - id: "secondary_hvac" - name: "Secondary HVAC System" - template: "hvac" - tabs: [18, 20] - - # Major appliances (240V) - - id: "electric_range" - name: "Electric Range" - template: "outlets" - tabs: [21, 23] - overrides: - power_range: [0.0, 8000.0] - typical_power: 2000.0 - - - id: "electric_dryer" - name: "Electric Dryer" - template: "outlets" - tabs: [22, 24] - overrides: - power_range: [0.0, 5000.0] - typical_power: 1500.0 - - - id: "water_heater" - name: "Water Heater" - template: "outlets" - tabs: [25, 27] - overrides: - power_range: [0.0, 4500.0] - typical_power: 2000.0 - - - id: "ev_charger_garage" - name: "Garage EV Charger" - template: "ev_charger" - tabs: [26, 28] - - - id: "ev_charger_driveway" - name: "Driveway EV Charger" - template: "ev_charger" - tabs: [38, 40] # 240V Level 2 charger - - # Solar and battery systems - - id: "solar_inverter_1" - name: "Solar Inverter 1" - template: "solar" - tabs: [29, 31] - - - id: "solar_inverter_2" - name: "Solar Inverter 2" - template: "solar" - tabs: [30, 32] - - # Battery storage on opposing phased tabs as requested - - id: "battery_system_1" - name: "Battery System 1" - template: "battery" - tabs: [33, 35] - - - id: "battery_system_2" - name: "Battery System 2" - template: "battery" - tabs: [34, 36] - - # Additional circuit (using tab 37) - - id: "pool_pump" - name: "Pool Pump" - template: "outlets" - tabs: [37] - overrides: - power_range: [0.0, 2000.0] - typical_power: 800.0 - - # Tab 39 is intentionally left unmapped to test unmapped tab creation - -# Circuit synchronizations for 240V systems -tab_synchronizations: - - tabs: [33, 35] # Battery System 1 - 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "battery_sync" - - - tabs: [34, 36] # Battery System 2 - 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "battery_sync" - - - tabs: [29, 31] # Solar Inverter 1 - 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "solar_sync" - - - tabs: [30, 32] # Solar Inverter 2 - 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "solar_sync" - - - tabs: [26, 28] # Garage EV Charger - 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "ev_charger_sync" - - - tabs: [38, 40] # Driveway EV Charger - 240V split phase - behavior: "240v_split_phase" - power_split: "equal" - energy_sync: true - template: "ev_charger_sync" - -# Explicitly list unmapped tabs for testing purposes -unmapped_tabs: [39] # Tab 39 remains unmapped (38 and 40 now used by driveway EV charger) - -# Unmapped tab templates for tabs that should have behavior but remain unmapped -unmapped_tab_templates: - "39": - energy_profile: - mode: "consumer" - power_range: [0.0, 800.0] - typical_power: 150.0 - power_variation: 0.2 - relay_behavior: "non_controllable" - priority: "OFF_GRID" - -simulation_params: - enable_realistic_behaviors: true - noise_factor: 0.02 - time_acceleration: 1.0 - update_interval: 5 - # Advanced battery behavior - battery_behaviors: - charge_efficiency: 0.95 - discharge_efficiency: 0.90 - self_discharge_rate: 0.001 # 0.1% per hour diff --git a/custom_components/span_panel/simulation_configs/simulation_config_8_tab_workshop.yaml b/custom_components/span_panel/simulation_configs/simulation_config_8_tab_workshop.yaml deleted file mode 100644 index 6f477fcc..00000000 --- a/custom_components/span_panel/simulation_configs/simulation_config_8_tab_workshop.yaml +++ /dev/null @@ -1,170 +0,0 @@ -# 8-tab workshop panel configuration with 4 realistic circuits -# Tabs 5-8 left unmapped for automatic unmapped tab detection testing - -panel_config: - serial_number: "SPAN-WORKSHOP-001" - total_tabs: 8 - main_size: 200 - -circuit_templates: - # Workshop-specific templates with realistic power ranges - - workshop_lighting: - energy_profile: - mode: "consumer" - power_range: [20.0, 80.0] - typical_power: 45.0 - power_variation: 0.2 - relay_behavior: "controllable" - priority: "NEVER" - time_of_day_profile: - enabled: true - peak_hours: [7, 8, 17, 18, 19, 20] - peak_multiplier: 1.0 - off_peak_multiplier: 0.1 - - heavy_machinery: - energy_profile: - mode: "consumer" - power_range: [0.0, 3500.0] - typical_power: 2200.0 - power_variation: 0.4 - relay_behavior: "controllable" - priority: "OFF_GRID" - cycling_pattern: - enabled: true - on_duration_minutes: 15 - off_duration_minutes: 10 - duty_cycle: 0.6 - - power_tools: - energy_profile: - mode: "consumer" - power_range: [0.0, 1800.0] - typical_power: 400.0 - power_variation: 0.6 - relay_behavior: "controllable" - priority: "OFF_GRID" - cycling_pattern: - enabled: true - on_duration_minutes: 8 - off_duration_minutes: 25 - duty_cycle: 0.25 - - hvac_workshop: - energy_profile: - mode: "consumer" - power_range: [0.0, 2400.0] - typical_power: 1500.0 - power_variation: 0.3 - relay_behavior: "controllable" - priority: "SOC_THRESHOLD" - time_of_day_profile: - enabled: true - peak_hours: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17] - peak_multiplier: 1.0 - off_peak_multiplier: 0.2 - cycling_pattern: - enabled: true - on_duration_minutes: 20 - off_duration_minutes: 15 - duty_cycle: 0.57 - -circuits: - - id: "workshop_led_lighting" - name: "Workshop LED Lighting" - template: "workshop_lighting" - tabs: [1] - - - id: "table_saw_planer" - name: "Table Saw & Planer" - template: "heavy_machinery" - tabs: [2] - - - id: "power_tool_outlets" - name: "Power Tool Outlets" - template: "power_tools" - tabs: [3] - - - id: "workshop_hvac" - name: "Workshop HVAC" - template: "hvac_workshop" - tabs: [4] - -# Tabs 5, 6, 7, 8 are intentionally left unmapped - -# Unmapped tab templates for tabs that should have behavior but remain unmapped -unmapped_tab_templates: - "5": - energy_profile: - mode: "consumer" - power_range: [0.0, 1000.0] - typical_power: 200.0 - power_variation: 0.3 - relay_behavior: "controllable" - priority: "OFF_GRID" - cycling_pattern: - enabled: true - on_duration_minutes: 10 - off_duration_minutes: 20 - duty_cycle: 0.33 - - "6": - energy_profile: - mode: "consumer" - power_range: [0.0, 1500.0] - typical_power: 400.0 - power_variation: 0.4 - relay_behavior: "controllable" - priority: "OFF_GRID" - cycling_pattern: - enabled: true - on_duration_minutes: 15 - off_duration_minutes: 25 - duty_cycle: 0.38 - - "7": - energy_profile: - mode: "consumer" - power_range: [0.0, 800.0] - typical_power: 150.0 - power_variation: 0.2 - relay_behavior: "controllable" - priority: "OFF_GRID" - time_of_day_profile: - enabled: true - peak_hours: [8, 9, 10, 11, 12, 13, 14, 15, 16, 17] - peak_multiplier: 1.0 - off_peak_multiplier: 0.1 - - "8": - energy_profile: - mode: "consumer" - power_range: [0.0, 1200.0] - typical_power: 300.0 - power_variation: 0.3 - relay_behavior: "controllable" - priority: "OFF_GRID" - cycling_pattern: - enabled: true - on_duration_minutes: 12 - off_duration_minutes: 18 - duty_cycle: 0.4 - -# Explicitly list unmapped tabs for testing purposes -unmapped_tabs: [5, 6, 7, 8] - -behavior_engine: - enabled: true - time_of_day_enabled: true - hvac_cycling_enabled: true - weather_simulation_enabled: true - smart_grid_enabled: true - solar: - enabled: false # Workshop typically doesn't have solar - -simulation_params: - enable_realistic_behaviors: true - noise_factor: 0.03 # Slightly more variation for workshop equipment - time_acceleration: 1.0 - update_interval: 5 diff --git a/custom_components/span_panel/simulation_factory.py b/custom_components/span_panel/simulation_factory.py deleted file mode 100644 index 97414194..00000000 --- a/custom_components/span_panel/simulation_factory.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Simulation factory for SPAN Panel integration. - -This module provides a factory pattern for handling simulation mode setup -without polluting the main integration code. It allows the integration to -treat simulation mode as if it were a real panel for YAML generation. -""" - -from __future__ import annotations - -import logging -import os -from typing import Any - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from .coordinator import SpanPanelCoordinator -from .synthetic_sensors import SyntheticSensorCoordinator - -_LOGGER = logging.getLogger(__name__) - - -class SimulationModeFactory: - """Factory for handling simulation mode setup and configuration.""" - - @staticmethod - def is_simulation_mode() -> bool: - """Check if we're running in simulation mode. - - Returns: - True if SPAN_USE_REAL_SIMULATION environment variable is set - - """ - return os.environ.get("SPAN_USE_REAL_SIMULATION", "").lower() in ("1", "true", "yes") - - @staticmethod - def create_simulation_coordinator( - coordinator: SyntheticSensorCoordinator, - ) -> SimulationCoordinator: - """Create a simulation coordinator that wraps the synthetic coordinator. - - Args: - coordinator: The synthetic sensor coordinator to wrap - - Returns: - SimulationCoordinator that provides simulation-specific behavior - - """ - return SimulationCoordinator(coordinator) - - @staticmethod - def setup_simulation_logging() -> None: - """Set up enhanced logging for simulation mode.""" - if SimulationModeFactory.is_simulation_mode(): - _LOGGER.info("🔧 Simulation mode enabled - enhanced logging active") - # Set debug level for simulation-related components - logging.getLogger("custom_components.span_panel.synthetic_sensors").setLevel( - logging.DEBUG - ) - logging.getLogger("custom_components.span_panel.synthetic_panel_circuits").setLevel( - logging.DEBUG - ) - logging.getLogger("custom_components.span_panel.synthetic_named_circuits").setLevel( - logging.DEBUG - ) - - -class SimulationCoordinator: - """Coordinator that provides simulation-specific behavior for synthetic sensors. - - This class wraps the main SyntheticSensorCoordinator and provides - simulation-specific setup and logging without modifying the core logic. - """ - - def __init__(self, coordinator: SyntheticSensorCoordinator): - """Initialize the simulation coordinator. - - Args: - coordinator: The synthetic sensor coordinator to wrap - - """ - self.coordinator = coordinator - self._is_simulation = SimulationModeFactory.is_simulation_mode() - - async def setup_configuration(self, config_entry: ConfigEntry) -> Any: - """Set up configuration with simulation-specific behavior. - - This method delegates to the main coordinator but adds simulation-specific - logging and behavior. - """ - if not self._is_simulation: - # Delegate to normal coordinator behavior - return await self.coordinator._setup_live_configuration(config_entry) - - # Simulation mode: use the same logic but with enhanced logging - _LOGGER.info("🔧 Simulation mode: Generating YAML from simulated panel data") - - # For simulation mode, we use the same configuration logic as live mode - # but with enhanced logging to show it's working with simulated data - result = await self.coordinator._setup_live_configuration(config_entry) - - _LOGGER.info("🎯 Simulation mode: YAML generation completed successfully") - return result - - def get_coordinator(self) -> SyntheticSensorCoordinator: - """Get the underlying synthetic sensor coordinator.""" - return self.coordinator - - -def create_synthetic_coordinator_with_simulation_support( - hass: HomeAssistant, coordinator: SpanPanelCoordinator, device_name: str -) -> SimulationCoordinator | SyntheticSensorCoordinator: - """Create a synthetic coordinator with optional simulation support. - - This factory function creates the appropriate coordinator based on whether - simulation mode is enabled, keeping the main integration code clean. - - Args: - hass: Home Assistant instance - coordinator: SPAN panel coordinator - device_name: Device name for the coordinator - - Returns: - Either a SimulationCoordinator (if simulation mode) or SyntheticSensorCoordinator - - """ - # Set up simulation logging if needed - SimulationModeFactory.setup_simulation_logging() - - # Create the base coordinator - base_coordinator = SyntheticSensorCoordinator(hass, coordinator, device_name) - - # Wrap with simulation coordinator if in simulation mode - if SimulationModeFactory.is_simulation_mode(): - return SimulationModeFactory.create_simulation_coordinator(base_coordinator) - - # Return the base coordinator for normal operation - return base_coordinator diff --git a/custom_components/span_panel/simulation_generator.py b/custom_components/span_panel/simulation_generator.py deleted file mode 100644 index 990a787f..00000000 --- a/custom_components/span_panel/simulation_generator.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Build simulation YAML from a live panel snapshot. - -This module inspects the current coordinator data and produces a YAML dict -that matches span_panel_api's simulation reference. It infers templates from -names and seeds energy profiles from current power readings. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - - -@dataclass -class SimulationYamlGenerator: - """Generate YAML from live panel data.""" - - hass: Any - coordinator: Any - - async def build_yaml_from_live_panel(self) -> tuple[dict[str, Any], int]: - """Build YAML from live panel data.""" - data = getattr(self.coordinator, "data", None) - circuits_obj = getattr(data, "circuits", None) - - # Prepare containers - circuit_templates: dict[str, Any] = {} - circuits: list[dict[str, Any]] = [] - mapped_tabs: set[int] = set() - - # Iterate circuits - iter_dict: dict[str, Any] = {} - # Safely extract iterable circuits mapping without accessing attributes on None - if isinstance(circuits_obj, dict): - iter_dict = circuits_obj - elif circuits_obj is not None: - inner_circuits = getattr(circuits_obj, "circuits", None) - if isinstance(inner_circuits, dict): - iter_dict = inner_circuits - - for cid, c in iter_dict.items(): - name = str(getattr(c, "name", cid)) - power_w = float(getattr(c, "instant_power_w", 0.0) or 0.0) - raw_tabs = getattr(c, "tabs", []) if hasattr(c, "tabs") else [] - tabs = ( - list(raw_tabs) - if isinstance(raw_tabs, (list | tuple)) - else ([] if raw_tabs in (None, "UNSET") else [int(raw_tabs)]) - ) - mapped_tabs.update(tabs) - - # Skip unmapped tab circuits - they should be handled as unmapped tabs, not circuits - if str(cid).startswith("unmapped_tab_"): - continue - - template_key = self._infer_template_key(name, power_w, tabs) - if template_key not in circuit_templates: - circuit_templates[template_key] = self._make_template(template_key, power_w, name) - - entry: dict[str, Any] = { - "id": str(cid), - "name": name, - "tabs": tabs, - "template": template_key, - } - if power_w != 0.0: - entry["overrides"] = {"energy_profile": {"typical_power": power_w}} - - circuits.append(entry) - - # Compute total tabs - num_tabs = 32 - if mapped_tabs: - max_tab = max(mapped_tabs) - if max_tab <= 8: - num_tabs = 8 - elif max_tab <= 32: - num_tabs = 32 - else: - num_tabs = 40 - - # Panel config - serial = ( - getattr(getattr(data, "status", None), "serial_number", None) or "span_panel_simulation" - ) - snapshot_yaml: dict[str, Any] = { - "panel_config": { - "serial_number": str(serial), - "total_tabs": num_tabs, - "main_size": 200, - }, - "circuit_templates": circuit_templates, - "circuits": circuits, - "unmapped_tabs": sorted(set(range(1, num_tabs + 1)) - mapped_tabs), - "simulation_params": { - "update_interval": 5, - "time_acceleration": 1.0, - "noise_factor": 0.02, - }, - } - - return snapshot_yaml, num_tabs - - def _infer_template_key(self, name: str, power_w: float, tabs: list[int]) -> str: - lname = name.lower() - if any(k in lname for k in ("light", "lights")): - return "lighting" - if "kitchen" in lname and "outlet" in lname: - return "kitchen_outlets" - if any(k in lname for k in ("hvac", "furnace", "air conditioner", "ac", "heat pump")): - return "hvac" - if any(k in lname for k in ("fridge", "refrigerator", "wine fridge")): - return "refrigerator" - if any(k in lname for k in ("ev", "charger")): - return "ev_charger" - if any(k in lname for k in ("pool", "spa", "fountain")): - return "pool_equipment" - if any(k in lname for k in ("internet", "router", "network", "modem")): - return "always_on" - if len(tabs) >= 2: - return "major_appliance" - if "outlet" in lname: - return "outlets" - if power_w < 0: - return "producer" - return "major_appliance" - - def _make_template(self, key: str, typical: float, name: str) -> dict[str, Any]: - # Base ranges derived from snapshot - if key == "producer" or typical < 0: - pr_min = min(typical * 2.0, -50.0) - profile = { - "mode": "producer", - "power_range": [pr_min, 0.0], - "typical_power": typical, - "power_variation": 0.3, - } - return { - "energy_profile": profile, - "relay_behavior": "non_controllable", - "priority": "MUST_HAVE", - } - - if key == "ev_charger": - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 7200.0)], - "typical_power": max(typical, 3000.0), - "power_variation": 0.15, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - # Prefer night charging and respond to grid stress - "time_of_day_profile": { - "enabled": True, - "peak_hours": [22, 23, 0, 1, 2, 3, 4, 5, 6], - }, - "smart_behavior": {"responds_to_grid": True, "max_power_reduction": 0.6}, - } - - if key == "refrigerator": - profile = { - "mode": "consumer", - "power_range": [50.0, 200.0], - "typical_power": max(typical, 120.0), - "power_variation": 0.2, - } - return { - "energy_profile": profile, - "relay_behavior": "non_controllable", - "priority": "MUST_HAVE", - "cycling_pattern": {"on_duration": 600, "off_duration": 1800}, - } - - if key == "hvac": - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 2800.0)], - "typical_power": max(typical, 1800.0), - "power_variation": 0.15, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - "cycling_pattern": {"on_duration": 1200, "off_duration": 2400}, - } - - if key == "lighting": - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 300.0)], - "typical_power": max(typical, 40.0), - "power_variation": 0.1, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - "time_of_day_profile": {"enabled": True, "peak_hours": [18, 19, 20, 21, 22]}, - } - - if key == "kitchen_outlets": - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 2400.0)], - "typical_power": max(typical, 300.0), - "power_variation": 0.4, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - - if key == "outlets": - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 1800.0)], - "typical_power": max(typical, 150.0), - "power_variation": 0.4, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - - if key == "always_on": - profile = { - "mode": "consumer", - "power_range": [40.0, 100.0], - "typical_power": max(typical, 60.0), - "power_variation": 0.1, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "MUST_HAVE", - } - - if key == "pool_equipment": - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 1200.0)], - "typical_power": max(typical, 800.0), - "power_variation": 0.1, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - # Typical pump run: 2h on, 4h off, repeating - "cycling_pattern": {"on_duration": 7200, "off_duration": 14400}, - } - - # major_appliance and fallback - profile = { - "mode": "consumer", - "power_range": [0.0, max(abs(typical) * 2.0, 2500.0)], - "typical_power": max(typical, 800.0), - "power_variation": 0.3, - } - return { - "energy_profile": profile, - "relay_behavior": "controllable", - "priority": "NON_ESSENTIAL", - } diff --git a/custom_components/span_panel/simulation_utils.py b/custom_components/span_panel/simulation_utils.py deleted file mode 100644 index c9fa42f3..00000000 --- a/custom_components/span_panel/simulation_utils.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Simulation utilities for SPAN Panel integration.""" - -from __future__ import annotations - -import logging -from pathlib import Path -from typing import Any - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.util import slugify -import yaml - -from .const import ( - CONF_SIMULATION_CONFIG, - COORDINATOR, - DOMAIN, -) -from .simulation_generator import SimulationYamlGenerator - -_LOGGER = logging.getLogger(__name__) - - -def infer_template_for(name: str, tabs: list[int]) -> str: - """Infer circuit template based on circuit name and tab configuration. - - Args: - name: Circuit name to analyze - tabs: List of tab numbers for this circuit - - Returns: - Template string identifier for the circuit type - - """ - lname = str(name).lower() - if any(k in lname for k in ["light", "lights"]): - return "lighting" - if "kitchen" in lname and "outlet" in lname: - return "kitchen_outlets" - if any(k in lname for k in ["hvac", "furnace", "air conditioner", "ac", "heat pump"]): - return "hvac" - if any(k in lname for k in ["fridge", "refrigerator", "wine fridge"]): - return "refrigerator" - if any(k in lname for k in ["ev", "charger"]): - return "ev_charger" - if any(k in lname for k in ["pool", "spa", "fountain"]): - return "pool_equipment" - if any(k in lname for k in ["internet", "router", "network", "modem"]): - return "always_on" - # Heuristics: 240V multi-tab loads as major appliances - if len(tabs) >= 2: - return "major_appliance" - # Fallbacks - if "outlet" in lname: - return "outlets" - return "major_appliance" - - -async def clone_panel_to_simulation( - hass: HomeAssistant, - config_entry: ConfigEntry, - user_input: dict[str, Any] | None = None, -) -> tuple[Path, dict[str, str]]: - """Clone the live panel into a simulation YAML stored in simulation_configs. - - Args: - hass: Home Assistant instance - config_entry: Configuration entry for the SPAN panel - user_input: User input from the config flow form - - Returns: - Tuple of (destination_path, errors_dict) - - """ - errors: dict[str, str] = {} - - # Compute default filename first - device_name = config_entry.data.get("device_name", config_entry.title) - safe_device = slugify(device_name) if isinstance(device_name, str) else "span_panel" - - config_dir = Path(__file__).parent / "simulation_configs" - base_name = f"simulation_config_{safe_device}.yaml" - dest_path = config_dir / base_name - - # Resolve coordinator (live) - coordinator_data = hass.data.get(DOMAIN, {}).get(config_entry.entry_id, {}) - coordinator = coordinator_data.get(COORDINATOR) - if coordinator is None: - errors["base"] = "coordinator_unavailable" - return dest_path, errors - - # Suffix if exists: _2, _3, ... - suffix_index = 1 - if await hass.async_add_executor_job(dest_path.exists): - suffix_index = 2 - while True: - candidate = config_dir / f"simulation_config_{safe_device}_{suffix_index}.yaml" - if not await hass.async_add_executor_job(candidate.exists): - dest_path = candidate - break - suffix_index += 1 - - if user_input is not None: - try: - # Use a separate generator to build YAML purely from live data - generator = SimulationYamlGenerator( - hass=hass, - coordinator=coordinator, - ) - snapshot_yaml, num_tabs = await generator.build_yaml_from_live_panel() - - # snapshot_yaml and num_tabs returned by generator - snapshot_yaml["panel_config"]["serial_number"] = f"{safe_device}_simulation" + ( - "" if suffix_index == 1 else f"_{suffix_index}" - ) - - # Use the same filename pattern (device name based, not tab count) - # The dest_path is already correctly set above - - # Ensure directory exists and write file - await hass.async_add_executor_job( - lambda: dest_path.parent.mkdir(parents=True, exist_ok=True) - ) - - def _write_yaml() -> None: - with dest_path.open("w", encoding="utf-8") as f: - yaml.safe_dump(snapshot_yaml, f, sort_keys=False) - - await hass.async_add_executor_job(_write_yaml) - _LOGGER.info("Cloned live panel to simulation YAML at %s", dest_path) - - # Update config entry to point to the new simulation config - try: - new_data = dict(config_entry.data) - new_data[CONF_SIMULATION_CONFIG] = dest_path.stem - hass.config_entries.async_update_entry(config_entry, data=new_data) - _LOGGER.debug("Set CONF_SIMULATION_CONFIG to %s", dest_path.stem) - except Exception as update_err: - _LOGGER.warning( - "Failed to set CONF_SIMULATION_CONFIG to %s: %s", - dest_path.stem, - update_err, - ) - - # Return success with no errors - return dest_path, {} - - except Exception as e: - _LOGGER.error("Clone to simulation failed: %s", e) - errors["base"] = f"Clone failed: {e}" - - # Return the destination path for the form - return dest_path, errors diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json index f9715792..1b47985f 100644 --- a/custom_components/span_panel/strings.json +++ b/custom_components/span_panel/strings.json @@ -10,10 +10,14 @@ }, "error": { "cannot_connect": "Failed to connect to Span Panel", - "host_required": "Host is required for non-simulator mode", + "host_required": "Host is required", "invalid_auth": "Invalid authentication", "proximity_failed": "Proximity not proven. Please open and close the panel door 3 times and try again.", - "unknown": "Unexpected error" + "unknown": "Unexpected error", + "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time." + }, + "progress": { + "registering_fqdn": "Registering domain name with the panel and waiting for TLS certificate update. This may take up to 60 seconds..." }, "flow_title": "Span Panel ({host})", "step": { @@ -24,19 +28,19 @@ "user": { "data": { "host": "Host", - "simulator_mode": "Simulator Mode", + "http_port": "HTTP Port", "power_display_precision": "Power Display Precision", "energy_display_precision": "Energy Display Precision", "enable_energy_dip_compensation": "Auto-Compensate Energy Dips" }, "data_description": { - "host": "IP address or hostname of SPAN Panel (required for real hardware, optional for simulation mode)", - "simulator_mode": "Enable simulator mode for testing (no hardware required). Host field becomes optional when simulation mode is enabled.", + "host": "IP address or hostname of SPAN Panel", + "http_port": "HTTP port for the panel API (default: 80)", "power_display_precision": "Number of decimal places for power values (0-3)", "energy_display_precision": "Number of decimal places for energy values (0-3)", "enable_energy_dip_compensation": "Automatically compensate when the panel reports lower energy readings, preventing spikes in the energy dashboard." }, - "description": "Enter the connection details for your SPAN Panel. For simulation mode, check the simulator checkbox and the host field becomes optional.", + "description": "Enter the connection details for your SPAN Panel.", "title": "Connect to the Span Panel" }, "choose_auth_type": { @@ -84,40 +88,27 @@ "host": "IP address or hostname of the SPAN Panel" } }, - "simulator_config": { - "title": "Simulator Configuration", - "description": "Select a simulation configuration and optionally set a custom start time. {config_count} configurations available.", - "data": { - "simulation_config": "Simulation Configuration", - "host": "Host (Serial Number)", - "simulation_start_time": "Simulation Start Time (Optional)", - "entity_naming_pattern": "Entity Naming Pattern" - }, - "data_description": { - "simulation_config": "Choose a simulation configuration that defines circuit templates and energy profiles", - "host": "Custom serial number for the simulated panel (optional, will use config default if empty)", - "simulation_start_time": "Time format: HH:MM (24h), H:MM (12h), or full ISO datetime (YYYY-MM-DDTHH:MM:SS). Leave empty to use current time.", - "entity_naming_pattern": "Choose how circuit entities are named in the simulation" - } + "register_fqdn": { + "title": "Registering Domain Name" + }, + "fqdn_failed": { + "title": "Domain Registration", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name." + }, + "reconfigure_register_fqdn": { + "title": "Registering Domain Name" + }, + "reconfigure_fqdn_failed": { + "title": "Domain Registration", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name." + }, + "reconfigure_fqdn_done": { + "title": "Reconfiguration Complete" } } }, "options": { - "error": { - "Invalid date/time format. Use ISO format (YYYY-MM-DDTHH:MM:SS)": "Invalid time format. Use HH:MM (24h), H:MM (12h), or full ISO datetime (YYYY-MM-DDTHH:MM:SS)", - "directory": "Directory path is required", - "base": "Export failed: {error}" - }, "step": { - "init": { - "title": "Options Menu", - "menu_options": { - "general_options": "General Options", - "export_config": "Export Synthetic Sensor Config", - "simulation_start_time": "Simulation Start Time", - "simulation_offline_minutes": "Simulation Offline Minutes" - } - }, "general_options": { "title": "General Options", "description": "Configure SPAN Panel integration settings.", @@ -135,36 +126,6 @@ "energy_reporting_grace_period": "How long energy sensors maintain their last known value when the panel becomes unavailable (0-60 minutes). Helps preserve energy statistics integrity during brief outages. Default: 15 minutes.", "enable_energy_dip_compensation": "Automatically compensate when the panel reports lower energy readings. Disabling clears all accumulated offsets." } - }, - "simulation_start_time": { - "title": "Simulation Start Time", - "description": "Configure the simulation start time. Changes to simulation time will reload the integration to apply the new time.", - "data": { - "simulation_start_time": "Simulation Start Time" - }, - "data_description": { - "simulation_start_time": "Time format: HH:MM (24h), H:MM (12h), or full ISO datetime (YYYY-MM-DDTHH:MM:SS). Leave empty to use current time. Integration will reload to apply changes." - } - }, - "simulation_offline_minutes": { - "title": "Simulation Offline Minutes", - "description": "Configure how long the panel should appear offline during simulation.", - "data": { - "simulation_offline_minutes": "Offline Minutes" - }, - "data_description": { - "simulation_offline_minutes": "Number of minutes the panel should appear offline during simulation. Set to 0 to disable offline simulation." - } - }, - "export_config": { - "title": "Export Synthetic Sensor Config", - "description": "Export the current synthetic sensor configuration to a YAML file. You can specify either a directory (file will be named automatically) or a full file path. Default filename: {filename}", - "data": { - "directory": "File Path" - }, - "data_description": { - "directory": "Either a directory path (file will be named automatically) or a full file path ending in .yaml where the configuration will be saved." - } } } }, @@ -257,6 +218,9 @@ "pv_power": { "name": "PV Power" }, + "grid_power_flow": { + "name": "Grid Power" + }, "site_power": { "name": "Site Power" }, diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py index e993a1ce..9e675401 100644 --- a/custom_components/span_panel/switch.py +++ b/custom_components/span_panel/switch.py @@ -242,7 +242,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" client = self.coordinator.client if not hasattr(client, "set_circuit_relay"): - _LOGGER.warning("Circuit relay control not available in simulation mode") + _LOGGER.warning("Client does not support relay control") return await client.set_circuit_relay(self._circuit_id, "CLOSED") @@ -253,7 +253,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" client = self.coordinator.client if not hasattr(client, "set_circuit_relay"): - _LOGGER.warning("Circuit relay control not available in simulation mode") + _LOGGER.warning("Client does not support relay control") return await client.set_circuit_relay(self._circuit_id, "OPEN") diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json index f9715792..c950ae20 100644 --- a/custom_components/span_panel/translations/en.json +++ b/custom_components/span_panel/translations/en.json @@ -10,10 +10,14 @@ }, "error": { "cannot_connect": "Failed to connect to Span Panel", - "host_required": "Host is required for non-simulator mode", + "host_required": "Host is required", "invalid_auth": "Invalid authentication", "proximity_failed": "Proximity not proven. Please open and close the panel door 3 times and try again.", - "unknown": "Unexpected error" + "unknown": "Unexpected error", + "fqdn_registration_failed": "Could not register the domain name with the panel or the TLS certificate was not updated in time." + }, + "progress": { + "registering_fqdn": "Registering domain name with the panel and waiting for TLS certificate update. This may take up to 60 seconds..." }, "flow_title": "Span Panel ({host})", "step": { @@ -24,19 +28,19 @@ "user": { "data": { "host": "Host", - "simulator_mode": "Simulator Mode", + "http_port": "HTTP Port", "power_display_precision": "Power Display Precision", "energy_display_precision": "Energy Display Precision", "enable_energy_dip_compensation": "Auto-Compensate Energy Dips" }, "data_description": { - "host": "IP address or hostname of SPAN Panel (required for real hardware, optional for simulation mode)", - "simulator_mode": "Enable simulator mode for testing (no hardware required). Host field becomes optional when simulation mode is enabled.", + "host": "IP address or hostname of SPAN Panel", + "http_port": "HTTP port for the panel API (default: 80)", "power_display_precision": "Number of decimal places for power values (0-3)", "energy_display_precision": "Number of decimal places for energy values (0-3)", "enable_energy_dip_compensation": "Automatically compensate when the panel reports lower energy readings, preventing spikes in the energy dashboard." }, - "description": "Enter the connection details for your SPAN Panel. For simulation mode, check the simulator checkbox and the host field becomes optional.", + "description": "Enter the connection details for your SPAN Panel.", "title": "Connect to the Span Panel" }, "choose_auth_type": { @@ -84,40 +88,27 @@ "host": "IP address or hostname of the SPAN Panel" } }, - "simulator_config": { - "title": "Simulator Configuration", - "description": "Select a simulation configuration and optionally set a custom start time. {config_count} configurations available.", - "data": { - "simulation_config": "Simulation Configuration", - "host": "Host (Serial Number)", - "simulation_start_time": "Simulation Start Time (Optional)", - "entity_naming_pattern": "Entity Naming Pattern" - }, - "data_description": { - "simulation_config": "Choose a simulation configuration that defines circuit templates and energy profiles", - "host": "Custom serial number for the simulated panel (optional, will use config default if empty)", - "simulation_start_time": "Time format: HH:MM (24h), H:MM (12h), or full ISO datetime (YYYY-MM-DDTHH:MM:SS). Leave empty to use current time.", - "entity_naming_pattern": "Choose how circuit entities are named in the simulation" - } + "register_fqdn": { + "title": "Registering Domain Name" + }, + "fqdn_failed": { + "title": "Domain Registration", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name." + }, + "reconfigure_register_fqdn": { + "title": "Registering Domain Name" + }, + "reconfigure_fqdn_failed": { + "title": "Domain Registration", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name." + }, + "reconfigure_fqdn_done": { + "title": "Reconfiguration Complete" } } }, "options": { - "error": { - "Invalid date/time format. Use ISO format (YYYY-MM-DDTHH:MM:SS)": "Invalid time format. Use HH:MM (24h), H:MM (12h), or full ISO datetime (YYYY-MM-DDTHH:MM:SS)", - "directory": "Directory path is required", - "base": "Export failed: {error}" - }, "step": { - "init": { - "title": "Options Menu", - "menu_options": { - "general_options": "General Options", - "export_config": "Export Synthetic Sensor Config", - "simulation_start_time": "Simulation Start Time", - "simulation_offline_minutes": "Simulation Offline Minutes" - } - }, "general_options": { "title": "General Options", "description": "Configure SPAN Panel integration settings.", @@ -129,42 +120,12 @@ "enable_energy_dip_compensation": "Auto-Compensate Energy Dips" }, "data_description": { - "snapshot_update_interval": "How often to rebuild the panel snapshot from MQTT data. Lower values give faster updates but use more CPU. Increase on low-power hardware (e.g., Raspberry Pi). Set to 0 for no debounce. Range: 0\u201315 seconds.", + "snapshot_update_interval": "How often to rebuild the panel snapshot from MQTT data. Lower values give faster updates but use more CPU. Increase on low-power hardware (e.g., Raspberry Pi). Set to 0 for no debounce. Range: 0–15 seconds.", "enable_panel_net_energy_sensors": "Create net energy sensors for panel-level energy flows (main meter and feed-through). Shows true energy balance accounting for bidirectional flows.", "enable_circuit_net_energy_sensors": "Create net energy sensors for individual circuits. Shows true energy consumption accounting for reactive power and regenerative flows.", "energy_reporting_grace_period": "How long energy sensors maintain their last known value when the panel becomes unavailable (0-60 minutes). Helps preserve energy statistics integrity during brief outages. Default: 15 minutes.", "enable_energy_dip_compensation": "Automatically compensate when the panel reports lower energy readings. Disabling clears all accumulated offsets." } - }, - "simulation_start_time": { - "title": "Simulation Start Time", - "description": "Configure the simulation start time. Changes to simulation time will reload the integration to apply the new time.", - "data": { - "simulation_start_time": "Simulation Start Time" - }, - "data_description": { - "simulation_start_time": "Time format: HH:MM (24h), H:MM (12h), or full ISO datetime (YYYY-MM-DDTHH:MM:SS). Leave empty to use current time. Integration will reload to apply changes." - } - }, - "simulation_offline_minutes": { - "title": "Simulation Offline Minutes", - "description": "Configure how long the panel should appear offline during simulation.", - "data": { - "simulation_offline_minutes": "Offline Minutes" - }, - "data_description": { - "simulation_offline_minutes": "Number of minutes the panel should appear offline during simulation. Set to 0 to disable offline simulation." - } - }, - "export_config": { - "title": "Export Synthetic Sensor Config", - "description": "Export the current synthetic sensor configuration to a YAML file. You can specify either a directory (file will be named automatically) or a full file path. Default filename: {filename}", - "data": { - "directory": "File Path" - }, - "data_description": { - "directory": "Either a directory path (file will be named automatically) or a full file path ending in .yaml where the configuration will be saved." - } } } }, @@ -257,6 +218,9 @@ "pv_power": { "name": "PV Power" }, + "grid_power_flow": { + "name": "Grid Power" + }, "site_power": { "name": "Site Power" }, diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json index cb77edaf..c8692178 100644 --- a/custom_components/span_panel/translations/es.json +++ b/custom_components/span_panel/translations/es.json @@ -10,7 +10,11 @@ "cannot_connect": "No se logró establecer conexión con Span Panel", "invalid_auth": "Autenticación invalida", "unknown": "Error inesperado", - "host_required": "Se requiere host para conexiones de panel en vivo" + "host_required": "Se requiere host", + "fqdn_registration_failed": "No se pudo registrar el nombre de dominio en el panel o el certificado TLS no se actualizó a tiempo." + }, + "progress": { + "registering_fqdn": "Registrando el nombre de dominio en el panel y esperando la actualización del certificado TLS. Esto puede tardar hasta 60 segundos..." }, "flow_title": "Span Panel ({host})", "step": { @@ -21,19 +25,17 @@ "user": { "data": { "host": "Host", - "simulator_mode": "Modo Simulador", "power_display_precision": "Precisión de Visualización de Potencia", "energy_display_precision": "Precisión de Visualización de Energía", "enable_energy_dip_compensation": "Compensar Caídas de Energía Automáticamente" }, "data_description": { - "host": "Dirección IP o nombre de host del SPAN Panel (para simulación: use número de serie como 'sp3-simulation-001' o déjelo vacío para el predeterminado)", - "simulator_mode": "Habilitar modo simulador para pruebas (no se requiere hardware). Deje el host vacío para simulación predeterminada, o ingrese un número de serie personalizado.", + "host": "Dirección IP o nombre de host del SPAN Panel", "power_display_precision": "Número de lugares decimales para valores de potencia (0-3)", "energy_display_precision": "Número de lugares decimales para valores de energía (0-3)", "enable_energy_dip_compensation": "Compensar automáticamente cuando el panel reporta lecturas de energía más bajas, evitando picos en el panel de energía." }, - "description": "Ingrese los detalles de conexión para su SPAN Panel. Para modo simulador, marque la casilla del simulador y opcionalmente ingrese un número de serie personalizado.", + "description": "Ingrese los detalles de conexión para su SPAN Panel.", "title": "Establecer conexión al Span Panel" }, "choose_auth_type": { @@ -64,40 +66,27 @@ "hop_passphrase": "La contraseña utilizada para autenticar con la API v2 del SPAN Panel" } }, - "simulator_config": { - "title": "Configuración del Simulador", - "description": "Seleccione una configuración de simulación y opcionalmente establezca un tiempo de inicio personalizado. {config_count} configuraciones disponibles.", - "data": { - "simulation_config": "Configuración de Simulación", - "host": "Host (Número de Serie)", - "simulation_start_time": "Tiempo de Inicio de Simulación (Opcional)", - "entity_naming_pattern": "Patrón de Nomenclatura de Entidades" - }, - "data_description": { - "simulation_config": "Elija una configuración de simulación que defina plantillas de circuito y perfiles de energía", - "host": "Número de serie personalizado para el panel simulado (opcional, usará el predeterminado de la configuración si está vacío)", - "simulation_start_time": "Formato de tiempo: HH:MM (24h), H:MM (12h), o datetime ISO completo (YYYY-MM-DDTHH:MM:SS). Deje vacío para usar el tiempo actual.", - "entity_naming_pattern": "Elija cómo se nombran las entidades de circuito en la simulación" - } + "register_fqdn": { + "title": "Registering Domain Name" + }, + "fqdn_failed": { + "title": "Domain Registration", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name." + }, + "reconfigure_register_fqdn": { + "title": "Registering Domain Name" + }, + "reconfigure_fqdn_failed": { + "title": "Domain Registration", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name." + }, + "reconfigure_fqdn_done": { + "title": "Reconfiguration Complete" } } }, "options": { - "error": { - "Invalid date/time format. Use ISO format (YYYY-MM-DDTHH:MM:SS)": "Formato de fecha/hora inválido. Use HH:MM (24h), H:MM (12h), o datetime ISO completo (YYYY-MM-DDTHH:MM:SS)", - "directory": "Se requiere la ruta del archivo", - "base": "Falló la exportación: {error}" - }, "step": { - "init": { - "title": "Menú de Opciones", - "menu_options": { - "general_options": "Opciones Generales", - "export_config": "Exportar Configuración de Sensores Sintéticos", - "simulation_start_time": "Tiempo de Inicio de Simulación", - "simulation_offline_minutes": "Minutos Sin Conexión de Simulación" - } - }, "general_options": { "title": "Opciones Generales", "description": "Configurar los ajustes de la integración SPAN Panel.", @@ -109,42 +98,12 @@ "enable_energy_dip_compensation": "Compensar Caídas de Energía Automáticamente" }, "data_description": { - "snapshot_update_interval": "Con qué frecuencia reconstruir el snapshot del panel desde datos MQTT. Valores más bajos dan actualizaciones más rápidas pero usan más CPU. Aumente en hardware de baja potencia (ej., Raspberry Pi). Establezca en 0 para sin debounce. Rango: 0\u201315 segundos.", + "snapshot_update_interval": "Con qué frecuencia reconstruir el snapshot del panel desde datos MQTT. Valores más bajos dan actualizaciones más rápidas pero usan más CPU. Aumente en hardware de baja potencia (ej., Raspberry Pi). Establezca en 0 para sin debounce. Rango: 0–15 segundos.", "enable_panel_net_energy_sensors": "Crear sensores de energía neta para flujos de energía a nivel de panel (medidor principal y derivación). Muestra el verdadero balance de energía contabilizando flujos bidireccionales.", "enable_circuit_net_energy_sensors": "Crear sensores de energía neta para circuitos individuales. Muestra el verdadero consumo de energía contabilizando potencia reactiva y flujos regenerativos.", "energy_reporting_grace_period": "Cuánto tiempo los sensores de energía mantienen su último valor conocido cuando el panel no está disponible (0-60 minutos). Ayuda a preservar la integridad de las estadísticas de energía durante cortes breves. Predeterminado: 15 minutos.", "enable_energy_dip_compensation": "Compensar automáticamente cuando el panel reporta lecturas de energía más bajas. Al deshabilitar se borran todas las compensaciones acumuladas." } - }, - "export_config": { - "title": "Exportar Configuración de Sensores Sintéticos", - "description": "Exportar la configuración actual de sensores sintéticos a un archivo YAML. Puede especificar un directorio (el archivo se nombrará automáticamente) o una ruta de archivo completa. Nombre de archivo predeterminado: {filename}", - "data": { - "directory": "Ruta del Archivo" - }, - "data_description": { - "directory": "Una ruta de directorio (el archivo se nombrará automáticamente) o una ruta de archivo completa que termine en .yaml donde se guardará la configuración." - } - }, - "simulation_start_time": { - "title": "Tiempo de Inicio de Simulación", - "description": "Configurar el tiempo de inicio de simulación. Los cambios en el tiempo de simulación recargarán la integración para aplicar el nuevo tiempo.", - "data": { - "simulation_start_time": "Tiempo de Inicio de Simulación" - }, - "data_description": { - "simulation_start_time": "Formato de tiempo: HH:MM (24h), H:MM (12h), o datetime ISO completo (YYYY-MM-DDTHH:MM:SS). Deje vacío para usar el tiempo actual. La integración se recargará para aplicar los cambios." - } - }, - "simulation_offline_minutes": { - "title": "Minutos Sin Conexión de Simulación", - "description": "Configurar cuánto tiempo debe aparecer sin conexión el panel durante la simulación.", - "data": { - "simulation_offline_minutes": "Minutos Sin Conexión" - }, - "data_description": { - "simulation_offline_minutes": "Número de minutos que el panel debe aparecer sin conexión durante la simulación. Establezca en 0 para deshabilitar la simulación sin conexión." - } } } }, diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json index cd7f8cf6..0124e7c5 100644 --- a/custom_components/span_panel/translations/fr.json +++ b/custom_components/span_panel/translations/fr.json @@ -10,7 +10,11 @@ "cannot_connect": "Échec de la connexion au Span Panel", "invalid_auth": "Authentification invalide", "unknown": "Erreur inattendue", - "host_required": "L'hôte est requis pour les connexions de panneau en direct" + "host_required": "L'hôte est requis", + "fqdn_registration_failed": "Impossible d'enregistrer le nom de domaine auprès du panneau ou le certificat TLS n'a pas été mis à jour à temps." + }, + "progress": { + "registering_fqdn": "Enregistrement du nom de domaine auprès du panneau et attente de la mise à jour du certificat TLS. Cela peut prendre jusqu'à 60 secondes..." }, "flow_title": "Span Panel ({host})", "step": { @@ -21,19 +25,17 @@ "user": { "data": { "host": "Host", - "simulator_mode": "Mode Simulateur", "power_display_precision": "Précision d'Affichage de Puissance", "energy_display_precision": "Précision d'Affichage d'Énergie", "enable_energy_dip_compensation": "Compenser les Baisses d'Énergie Automatiquement" }, "data_description": { - "host": "Adresse IP ou nom d'hôte du SPAN Panel (pour la simulation : utilisez un numéro de série comme 'sp3-simulation-001' ou laissez vide pour la valeur par défaut)", - "simulator_mode": "Activer le mode simulateur pour les tests (aucun matériel requis). Laissez l'hôte vide pour la simulation par défaut, ou entrez un numéro de série personnalisé.", + "host": "Adresse IP ou nom d'hôte du SPAN Panel", "power_display_precision": "Nombre de décimales pour les valeurs de puissance (0-3)", "energy_display_precision": "Nombre de décimales pour les valeurs d'énergie (0-3)", "enable_energy_dip_compensation": "Compenser automatiquement lorsque le panneau signale des lectures d'énergie inférieures, évitant les pics dans le tableau de bord énergétique." }, - "description": "Entrez les détails de connexion pour votre SPAN Panel. Pour le mode simulateur, cochez la case simulateur et entrez optionnellement un numéro de série personnalisé.", + "description": "Entrez les détails de connexion pour votre SPAN Panel.", "title": "Connectez-vous au Span Panel" }, "choose_auth_type": { @@ -64,40 +66,27 @@ "hop_passphrase": "Le mot de passe utilisé pour s'authentifier avec l'API v2 du SPAN Panel" } }, - "simulator_config": { - "title": "Configuration du Simulateur", - "description": "Sélectionnez une configuration de simulation et définissez optionnellement un heure de début personnalisée. {config_count} configurations disponibles.", - "data": { - "simulation_config": "Configuration de Simulation", - "host": "Host (Numéro de Série)", - "simulation_start_time": "Heure de Début de Simulation (Optionnel)", - "entity_naming_pattern": "Modèle de Nommage des Entités" - }, - "data_description": { - "simulation_config": "Choisissez une configuration de simulation qui définit les modèles de circuit et les profils d'énergie", - "host": "Numéro de série personnalisé pour le panneau simulé (optionnel, utilisera la valeur par défaut de la configuration si vide)", - "simulation_start_time": "Format d'heure : HH:MM (24h), H:MM (12h), ou datetime ISO complet (YYYY-MM-DDTHH:MM:SS). Laissez vide pour utiliser l'heure actuelle.", - "entity_naming_pattern": "Choisissez comment les entités de circuit sont nommées dans la simulation" - } + "register_fqdn": { + "title": "Registering Domain Name" + }, + "fqdn_failed": { + "title": "Domain Registration", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name." + }, + "reconfigure_register_fqdn": { + "title": "Registering Domain Name" + }, + "reconfigure_fqdn_failed": { + "title": "Domain Registration", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name." + }, + "reconfigure_fqdn_done": { + "title": "Reconfiguration Complete" } } }, "options": { - "error": { - "Invalid date/time format. Use ISO format (YYYY-MM-DDTHH:MM:SS)": "Format de date/heure invalide. Utilisez HH:MM (24h), H:MM (12h), ou datetime ISO complet (YYYY-MM-DDTHH:MM:SS)", - "directory": "Le chemin du fichier est requis", - "base": "Échec de l'exportation : {error}" - }, "step": { - "init": { - "title": "Menu des Options", - "menu_options": { - "general_options": "Options Générales", - "export_config": "Exporter la Configuration des Capteurs Synthétiques", - "simulation_start_time": "Heure de Début de Simulation", - "simulation_offline_minutes": "Minutes Hors Ligne de Simulation" - } - }, "general_options": { "title": "Options Générales", "description": "Configurer les paramètres de l'intégration SPAN Panel.", @@ -109,42 +98,12 @@ "enable_energy_dip_compensation": "Compenser les Baisses d'Énergie Automatiquement" }, "data_description": { - "snapshot_update_interval": "Fréquence de reconstruction du snapshot du panneau à partir des données MQTT. Des valeurs plus basses donnent des mises à jour plus rapides mais utilisent plus de CPU. Augmentez sur du matériel peu puissant (ex., Raspberry Pi). Définissez sur 0 pour désactiver le debounce. Plage : 0\u201315 secondes.", + "snapshot_update_interval": "Fréquence de reconstruction du snapshot du panneau à partir des données MQTT. Des valeurs plus basses donnent des mises à jour plus rapides mais utilisent plus de CPU. Augmentez sur du matériel peu puissant (ex., Raspberry Pi). Définissez sur 0 pour désactiver le debounce. Plage : 0–15 secondes.", "enable_panel_net_energy_sensors": "Créer des capteurs d'énergie nette pour les flux d'énergie au niveau du panneau (compteur principal et dérivation). Affiche le vrai bilan énergétique en tenant compte des flux bidirectionnels.", "enable_circuit_net_energy_sensors": "Créer des capteurs d'énergie nette pour les circuits individuels. Affiche la vraie consommation d'énergie en tenant compte de la puissance réactive et des flux régénératifs.", "energy_reporting_grace_period": "Combien de temps les capteurs d'énergie maintiennent leur dernière valeur connue lorsque le panneau devient indisponible (0-60 minutes). Aide à préserver l'intégrité des statistiques d'énergie pendant les pannes brèves. Par défaut : 15 minutes.", "enable_energy_dip_compensation": "Compenser automatiquement lorsque le panneau signale des lectures d'énergie inférieures. La désactivation efface toutes les compensations accumulées." } - }, - "export_config": { - "title": "Exporter la Configuration des Capteurs Synthétiques", - "description": "Exporter la configuration actuelle des capteurs synthétiques vers un fichier YAML. Vous pouvez spécifier un répertoire (le fichier sera nommé automatiquement) ou un chemin de fichier complet. Nom de fichier par défaut : {filename}", - "data": { - "directory": "Chemin du Fichier" - }, - "data_description": { - "directory": "Un chemin de répertoire (le fichier sera nommé automatiquement) ou un chemin de fichier complet se terminant par .yaml où la configuration sera sauvegardée." - } - }, - "simulation_start_time": { - "title": "Heure de Début de Simulation", - "description": "Configurer l'heure de début de simulation. Les modifications de l'heure de simulation rechargeront l'intégration pour appliquer la nouvelle heure.", - "data": { - "simulation_start_time": "Heure de Début de Simulation" - }, - "data_description": { - "simulation_start_time": "Format d'heure : HH:MM (24h), H:MM (12h), ou datetime ISO complet (YYYY-MM-DDTHH:MM:SS). Laissez vide pour utiliser l'heure actuelle. L'intégration se rechargera pour appliquer les changements." - } - }, - "simulation_offline_minutes": { - "title": "Minutes Hors Ligne de Simulation", - "description": "Configurer combien de temps le panneau doit apparaître hors ligne pendant la simulation.", - "data": { - "simulation_offline_minutes": "Minutes Hors Ligne" - }, - "data_description": { - "simulation_offline_minutes": "Nombre de minutes pendant lesquelles le panneau doit apparaître hors ligne pendant la simulation. Définissez sur 0 pour désactiver la simulation hors ligne." - } } } }, diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json index db886c37..e56dc67c 100644 --- a/custom_components/span_panel/translations/ja.json +++ b/custom_components/span_panel/translations/ja.json @@ -10,7 +10,11 @@ "cannot_connect": "スパンパネルへの接続に失敗しました", "invalid_auth": "認証が無効です", "unknown": "予期しないエラー", - "host_required": "ライブパネル接続にはホストが必要です" + "host_required": "ホストが必要です", + "fqdn_registration_failed": "パネルにドメイン名を登録できなかったか、TLS証明書が時間内に更新されませんでした。" + }, + "progress": { + "registering_fqdn": "パネルにドメイン名を登録し、TLS証明書の更新を待っています。最大60秒かかる場合があります..." }, "flow_title": "スパンパネル ({host})", "step": { @@ -21,19 +25,17 @@ "user": { "data": { "host": "ホスト", - "simulator_mode": "シミュレーターモード", "power_display_precision": "電力表示精度", "energy_display_precision": "エネルギー表示精度", "enable_energy_dip_compensation": "エネルギー低下を自動補正" }, "data_description": { - "host": "SPAN PanelのIPアドレスまたはホスト名(シミュレーション用:'sp3-simulation-001'のようなシリアル番号を使用するか、デフォルトの場合は空にしてください)", - "simulator_mode": "テスト用のシミュレーターモードを有効にします(ハードウェア不要)。デフォルトシミュレーションの場合はホストを空にするか、カスタムシリアル番号を入力してください。", + "host": "SPAN PanelのIPアドレスまたはホスト名", "power_display_precision": "電力値の小数点以下の桁数(0-3)", "energy_display_precision": "エネルギー値の小数点以下の桁数(0-3)", "enable_energy_dip_compensation": "パネルがより低いエネルギー読み取り値を報告した場合に自動的に補正し、エネルギーダッシュボードのスパイクを防止します。" }, - "description": "SPAN Panelの接続詳細を入力してください。シミュレーターモードの場合は、シミュレーターチェックボックスをチェックし、必要に応じてカスタムシリアル番号を入力してください。", + "description": "SPAN Panelの接続詳細を入力してください。", "title": "スパンパネルに接続" }, "choose_auth_type": { @@ -64,40 +66,27 @@ "hop_passphrase": "SPAN Panel v2 APIでの認証に使用するパスフレーズ" } }, - "simulator_config": { - "title": "シミュレーター設定", - "description": "シミュレーション設定を選択し、必要に応じてカスタム開始時刻を設定します。{config_count}の設定が利用可能です。", - "data": { - "simulation_config": "シミュレーション設定", - "host": "ホスト(シリアル番号)", - "simulation_start_time": "シミュレーション開始時刻(オプション)", - "entity_naming_pattern": "エンティティ命名パターン" - }, - "data_description": { - "simulation_config": "回路テンプレートとエネルギー プロファイルを定義するシミュレーション設定を選択します", - "host": "シミュレートされたパネルのカスタムシリアル番号(オプション、空の場合は設定のデフォルトを使用)", - "simulation_start_time": "時刻形式:HH:MM(24時間)、H:MM(12時間)、または完全なISO日時(YYYY-MM-DDTHH:MM:SS)。空のままにすると現在時刻を使用します。", - "entity_naming_pattern": "シミュレーションで回路エンティティの命名方法を選択します" - } + "register_fqdn": { + "title": "Registering Domain Name" + }, + "fqdn_failed": { + "title": "Domain Registration", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name." + }, + "reconfigure_register_fqdn": { + "title": "Registering Domain Name" + }, + "reconfigure_fqdn_failed": { + "title": "Domain Registration", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name." + }, + "reconfigure_fqdn_done": { + "title": "Reconfiguration Complete" } } }, "options": { - "error": { - "Invalid date/time format. Use ISO format (YYYY-MM-DDTHH:MM:SS)": "日付/時刻の形式が無効です。HH:MM(24時間)、H:MM(12時間)、または完全なISO日時(YYYY-MM-DDTHH:MM:SS)を使用してください", - "directory": "ファイルパスが必要です", - "base": "エクスポートに失敗しました:{error}" - }, "step": { - "init": { - "title": "オプションメニュー", - "menu_options": { - "general_options": "一般オプション", - "export_config": "合成センサー設定をエクスポート", - "simulation_start_time": "シミュレーション開始時刻", - "simulation_offline_minutes": "シミュレーションオフラインミニッツ" - } - }, "general_options": { "title": "一般オプション", "description": "SPAN Panel統合の設定を構成します。", @@ -115,36 +104,6 @@ "energy_reporting_grace_period": "パネルが利用できなくなったときにエネルギーセンサーが最後の既知の値を維持する時間(0-60分)。短時間の停電中にエネルギー統計の整合性を維持するのに役立ちます。デフォルト:15分。", "enable_energy_dip_compensation": "パネルがより低いエネルギー読み取り値を報告した場合に自動的に補正します。無効にすると、蓄積されたすべてのオフセットがクリアされます。" } - }, - "export_config": { - "title": "合成センサー設定をエクスポート", - "description": "現在の合成センサー設定をYAMLファイルにエクスポートします。ディレクトリ(ファイルは自動的に名前が付けられます)または完全なファイルパスを指定できます。デフォルトファイル名:{filename}", - "data": { - "directory": "ファイルパス" - }, - "data_description": { - "directory": "ディレクトリパス(ファイルは自動的に名前が付けられます)または設定が保存される.yamlで終わる完全なファイルパス。" - } - }, - "simulation_start_time": { - "title": "シミュレーション開始時刻", - "description": "シミュレーション開始時刻を構成します。シミュレーション時刻の変更により、新しい時刻を適用するために統合が再読み込みされます。", - "data": { - "simulation_start_time": "シミュレーション開始時刻" - }, - "data_description": { - "simulation_start_time": "時刻形式:HH:MM(24時間)、H:MM(12時間)、または完全なISO日時(YYYY-MM-DDTHH:MM:SS)。空のままにすると現在時刻を使用します。統合は変更を適用するために再読み込みされます。" - } - }, - "simulation_offline_minutes": { - "title": "シミュレーションオフラインミニッツ", - "description": "シミュレーション中にパネルがオフラインに表示される時間を構成します。", - "data": { - "simulation_offline_minutes": "オフラインミニッツ" - }, - "data_description": { - "simulation_offline_minutes": "シミュレーション中にパネルがオフラインに表示される分数。オフラインシミュレーションを無効にするには0に設定します。" - } } } }, diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json index fa68694e..c243a3c5 100644 --- a/custom_components/span_panel/translations/pt.json +++ b/custom_components/span_panel/translations/pt.json @@ -10,7 +10,11 @@ "cannot_connect": "Falha ao ligar ao Painel Span", "invalid_auth": "Autenticação inválida", "unknown": "Erro inesperado", - "host_required": "O anfitrião é necessário para conexões de painel ao vivo" + "host_required": "O anfitrião é necessário", + "fqdn_registration_failed": "Não foi possível registar o nome de domínio no painel ou o certificado TLS não foi atualizado a tempo." + }, + "progress": { + "registering_fqdn": "A registar o nome de domínio no painel e a aguardar a atualização do certificado TLS. Isto pode demorar até 60 segundos..." }, "flow_title": "Painel Span ({host})", "step": { @@ -21,19 +25,17 @@ "user": { "data": { "host": "Anfitrião", - "simulator_mode": "Modo Simulador", "power_display_precision": "Precisão de Exibição de Potência", "energy_display_precision": "Precisão de Exibição de Energia", "enable_energy_dip_compensation": "Compensar Quedas de Energia Automaticamente" }, "data_description": { - "host": "Endereço IP ou nome do host do SPAN Panel (para simulação: use número de série como 'sp3-simulation-001' ou deixe vazio para padrão)", - "simulator_mode": "Ativar modo simulador para testes (não requer hardware). Deixe o host vazio para simulação padrão, ou insira um número de série personalizado.", + "host": "Endereço IP ou nome do host do SPAN Panel", "power_display_precision": "Número de casas decimais para valores de potência (0-3)", "energy_display_precision": "Número de casas decimais para valores de energia (0-3)", "enable_energy_dip_compensation": "Compensar automaticamente quando o painel reporta leituras de energia mais baixas, prevenindo picos no painel de energia." }, - "description": "Insira os detalhes de conexão para seu SPAN Panel. Para modo simulador, marque a caixa do simulador e opcionalmente insira um número de série personalizado.", + "description": "Insira os detalhes de conexão para seu SPAN Panel.", "title": "Ligar ao Painel Span" }, "choose_auth_type": { @@ -64,40 +66,27 @@ "hop_passphrase": "A frase-passe utilizada para autenticar com a API v2 do SPAN Panel" } }, - "simulator_config": { - "title": "Configuração do Simulador", - "description": "Selecione uma configuração de simulação e opcionalmente defina um horário de início personalizado. {config_count} configurações disponíveis.", - "data": { - "simulation_config": "Configuração de Simulação", - "host": "Anfitrião (Número de Série)", - "simulation_start_time": "Horário de Início da Simulação (Opcional)", - "entity_naming_pattern": "Padrão de Nomenclatura de Entidades" - }, - "data_description": { - "simulation_config": "Escolha uma configuração de simulação que define modelos de circuito e perfis de energia", - "host": "Número de série personalizado para o painel simulado (opcional, usará o padrão da configuração se vazio)", - "simulation_start_time": "Formato de horário: HH:MM (24h), H:MM (12h), ou datetime ISO completo (YYYY-MM-DDTHH:MM:SS). Deixe vazio para usar o horário atual.", - "entity_naming_pattern": "Escolha como as entidades de circuito são nomeadas na simulação" - } + "register_fqdn": { + "title": "Registering Domain Name" + }, + "fqdn_failed": { + "title": "Domain Registration", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name." + }, + "reconfigure_register_fqdn": { + "title": "Registering Domain Name" + }, + "reconfigure_fqdn_failed": { + "title": "Domain Registration", + "description": "The domain name could not be registered with the panel, or the TLS certificate was not updated in time. You may continue, but the MQTT connection may fail if the panel's certificate does not include your domain name." + }, + "reconfigure_fqdn_done": { + "title": "Reconfiguration Complete" } } }, "options": { - "error": { - "Invalid date/time format. Use ISO format (YYYY-MM-DDTHH:MM:SS)": "Formato de data/hora inválido. Use HH:MM (24h), H:MM (12h), ou datetime ISO completo (YYYY-MM-DDTHH:MM:SS)", - "directory": "O caminho do arquivo é necessário", - "base": "Falha na exportação: {error}" - }, "step": { - "init": { - "title": "Menu de Opções", - "menu_options": { - "general_options": "Opções Gerais", - "export_config": "Exportar Configuração de Sensores Sintéticos", - "simulation_start_time": "Horário de Início da Simulação", - "simulation_offline_minutes": "Minutos Offline da Simulação" - } - }, "general_options": { "title": "Opções Gerais", "description": "Configurar os ajustes da integração SPAN Panel.", @@ -109,42 +98,12 @@ "enable_energy_dip_compensation": "Compensar Quedas de Energia Automaticamente" }, "data_description": { - "snapshot_update_interval": "Com que frequência reconstruir o snapshot do painel a partir de dados MQTT. Valores mais baixos dão atualizações mais rápidas mas usam mais CPU. Aumente em hardware de baixa potência (ex., Raspberry Pi). Defina como 0 para sem debounce. Intervalo: 0\u201315 segundos.", + "snapshot_update_interval": "Com que frequência reconstruir o snapshot do painel a partir de dados MQTT. Valores mais baixos dão atualizações mais rápidas mas usam mais CPU. Aumente em hardware de baixa potência (ex., Raspberry Pi). Defina como 0 para sem debounce. Intervalo: 0–15 segundos.", "enable_panel_net_energy_sensors": "Criar sensores de energia líquida para fluxos de energia no nível do painel (medidor principal e derivação). Mostra o verdadeiro balanço energético contabilizando fluxos bidirecionais.", "enable_circuit_net_energy_sensors": "Criar sensores de energia líquida para circuitos individuais. Mostra o verdadeiro consumo de energia contabilizando potência reativa e fluxos regenerativos.", "energy_reporting_grace_period": "Quanto tempo os sensores de energia mantêm seu último valor conhecido quando o painel fica indisponível (0-60 minutos). Ajuda a preservar a integridade das estatísticas de energia durante interrupções breves. Padrão: 15 minutos.", "enable_energy_dip_compensation": "Compensar automaticamente quando o painel reporta leituras de energia mais baixas. Ao desativar, todas as compensações acumuladas são apagadas." } - }, - "export_config": { - "title": "Exportar Configuração de Sensores Sintéticos", - "description": "Exportar a configuração atual de sensores sintéticos para um arquivo YAML. Você pode especificar um diretório (o arquivo será nomeado automaticamente) ou um caminho de arquivo completo. Nome do arquivo padrão: {filename}", - "data": { - "directory": "Caminho do Arquivo" - }, - "data_description": { - "directory": "Um caminho de diretório (o arquivo será nomeado automaticamente) ou um caminho de arquivo completo terminando em .yaml onde a configuração será salva." - } - }, - "simulation_start_time": { - "title": "Horário de Início da Simulação", - "description": "Configurar o horário de início da simulação. Mudanças no horário de simulação recarregarão a integração para aplicar o novo horário.", - "data": { - "simulation_start_time": "Horário de Início da Simulação" - }, - "data_description": { - "simulation_start_time": "Formato de horário: HH:MM (24h), H:MM (12h), ou datetime ISO completo (YYYY-MM-DDTHH:MM:SS). Deixe vazio para usar o horário atual. A integração será recarregada para aplicar as mudanças." - } - }, - "simulation_offline_minutes": { - "title": "Minutos Offline da Simulação", - "description": "Configurar por quanto tempo o painel deve aparecer offline durante a simulação.", - "data": { - "simulation_offline_minutes": "Minutos Offline" - }, - "data_description": { - "simulation_offline_minutes": "Número de minutos que o painel deve aparecer offline durante a simulação. Defina como 0 para desabilitar a simulação offline." - } } } }, diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py index 8bab46ec..a42ce0de 100644 --- a/custom_components/span_panel/util.py +++ b/custom_components/span_panel/util.py @@ -3,7 +3,6 @@ import logging from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.util import slugify from span_panel_api import SpanBatterySnapshot, SpanEvseSnapshot, SpanPanelSnapshot from .const import DOMAIN @@ -14,24 +13,13 @@ def snapshot_to_device_info( snapshot: SpanPanelSnapshot, device_name: str | None = None, - is_simulator: bool = False, host: str | None = None, ) -> DeviceInfo: - """Convert a SpanPanelSnapshot to a Home Assistant device info object. - - For simulator entries, use a per-entry identifier derived from the device name - so multiple simulators don't collapse into a single device in the registry. - Live panels continue to use the true serial number identifier. - """ - if is_simulator and device_name: - device_identifier = slugify(device_name) - else: - device_identifier = snapshot.serial_number - + """Convert a SpanPanelSnapshot to a Home Assistant device info object.""" configuration_url = f"http://{host}" if host else None return DeviceInfo( - identifiers={(DOMAIN, device_identifier)}, + identifiers={(DOMAIN, snapshot.serial_number)}, manufacturer="Span", model="SPAN Panel", name=device_name or "Span Panel", diff --git a/docs/dev/mqtt-sensor-topic.md b/docs/dev/mqtt-sensor-topic.md index 8767628e..90eb2f3d 100644 --- a/docs/dev/mqtt-sensor-topic.md +++ b/docs/dev/mqtt-sensor-topic.md @@ -145,11 +145,11 @@ These are exposed as attributes on the corresponding power sensors: **PV Power sensor** (`pvPowerW`) attributes: -| Attribute | Value source | Notes | -| ----------------------- | ---------------------------- | --------------------------------------------- | -| `vendor_name` | `s.pv.vendor_name` | PV inverter vendor (e.g., "Enphase", "Other") | -| `product_name` | `s.pv.product_name` | PV inverter product (e.g., "IQ8+") | -| `nameplate_capacity_kw` | `s.pv.nameplate_capacity_kw` | Rated inverter capacity in kW | +| Attribute | Value source | Notes | +| ---------------------- | --------------------------- | --------------------------------------------- | +| `vendor_name` | `s.pv.vendor_name` | PV inverter vendor (e.g., "Enphase", "Other") | +| `product_name` | `s.pv.product_name` | PV inverter product (e.g., "IQ8+") | +| `nameplate_capacity_w` | `s.pv.nameplate_capacity_w` | Rated inverter capacity in W | **Battery Power sensor** (`batteryPowerW`) attributes: @@ -161,7 +161,7 @@ These are exposed as attributes on the corresponding power sensors: **Library models:** -- `SpanPVSnapshot` (new): `vendor_name`, `product_name`, `nameplate_capacity_kw` — populated from first PV metadata node +- `SpanPVSnapshot` (new): `vendor_name`, `product_name`, `nameplate_capacity_w` — populated from first PV metadata node - `SpanBatterySnapshot` (extended): `vendor_name`, `product_name`, `nameplate_capacity_kwh` — parsed from BESS metadata node ### 1E. Enriched circuit sensor attributes @@ -274,7 +274,7 @@ power_flow_pv = _parse_float(self._get_prop(pf_node, "pv")) if pf_node else None class SpanPVSnapshot: vendor_name: str | None = None product_name: str | None = None - nameplate_capacity_kw: float | None = None + nameplate_capacity_w: float | None = None ``` **`models.py` — extend `SpanBatterySnapshot`:** @@ -486,6 +486,6 @@ to work with the new sensors. 7. Circuit power sensor attributes include: breaker_rating, device_type, always_on, relay_state, shed_priority, is_sheddable 8. Panel power sensor (Current Power) attributes include: l1_voltage, l2_voltage, l1_amperage, l2_amperage, main_breaker_rating, grid_islandable 8b. Panel power sensor (Feed Through Power) attributes include: l1_amperage, l2_amperage -9. PV Power sensor attributes include: vendor_name, product_name, nameplate_capacity_kw +9. PV Power sensor attributes include: vendor_name, product_name, nameplate_capacity_w 10. Battery Power sensor attributes include: vendor_name, product_name, nameplate_capacity_kwh 11. Software version sensor attributes include: panel_size, wifi_ssid diff --git a/docs/dev/schema_driven_changes.md b/docs/dev/schema_driven_changes.md new file mode 100644 index 00000000..84956fab --- /dev/null +++ b/docs/dev/schema_driven_changes.md @@ -0,0 +1,228 @@ +# Schema-Driven Sensor Discovery + +This document describes a phased approach to reducing the manual coupling between the SPAN Homie MQTT schema, the `span-panel-api` library, and the HA +integration's sensor definitions. The goal is not full auto-generation but a practical reduction in the maintenance surface when SPAN firmware adds, corrects, +or extends properties. + +## Motivation + +Today the system has three layers of hardcoded knowledge about SPAN panel properties: + +1. **Homie schema** (`GET /api/v2/homie/schema`) -- declares every node type, property, datatype, unit, format, and settable flag. Self-describing and + firmware-versioned. +2. **span-panel-api** -- hand-coded `HomieDeviceConsumer._build_snapshot()` (653 lines) maps MQTT properties to frozen dataclass fields. Sign conventions, + cross-references, and derived state are embedded here. +3. **span integration** -- 47+ `SensorEntityDescription` instances in `sensor_definitions.py`, each with a `value_fn` lambda reaching into a specific snapshot + field plus HA metadata (`device_class`, `state_class`, `native_unit_of_measurement`). + +Adding a new sensor requires changes to all three layers. Correcting a unit requires changes to layers 2 and 3. The schema itself evolves with firmware +releases. + +### Why Not Go Fully Schema-Driven + +The Homie schema is self-describing but not self-correct. The 202609 changelog documented unit declaration errors (`kW` declared when values were actually `W`) +for `active-power` and PV `nameplate-capacity`. The integration's hardcoded knowledge of the correct units protected users from displaying values 1000x off. A +schema-driven integration would have propagated the error. + +Additional blockers: + +- **No schema versioning** -- the schema is tied to firmware releases (`rYYYYWW`), which conflates "the software running on the panel" with "the data contract + the panel exposes." A firmware update may change dozens of things without touching the schema, or alter one property's unit declaration without changing the + firmware version format. There is no independent schema version, no mechanism to request a specific version, and no backwards-compatibility guarantee. The + Homie API (`/api/v2/`) is currently in beta, which explains the in-place schema mutations; post-beta breaking changes would be expected under a new endpoint + (e.g. `/api/v3/`). The schema hash computed by Phase 1 drift detection is the best available proxy for a schema version -- a content-addressed identifier for + the exact set of node types, properties, units, and datatypes. +- **Irreducible semantic layer** -- sign conventions, derived state machines (`dsm_state`, `current_run_config`), cross-references (EVSE `feed` to circuit), + unmapped tab synthesis, and energy dip compensation are domain logic not representable in the Homie schema. +- **HA-specific metadata** -- `device_class`, `state_class`, `entity_category`, `suggested_display_precision` have no Homie equivalent. +- **User stability** -- HA users build automations and dashboards against stable entity IDs and sensor behaviors. Schema-driven changes that silently alter a + sensor's unit or meaning would break installations. + +The phased approach below progressively surfaces schema metadata for validation and diagnostic purposes first, then optionally for reducing entity definition +boilerplate on reviewed fields, without ever trusting the schema blindly for units or semantics and without ever exposing fields to users without human review. + +## Phase 1: Schema Metadata Exposure (Validation and Diagnostics) — COMPLETE + +**Status**: Implemented and tested across both repositories. + +### Architectural Boundary + +The integration knows nothing about Homie, MQTT, node types, or property IDs. All transport knowledge lives in `span-panel-api`. The integration sees only: + +- **Snapshot field paths** -- `"panel.instant_grid_power_w"`, `"circuit.current_a"`, etc. +- **Field metadata** -- unit and datatype per field, exposed by the library in transport-agnostic terms. +- **Sensor definitions** -- the integration's own HA metadata for each sensor. + +### span-panel-api Implementation + +| Module | Purpose | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `models.py` | `FieldMetadata(unit, datatype)` frozen dataclass | +| `mqtt/field_metadata.py` | `_PROPERTY_FIELD_MAP` (Homie property → field path), `build_field_metadata()`, `log_schema_drift()` | +| `mqtt/client.py` | Retains schema across connections, builds/caches field metadata during `connect()`, detects schema hash changes and diffs properties | +| `protocol.py` | `field_metadata` property added to `SpanPanelClientProtocol` | + +**Data flow at connect time:** + +1. `SpanMqttClient.connect()` fetches the Homie schema +2. If the schema hash changed since last connection, `log_schema_drift()` diffs the old and new schemas at the property level (new/removed node types, + new/removed properties, unit/datatype/format changes) -- all logged internally, never exposed to the integration +3. `build_field_metadata(schema.types)` iterates `_PROPERTY_FIELD_MAP`, looks up each Homie property in the live schema for its declared unit and datatype, and + produces `dict[str, FieldMetadata]` keyed by snapshot field path +4. The result is cached on the client as the `field_metadata` property + +**Field path convention:** `{snapshot_type}.{field_name}` where snapshot_type is one of `panel`, `circuit`, `battery`, `pv`, `evse`. The library defines this +convention in `_PROPERTY_FIELD_MAP` (~55 entries covering all properties that `_build_snapshot()` reads). + +### span Integration Implementation + +| Module | Purpose | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `schema_expectations.py` | `SENSOR_FIELD_MAP` -- sensor definition key → snapshot field path (the ONLY manually-maintained data) | +| `schema_validation.py` | `validate_field_metadata()` -- unit cross-check, unmapped field reporting. `collect_sensor_definitions()` -- builds the sensor defs dict | +| `coordinator.py` | `_run_schema_validation()` -- one-shot call after first successful refresh | + +**Data flow at first refresh:** + +1. `SpanPanelCoordinator._run_post_update_tasks()` fires `_run_schema_validation()` once +2. Reads `client.field_metadata` and converts `FieldMetadata` objects to plain dicts +3. `collect_sensor_definitions()` gathers all sensor descriptors into a dict keyed by sensor key +4. `validate_field_metadata()` walks `SENSOR_FIELD_MAP`, for each entry: + - Looks up the field path in the library's metadata to get the schema-declared unit + - Looks up the sensor key in the sensor definitions to get the HA `native_unit_of_measurement` + - Compares them and logs mismatches +5. Reports fields in the library's metadata that no sensor references + +**Validation checks:** + +| Check | Severity | Example | +| ---------------- | -------- | ------------------------------------------------------------ | +| Unit mismatch | DEBUG | Field metadata says `kW`, sensor definition has `W` | +| Missing metadata | DEBUG | Sensor reads a field the library has no metadata for | +| Missing unit | DEBUG | Field metadata has no unit but sensor definition expects `V` | +| Unmapped field | DEBUG | Library metadata contains a field no sensor references | + +All output is DEBUG-level only -- invisible to users at default HA log levels. A maintainer enables it by setting +`logger: custom_components.span_panel.schema_validation: debug` in their HA configuration. No entity creation or sensor behavior changes. + +### Tests + +**span-panel-api** (`tests/test_field_metadata.py`, 15 tests): field metadata building for all snapshot types, unit/datatype correctness, enum and boolean +handling, empty schema, field path convention, generic lugs fallback. + +**span integration** (`tests/test_schema_validation.py`, 13 tests): mapping structure validation, sensor keys match definitions, field paths match snapshot +attributes, unit cross-check (match, mismatch, missing), unmapped field detection, no-op when metadata unavailable. + +### What This Achieves + +- Zero risk to users -- no sensor behavior changes, log-only output. +- Clean architectural boundary -- integration never sees Homie/MQTT details. +- Early warning when schema-derived field metadata disagrees with sensor definitions (e.g. the kW/W error). +- Foundation for Phase 2 -- the field metadata and mapping are reusable. + +## Versioning Model + +The `span-panel-api` library is the gating factor for all schema changes reaching the integration. Even before SPAN corrects known unit declaration errors in +the Homie schema, the library applies the correct interpretation -- the snapshot contract defines the truth, not the schema. This isolation has two +consequences: + +1. **Schema corrections (declaration-only)** -- when SPAN fixes a unit declaration (e.g. `kW` → `W`) without changing actual values, neither repo needs code + changes. The library's `build_field_metadata()` automatically reflects the corrected declaration, and Phase 1 validation mismatches resolve themselves. + +2. **Value changes** -- if SPAN changes actual transmitted values (e.g. starts sending kW-scale values to match a `kW` declaration), the library must apply a + conversion in `_build_snapshot()` to maintain the snapshot contract. The integration bumps the library version; no other changes needed. + +In both cases, the library version pins a specific interpretation of firmware data. The version sequence for a breaking firmware change: + +1. SPAN releases firmware with changed property behavior +2. Library releases a new version with the conversion/adaptation +3. Integration bumps its library dependency +4. User updates the integration -- changelog explains what changed + +Each step is a human decision point. No change reaches users without explicit maintainer review. + +### Schema Version vs Firmware Version + +The correct thing to version against is the schema, not the firmware. The `rYYYYWW` firmware identifier conflates panel software with data contract. Ideally +SPAN would provide a declared `schema_version` field -- a monotonically increasing version or a semver -- so the library can say "I understand schema versions +up to X" rather than "I was built against firmware rXXXXYY." + +The current unit corrections and schema changes being made without a version bump are beta-phase behavior -- the Homie API is served at `/api/v2/` and is not +yet stable. Once the API exits beta, breaking changes to the schema would be expected to land under a new endpoint (e.g. `/api/v3/`), not as in-place mutations +to the v2 schema. This distinction matters: the current churn is not representative of the long-term maintenance burden, and the trigger criteria for later +phases should be evaluated against post-beta stability, not beta-phase corrections. + +Until SPAN provides a declared schema version, the library's schema hash (computed during Phase 1 drift detection) serves as the implicit schema version. The +library could maintain a known-schema-hashes table, mapping each validated hash to the set of corrections it applies. When encountering an unknown hash, it logs +a warning (drift detection already does this) and falls back to existing corrections -- safe-by-default behavior. + +### New Fields Require Human Review + +A new property appearing in the Homie schema must not be automatically exposed to users. The kW/W precedent proves that schema declarations cannot be trusted +for correctness on first appearance. If a field were surfaced automatically, users would build automations on it, and a subsequent correction to its unit or +sign convention would break those automations. + +The path for a new field: + +1. Phase 1 drift detection logs the new property +2. A maintainer reviews the property's actual values against its declared unit and datatype +3. The library adds the field to `_build_snapshot()` and `_PROPERTY_FIELD_MAP` +4. The integration adds a `SensorEntityDescription` with verified HA metadata +5. Both repos release new versions + +This is the same human-gated process used for existing fields. The library absorbs transport details; the integration adds HA semantics; nothing reaches users +without review. + +## Phase 2: Override-Table Entity Creation (Future) + +**Prerequisite**: Phase 1 complete. Schema metadata proven stable across multiple firmware releases. Schema unit corrections resolved (no outstanding known +errors). + +Replace the 47+ hardcoded `SensorEntityDescription` instances with: + +1. **A declarative override table** mapping snapshot field paths to HA metadata (`device_class`, `state_class`, sign convention, entity category). The library's + field metadata provides the base unit and datatype; the override table adds HA-specific semantics. +2. **A generic entity factory** that iterates the library's field metadata, applies overrides where present, and creates entities for fields that have an + override entry. Fields without an override entry are not exposed -- they remain invisible until a maintainer explicitly reviews them and adds an override. + +The override table reduces boilerplate for reviewed fields: the maintainer writes only the HA-specific semantics (device class, sign convention, etc.) and the +factory derives the rest from the library's field metadata. But no field is ever exposed without an explicit override entry. The integration never references +Homie node types or property IDs -- it operates entirely in terms of snapshot field paths and the library's field metadata. + +### Trigger Criteria for Phase 2 + +Do not proceed to Phase 2 until: + +- [ ] The Homie schema has had at least two firmware releases with no unit corrections +- [ ] Phase 1 validation logging has run in production and confirmed schema accuracy +- [ ] SPAN introduces schema versioning or a backwards-compatibility guarantee +- [ ] The rate of new properties is high enough that manual sensor additions are a meaningful maintenance burden + +## Phase 3: Build-Time Dataclass Generation (Future) + +**Prerequisite**: Phase 2 complete. Schema stable. Property additions are frequent. + +Auto-generate `span-panel-api` snapshot dataclasses from the schema at build time: + +1. A codegen script reads the schema from a reference panel (or saved fixture). +2. Outputs `models_generated.py` with typed frozen dataclasses matching the schema. +3. Manual `models.py` inherits from generated classes and adds derived fields (`dsm_state`, `current_run_config`, etc.). +4. `mypy` and IDE autocomplete continue working against concrete types. + +This reduces the library-side work for new fields -- the snapshot dataclass picks up new fields automatically from the schema. However, generated fields still +require human review before they are exposed to the integration. The maintainer must verify the field's actual values against its declared unit and datatype, +then add an override entry in the integration's Phase 2 override table. The codegen eliminates the library boilerplate but does not bypass the review gate. + +### Trigger Criteria for Phase 3 + +Do not proceed to Phase 3 until: + +- [ ] Phase 2 override-table model is proven and the generic entity factory is stable +- [ ] SPAN releases firmware updates with new properties frequently enough to justify the codegen infrastructure +- [ ] The manual snapshot dataclass maintenance cost exceeds the codegen maintenance cost + +## Cross-References + +- [Architecture](architecture.md) -- system overview and data flow +- [Dynamic Enum Options](dynamic_enum_options.md) -- runtime enum handling and schema trust limitations (directly relevant to Phase 1 validation) +- [SPAN API Client Docs](https://github.com/spanio/SPAN-API-Client-Docs) -- upstream Homie schema documentation and changelog diff --git a/poetry.lock b/poetry.lock index 38dbbfce..17fe08be 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4520,7 +4520,7 @@ test = ["covdefaults (==2.3.0)", "pytest (==8.4.1)", "pytest-aiohttp (==1.1.0)", [[package]] name = "span-panel-api" -version = "2.2.4" +version = "2.3.2" description = "A client library for SPAN Panel API" optional = false python-versions = ">=3.10,<4.0" diff --git a/scripts/sync-dependencies.py b/scripts/sync-dependencies.py index 40fda34f..e84424f1 100755 --- a/scripts/sync-dependencies.py +++ b/scripts/sync-dependencies.py @@ -29,13 +29,13 @@ def get_manifest_versions(): for req in requirements: if req.startswith("span-panel-api"): - # Extract version from span-panel-api>=2.0.0 or span-panel-api~=1.1.0 - match = re.search(r"span-panel-api[>~=]+([0-9.]+)", req) + # Extract full specifier (e.g. ==2.3.0, >=2.0.0, ~=1.1.0) + match = re.search(r"span-panel-api([>~=!]+[0-9.]+)", req) if match: versions["span-panel-api"] = match.group(1) elif req.startswith("ha-synthetic-sensors"): - # Extract version from ha-synthetic-sensors>=1.0.8 or ~=1.0.8 - match = re.search(r"ha-synthetic-sensors[>~=]+([0-9.]+)", req) + # Extract full specifier (e.g. >=1.0.8, ~=1.0.8) + match = re.search(r"ha-synthetic-sensors([>~=!]+[0-9.]+)", req) if match: versions["ha-synthetic-sensors"] = match.group(1) @@ -58,21 +58,21 @@ def update_ci_workflow(versions): original_content = content - # Update span-panel-api version (handles ^, >=, ~= specifiers) + # Update span-panel-api version — preserve exact specifier from manifest if "span-panel-api" in versions: - span_version = versions["span-panel-api"] + span_spec = versions["span-panel-api"] content = re.sub( - r'span-panel-api = "[>=~^]+[0-9.]+"', - f'span-panel-api = ">={span_version}"', + r'span-panel-api = "[>=~^!]+[0-9.]+"', + f'span-panel-api = "{span_spec}"', content, ) - # Update ha-synthetic-sensors version (handles ^, >=, ~= specifiers) + # Update ha-synthetic-sensors version — preserve exact specifier from manifest if "ha-synthetic-sensors" in versions: - ha_version = versions["ha-synthetic-sensors"] + ha_spec = versions["ha-synthetic-sensors"] content = re.sub( - r'ha-synthetic-sensors = "[>=~^]+[0-9.]+"', - f'ha-synthetic-sensors = "^{ha_version}"', + r'ha-synthetic-sensors = "[>=~^!]+[0-9.]+"', + f'ha-synthetic-sensors = "{ha_spec}"', content, ) diff --git a/tests/conftest.py b/tests/conftest.py index ac61f518..fb98d6f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,6 @@ """Configure test framework.""" import logging -import os from pathlib import Path import sys import types @@ -9,10 +8,8 @@ import pytest -from tests.test_factories.span_panel_simulation_factory import SpanPanelSimulationFactory - # The real span_panel_api library is used directly (no sys.modules mocking). -# Individual tests mock SpanMqttClient / DynamicSimulationEngine as needed. +# Individual tests mock SpanMqttClient as needed. @pytest.fixture(autouse=True) @@ -68,14 +65,6 @@ def patch_frontend_and_panel_custom(): yield -@pytest.fixture -async def baseline_serial_number(): - """Fixture to provide the serial number from the baseline YAML (friendly_names.yaml).""" - fixtures_dir = os.path.join(os.path.dirname(__file__), "fixtures") - baseline_path = os.path.join(fixtures_dir, "friendly_names.yaml") - return await SpanPanelSimulationFactory.extract_serial_number_from_yaml(baseline_path) - - @pytest.fixture def async_add_entities(): """Mock async_add_entities callback for testing.""" diff --git a/tests/docs/simulation_test_documentation.md b/tests/docs/simulation_test_documentation.md deleted file mode 100644 index c37b62cc..00000000 --- a/tests/docs/simulation_test_documentation.md +++ /dev/null @@ -1,188 +0,0 @@ -# SPAN Panel Simulation Testing Documentation - -## Overview - -The SPAN Panel integration includes a simulation testing mode that allows tests to run against real simulation data instead of mocked responses. This provides -more accurate testing by using the actual SpanPanelApi library with a built-in simulator. - -## Solution: CI-Friendly Simulation Tests - -### Problem Solved - -The main challenge was that simulation tests require `SPAN_USE_REAL_SIMULATION=1` to be set **before** the test module imports, but this made it difficult to -integrate into CI where you want regular `pytest` to work without environment variables. - -### Solution: Module-Level Skip - -Simulation tests now automatically skip when the environment variable isn't set: - -```python -import os -import pytest - -# Skip this test if SPAN_USE_REAL_SIMULATION is not set externally -if not os.environ.get('SPAN_USE_REAL_SIMULATION', '').lower() in ('1', 'true', 'yes'): - pytest.skip("Simulation tests require SPAN_USE_REAL_SIMULATION=1", allow_module_level=True) - -os.environ['SPAN_USE_REAL_SIMULATION'] = '1' -``` - -## How It Works - -### Regular Test Runs (Default) - -```bash -# Regular pytest - simulation tests are automatically skipped -pytest tests/ -pytest tests/test_solar_configuration_with_simulator.py # Shows "SKIPPED" -``` - -### Simulation Test Runs - -```bash -# Run simulation tests with environment variable -SPAN_USE_REAL_SIMULATION=1 pytest tests/test_solar_configuration_with_simulator.py -v - -# Clean output (reduced YAML noise) -SPAN_USE_REAL_SIMULATION=1 python -m pytest tests/test_solar_configuration_with_simulator.py::test_solar_configuration_with_simulator_friendly_names -v -``` - -### Mock vs Simulation Architecture - -#### Regular Tests (Default) - -- `tests/conftest.py` checks for `SPAN_USE_REAL_SIMULATION` environment variable -- If not set, installs mock modules for `span_panel_api` and `span_panel_api.exceptions` -- All API calls are mocked with predefined responses -- Fast execution but limited real-world accuracy - -#### Simulation Tests (With Environment Variable) - -- Environment variable set before module imports -- `tests/conftest.py` sees the variable and skips mock installation -- Real `span_panel_api` library is imported and used -- Integration connects to built-in simulator with realistic data - -## Reduced Logging Output - -The simulation test includes automatic logging configuration to reduce YAML and other verbose output: - -```python -# Configure logging to reduce noise BEFORE other imports -import logging -logging.getLogger("homeassistant.core").setLevel(logging.WARNING) -logging.getLogger("homeassistant.loader").setLevel(logging.WARNING) -logging.getLogger("homeassistant.setup").setLevel(logging.WARNING) -logging.getLogger("homeassistant.components").setLevel(logging.WARNING) -logging.getLogger("aiohttp").setLevel(logging.WARNING) -logging.getLogger("urllib3").setLevel(logging.WARNING) -logging.getLogger("yaml").setLevel(logging.WARNING) -logging.getLogger("homeassistant.helpers").setLevel(logging.WARNING) -logging.getLogger("homeassistant.config_entries").setLevel(logging.WARNING) - -# Keep our own logs visible for debugging -logging.getLogger("custom_components.span_panel").setLevel(logging.INFO) -logging.getLogger("ha_synthetic_sensors").setLevel(logging.INFO) -``` - -## CI Integration - -### GitHub Actions Example - -```yaml -# Regular tests - simulation tests are automatically skipped -- name: Run tests with coverage - run: poetry run pytest tests/ --cov=custom_components/span_panel --cov-report=xml - -# Optional: Run simulation tests separately -- name: Run simulation tests - env: - SPAN_USE_REAL_SIMULATION: 1 - run: poetry run pytest tests/test_solar_configuration_with_simulator.py -v -``` - -### Advantages for CI/CD - -1. **No Configuration Required**: Regular `pytest` just works -2. **Automatic Skipping**: Simulation tests skip gracefully without environment setup -3. **Optional Simulation**: Can run simulation tests in separate CI job if desired -4. **Clean Output**: Reduced logging noise for better CI readability -5. **Flexible**: Can run all tests together or separately - -## Solar Configuration Testing - -### Test Flow for Solar Sensors - -The solar configuration test follows this specific sequence: - -1. **Initial Setup**: Integration loads with native sensors only -2. **Options Change**: Trigger solar configuration to create solar sensors -3. **Reload**: Integration reloads to activate the new solar sensors -4. **Verification**: Solar sensors are verified in the entity registry - -### Why This Sequence is Necessary - -Solar sensors are created via the options flow (when users change settings in the UI), not during initial integration setup. The test simulates this by: - -1. Setting up the integration normally -2. Manually calling `handle_solar_options_change()` -3. Reloading the integration to activate the new sensors - -## Available Simulation Data - -The built-in simulator provides: - -- **Circuits**: Realistic circuit data including tabs 30 and 32 used for solar testing -- **Power Data**: Simulated power consumption and generation values -- **Device Info**: Realistic device metadata and status information -- **Unmapped Tabs**: Circuits not assigned to specific loads (essential for solar) - -## Running Simulation Tests - -### Local Development - -```bash -# Check test status without running -pytest tests/test_solar_configuration_with_simulator.py -v -# Output: SKIPPED [1] Simulation tests require SPAN_USE_REAL_SIMULATION=1 - -# Run simulation tests -SPAN_USE_REAL_SIMULATION=1 pytest tests/test_solar_configuration_with_simulator.py -v - -# Run specific test with clean output -SPAN_USE_REAL_SIMULATION=1 python -m pytest tests/test_solar_configuration_with_simulator.py::test_solar_configuration_with_simulator_friendly_names -v -``` - -### CI/CD Pipeline - -```bash -# Regular tests (simulation automatically skipped) -pytest tests/ --cov=custom_components/span_panel - -# Simulation tests (separate job) -SPAN_USE_REAL_SIMULATION=1 pytest tests/test_solar_configuration_with_simulator.py -v -``` - -## Alternative: Simulation Directory Approach - -We also created an alternative approach with a dedicated `tests/simulation/` directory that has its own `conftest.py`. This approach works but has some fixture -complexity. The module-level skip approach is simpler and more reliable. - -## Advantages of Current Solution - -1. **CI-Friendly**: Works without external environment variables -2. **Automatic**: Simulation tests skip gracefully when environment not set -3. **Clean Output**: Logging configuration reduces YAML noise -4. **Flexible**: Can run regular and simulation tests separately or together -5. **Simple**: Single test file approach is easier to maintain - -## Future Improvements - -The simulation testing mechanism provides a foundation for: - -- Testing edge cases with realistic data -- Validating complex solar configurations -- Testing error recovery scenarios -- Performance testing with large circuit counts - -This documentation should be updated as the simulation capabilities expand. diff --git a/tests/providers/integration_data_provider.py b/tests/providers/integration_data_provider.py deleted file mode 100644 index d1730426..00000000 --- a/tests/providers/integration_data_provider.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Integration Data Provider for bridging simulation data with SPAN Panel integration. - -This module provides data using the integration's actual processing logic, -ensuring all data flows through the same code paths as production. -""" - -from typing import Any -from unittest.mock import MagicMock - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from custom_components.span_panel.coordinator import SpanPanelCoordinator -from tests.factories.span_panel_simulation_factory import SpanPanelSimulationFactory - - -class IntegrationDataProvider: - """Provides data using integration's actual processing logic.""" - - def __init__(self): - """Initialize the data provider.""" - self._simulation_factory = SpanPanelSimulationFactory() - - async def create_coordinator_with_simulation_data( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - simulation_variations: dict | None = None, - scenario_name: str | None = None - ) -> SpanPanelCoordinator: - """Create coordinator with simulation data processed through integration. - - Args: - hass: Home Assistant instance - config_entry: Configuration entry with naming flags - simulation_variations: Direct simulation variations to apply - scenario_name: Named scenario from simulation factory - - Returns: - SpanPanelCoordinator with realistic simulation data - - """ - # Get simulation data - if scenario_name: - sim_data = await self._simulation_factory.get_panel_data_for_scenario(scenario_name) - else: - sim_data = await self._simulation_factory.get_realistic_panel_data( - variations=simulation_variations - ) - - # Create a mock span panel client that returns simulation data - mock_span_panel = self._create_mock_span_panel_from_sim_data(sim_data) - - # Create coordinator using integration's actual initialization - coordinator = SpanPanelCoordinator(hass, mock_span_panel, config_entry) - - # Set data as if it came from real API calls - coordinator.data = mock_span_panel - - return coordinator - - def _create_mock_span_panel_from_sim_data(self, sim_data: dict[str, Any]) -> MagicMock: - """Convert simulation data to coordinator's expected format. - - This ensures the data structure matches exactly what the coordinator expects - by using the same attribute names and structure as the real SpanPanel client. - - Args: - sim_data: Dictionary containing simulation data from span-panel-api - - Returns: - MagicMock configured to match SpanPanel client interface - - """ - mock_panel = MagicMock() - - # Extract data from simulation response - circuits_data = sim_data['circuits'] - panel_state = sim_data['panel_state'] - status_data = sim_data['status'] - storage_data = sim_data['storage'] - - # Set panel-level attributes from panel_state - mock_panel.id = getattr(panel_state, 'panel_id', 'test_panel_123') - mock_panel.name = "Simulation Panel" - mock_panel.model = getattr(panel_state, 'panel_model', '32A') - mock_panel.firmware_version = getattr(panel_state, 'firmware_version', '1.2.3') - mock_panel.main_breaker_size = 200 - - # Power and energy data from panel_state - mock_panel.instant_grid_power_w = getattr(panel_state, 'instant_grid_power_w', 0) - mock_panel.instant_load_power_w = getattr(panel_state, 'instant_load_power_w', 0) - mock_panel.instant_production_power_w = getattr(panel_state, 'instant_production_power_w', 0) - mock_panel.feedthrough_power = getattr(panel_state, 'feedthrough_power_w', 0) - - # Environmental data - mock_panel.env_temp_c = getattr(panel_state, 'env_temp_c', 25.0) - mock_panel.uptime_s = getattr(panel_state, 'uptime_s', 86400) - - # Status data - mock_panel.door_state = getattr(status_data, 'door_state', 'CLOSED') - mock_panel.main_relay_state = getattr(status_data, 'main_relay_state', 'CLOSED') - - # DSM data from panel_state - mock_panel.dsmCurrentRms = getattr(panel_state, 'dsm_current_rms', [120.5, 118.3]) - mock_panel.dsmVoltageRms = getattr(panel_state, 'dsm_voltage_rms', [245.6, 244.1]) - mock_panel.grid_sample_start_ms = getattr(panel_state, 'grid_sample_start_ms', 1234567890123) - - # Convert circuits data to the format the integration expects - mock_circuits = [] - for _circuit_id, circuit_data in circuits_data.circuits.additional_properties.items(): - circuit_mock = MagicMock() - circuit_mock.id = circuit_data.id - circuit_mock.name = circuit_data.name - circuit_mock.instant_power_w = circuit_data.instant_power_w - circuit_mock.produced_energy_wh = circuit_data.produced_energy_wh - circuit_mock.consumed_energy_wh = circuit_data.consumed_energy_wh - circuit_mock.relay_state = circuit_data.relay_state - circuit_mock.priority = circuit_data.priority - circuit_mock.tabs = circuit_data.tabs - circuit_mock.is_user_controllable = circuit_data.is_user_controllable - circuit_mock.is_sheddable = circuit_data.is_sheddable - circuit_mock.is_never_backup = circuit_data.is_never_backup - - # Add properties that integration might expect - circuit_mock.is_main = circuit_data.id == "main" or "main" in circuit_data.name.lower() - circuit_mock.breaker_size = 20 # Default breaker size - - mock_circuits.append(circuit_mock) - - mock_panel.circuits = mock_circuits - - # Storage/battery data if available - if storage_data: - mock_panel.battery_soe = getattr(storage_data, 'soe', 0.5) - mock_panel.max_energy_kwh = getattr(storage_data, 'max_energy_kwh', 10.0) - - return mock_panel - - async def create_config_entry_with_flags( - self, - naming_flags: dict[str, Any], - entry_id: str = "test_entry_id" - ) -> ConfigEntry: - """Create a config entry with specific naming flags. - - Args: - naming_flags: Dictionary of naming configuration flags - entry_id: Unique identifier for the config entry - - Returns: - ConfigEntry configured with the specified flags - - """ - # Import here to avoid circular imports - from tests.common import create_mock_config_entry - - config_entry = create_mock_config_entry() - config_entry.entry_id = entry_id - config_entry.options = { - **config_entry.options, - **naming_flags - } - - return config_entry - - async def create_full_integration_setup( - self, - hass: HomeAssistant, - naming_flags: dict[str, Any], - simulation_variations: dict | None = None, - scenario_name: str | None = None - ) -> tuple[SpanPanelCoordinator, ConfigEntry]: - """Create a complete integration setup with simulation data. - - Args: - hass: Home Assistant instance - naming_flags: Entity naming configuration flags - simulation_variations: Direct simulation variations - scenario_name: Named simulation scenario - - Returns: - Tuple of (coordinator, config_entry) ready for testing - - """ - # Create config entry with naming flags - config_entry = await self.create_config_entry_with_flags(naming_flags) - - # Create coordinator with simulation data - coordinator = await self.create_coordinator_with_simulation_data( - hass, - config_entry, - simulation_variations=simulation_variations, - scenario_name=scenario_name - ) - - return coordinator, config_entry - - async def get_circuit_data_for_naming_tests( - self, - circuit_types: list[str] | None = None - ) -> dict[str, dict]: - """Get circuit data specifically formatted for naming pattern tests. - - Args: - circuit_types: List of circuit types to include (lights, ev_chargers, etc.) - - Returns: - Dictionary with circuit data formatted for naming tests - - """ - # Get all circuit details - circuit_details = await self._simulation_factory.get_circuit_details() - - if circuit_types: - # Filter by circuit types - circuit_ids_by_type = await self._simulation_factory.get_circuit_ids_by_type() - included_ids = set() - for circuit_type in circuit_types: - if circuit_type in circuit_ids_by_type: - included_ids.update(circuit_ids_by_type[circuit_type]) - - circuit_details = { - circuit_id: details - for circuit_id, details in circuit_details.items() - if circuit_id in included_ids - } - - return circuit_details diff --git a/tests/test_circuit_manifest_service.py b/tests/test_circuit_manifest_service.py new file mode 100644 index 00000000..473295ac --- /dev/null +++ b/tests/test_circuit_manifest_service.py @@ -0,0 +1,431 @@ +"""Tests for the export_circuit_manifest service.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from custom_components.span_panel import ( + SpanPanelRuntimeData, + _async_register_services, +) +from custom_components.span_panel.const import DOMAIN + +from tests.factories import ( + SpanCircuitSnapshotFactory, + SpanPanelSnapshotFactory, +) + + +def _make_coordinator(snapshot): + """Create a mock coordinator wrapping a snapshot.""" + coordinator = MagicMock() + coordinator.data = snapshot + return coordinator + + +def _register_power_entity( + hass: HomeAssistant, + config_entry_id: str, + serial: str, + circuit_id: str, + suggested_entity_id: str, +) -> er.RegistryEntry: + """Register a circuit power sensor entity in the entity registry.""" + config_entry = hass.config_entries.async_get_entry(config_entry_id) + entity_registry = er.async_get(hass) + unique_id = f"span_{serial.lower()}_{circuit_id}_power" + return entity_registry.async_get_or_create( + "sensor", + DOMAIN, + unique_id, + config_entry=config_entry, + suggested_object_id=suggested_entity_id.split(".", 1)[1], + ) + + +async def _call_manifest_service(hass: HomeAssistant): + """Call the export_circuit_manifest service and return the response.""" + return await hass.services.async_call( + DOMAIN, + "export_circuit_manifest", + {}, + blocking=True, + return_response=True, + ) + + +class TestExportCircuitManifest: + """Tests for the export_circuit_manifest service.""" + + @pytest.mark.asyncio + async def test_basic_manifest(self, hass: HomeAssistant): + """Returns correct manifest for a panel with circuits.""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + kitchen = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_kitchen", name="Kitchen", tabs=[2, 3], + ) + bedroom = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_bedroom", name="Bedroom", tabs=[5], + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-test-001", + circuits={"uuid_kitchen": kitchen, "uuid_bedroom": bedroom}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + entry_id="span_entry", + unique_id="sp3-test-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData(coordinator=_make_coordinator(snapshot)) + + _register_power_entity( + hass, "span_entry", "sp3-test-001", "uuid_kitchen", + "sensor.span_panel_kitchen_power", + ) + _register_power_entity( + hass, "span_entry", "sp3-test-001", "uuid_bedroom", + "sensor.span_panel_bedroom_power", + ) + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + assert result is not None + assert len(result["panels"]) == 1 + + panel = result["panels"][0] + assert panel["serial"] == "sp3-test-001" + assert panel["host"] == "192.168.1.100" + assert len(panel["circuits"]) == 2 + + by_template = {c["template"]: c for c in panel["circuits"]} + + assert by_template["clone_2"]["device_type"] == "circuit" + assert by_template["clone_2"]["tabs"] == [2, 3] + assert by_template["clone_2"]["entity_id"].endswith("_kitchen_power") + + assert by_template["clone_5"]["device_type"] == "circuit" + assert by_template["clone_5"]["tabs"] == [5] + assert by_template["clone_5"]["entity_id"].endswith("_bedroom_power") + + @pytest.mark.asyncio + async def test_multiple_panels(self, hass: HomeAssistant): + """Returns manifests for all loaded panels.""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + circuit_a = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_a", tabs=[1], + ) + circuit_b = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_b", tabs=[3], + ) + snapshot_a = SpanPanelSnapshotFactory.create( + serial_number="serial-aaa", circuits={"uuid_a": circuit_a}, + ) + snapshot_b = SpanPanelSnapshotFactory.create( + serial_number="serial-bbb", circuits={"uuid_b": circuit_b}, + ) + + entry_a = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + entry_id="entry_a", + unique_id="serial-aaa", + ) + entry_b = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.101"}, + entry_id="entry_b", + unique_id="serial-bbb", + ) + entry_a.add_to_hass(hass) + entry_b.add_to_hass(hass) + entry_a.mock_state(hass, ConfigEntryState.LOADED) + entry_b.mock_state(hass, ConfigEntryState.LOADED) + entry_a.runtime_data = SpanPanelRuntimeData(coordinator=_make_coordinator(snapshot_a)) + entry_b.runtime_data = SpanPanelRuntimeData(coordinator=_make_coordinator(snapshot_b)) + + _register_power_entity(hass, "entry_a", "serial-aaa", "uuid_a", "sensor.panel_a_power") + _register_power_entity(hass, "entry_b", "serial-bbb", "uuid_b", "sensor.panel_b_power") + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + assert result is not None + serials = {p["serial"] for p in result["panels"]} + assert serials == {"serial-aaa", "serial-bbb"} + + by_serial = {p["serial"]: p for p in result["panels"]} + assert by_serial["serial-aaa"]["host"] == "192.168.1.100" + assert by_serial["serial-bbb"]["host"] == "192.168.1.101" + + @pytest.mark.asyncio + async def test_unmapped_tabs_excluded(self, hass: HomeAssistant): + """Unmapped tab circuits are excluded from the manifest.""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + real = SpanCircuitSnapshotFactory.create(circuit_id="uuid_real", tabs=[1]) + unmapped = SpanCircuitSnapshotFactory.create(circuit_id="unmapped_tab_5", tabs=[5]) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", + circuits={"uuid_real": real, "unmapped_tab_5": unmapped}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.1"}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData(coordinator=_make_coordinator(snapshot)) + + _register_power_entity(hass, "span_entry", "sp3-001", "uuid_real", "sensor.real_power") + _register_power_entity( + hass, "span_entry", "sp3-001", "unmapped_tab_5", "sensor.unmapped_power", + ) + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + panel = result["panels"][0] + templates = [c["template"] for c in panel["circuits"]] + assert "clone_1" in templates + assert "clone_5" not in templates + + @pytest.mark.asyncio + async def test_circuit_without_entity_excluded(self, hass: HomeAssistant): + """Circuits with no registered power entity are excluded.""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + registered = SpanCircuitSnapshotFactory.create(circuit_id="uuid_reg", tabs=[1]) + unregistered = SpanCircuitSnapshotFactory.create(circuit_id="uuid_unreg", tabs=[3]) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", + circuits={"uuid_reg": registered, "uuid_unreg": unregistered}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.1"}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData(coordinator=_make_coordinator(snapshot)) + + # Only register entity for one circuit + _register_power_entity(hass, "span_entry", "sp3-001", "uuid_reg", "sensor.reg_power") + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + panel = result["panels"][0] + assert len(panel["circuits"]) == 1 + assert panel["circuits"][0]["template"] == "clone_1" + + @pytest.mark.asyncio + async def test_no_loaded_entries_raises_validation_error(self, hass: HomeAssistant): + """Raises ServiceValidationError when no config entries are loaded.""" + from homeassistant.exceptions import ServiceValidationError + + _async_register_services(hass) + + with pytest.raises(ServiceValidationError): + await _call_manifest_service(hass) + + @pytest.mark.asyncio + async def test_all_device_types_included(self, hass: HomeAssistant): + """All device types are included — PV, BESS, EVSE, and regular circuits.""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + regular = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_reg", tabs=[1], device_type="circuit", + ) + pv = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_pv", tabs=[5], device_type="pv", + ) + evse = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_evse", tabs=[10, 12], device_type="evse", + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", + circuits={ + "uuid_reg": regular, + "uuid_pv": pv, + "uuid_evse": evse, + }, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.1"}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData(coordinator=_make_coordinator(snapshot)) + + _register_power_entity(hass, "span_entry", "sp3-001", "uuid_reg", "sensor.reg_power") + _register_power_entity(hass, "span_entry", "sp3-001", "uuid_pv", "sensor.pv_power") + _register_power_entity(hass, "span_entry", "sp3-001", "uuid_evse", "sensor.evse_power") + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + panel = result["panels"][0] + assert len(panel["circuits"]) == 3 + + by_template = {c["template"]: c for c in panel["circuits"]} + assert by_template["clone_1"]["device_type"] == "circuit" + assert by_template["clone_5"]["device_type"] == "pv" + assert by_template["clone_10"]["device_type"] == "evse" + + @pytest.mark.asyncio + async def test_bess_device_type_mapped_to_battery(self, hass: HomeAssistant): + """Internal 'bess' device type is mapped to 'battery' in the manifest.""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + bess = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_bess", tabs=[14, 16], device_type="bess", + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", circuits={"uuid_bess": bess}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.1"}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData(coordinator=_make_coordinator(snapshot)) + + _register_power_entity(hass, "span_entry", "sp3-001", "uuid_bess", "sensor.bess_power") + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + panel = result["panels"][0] + assert len(panel["circuits"]) == 1 + assert panel["circuits"][0]["device_type"] == "battery" + assert panel["circuits"][0]["template"] == "clone_14" + assert panel["circuits"][0]["tabs"] == [14, 16] + + @pytest.mark.asyncio + async def test_panel_with_no_resolvable_circuits_omitted(self, hass: HomeAssistant): + """Panel where no circuits have registered entities is omitted.""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + circuit = SpanCircuitSnapshotFactory.create(circuit_id="uuid_orphan", tabs=[1]) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", circuits={"uuid_orphan": circuit}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, data={}, entry_id="span_entry", unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData(coordinator=_make_coordinator(snapshot)) + # No entities registered + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + assert result["panels"] == [] + + @pytest.mark.asyncio + async def test_coordinator_with_no_snapshot_skipped(self, hass: HomeAssistant): + """Entry whose coordinator has no data (None snapshot) is skipped.""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + coordinator = MagicMock() + coordinator.data = None + + entry = MockConfigEntry( + domain=DOMAIN, data={}, entry_id="span_entry", unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator) + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + assert result["panels"] == [] + + @pytest.mark.asyncio + async def test_template_uses_min_tab(self, hass: HomeAssistant): + """Template name is clone_{min(tabs)}, not max or first.""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + circuit = SpanCircuitSnapshotFactory.create( + circuit_id="uuid_240v", tabs=[8, 6], + ) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", circuits={"uuid_240v": circuit}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.1"}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData(coordinator=_make_coordinator(snapshot)) + + _register_power_entity(hass, "span_entry", "sp3-001", "uuid_240v", "sensor.c_power") + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + circuit_data = result["panels"][0]["circuits"][0] + assert circuit_data["template"] == "clone_6" + assert circuit_data["tabs"] == [8, 6] + + @pytest.mark.asyncio + async def test_host_included_from_config_entry(self, hass: HomeAssistant): + """Host field is populated from config entry data.""" + from pytest_homeassistant_custom_component.common import MockConfigEntry + + circuit = SpanCircuitSnapshotFactory.create(circuit_id="uuid_a", tabs=[1]) + snapshot = SpanPanelSnapshotFactory.create( + serial_number="sp3-001", circuits={"uuid_a": circuit}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "10.0.0.50"}, + entry_id="span_entry", + unique_id="sp3-001", + ) + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + entry.runtime_data = SpanPanelRuntimeData(coordinator=_make_coordinator(snapshot)) + + _register_power_entity(hass, "span_entry", "sp3-001", "uuid_a", "sensor.a_power") + + _async_register_services(hass) + result = await _call_manifest_service(hass) + + assert result["panels"][0]["host"] == "10.0.0.50" diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py index 4ee90299..12d26770 100644 --- a/tests/test_dps_and_bess.py +++ b/tests/test_dps_and_bess.py @@ -128,8 +128,8 @@ async def test_grid_button_press_publishes_grid(self) -> None: coordinator.async_request_refresh.assert_called_once() @pytest.mark.asyncio - async def test_button_simulation_mode(self) -> None: - """Simulation mode (no set_dominant_power_source method) logs warning.""" + async def test_button_missing_control_method(self) -> None: + """Client without set_dominant_power_source method logs warning.""" from custom_components.span_panel.button import ( GFE_OVERRIDE_DESCRIPTION, SpanPanelGFEOverrideButton, @@ -141,7 +141,7 @@ async def test_button_simulation_mode(self) -> None: ) button.hass = MagicMock() - # Simulation client without set_dominant_power_source + # Client without set_dominant_power_source coordinator.client = MagicMock(spec=[]) await button.async_press() diff --git a/tests/test_factories/span_panel_simulation_factory.py b/tests/test_factories/span_panel_simulation_factory.py deleted file mode 100644 index ac3038f0..00000000 --- a/tests/test_factories/span_panel_simulation_factory.py +++ /dev/null @@ -1,426 +0,0 @@ -"""SPAN Panel Simulation Factory for realistic test data generation. - -This factory leverages the span-panel-api simulation mode with YAML configurations -to generate realistic SPAN panel data that exactly matches what the integration expects, -using actual SPAN panel response structures. -""" - -from __future__ import annotations - -import asyncio -import os -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import yaml - -if TYPE_CHECKING or os.environ.get("SPAN_USE_REAL_SIMULATION", "").lower() in ( - "1", - "true", - "yes", -): - from span_panel_api import SpanPanelClient - - -class SpanPanelSimulationFactory: - """Factory for creating simulation-based SPAN panel data using YAML configurations.""" - - @classmethod - async def _get_config_path(cls, config_name: str = "simulation_config_32_circuit") -> str: - """Get path to a simulation configuration file. - - Args: - config_name: Name of the config file (without .yaml extension) - - Returns: - Full path to the configuration file - - """ - # Look for config in the integration's simulation_configs directory - current_file = Path(__file__) - integration_root = current_file.parent.parent.parent / "custom_components" / "span_panel" - config_path = integration_root / "simulation_configs" / f"{config_name}.yaml" - - if await asyncio.to_thread(config_path.exists): - return str(config_path) - - # Fallback: look in span-panel-api examples - span_api_examples = current_file.parent.parent.parent.parent / "span-panel-api" / "examples" - fallback_path = span_api_examples / f"{config_name}.yaml" - - if await asyncio.to_thread(fallback_path.exists): - return str(fallback_path) - - raise FileNotFoundError(f"Could not find simulation config: {config_name}.yaml") - - @classmethod - async def create_simulation_client( - cls, - host: str = "test-panel-001", - config_name: str = "simulation_config_32_circuit", - **kwargs: Any - ) -> SpanPanelClient: - """Create a simulation client with YAML-based realistic data. - - Args: - host: Host identifier (becomes serial number in simulation mode) - config_name: Name of the YAML config file to use - **kwargs: Additional client configuration parameters - - Returns: - SpanPanelClient configured for simulation mode with YAML config - - """ - config_path = await cls._get_config_path(config_name) - - return SpanPanelClient( - host=host, - simulation_mode=True, - simulation_config_path=config_path, - **kwargs - ) - - @classmethod - async def get_realistic_panel_data( - cls, - host: str = "test-panel-001", - config_name: str = "simulation_config_32_circuit", - circuit_overrides: dict[str, dict] | None = None, - global_overrides: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Get panel data using YAML-based simulation mode. - - Args: - host: Host identifier for the simulated panel - config_name: Name of the YAML config file to use - circuit_overrides: Per-circuit overrides to apply dynamically - global_overrides: Global overrides (e.g., power_multiplier) - - Returns: - Dictionary containing all panel data types the integration needs - - """ - client = await cls.create_simulation_client(host=host, config_name=config_name) - async with client: - # Apply any dynamic overrides if specified - if circuit_overrides or global_overrides: - await client.set_circuit_overrides( - circuit_overrides=circuit_overrides or {}, - global_overrides=global_overrides or {} - ) - - # Get all data types the integration needs - circuits = await client.get_circuits() - panel_state = await client.get_panel_state() - status = await client.get_status() - storage = await client.get_storage_soe() - - return { - 'circuits': circuits, - 'panel_state': panel_state, - 'status': status, - 'storage': storage - } - - @classmethod - async def get_realistic_circuits_only( - cls, - host: str = "test-circuits-001", - config_name: str = "simulation_config_32_circuit", - circuit_overrides: dict[str, dict] | None = None, - global_overrides: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Get only circuits data for tests that don't need full panel state. - - Args: - host: Host identifier for the simulated panel - config_name: Name of the YAML config file to use - circuit_overrides: Per-circuit overrides to apply dynamically - global_overrides: Global overrides (e.g., power_multiplier) - - Returns: - CircuitsOut object from simulation - - """ - client = await cls.create_simulation_client(host=host, config_name=config_name) - async with client: - # Apply any dynamic overrides if specified - if circuit_overrides or global_overrides: - await client.set_circuit_overrides( - circuit_overrides=circuit_overrides or {}, - global_overrides=global_overrides or {} - ) - - circuits = await client.get_circuits() - return dict(circuits) if circuits else {} - - @classmethod - def get_preset_scenarios(cls) -> dict[str, dict[str, Any]]: - """Get predefined simulation scenarios for common test cases. - - Returns: - Dictionary of scenario names to simulation parameters - - """ - return { - "normal_operation": { - "config_name": "simulation_config_32_circuit", - "global_overrides": {} - }, - "high_load": { - "config_name": "simulation_config_32_circuit", - "global_overrides": {"power_multiplier": 1.5}, - "circuit_overrides": { - "ev_charger_garage": { - "power_override": 11000.0, # Max EV charging - "relay_state": "CLOSED" - } - } - }, - "circuit_failures": { - "config_name": "simulation_config_32_circuit", - "circuit_overrides": { - "living_room_outlets": {"relay_state": "OPEN"}, - "office_outlets": {"relay_state": "OPEN"} - } - }, - "low_power_stable": { - "config_name": "simple_test_config", # Use simpler config - "global_overrides": {"power_multiplier": 0.3} - }, - "solar_peak": { - "config_name": "simulation_config_32_circuit", - "circuit_overrides": { - "solar_inverter_main": { - "power_override": -8000.0, # Peak solar production - "relay_state": "CLOSED" - } - } - }, - "grid_stress": { - "config_name": "simulation_config_32_circuit", - "global_overrides": {"power_multiplier": 2.0}, - "circuit_overrides": { - "main_hvac": {"relay_state": "OPEN"}, # Load shedding - "heat_pump_backup": {"relay_state": "OPEN"} - } - } - } - - @classmethod - async def get_panel_data_for_scenario(cls, scenario_name: str) -> dict[str, Any]: - """Get panel data for a predefined scenario. - - Args: - scenario_name: Name of the scenario from get_preset_scenarios() - - Returns: - Panel data configured for the specified scenario - - Raises: - ValueError: If scenario_name is not found - - """ - scenarios = cls.get_preset_scenarios() - if scenario_name not in scenarios: - available = ", ".join(scenarios.keys()) - raise ValueError(f"Unknown scenario '{scenario_name}'. Available: {available}") - - scenario_config = scenarios[scenario_name] - return await cls.get_realistic_panel_data(**scenario_config) - - @classmethod - async def get_real_circuit_ids(cls, config_name: str = "simulation_config_32_circuit") -> dict[str, str]: - """Get the actual circuit IDs from YAML simulation config with their names. - - Args: - config_name: Name of the YAML config file to use - - Returns: - Dictionary mapping circuit IDs to their friendly names - - """ - client = await cls.create_simulation_client(config_name=config_name) - async with client: - circuits = await client.get_circuits() - return { - circuit_id: circuit.name - for circuit_id, circuit in circuits.circuits.additional_properties.items() - } - - @classmethod - async def get_circuit_ids_by_type(cls, config_name: str = "simulation_config_32_circuit") -> dict[str, list[str]]: - """Get circuit IDs grouped by appliance type for targeted testing. - - Args: - config_name: Name of the YAML config file to use - - Returns: - Dictionary mapping appliance types to lists of circuit IDs - - """ - # Get all circuits dynamically from the YAML config - circuit_ids = await cls.get_real_circuit_ids(config_name) - - # Categorize circuits based on their names - categorized: dict[str, list[str]] = { - "lights": [], - "ev_chargers": [], - "hvac": [], - "appliances": [], - "outlets": [], - "solar": [], - "pool": [], - "essential": [] - } - - for circuit_id, name in circuit_ids.items(): - name_lower = name.lower() - - # Categorize based on circuit names from YAML config - if "light" in name_lower: - categorized["lights"].append(circuit_id) - elif "ev" in name_lower or "charger" in name_lower: - categorized["ev_chargers"].append(circuit_id) - elif any(term in name_lower for term in ["hvac", "heat pump"]): - categorized["hvac"].append(circuit_id) - elif any(term in name_lower for term in ["dishwasher", "dryer", "microwave", "oven", "refrigerator", "washing"]): - categorized["appliances"].append(circuit_id) - elif "outlet" in name_lower: - categorized["outlets"].append(circuit_id) - elif "solar" in name_lower or "inverter" in name_lower: - categorized["solar"].append(circuit_id) - elif "pool" in name_lower: - categorized["pool"].append(circuit_id) - elif any(term in name_lower for term in ["master", "bedroom", "kitchen", "bathroom"]): - categorized["essential"].append(circuit_id) - else: - # Default to essential for unrecognized circuits - categorized["essential"].append(circuit_id) - - return categorized - - @classmethod - async def find_circuit_ids_by_name( - cls, - name_patterns: str | list[str], - config_name: str = "simulation_config_32_circuit" - ) -> list[str]: - """Find circuit IDs by name patterns. - - Args: - name_patterns: String or list of strings to search for in circuit names (case-insensitive) - config_name: Name of the YAML config file to use - - Returns: - List of circuit IDs matching the patterns - - """ - if isinstance(name_patterns, str): - name_patterns = [name_patterns] - - circuit_ids = await cls.get_real_circuit_ids(config_name) - matching_ids = [] - - for circuit_id, name in circuit_ids.items(): - name_lower = name.lower() - if any(pattern.lower() in name_lower for pattern in name_patterns): - matching_ids.append(circuit_id) - - return matching_ids - - @classmethod - async def get_circuit_details(cls, config_name: str = "simulation_config_32_circuit") -> dict[str, dict[str, Any]]: - """Get detailed information about all circuits from YAML simulation. - - Args: - config_name: Name of the YAML config file to use - - Returns: - Dictionary mapping circuit IDs to their full circuit data - - """ - client = await cls.create_simulation_client(config_name=config_name) - async with client: - circuits = await client.get_circuits() - return { - circuit_id: { - "id": circuit.id, - "name": circuit.name, - "relay_state": circuit.relay_state, - "instant_power_w": circuit.instant_power_w, - "produced_energy_wh": circuit.produced_energy_wh, - "consumed_energy_wh": circuit.consumed_energy_wh, - "tabs": circuit.tabs, - "priority": circuit.priority, - "is_user_controllable": circuit.is_user_controllable, - "is_sheddable": circuit.is_sheddable, - "is_never_backup": circuit.is_never_backup, - } - for circuit_id, circuit in circuits.circuits.additional_properties.items() - } - - @classmethod - async def get_available_configs(cls) -> list[str]: - """Get list of available YAML configuration files. - - Returns: - List of config names (without .yaml extension) - - """ - configs = [] - - # Check integration configs first (this is the primary location) - try: - current_file = Path(__file__) - integration_root = current_file.parent.parent.parent / "custom_components" / "span_panel" - config_dir = integration_root / "simulation_configs" - - if await asyncio.to_thread(config_dir.exists): - files = await asyncio.to_thread(lambda: list(config_dir.glob("*.yaml"))) - for file in files: - configs.append(file.stem) - except Exception: - pass - - # Check span-panel-api examples as fallback - try: - current_file = Path(__file__) - span_api_examples = current_file.parent.parent.parent.parent / "span-panel-api" / "examples" - - if await asyncio.to_thread(span_api_examples.exists): - files = await asyncio.to_thread(lambda: list(span_api_examples.glob("*.yaml"))) - for file in files: - if file.stem not in configs: # Avoid duplicates - configs.append(file.stem) - except Exception: - pass - - return sorted(configs) - - @classmethod - def get_available_configs_with_names(cls) -> dict[str, str]: - """Get available configs with user-friendly display names. - - Returns: - Dictionary mapping config keys to display names - - """ - # Use the same logic as the config flow - from custom_components.span_panel.config_flow import get_available_simulation_configs - return get_available_simulation_configs() - - @staticmethod - def extract_serial_number_from_yaml(yaml_path: str) -> str: - """Extract the serial number from a YAML simulation config file. - - Args: - yaml_path: Path to the YAML configuration file - - Returns: - Serial number from the config file - - """ - content = Path(yaml_path).read_text(encoding="utf-8") - data = yaml.safe_load(content) - return str(data["global_settings"]["device_identifier"]) diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py index fe2d6c7d..92cd9c1d 100644 --- a/tests/test_promoted_sensors.py +++ b/tests/test_promoted_sensors.py @@ -311,10 +311,10 @@ def test_pv_product_value_function(self): def test_pv_nameplate_capacity_value_function(self): snapshot = SpanPanelSnapshotFactory.create( - pv=SpanPVSnapshot(nameplate_capacity_kw=7.6) + pv=SpanPVSnapshot(nameplate_capacity_w=7600.0) ) desc = next(d for d in PV_METADATA_SENSORS if d.key == "pv_nameplate_capacity") - assert desc.value_fn(snapshot) == 7.6 + assert desc.value_fn(snapshot) == 7600.0 assert desc.device_class == SensorDeviceClass.POWER def test_pv_none_metadata(self): diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py new file mode 100644 index 00000000..ee45f05c --- /dev/null +++ b/tests/test_schema_validation.py @@ -0,0 +1,312 @@ +"""Tests for schema validation and sensor-to-field mapping.""" + +from __future__ import annotations + +import logging +from types import TracebackType +from unittest.mock import MagicMock + +from custom_components.span_panel.schema_expectations import ( + SENSOR_FIELD_MAP, + all_referenced_field_paths, +) +from custom_components.span_panel.schema_validation import validate_field_metadata + +# --------------------------------------------------------------------------- +# Test utility — capture log records at DEBUG level +# --------------------------------------------------------------------------- + + +class CaptureHandler(logging.Handler): + """Minimal log capture handler for test assertions.""" + + def __init__(self, logger: logging.Logger, level: int = logging.DEBUG) -> None: + """Attach to a logger at the given level and collect records.""" + super().__init__(level) + self._logger = logger + self._prev_level = logger.level + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + """Store the log record for later assertion.""" + self.records.append(record) + + def __enter__(self) -> CaptureHandler: + """Attach handler and lower logger level for capture.""" + self._prev_level = self._logger.level + self._logger.setLevel(self.level) + self._logger.addHandler(self) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Detach handler and restore logger level.""" + self._logger.removeHandler(self) + self._logger.setLevel(self._prev_level) + + +# --------------------------------------------------------------------------- +# Sensor field mapping tests +# --------------------------------------------------------------------------- + + +class TestSensorFieldMap: + """Tests for the sensor-to-snapshot-field mapping.""" + + def test_no_empty_keys_or_paths(self) -> None: + """Every entry must have non-empty sensor key and field path.""" + for sensor_key, field_path in SENSOR_FIELD_MAP.items(): + assert sensor_key, "Empty sensor key in SENSOR_FIELD_MAP" + assert field_path, f"Empty field path for sensor key '{sensor_key}'" + + def test_field_paths_follow_convention(self) -> None: + """All field paths must be {snapshot_type}.{field_name}.""" + valid_prefixes = {"panel", "circuit", "battery", "pv", "evse"} + for sensor_key, field_path in SENSOR_FIELD_MAP.items(): + parts = field_path.split(".", 1) + assert len(parts) == 2, ( + f"Field path '{field_path}' for sensor '{sensor_key}' " + f"does not follow 'type.field' convention" + ) + assert parts[0] in valid_prefixes, ( + f"Field path '{field_path}' for sensor '{sensor_key}' " + f"has unknown prefix '{parts[0]}'" + ) + + def test_sensor_keys_exist_in_definitions(self) -> None: + """Every sensor key should match a real sensor definition.""" + from custom_components.span_panel.sensor_definitions import ( + BATTERY_POWER_SENSOR, + BATTERY_SENSOR, + BESS_METADATA_SENSORS, + CIRCUIT_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_SENSORS, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + EVSE_SENSORS, + GRID_POWER_FLOW_SENSOR, + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + MAIN_BREAKER_RATING_SENSOR, + PANEL_DATA_STATUS_SENSORS, + PANEL_ENERGY_SENSORS, + PANEL_POWER_SENSORS, + PV_METADATA_SENSORS, + PV_POWER_SENSOR, + SITE_POWER_SENSOR, + STATUS_SENSORS, + UNMAPPED_SENSORS, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, + ) + + all_defs = [ + *PANEL_DATA_STATUS_SENSORS, + *STATUS_SENSORS, + *UNMAPPED_SENSORS, + BATTERY_SENSOR, + L1_VOLTAGE_SENSOR, + L2_VOLTAGE_SENSOR, + UPSTREAM_L1_CURRENT_SENSOR, + UPSTREAM_L2_CURRENT_SENSOR, + DOWNSTREAM_L1_CURRENT_SENSOR, + DOWNSTREAM_L2_CURRENT_SENSOR, + MAIN_BREAKER_RATING_SENSOR, + CIRCUIT_CURRENT_SENSOR, + CIRCUIT_BREAKER_RATING_SENSOR, + *BESS_METADATA_SENSORS, + *PV_METADATA_SENSORS, + *PANEL_POWER_SENSORS, + BATTERY_POWER_SENSOR, + PV_POWER_SENSOR, + GRID_POWER_FLOW_SENSOR, + SITE_POWER_SENSOR, + *PANEL_ENERGY_SENSORS, + *CIRCUIT_SENSORS, + *EVSE_SENSORS, + ] + known_keys = {d.key for d in all_defs} + + for sensor_key in SENSOR_FIELD_MAP: + assert sensor_key in known_keys, ( + f"Sensor key '{sensor_key}' in SENSOR_FIELD_MAP not found in sensor definitions" + ) + + def test_field_paths_match_snapshot_attrs(self) -> None: + """Field names should match actual snapshot dataclass attributes.""" + from span_panel_api import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanEvseSnapshot, + SpanPanelSnapshot, + SpanPVSnapshot, + ) + + snapshot_classes = { + "panel": SpanPanelSnapshot, + "circuit": SpanCircuitSnapshot, + "battery": SpanBatterySnapshot, + "pv": SpanPVSnapshot, + "evse": SpanEvseSnapshot, + } + + for sensor_key, field_path in SENSOR_FIELD_MAP.items(): + prefix, field_name = field_path.split(".", 1) + cls = snapshot_classes[prefix] + assert hasattr(cls, field_name) or field_name in { + f.name for f in cls.__dataclass_fields__.values() + }, ( + f"Field '{field_name}' from path '{field_path}' " + f"(sensor '{sensor_key}') not found on {cls.__name__}" + ) + + def test_all_referenced_field_paths(self) -> None: + """all_referenced_field_paths should return all unique values.""" + paths = all_referenced_field_paths() + assert paths == frozenset(SENSOR_FIELD_MAP.values()) + + +# --------------------------------------------------------------------------- +# Unit cross-check tests +# --------------------------------------------------------------------------- + + +def _make_sensor_def(key: str, unit: str | None) -> MagicMock: + """Create a minimal mock SensorEntityDescription with key and unit.""" + mock = MagicMock(spec=["key", "native_unit_of_measurement"]) + mock.key = key + mock.native_unit_of_measurement = unit + return mock + + +class TestUnitCrossCheck: + """Tests for field metadata unit vs sensor definition unit cross-checking.""" + + def test_matching_units_no_cross_check_message(self) -> None: + """Matching units should produce no cross-check log messages.""" + metadata = {"panel.instant_grid_power_w": {"unit": "W", "datatype": "float"}} + sensor_defs = {"instantGridPowerW": _make_sensor_def("instantGridPowerW", "W")} + logger = logging.getLogger("custom_components.span_panel.schema_validation") + with CaptureHandler(logger) as handler: + validate_field_metadata(metadata, sensor_defs=sensor_defs) + cross_check_msgs = [ + r for r in handler.records if "cross-check" in r.getMessage().lower() + ] + assert len(cross_check_msgs) == 0 + + def test_mismatched_units_logs_debug(self) -> None: + """Unit mismatch should produce a debug message naming both units.""" + metadata = {"panel.instant_grid_power_w": {"unit": "kW", "datatype": "float"}} + sensor_defs = {"instantGridPowerW": _make_sensor_def("instantGridPowerW", "W")} + logger = logging.getLogger("custom_components.span_panel.schema_validation") + with CaptureHandler(logger) as handler: + validate_field_metadata(metadata, sensor_defs=sensor_defs) + msgs = [r for r in handler.records if "cross-check" in r.getMessage().lower()] + assert len(msgs) == 1 + assert "'kW'" in msgs[0].getMessage() + assert "'W'" in msgs[0].getMessage() + + def test_missing_metadata_logs_debug(self) -> None: + """Sensor reading a field with no metadata should log debug.""" + metadata: dict[str, dict[str, object]] = {} + sensor_defs = {"l1_voltage": _make_sensor_def("l1_voltage", "V")} + logger = logging.getLogger("custom_components.span_panel.schema_validation") + with CaptureHandler(logger) as handler: + validate_field_metadata(metadata, sensor_defs=sensor_defs) + msgs = [r for r in handler.records if "no metadata" in r.getMessage()] + assert len(msgs) == 1 + + def test_missing_schema_unit_logs_debug(self) -> None: + """Field with no unit in metadata but unit in sensor def should log debug.""" + metadata = {"panel.l1_voltage": {"datatype": "float"}} + sensor_defs = {"l1_voltage": _make_sensor_def("l1_voltage", "V")} + logger = logging.getLogger("custom_components.span_panel.schema_validation") + with CaptureHandler(logger) as handler: + validate_field_metadata(metadata, sensor_defs=sensor_defs) + msgs = [r for r in handler.records if "no unit" in r.getMessage()] + assert len(msgs) == 1 + + def test_sensor_without_unit_skipped(self) -> None: + """Sensor with no native_unit_of_measurement should be skipped.""" + metadata = {"panel.main_relay_state": {"datatype": "enum"}} + sensor_defs = {"main_relay_state": _make_sensor_def("main_relay_state", None)} + logger = logging.getLogger("custom_components.span_panel.schema_validation") + with CaptureHandler(logger) as handler: + validate_field_metadata(metadata, sensor_defs=sensor_defs) + cross_check_msgs = [ + r for r in handler.records if "cross-check" in r.getMessage().lower() + ] + assert len(cross_check_msgs) == 0 + + def test_all_output_is_debug_level(self) -> None: + """All schema validation output should be DEBUG — never visible to users.""" + metadata = { + "panel.instant_grid_power_w": {"unit": "kW", "datatype": "float"}, + "panel.l1_voltage": {"datatype": "float"}, + "panel.new_fancy_field": {"unit": "W", "datatype": "float"}, + } + sensor_defs = { + "instantGridPowerW": _make_sensor_def("instantGridPowerW", "W"), + "l1_voltage": _make_sensor_def("l1_voltage", "V"), + } + logger = logging.getLogger("custom_components.span_panel.schema_validation") + with CaptureHandler(logger) as handler: + validate_field_metadata(metadata, sensor_defs=sensor_defs) + above_debug = [r for r in handler.records if r.levelno > logging.DEBUG] + assert len(above_debug) == 0, ( + f"Expected all DEBUG, got: {[(r.levelname, r.getMessage()) for r in above_debug]}" + ) + + +# --------------------------------------------------------------------------- +# Unmapped field detection tests +# --------------------------------------------------------------------------- + + +class TestUnmappedFields: + """Tests for detecting fields the integration doesn't consume.""" + + def test_unmapped_field_logs_debug(self) -> None: + """Field not in SENSOR_FIELD_MAP values should log at DEBUG.""" + metadata = {"panel.new_fancy_field": {"unit": "W", "datatype": "float"}} + logger = logging.getLogger("custom_components.span_panel.schema_validation") + with CaptureHandler(logger) as handler: + validate_field_metadata(metadata) + msgs = [ + r + for r in handler.records + if r.levelno == logging.DEBUG and "new_fancy_field" in r.getMessage() + ] + assert len(msgs) == 1 + + def test_mapped_field_not_reported(self) -> None: + """Field that IS in SENSOR_FIELD_MAP should not be reported as unmapped.""" + metadata = {"panel.instant_grid_power_w": {"unit": "W", "datatype": "float"}} + logger = logging.getLogger("custom_components.span_panel.schema_validation") + with CaptureHandler(logger) as handler: + validate_field_metadata(metadata) + unmapped_msgs = [r for r in handler.records if "not mapped" in r.getMessage()] + assert len(unmapped_msgs) == 0 + + +# --------------------------------------------------------------------------- +# No-op when metadata unavailable +# --------------------------------------------------------------------------- + + +class TestNoOp: + """Tests for graceful handling when library doesn't expose metadata.""" + + def test_none_metadata_is_noop(self) -> None: + """None metadata should produce no output above DEBUG.""" + logger = logging.getLogger("custom_components.span_panel.schema_validation") + with CaptureHandler(logger) as handler: + validate_field_metadata(None) + assert any("skipped" in r.getMessage() for r in handler.records) + above_debug = [r for r in handler.records if r.levelno > logging.DEBUG] + assert len(above_debug) == 0 diff --git a/tests/test_v2_config_flow.py b/tests/test_v2_config_flow.py index 7278eb20..84ffb19d 100644 --- a/tests/test_v2_config_flow.py +++ b/tests/test_v2_config_flow.py @@ -92,7 +92,7 @@ async def test_user_flow_detects_v2_and_shows_auth_choice(hass: HomeAssistant) - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) assert result2["type"] == FlowResultType.MENU @@ -120,7 +120,7 @@ async def test_user_flow_v1_aborts(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) # v1 panels should go through setup_flow which detects v1 @@ -155,7 +155,7 @@ async def test_passphrase_auth_success(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) assert result2["step_id"] == "choose_v2_auth" @@ -198,7 +198,7 @@ async def test_passphrase_auth_bad_passphrase(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) # Select passphrase auth from the menu @@ -240,7 +240,7 @@ async def test_passphrase_auth_connection_error(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) # Select passphrase auth from the menu @@ -286,7 +286,7 @@ async def test_v2_entry_contains_mqtt_credentials(hass: HomeAssistant) -> None: # Step 1: submit host result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) # Step 2: choose auth method (passphrase) @@ -356,7 +356,7 @@ async def test_migration_v2_to_v3_live_panel(hass: HomeAssistant) -> None: result = await async_migrate_entry(hass, entry) assert result is True - assert entry.version == 5 + assert entry.version == 6 assert entry.data.get(CONF_API_VERSION) == "v1" @@ -421,18 +421,18 @@ async def test_migration_blocked_when_panel_unreachable(hass: HomeAssistant) -> @pytest.mark.asyncio -async def test_migration_v2_to_v4_simulator(hass: HomeAssistant) -> None: - """Simulator entries migrating from version 2 to 5 should get api_version=simulation.""" +async def test_migration_v5_to_v6_rejects_simulation_entry(hass: HomeAssistant) -> None: + """Simulation entries at v5 should be rejected by v5→v6 migration.""" entry = MockConfigEntry( - version=2, + version=5, minor_version=1, domain=DOMAIN, title="Span Simulator", data={ CONF_HOST: "sim-001", CONF_ACCESS_TOKEN: "simulator_token", + CONF_API_VERSION: "simulation", "simulation_mode": True, - "simulation_config": "simulation_config_32_circuit", }, source=config_entries.SOURCE_USER, options={}, @@ -440,17 +440,11 @@ async def test_migration_v2_to_v4_simulator(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) - with patch( - "custom_components.span_panel.migrate_config_entry_sensors", - return_value=True, - ): - from custom_components.span_panel import async_migrate_entry + from custom_components.span_panel import async_migrate_entry - result = await async_migrate_entry(hass, entry) + result = await async_migrate_entry(hass, entry) - assert result is True - assert entry.version == 5 - assert entry.data.get(CONF_API_VERSION) == "simulation" + assert result is False # ---------- zeroconf v2 discovery ---------- @@ -593,7 +587,7 @@ async def test_user_flow_empty_host(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: "", "simulator_mode": False}, + {CONF_HOST: ""}, ) assert result2["type"] == FlowResultType.FORM @@ -614,7 +608,7 @@ async def test_user_flow_host_unreachable(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: "10.0.0.99", "simulator_mode": False}, + {CONF_HOST: "10.0.0.99"}, ) assert result2["type"] == FlowResultType.FORM @@ -642,14 +636,14 @@ async def test_user_flow_recovery_after_bad_host(hass: HomeAssistant) -> None: # First attempt fails result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: "bad-host", "simulator_mode": False}, + {CONF_HOST: "bad-host"}, ) assert result2["errors"] == {"base": "cannot_connect"} # Second attempt succeeds result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) assert result3["type"] == FlowResultType.MENU assert result3["step_id"] == "choose_v2_auth" @@ -677,7 +671,7 @@ async def test_passphrase_auth_empty_passphrase(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) result2b = await hass.config_entries.flow.async_configure( @@ -718,7 +712,7 @@ async def test_passphrase_auth_recovery_after_error(hass: HomeAssistant) -> None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) result2b = await hass.config_entries.flow.async_configure( @@ -768,7 +762,7 @@ async def test_proximity_auth_success(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) assert result2["step_id"] == "choose_v2_auth" @@ -811,7 +805,7 @@ async def test_proximity_auth_failed(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) result2b = await hass.config_entries.flow.async_configure( @@ -852,7 +846,7 @@ async def test_proximity_auth_connection_error(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) result2b = await hass.config_entries.flow.async_configure( @@ -908,7 +902,7 @@ async def test_duplicate_entry_aborts(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: MOCK_HOST, "simulator_mode": False}, + {CONF_HOST: MOCK_HOST}, ) assert result2["type"] == FlowResultType.ABORT