Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions custom_components/span_panel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions custom_components/span_panel/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from .const import (
CONF_DEVICE_NAME,
CONF_PANEL_GEN,
COORDINATOR,
DOMAIN,
PANEL_STATUS,
Expand Down Expand Up @@ -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]
Expand Down
28 changes: 27 additions & 1 deletion custom_components/span_panel/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -280,8 +281,22 @@ 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,
Expand All @@ -298,6 +313,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
Expand Down
3 changes: 3 additions & 0 deletions custom_components/span_panel/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions custom_components/span_panel/gen3/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Gen3 gRPC support for Span panels (MAIN 40 / MLO 48)."""
78 changes: 78 additions & 0 deletions custom_components/span_panel/gen3/binary_sensors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""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
33 changes: 33 additions & 0 deletions custom_components/span_panel/gen3/const.py
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions custom_components/span_panel/gen3/coordinator.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading