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
2 changes: 1 addition & 1 deletion .github/workflows/ci-simulation-example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
python-version: "3.14"

- name: Install Poetry
uses: snok/install-poetry@v1
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
python-version: "3.14"

- name: Install Poetry
uses: snok/install-poetry@v1

- name: Install dependencies
run: |
# Replace path dependencies with PyPI versions for CI
sed -i 's/span-panel-api = {path = "..\/span-panel-api", develop = true}/span-panel-api = "^1.1.14"/' pyproject.toml
sed -i 's/span-panel-api = {path = "..\/span-panel-api", develop = true, extras = \["grpc"\]}/span-panel-api = "^1.1.15"/' pyproject.toml
sed -i 's/ha-synthetic-sensors = {path = "..\/ha-synthetic-sensors", develop = true}/ha-synthetic-sensors = "^1.1.13"/' pyproject.toml
# Regenerate lock file with the modified dependencies
poetry lock
Expand Down
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.13.2
3.14.2
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.3.2] - Unreleased

### ✨ New Features

- **Gen3 panel support (MAIN40 / MLO48)**: Real-time power monitoring for Gen3 gRPC panels via
push-streaming. Circuit and panel power sensors are created automatically; features not
available on Gen3 (relay control, energy history, battery, priorities) are suppressed.
Panel generation is selected during config flow (auto-detect / Gen2 / Gen3).
Thanks to @Griswoldlabs for the Gen3 implementation (PR #169).

### 🔧 Improvements

- **Capability-gated platform loading**: Entity platforms (switch, select, battery, solar) now
load only when the panel reports the corresponding capability. Gen2 behavior is unchanged.
- **Unified snapshot model**: All panel data flows through a single `SpanPanelSnapshot` /
`SpanCircuitSnapshot` model regardless of transport, removing all direct OpenAPI type
dependencies from integration code above the library boundary.
- **Push-streaming coordinator**: Gen3 panels drive entity updates via gRPC push callbacks
rather than a polling timer. The coordinator self-configures based on panel capabilities.

## [1.3.1] - 2026-01-19

### 🐛 Bug Fixes
Expand Down
85 changes: 55 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,15 @@ monitoring and control of your home's electrical system.
[![prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier)
[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)

This integration relies on the OpenAPI interface contract sourced from the SPAN Panel. The integration may break if SPAN changes the API in an incompatible way.
This integration supports both **Gen2** panels (MAIN 32 — REST/OpenAPI) and **Gen3** panels (MAIN 40 / MLO 48 — gRPC).
The Gen2 integration relies on the OpenAPI interface contract sourced from the panel. The integration may break if SPAN
changes the API in an incompatible way.

The software is provided as-is with no warranty or guarantee of performance or suitability to your particular setting.

This integration provides the user with sensors and controls that are useful in understanding an installation's power consumption, energy usage, and the ability
to control user-manageable panel circuits.

## Major Upgrade

**Before upgrading to version 1.2.x from a prior version, please backup your Home Assistant configuration and database.**

See the [CHANGELOG.md](CHANGELOG.md) for detailed information about all new features and improvements.

## HACS Upgrade Process

When upgrading through HACS, you'll see a notification about the new version. Before clicking "Update":
Expand Down Expand Up @@ -55,21 +51,21 @@ If you encounter any issues during the upgrade, you can:

This integration provides a Home Assistant device for your SPAN panel with entities for:

- User Managed Circuits
- User Managed Circuits _(Gen2 only)_
- On/Off Switch (user managed circuits)
- Priority Selector (user managed circuits)
- Power Sensors
- Power Usage / Generation (Watts)
- Energy Usage / Generation (Wh)
- Net Energy (Wh) - Calculated as consumed energy minus produced energy
- Panel and Grid Status
- Power Usage / Generation (Watts) _(Gen2 and Gen3)_
- Energy Usage / Generation (Wh) _(Gen2 only)_
- Net Energy (Wh) - Calculated as consumed energy minus produced energy _(Gen2 only)_
- Panel and Grid Status _(Gen2 only)_
- Main Relay State (e.g., CLOSED)
- Current Run Config (e.g., PANEL_ON_GRID)
- DSM State (e.g., DSM_GRID_UP)
- DSM Grid State (e.g., DSM_ON_GRID)
- Network Connectivity Status (Wi-Fi, Wired, & Cellular)
- Door State (device class is tamper)
- Storage Battery
- Storage Battery _(Gen2 only)_
- Battery percentage (options configuration)

## Installation
Expand All @@ -85,20 +81,23 @@ This integration provides a Home Assistant device for your SPAN panel with entit
9. Click `+ Add Integration`.
10. Search for "Span". This entry should correspond to this repository and offer the current version.
11. Enter the IP of your SPAN Panel to begin setup, or select the automatically discovered panel if it shows up or another address if you have multiple panels.
12. Use the door proximity authentication (see below) and optionally create a token for future configurations. Obtaining a token **_may_** be more durable
against network changes, for example, if you change client hostname or IP and don't want to access the panel for authorization.
12. **Gen2 panels**: Use the door proximity authentication (see below) and optionally create a token for future configurations. Obtaining a token **_may_** be
more durable against network changes, for example, if you change client hostname or IP and don't want to access the panel for authorization.
**Gen3 panels** (MAIN 40 / MLO 48): No authentication is required. The integration connects directly over gRPC without any token or door-proximity step.
13. See post install steps for solar or scan frequency configuration to optionally add additional sensors if applicable.

## Authorization Methods

### Method 1: Door Proximity Authentication
> **Gen3 panels (MAIN 40 / MLO 48) do not require authentication.** Steps 1 and 2 below apply only to Gen2 (MAIN 32) panels.

### Method 1: Door Proximity Authentication (Gen2 only)

1. Open your SPAN Panel door
2. Press the door sensor button at the top 3 times in succession
3. Wait for the frame lights to blink, indicating the panel is "unlocked" for 15 minutes
4. Complete the integration setup in Home Assistant

### Method 2: Authentication Token (Optional)
### Method 2: Authentication Token (Gen2 only, optional)

To acquire an authorization token, proceed as follows while the panel is in its unlocked period:

Expand Down Expand Up @@ -207,14 +206,46 @@ You can change the display precision for any entity in Home Assistant via `Setti
to change in the list and click on it, then click on the gear wheel in the top right. Select the precision you prefer from the "Display Precision" menu and then
press `UPDATE`.

## Limitations
## Panel Generation Support

### Gen2 — SPAN Panel MAIN 32

The original MAIN 32 uses a REST/OpenAPI interface and provides the full feature set of this integration, including circuit relay control, circuit priority
selector, energy history, battery/storage state-of-energy, solar/DSM state, and network connectivity status.

### Gen3 — SPAN Panel MAIN 40 / MLO 48

The original SPAN Panel MAIN 32 has a standardized OpenAPI endpoint that is leveraged by this integration.
The MAIN 40 and MLO 48 released in Q2 2025 use a gRPC-based interface on port 50065. Gen3 panels are **read-only** — no authentication is required to
connect, but the protocol does not expose any write operations. As a result, the following features are **not available** on Gen3 panels:

However, the new SPAN Panel MAIN 40 and MLO 48 that were released in Q2 of 2025 leverage a different hardware/software stack, even going so far as to use a
different mobile app logins. This stack is not yet publicly documented and as such, we have not had a chance to discern how to support this stack at the time of
writing this. The underlying software may be the same codebase as the MAIN 32, so in theory, SPAN may provide access that we have yet to discover or that they
will eventually expose.
- Circuit relay (on/off) control — no switches
- Circuit priority selector
- Energy history (Wh accumulated)
- Battery / storage state-of-energy
- Solar / DSM state
- Network connectivity status

Gen3 panels **do** provide real-time power metrics (watts, voltage, current) for each circuit, as well as apparent power (VA), reactive power (VAR), and
power factor — fields that are not available on Gen2.

The integration auto-detects the panel generation on first connection. No additional configuration is required to support a Gen3 panel; entities that are not
available for Gen3 will simply not be created.

### Feature Comparison

| Feature | Gen2 (MAIN 32) | Gen3 (MAIN 40 / MLO 48) |
| --------------------------------- | -------------- | ----------------------- |
| Authentication | Required (JWT) | None |
| Circuit on/off switch | Yes | No |
| Circuit priority selector | Yes | No |
| Energy history (Wh) | Yes | No |
| Battery / storage SOE | Yes | No |
| Solar / DSM state | Yes | No |
| Network connectivity status | Yes | No |
| Real-time power (W) per circuit | Yes | Yes |
| Apparent power (VA) per circuit | No | Yes |
| Reactive power (VAR) per circuit | No | Yes |
| Power factor per circuit | No | Yes |

## Troubleshooting

Expand Down Expand Up @@ -302,7 +333,7 @@ This integration is published under the MIT license.
This repository is set up as part of an organization so a single committer is not the weak link. The repository is a fork in a long line of SPAN forks that may
or may not be stable (from newer to older):

- SpanPanel/span (current GitHub organization, current repository, currently listed in HACS)
- SpanPanel/span (current GitHub organization, current repository, default listed in HACS)
- SpanPanel/Span (was moved to [SpanPanel/SpanCustom](https://github.com/SpanPanel/SpanCustom))
- cayossarian/span
- haext/span
Expand All @@ -311,12 +342,6 @@ or may not be stable (from newer to older):
- wez/span-hacs
- galak/span-hacs

Additional contributors:

- pavandave
- sargonas
- NickBorgersOnLowSecurityNode

## Issues

If you have a problem with the integration, feel free to [open an issue](https://github.com/SpanPanel/span/issues), but please know that issues regarding your
Expand Down
69 changes: 56 additions & 13 deletions custom_components/span_panel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.util import slugify
from span_panel_api import PanelCapability

# Import config flow to ensure it's registered
from . import config_flow # noqa: F401 # type: ignore[misc]
from .const import (
CONF_PANEL_GENERATION,
CONF_SIMULATION_CONFIG,
CONF_SIMULATION_OFFLINE_MINUTES,
CONF_SIMULATION_START_TIME,
Expand Down Expand Up @@ -49,13 +51,30 @@
from .span_panel_api import SpanPanelAuthError, set_async_delay_func
from .util import panel_to_device_info

# Platforms that are always loaded regardless of panel generation.
_BASE_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
]

# Platforms that are only loaded when the panel advertises the matching capability.
_CAPABILITY_PLATFORMS: dict[PanelCapability, Platform] = {
PanelCapability.RELAY_CONTROL: Platform.SWITCH,
PanelCapability.PRIORITY_CONTROL: Platform.SELECT,
}

# Convenience constant for callers (e.g. unload) that need the full set — this is
# the Gen2 superset. Per-entry active platforms are stored in hass.data.
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]

# Key for storing the active platform list in hass.data per config entry.
_ACTIVE_PLATFORMS = "active_platforms"

_LOGGER = logging.getLogger(__name__)

# Config entry version for unique ID consistency migration
Expand Down Expand Up @@ -85,10 +104,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
)
return False
else:
# Normal successful migration
# Normal successful migration.
# Stamp CONF_PANEL_GENERATION on entries that pre-date Gen3 support —
# all v1 config entries were created against Gen2 hardware.
migrated_data = dict(config_entry.data)
if CONF_PANEL_GENERATION not in migrated_data:
migrated_data[CONF_PANEL_GENERATION] = "gen2"
_LOGGER.debug(
"Migration: set panel_generation=gen2 for entry %s",
config_entry.entry_id,
)
hass.config_entries.async_update_entry(
config_entry,
data=config_entry.data,
data=migrated_data,
options=config_entry.options,
title=config_entry.title,
version=CURRENT_CONFIG_VERSION,
Expand Down Expand Up @@ -200,6 +228,7 @@ async def ha_compatible_delay(seconds: float) -> None:
simulation_config_path=simulation_config_path,
simulation_start_time=simulation_start_time,
simulation_offline_minutes=simulation_offline_minutes,
panel_generation=config.get(CONF_PANEL_GENERATION, "auto"),
)

_LOGGER.debug("Created SpanPanel instance: %s", span_panel)
Expand Down Expand Up @@ -254,10 +283,23 @@ async def _test_authenticated_connection() -> None:

entry.async_on_unload(entry.add_update_listener(update_listener))

# Build the capability-gated platform list for this entry.
capabilities = span_panel.api.capabilities
active_platforms: list[Platform] = list(_BASE_PLATFORMS)
for cap, platform in _CAPABILITY_PLATFORMS.items():
if cap in capabilities:
active_platforms.append(platform)
_LOGGER.debug(
"Panel capabilities: %s — loading platforms: %s",
capabilities,
[p.value for p in active_platforms],
)

hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
COORDINATOR: coordinator,
NAME: name,
_ACTIVE_PLATFORMS: active_platforms,
}

# Generate default device name based on existing devices
Expand Down Expand Up @@ -301,7 +343,7 @@ async def _test_authenticated_connection() -> None:
# PHASE 1 Ensure device is registered BEFORE sensors are created
await ensure_device_registered(hass, entry, span_panel, smart_device_name)

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await hass.config_entries.async_forward_entry_setups(entry, active_platforms)

# Register services
await async_setup_cleanup_energy_spikes_service(hass)
Expand Down Expand Up @@ -341,24 +383,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator_data = hass.data[DOMAIN].get(entry.entry_id)
if coordinator_data and COORDINATOR in coordinator_data:
coordinator = coordinator_data[COORDINATOR]
# Clean up the API client resources
if hasattr(coordinator, "span_panel_api") and coordinator.span_panel_api:
span_panel = coordinator.span_panel_api
# Close the SpanPanel (stops gRPC streaming, closes HTTP connections, etc.)
span_panel = getattr(coordinator, "span_panel", None)
if isinstance(span_panel, SpanPanel):
try:
# SpanPanel has a close method that properly cleans up the API client
if isinstance(span_panel, SpanPanel):
await span_panel.close()
_LOGGER.debug("Successfully closed SpanPanel API client")
await span_panel.close()
_LOGGER.debug("Successfully closed SpanPanel API client")
except TypeError as e:
# Handle non-awaitable objects gracefully
_LOGGER.debug("API close method is not awaitable, skipping cleanup: %s", e)
except Exception as e:
_LOGGER.error("Error during API cleanup: %s", e)
else:
_LOGGER.warning("No coordinator data found for entry %s", entry.entry_id)

_LOGGER.debug("Unloading platforms: %s", PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
# Retrieve the exact set of platforms that were loaded for this entry so we
# unload the same set (capability-gated entries may not have SELECT/SWITCH).
entry_data = hass.data.get(DOMAIN, {}).get(entry.entry_id, {})
active_platforms = entry_data.get(_ACTIVE_PLATFORMS, PLATFORMS)
_LOGGER.debug("Unloading platforms: %s", [p.value for p in active_platforms])
unload_ok = await hass.config_entries.async_unload_platforms(entry, active_platforms)

if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id, None)
Expand Down
Loading