span-panel-api-schema-1 1.4.0b2__tar.gz → 1.4.0b3__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.
Files changed (40) hide show
  1. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/.gitignore +1 -0
  2. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/CHANGELOG.md +2 -0
  3. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/PKG-INFO +1 -1
  4. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/pyproject.toml +1 -1
  5. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/adapter.py +29 -0
  6. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/transport.py +45 -17
  7. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/README.md +0 -0
  8. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/breaker.json +0 -0
  9. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/charge-limit.json +0 -0
  10. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/connection.json +0 -0
  11. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/door.json +0 -0
  12. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/grid-forming.json +0 -0
  13. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/grid.json +0 -0
  14. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/info.json +0 -0
  15. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/load-shed.json +0 -0
  16. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/meter.json +0 -0
  17. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/pcs.json +0 -0
  18. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/power-flows.json +0 -0
  19. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/shed-forecast.json +0 -0
  20. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/shed.json +0 -0
  21. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/soc.json +0 -0
  22. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/status.json +0 -0
  23. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/catalogs/switch.json +0 -0
  24. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/spec/registries/device-types.md +0 -0
  25. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/__init__.py +0 -0
  26. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/adoption.py +0 -0
  27. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/catalog.py +0 -0
  28. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/charge_limit.py +0 -0
  29. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/circuits.py +0 -0
  30. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/const.py +0 -0
  31. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/description.py +0 -0
  32. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/devices.py +0 -0
  33. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/extension.py +0 -0
  34. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/field_metadata.py +0 -0
  35. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/firmware.py +0 -0
  36. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/panel.py +0 -0
  37. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/py.typed +0 -0
  38. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/reference/parent_child_tree.json +0 -0
  39. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/snapshot.py +0 -0
  40. {span_panel_api_schema_1-1.4.0b2 → span_panel_api_schema_1-1.4.0b3}/src/span_panel_api_schema_1/spec_lock.json +0 -0
@@ -16,6 +16,7 @@ xnotes/*
16
16
  .env
17
17
  .envrc
18
18
  .venv
19
+ .deps-installed
19
20
  .vscode/settings.json
20
21
 
21
22
  # mypy
@@ -35,6 +35,8 @@ Requires `span-panel-api` **3.8.0 or newer**, whose snapshot fields accept the u
35
35
  - **The grid-forming device name falls back to vendor and model instead of showing a device id.**
36
36
  - **A panel with no MID reports `dsm_state` `DSM_ON_GRID` and `current_run_config` `PANEL_ON_GRID` whenever it is publishing**, where both dropped to `UNKNOWN` each time `power-flows/grid` read within 1 W of zero
37
37
  ([#215](https://github.com/SpanPanel/span-panel-api/issues/215)).
38
+ - **Each MQTT message costs the same few microseconds however many devices the panel declares**, where routing it tested every recorded subscription in turn and every readiness check walked the device tree. On a 30-device tree the work per message falls
39
+ from 90–250 µs to about 2 µs, measured on a desktop machine ([#223](https://github.com/SpanPanel/span-panel-api/issues/223)).
38
40
 
39
41
  ## [1.2.2]
40
42
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api-schema-1
3
- Version: 1.4.0b2
3
+ Version: 1.4.0b3
4
4
  Summary: Parent/child schema (data-model-version 1.x) parser for span-panel-api
5
5
  Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
6
6
  Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "span-panel-api-schema-1"
3
- version = "1.4.0b2"
3
+ version = "1.4.0b3"
4
4
  description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api"
5
5
  authors = [
6
6
  {name = "SpanPanel"}
@@ -118,7 +118,14 @@ class SchemaOneAdapter:
118
118
  self._controller = Controller(root_device_id=serial_number, mqttc=self._routes)
119
119
  self._property_callbacks: list[Callable[[str, str, str, str | None], None]] = []
120
120
  self._awaiting: tuple[str, ...] = ()
121
+ # Readiness as last computed, or None once the tree has changed since.
122
+ self._ready: bool | None = None
121
123
  self._controller.set_on_property_changed_callback(self._on_property_changed)
124
+ # Every event that can change what `is_ready()` answers; see there.
125
+ self._controller.set_on_device_discovered_callback(self._tree_changed)
126
+ self._controller.set_on_device_state_changed_callback(self._tree_changed)
127
+ self._controller.set_on_device_removed_callback(self._tree_changed)
128
+ self._controller.set_on_description_received_callback(self._tree_changed)
122
129
  # Records the subscriptions the tree walk needs; nothing reaches the
123
130
  # wire, because this object has no connection to reach it with.
124
131
  self._controller.start_discovery()
@@ -173,7 +180,22 @@ class SchemaOneAdapter:
173
180
  panel's positions with none to lay out. Asking only for what the panel
174
181
  itself promised keeps a firmware that omits the property connectable —
175
182
  it falls back to the drift warning in `panel_size_from_model`.
183
+
184
+ **The answer is remembered until the tree changes.** The transport asks
185
+ before and after every message, and walking the tree each time cost
186
+ more than routing the message did (#223). Only the Controller's own
187
+ events can move it: a device's first `$state`, a change of state, a
188
+ removal, a `$description`, and the root's `info/model` value. Each
189
+ forgets the answer and the next call works it out again. The events
190
+ never work it out themselves, because a description is announced before
191
+ the SDK has reconciled the children it declares.
176
192
  """
193
+ if self._ready is None:
194
+ self._ready = self._tree_ready()
195
+ return self._ready
196
+
197
+ def _tree_ready(self) -> bool:
198
+ """What `is_ready()` answers, worked out from the tree as it stands."""
177
199
  root = self._controller.get_root(self._serial_number)
178
200
  if root is None or root.state != STATE_READY or not root.description:
179
201
  return False
@@ -626,5 +648,12 @@ class SchemaOneAdapter:
626
648
  protocol has no place for the previous value, and consumers that need
627
649
  one keep it themselves.
628
650
  """
651
+ # The one value readiness waits on.
652
+ if (device_id, node_id, property_id) == (self._serial_number, NODE_INFO, PROP_MODEL):
653
+ self._ready = None
629
654
  for callback in list(self._property_callbacks):
630
655
  callback(device_id, node_id, property_id, value)
656
+
657
+ def _tree_changed(self, *_event: object) -> None:
658
+ """Forget the remembered readiness. Accepts any of the Controller's tree events."""
659
+ self._ready = None
@@ -38,10 +38,11 @@ zero circuits.
38
38
 
39
39
  from __future__ import annotations
40
40
 
41
+ import itertools
41
42
  import logging
42
- from typing import TYPE_CHECKING, Any
43
+ from typing import TYPE_CHECKING, Any, NamedTuple
43
44
 
44
- from paho.mqtt.client import topic_matches_sub
45
+ from paho.mqtt.matcher import MQTTMatcher
45
46
 
46
47
  from span_panel_api.models import ControlTarget
47
48
  from span_panel_api_schema_1.const import HOMIE_DOMAIN, HOMIE_VERSION
@@ -80,6 +81,13 @@ def control_target(device_id: str, node: str, prop: str) -> ControlTarget:
80
81
  MAX_HELD_MESSAGES = 4096
81
82
 
82
83
 
84
+ class _Route(NamedTuple):
85
+ """A recorded callback, and when it was recorded, which decides an overlap."""
86
+
87
+ recorded: int
88
+ callback: Callable[[str, bytes], None]
89
+
90
+
83
91
  class ControllerRoutes:
84
92
  """Record `Controller`'s subscriptions and route messages back to them.
85
93
 
@@ -88,8 +96,11 @@ class ControllerRoutes:
88
96
  """
89
97
 
90
98
  def __init__(self) -> None:
91
- # Insertion-ordered; dispatch walks it in reverse — see dispatch().
92
- self._routes: dict[str, Callable[[str, bytes], None]] = {}
99
+ self._routes: dict[str, _Route] = {}
100
+ # The same patterns, indexed for matching: each maps to itself, and
101
+ # dispatch resolves the patterns that match through `_routes`.
102
+ self._matcher = MQTTMatcher()
103
+ self._recordings = itertools.count()
93
104
  # Last payload per topic that matched no route yet, keyed by topic so a
94
105
  # newer value supersedes an older one — the same last-value-wins rule
95
106
  # the broker applies to the retained message this stands in for.
@@ -132,27 +143,35 @@ class ControllerRoutes:
132
143
  the adapter's behalf, not of a route recorded in a dict.
133
144
  """
134
145
  # Pop before insert: assigning an existing key updates the value but
135
- # keeps the key's original position, which would leave a re-registered
136
- # pattern behind whatever was added after it in dispatch's match order.
146
+ # keeps the key's original position, and `routes` lists most recent last.
137
147
  self._routes.pop(sub, None)
138
- self._routes[sub] = param
148
+ self._routes[sub] = _Route(next(self._recordings), param)
149
+ self._matcher[sub] = sub
139
150
  self._release(sub, param)
140
151
 
141
152
  def unsubscribe(self, sub: str) -> None:
142
153
  """Forget the callback for `sub`.
143
154
 
144
155
  The broad wire subscription stays. Messages for a device the SDK has
145
- dropped simply stop matching a route, and dispatch discards them.
156
+ dropped simply stop matching a route, and dispatch holds them.
146
157
  """
147
- self._routes.pop(sub, None)
158
+ # Guarded because the matcher raises for a pattern it never held, and
159
+ # forgetting one that was never recorded is harmless.
160
+ if self._routes.pop(sub, None) is not None:
161
+ del self._matcher[sub]
148
162
 
149
163
  # -- our side ----------------------------------------------------------
150
164
 
151
165
  def dispatch(self, topic: str, payload: str) -> None:
152
166
  """Deliver one message to the callback of the route that matches.
153
167
 
154
- Walked most-recent-first, which is defensive rather than currently
155
- required. Tree-rooted discovery subscribes four **device-scoped**
168
+ Matched through a prefix tree of the recorded patterns, so the cost
169
+ follows the topic's depth rather than the number of routes. Every
170
+ message takes this path, and a linear scan grew with each device the
171
+ panel declares, at four routes per device (#223).
172
+
173
+ The most recently recorded match wins, which is defensive rather than
174
+ currently required. Tree-rooted discovery subscribes four **device-scoped**
156
175
  patterns per device — `$state`, `$description`, `+/+`, `+/+/$target`
157
176
  (`Controller._subscribe_device_topics`) — which cannot overlap each
158
177
  other or another device's, so today exactly one route matches any topic.
@@ -172,11 +191,15 @@ class ControllerRoutes:
172
191
 
173
192
  The SDK hands callbacks `bytes`; the transport hands us `str`.
174
193
  """
175
- for sub in reversed(self._routes):
176
- if topic_matches_sub(sub, topic):
177
- self._routes[sub](topic, payload.encode())
178
- return
179
- self._hold(topic, payload)
194
+ # The matcher is untyped and yields what it was given, the pattern
195
+ # string; `_routes` turns each back into a typed route. The list is
196
+ # complete before any callback runs, so a callback that subscribes
197
+ # changes a matcher nothing is still walking.
198
+ matched = [self._routes[sub] for sub in self._matcher.iter_match(topic)]
199
+ if not matched:
200
+ self._hold(topic, payload)
201
+ return
202
+ max(matched, key=lambda route: route.recorded).callback(topic, payload.encode())
180
203
 
181
204
  def _hold(self, topic: str, payload: str) -> None:
182
205
  """Keep a message for a route that has not been registered yet."""
@@ -201,8 +224,13 @@ class ControllerRoutes:
201
224
  in turn, so a whole tree unfolds from one root subscription. The
202
225
  candidate list is therefore taken up front and each entry re-checked,
203
226
  since a nested release may have claimed it already.
227
+
228
+ The pattern is indexed once and matched against every held topic,
229
+ rather than indexed afresh for each of them.
204
230
  """
205
- for topic in [held for held in self._held if topic_matches_sub(sub, held)]:
231
+ pattern = MQTTMatcher()
232
+ pattern[sub] = sub
233
+ for topic in [held for held in self._held if next(pattern.iter_match(held), None) is not None]:
206
234
  payload = self._held.pop(topic, None)
207
235
  if payload is not None:
208
236
  callback(topic, payload.encode())