From 9885d18ddac4d4d655e098eed807c68ccced982e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 13:27:28 -0500 Subject: [PATCH 1/8] Add Gen3 (MAIN 40 / MLO 48) gRPC support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds local gRPC-based support for Gen3 Span panels alongside existing Gen2 REST support. Gen2 code is completely untouched — Gen3 activates only via auto-detection in the config flow. Architecture: - New gen3/ subdirectory with isolated Gen3 code path - SpanGrpcClient: connects to port 50065, raw protobuf parsing - SpanGen3Coordinator: wraps push-based streaming in DataUpdateCoordinator - Config flow auto-detects Gen2 vs Gen3 (REST → gRPC fallback) - Gen3 panels require no authentication Entities: - Main feed: power, voltage, current, frequency sensors - Per-circuit: power, voltage, current sensors + breaker binary sensor - Device hierarchy: panel → circuit sub-devices Not included (future PRs): - Circuit relay control via gRPC UpdateState RPC - Energy accumulation (Gen3 gRPC doesn't provide this yet) - Solar sensor combining Closes #96 Relates to #98 --- custom_components/span_panel/__init__.py | 43 + custom_components/span_panel/binary_sensor.py | 9 + custom_components/span_panel/config_flow.py | 30 +- custom_components/span_panel/const.py | 3 + custom_components/span_panel/gen3/__init__.py | 1 + .../span_panel/gen3/binary_sensors.py | 84 ++ custom_components/span_panel/gen3/const.py | 33 + .../span_panel/gen3/coordinator.py | 84 ++ custom_components/span_panel/gen3/sensors.py | 266 ++++++ .../span_panel/gen3/span.protoset | Bin 0 -> 158636 bytes .../span_panel/gen3/span_grpc_client.py | 787 ++++++++++++++++++ custom_components/span_panel/manifest.json | 5 +- custom_components/span_panel/sensor.py | 10 +- 13 files changed, 1351 insertions(+), 4 deletions(-) create mode 100644 custom_components/span_panel/gen3/__init__.py create mode 100644 custom_components/span_panel/gen3/binary_sensors.py create mode 100644 custom_components/span_panel/gen3/const.py create mode 100644 custom_components/span_panel/gen3/coordinator.py create mode 100644 custom_components/span_panel/gen3/sensors.py create mode 100644 custom_components/span_panel/gen3/span.protoset create mode 100644 custom_components/span_panel/gen3/span_grpc_client.py diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 459d166..49f16f8 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -21,6 +21,7 @@ # Import config flow to ensure it's registered from . import config_flow # noqa: F401 # type: ignore[misc] from .const import ( + CONF_PANEL_GEN, CONF_SIMULATION_CONFIG, CONF_SIMULATION_OFFLINE_MINUTES, CONF_SIMULATION_START_TIME, @@ -102,10 +103,20 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True +GEN3_PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Span Panel from a config entry.""" _LOGGER.debug("SETUP ENTRY CALLED! Entry ID: %s, Version: %s", entry.entry_id, entry.version) + # Gen3 gRPC path — completely separate from Gen2 REST + if entry.data.get(CONF_PANEL_GEN) == "gen3": + return await _async_setup_gen3_entry(hass, entry) + # Migration flags will be handled by the coordinator during its update cycle async def ha_compatible_delay(seconds: float) -> None: @@ -329,8 +340,40 @@ async def _test_authenticated_connection() -> None: return True +async def _async_setup_gen3_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a Gen3 panel via gRPC.""" + from .gen3.coordinator import SpanGen3Coordinator # noqa: E402 + + coordinator = SpanGen3Coordinator(hass, entry) + if not await coordinator.async_setup(): + _LOGGER.error("Failed to connect to Gen3 panel at %s", entry.data.get("host")) + return False + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {COORDINATOR: coordinator, NAME: "SpanPanel"} + + await hass.config_entries.async_forward_entry_setups(entry, GEN3_PLATFORMS) + _LOGGER.info("Gen3 panel setup complete for %s", entry.data.get("host")) + return True + + +async def _async_unload_gen3_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a Gen3 panel entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, GEN3_PLATFORMS) + if unload_ok: + coordinator_data = hass.data[DOMAIN].pop(entry.entry_id, {}) + coordinator = coordinator_data.get(COORDINATOR) + if coordinator and hasattr(coordinator, "async_shutdown"): + await coordinator.async_shutdown() + return bool(unload_ok) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + # Gen3 unload path + if entry.data.get(CONF_PANEL_GEN) == "gen3": + return await _async_unload_gen3_entry(hass, entry) + _LOGGER.debug("Unloading SPAN Panel integration") # Reset span-panel-api delay function to default diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index a46aaf6..5d7bc3a 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -20,6 +20,7 @@ from .const import ( CONF_DEVICE_NAME, + CONF_PANEL_GEN, COORDINATOR, DOMAIN, PANEL_STATUS, @@ -258,6 +259,14 @@ async def async_setup_entry( ) -> None: """Set up status sensor platform.""" + # Gen3 path — use Gen3 binary sensor factory + if config_entry.data.get(CONF_PANEL_GEN) == "gen3": + from .gen3.binary_sensors import create_gen3_binary_sensors # noqa: E402 + + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + async_add_entities(create_gen3_binary_sensors(coordinator)) + return + _LOGGER.debug("ASYNC SETUP ENTRY BINARYSENSOR") data: dict[str, Any] = hass.data[DOMAIN][config_entry.entry_id] diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 4036d09..b32163c 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -54,6 +54,7 @@ CONF_API_RETRIES, CONF_API_RETRY_BACKOFF_MULTIPLIER, CONF_API_RETRY_TIMEOUT, + CONF_PANEL_GEN, CONF_SIMULATION_CONFIG, CONF_SIMULATION_OFFLINE_MINUTES, CONF_SIMULATION_START_TIME, @@ -280,8 +281,24 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con use_ssl: bool = user_input.get(CONF_USE_SSL, False) - # Validate host before setting up flow + # Validate host before setting up flow (Gen2 REST API) if not await validate_host(self.hass, host, use_ssl=use_ssl): + # REST failed — try Gen3 gRPC as fallback + if await self._test_gen3_connection(host): + _LOGGER.info( + "Gen3 panel detected at %s (REST unavailable, gRPC OK)", host + ) + # Gen3 panels don't need auth — create entry directly + await self.async_set_unique_id(host) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"SPAN Panel ({host})", + data={ + CONF_HOST: host, + CONF_PANEL_GEN: "gen3", + }, + ) + return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, @@ -298,6 +315,17 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con return await self.async_step_choose_auth_type() + async def _test_gen3_connection(self, host: str) -> bool: + """Test if the host is a Gen3 panel via gRPC on port 50065.""" + try: + from .gen3.span_grpc_client import SpanGrpcClient # noqa: E402 + + client = SpanGrpcClient(host) + return await client.test_connection() + except Exception: + _LOGGER.debug("Gen3 gRPC connection test failed for %s", host) + return False + async def _handle_simulator_setup(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Handle simulator mode setup.""" # Precision settings already stored in async_step_user diff --git a/custom_components/span_panel/const.py b/custom_components/span_panel/const.py index 57b5d14..d43aadf 100644 --- a/custom_components/span_panel/const.py +++ b/custom_components/span_panel/const.py @@ -12,6 +12,9 @@ CONF_USE_SSL = "use_ssl" CONF_DEVICE_NAME = "device_name" +# Gen3 gRPC panel generation detection +CONF_PANEL_GEN = "panel_generation" + # Simulation configuration CONF_SIMULATION_CONFIG = "simulation_config" CONF_SIMULATION_START_TIME = "simulation_start_time" diff --git a/custom_components/span_panel/gen3/__init__.py b/custom_components/span_panel/gen3/__init__.py new file mode 100644 index 0000000..1d49d07 --- /dev/null +++ b/custom_components/span_panel/gen3/__init__.py @@ -0,0 +1 @@ +"""Gen3 gRPC support for Span panels (MAIN 40 / MLO 48).""" diff --git a/custom_components/span_panel/gen3/binary_sensors.py b/custom_components/span_panel/gen3/binary_sensors.py new file mode 100644 index 0000000..7de31bf --- /dev/null +++ b/custom_components/span_panel/gen3/binary_sensors.py @@ -0,0 +1,84 @@ +"""Binary sensor entities for Gen3 Span panels. + +Provides breaker ON/OFF state detection for each circuit based on +voltage threshold. A breaker is considered ON if its voltage exceeds +5V (5000 mV). +""" + +from __future__ import annotations + +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from ..const import DOMAIN +from .coordinator import SpanGen3Coordinator +from .span_grpc_client import PanelData + +_LOGGER = logging.getLogger(__name__) + + +def create_gen3_binary_sensors( + coordinator: SpanGen3Coordinator, +) -> list[BinarySensorEntity]: + """Create all Gen3 binary sensor entities for the panel.""" + host = coordinator.config_entry.data["host"] + data: PanelData = coordinator.data + entities: list[BinarySensorEntity] = [] + + for circuit_id in data.circuits: + entities.append( + SpanGen3BreakerSensor(coordinator, host, circuit_id) + ) + + return entities + + +class SpanGen3BreakerSensor( + CoordinatorEntity[SpanGen3Coordinator], BinarySensorEntity +): + """Binary sensor for breaker state (ON/OFF based on voltage).""" + + _attr_has_entity_name = True + _attr_device_class = BinarySensorDeviceClass.POWER + + def __init__( + self, + coordinator: SpanGen3Coordinator, + host: str, + circuit_id: int, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._host = host + self._circuit_id = circuit_id + self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_breaker" + self._attr_name = "Breaker" + + @property + def device_info(self) -> DeviceInfo: + """Return device info — circuit sub-device.""" + info = self.coordinator.data.circuits.get(self._circuit_id) + name = info.name if info else f"Circuit {self._circuit_id}" + return DeviceInfo( + identifiers={ + (DOMAIN, f"{self._host}_circuit_{self._circuit_id}") + }, + name=name, + manufacturer="Span", + model="Circuit Breaker", + via_device=(DOMAIN, self._host), + ) + + @property + def is_on(self) -> bool | None: + """Return true if breaker is ON (voltage present).""" + m = self.coordinator.data.metrics.get(self._circuit_id) + if m is None: + return None + return m.is_on diff --git a/custom_components/span_panel/gen3/const.py b/custom_components/span_panel/gen3/const.py new file mode 100644 index 0000000..d6d160a --- /dev/null +++ b/custom_components/span_panel/gen3/const.py @@ -0,0 +1,33 @@ +"""Constants for Gen3 Span panel gRPC support.""" + +# Configuration key to distinguish Gen2 (REST) from Gen3 (gRPC) +CONF_PANEL_GEN = "panel_generation" +GEN2 = "gen2" +GEN3 = "gen3" + +# gRPC connection +DEFAULT_GRPC_PORT = 50065 +GRPC_SERVICE_PATH = "/io.span.panel.protocols.traithandler.TraitHandlerService" + +# Trait IDs +TRAIT_BREAKER_GROUPS = 15 +TRAIT_CIRCUIT_NAMES = 16 +TRAIT_BREAKER_CONFIG = 17 +TRAIT_POWER_METRICS = 26 +TRAIT_RELAY_STATE = 27 +TRAIT_BREAKER_PARAMS = 31 + +# Vendor/Product IDs +VENDOR_SPAN = 1 +PRODUCT_GEN3_PANEL = 4 +PRODUCT_GEN3_GATEWAY = 5 + +# Metric IID offset: circuit N -> metric IID = N + 27 +METRIC_IID_OFFSET = 27 + +# Main feed IID (always 1 for trait 26) +MAIN_FEED_IID = 1 + +# Voltage threshold for breaker state detection (millivolts) +# Below this = breaker OFF +BREAKER_OFF_VOLTAGE_MV = 5000 # 5V diff --git a/custom_components/span_panel/gen3/coordinator.py b/custom_components/span_panel/gen3/coordinator.py new file mode 100644 index 0000000..c14dfd5 --- /dev/null +++ b/custom_components/span_panel/gen3/coordinator.py @@ -0,0 +1,84 @@ +"""Data coordinator for Gen3 Span panels. + +Wraps the push-based gRPC streaming client in Home Assistant's standard +DataUpdateCoordinator pattern. This gives Gen3 the same +``coordinator.data`` interface that entities expect, while receiving +real-time updates from the gRPC stream rather than polling. +""" + +from __future__ import annotations + +import logging +from datetime import timedelta + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from ..const import DOMAIN +from .const import DEFAULT_GRPC_PORT +from .span_grpc_client import PanelData, SpanGrpcClient + +_LOGGER = logging.getLogger(__name__) + +# Fallback poll interval — the gRPC stream pushes data, but +# DataUpdateCoordinator requires an interval. Set to a long value +# since real updates come from the stream callback. +_FALLBACK_INTERVAL = timedelta(seconds=300) + + +class SpanGen3Coordinator(DataUpdateCoordinator[PanelData]): + """Coordinator for Gen3 Span panels using gRPC streaming.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"Span Gen3 ({entry.data.get('host', 'unknown')})", + update_interval=_FALLBACK_INTERVAL, + ) + self.config_entry = entry + self._client = SpanGrpcClient( + host=entry.data["host"], + port=entry.data.get("port", DEFAULT_GRPC_PORT), + ) + + @property + def client(self) -> SpanGrpcClient: + """Return the gRPC client.""" + return self._client + + async def async_setup(self) -> bool: + """Connect to the panel and start streaming.""" + if not await self._client.connect(): + return False + + # Wire up the gRPC stream callback to DataUpdateCoordinator + self._client.register_callback(self._on_data_update) + + # Seed the coordinator with initial data + self.async_set_updated_data(self._client.data) + + # Start the metric stream + await self._client.start_streaming() + return True + + async def async_shutdown(self) -> None: + """Stop streaming and disconnect.""" + await self._client.stop_streaming() + await self._client.disconnect() + + @callback + def _on_data_update(self) -> None: + """Handle data update from gRPC stream. + + Called by the gRPC client whenever new metrics arrive. Pushes + the latest PanelData into the DataUpdateCoordinator, which + triggers entity state writes. + """ + self.async_set_updated_data(self._client.data) + + async def _async_update_data(self) -> PanelData: + """Fallback for manual refresh — return cached data from stream.""" + return self._client.data diff --git a/custom_components/span_panel/gen3/sensors.py b/custom_components/span_panel/gen3/sensors.py new file mode 100644 index 0000000..5d8d75f --- /dev/null +++ b/custom_components/span_panel/gen3/sensors.py @@ -0,0 +1,266 @@ +"""Sensor entities for Gen3 Span panels. + +Creates power, voltage, current, and frequency sensors for both +the main feed and individual circuits. Uses CoordinatorEntity + +SensorEntity following standard HA patterns. +""" + +from __future__ import annotations + +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import ( + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfFrequency, + UnitOfPower, +) +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from ..const import DOMAIN +from .coordinator import SpanGen3Coordinator +from .span_grpc_client import PanelData + +_LOGGER = logging.getLogger(__name__) + + +def create_gen3_sensors( + coordinator: SpanGen3Coordinator, +) -> list[SensorEntity]: + """Create all Gen3 sensor entities for the panel. + + Returns a flat list of main feed sensors + per-circuit sensors. + """ + host = coordinator.config_entry.data["host"] + entities: list[SensorEntity] = [] + + # Main feed sensors + entities.extend( + [ + SpanGen3MainPowerSensor(coordinator, host), + SpanGen3MainVoltageSensor(coordinator, host), + SpanGen3MainCurrentSensor(coordinator, host), + SpanGen3MainFrequencySensor(coordinator, host), + ] + ) + + # Per-circuit sensors + data: PanelData = coordinator.data + for circuit_id in data.circuits: + entities.extend( + [ + SpanGen3CircuitPowerSensor(coordinator, host, circuit_id), + SpanGen3CircuitVoltageSensor(coordinator, host, circuit_id), + SpanGen3CircuitCurrentSensor(coordinator, host, circuit_id), + ] + ) + + return entities + + +# --------------------------------------------------------------------------- +# Base classes +# --------------------------------------------------------------------------- + + +class SpanGen3SensorBase(CoordinatorEntity[SpanGen3Coordinator], SensorEntity): + """Base class for Gen3 sensors.""" + + _attr_has_entity_name = True + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__(self, coordinator: SpanGen3Coordinator, host: str) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._host = host + + @property + def device_info(self) -> DeviceInfo: + """Return device info for the main panel device.""" + return DeviceInfo( + identifiers={(DOMAIN, self._host)}, + name="SPAN Panel", + manufacturer="Span", + model="Gen3", + ) + + +class SpanGen3CircuitSensorBase(SpanGen3SensorBase): + """Base class for per-circuit Gen3 sensors.""" + + def __init__( + self, + coordinator: SpanGen3Coordinator, + host: str, + circuit_id: int, + ) -> None: + """Initialize the circuit sensor.""" + super().__init__(coordinator, host) + self._circuit_id = circuit_id + + @property + def _circuit_info(self): + """Return circuit info.""" + return self.coordinator.data.circuits.get(self._circuit_id) + + @property + def _circuit_metrics(self): + """Return circuit metrics.""" + return self.coordinator.data.metrics.get(self._circuit_id) + + @property + def device_info(self) -> DeviceInfo: + """Return device info — circuit as sub-device of panel.""" + info = self._circuit_info + name = info.name if info else f"Circuit {self._circuit_id}" + return DeviceInfo( + identifiers={ + (DOMAIN, f"{self._host}_circuit_{self._circuit_id}") + }, + name=name, + manufacturer="Span", + model="Circuit Breaker", + via_device=(DOMAIN, self._host), + ) + + +# --------------------------------------------------------------------------- +# Main feed sensors +# --------------------------------------------------------------------------- + + +class SpanGen3MainPowerSensor(SpanGen3SensorBase): + """Main feed power sensor.""" + + _attr_device_class = SensorDeviceClass.POWER + _attr_native_unit_of_measurement = UnitOfPower.WATT + _attr_suggested_display_precision = 0 + + def __init__(self, coordinator, host): + super().__init__(coordinator, host) + self._attr_unique_id = f"{host}_gen3_main_power" + self._attr_name = "Main Feed Power" + + @property + def native_value(self) -> float | None: + m = self.coordinator.data.main_feed + return round(m.power_w, 1) if m else None + + +class SpanGen3MainVoltageSensor(SpanGen3SensorBase): + """Main feed voltage sensor.""" + + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT + _attr_suggested_display_precision = 1 + + def __init__(self, coordinator, host): + super().__init__(coordinator, host) + self._attr_unique_id = f"{host}_gen3_main_voltage" + self._attr_name = "Main Feed Voltage" + + @property + def native_value(self) -> float | None: + m = self.coordinator.data.main_feed + return round(m.voltage_v, 1) if m else None + + +class SpanGen3MainCurrentSensor(SpanGen3SensorBase): + """Main feed current sensor.""" + + _attr_device_class = SensorDeviceClass.CURRENT + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _attr_suggested_display_precision = 1 + + def __init__(self, coordinator, host): + super().__init__(coordinator, host) + self._attr_unique_id = f"{host}_gen3_main_current" + self._attr_name = "Main Feed Current" + + @property + def native_value(self) -> float | None: + m = self.coordinator.data.main_feed + return round(m.current_a, 1) if m else None + + +class SpanGen3MainFrequencySensor(SpanGen3SensorBase): + """Main feed frequency sensor.""" + + _attr_device_class = SensorDeviceClass.FREQUENCY + _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ + _attr_suggested_display_precision = 2 + + def __init__(self, coordinator, host): + super().__init__(coordinator, host) + self._attr_unique_id = f"{host}_gen3_main_frequency" + self._attr_name = "Main Feed Frequency" + + @property + def native_value(self) -> float | None: + m = self.coordinator.data.main_feed + return round(m.frequency_hz, 2) if m and m.frequency_hz > 0 else None + + +# --------------------------------------------------------------------------- +# Per-circuit sensors +# --------------------------------------------------------------------------- + + +class SpanGen3CircuitPowerSensor(SpanGen3CircuitSensorBase): + """Per-circuit power sensor.""" + + _attr_device_class = SensorDeviceClass.POWER + _attr_native_unit_of_measurement = UnitOfPower.WATT + _attr_suggested_display_precision = 0 + + def __init__(self, coordinator, host, circuit_id): + super().__init__(coordinator, host, circuit_id) + self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_power" + self._attr_name = "Power" + + @property + def native_value(self) -> float | None: + m = self._circuit_metrics + return round(m.power_w, 1) if m else None + + +class SpanGen3CircuitVoltageSensor(SpanGen3CircuitSensorBase): + """Per-circuit voltage sensor.""" + + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT + _attr_suggested_display_precision = 1 + + def __init__(self, coordinator, host, circuit_id): + super().__init__(coordinator, host, circuit_id) + self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_voltage" + self._attr_name = "Voltage" + + @property + def native_value(self) -> float | None: + m = self._circuit_metrics + return round(m.voltage_v, 1) if m else None + + +class SpanGen3CircuitCurrentSensor(SpanGen3CircuitSensorBase): + """Per-circuit current sensor.""" + + _attr_device_class = SensorDeviceClass.CURRENT + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _attr_suggested_display_precision = 2 + + def __init__(self, coordinator, host, circuit_id): + super().__init__(coordinator, host, circuit_id) + self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_current" + self._attr_name = "Current" + + @property + def native_value(self) -> float | None: + m = self._circuit_metrics + return round(m.current_a, 3) if m else None diff --git a/custom_components/span_panel/gen3/span.protoset b/custom_components/span_panel/gen3/span.protoset new file mode 100644 index 0000000000000000000000000000000000000000..d3c3ad2d3dbc69d03cefe36f78ffb0b73242f14b GIT binary patch literal 158636 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 tuple[int, int]: + """Decode a protobuf varint, return (value, new_offset).""" + result = 0 + shift = 0 + while offset < len(data): + b = data[offset] + offset += 1 + result |= (b & 0x7F) << shift + if not (b & 0x80): + break + shift += 7 + return result, offset + + +def _parse_protobuf_fields(data: bytes) -> dict[int, list]: + """Parse raw protobuf bytes into a dict of field_number -> [values].""" + fields: dict[int, list] = {} + offset = 0 + while offset < len(data): + tag, offset = _decode_varint(data, offset) + field_num = tag >> 3 + wire_type = tag & 0x07 + + if wire_type == 0: # varint + value, offset = _decode_varint(data, offset) + elif wire_type == 1: # 64-bit + if offset + 8 > len(data): + break + value = struct.unpack_from(" len(data): + break + value = data[offset : offset + length] + offset += length + elif wire_type == 5: # 32-bit + if offset + 4 > len(data): + break + value = struct.unpack_from(" dict[str, float]: + """Parse a min/max/avg sub-message (fields 1/2/3).""" + fields = _parse_protobuf_fields(data) + return { + "min": _get_field(fields, 1, 0), + "max": _get_field(fields, 2, 0), + "avg": _get_field(fields, 3, 0), + } + + +# --------------------------------------------------------------------------- +# Metric decoders — single-phase, dual-phase, and main feed +# --------------------------------------------------------------------------- + + +def _decode_single_phase(data: bytes) -> CircuitMetrics: + """Decode single-phase (120V) metrics from protobuf field 11.""" + fields = _parse_protobuf_fields(data) + metrics = CircuitMetrics() + + current_data = _get_field(fields, 1) + if current_data and isinstance(current_data, bytes): + current = _parse_min_max_avg(current_data) + metrics.current_a = current["avg"] / 1000.0 + + voltage_data = _get_field(fields, 2) + if voltage_data and isinstance(voltage_data, bytes): + voltage = _parse_min_max_avg(voltage_data) + metrics.voltage_v = voltage["avg"] / 1000.0 + + power_data = _get_field(fields, 3) + if power_data and isinstance(power_data, bytes): + power = _parse_min_max_avg(power_data) + metrics.power_w = power["avg"] / 2000.0 + + apparent_data = _get_field(fields, 4) + if apparent_data and isinstance(apparent_data, bytes): + apparent = _parse_min_max_avg(apparent_data) + metrics.apparent_power_va = apparent["avg"] / 2000.0 + + reactive_data = _get_field(fields, 5) + if reactive_data and isinstance(reactive_data, bytes): + reactive = _parse_min_max_avg(reactive_data) + metrics.reactive_power_var = reactive["avg"] / 2000.0 + + metrics.is_on = (metrics.voltage_v * 1000) > BREAKER_OFF_VOLTAGE_MV + return metrics + + +def _decode_dual_phase(data: bytes) -> CircuitMetrics: + """Decode dual-phase (240V) metrics from protobuf field 12.""" + fields = _parse_protobuf_fields(data) + metrics = CircuitMetrics() + + # Leg A (field 1) + leg_a_data = _get_field(fields, 1) + if leg_a_data and isinstance(leg_a_data, bytes): + leg_a = _parse_protobuf_fields(leg_a_data) + current_data = _get_field(leg_a, 1) + if current_data and isinstance(current_data, bytes): + metrics.current_a_a = _parse_min_max_avg(current_data)["avg"] / 1000.0 + voltage_data = _get_field(leg_a, 2) + if voltage_data and isinstance(voltage_data, bytes): + metrics.voltage_a_v = _parse_min_max_avg(voltage_data)["avg"] / 1000.0 + + # Leg B (field 2) + leg_b_data = _get_field(fields, 2) + if leg_b_data and isinstance(leg_b_data, bytes): + leg_b = _parse_protobuf_fields(leg_b_data) + current_data = _get_field(leg_b, 1) + if current_data and isinstance(current_data, bytes): + metrics.current_b_a = _parse_min_max_avg(current_data)["avg"] / 1000.0 + voltage_data = _get_field(leg_b, 2) + if voltage_data and isinstance(voltage_data, bytes): + metrics.voltage_b_v = _parse_min_max_avg(voltage_data)["avg"] / 1000.0 + + # Combined (field 3) + combined_data = _get_field(fields, 3) + if combined_data and isinstance(combined_data, bytes): + combined = _parse_protobuf_fields(combined_data) + voltage_data = _get_field(combined, 2) + if voltage_data and isinstance(voltage_data, bytes): + metrics.voltage_v = _parse_min_max_avg(voltage_data)["avg"] / 1000.0 + power_data = _get_field(combined, 3) + if power_data and isinstance(power_data, bytes): + metrics.power_w = _parse_min_max_avg(power_data)["avg"] / 2000.0 + apparent_data = _get_field(combined, 4) + if apparent_data and isinstance(apparent_data, bytes): + metrics.apparent_power_va = _parse_min_max_avg(apparent_data)["avg"] / 2000.0 + reactive_data = _get_field(combined, 5) + if reactive_data and isinstance(reactive_data, bytes): + metrics.reactive_power_var = _parse_min_max_avg(reactive_data)["avg"] / 2000.0 + pf_data = _get_field(combined, 6) + if pf_data and isinstance(pf_data, bytes): + pf = _parse_min_max_avg(pf_data) + metrics.power_factor = pf["avg"] / 2000.0 + + # Frequency (field 4) + freq_data = _get_field(fields, 4) + if freq_data and isinstance(freq_data, bytes): + freq = _parse_min_max_avg(freq_data) + metrics.frequency_hz = freq["avg"] / 1000.0 + + # Total current = leg A + leg B + metrics.current_a = metrics.current_a_a + metrics.current_b_a + + metrics.is_on = (metrics.voltage_v * 1000) > BREAKER_OFF_VOLTAGE_MV + return metrics + + +def _extract_deepest_value(data: bytes, target_field: int = 3) -> int: + """Extract the deepest varint from nested protobuf. + + Recursively searches for the largest non-zero value at the target field + within nested sub-messages. + """ + fields = _parse_protobuf_fields(data) + best = 0 + + for fn, vals in fields.items(): + for v in vals: + if isinstance(v, bytes) and len(v) > 0: + inner = _extract_deepest_value(v, target_field) + if inner > best: + best = inner + elif not isinstance(v, bytes) and fn == target_field: + if v > best: + best = v + return best + + +def _decode_main_feed(data: bytes) -> CircuitMetrics: + """Decode main feed metrics from protobuf field 14. + + Field 14 has deeper nesting than circuit fields 11/12. The structure: + 14.1 = primary data block (leg A) + 14.2 = secondary data block (leg B) + Each leg: {1: current stats, 2: voltage stats, 3: power stats, 4: frequency} + """ + fields = _parse_protobuf_fields(data) + main_data = _get_field(fields, 14) + if not main_data or not isinstance(main_data, bytes): + return CircuitMetrics() + + metrics = CircuitMetrics() + main_fields = _parse_protobuf_fields(main_data) + + # Extract from primary data block (field 1 = leg A) + leg_a = _get_field(main_fields, 1) + if leg_a and isinstance(leg_a, bytes): + la_fields = _parse_protobuf_fields(leg_a) + + power_stats = _get_field(la_fields, 3) + if power_stats and isinstance(power_stats, bytes): + metrics.power_w = _extract_deepest_value(power_stats) / 2000.0 + + voltage_stats = _get_field(la_fields, 2) + if voltage_stats and isinstance(voltage_stats, bytes): + vs_fields = _parse_protobuf_fields(voltage_stats) + f2 = _get_field(vs_fields, 2) + if f2 and isinstance(f2, bytes): + inner = _parse_protobuf_fields(f2) + v = _get_field(inner, 3, 0) + if isinstance(v, int) and v > 0: + metrics.voltage_a_v = v / 1000.0 + + freq_stats = _get_field(la_fields, 4) + if freq_stats and isinstance(freq_stats, bytes): + freq_fields = _parse_protobuf_fields(freq_stats) + freq_val = _get_field(freq_fields, 3, 0) + if isinstance(freq_val, int) and freq_val > 0: + metrics.frequency_hz = freq_val / 1000.0 + + # Leg B data (field 2) + leg_b = _get_field(main_fields, 2) + if leg_b and isinstance(leg_b, bytes): + lb_fields = _parse_protobuf_fields(leg_b) + power_stats = _get_field(lb_fields, 3) + if power_stats and isinstance(power_stats, bytes): + lb_power = _extract_deepest_value(power_stats) / 2000.0 + if lb_power > 0: + metrics.power_w += lb_power + voltage_stats = _get_field(lb_fields, 2) + if voltage_stats and isinstance(voltage_stats, bytes): + vs_fields = _parse_protobuf_fields(voltage_stats) + f2 = _get_field(vs_fields, 2) + if f2 and isinstance(f2, bytes): + inner = _parse_protobuf_fields(f2) + v = _get_field(inner, 3, 0) + if isinstance(v, int) and v > 0: + metrics.voltage_b_v = v / 1000.0 + + # Combined voltage (split-phase: leg A + leg B, or 2x leg A) + if metrics.voltage_b_v > 0: + metrics.voltage_v = metrics.voltage_a_v + metrics.voltage_b_v + else: + metrics.voltage_v = metrics.voltage_a_v * 2 # Assume symmetric + + # Derive current from power and voltage + if metrics.voltage_v > 0: + metrics.current_a = metrics.power_w / metrics.voltage_v + + metrics.is_on = True + return metrics + + +# --------------------------------------------------------------------------- +# Protobuf encoding helpers +# --------------------------------------------------------------------------- + + +def _encode_varint(value: int) -> bytes: + """Encode an integer as a protobuf varint.""" + parts = [] + while value > 0x7F: + parts.append((value & 0x7F) | 0x80) + value >>= 7 + parts.append(value & 0x7F) + return bytes(parts) if parts else b"\x00" + + +def _encode_varint_field(field_num: int, value: int) -> bytes: + """Encode a varint field (tag + value).""" + tag = (field_num << 3) | 0 # wire type 0 = varint + return _encode_varint(tag) + _encode_varint(value) + + +def _encode_bytes_field(field_num: int, value: bytes) -> bytes: + """Encode a length-delimited field (tag + length + value).""" + tag = (field_num << 3) | 2 # wire type 2 = length-delimited + return _encode_varint(tag) + _encode_varint(len(value)) + value + + +def _encode_string_field(field_num: int, value: str) -> bytes: + """Encode a string field (tag + length + utf-8 bytes).""" + return _encode_bytes_field(field_num, value.encode("utf-8")) + + +# --------------------------------------------------------------------------- +# gRPC Client +# --------------------------------------------------------------------------- + + +class SpanGrpcClient: + """gRPC client for Gen3 Span panels. + + Connects to the panel's TraitHandlerService on port 50065 (no auth). + Discovers circuits via GetInstances, fetches names via GetRevision, + and streams real-time power metrics via Subscribe. + """ + + def __init__(self, host: str, port: int = DEFAULT_GRPC_PORT) -> None: + """Initialize the client.""" + self._host = host + self._port = port + self._channel: grpc.aio.Channel | None = None + self._stream_task: asyncio.Task | None = None + self._data = PanelData() + self._callbacks: list[Callable[[], None]] = [] + self._connected = False + + @property + def data(self) -> PanelData: + """Return current panel data.""" + return self._data + + @property + def connected(self) -> bool: + """Return connection status.""" + return self._connected + + def register_callback(self, callback: Callable[[], None]) -> Callable[[], None]: + """Register a callback for data updates. Returns unregister function.""" + self._callbacks.append(callback) + return lambda: self._callbacks.remove(callback) + + def _notify(self) -> None: + """Notify all registered callbacks.""" + for cb in self._callbacks: + try: + cb() + except Exception: + _LOGGER.exception("Error in callback") + + async def connect(self) -> bool: + """Connect to the panel and fetch initial data.""" + try: + self._channel = grpc.aio.insecure_channel( + f"{self._host}:{self._port}", + options=[ + ("grpc.keepalive_time_ms", 30000), + ("grpc.keepalive_timeout_ms", 10000), + ("grpc.keepalive_permit_without_calls", True), + ], + ) + await self._fetch_instances() + await self._fetch_circuit_names() + self._connected = True + _LOGGER.info( + "Connected to Gen3 panel at %s:%s — %d circuits discovered", + self._host, + self._port, + len(self._data.circuits), + ) + return True + except Exception: + _LOGGER.exception( + "Failed to connect to Gen3 panel at %s:%s", self._host, self._port + ) + self._connected = False + return False + + async def disconnect(self) -> None: + """Disconnect from the panel.""" + self._connected = False + if self._stream_task and not self._stream_task.done(): + self._stream_task.cancel() + try: + await self._stream_task + except asyncio.CancelledError: + pass + if self._channel: + await self._channel.close() + self._channel = None + + async def start_streaming(self) -> None: + """Start the metric streaming task.""" + if self._stream_task and not self._stream_task.done(): + return + self._stream_task = asyncio.create_task(self._stream_loop()) + + async def stop_streaming(self) -> None: + """Stop the metric streaming task.""" + if self._stream_task and not self._stream_task.done(): + self._stream_task.cancel() + try: + await self._stream_task + except asyncio.CancelledError: + pass + + async def test_connection(self) -> bool: + """Test if we can connect to the panel (static method-like).""" + try: + channel = grpc.aio.insecure_channel( + f"{self._host}:{self._port}", + options=[("grpc.initial_reconnect_backoff_ms", 1000)], + ) + try: + response = await asyncio.wait_for( + channel.unary_unary( + _GET_INSTANCES, + request_serializer=lambda x: x, + response_deserializer=lambda x: x, + )(b""), + timeout=5.0, + ) + return len(response) > 0 + finally: + await channel.close() + except Exception: + return False + + # ------------------------------------------------------------------ + # Instance discovery + # ------------------------------------------------------------------ + + async def _fetch_instances(self) -> None: + """Fetch all trait instances to discover circuits.""" + response = await self._channel.unary_unary( + _GET_INSTANCES, + request_serializer=lambda x: x, + response_deserializer=lambda x: x, + )(b"") + self._parse_instances(response) + + def _parse_instances(self, data: bytes) -> None: + """Parse GetInstancesResponse to discover circuits and panel info.""" + fields = _parse_protobuf_fields(data) + items = fields.get(1, []) + + for item_data in items: + if not isinstance(item_data, bytes): + continue + item_fields = _parse_protobuf_fields(item_data) + + trait_info_data = _get_field(item_fields, 1) + if not trait_info_data or not isinstance(trait_info_data, bytes): + continue + + trait_info_fields = _parse_protobuf_fields(trait_info_data) + + external_data = _get_field(trait_info_fields, 2) + if not external_data or not isinstance(external_data, bytes): + continue + + ext_fields = _parse_protobuf_fields(external_data) + + # resource_id (field 1) + resource_data = _get_field(ext_fields, 1) + resource_id_str = "" + if resource_data and isinstance(resource_data, bytes): + rid_fields = _parse_protobuf_fields(resource_data) + rid_val = _get_field(rid_fields, 1) + if rid_val and isinstance(rid_val, bytes): + resource_id_str = rid_val.decode("utf-8", errors="replace") + + # trait_info (field 2) + inner_info = _get_field(ext_fields, 2) + if not inner_info or not isinstance(inner_info, bytes): + continue + + inner_fields = _parse_protobuf_fields(inner_info) + + meta_data = _get_field(inner_fields, 1) + if not meta_data or not isinstance(meta_data, bytes): + continue + + meta_fields = _parse_protobuf_fields(meta_data) + vendor_id = _get_field(meta_fields, 1, 0) + product_id = _get_field(meta_fields, 2, 0) + trait_id = _get_field(meta_fields, 3, 0) + + instance_data = _get_field(inner_fields, 2) + instance_id = 0 + if instance_data and isinstance(instance_data, bytes): + iid_fields = _parse_protobuf_fields(instance_data) + instance_id = _get_field(iid_fields, 1, 0) + + # Capture panel resource_id + if ( + product_id == PRODUCT_GEN3_PANEL + and resource_id_str + and not self._data.panel_resource_id + ): + self._data.panel_resource_id = resource_id_str + + # Detect power metric circuits (trait 26) + if trait_id == TRAIT_POWER_METRICS and vendor_id == VENDOR_SPAN: + circuit_id = instance_id - METRIC_IID_OFFSET + if 1 <= circuit_id <= 50: + if circuit_id not in self._data.circuits: + self._data.circuits[circuit_id] = CircuitInfo( + circuit_id=circuit_id, + name=f"Circuit {circuit_id}", + metric_iid=instance_id, + ) + + # ------------------------------------------------------------------ + # Circuit names + # ------------------------------------------------------------------ + + async def _fetch_circuit_names(self) -> None: + """Fetch circuit names from trait 16 via GetRevision.""" + for circuit_id in list(self._data.circuits.keys()): + try: + name = await self._get_circuit_name(circuit_id) + if name: + self._data.circuits[circuit_id].name = name + except Exception: + _LOGGER.debug("Failed to get name for circuit %d", circuit_id) + + async def _get_circuit_name(self, circuit_id: int) -> str | None: + """Get a single circuit name via GetRevision on trait 16.""" + request = self._build_get_revision_request( + vendor_id=VENDOR_SPAN, + product_id=PRODUCT_GEN3_PANEL, + trait_id=TRAIT_CIRCUIT_NAMES, + instance_id=circuit_id, + ) + + try: + response = await self._channel.unary_unary( + _GET_REVISION, + request_serializer=lambda x: x, + response_deserializer=lambda x: x, + )(request) + + return self._parse_circuit_name(response) + except grpc.aio.AioRpcError: + return None + + def _build_get_revision_request( + self, vendor_id: int, product_id: int, trait_id: int, instance_id: int + ) -> bytes: + """Build a GetRevisionRequest protobuf message manually.""" + # TraitMetadata (field 1) + meta = _encode_varint_field(1, vendor_id) + meta += _encode_varint_field(2, product_id) + meta += _encode_varint_field(3, trait_id) + meta += _encode_varint_field(4, 1) # version + + # ResourceId message + resource_id_msg = _encode_string_field(1, self._data.panel_resource_id) + + # InstanceMetadata (field 2) + iid_msg = _encode_varint_field(1, instance_id) + instance_meta = _encode_bytes_field(1, resource_id_msg) + instance_meta += _encode_bytes_field(2, iid_msg) + + # RevisionRequest (field 3) + req_metadata = _encode_bytes_field(2, resource_id_msg) + revision_request = _encode_bytes_field(1, req_metadata) + + result = _encode_bytes_field(1, meta) + result += _encode_bytes_field(2, instance_meta) + result += _encode_bytes_field(3, revision_request) + return result + + @staticmethod + def _parse_circuit_name(data: bytes) -> str | None: + """Parse circuit name from GetRevision response.""" + fields = _parse_protobuf_fields(data) + + sr_data = _get_field(fields, 3) + if not sr_data or not isinstance(sr_data, bytes): + return None + + sr_fields = _parse_protobuf_fields(sr_data) + payload_data = _get_field(sr_fields, 2) + if not payload_data or not isinstance(payload_data, bytes): + return None + + pl_fields = _parse_protobuf_fields(payload_data) + raw = _get_field(pl_fields, 1) + if not raw or not isinstance(raw, bytes): + return None + + name_fields = _parse_protobuf_fields(raw) + name = _get_field(name_fields, 4) + if name and isinstance(name, bytes): + return name.decode("utf-8", errors="replace").strip() + return None + + # ------------------------------------------------------------------ + # Metric streaming + # ------------------------------------------------------------------ + + async def _stream_loop(self) -> None: + """Main streaming loop with automatic reconnection.""" + while self._connected: + try: + await self._subscribe_stream() + except asyncio.CancelledError: + return + except Exception: + _LOGGER.exception("Stream error, reconnecting in 5s") + await asyncio.sleep(5) + + async def _subscribe_stream(self) -> None: + """Subscribe to the gRPC stream and process updates.""" + call = self._channel.unary_stream( + _SUBSCRIBE, + request_serializer=lambda x: x, + response_deserializer=lambda x: x, + ) + + stream = call(b"") + async for response in stream: + try: + self._process_notification(response) + except Exception: + _LOGGER.debug("Error processing notification", exc_info=True) + + def _process_notification(self, data: bytes) -> None: + """Process a TraitInstanceNotification from the stream.""" + fields = _parse_protobuf_fields(data) + + rti_data = _get_field(fields, 1) + if not rti_data or not isinstance(rti_data, bytes): + return + + rti_fields = _parse_protobuf_fields(rti_data) + ext_data = _get_field(rti_fields, 2) + if not ext_data or not isinstance(ext_data, bytes): + return + + ext_fields = _parse_protobuf_fields(ext_data) + info_data = _get_field(ext_fields, 2) + if not info_data or not isinstance(info_data, bytes): + return + + info_fields = _parse_protobuf_fields(info_data) + meta_data = _get_field(info_fields, 1) + if not meta_data or not isinstance(meta_data, bytes): + return + + meta_fields = _parse_protobuf_fields(meta_data) + trait_id = _get_field(meta_fields, 3, 0) + + iid_data = _get_field(info_fields, 2) + instance_id = 0 + if iid_data and isinstance(iid_data, bytes): + iid_fields = _parse_protobuf_fields(iid_data) + instance_id = _get_field(iid_fields, 1, 0) + + # Only process trait 26 (power metrics) + if trait_id != TRAIT_POWER_METRICS: + return + + notify_data = _get_field(fields, 2) + if not notify_data or not isinstance(notify_data, bytes): + return + + notify_fields = _parse_protobuf_fields(notify_data) + + metrics_list = notify_fields.get(3, []) + for metric_data in metrics_list: + if not isinstance(metric_data, bytes): + continue + + ml_fields = _parse_protobuf_fields(metric_data) + raw_metrics = ml_fields.get(3, []) + + for raw in raw_metrics: + if not isinstance(raw, bytes): + continue + self._decode_and_store_metric(instance_id, raw) + + self._notify() + + def _decode_and_store_metric(self, iid: int, raw: bytes) -> None: + """Decode a raw metric payload and store it.""" + top_fields = _parse_protobuf_fields(raw) + + # Main feed (IID 1) uses field 14 with deeper nesting + if iid == MAIN_FEED_IID: + self._data.main_feed = _decode_main_feed(raw) + return + + circuit_id = iid - METRIC_IID_OFFSET + if not (1 <= circuit_id <= 50): + return + + # Dual-phase (field 12) — check first since it's more specific + dual_data = _get_field(top_fields, 12) + if dual_data and isinstance(dual_data, bytes): + self._data.metrics[circuit_id] = _decode_dual_phase(dual_data) + if circuit_id in self._data.circuits: + self._data.circuits[circuit_id].is_dual_phase = True + return + + # Single-phase (field 11) + single_data = _get_field(top_fields, 11) + if single_data and isinstance(single_data, bytes): + self._data.metrics[circuit_id] = _decode_single_phase(single_data) + if circuit_id in self._data.circuits: + self._data.circuits[circuit_id].is_dual_phase = False diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index bff2ab9..a3bf70c 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -12,9 +12,10 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/SpanPanel/span/issues", "requirements": [ - "span-panel-api~=1.1.14" + "span-panel-api~=1.1.14", + "grpcio>=1.60.0" ], - "version": "1.3.1", + "version": "1.4.0", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index b63bb3c..032f4d1 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COORDINATOR, DOMAIN +from .const import CONF_PANEL_GEN, COORDINATOR, DOMAIN from .coordinator import SpanPanelCoordinator from .sensors import ( SpanCircuitEnergySensor, @@ -55,6 +55,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor platform.""" + # Gen3 path — use Gen3 sensor factory + if config_entry.data.get(CONF_PANEL_GEN) == "gen3": + from .gen3.sensors import create_gen3_sensors # noqa: E402 + + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + async_add_entities(create_gen3_sensors(coordinator)) + return + try: data = hass.data[DOMAIN][config_entry.entry_id] coordinator: SpanPanelCoordinator = data[COORDINATOR] From 6fc5bb46e2defd5853c8156aa13d5620b86981b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 15:46:18 -0500 Subject: [PATCH 2/8] Fix circuit name/power mapping for different panel configurations The METRIC_IID_OFFSET was hardcoded to 27, which only worked for panels where trait 26 (metrics) IIDs start at 28. On panels with different numbering, this caused names to pair with wrong power readings. Now dynamically discovers both trait 16 (name) and trait 26 (metric) instance IDs during setup and pairs them by sorted position, making the mapping work regardless of the panel's IID numbering scheme. --- .../span_panel/gen3/span_grpc_client.py | 113 +++++++++++++++--- 1 file changed, 97 insertions(+), 16 deletions(-) diff --git a/custom_components/span_panel/gen3/span_grpc_client.py b/custom_components/span_panel/gen3/span_grpc_client.py index 00442e6..c055ac7 100644 --- a/custom_components/span_panel/gen3/span_grpc_client.py +++ b/custom_components/span_panel/gen3/span_grpc_client.py @@ -45,6 +45,7 @@ class CircuitInfo: circuit_id: int name: str metric_iid: int + name_iid: int = 0 is_dual_phase: bool = False @@ -77,6 +78,8 @@ class PanelData: circuits: dict[int, CircuitInfo] = field(default_factory=dict) metrics: dict[int, CircuitMetrics] = field(default_factory=dict) main_feed: CircuitMetrics = field(default_factory=CircuitMetrics) + # Reverse lookup: metric IID → circuit_id (built during discovery) + metric_iid_to_circuit: dict[int, int] = field(default_factory=dict) # --------------------------------------------------------------------------- @@ -514,10 +517,20 @@ async def _fetch_instances(self) -> None: self._parse_instances(response) def _parse_instances(self, data: bytes) -> None: - """Parse GetInstancesResponse to discover circuits and panel info.""" + """Parse GetInstancesResponse to discover circuits and panel info. + + Collects both trait 16 (name) and trait 26 (metric) instance IDs, + then pairs them by sorted position to build the circuit mapping. + This avoids hardcoding a fixed offset between the two trait IID + spaces, which can vary across panel models/configurations. + """ fields = _parse_protobuf_fields(data) items = fields.get(1, []) + # Collect instance IDs for both traits before building circuits + name_iids: list[int] = [] # Trait 16 instance IDs + metric_iids: list[int] = [] # Trait 26 instance IDs (excl main feed) + for item_data in items: if not isinstance(item_data, bytes): continue @@ -574,30 +587,97 @@ def _parse_instances(self, data: bytes) -> None: ): self._data.panel_resource_id = resource_id_str - # Detect power metric circuits (trait 26) + # Collect trait 16 (circuit names) instance IDs + if trait_id == TRAIT_CIRCUIT_NAMES and vendor_id == VENDOR_SPAN: + name_iids.append(instance_id) + + # Collect trait 26 (power metrics) instance IDs if trait_id == TRAIT_POWER_METRICS and vendor_id == VENDOR_SPAN: - circuit_id = instance_id - METRIC_IID_OFFSET + if instance_id != MAIN_FEED_IID: + metric_iids.append(instance_id) + + # Sort both sets to pair by position (lowest IID = first circuit) + name_iids.sort() + metric_iids.sort() + + _LOGGER.debug( + "Discovered %d name instances (trait 16) and %d metric instances " + "(trait 26, excl main feed). Name IIDs: %s, Metric IIDs: %s", + len(name_iids), + len(metric_iids), + name_iids[:5], + metric_iids[:5], + ) + + if name_iids and metric_iids: + if len(name_iids) != len(metric_iids): + _LOGGER.warning( + "Trait 16 has %d instances but trait 26 has %d — " + "pairing by position (some circuits may be unnamed)", + len(name_iids), + len(metric_iids), + ) + + # Pair by sorted position — each name IID corresponds to the + # metric IID at the same index + for idx, metric_iid in enumerate(metric_iids): + circuit_id = idx + 1 + name_iid = name_iids[idx] if idx < len(name_iids) else 0 + self._data.circuits[circuit_id] = CircuitInfo( + circuit_id=circuit_id, + name=f"Circuit {circuit_id}", + metric_iid=metric_iid, + name_iid=name_iid, + ) + self._data.metric_iid_to_circuit[metric_iid] = circuit_id + + elif metric_iids: + # No trait 16 instances — fall back to offset-based mapping + _LOGGER.warning( + "No trait 16 (name) instances found — falling back to " + "offset-based circuit mapping (offset=%d)", + METRIC_IID_OFFSET, + ) + for metric_iid in metric_iids: + circuit_id = metric_iid - METRIC_IID_OFFSET if 1 <= circuit_id <= 50: - if circuit_id not in self._data.circuits: - self._data.circuits[circuit_id] = CircuitInfo( - circuit_id=circuit_id, - name=f"Circuit {circuit_id}", - metric_iid=instance_id, - ) + self._data.circuits[circuit_id] = CircuitInfo( + circuit_id=circuit_id, + name=f"Circuit {circuit_id}", + metric_iid=metric_iid, + name_iid=circuit_id, + ) + self._data.metric_iid_to_circuit[metric_iid] = circuit_id # ------------------------------------------------------------------ # Circuit names # ------------------------------------------------------------------ async def _fetch_circuit_names(self) -> None: - """Fetch circuit names from trait 16 via GetRevision.""" - for circuit_id in list(self._data.circuits.keys()): + """Fetch circuit names from trait 16 via GetRevision. + + Uses each circuit's name_iid (trait 16 instance ID) rather than + the circuit_id, since the two numbering spaces may differ. + """ + for circuit_id, info in list(self._data.circuits.items()): + name_iid = info.name_iid or circuit_id try: - name = await self._get_circuit_name(circuit_id) + name = await self._get_circuit_name(name_iid) if name: - self._data.circuits[circuit_id].name = name + info.name = name + _LOGGER.debug( + "Circuit %d (name_iid=%d, metric_iid=%d): %s", + circuit_id, + name_iid, + info.metric_iid, + name, + ) except Exception: - _LOGGER.debug("Failed to get name for circuit %d", circuit_id) + _LOGGER.debug( + "Failed to get name for circuit %d (name_iid=%d)", + circuit_id, + name_iid, + ) async def _get_circuit_name(self, circuit_id: int) -> str | None: """Get a single circuit name via GetRevision on trait 16.""" @@ -767,8 +847,9 @@ def _decode_and_store_metric(self, iid: int, raw: bytes) -> None: self._data.main_feed = _decode_main_feed(raw) return - circuit_id = iid - METRIC_IID_OFFSET - if not (1 <= circuit_id <= 50): + # Look up circuit_id from the discovered mapping + circuit_id = self._data.metric_iid_to_circuit.get(iid) + if circuit_id is None: return # Dual-phase (field 12) — check first since it's more specific From 9f475ba0c7568ea6da3deb4f5d49977c58f2c9db Mon Sep 17 00:00:00 2001 From: Matt Welch Date: Tue, 17 Feb 2026 20:39:23 -0500 Subject: [PATCH 3/8] Discovery and position Support for reading and exposing the physical breaker slot position from Gen3 panels. It introduces a new "Panel Position" sensor that displays the breaker position (1-48) for each circuit, and refactors the circuit discovery logic to properly resolve breaker group information. --- custom_components/span_panel/gen3/sensors.py | 21 ++ .../span_panel/gen3/span_grpc_client.py | 239 +++++++++++++----- custom_components/span_panel/sensor.py | 2 + .../span_panel/sensor_definitions.py | 12 + .../span_panel/sensors/__init__.py | 8 +- .../span_panel/sensors/circuit.py | 97 +++++++ .../span_panel/sensors/factory.py | 20 +- 7 files changed, 331 insertions(+), 68 deletions(-) diff --git a/custom_components/span_panel/gen3/sensors.py b/custom_components/span_panel/gen3/sensors.py index 5d8d75f..594cc82 100644 --- a/custom_components/span_panel/gen3/sensors.py +++ b/custom_components/span_panel/gen3/sensors.py @@ -58,6 +58,7 @@ def create_gen3_sensors( SpanGen3CircuitPowerSensor(coordinator, host, circuit_id), SpanGen3CircuitVoltageSensor(coordinator, host, circuit_id), SpanGen3CircuitCurrentSensor(coordinator, host, circuit_id), + SpanGen3CircuitPositionSensor(coordinator, host, circuit_id), ] ) @@ -264,3 +265,23 @@ def __init__(self, coordinator, host, circuit_id): def native_value(self) -> float | None: m = self._circuit_metrics return round(m.current_a, 3) if m else None + + +class SpanGen3CircuitPositionSensor(SpanGen3CircuitSensorBase): + """Per-circuit panel position (breaker slot number) sensor.""" + + _attr_icon = "mdi:electric-switch" + _attr_state_class = None # Static configuration value, not a time-series measurement + + def __init__(self, coordinator, host, circuit_id): + super().__init__(coordinator, host, circuit_id) + self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_position" + self._attr_name = "Panel Position" + + @property + def native_value(self) -> int | None: + info = self._circuit_info + if info is None: + return None + pos = info.breaker_position + return pos if pos > 0 else None diff --git a/custom_components/span_panel/gen3/span_grpc_client.py b/custom_components/span_panel/gen3/span_grpc_client.py index c055ac7..9c8b373 100644 --- a/custom_components/span_panel/gen3/span_grpc_client.py +++ b/custom_components/span_panel/gen3/span_grpc_client.py @@ -24,6 +24,7 @@ MAIN_FEED_IID, METRIC_IID_OFFSET, PRODUCT_GEN3_PANEL, + TRAIT_BREAKER_GROUPS, TRAIT_CIRCUIT_NAMES, TRAIT_POWER_METRICS, VENDOR_SPAN, @@ -47,6 +48,7 @@ class CircuitInfo: metric_iid: int name_iid: int = 0 is_dual_phase: bool = False + breaker_position: int = 0 @dataclass @@ -80,6 +82,8 @@ class PanelData: main_feed: CircuitMetrics = field(default_factory=CircuitMetrics) # Reverse lookup: metric IID → circuit_id (built during discovery) metric_iid_to_circuit: dict[int, int] = field(default_factory=dict) + # BreakerGroups trait 15 instance IIDs — populated in _parse_instances + breaker_group_iids: list[int] = field(default_factory=list) # --------------------------------------------------------------------------- @@ -437,13 +441,17 @@ async def connect(self) -> bool: ], ) await self._fetch_instances() + await self._fetch_breaker_groups() await self._fetch_circuit_names() self._connected = True + with_pos = sum(1 for c in self._data.circuits.values() if c.breaker_position > 0) _LOGGER.info( - "Connected to Gen3 panel at %s:%s — %d circuits discovered", + "Connected to Gen3 panel at %s:%s — %d circuits discovered, " + "%d with breaker positions", self._host, self._port, len(self._data.circuits), + with_pos, ) return True except Exception: @@ -519,16 +527,14 @@ async def _fetch_instances(self) -> None: def _parse_instances(self, data: bytes) -> None: """Parse GetInstancesResponse to discover circuits and panel info. - Collects both trait 16 (name) and trait 26 (metric) instance IDs, - then pairs them by sorted position to build the circuit mapping. - This avoids hardcoding a fixed offset between the two trait IID - spaces, which can vary across panel models/configurations. + Collects trait 26 (PowerMetrics) instance IDs as circuit entries, + and trait 15 (BreakerGroups) IIDs for later BreakerGroups resolution. + Circuit names and breaker positions are resolved in subsequent steps + (_fetch_breaker_groups, _fetch_circuit_names) rather than here. """ fields = _parse_protobuf_fields(data) items = fields.get(1, []) - # Collect instance IDs for both traits before building circuits - name_iids: list[int] = [] # Trait 16 instance IDs metric_iids: list[int] = [] # Trait 26 instance IDs (excl main feed) for item_data in items: @@ -587,67 +593,92 @@ def _parse_instances(self, data: bytes) -> None: ): self._data.panel_resource_id = resource_id_str - # Collect trait 16 (circuit names) instance IDs - if trait_id == TRAIT_CIRCUIT_NAMES and vendor_id == VENDOR_SPAN: - name_iids.append(instance_id) - - # Collect trait 26 (power metrics) instance IDs - if trait_id == TRAIT_POWER_METRICS and vendor_id == VENDOR_SPAN: - if instance_id != MAIN_FEED_IID: - metric_iids.append(instance_id) + if vendor_id != VENDOR_SPAN: + continue - # Sort both sets to pair by position (lowest IID = first circuit) - name_iids.sort() - metric_iids.sort() + # Trait 15 (BreakerGroups): collect IIDs for Step 2 + if trait_id == TRAIT_BREAKER_GROUPS: + if instance_id not in self._data.breaker_group_iids: + self._data.breaker_group_iids.append(instance_id) + + # Trait 26 (PowerMetrics): each non-main-feed instance is a circuit + if trait_id == TRAIT_POWER_METRICS and instance_id != MAIN_FEED_IID: + metric_iids.append(instance_id) + self._data.circuits[instance_id] = CircuitInfo( + circuit_id=instance_id, + name=f"Circuit {instance_id}", + metric_iid=instance_id, + ) + self._data.metric_iid_to_circuit[instance_id] = instance_id _LOGGER.debug( - "Discovered %d name instances (trait 16) and %d metric instances " - "(trait 26, excl main feed). Name IIDs: %s, Metric IIDs: %s", - len(name_iids), + "Discovered %d metric instances (trait 26, excl main feed) and " + "%d breaker group instances (trait 15). Metric IIDs: %s, " + "BreakerGroup IIDs: %s", len(metric_iids), - name_iids[:5], - metric_iids[:5], + len(self._data.breaker_group_iids), + sorted(metric_iids)[:5], + sorted(self._data.breaker_group_iids)[:5], ) - if name_iids and metric_iids: - if len(name_iids) != len(metric_iids): - _LOGGER.warning( - "Trait 16 has %d instances but trait 26 has %d — " - "pairing by position (some circuits may be unnamed)", - len(name_iids), - len(metric_iids), - ) + # ------------------------------------------------------------------ + # Breaker group resolution + # ------------------------------------------------------------------ - # Pair by sorted position — each name IID corresponds to the - # metric IID at the same index - for idx, metric_iid in enumerate(metric_iids): - circuit_id = idx + 1 - name_iid = name_iids[idx] if idx < len(name_iids) else 0 - self._data.circuits[circuit_id] = CircuitInfo( - circuit_id=circuit_id, - name=f"Circuit {circuit_id}", - metric_iid=metric_iid, - name_iid=name_iid, - ) - self._data.metric_iid_to_circuit[metric_iid] = circuit_id - - elif metric_iids: - # No trait 16 instances — fall back to offset-based mapping - _LOGGER.warning( - "No trait 16 (name) instances found — falling back to " - "offset-based circuit mapping (offset=%d)", - METRIC_IID_OFFSET, + async def _fetch_breaker_groups(self) -> None: + """Fetch BreakerGroup mappings (trait 15) to get name_iid and breaker_position. + + For each BreakerGroup IID that matches a PowerMetrics circuit, calls + GetRevision(trait 15) to extract: + - name_iid: the trait 16 IID used to fetch the human-readable circuit name + - breaker_position: the physical slot number (1-48) in the panel + + Circuits with no BreakerGroup mapping (orphans) are removed — they are + system/ghost metrics with no physical breaker. + """ + for group_iid in self._data.breaker_group_iids: + if group_iid not in self._data.circuits: + # BreakerGroup IID without a matching PowerMetrics instance — skip + continue + request = self._build_get_revision_request( + vendor_id=VENDOR_SPAN, + product_id=PRODUCT_GEN3_PANEL, + trait_id=TRAIT_BREAKER_GROUPS, + instance_id=group_iid, ) - for metric_iid in metric_iids: - circuit_id = metric_iid - METRIC_IID_OFFSET - if 1 <= circuit_id <= 50: - self._data.circuits[circuit_id] = CircuitInfo( - circuit_id=circuit_id, - name=f"Circuit {circuit_id}", - metric_iid=metric_iid, - name_iid=circuit_id, - ) - self._data.metric_iid_to_circuit[metric_iid] = circuit_id + try: + response = await self._channel.unary_unary( + _GET_REVISION, + request_serializer=lambda x: x, + response_deserializer=lambda x: x, + )(request) + name_id, breaker_pos = self._parse_breaker_group(response) + circuit = self._data.circuits[group_iid] + circuit.name_iid = name_id + if breaker_pos > 0: + circuit.breaker_position = breaker_pos + _LOGGER.debug( + "BreakerGroup IID %d → name_iid=%d, breaker_position=%d", + group_iid, + name_id, + breaker_pos, + ) + except grpc.aio.AioRpcError: + _LOGGER.debug("Failed to fetch BreakerGroup for IID %d", group_iid) + + # Orphan filtering: circuits with no BreakerGroup mapping are not real + # user circuits (system/ghost metrics) — remove them + orphan_iids = [iid for iid, c in self._data.circuits.items() if c.name_iid == 0] + for iid in orphan_iids: + _LOGGER.debug("Removing orphan circuit IID %d (no BreakerGroup mapping)", iid) + del self._data.circuits[iid] + self._data.metric_iid_to_circuit.pop(iid, None) + + _LOGGER.debug( + "After BreakerGroups resolution: %d circuits remain (%d orphans removed)", + len(self._data.circuits), + len(orphan_iids), + ) # ------------------------------------------------------------------ # Circuit names @@ -656,20 +687,28 @@ def _parse_instances(self, data: bytes) -> None: async def _fetch_circuit_names(self) -> None: """Fetch circuit names from trait 16 via GetRevision. - Uses each circuit's name_iid (trait 16 instance ID) rather than - the circuit_id, since the two numbering spaces may differ. + Uses each circuit's name_iid (from BreakerGroups resolution) to look + up the human-readable name from trait 16. """ for circuit_id, info in list(self._data.circuits.items()): - name_iid = info.name_iid or circuit_id + name_iid = info.name_iid + if not name_iid: + _LOGGER.debug( + "Circuit %d has no name_iid (not resolved via BreakerGroups), " + "skipping name fetch", + circuit_id, + ) + continue try: name = await self._get_circuit_name(name_iid) if name: info.name = name _LOGGER.debug( - "Circuit %d (name_iid=%d, metric_iid=%d): %s", + "Circuit %d (name_iid=%d, metric_iid=%d, breaker_pos=%d): %s", circuit_id, name_iid, info.metric_iid, + info.breaker_position, name, ) except Exception: @@ -751,6 +790,78 @@ def _parse_circuit_name(data: bytes) -> str | None: return name.decode("utf-8", errors="replace").strip() return None + @staticmethod + def _extract_trait_ref_iid(ref_data: bytes) -> int: + """Extract an IID from a trait reference sub-message. + + Trait references use the structure: field 2 -> field 1 = iid (varint). + Returns 0 if the data cannot be parsed. + """ + if not ref_data or not isinstance(ref_data, bytes): + return 0 + ref_fields = _parse_protobuf_fields(ref_data) + iid_data = _get_field(ref_fields, 2) + if iid_data and isinstance(iid_data, bytes): + iid_fields = _parse_protobuf_fields(iid_data) + return _get_field(iid_fields, 1, 0) + return 0 + + @staticmethod + def _parse_breaker_group(data: bytes) -> tuple[int, int]: + """Parse a BreakerGroups (trait 15) GetRevision response. + + Returns (name_id, breaker_position). Both are 0 on failure. + + Single-pole (120V) groups use field 11: + f11.f1 -> CircuitNames ref (f2.f1 = name_id) + f11.f2 -> BreakerConfig ref (f2.f1 = breaker position) + + Dual-pole (240V) groups use field 13: + f13.f1.f1 -> CircuitNames ref (f2.f1 = name_id) + f13.f4 -> BreakerConfig leg A ref (f2.f1 = breaker position A) + """ + fields = _parse_protobuf_fields(data) + sr_data = _get_field(fields, 3) + if not sr_data or not isinstance(sr_data, bytes): + return 0, 0 + sr_fields = _parse_protobuf_fields(sr_data) + payload_data = _get_field(sr_fields, 2) + if not payload_data or not isinstance(payload_data, bytes): + return 0, 0 + pl_fields = _parse_protobuf_fields(payload_data) + raw = _get_field(pl_fields, 1) + if not raw or not isinstance(raw, bytes): + return 0, 0 + + group_fields = _parse_protobuf_fields(raw) + + # Single-pole (field 11) + refs_data = _get_field(group_fields, 11) + if refs_data and isinstance(refs_data, bytes): + refs = _parse_protobuf_fields(refs_data) + name_ref = _get_field(refs, 1) + config_ref = _get_field(refs, 2) + name_id = SpanGrpcClient._extract_trait_ref_iid(name_ref or b"") + brk_pos = SpanGrpcClient._extract_trait_ref_iid(config_ref or b"") + return name_id, brk_pos + + # Dual-pole (field 13) + dual_data = _get_field(group_fields, 13) + if dual_data and isinstance(dual_data, bytes): + dual_fields = _parse_protobuf_fields(dual_data) + name_id = 0 + name_wrapper = _get_field(dual_fields, 1) + if name_wrapper and isinstance(name_wrapper, bytes): + wf = _parse_protobuf_fields(name_wrapper) + name_ref = _get_field(wf, 1) + if name_ref and isinstance(name_ref, bytes): + name_id = SpanGrpcClient._extract_trait_ref_iid(name_ref) + leg_a_ref = _get_field(dual_fields, 4) + brk_pos = SpanGrpcClient._extract_trait_ref_iid(leg_a_ref or b"") + return name_id, brk_pos + + return 0, 0 + # ------------------------------------------------------------------ # Metric streaming # ------------------------------------------------------------------ diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index 032f4d1..0c5c983 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -10,6 +10,7 @@ from .coordinator import SpanPanelCoordinator from .sensors import ( SpanCircuitEnergySensor, + SpanCircuitPositionSensor, SpanCircuitPowerSensor, SpanEnergySensorBase, SpanPanelBattery, @@ -37,6 +38,7 @@ "SpanPanelEnergySensor", "SpanCircuitPowerSensor", "SpanCircuitEnergySensor", + "SpanCircuitPositionSensor", "SpanUnmappedCircuitSensor", "SpanSolarSensor", "SpanSolarEnergySensor", diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 87d3a3f..539f755 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -273,6 +273,7 @@ class SpanPanelBatterySensorEntityDescription( SpanPanelCircuitsSensorEntityDescription, SpanPanelCircuitsSensorEntityDescription, SpanPanelCircuitsSensorEntityDescription, + SpanPanelCircuitsSensorEntityDescription, ] = ( SpanPanelCircuitsSensorEntityDescription( key="circuit_power", @@ -318,6 +319,17 @@ class SpanPanelBatterySensorEntityDescription( entity_registry_enabled_default=True, entity_registry_visible_default=True, ), + SpanPanelCircuitsSensorEntityDescription( + key="circuit_panel_position", + name="Panel Position", + native_unit_of_measurement=None, + state_class=None, + suggested_display_precision=0, + device_class=None, + value_fn=lambda circuit: min(circuit.tabs) if circuit.tabs else None, + entity_registry_enabled_default=True, + entity_registry_visible_default=True, + ), ) diff --git a/custom_components/span_panel/sensors/__init__.py b/custom_components/span_panel/sensors/__init__.py index 897c13f..1280d9b 100644 --- a/custom_components/span_panel/sensors/__init__.py +++ b/custom_components/span_panel/sensors/__init__.py @@ -1,7 +1,12 @@ """Sensors package for Span Panel integration.""" from .base import SpanEnergySensorBase, SpanSensorBase -from .circuit import SpanCircuitEnergySensor, SpanCircuitPowerSensor, SpanUnmappedCircuitSensor +from .circuit import ( + SpanCircuitEnergySensor, + SpanCircuitPositionSensor, + SpanCircuitPowerSensor, + SpanUnmappedCircuitSensor, +) from .factory import create_native_sensors, enable_unmapped_tab_entities from .panel import ( SpanPanelBattery, @@ -22,6 +27,7 @@ "SpanPanelEnergySensor", "SpanCircuitPowerSensor", "SpanCircuitEnergySensor", + "SpanCircuitPositionSensor", "SpanUnmappedCircuitSensor", "SpanSolarSensor", "SpanSolarEnergySensor", diff --git a/custom_components/span_panel/sensors/circuit.py b/custom_components/span_panel/sensors/circuit.py index bb1c3dc..cdaaa30 100644 --- a/custom_components/span_panel/sensors/circuit.py +++ b/custom_components/span_panel/sensors/circuit.py @@ -286,6 +286,103 @@ def extra_state_attributes(self) -> dict[str, Any] | None: return attributes if attributes else None +class SpanCircuitPositionSensor( + SpanSensorBase[SpanPanelCircuitsSensorEntityDescription, SpanPanelCircuit] +): + """Circuit panel position (breaker slot number) sensor for Gen2 panels.""" + + def __init__( + self, + data_coordinator: SpanPanelCoordinator, + description: SpanPanelCircuitsSensorEntityDescription, + span_panel: SpanPanel, + circuit_id: str, + ) -> None: + """Initialize the circuit panel position sensor.""" + self.circuit_id = circuit_id + self.original_key = description.key + + description_with_circuit = SpanPanelCircuitsSensorEntityDescription( + key=circuit_id, + name=description.name, + native_unit_of_measurement=description.native_unit_of_measurement, + state_class=description.state_class, + suggested_display_precision=description.suggested_display_precision, + device_class=description.device_class, + value_fn=description.value_fn, + entity_registry_enabled_default=description.entity_registry_enabled_default, + entity_registry_visible_default=description.entity_registry_visible_default, + ) + + super().__init__(data_coordinator, description_with_circuit, span_panel) + + def _generate_unique_id( + self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription + ) -> str: + """Generate unique ID for circuit position sensors.""" + return construct_circuit_unique_id_for_entry( + self.coordinator, span_panel, self.circuit_id, + "circuit_panel_position", self._device_name + ) + + def _generate_friendly_name( + self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription + ) -> str | None: + """Generate friendly name for circuit position sensors.""" + circuit = span_panel.circuits.get(self.circuit_id) + if not circuit: + return construct_unmapped_friendly_name( + self.circuit_id, str(description.name or "Panel Position") + ) + + use_circuit_numbers = self.coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False) + + if use_circuit_numbers: + if circuit.tabs and len(circuit.tabs) == 2: + sorted_tabs = sorted(circuit.tabs) + circuit_identifier = f"Circuit {sorted_tabs[0]} {sorted_tabs[1]}" + elif circuit.tabs and len(circuit.tabs) == 1: + circuit_identifier = f"Circuit {circuit.tabs[0]}" + else: + circuit_identifier = f"Circuit {self.circuit_id}" + else: + if circuit.name is None: + return None + circuit_identifier = circuit.name + + return f"{circuit_identifier} {description.name or 'Panel Position'}" + + def _generate_panel_name( + self, span_panel: SpanPanel, description: SpanPanelCircuitsSensorEntityDescription + ) -> str | None: + """Generate panel name for circuit position sensors.""" + circuit = span_panel.circuits.get(self.circuit_id) + if not circuit: + return construct_unmapped_friendly_name( + self.circuit_id, str(description.name or "Panel Position") + ) + if circuit.name is None: + return None + return f"{circuit.name} {description.name or 'Panel Position'}" + + def get_data_source(self, span_panel: SpanPanel) -> SpanPanelCircuit: + """Get the data source for the circuit position sensor.""" + circuit = span_panel.circuits.get(self.circuit_id) + if circuit is None: + raise ValueError(f"Circuit {self.circuit_id} not found in panel data") + return circuit + + @property + def native_value(self) -> int | None: + """Return the breaker slot number (lowest tab position).""" + if not self.coordinator.last_update_success or not self.coordinator.data: + return None + circuit = self.coordinator.data.circuits.get(self.circuit_id) + if not circuit or not circuit.tabs: + return None + return min(circuit.tabs) + + class SpanUnmappedCircuitSensor( SpanSensorBase[SpanPanelCircuitsSensorEntityDescription, SpanPanelCircuit] ): diff --git a/custom_components/span_panel/sensors/factory.py b/custom_components/span_panel/sensors/factory.py index a939cd7..7eb6608 100644 --- a/custom_components/span_panel/sensors/factory.py +++ b/custom_components/span_panel/sensors/factory.py @@ -33,7 +33,12 @@ ) from custom_components.span_panel.span_panel import SpanPanel -from .circuit import SpanCircuitEnergySensor, SpanCircuitPowerSensor, SpanUnmappedCircuitSensor +from .circuit import ( + SpanCircuitEnergySensor, + SpanCircuitPositionSensor, + SpanCircuitPowerSensor, + SpanUnmappedCircuitSensor, +) from .panel import ( SpanPanelBattery, SpanPanelEnergySensor, @@ -83,9 +88,9 @@ def create_panel_sensors( def create_circuit_sensors( coordinator: SpanPanelCoordinator, span_panel: SpanPanel, config_entry: ConfigEntry -) -> list[SpanCircuitPowerSensor | SpanCircuitEnergySensor]: +) -> list[SpanCircuitPowerSensor | SpanCircuitEnergySensor | SpanCircuitPositionSensor]: """Create circuit-level sensors for named circuits.""" - entities: list[SpanCircuitPowerSensor | SpanCircuitEnergySensor] = [] + entities: list[SpanCircuitPowerSensor | SpanCircuitEnergySensor | SpanCircuitPositionSensor] = [] # Add circuit sensors for all named circuits (replacing synthetic ones) named_circuits = [cid for cid in span_panel.circuits if not cid.startswith("unmapped_tab_")] @@ -106,6 +111,13 @@ def create_circuit_sensors( entities.append( SpanCircuitPowerSensor(coordinator, circuit_description, span_panel, circuit_id) ) + elif circuit_description.key == "circuit_panel_position": + # Use position sensor for breaker slot number + entities.append( + SpanCircuitPositionSensor( + coordinator, circuit_description, span_panel, circuit_id + ) + ) else: # Use energy sensor with grace period tracking for energy measurements entities.append( @@ -230,6 +242,7 @@ def create_native_sensors( | SpanPanelEnergySensor | SpanCircuitPowerSensor | SpanCircuitEnergySensor + | SpanCircuitPositionSensor | SpanUnmappedCircuitSensor | SpanPanelBattery | SpanSolarSensor @@ -243,6 +256,7 @@ def create_native_sensors( | SpanPanelEnergySensor | SpanCircuitPowerSensor | SpanCircuitEnergySensor + | SpanCircuitPositionSensor | SpanUnmappedCircuitSensor | SpanPanelBattery | SpanSolarSensor From 58c472e796ca5c7914c2bad04f2518324135bdef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 21:30:11 -0500 Subject: [PATCH 4/8] Add dual-phase detection from BreakerGroup field type The _parse_breaker_group method already distinguishes field 11 (single-pole) from field 13 (dual-pole) but wasn't propagating that info. Now returns is_dual_phase and sets it on CircuitInfo. Tested on MAIN 40: correctly identifies Furnace, Electric dryer, Water heater, and Electric range as 240V dual-phase circuits. --- .../span_panel/gen3/span_grpc_client.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/custom_components/span_panel/gen3/span_grpc_client.py b/custom_components/span_panel/gen3/span_grpc_client.py index 9c8b373..3e055fa 100644 --- a/custom_components/span_panel/gen3/span_grpc_client.py +++ b/custom_components/span_panel/gen3/span_grpc_client.py @@ -652,16 +652,18 @@ async def _fetch_breaker_groups(self) -> None: request_serializer=lambda x: x, response_deserializer=lambda x: x, )(request) - name_id, breaker_pos = self._parse_breaker_group(response) + name_id, breaker_pos, is_dual = self._parse_breaker_group(response) circuit = self._data.circuits[group_iid] circuit.name_iid = name_id + circuit.is_dual_phase = is_dual if breaker_pos > 0: circuit.breaker_position = breaker_pos _LOGGER.debug( - "BreakerGroup IID %d → name_iid=%d, breaker_position=%d", + "BreakerGroup IID %d → name_iid=%d, breaker_position=%d, dual=%s", group_iid, name_id, breaker_pos, + is_dual, ) except grpc.aio.AioRpcError: _LOGGER.debug("Failed to fetch BreakerGroup for IID %d", group_iid) @@ -807,10 +809,11 @@ def _extract_trait_ref_iid(ref_data: bytes) -> int: return 0 @staticmethod - def _parse_breaker_group(data: bytes) -> tuple[int, int]: + def _parse_breaker_group(data: bytes) -> tuple[int, int, bool]: """Parse a BreakerGroups (trait 15) GetRevision response. - Returns (name_id, breaker_position). Both are 0 on failure. + Returns (name_id, breaker_position, is_dual_phase). All zero/False + on failure. Single-pole (120V) groups use field 11: f11.f1 -> CircuitNames ref (f2.f1 = name_id) @@ -823,15 +826,15 @@ def _parse_breaker_group(data: bytes) -> tuple[int, int]: fields = _parse_protobuf_fields(data) sr_data = _get_field(fields, 3) if not sr_data or not isinstance(sr_data, bytes): - return 0, 0 + return 0, 0, False sr_fields = _parse_protobuf_fields(sr_data) payload_data = _get_field(sr_fields, 2) if not payload_data or not isinstance(payload_data, bytes): - return 0, 0 + return 0, 0, False pl_fields = _parse_protobuf_fields(payload_data) raw = _get_field(pl_fields, 1) if not raw or not isinstance(raw, bytes): - return 0, 0 + return 0, 0, False group_fields = _parse_protobuf_fields(raw) @@ -843,7 +846,7 @@ def _parse_breaker_group(data: bytes) -> tuple[int, int]: config_ref = _get_field(refs, 2) name_id = SpanGrpcClient._extract_trait_ref_iid(name_ref or b"") brk_pos = SpanGrpcClient._extract_trait_ref_iid(config_ref or b"") - return name_id, brk_pos + return name_id, brk_pos, False # Dual-pole (field 13) dual_data = _get_field(group_fields, 13) @@ -858,9 +861,9 @@ def _parse_breaker_group(data: bytes) -> tuple[int, int]: name_id = SpanGrpcClient._extract_trait_ref_iid(name_ref) leg_a_ref = _get_field(dual_fields, 4) brk_pos = SpanGrpcClient._extract_trait_ref_iid(leg_a_ref or b"") - return name_id, brk_pos + return name_id, brk_pos, True - return 0, 0 + return 0, 0, False # ------------------------------------------------------------------ # Metric streaming From 8f8fad506c4201684e64957d03025268b49a700b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 10:49:47 -0500 Subject: [PATCH 5/8] style: fix ruff formatting across 7 files --- custom_components/span_panel/config_flow.py | 4 +--- custom_components/span_panel/gen3/binary_sensors.py | 12 +++--------- custom_components/span_panel/gen3/sensors.py | 4 +--- .../span_panel/gen3/span_grpc_client.py | 6 ++---- custom_components/span_panel/sensor_definitions.py | 12 ++++++++---- custom_components/span_panel/sensors/circuit.py | 7 +++++-- custom_components/span_panel/sensors/factory.py | 4 +++- 7 files changed, 23 insertions(+), 26 deletions(-) diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index b32163c..6892ec5 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -285,9 +285,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con if not await validate_host(self.hass, host, use_ssl=use_ssl): # REST failed — try Gen3 gRPC as fallback if await self._test_gen3_connection(host): - _LOGGER.info( - "Gen3 panel detected at %s (REST unavailable, gRPC OK)", host - ) + _LOGGER.info("Gen3 panel detected at %s (REST unavailable, gRPC OK)", host) # Gen3 panels don't need auth — create entry directly await self.async_set_unique_id(host) self._abort_if_unique_id_configured() diff --git a/custom_components/span_panel/gen3/binary_sensors.py b/custom_components/span_panel/gen3/binary_sensors.py index 7de31bf..139a929 100644 --- a/custom_components/span_panel/gen3/binary_sensors.py +++ b/custom_components/span_panel/gen3/binary_sensors.py @@ -32,16 +32,12 @@ def create_gen3_binary_sensors( entities: list[BinarySensorEntity] = [] for circuit_id in data.circuits: - entities.append( - SpanGen3BreakerSensor(coordinator, host, circuit_id) - ) + entities.append(SpanGen3BreakerSensor(coordinator, host, circuit_id)) return entities -class SpanGen3BreakerSensor( - CoordinatorEntity[SpanGen3Coordinator], BinarySensorEntity -): +class SpanGen3BreakerSensor(CoordinatorEntity[SpanGen3Coordinator], BinarySensorEntity): """Binary sensor for breaker state (ON/OFF based on voltage).""" _attr_has_entity_name = True @@ -66,9 +62,7 @@ def device_info(self) -> DeviceInfo: info = self.coordinator.data.circuits.get(self._circuit_id) name = info.name if info else f"Circuit {self._circuit_id}" return DeviceInfo( - identifiers={ - (DOMAIN, f"{self._host}_circuit_{self._circuit_id}") - }, + identifiers={(DOMAIN, f"{self._host}_circuit_{self._circuit_id}")}, name=name, manufacturer="Span", model="Circuit Breaker", diff --git a/custom_components/span_panel/gen3/sensors.py b/custom_components/span_panel/gen3/sensors.py index 594cc82..44f6e75 100644 --- a/custom_components/span_panel/gen3/sensors.py +++ b/custom_components/span_panel/gen3/sensors.py @@ -121,9 +121,7 @@ def device_info(self) -> DeviceInfo: info = self._circuit_info name = info.name if info else f"Circuit {self._circuit_id}" return DeviceInfo( - identifiers={ - (DOMAIN, f"{self._host}_circuit_{self._circuit_id}") - }, + identifiers={(DOMAIN, f"{self._host}_circuit_{self._circuit_id}")}, name=name, manufacturer="Span", model="Circuit Breaker", diff --git a/custom_components/span_panel/gen3/span_grpc_client.py b/custom_components/span_panel/gen3/span_grpc_client.py index 3e055fa..448178a 100644 --- a/custom_components/span_panel/gen3/span_grpc_client.py +++ b/custom_components/span_panel/gen3/span_grpc_client.py @@ -455,9 +455,7 @@ async def connect(self) -> bool: ) return True except Exception: - _LOGGER.exception( - "Failed to connect to Gen3 panel at %s:%s", self._host, self._port - ) + _LOGGER.exception("Failed to connect to Gen3 panel at %s:%s", self._host, self._port) self._connected = False return False @@ -535,7 +533,7 @@ def _parse_instances(self, data: bytes) -> None: fields = _parse_protobuf_fields(data) items = fields.get(1, []) - metric_iids: list[int] = [] # Trait 26 instance IDs (excl main feed) + metric_iids: list[int] = [] # Trait 26 instance IDs (excl main feed) for item_data in items: if not isinstance(item_data, bytes): diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 539f755..a18b58b 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -252,8 +252,10 @@ class SpanPanelBatterySensorEntityDescription( state_class=SensorStateClass.TOTAL, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda panel_data: (panel_data.main_meter_energy_consumed or 0) - - (panel_data.main_meter_energy_produced or 0), + value_fn=lambda panel_data: ( + (panel_data.main_meter_energy_consumed or 0) + - (panel_data.main_meter_energy_produced or 0) + ), ), SpanPanelDataSensorEntityDescription( key="feedthroughNetEnergyWh", @@ -262,8 +264,10 @@ class SpanPanelBatterySensorEntityDescription( state_class=SensorStateClass.TOTAL, suggested_display_precision=2, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda panel_data: (panel_data.feedthrough_energy_consumed or 0) - - (panel_data.feedthrough_energy_produced or 0), + value_fn=lambda panel_data: ( + (panel_data.feedthrough_energy_consumed or 0) + - (panel_data.feedthrough_energy_produced or 0) + ), ), ) diff --git a/custom_components/span_panel/sensors/circuit.py b/custom_components/span_panel/sensors/circuit.py index cdaaa30..12cc572 100644 --- a/custom_components/span_panel/sensors/circuit.py +++ b/custom_components/span_panel/sensors/circuit.py @@ -321,8 +321,11 @@ def _generate_unique_id( ) -> str: """Generate unique ID for circuit position sensors.""" return construct_circuit_unique_id_for_entry( - self.coordinator, span_panel, self.circuit_id, - "circuit_panel_position", self._device_name + self.coordinator, + span_panel, + self.circuit_id, + "circuit_panel_position", + self._device_name, ) def _generate_friendly_name( diff --git a/custom_components/span_panel/sensors/factory.py b/custom_components/span_panel/sensors/factory.py index 7eb6608..aa4b3e4 100644 --- a/custom_components/span_panel/sensors/factory.py +++ b/custom_components/span_panel/sensors/factory.py @@ -90,7 +90,9 @@ def create_circuit_sensors( coordinator: SpanPanelCoordinator, span_panel: SpanPanel, config_entry: ConfigEntry ) -> list[SpanCircuitPowerSensor | SpanCircuitEnergySensor | SpanCircuitPositionSensor]: """Create circuit-level sensors for named circuits.""" - entities: list[SpanCircuitPowerSensor | SpanCircuitEnergySensor | SpanCircuitPositionSensor] = [] + entities: list[ + SpanCircuitPowerSensor | SpanCircuitEnergySensor | SpanCircuitPositionSensor + ] = [] # Add circuit sensors for all named circuits (replacing synthetic ones) named_circuits = [cid for cid in span_panel.circuits if not cid.startswith("unmapped_tab_")] From 4689fd2ac0521ca294d7283a9697160a460bed7c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 20:09:52 -0500 Subject: [PATCH 6/8] style: fix all ruff lint errors in gen3 module - Replace relative parent imports with absolute imports (TID252) - Remove unused imports: DOMAIN in coordinator, METRIC_IID_OFFSET in client (F401) - Add missing docstrings to sensor __init__ and native_value methods (D107/D102) - Use contextlib.suppress instead of try/except/pass (SIM105) - Fix docstring to imperative mood (D401) - Auto-fix import sorting (I001) --- .../span_panel/gen3/binary_sensors.py | 3 ++- .../span_panel/gen3/coordinator.py | 3 +-- custom_components/span_panel/gen3/sensors.py | 19 ++++++++++++++++++- .../span_panel/gen3/span_grpc_client.py | 16 ++++++---------- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/custom_components/span_panel/gen3/binary_sensors.py b/custom_components/span_panel/gen3/binary_sensors.py index 139a929..054e722 100644 --- a/custom_components/span_panel/gen3/binary_sensors.py +++ b/custom_components/span_panel/gen3/binary_sensors.py @@ -16,7 +16,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from ..const import DOMAIN +from custom_components.span_panel.const import DOMAIN + from .coordinator import SpanGen3Coordinator from .span_grpc_client import PanelData diff --git a/custom_components/span_panel/gen3/coordinator.py b/custom_components/span_panel/gen3/coordinator.py index c14dfd5..e7d5093 100644 --- a/custom_components/span_panel/gen3/coordinator.py +++ b/custom_components/span_panel/gen3/coordinator.py @@ -8,14 +8,13 @@ from __future__ import annotations -import logging from datetime import timedelta +import logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from ..const import DOMAIN from .const import DEFAULT_GRPC_PORT from .span_grpc_client import PanelData, SpanGrpcClient diff --git a/custom_components/span_panel/gen3/sensors.py b/custom_components/span_panel/gen3/sensors.py index 44f6e75..8cff61c 100644 --- a/custom_components/span_panel/gen3/sensors.py +++ b/custom_components/span_panel/gen3/sensors.py @@ -23,7 +23,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from ..const import DOMAIN +from custom_components.span_panel.const import DOMAIN + from .coordinator import SpanGen3Coordinator from .span_grpc_client import PanelData @@ -142,12 +143,14 @@ class SpanGen3MainPowerSensor(SpanGen3SensorBase): _attr_suggested_display_precision = 0 def __init__(self, coordinator, host): + """Initialize the main feed power sensor.""" super().__init__(coordinator, host) self._attr_unique_id = f"{host}_gen3_main_power" self._attr_name = "Main Feed Power" @property def native_value(self) -> float | None: + """Return the current power reading.""" m = self.coordinator.data.main_feed return round(m.power_w, 1) if m else None @@ -160,12 +163,14 @@ class SpanGen3MainVoltageSensor(SpanGen3SensorBase): _attr_suggested_display_precision = 1 def __init__(self, coordinator, host): + """Initialize the main feed voltage sensor.""" super().__init__(coordinator, host) self._attr_unique_id = f"{host}_gen3_main_voltage" self._attr_name = "Main Feed Voltage" @property def native_value(self) -> float | None: + """Return the current voltage reading.""" m = self.coordinator.data.main_feed return round(m.voltage_v, 1) if m else None @@ -178,12 +183,14 @@ class SpanGen3MainCurrentSensor(SpanGen3SensorBase): _attr_suggested_display_precision = 1 def __init__(self, coordinator, host): + """Initialize the main feed current sensor.""" super().__init__(coordinator, host) self._attr_unique_id = f"{host}_gen3_main_current" self._attr_name = "Main Feed Current" @property def native_value(self) -> float | None: + """Return the current amperage reading.""" m = self.coordinator.data.main_feed return round(m.current_a, 1) if m else None @@ -196,12 +203,14 @@ class SpanGen3MainFrequencySensor(SpanGen3SensorBase): _attr_suggested_display_precision = 2 def __init__(self, coordinator, host): + """Initialize the main feed frequency sensor.""" super().__init__(coordinator, host) self._attr_unique_id = f"{host}_gen3_main_frequency" self._attr_name = "Main Feed Frequency" @property def native_value(self) -> float | None: + """Return the current frequency reading.""" m = self.coordinator.data.main_feed return round(m.frequency_hz, 2) if m and m.frequency_hz > 0 else None @@ -219,12 +228,14 @@ class SpanGen3CircuitPowerSensor(SpanGen3CircuitSensorBase): _attr_suggested_display_precision = 0 def __init__(self, coordinator, host, circuit_id): + """Initialize the circuit power sensor.""" super().__init__(coordinator, host, circuit_id) self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_power" self._attr_name = "Power" @property def native_value(self) -> float | None: + """Return the current power reading.""" m = self._circuit_metrics return round(m.power_w, 1) if m else None @@ -237,12 +248,14 @@ class SpanGen3CircuitVoltageSensor(SpanGen3CircuitSensorBase): _attr_suggested_display_precision = 1 def __init__(self, coordinator, host, circuit_id): + """Initialize the circuit voltage sensor.""" super().__init__(coordinator, host, circuit_id) self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_voltage" self._attr_name = "Voltage" @property def native_value(self) -> float | None: + """Return the current voltage reading.""" m = self._circuit_metrics return round(m.voltage_v, 1) if m else None @@ -255,12 +268,14 @@ class SpanGen3CircuitCurrentSensor(SpanGen3CircuitSensorBase): _attr_suggested_display_precision = 2 def __init__(self, coordinator, host, circuit_id): + """Initialize the circuit current sensor.""" super().__init__(coordinator, host, circuit_id) self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_current" self._attr_name = "Current" @property def native_value(self) -> float | None: + """Return the current amperage reading.""" m = self._circuit_metrics return round(m.current_a, 3) if m else None @@ -272,12 +287,14 @@ class SpanGen3CircuitPositionSensor(SpanGen3CircuitSensorBase): _attr_state_class = None # Static configuration value, not a time-series measurement def __init__(self, coordinator, host, circuit_id): + """Initialize the circuit position sensor.""" super().__init__(coordinator, host, circuit_id) self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_position" self._attr_name = "Panel Position" @property def native_value(self) -> int | None: + """Return the breaker slot number.""" info = self._circuit_info if info is None: return None diff --git a/custom_components/span_panel/gen3/span_grpc_client.py b/custom_components/span_panel/gen3/span_grpc_client.py index 448178a..d56357b 100644 --- a/custom_components/span_panel/gen3/span_grpc_client.py +++ b/custom_components/span_panel/gen3/span_grpc_client.py @@ -11,10 +11,11 @@ from __future__ import annotations import asyncio -import logging -import struct from collections.abc import Callable +import contextlib from dataclasses import dataclass, field +import logging +import struct import grpc @@ -22,7 +23,6 @@ BREAKER_OFF_VOLTAGE_MV, DEFAULT_GRPC_PORT, MAIN_FEED_IID, - METRIC_IID_OFFSET, PRODUCT_GEN3_PANEL, TRAIT_BREAKER_GROUPS, TRAIT_CIRCUIT_NAMES, @@ -464,10 +464,8 @@ async def disconnect(self) -> None: self._connected = False if self._stream_task and not self._stream_task.done(): self._stream_task.cancel() - try: + with contextlib.suppress(asyncio.CancelledError): await self._stream_task - except asyncio.CancelledError: - pass if self._channel: await self._channel.close() self._channel = None @@ -482,10 +480,8 @@ async def stop_streaming(self) -> None: """Stop the metric streaming task.""" if self._stream_task and not self._stream_task.done(): self._stream_task.cancel() - try: + with contextlib.suppress(asyncio.CancelledError): await self._stream_task - except asyncio.CancelledError: - pass async def test_connection(self) -> bool: """Test if we can connect to the panel (static method-like).""" @@ -868,7 +864,7 @@ def _parse_breaker_group(data: bytes) -> tuple[int, int, bool]: # ------------------------------------------------------------------ async def _stream_loop(self) -> None: - """Main streaming loop with automatic reconnection.""" + """Run the main streaming loop with automatic reconnection.""" while self._connected: try: await self._subscribe_stream() From 775c9e84b32e54973229dbbffc7759560740245b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 20:22:21 -0500 Subject: [PATCH 7/8] style: ignore UP046 (PEP 695 type params) in ruff config target-version is py311 which doesn't support PEP 695 type parameter syntax. Ignore UP046 until target-version is bumped to py312+. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 1b7b426..f6709d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -162,6 +162,7 @@ select = [ ignore = [ "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring + "UP046", # PEP 695 type params — target-version is py311, needs py312+ "D213", # Multi-line docstring summary should start at the second line "D406", # Section name should end with a newline "D407", # Section name underlining From ef8e369f0b83d762a595a185a36f1cc0a471d45f Mon Sep 17 00:00:00 2001 From: cayossarian Date: Thu, 19 Feb 2026 17:54:33 -0800 Subject: [PATCH 8/8] fix: resolve all mypy and pylint CI failures for Gen3 gRPC integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix pylint C0415 (import-outside-toplevel): move disable comment to the `from ... import (` line in __init__.py, sensor.py, binary_sensor.py, and config_flow.py so pylint recognizes the suppression correctly - Fix 48 mypy errors across gen3/sensors.py, gen3/span_grpc_client.py, gen3/binary_sensors.py, sensor_definitions.py, binary_sensor.py, sensor.py, and sensors/circuit.py: - Add type annotations to all untyped __init__ methods in gen3/sensors.py - Use `SensorDeviceClass | None` + `# type: ignore[mutable-override]` to resolve HA entity hierarchy type contradiction - Add `assert self._channel is not None` guards for union-attr errors - Fix _parse_protobuf_fields and _get_field return types with Any - Fix no-any-return errors with explicit typed intermediate variables - Change value_fn type to `Callable[[SpanPanelCircuit], float | None]` - Fix no-redef errors by renaming coordinator → gen3_coordinator - Apply PEP 695 generic class syntax to SpanPanelBinarySensor, SpanSensorBase, and SpanEnergySensorBase (ruff UP046) - Update pyproject.toml python constraint to `>=3.13.2,<3.15` to allow Python 3.14.x; update ruff target-version to py313 --- custom_components/span_panel/__init__.py | 4 +- custom_components/span_panel/binary_sensor.py | 16 ++--- custom_components/span_panel/config_flow.py | 4 +- .../span_panel/gen3/binary_sensors.py | 5 +- custom_components/span_panel/gen3/sensors.py | 66 ++++++++++--------- .../span_panel/gen3/span_grpc_client.py | 18 +++-- custom_components/span_panel/sensor.py | 8 ++- .../span_panel/sensor_definitions.py | 2 +- custom_components/span_panel/sensors/base.py | 11 ++-- .../span_panel/sensors/circuit.py | 3 +- poetry.lock | 18 +++-- pyproject.toml | 5 +- 12 files changed, 91 insertions(+), 69 deletions(-) diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 49f16f8..bb61e9d 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -342,7 +342,9 @@ async def _test_authenticated_connection() -> None: async def _async_setup_gen3_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Gen3 panel via gRPC.""" - from .gen3.coordinator import SpanGen3Coordinator # noqa: E402 + from .gen3.coordinator import ( # pylint: disable=import-outside-toplevel + SpanGen3Coordinator, # noqa: E402 + ) coordinator = SpanGen3Coordinator(hass, entry) if not await coordinator.async_setup(): diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py index 5d7bc3a..0698fb5 100644 --- a/custom_components/span_panel/binary_sensor.py +++ b/custom_components/span_panel/binary_sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, Generic, TypeVar +from typing import Any from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -105,11 +105,9 @@ class SpanPanelBinarySensorEntityDescription( ), ) -T = TypeVar("T", bound=SpanPanelBinarySensorEntityDescription) - -class SpanPanelBinarySensor( - CoordinatorEntity[SpanPanelCoordinator], BinarySensorEntity, Generic[T] +class SpanPanelBinarySensor[T: SpanPanelBinarySensorEntityDescription]( + CoordinatorEntity[SpanPanelCoordinator], BinarySensorEntity ): """Binary Sensor status entity.""" @@ -261,10 +259,12 @@ async def async_setup_entry( # Gen3 path — use Gen3 binary sensor factory if config_entry.data.get(CONF_PANEL_GEN) == "gen3": - from .gen3.binary_sensors import create_gen3_binary_sensors # noqa: E402 + from .gen3.binary_sensors import ( # pylint: disable=import-outside-toplevel + create_gen3_binary_sensors, # noqa: E402 + ) - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - async_add_entities(create_gen3_binary_sensors(coordinator)) + gen3_coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + async_add_entities(create_gen3_binary_sensors(gen3_coordinator)) return _LOGGER.debug("ASYNC SETUP ENTRY BINARYSENSOR") diff --git a/custom_components/span_panel/config_flow.py b/custom_components/span_panel/config_flow.py index 6892ec5..a1835aa 100644 --- a/custom_components/span_panel/config_flow.py +++ b/custom_components/span_panel/config_flow.py @@ -316,7 +316,9 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con async def _test_gen3_connection(self, host: str) -> bool: """Test if the host is a Gen3 panel via gRPC on port 50065.""" try: - from .gen3.span_grpc_client import SpanGrpcClient # noqa: E402 + from .gen3.span_grpc_client import ( # pylint: disable=import-outside-toplevel + SpanGrpcClient, # noqa: E402 + ) client = SpanGrpcClient(host) return await client.test_connection() diff --git a/custom_components/span_panel/gen3/binary_sensors.py b/custom_components/span_panel/gen3/binary_sensors.py index 054e722..1f72ba0 100644 --- a/custom_components/span_panel/gen3/binary_sensors.py +++ b/custom_components/span_panel/gen3/binary_sensors.py @@ -42,7 +42,7 @@ class SpanGen3BreakerSensor(CoordinatorEntity[SpanGen3Coordinator], BinarySensor """Binary sensor for breaker state (ON/OFF based on voltage).""" _attr_has_entity_name = True - _attr_device_class = BinarySensorDeviceClass.POWER + _attr_device_class: BinarySensorDeviceClass | None = BinarySensorDeviceClass.POWER def __init__( self, @@ -76,4 +76,5 @@ def is_on(self) -> bool | None: m = self.coordinator.data.metrics.get(self._circuit_id) if m is None: return None - return m.is_on + is_on: bool = m.is_on + return is_on diff --git a/custom_components/span_panel/gen3/sensors.py b/custom_components/span_panel/gen3/sensors.py index 8cff61c..bdfaac0 100644 --- a/custom_components/span_panel/gen3/sensors.py +++ b/custom_components/span_panel/gen3/sensors.py @@ -75,7 +75,7 @@ class SpanGen3SensorBase(CoordinatorEntity[SpanGen3Coordinator], SensorEntity): """Base class for Gen3 sensors.""" _attr_has_entity_name = True - _attr_state_class = SensorStateClass.MEASUREMENT + _attr_state_class: str | None = SensorStateClass.MEASUREMENT def __init__(self, coordinator: SpanGen3Coordinator, host: str) -> None: """Initialize the sensor.""" @@ -138,11 +138,11 @@ def device_info(self) -> DeviceInfo: class SpanGen3MainPowerSensor(SpanGen3SensorBase): """Main feed power sensor.""" - _attr_device_class = SensorDeviceClass.POWER - _attr_native_unit_of_measurement = UnitOfPower.WATT - _attr_suggested_display_precision = 0 + _attr_device_class: SensorDeviceClass | None = SensorDeviceClass.POWER # type: ignore[mutable-override] + _attr_native_unit_of_measurement: str | None = UnitOfPower.WATT + _attr_suggested_display_precision: int | None = 0 - def __init__(self, coordinator, host): + def __init__(self, coordinator: SpanGen3Coordinator, host: str) -> None: """Initialize the main feed power sensor.""" super().__init__(coordinator, host) self._attr_unique_id = f"{host}_gen3_main_power" @@ -158,11 +158,11 @@ def native_value(self) -> float | None: class SpanGen3MainVoltageSensor(SpanGen3SensorBase): """Main feed voltage sensor.""" - _attr_device_class = SensorDeviceClass.VOLTAGE - _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT - _attr_suggested_display_precision = 1 + _attr_device_class: SensorDeviceClass | None = SensorDeviceClass.VOLTAGE # type: ignore[mutable-override] + _attr_native_unit_of_measurement: str | None = UnitOfElectricPotential.VOLT + _attr_suggested_display_precision: int | None = 1 - def __init__(self, coordinator, host): + def __init__(self, coordinator: SpanGen3Coordinator, host: str) -> None: """Initialize the main feed voltage sensor.""" super().__init__(coordinator, host) self._attr_unique_id = f"{host}_gen3_main_voltage" @@ -178,11 +178,11 @@ def native_value(self) -> float | None: class SpanGen3MainCurrentSensor(SpanGen3SensorBase): """Main feed current sensor.""" - _attr_device_class = SensorDeviceClass.CURRENT - _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE - _attr_suggested_display_precision = 1 + _attr_device_class: SensorDeviceClass | None = SensorDeviceClass.CURRENT # type: ignore[mutable-override] + _attr_native_unit_of_measurement: str | None = UnitOfElectricCurrent.AMPERE + _attr_suggested_display_precision: int | None = 1 - def __init__(self, coordinator, host): + def __init__(self, coordinator: SpanGen3Coordinator, host: str) -> None: """Initialize the main feed current sensor.""" super().__init__(coordinator, host) self._attr_unique_id = f"{host}_gen3_main_current" @@ -198,11 +198,11 @@ def native_value(self) -> float | None: class SpanGen3MainFrequencySensor(SpanGen3SensorBase): """Main feed frequency sensor.""" - _attr_device_class = SensorDeviceClass.FREQUENCY - _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ - _attr_suggested_display_precision = 2 + _attr_device_class: SensorDeviceClass | None = SensorDeviceClass.FREQUENCY # type: ignore[mutable-override] + _attr_native_unit_of_measurement: str | None = UnitOfFrequency.HERTZ + _attr_suggested_display_precision: int | None = 2 - def __init__(self, coordinator, host): + def __init__(self, coordinator: SpanGen3Coordinator, host: str) -> None: """Initialize the main feed frequency sensor.""" super().__init__(coordinator, host) self._attr_unique_id = f"{host}_gen3_main_frequency" @@ -223,11 +223,11 @@ def native_value(self) -> float | None: class SpanGen3CircuitPowerSensor(SpanGen3CircuitSensorBase): """Per-circuit power sensor.""" - _attr_device_class = SensorDeviceClass.POWER - _attr_native_unit_of_measurement = UnitOfPower.WATT - _attr_suggested_display_precision = 0 + _attr_device_class: SensorDeviceClass | None = SensorDeviceClass.POWER # type: ignore[mutable-override] + _attr_native_unit_of_measurement: str | None = UnitOfPower.WATT + _attr_suggested_display_precision: int | None = 0 - def __init__(self, coordinator, host, circuit_id): + def __init__(self, coordinator: SpanGen3Coordinator, host: str, circuit_id: int) -> None: """Initialize the circuit power sensor.""" super().__init__(coordinator, host, circuit_id) self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_power" @@ -243,11 +243,11 @@ def native_value(self) -> float | None: class SpanGen3CircuitVoltageSensor(SpanGen3CircuitSensorBase): """Per-circuit voltage sensor.""" - _attr_device_class = SensorDeviceClass.VOLTAGE - _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT - _attr_suggested_display_precision = 1 + _attr_device_class: SensorDeviceClass | None = SensorDeviceClass.VOLTAGE # type: ignore[mutable-override] + _attr_native_unit_of_measurement: str | None = UnitOfElectricPotential.VOLT + _attr_suggested_display_precision: int | None = 1 - def __init__(self, coordinator, host, circuit_id): + def __init__(self, coordinator: SpanGen3Coordinator, host: str, circuit_id: int) -> None: """Initialize the circuit voltage sensor.""" super().__init__(coordinator, host, circuit_id) self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_voltage" @@ -263,11 +263,11 @@ def native_value(self) -> float | None: class SpanGen3CircuitCurrentSensor(SpanGen3CircuitSensorBase): """Per-circuit current sensor.""" - _attr_device_class = SensorDeviceClass.CURRENT - _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE - _attr_suggested_display_precision = 2 + _attr_device_class: SensorDeviceClass | None = SensorDeviceClass.CURRENT # type: ignore[mutable-override] + _attr_native_unit_of_measurement: str | None = UnitOfElectricCurrent.AMPERE + _attr_suggested_display_precision: int | None = 2 - def __init__(self, coordinator, host, circuit_id): + def __init__(self, coordinator: SpanGen3Coordinator, host: str, circuit_id: int) -> None: """Initialize the circuit current sensor.""" super().__init__(coordinator, host, circuit_id) self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_current" @@ -283,10 +283,12 @@ def native_value(self) -> float | None: class SpanGen3CircuitPositionSensor(SpanGen3CircuitSensorBase): """Per-circuit panel position (breaker slot number) sensor.""" - _attr_icon = "mdi:electric-switch" - _attr_state_class = None # Static configuration value, not a time-series measurement + _attr_icon: str | None = "mdi:electric-switch" + _attr_state_class: str | None = ( + None # Static configuration value, not a time-series measurement + ) - def __init__(self, coordinator, host, circuit_id): + def __init__(self, coordinator: SpanGen3Coordinator, host: str, circuit_id: int) -> None: """Initialize the circuit position sensor.""" super().__init__(coordinator, host, circuit_id) self._attr_unique_id = f"{host}_gen3_circuit_{circuit_id}_position" diff --git a/custom_components/span_panel/gen3/span_grpc_client.py b/custom_components/span_panel/gen3/span_grpc_client.py index d56357b..6dc87a5 100644 --- a/custom_components/span_panel/gen3/span_grpc_client.py +++ b/custom_components/span_panel/gen3/span_grpc_client.py @@ -16,6 +16,7 @@ from dataclasses import dataclass, field import logging import struct +from typing import Any import grpc @@ -105,15 +106,16 @@ def _decode_varint(data: bytes, offset: int) -> tuple[int, int]: return result, offset -def _parse_protobuf_fields(data: bytes) -> dict[int, list]: +def _parse_protobuf_fields(data: bytes) -> dict[int, list[Any]]: """Parse raw protobuf bytes into a dict of field_number -> [values].""" - fields: dict[int, list] = {} + fields: dict[int, list[Any]] = {} offset = 0 while offset < len(data): tag, offset = _decode_varint(data, offset) field_num = tag >> 3 wire_type = tag & 0x07 + value: int | bytes if wire_type == 0: # varint value, offset = _decode_varint(data, offset) elif wire_type == 1: # 64-bit @@ -139,7 +141,7 @@ def _parse_protobuf_fields(data: bytes) -> dict[int, list]: return fields -def _get_field(fields: dict, num: int, default=None): +def _get_field(fields: dict[int, list[Any]], num: int, default: Any = None) -> Any: """Get first value for a field number.""" vals = fields.get(num) return vals[0] if vals else default @@ -511,6 +513,7 @@ async def test_connection(self) -> bool: async def _fetch_instances(self) -> None: """Fetch all trait instances to discover circuits.""" + assert self._channel is not None response = await self._channel.unary_unary( _GET_INSTANCES, request_serializer=lambda x: x, @@ -630,6 +633,7 @@ async def _fetch_breaker_groups(self) -> None: Circuits with no BreakerGroup mapping (orphans) are removed — they are system/ghost metrics with no physical breaker. """ + assert self._channel is not None for group_iid in self._data.breaker_group_iids: if group_iid not in self._data.circuits: # BreakerGroup IID without a matching PowerMetrics instance — skip @@ -716,6 +720,7 @@ async def _fetch_circuit_names(self) -> None: async def _get_circuit_name(self, circuit_id: int) -> str | None: """Get a single circuit name via GetRevision on trait 16.""" + assert self._channel is not None request = self._build_get_revision_request( vendor_id=VENDOR_SPAN, product_id=PRODUCT_GEN3_PANEL, @@ -783,7 +788,8 @@ def _parse_circuit_name(data: bytes) -> str | None: name_fields = _parse_protobuf_fields(raw) name = _get_field(name_fields, 4) if name and isinstance(name, bytes): - return name.decode("utf-8", errors="replace").strip() + decoded: str = name.decode("utf-8", errors="replace").strip() + return decoded return None @staticmethod @@ -799,7 +805,8 @@ def _extract_trait_ref_iid(ref_data: bytes) -> int: iid_data = _get_field(ref_fields, 2) if iid_data and isinstance(iid_data, bytes): iid_fields = _parse_protobuf_fields(iid_data) - return _get_field(iid_fields, 1, 0) + val = _get_field(iid_fields, 1, 0) + return val if isinstance(val, int) else 0 return 0 @staticmethod @@ -876,6 +883,7 @@ async def _stream_loop(self) -> None: async def _subscribe_stream(self) -> None: """Subscribe to the gRPC stream and process updates.""" + assert self._channel is not None call = self._channel.unary_stream( _SUBSCRIBE, request_serializer=lambda x: x, diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py index 0c5c983..2a6c87a 100644 --- a/custom_components/span_panel/sensor.py +++ b/custom_components/span_panel/sensor.py @@ -59,10 +59,12 @@ async def async_setup_entry( """Set up sensor platform.""" # Gen3 path — use Gen3 sensor factory if config_entry.data.get(CONF_PANEL_GEN) == "gen3": - from .gen3.sensors import create_gen3_sensors # noqa: E402 + from .gen3.sensors import ( # pylint: disable=import-outside-toplevel + create_gen3_sensors, # noqa: E402 + ) - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - async_add_entities(create_gen3_sensors(coordinator)) + gen3_coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + async_add_entities(create_gen3_sensors(gen3_coordinator)) return try: diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index a18b58b..0d944ec 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -36,7 +36,7 @@ class SpanPanelCircuitsRequiredKeysMixin: """Required keys mixin for Span Panel circuit sensors.""" - value_fn: Callable[[SpanPanelCircuit], float] + value_fn: Callable[[SpanPanelCircuit], float | None] @dataclass(frozen=True) diff --git a/custom_components/span_panel/sensors/base.py b/custom_components/span_panel/sensors/base.py index c53d44e..b544261 100644 --- a/custom_components/span_panel/sensors/base.py +++ b/custom_components/span_panel/sensors/base.py @@ -8,7 +8,7 @@ from datetime import date, datetime, timedelta from decimal import Decimal import logging -from typing import Any, Generic, Self, TypeVar +from typing import Any, Self from homeassistant.components.sensor import ( RestoreSensor, @@ -34,9 +34,6 @@ # Sentinel value to distinguish "never synced" from "circuit name is None" _NAME_UNSET: object = object() -T = TypeVar("T", bound=SensorEntityDescription) -D = TypeVar("D") # For the type returned by get_data_source - def _parse_numeric_state(state: State | None) -> tuple[float | None, datetime | None]: """Extract a numeric value and naive timestamp from a restored HA state. @@ -60,7 +57,9 @@ def _parse_numeric_state(state: State | None) -> tuple[float | None, datetime | return value, last_changed -class SpanSensorBase(CoordinatorEntity[SpanPanelCoordinator], SensorEntity, Generic[T, D], ABC): +class SpanSensorBase[T: SensorEntityDescription, D]( + CoordinatorEntity[SpanPanelCoordinator], SensorEntity, ABC +): """Abstract base class for Span Panel Sensors with overrideable methods.""" _attr_has_entity_name = True @@ -395,7 +394,7 @@ def from_dict(cls, restored: dict[str, Any]) -> Self | None: return None -class SpanEnergySensorBase(SpanSensorBase[T, D], RestoreSensor, ABC): +class SpanEnergySensorBase[T: SensorEntityDescription, D](SpanSensorBase[T, D], RestoreSensor, ABC): """Base class for energy sensors that includes grace period tracking. This class extends SpanSensorBase with: diff --git a/custom_components/span_panel/sensors/circuit.py b/custom_components/span_panel/sensors/circuit.py index 12cc572..72ea8fb 100644 --- a/custom_components/span_panel/sensors/circuit.py +++ b/custom_components/span_panel/sensors/circuit.py @@ -383,7 +383,8 @@ def native_value(self) -> int | None: circuit = self.coordinator.data.circuits.get(self.circuit_id) if not circuit or not circuit.tabs: return None - return min(circuit.tabs) + tab_min: int = min(circuit.tabs) + return tab_min class SpanUnmappedCircuitSensor( diff --git a/poetry.lock b/poetry.lock index 9a926e5..85fd9a2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "acme" @@ -1838,7 +1838,7 @@ description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.9" groups = ["main", "dev"] -markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" +markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" files = [ {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"}, {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"}, @@ -3836,7 +3836,10 @@ files = [ ] [package.dependencies] -cffi = {version = ">=1.5.0", markers = "python_version < \"3.14\""} +cffi = [ + {version = ">=1.5.0", markers = "python_version < \"3.14\""}, + {version = ">=2.0.0b1", markers = "python_version >= \"3.14\""}, +] [package.extras] idna = ["idna (>=2.1)"] @@ -5137,7 +5140,7 @@ test = ["covdefaults (==2.3.0)", "pytest (==8.4.1)", "pytest-aiohttp (==1.1.0)", [[package]] name = "span-panel-api" -version = "1.1.14" +version = "1.1.15" description = "A client library for SPAN Panel API" optional = false python-versions = ">=3.10,<4.0" @@ -5153,6 +5156,9 @@ numpy = ">=1.21.0" python-dateutil = ">=2.8.0" pyyaml = ">=6.0.0" +[package.extras] +grpc = ["grpcio (>=1.50.0)"] + [package.source] type = "directory" url = "../span-panel-api" @@ -6314,5 +6320,5 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" -python-versions = ">=3.13.2,<3.14" -content-hash = "d2f773c82e16ff28e156c9f3cf068ed68b2d776c27a9c86f2210159f3bfecc2f" +python-versions = ">=3.13.2,<3.15" +content-hash = "d82eb51ede9ec16dc4e2cf09c0e51db9315f2469d650b9160dd4bd54b67e5e22" diff --git a/pyproject.toml b/pyproject.toml index f6709d1..b15d6cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ readme = "README.md" package-mode = false [tool.poetry.dependencies] -python = ">=3.13.2,<3.14" +python = ">=3.13.2,<3.15" homeassistant = "2025.12.4,<2026.0.0" # Pin to exact version for custom component compatibility span-panel-api = {path = "../span-panel-api", develop = true} @@ -130,7 +130,7 @@ venv = ".venv" [tool.ruff] line-length = 100 -target-version = "py311" +target-version = "py313" [tool.ruff.lint] select = [ @@ -162,7 +162,6 @@ select = [ ignore = [ "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring - "UP046", # PEP 695 type params — target-version is py311, needs py312+ "D213", # Multi-line docstring summary should start at the second line "D406", # Section name should end with a newline "D407", # Section name underlining