span-panel-api-schema-1 0.1.0b1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- span_panel_api_schema_1-0.1.0b1/.gitignore +36 -0
- span_panel_api_schema_1-0.1.0b1/CHANGELOG.md +45 -0
- span_panel_api_schema_1-0.1.0b1/PKG-INFO +21 -0
- span_panel_api_schema_1-0.1.0b1/README.md +8 -0
- span_panel_api_schema_1-0.1.0b1/pyproject.toml +42 -0
- span_panel_api_schema_1-0.1.0b1/src/span_panel_api_schema_1/__init__.py +6 -0
- span_panel_api_schema_1-0.1.0b1/src/span_panel_api_schema_1/adapter.py +255 -0
- span_panel_api_schema_1-0.1.0b1/src/span_panel_api_schema_1/circuits.py +177 -0
- span_panel_api_schema_1-0.1.0b1/src/span_panel_api_schema_1/const.py +115 -0
- span_panel_api_schema_1-0.1.0b1/src/span_panel_api_schema_1/devices.py +143 -0
- span_panel_api_schema_1-0.1.0b1/src/span_panel_api_schema_1/field_metadata.py +169 -0
- span_panel_api_schema_1-0.1.0b1/src/span_panel_api_schema_1/panel.py +291 -0
- span_panel_api_schema_1-0.1.0b1/src/span_panel_api_schema_1/py.typed +0 -0
- span_panel_api_schema_1-0.1.0b1/src/span_panel_api_schema_1/snapshot.py +155 -0
- span_panel_api_schema_1-0.1.0b1/src/span_panel_api_schema_1/transport.py +194 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
build/
|
|
3
|
+
dist/
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
.cursor/
|
|
7
|
+
.cursorignore
|
|
8
|
+
.cursor
|
|
9
|
+
.cursorindexingignore
|
|
10
|
+
xnotes/*
|
|
11
|
+
|
|
12
|
+
# pyenv
|
|
13
|
+
.python-version
|
|
14
|
+
|
|
15
|
+
# Environments
|
|
16
|
+
.env
|
|
17
|
+
.envrc
|
|
18
|
+
.venv
|
|
19
|
+
.vscode/settings.json
|
|
20
|
+
|
|
21
|
+
# mypy
|
|
22
|
+
.mypy_cache/
|
|
23
|
+
.dmypy.json
|
|
24
|
+
dmypy.json
|
|
25
|
+
|
|
26
|
+
# ruff
|
|
27
|
+
.ruff_cache/
|
|
28
|
+
|
|
29
|
+
# JetBrains
|
|
30
|
+
.idea/
|
|
31
|
+
|
|
32
|
+
/coverage.xml
|
|
33
|
+
/.coverage
|
|
34
|
+
coverage_output.log
|
|
35
|
+
**/.DS_Store
|
|
36
|
+
.local_coverage_data
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `span-panel-api-schema-1` are documented in this file.
|
|
4
|
+
|
|
5
|
+
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).
|
|
6
|
+
|
|
7
|
+
Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version
|
|
8
|
+
number. A release here means this parser changed, never that the panel did.
|
|
9
|
+
|
|
10
|
+
## [0.1.0b1] - 08/2026
|
|
11
|
+
|
|
12
|
+
Pre-release. First release as a standalone distribution, and the first parser for the parent/child data model.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`SchemaOneAdapter`**, registered as `schema_1` under the `span_panel_api.schema_adapters` entry-point group. A panel reporting `data-model-version` `1.x` resolves to it; a panel without this package installed still gets the named
|
|
17
|
+
`SpanPanelAdapterMissingError`, so installing it is the opt-in.
|
|
18
|
+
- **`ControllerRoutes`** — an `ebus_sdk.MqttControllerTransport` that records `Controller`'s subscriptions instead of making them, so the SDK parses the tree over span-panel-api's own connection to the panel's broker. The adapter is built before a
|
|
19
|
+
connection exists and never receives one; a single wildcard subscription made by the transport layer covers the whole tree, and this routes each message to whichever SDK callback asked for it.
|
|
20
|
+
- **The snapshot mapper.** Sorts the tree by declared device type — never by device id — and maps it onto `SpanPanelSnapshot`: circuits, both lugs, the MID, and the BESS/PV/EVSE devices.
|
|
21
|
+
- **Panel size from `info/model`** via `PANEL_SIZE_BY_MODEL`, which is what restores the unmapped-position entries the integration builds from the difference between total and occupied spaces. `info/spaces` has no format and the panel publishes no size
|
|
22
|
+
property, so the model is the only source; `panel_model_drift()` reports a model the panel declares that we have no size for, because the alternative is a user noticing missing positions.
|
|
23
|
+
- **Field metadata read from each device's `$description`** rather than a schema document. The same capability type exposes different properties on different device classes — `meter` is voltage on the panel, power and energy on a circuit, both currents on
|
|
24
|
+
lugs — so the per-device description is what this panel actually has.
|
|
25
|
+
- **A `py.typed` marker**, so consumers type-check against this package's real annotations.
|
|
26
|
+
|
|
27
|
+
### Known deviations and deliberate gaps
|
|
28
|
+
|
|
29
|
+
- **`set_dominant_power_source_topic()` returns `None`.** The v1.0 property split into `grid-forming-entity` and `asserted-islanding-state`, which are different controls on different devices rather than a rename. `None` makes the transport reject the
|
|
30
|
+
command instead of publishing where nothing listens; which successor to expose is a product decision.
|
|
31
|
+
- **`dsm_state`, `current_run_config`, `grid_islandable` and `pv.relative_position`** have no direct v1.0 equivalent and are left to the product decisions tracked separately. Fields the mapper declines carry no metadata row, so the integration never
|
|
32
|
+
validates against a field nothing populates.
|
|
33
|
+
|
|
34
|
+
### Fixed before first release
|
|
35
|
+
|
|
36
|
+
Both found by verifying reconnect against a live broker, and both presented as a healthy connection.
|
|
37
|
+
|
|
38
|
+
- **Messages arriving before the SDK registered a route for them were dropped.** `Controller` learns its topics as it walks the tree, but one subscription delivers the whole tree at once in whatever order the broker replays its retained store. Seeded
|
|
39
|
+
children-first, a 40-space panel parsed as zero circuits. Unrouted messages are now held and released when the matching route appears — the value a per-device subscription would have been given at subscribe time — with a ceiling so an unclaimed subtree
|
|
40
|
+
cannot leak.
|
|
41
|
+
- **Readiness asked only about the root**, so a connection completed with a fraction of its circuits and no panel size. It now waits for every declared device to describe itself, at any depth. Child _state_ is deliberately not required, so an offline DER
|
|
42
|
+
does not block a connection; the model is required only when the root's description declares it.
|
|
43
|
+
- **`grid_state` read the wrong one of the MID's two grid properties.** The MID publishes both `grid/islanding-state` (`ON_GRID`/`OFF_GRID`/`UNKNOWN`) and `grid/grid-state` (`UP`/`DOWN`/`DEGRADED`/`UNKNOWN`). The flat schema's `grid_state` was the BESS's
|
|
44
|
+
`grid-state`, an islanding answer, so its successor is `islanding-state`; `grid/grid-state` asks whether the utility supply is healthy and is new in v1.0 with no flat equivalent. Matching on the property name rather than the value set put `UP` where a
|
|
45
|
+
consumer expects `ON_GRID` — an entity keeping its id and history while its vocabulary silently changed. `grid/grid-state` is left unmapped, being a new signal rather than a replacement for an existing field.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: span-panel-api-schema-1
|
|
3
|
+
Version: 0.1.0b1
|
|
4
|
+
Summary: Parent/child schema (data-model-version 1.x) parser for span-panel-api
|
|
5
|
+
Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
|
|
6
|
+
Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
|
|
7
|
+
Author: SpanPanel
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Python: <4.0,>=3.10
|
|
10
|
+
Requires-Dist: ebus-sdk<1.0,>=0.18.0
|
|
11
|
+
Requires-Dist: span-panel-api<4.0,>=3.0.0b1
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# span-panel-api-schema-1
|
|
15
|
+
|
|
16
|
+
Parent/child schema parser (`data-model-version` 1.x, SPAN firmware r202633+) for [span-panel-api](https://github.com/SpanPanel/span-panel-api).
|
|
17
|
+
|
|
18
|
+
**Status: incomplete.** This distribution does not yet register a `schema_1` adapter, so installing it does not make a parent/child panel work. A 1.x panel still raises `SpanPanelAdapterMissingError` naming `schema_1`, which is the honest answer until the
|
|
19
|
+
parser can build a snapshot.
|
|
20
|
+
|
|
21
|
+
What exists today is `BridgeControllerTransport` — an `ebus_sdk.MqttControllerTransport` backed by span-panel-api's own MQTT connection, so the eBus SDK can parse the parent/child tree while the connection to the panel's broker stays ours.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# span-panel-api-schema-1
|
|
2
|
+
|
|
3
|
+
Parent/child schema parser (`data-model-version` 1.x, SPAN firmware r202633+) for [span-panel-api](https://github.com/SpanPanel/span-panel-api).
|
|
4
|
+
|
|
5
|
+
**Status: incomplete.** This distribution does not yet register a `schema_1` adapter, so installing it does not make a parent/child panel work. A 1.x panel still raises `SpanPanelAdapterMissingError` naming `schema_1`, which is the honest answer until the
|
|
6
|
+
parser can build a snapshot.
|
|
7
|
+
|
|
8
|
+
What exists today is `BridgeControllerTransport` — an `ebus_sdk.MqttControllerTransport` backed by span-panel-api's own MQTT connection, so the eBus SDK can parse the parent/child tree while the connection to the panel's broker stays ours.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "span-panel-api-schema-1"
|
|
3
|
+
version = "0.1.0b1"
|
|
4
|
+
description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "SpanPanel"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.10,<4.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"span-panel-api>=3.0.0b1,<4.0",
|
|
13
|
+
# Only this distribution depends on the eBus SDK. The bootstrap and
|
|
14
|
+
# schema-0 stay clean, so a flat-panel install never pulls it in — which is
|
|
15
|
+
# what bounds the release coupling this dependency introduces to panels on
|
|
16
|
+
# r202633+.
|
|
17
|
+
"ebus-sdk>=0.18.0,<1.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/SpanPanel/span-panel-api"
|
|
22
|
+
Issues = "https://github.com/SpanPanel/span-panel-api/issues"
|
|
23
|
+
|
|
24
|
+
# The whole point of this distribution. Dispatch resolves `schema_1` for a 1.x
|
|
25
|
+
# panel by discovering this group, never by importing this package.
|
|
26
|
+
#
|
|
27
|
+
# Held back until the parser could answer for a panel end to end — mapper,
|
|
28
|
+
# field metadata, command topics, and recovery from a real broker outage.
|
|
29
|
+
# Installing this package remains the opt-in: a 1.x panel without it still gets
|
|
30
|
+
# the named SpanPanelAdapterMissingError rather than a silent misparse.
|
|
31
|
+
[project.entry-points."span_panel_api.schema_adapters"]
|
|
32
|
+
schema_1 = "span_panel_api_schema_1:SchemaOneAdapter"
|
|
33
|
+
|
|
34
|
+
[tool.uv.sources]
|
|
35
|
+
span-panel-api = { workspace = true }
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["hatchling"]
|
|
39
|
+
build-backend = "hatchling.build"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/span_panel_api_schema_1"]
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Parent/child adapter: `ebus_sdk.Controller` behind the `SchemaAdapter` protocol.
|
|
2
|
+
|
|
3
|
+
The SDK does the Homie work — walking `$description.children`, gating each
|
|
4
|
+
child's subscription on its parent reaching `ready`, and cascading state down
|
|
5
|
+
the tree. This adapter supplies the transport it parses over, sorts the result
|
|
6
|
+
into a `SpanPanelSnapshot`, and builds the topics the transport publishes
|
|
7
|
+
commands to.
|
|
8
|
+
|
|
9
|
+
**It never touches the connection.** `SchemaAdapter` instances are built before
|
|
10
|
+
one exists, so `Controller` is given a route table (`ControllerRoutes`) that
|
|
11
|
+
records its subscriptions instead of making them, and this adapter asks for one
|
|
12
|
+
broad subscription up front through `topics_to_subscribe()`. Every message then
|
|
13
|
+
arrives via `handle_message` and is routed to whichever SDK callback wanted it.
|
|
14
|
+
A reconnect re-subscribes that same static list, the broker replays the retained
|
|
15
|
+
tree, and the SDK repopulates — so there is no resync hook to wire or forget.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from ebus_sdk import Controller
|
|
24
|
+
|
|
25
|
+
from span_panel_api_schema_1.const import (
|
|
26
|
+
HOMIE_DOMAIN,
|
|
27
|
+
HOMIE_VERSION,
|
|
28
|
+
NODE_INFO,
|
|
29
|
+
NODE_LOAD_SHED,
|
|
30
|
+
NODE_SWITCH,
|
|
31
|
+
PROP_MODEL,
|
|
32
|
+
PROP_NAME,
|
|
33
|
+
PROP_PRIORITY,
|
|
34
|
+
PROP_RELAY,
|
|
35
|
+
STATE_READY,
|
|
36
|
+
)
|
|
37
|
+
from span_panel_api_schema_1.field_metadata import build_field_metadata
|
|
38
|
+
from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, device_type
|
|
39
|
+
from span_panel_api_schema_1.transport import ControllerRoutes
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from collections.abc import Callable
|
|
43
|
+
|
|
44
|
+
from ebus_sdk.homie import DiscoveredDevice
|
|
45
|
+
|
|
46
|
+
from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema
|
|
47
|
+
|
|
48
|
+
_LOGGER = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SchemaOneAdapter:
|
|
52
|
+
"""Parser for the parent/child schema (data-model-version 1.x)."""
|
|
53
|
+
|
|
54
|
+
schema_major = "schema_1"
|
|
55
|
+
SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=1.0", "<2.0")
|
|
56
|
+
|
|
57
|
+
def __init__(self, serial_number: str, schema: V2HomieSchema) -> None:
|
|
58
|
+
self._serial_number = serial_number
|
|
59
|
+
self._schema = schema
|
|
60
|
+
self._routes = ControllerRoutes()
|
|
61
|
+
self._controller = Controller(root_device_id=serial_number, mqttc=self._routes)
|
|
62
|
+
self._property_callbacks: list[Callable[[str, str, str, str | None], None]] = []
|
|
63
|
+
self._awaiting: tuple[str, ...] = ()
|
|
64
|
+
self._controller.set_on_property_changed_callback(self._on_property_changed)
|
|
65
|
+
# Records the subscriptions the tree walk needs; nothing reaches the
|
|
66
|
+
# wire, because this object has no connection to reach it with.
|
|
67
|
+
self._controller.start_discovery()
|
|
68
|
+
|
|
69
|
+
# -- SchemaAdapter -----------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def topics_to_subscribe(self) -> list[str]:
|
|
72
|
+
"""One subscription covering the whole tree.
|
|
73
|
+
|
|
74
|
+
Deliberately broader than the SDK's own per-device subscriptions,
|
|
75
|
+
because the adapter is asked this once at connect and again after a
|
|
76
|
+
reconnect — it has no way to add one when a child announces later. The
|
|
77
|
+
flat adapter takes the same approach with `ebus/5/{serial}/#`; here the
|
|
78
|
+
wildcard spans devices, since children are peers of the panel in the
|
|
79
|
+
topic tree rather than nodes beneath it.
|
|
80
|
+
"""
|
|
81
|
+
return [f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/#"]
|
|
82
|
+
|
|
83
|
+
def handle_message(self, topic: str, payload: str) -> None:
|
|
84
|
+
self._routes.dispatch(topic, payload)
|
|
85
|
+
|
|
86
|
+
def is_ready(self) -> bool:
|
|
87
|
+
"""Ready when the whole declared tree has described itself.
|
|
88
|
+
|
|
89
|
+
The flat schema gets its entire topology in one `$description`, so
|
|
90
|
+
"described" and "complete" are the same event. Under parent/child the
|
|
91
|
+
topology arrives as one description per device, and the root's says
|
|
92
|
+
ready as soon as *its own* arrives — while its children are still
|
|
93
|
+
landing. Treating that as ready hands the transport a panel with a
|
|
94
|
+
handful of circuits and no model, which it reports as a healthy
|
|
95
|
+
connection. So readiness waits for every device the tree declares.
|
|
96
|
+
|
|
97
|
+
Child *state* is deliberately not required. A commissioned DER that is
|
|
98
|
+
currently offline publishes `lost` but keeps its retained description,
|
|
99
|
+
and a panel should not fail to connect because a battery is unplugged.
|
|
100
|
+
|
|
101
|
+
The model is required only when the root's description declares it: the
|
|
102
|
+
panel's size comes from nowhere else, and a snapshot built a moment too
|
|
103
|
+
early reports zero spaces, which erases every unmapped position rather
|
|
104
|
+
than merely mis-stating a number. Asking only for what the panel itself
|
|
105
|
+
promised keeps a firmware that omits the property connectable — it
|
|
106
|
+
falls back to the drift warning in `panel_size_from_model`.
|
|
107
|
+
"""
|
|
108
|
+
root = self._controller.get_root(self._serial_number)
|
|
109
|
+
if root is None or root.state != STATE_READY or not root.description:
|
|
110
|
+
return False
|
|
111
|
+
if self._awaiting_descriptions(root):
|
|
112
|
+
return False
|
|
113
|
+
return self._model_arrived(root)
|
|
114
|
+
|
|
115
|
+
def build_snapshot(self) -> SpanPanelSnapshot:
|
|
116
|
+
root = self._require_root()
|
|
117
|
+
return build_snapshot(root, self._children())
|
|
118
|
+
|
|
119
|
+
def build_field_metadata(self) -> dict[str, FieldMetadata]:
|
|
120
|
+
root = self._controller.get_root(self._serial_number)
|
|
121
|
+
devices = [] if root is None else [root, *self._children()]
|
|
122
|
+
return build_field_metadata(devices)
|
|
123
|
+
|
|
124
|
+
def circuit_nodes_missing_names(self) -> list[str]:
|
|
125
|
+
"""Devices whose retained identity has not arrived yet.
|
|
126
|
+
|
|
127
|
+
The transport polls this during connect so the first snapshot carries
|
|
128
|
+
real names rather than falling back to identifiers.
|
|
129
|
+
|
|
130
|
+
Readiness proves the tree's *shape* — every device the tree declares
|
|
131
|
+
has described itself. It cannot prove the tree's *labels*: a
|
|
132
|
+
description says which properties exist, and their retained values
|
|
133
|
+
arrive as separate messages that may land after the last description
|
|
134
|
+
does. That gap exists under the flat schema too; it just matters more
|
|
135
|
+
here, because a DER is its own device and the integration registers it
|
|
136
|
+
from this first snapshot.
|
|
137
|
+
|
|
138
|
+
Named for the flat schema's circuits, where a missing name was the only
|
|
139
|
+
way to get a placeholder. Under parent/child every mapped device has
|
|
140
|
+
the same exposure, so a DER missing the model it declared is reported
|
|
141
|
+
alongside a circuit missing its name.
|
|
142
|
+
"""
|
|
143
|
+
roles = TreeRoles(self._children())
|
|
144
|
+
missing = [circuit.device_id for circuit in roles.circuits if not circuit.get_property(NODE_INFO, PROP_NAME)]
|
|
145
|
+
ders = (roles.bess, roles.pv, *roles.evse)
|
|
146
|
+
missing.extend(
|
|
147
|
+
device.device_id
|
|
148
|
+
for device in ders
|
|
149
|
+
if device is not None
|
|
150
|
+
and PROP_MODEL in device.get_node_properties(NODE_INFO)
|
|
151
|
+
and device.get_property(NODE_INFO, PROP_MODEL) is None
|
|
152
|
+
)
|
|
153
|
+
return missing
|
|
154
|
+
|
|
155
|
+
def find_node_by_type(self, type_str: str) -> str | None:
|
|
156
|
+
"""Return the id of the first device declaring `type_str`.
|
|
157
|
+
|
|
158
|
+
Named for the flat schema's nodes; under parent/child the same question
|
|
159
|
+
is asked of devices, and the answer is a device id.
|
|
160
|
+
"""
|
|
161
|
+
for device in self._children():
|
|
162
|
+
if device_type(device) == type_str:
|
|
163
|
+
return device.device_id
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
# -- Command topics ----------------------------------------------------
|
|
167
|
+
#
|
|
168
|
+
# The adapter names the topic and the transport publishes it, so commanding
|
|
169
|
+
# a panel needs no connection here either.
|
|
170
|
+
|
|
171
|
+
def set_circuit_relay_topic(self, circuit_id: str) -> str:
|
|
172
|
+
return self._set_topic(circuit_id, NODE_SWITCH, PROP_RELAY)
|
|
173
|
+
|
|
174
|
+
def set_circuit_priority_topic(self, circuit_id: str) -> str:
|
|
175
|
+
return self._set_topic(circuit_id, NODE_LOAD_SHED, PROP_PRIORITY)
|
|
176
|
+
|
|
177
|
+
def set_dominant_power_source_topic(self) -> str | None:
|
|
178
|
+
"""No v1.0 equivalent, so no topic.
|
|
179
|
+
|
|
180
|
+
`dominant-power-source` split into `grid-forming-entity` and
|
|
181
|
+
`asserted-islanding-state`, which are different controls on different
|
|
182
|
+
devices rather than a renamed one. Returning None makes the transport
|
|
183
|
+
reject the command instead of publishing to a topic nothing serves —
|
|
184
|
+
and which successor to expose is a product decision, tracked in the
|
|
185
|
+
entity and config deltas write-up.
|
|
186
|
+
"""
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]:
|
|
190
|
+
"""Subscribe to per-property updates; returns an unregister callable."""
|
|
191
|
+
self._property_callbacks.append(callback)
|
|
192
|
+
|
|
193
|
+
def _unregister() -> None:
|
|
194
|
+
if callback in self._property_callbacks:
|
|
195
|
+
self._property_callbacks.remove(callback)
|
|
196
|
+
|
|
197
|
+
return _unregister
|
|
198
|
+
|
|
199
|
+
# -- internals ---------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def _set_topic(self, device_id: str, node: str, prop: str) -> str:
|
|
202
|
+
return f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/{device_id}/{node}/{prop}/set"
|
|
203
|
+
|
|
204
|
+
def _require_root(self) -> DiscoveredDevice:
|
|
205
|
+
"""The root, or a clear error if discovery has not finished.
|
|
206
|
+
|
|
207
|
+
Checks readiness rather than existence: `start_discovery` pre-creates
|
|
208
|
+
the root entry so descendants have somewhere to attach, so the device
|
|
209
|
+
object exists from construction and proves nothing on its own.
|
|
210
|
+
"""
|
|
211
|
+
root = self._controller.get_root(self._serial_number)
|
|
212
|
+
if root is None or not self.is_ready():
|
|
213
|
+
raise RuntimeError(f"Device tree for {self._serial_number!r} is not ready; build_snapshot called too early")
|
|
214
|
+
return root
|
|
215
|
+
|
|
216
|
+
def _children(self) -> list[DiscoveredDevice]:
|
|
217
|
+
return list(self._controller.get_descendants(self._serial_number))
|
|
218
|
+
|
|
219
|
+
def _awaiting_descriptions(self, root: DiscoveredDevice) -> tuple[str, ...]:
|
|
220
|
+
"""Devices the tree declares that have not described themselves yet.
|
|
221
|
+
|
|
222
|
+
Walks declarations rather than discoveries, and at any depth: a child
|
|
223
|
+
may declare children of its own, and those count too. Logged when the
|
|
224
|
+
set changes, because the alternative diagnostic for a tree that never
|
|
225
|
+
completes is a bare 30-second connect timeout.
|
|
226
|
+
"""
|
|
227
|
+
described = {device.device_id: device for device in self._children() if device.description is not None}
|
|
228
|
+
awaiting = {
|
|
229
|
+
child_id
|
|
230
|
+
for device in (root, *described.values())
|
|
231
|
+
for child_id in device.children_ids
|
|
232
|
+
if child_id not in described
|
|
233
|
+
}
|
|
234
|
+
pending = tuple(sorted(awaiting))
|
|
235
|
+
if pending != self._awaiting:
|
|
236
|
+
self._awaiting = pending
|
|
237
|
+
if pending:
|
|
238
|
+
_LOGGER.debug("Waiting on %d declared devices: %s", len(pending), ", ".join(pending))
|
|
239
|
+
return pending
|
|
240
|
+
|
|
241
|
+
def _model_arrived(self, root: DiscoveredDevice) -> bool:
|
|
242
|
+
"""Whether the panel has published the model it said it would."""
|
|
243
|
+
if PROP_MODEL not in root.get_node_properties(NODE_INFO):
|
|
244
|
+
return True
|
|
245
|
+
return root.get_property(NODE_INFO, PROP_MODEL) is not None
|
|
246
|
+
|
|
247
|
+
def _on_property_changed(self, device_id: str, node_id: str, property_id: str, value: str, _old: str | None) -> None:
|
|
248
|
+
"""Fan a Controller property change out to registered consumers.
|
|
249
|
+
|
|
250
|
+
Signature adapts the SDK's five arguments to the protocol's four: the
|
|
251
|
+
protocol has no place for the previous value, and consumers that need
|
|
252
|
+
one keep it themselves.
|
|
253
|
+
"""
|
|
254
|
+
for callback in list(self._property_callbacks):
|
|
255
|
+
callback(device_id, node_id, property_id, value)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Map a v1.0 circuit device onto ``SpanCircuitSnapshot``.
|
|
2
|
+
|
|
3
|
+
The snapshot's field names come from the v1 REST API and are preserved so the
|
|
4
|
+
integration's entities do not move. Three of them no longer have a property to
|
|
5
|
+
read, because v1.0 consolidated four flat mechanisms into two. Their
|
|
6
|
+
derivations are defined by the migration guide, not invented here:
|
|
7
|
+
|
|
8
|
+
====================== ===========================================================
|
|
9
|
+
Flat property v1.0 source
|
|
10
|
+
====================== ===========================================================
|
|
11
|
+
``always-on`` ``switch/relay-controllable``, inverted
|
|
12
|
+
``never-backup`` ``$settable`` on ``load-shed/priority``, inverted
|
|
13
|
+
``sheddable`` computed: ``priority != NEVER and relay-controllable``
|
|
14
|
+
====================== ===========================================================
|
|
15
|
+
|
|
16
|
+
Sign and direction are unchanged from the flat schema, and both are the reverse
|
|
17
|
+
of what the property names suggest. Values are in the enclosure's reference
|
|
18
|
+
frame: a normal load reads **negative** ``active-power`` and accumulates
|
|
19
|
+
``exported-energy`` (the panel exported it *to* the circuit). The snapshot
|
|
20
|
+
reports consumption as positive, so power is negated and the two energy
|
|
21
|
+
accumulators are swapped.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from span_panel_api.models import SpanCircuitSnapshot
|
|
29
|
+
from span_panel_api_schema_1.const import (
|
|
30
|
+
ATTR_SETTABLE,
|
|
31
|
+
NODE_BREAKER,
|
|
32
|
+
NODE_INFO,
|
|
33
|
+
NODE_LOAD_SHED,
|
|
34
|
+
NODE_METER,
|
|
35
|
+
NODE_SWITCH,
|
|
36
|
+
PRIORITY_NEVER,
|
|
37
|
+
PROP_ACTIVE_POWER,
|
|
38
|
+
PROP_CURRENT,
|
|
39
|
+
PROP_EXPORTED_ENERGY,
|
|
40
|
+
PROP_IMPORTED_ENERGY,
|
|
41
|
+
PROP_NAME,
|
|
42
|
+
PROP_POLES,
|
|
43
|
+
PROP_PRIORITY,
|
|
44
|
+
PROP_RATING,
|
|
45
|
+
PROP_RELAY,
|
|
46
|
+
PROP_RELAY_CONTROLLABLE,
|
|
47
|
+
PROP_RELAY_REQUESTER,
|
|
48
|
+
PROP_SPACES,
|
|
49
|
+
UNKNOWN,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from ebus_sdk.homie import DiscoveredDevice
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _text(device: DiscoveredDevice, node: str, prop: str, default: str = "") -> str:
|
|
57
|
+
value = device.get_property(node, prop)
|
|
58
|
+
return default if value is None else str(value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _number(device: DiscoveredDevice, node: str, prop: str) -> float | None:
|
|
62
|
+
"""Read a numeric property, or None when it is absent or unparseable.
|
|
63
|
+
|
|
64
|
+
Unparseable is treated as absent rather than as an error: a single
|
|
65
|
+
malformed value must not take down a whole snapshot, and the field it
|
|
66
|
+
feeds is optional.
|
|
67
|
+
"""
|
|
68
|
+
raw = device.get_property(node, prop)
|
|
69
|
+
if raw is None or raw == "":
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
return float(raw)
|
|
73
|
+
except (TypeError, ValueError):
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _flag(device: DiscoveredDevice, node: str, prop: str, *, default: bool) -> bool:
|
|
78
|
+
"""Read a Homie boolean. Absent means `default`, which is not always False.
|
|
79
|
+
|
|
80
|
+
`relay-controllable` absent has to mean *controllable*, because the
|
|
81
|
+
property exists to mark the exception (an always-on circuit). Defaulting it
|
|
82
|
+
to False would silently make every circuit uncontrollable on a panel that
|
|
83
|
+
omits it.
|
|
84
|
+
"""
|
|
85
|
+
raw = device.get_property(node, prop)
|
|
86
|
+
if raw is None or raw == "":
|
|
87
|
+
return default
|
|
88
|
+
return str(raw).strip().lower() == "true"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _tabs(device: DiscoveredDevice) -> list[int]:
|
|
92
|
+
"""Breaker spaces from ``info/spaces``.
|
|
93
|
+
|
|
94
|
+
v1.0 publishes the occupied spaces literally (``"36,38"``), where the flat
|
|
95
|
+
schema published one space plus a `dipole` flag and left the consumer to
|
|
96
|
+
infer the second as ``space + 2``. Reading the list means a 3-pole breaker
|
|
97
|
+
reports three tabs instead of being silently truncated to two.
|
|
98
|
+
"""
|
|
99
|
+
raw = _text(device, NODE_INFO, PROP_SPACES)
|
|
100
|
+
if not raw:
|
|
101
|
+
return []
|
|
102
|
+
tabs: list[int] = []
|
|
103
|
+
for part in raw.split(","):
|
|
104
|
+
part = part.strip()
|
|
105
|
+
if not part:
|
|
106
|
+
continue
|
|
107
|
+
try:
|
|
108
|
+
tabs.append(int(part))
|
|
109
|
+
except ValueError:
|
|
110
|
+
continue
|
|
111
|
+
return tabs
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _priority_is_settable(device: DiscoveredDevice) -> bool:
|
|
115
|
+
"""Whether ``load-shed/priority`` is user-settable on this circuit.
|
|
116
|
+
|
|
117
|
+
This is the successor to the flat ``never-backup`` boolean, and it is read
|
|
118
|
+
from the description rather than from a value topic — v1.0 expresses
|
|
119
|
+
never-backup as *mutability*, so the signal is the Homie ``$settable``
|
|
120
|
+
attribute on the property definition.
|
|
121
|
+
|
|
122
|
+
Absent means settable: locking is the exception a panel announces, so
|
|
123
|
+
treating an unannounced circuit as locked would mark every circuit
|
|
124
|
+
never-backup on a panel that does not publish the attribute.
|
|
125
|
+
"""
|
|
126
|
+
definition = device.get_node_properties(NODE_LOAD_SHED).get(PROP_PRIORITY)
|
|
127
|
+
if not isinstance(definition, dict):
|
|
128
|
+
return True
|
|
129
|
+
settable = definition.get(ATTR_SETTABLE)
|
|
130
|
+
if settable is None:
|
|
131
|
+
return True
|
|
132
|
+
if isinstance(settable, bool):
|
|
133
|
+
return settable
|
|
134
|
+
return str(settable).strip().lower() != "false"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_circuit(
|
|
138
|
+
device: DiscoveredDevice, device_type: str = "circuit", relative_position: str = ""
|
|
139
|
+
) -> SpanCircuitSnapshot:
|
|
140
|
+
"""Build one circuit snapshot from its v1.0 device."""
|
|
141
|
+
raw_power = _number(device, NODE_METER, PROP_ACTIVE_POWER) or 0.0
|
|
142
|
+
# Negate so positive means consumption. The guard keeps -0.0 out of the
|
|
143
|
+
# snapshot, where it would compare equal to 0.0 but format as "-0.0".
|
|
144
|
+
instant_power_w = 0.0 if raw_power == 0.0 else -raw_power
|
|
145
|
+
|
|
146
|
+
relay_controllable = _flag(device, NODE_SWITCH, PROP_RELAY_CONTROLLABLE, default=True)
|
|
147
|
+
priority = _text(device, NODE_LOAD_SHED, PROP_PRIORITY, UNKNOWN)
|
|
148
|
+
priority_settable = _priority_is_settable(device)
|
|
149
|
+
|
|
150
|
+
return SpanCircuitSnapshot(
|
|
151
|
+
circuit_id=device.device_id,
|
|
152
|
+
name=_text(device, NODE_INFO, PROP_NAME),
|
|
153
|
+
relay_state=_text(device, NODE_SWITCH, PROP_RELAY, UNKNOWN),
|
|
154
|
+
instant_power_w=instant_power_w,
|
|
155
|
+
# The panel *imported* this energy from the circuit, so the circuit
|
|
156
|
+
# produced it. Named from the panel's perspective, reported from the
|
|
157
|
+
# circuit's.
|
|
158
|
+
produced_energy_wh=_number(device, NODE_METER, PROP_IMPORTED_ENERGY) or 0.0,
|
|
159
|
+
consumed_energy_wh=_number(device, NODE_METER, PROP_EXPORTED_ENERGY) or 0.0,
|
|
160
|
+
tabs=_tabs(device),
|
|
161
|
+
priority=priority,
|
|
162
|
+
# `always-on` is `not relay-controllable`, and the flat schema derived
|
|
163
|
+
# user-controllability from `always-on` — so this is the same answer by
|
|
164
|
+
# a shorter route.
|
|
165
|
+
is_user_controllable=relay_controllable,
|
|
166
|
+
is_sheddable=priority != PRIORITY_NEVER and relay_controllable,
|
|
167
|
+
is_never_backup=not priority_settable,
|
|
168
|
+
device_type=device_type,
|
|
169
|
+
relative_position=relative_position,
|
|
170
|
+
is_240v=(_number(device, NODE_BREAKER, PROP_POLES) or 1) >= 2,
|
|
171
|
+
current_a=_number(device, NODE_METER, PROP_CURRENT),
|
|
172
|
+
breaker_rating_a=_number(device, NODE_BREAKER, PROP_RATING),
|
|
173
|
+
always_on=not relay_controllable,
|
|
174
|
+
relay_requester=_text(device, NODE_SWITCH, PROP_RELAY_REQUESTER, UNKNOWN),
|
|
175
|
+
relay_state_target=device.get_property_target(NODE_SWITCH, PROP_RELAY),
|
|
176
|
+
priority_target=device.get_property_target(NODE_LOAD_SHED, PROP_PRIORITY),
|
|
177
|
+
)
|