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.
@@ -0,0 +1,6 @@
1
+ """Parent/child schema (data-model-version 1.x) parser for span-panel-api."""
2
+
3
+ from span_panel_api_schema_1.adapter import SchemaOneAdapter
4
+ from span_panel_api_schema_1.transport import ControllerRoutes
5
+
6
+ __all__ = ["ControllerRoutes", "SchemaOneAdapter"]
@@ -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"