span-panel-api-schema-1 0.1.0b1__py3-none-any.whl
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/__init__.py +6 -0
- span_panel_api_schema_1/adapter.py +255 -0
- span_panel_api_schema_1/circuits.py +177 -0
- span_panel_api_schema_1/const.py +115 -0
- span_panel_api_schema_1/devices.py +143 -0
- span_panel_api_schema_1/field_metadata.py +169 -0
- span_panel_api_schema_1/panel.py +291 -0
- span_panel_api_schema_1/py.typed +0 -0
- span_panel_api_schema_1/snapshot.py +155 -0
- span_panel_api_schema_1/transport.py +194 -0
- span_panel_api_schema_1-0.1.0b1.dist-info/METADATA +21 -0
- span_panel_api_schema_1-0.1.0b1.dist-info/RECORD +14 -0
- span_panel_api_schema_1-0.1.0b1.dist-info/WHEEL +4 -0
- span_panel_api_schema_1-0.1.0b1.dist-info/entry_points.txt +2 -0
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Wire vocabulary for the parent/child schema (data-model-version 1.x).
|
|
2
|
+
|
|
3
|
+
Every name here is a v1.0 device class, capability node, or property id. Nothing
|
|
4
|
+
in this module is shared with the flat schema: v1.0 moved each property from a
|
|
5
|
+
node on one device to a capability node on its own device, so even names that
|
|
6
|
+
look unchanged are addressed differently.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# -- Device classes ---------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
TYPE_PANEL = "energy.ebus.device.distribution-enclosure"
|
|
14
|
+
TYPE_CIRCUIT = "energy.ebus.device.circuit"
|
|
15
|
+
TYPE_BESS = "energy.ebus.device.bess"
|
|
16
|
+
TYPE_PV = "energy.ebus.device.pv"
|
|
17
|
+
TYPE_EVSE = "energy.ebus.device.evse"
|
|
18
|
+
TYPE_MID = "energy.ebus.device.mid"
|
|
19
|
+
TYPE_LUGS = "energy.ebus.device.lugs"
|
|
20
|
+
|
|
21
|
+
# -- Capability nodes -------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
NODE_BREAKER = "breaker"
|
|
24
|
+
NODE_CONNECTION = "connection"
|
|
25
|
+
NODE_DOOR = "door"
|
|
26
|
+
NODE_GRID = "grid"
|
|
27
|
+
NODE_INFO = "info"
|
|
28
|
+
NODE_LOAD_SHED = "load-shed"
|
|
29
|
+
NODE_METER = "meter"
|
|
30
|
+
NODE_PCS = "pcs"
|
|
31
|
+
NODE_POWER_FLOWS = "power-flows"
|
|
32
|
+
NODE_SHED = "shed"
|
|
33
|
+
NODE_SOC = "soc"
|
|
34
|
+
NODE_STATUS = "status"
|
|
35
|
+
NODE_SWITCH = "switch"
|
|
36
|
+
|
|
37
|
+
# -- Properties -------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
PROP_ACTIVE_POWER = "active-power"
|
|
40
|
+
PROP_CURRENT = "current"
|
|
41
|
+
PROP_EXPORTED_ENERGY = "exported-energy"
|
|
42
|
+
PROP_IMPORTED_ENERGY = "imported-energy"
|
|
43
|
+
PROP_NAME = "name"
|
|
44
|
+
PROP_POLES = "poles"
|
|
45
|
+
PROP_PRIORITY = "priority"
|
|
46
|
+
PROP_RATING = "rating"
|
|
47
|
+
PROP_RELAY = "relay"
|
|
48
|
+
PROP_RELAY_CONTROLLABLE = "relay-controllable"
|
|
49
|
+
PROP_RELAY_REQUESTER = "relay-requester"
|
|
50
|
+
PROP_SPACES = "spaces"
|
|
51
|
+
|
|
52
|
+
# Panel-level
|
|
53
|
+
PROP_DATA_MODEL_VERSION = "data-model-version"
|
|
54
|
+
PROP_FIRMWARE_VERSION = "firmware-version"
|
|
55
|
+
PROP_SERIAL_NUMBER = "serial-number"
|
|
56
|
+
PROP_STATE = "state"
|
|
57
|
+
PROP_VOLTAGE_A = "voltage-a"
|
|
58
|
+
PROP_VOLTAGE_B = "voltage-b"
|
|
59
|
+
|
|
60
|
+
# status node
|
|
61
|
+
PROP_CLOUD_CONNECTION = "cloud-connection"
|
|
62
|
+
PROP_ETHERNET = "ethernet"
|
|
63
|
+
PROP_WIFI = "wifi"
|
|
64
|
+
|
|
65
|
+
# -- Values -----------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
PRIORITY_NEVER = "NEVER"
|
|
68
|
+
UNKNOWN = "UNKNOWN"
|
|
69
|
+
CLOUD_CONNECTED = "CONNECTED"
|
|
70
|
+
|
|
71
|
+
PROP_MODEL = "model"
|
|
72
|
+
|
|
73
|
+
# Topic root. Children are peers of the panel in the topic tree rather than
|
|
74
|
+
# nodes beneath it, so a subscription covering the tree spans the domain.
|
|
75
|
+
HOMIE_DOMAIN = "ebus"
|
|
76
|
+
HOMIE_VERSION = "5"
|
|
77
|
+
|
|
78
|
+
STATE_READY = "ready"
|
|
79
|
+
|
|
80
|
+
# Breaker spaces per panel model.
|
|
81
|
+
#
|
|
82
|
+
# This is the only source of the panel's total size in v1.0. The flat schema
|
|
83
|
+
# carried it in the Homie schema's `space` format (`"1:32:1"`, max = 32); its
|
|
84
|
+
# successor `info/spaces` is a plain string with no format, and the panel device
|
|
85
|
+
# publishes no size property. What v1.0 does publish is `info/model`, a **closed
|
|
86
|
+
# enum** — the topic reference, the migration guide, and the panel's own Homie
|
|
87
|
+
# `$format` all list exactly these five values — so this is a lookup over a
|
|
88
|
+
# defined value set, not an inference from a vendor string.
|
|
89
|
+
#
|
|
90
|
+
# The *sizes* are ours: neither the SDK nor the published schema states how many
|
|
91
|
+
# spaces a model has, only which model names are valid. `panel_model_drift`
|
|
92
|
+
# exists because of that split — the panel can tell us a model we have no size
|
|
93
|
+
# for, and we would rather say so than guess.
|
|
94
|
+
#
|
|
95
|
+
# Total size matters beyond a display field: unoccupied positions are only
|
|
96
|
+
# knowable as `total - occupied`, and synthesising them is what gives the
|
|
97
|
+
# integration its unmapped-circuit sensors.
|
|
98
|
+
PANEL_SIZE_BY_MODEL: dict[str, int] = {
|
|
99
|
+
"MAIN_16": 16,
|
|
100
|
+
"MLO_24": 24,
|
|
101
|
+
"MAIN_32": 32,
|
|
102
|
+
"MAIN_40": 40,
|
|
103
|
+
"MLO_48": 48,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Prefix for synthesised unoccupied-position entries. Must match the flat
|
|
107
|
+
# adapter's, because the integration keys entities off it and a rename would
|
|
108
|
+
# strand every existing unmapped-tab entity.
|
|
109
|
+
UNMAPPED_TAB_PREFIX = "unmapped_tab_"
|
|
110
|
+
|
|
111
|
+
# The Homie attribute that carries what the flat schema published as the
|
|
112
|
+
# `never-backup` boolean. v1.0 retires the property and expresses it as
|
|
113
|
+
# mutability: a circuit commissioned never-backup has its priority locked, so
|
|
114
|
+
# the panel publishes `$settable = false` on `load-shed/priority`.
|
|
115
|
+
ATTR_SETTABLE = "settable"
|