span-panel-api-schema-0 1.1.2__tar.gz → 1.3.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_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/CHANGELOG.md +28 -0
- {span_panel_api_schema_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/PKG-INFO +2 -2
- {span_panel_api_schema_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/pyproject.toml +7 -2
- {span_panel_api_schema_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/src/span_panel_api_schema_0/accumulator.py +3 -3
- {span_panel_api_schema_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/src/span_panel_api_schema_0/consumer.py +34 -55
- {span_panel_api_schema_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/.gitignore +0 -0
- {span_panel_api_schema_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/README.md +0 -0
- {span_panel_api_schema_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/src/span_panel_api_schema_0/__init__.py +0 -0
- {span_panel_api_schema_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/src/span_panel_api_schema_0/adapter.py +0 -0
- {span_panel_api_schema_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/src/span_panel_api_schema_0/const.py +0 -0
- {span_panel_api_schema_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/src/span_panel_api_schema_0/field_metadata.py +0 -0
- {span_panel_api_schema_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/src/span_panel_api_schema_0/py.typed +0 -0
- {span_panel_api_schema_0-1.1.2 → span_panel_api_schema_0-1.3.0b1}/src/span_panel_api_schema_0/reference/homie_schema.json +0 -0
|
@@ -9,6 +9,34 @@ rather than by this version number. A release here means this parser changed, ne
|
|
|
9
9
|
|
|
10
10
|
Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the last public release, never against the beta before it.
|
|
11
11
|
|
|
12
|
+
## [Unreleased]
|
|
13
|
+
|
|
14
|
+
Requires `span-panel-api` **3.7.0 or newer**, whose snapshot fields accept the unknown values this parser now reports.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **`proximity_proven` is `None`**, where it was `True` on every snapshot because it read readiness rather than any proximity property.
|
|
19
|
+
- **`uptime_s` is `None` before the panel first reports ready**, where it was `0`.
|
|
20
|
+
- **A circuit's `instant_power_update_time_s` and `energy_accum_update_time_s` are `None` until the reading arrives**, where they were `0`.
|
|
21
|
+
- **BREAKING FOR CONSUMERS: `HomiePropertyAccumulator.get_timestamp()` returns `int | None`, and `None` for a property never received**, where it returned `0`.
|
|
22
|
+
|
|
23
|
+
### Removed
|
|
24
|
+
|
|
25
|
+
- **BREAKING: snapshots no longer include synthesised `unmapped_tab_N` entries for unoccupied breaker positions, which carried no measured data.**
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **`uptime_s` keeps advancing while only circuits change**, where a snapshot rebuilt for changed circuits alone carried the previous snapshot's uptime forward until something panel-level changed.
|
|
30
|
+
|
|
31
|
+
## [1.2.0]
|
|
32
|
+
|
|
33
|
+
The panel's one inverter is reported through `pv_inverters` as well as `pv`. Requires `span-panel-api` **3.6.0 or newer**.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **`pv_inverters` holds the panel's one inverter** under the key `span-panel-api-schema-1` gives it, so a consumer can read every panel through that field.
|
|
38
|
+
- **`pv.device_id` and `pv.node_id`** give the inverter's flat node id and its `pv_inverters` key.
|
|
39
|
+
|
|
12
40
|
## [1.1.2]
|
|
13
41
|
|
|
14
42
|
The reference schema document this parser is tested against now ships in the wheel.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: span-panel-api-schema-0
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0b1
|
|
4
4
|
Summary: Flat-schema (data-model-version absent) 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
|
|
7
7
|
Author: SpanPanel
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
Requires-Python: <4.0,>=3.14
|
|
10
|
-
Requires-Dist: span-panel-api<4.0,>=3.
|
|
10
|
+
Requires-Dist: span-panel-api<4.0,>=3.7.0b1
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
|
|
13
13
|
# span-panel-api-schema-0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "span-panel-api-schema-0"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.3.0b1"
|
|
4
4
|
description = "Flat-schema (data-model-version absent) parser for span-panel-api"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "SpanPanel"}
|
|
@@ -16,7 +16,12 @@ dependencies = [
|
|
|
16
16
|
# for `set_*_target` instead of `set_*_topic`. Against 3.0.x this wheel is
|
|
17
17
|
# rejected at discovery for carrying names that bootstrap does not require —
|
|
18
18
|
# so the floor moves with the rename rather than trailing it.
|
|
19
|
-
|
|
19
|
+
# Raised to 3.6.0, the first bootstrap whose snapshot carries `pv_inverters`
|
|
20
|
+
# and the inverter's `device_id` and `node_id`, which this parser fills.
|
|
21
|
+
# Against an earlier one, building a snapshot fails on those arguments.
|
|
22
|
+
# Raised to 3.7.0b1, whose snapshot fields accept the unknown values this
|
|
23
|
+
# parser now reports, where an earlier one types them as never unknown.
|
|
24
|
+
"span-panel-api>=3.7.0b1,<4.0",
|
|
20
25
|
]
|
|
21
26
|
|
|
22
27
|
[project.urls]
|
|
@@ -118,9 +118,9 @@ class HomiePropertyAccumulator:
|
|
|
118
118
|
"""Get a property's reported value."""
|
|
119
119
|
return self._property_values.get(node_id, {}).get(prop_id, default)
|
|
120
120
|
|
|
121
|
-
def get_timestamp(self, node_id: str, prop_id: str) -> int:
|
|
122
|
-
"""Get the epoch timestamp of a property's last update."""
|
|
123
|
-
return self._property_timestamps.get(node_id, {}).get(prop_id
|
|
121
|
+
def get_timestamp(self, node_id: str, prop_id: str) -> int | None:
|
|
122
|
+
"""Get the epoch timestamp of a property's last update, or None if it never arrived."""
|
|
123
|
+
return self._property_timestamps.get(node_id, {}).get(prop_id)
|
|
124
124
|
|
|
125
125
|
def get_target(self, node_id: str, prop_id: str) -> str | None:
|
|
126
126
|
"""Get a property's target value, or None if no target set."""
|
|
@@ -85,6 +85,16 @@ def _parse_int(value: str, default: int = 0) -> int:
|
|
|
85
85
|
return default
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def _latest(*timestamps: int | None) -> int | None:
|
|
89
|
+
"""The most recent of the timestamps that exist, or `None` when none do.
|
|
90
|
+
|
|
91
|
+
A property that has not arrived has no receipt time, so it takes no part
|
|
92
|
+
rather than standing in as `0`.
|
|
93
|
+
"""
|
|
94
|
+
arrived = [timestamp for timestamp in timestamps if timestamp is not None]
|
|
95
|
+
return max(arrived) if arrived else None
|
|
96
|
+
|
|
97
|
+
|
|
88
98
|
class HomieDeviceConsumer:
|
|
89
99
|
"""Build SPAN-specific snapshots from accumulated Homie property state.
|
|
90
100
|
|
|
@@ -220,12 +230,8 @@ class HomieDeviceConsumer:
|
|
|
220
230
|
cached = self._cached_snapshot
|
|
221
231
|
|
|
222
232
|
feed_metadata = self._build_feed_metadata()
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
for cid, circ in cached.circuits.items():
|
|
226
|
-
if cid.startswith("unmapped_tab_"):
|
|
227
|
-
continue # drop old unmapped entries; will recompute below
|
|
228
|
-
updated_circuits[cid] = circ
|
|
233
|
+
# Keep cached circuits, rebuild dirty ones
|
|
234
|
+
updated_circuits = dict(cached.circuits)
|
|
229
235
|
for node_id in dirty:
|
|
230
236
|
if self.is_circuit_node(node_id):
|
|
231
237
|
meta = feed_metadata.get(node_id, {})
|
|
@@ -234,11 +240,14 @@ class HomieDeviceConsumer:
|
|
|
234
240
|
circuit = self._build_circuit(node_id, device_type, relative_position)
|
|
235
241
|
updated_circuits[circuit.circuit_id] = circuit
|
|
236
242
|
|
|
237
|
-
#
|
|
238
|
-
|
|
239
|
-
updated_circuits.
|
|
243
|
+
# Uptime is a clock, not a property, so no dirty node announces that it
|
|
244
|
+
# moved; carrying the cached value would freeze it between full builds.
|
|
245
|
+
return dataclasses.replace(cached, circuits=updated_circuits, uptime_s=self._uptime_s())
|
|
240
246
|
|
|
241
|
-
|
|
247
|
+
def _uptime_s(self) -> int | None:
|
|
248
|
+
"""Connection uptime since $state==ready, measured here; none before the first ready."""
|
|
249
|
+
ready_since = self._acc.ready_since
|
|
250
|
+
return int(time.monotonic() - ready_since) if ready_since > 0.0 else None
|
|
242
251
|
|
|
243
252
|
def _find_lugs_node(self, direction: str) -> str | None:
|
|
244
253
|
"""Find the lugs node with a specific direction.
|
|
@@ -339,8 +348,8 @@ class HomieDeviceConsumer:
|
|
|
339
348
|
|
|
340
349
|
always_on = _parse_bool(self._acc.get_prop(node_id, "always-on"))
|
|
341
350
|
|
|
342
|
-
# Timestamps from MQTT
|
|
343
|
-
energy_ts =
|
|
351
|
+
# Timestamps from MQTT receipt time, `None` until the reading arrives
|
|
352
|
+
energy_ts = _latest(
|
|
344
353
|
self._acc.get_timestamp(node_id, "exported-energy"),
|
|
345
354
|
self._acc.get_timestamp(node_id, "imported-energy"),
|
|
346
355
|
)
|
|
@@ -423,13 +432,18 @@ class HomieDeviceConsumer:
|
|
|
423
432
|
feed = self._acc.get_prop(pv_node, "feed")
|
|
424
433
|
rel_pos = self._acc.get_prop(pv_node, "relative-position")
|
|
425
434
|
|
|
435
|
+
feed_circuit_id = normalize_circuit_id(feed) if feed else None
|
|
426
436
|
return SpanPVSnapshot(
|
|
427
437
|
vendor_name=vn if vn else None,
|
|
428
438
|
model=pn if pn else None,
|
|
429
439
|
software_version=sw if sw else None,
|
|
430
440
|
nameplate_capacity_w=_parse_float(nc) if nc else None,
|
|
431
|
-
feed_circuit_id=
|
|
441
|
+
feed_circuit_id=feed_circuit_id,
|
|
432
442
|
relative_position=rel_pos.upper() if rel_pos else None,
|
|
443
|
+
device_id=pv_node,
|
|
444
|
+
# The same key schema_1 gives this inverter, so a consumer's handle
|
|
445
|
+
# for it survives the panel's upgrade to the parent/child schema.
|
|
446
|
+
node_id=feed_circuit_id or pv_node,
|
|
433
447
|
)
|
|
434
448
|
|
|
435
449
|
def _build_evse_devices(self) -> dict[str, SpanEvseSnapshot]:
|
|
@@ -519,39 +533,6 @@ class HomieDeviceConsumer:
|
|
|
519
533
|
|
|
520
534
|
return "UNKNOWN"
|
|
521
535
|
|
|
522
|
-
def _build_unmapped_tabs(
|
|
523
|
-
self,
|
|
524
|
-
circuits: dict[str, SpanCircuitSnapshot],
|
|
525
|
-
) -> dict[str, SpanCircuitSnapshot]:
|
|
526
|
-
"""Synthesize unmapped tab entries for breaker positions with no circuit.
|
|
527
|
-
|
|
528
|
-
Creates zero-power SpanCircuitSnapshot entries for unoccupied positions
|
|
529
|
-
up to ``self._panel_size``.
|
|
530
|
-
"""
|
|
531
|
-
occupied_tabs: set[int] = set()
|
|
532
|
-
for circuit in circuits.values():
|
|
533
|
-
occupied_tabs.update(circuit.tabs)
|
|
534
|
-
|
|
535
|
-
unmapped: dict[str, SpanCircuitSnapshot] = {}
|
|
536
|
-
for tab in range(1, self._panel_size + 1):
|
|
537
|
-
if tab not in occupied_tabs:
|
|
538
|
-
circuit_id = f"unmapped_tab_{tab}"
|
|
539
|
-
unmapped[circuit_id] = SpanCircuitSnapshot(
|
|
540
|
-
circuit_id=circuit_id,
|
|
541
|
-
name=f"Unmapped Tab {tab}",
|
|
542
|
-
relay_state="CLOSED",
|
|
543
|
-
instant_power_w=0.0,
|
|
544
|
-
produced_energy_wh=0.0,
|
|
545
|
-
consumed_energy_wh=0.0,
|
|
546
|
-
tabs=[tab],
|
|
547
|
-
priority="UNKNOWN",
|
|
548
|
-
is_user_controllable=False,
|
|
549
|
-
is_sheddable=False,
|
|
550
|
-
is_never_backup=False,
|
|
551
|
-
)
|
|
552
|
-
|
|
553
|
-
return unmapped
|
|
554
|
-
|
|
555
536
|
def _build_snapshot(self) -> SpanPanelSnapshot:
|
|
556
537
|
"""Build full snapshot from accumulated property values."""
|
|
557
538
|
core_node = self._acc.find_node_by_type(TYPE_CORE)
|
|
@@ -665,10 +646,6 @@ class HomieDeviceConsumer:
|
|
|
665
646
|
circuit = self._build_circuit(node_id, device_type, relative_position)
|
|
666
647
|
circuits[circuit.circuit_id] = circuit
|
|
667
648
|
|
|
668
|
-
# Synthesize unmapped tab entries
|
|
669
|
-
unmapped = self._build_unmapped_tabs(circuits)
|
|
670
|
-
circuits.update(unmapped)
|
|
671
|
-
|
|
672
649
|
# Battery, PV, and EVSE metadata
|
|
673
650
|
battery = self._build_battery()
|
|
674
651
|
pv = self._build_pv()
|
|
@@ -685,9 +662,6 @@ class HomieDeviceConsumer:
|
|
|
685
662
|
dsm_state = self._derive_dsm_state(core_node, grid_power, power_flow_grid)
|
|
686
663
|
current_run_config = self._derive_run_config(dsm_state, grid_islandable, dominant_power_source)
|
|
687
664
|
|
|
688
|
-
# Connection uptime since $state==ready
|
|
689
|
-
uptime = int(time.monotonic() - self._acc.ready_since) if self._acc.ready_since > 0.0 else 0
|
|
690
|
-
|
|
691
665
|
return SpanPanelSnapshot(
|
|
692
666
|
serial_number=self._acc.serial_number,
|
|
693
667
|
firmware_version=firmware,
|
|
@@ -701,8 +675,10 @@ class HomieDeviceConsumer:
|
|
|
701
675
|
dsm_state=dsm_state,
|
|
702
676
|
current_run_config=current_run_config,
|
|
703
677
|
door_state=door_state,
|
|
704
|
-
|
|
705
|
-
|
|
678
|
+
# No flat property reports proximity. Readiness is not a stand-in:
|
|
679
|
+
# a snapshot is only built once ready, so it would always say True.
|
|
680
|
+
proximity_proven=None,
|
|
681
|
+
uptime_s=self._uptime_s(),
|
|
706
682
|
eth0_link=eth0,
|
|
707
683
|
wlan_link=wlan,
|
|
708
684
|
wwan_link=wwan_connected,
|
|
@@ -726,5 +702,8 @@ class HomieDeviceConsumer:
|
|
|
726
702
|
circuits=circuits,
|
|
727
703
|
battery=battery,
|
|
728
704
|
pv=pv,
|
|
705
|
+
# Flat publishes one inverter at most, so this is `pv` again, keyed
|
|
706
|
+
# as schema_1 keys each of its inverters.
|
|
707
|
+
pv_inverters={pv.node_id: pv} if pv.node_id is not None else {},
|
|
729
708
|
evse=evse,
|
|
730
709
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|