span-panel-api-schema-0 1.2.0__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.
@@ -9,6 +9,25 @@ 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
+
12
31
  ## [1.2.0]
13
32
 
14
33
  The panel's one inverter is reported through `pv_inverters` as well as `pv`. Requires `span-panel-api` **3.6.0 or newer**.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: span-panel-api-schema-0
3
- Version: 1.2.0
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.6.0
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.2.0"
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"}
@@ -19,7 +19,9 @@ dependencies = [
19
19
  # Raised to 3.6.0, the first bootstrap whose snapshot carries `pv_inverters`
20
20
  # and the inverter's `device_id` and `node_id`, which this parser fills.
21
21
  # Against an earlier one, building a snapshot fails on those arguments.
22
- "span-panel-api>=3.6.0,<4.0",
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",
23
25
  ]
24
26
 
25
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, 0)
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
- updated_circuits: dict[str, SpanCircuitSnapshot] = {}
224
- # Keep only non-unmapped circuits from cache, rebuild dirty ones
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
- # Recompute unmapped tabs based on current circuit set
238
- unmapped = self._build_unmapped_tabs(updated_circuits)
239
- updated_circuits.update(unmapped)
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
- return dataclasses.replace(cached, circuits=updated_circuits)
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 arrival time
343
- energy_ts = max(
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
  )
@@ -524,39 +533,6 @@ class HomieDeviceConsumer:
524
533
 
525
534
  return "UNKNOWN"
526
535
 
527
- def _build_unmapped_tabs(
528
- self,
529
- circuits: dict[str, SpanCircuitSnapshot],
530
- ) -> dict[str, SpanCircuitSnapshot]:
531
- """Synthesize unmapped tab entries for breaker positions with no circuit.
532
-
533
- Creates zero-power SpanCircuitSnapshot entries for unoccupied positions
534
- up to ``self._panel_size``.
535
- """
536
- occupied_tabs: set[int] = set()
537
- for circuit in circuits.values():
538
- occupied_tabs.update(circuit.tabs)
539
-
540
- unmapped: dict[str, SpanCircuitSnapshot] = {}
541
- for tab in range(1, self._panel_size + 1):
542
- if tab not in occupied_tabs:
543
- circuit_id = f"unmapped_tab_{tab}"
544
- unmapped[circuit_id] = SpanCircuitSnapshot(
545
- circuit_id=circuit_id,
546
- name=f"Unmapped Tab {tab}",
547
- relay_state="CLOSED",
548
- instant_power_w=0.0,
549
- produced_energy_wh=0.0,
550
- consumed_energy_wh=0.0,
551
- tabs=[tab],
552
- priority="UNKNOWN",
553
- is_user_controllable=False,
554
- is_sheddable=False,
555
- is_never_backup=False,
556
- )
557
-
558
- return unmapped
559
-
560
536
  def _build_snapshot(self) -> SpanPanelSnapshot:
561
537
  """Build full snapshot from accumulated property values."""
562
538
  core_node = self._acc.find_node_by_type(TYPE_CORE)
@@ -670,10 +646,6 @@ class HomieDeviceConsumer:
670
646
  circuit = self._build_circuit(node_id, device_type, relative_position)
671
647
  circuits[circuit.circuit_id] = circuit
672
648
 
673
- # Synthesize unmapped tab entries
674
- unmapped = self._build_unmapped_tabs(circuits)
675
- circuits.update(unmapped)
676
-
677
649
  # Battery, PV, and EVSE metadata
678
650
  battery = self._build_battery()
679
651
  pv = self._build_pv()
@@ -690,9 +662,6 @@ class HomieDeviceConsumer:
690
662
  dsm_state = self._derive_dsm_state(core_node, grid_power, power_flow_grid)
691
663
  current_run_config = self._derive_run_config(dsm_state, grid_islandable, dominant_power_source)
692
664
 
693
- # Connection uptime since $state==ready
694
- uptime = int(time.monotonic() - self._acc.ready_since) if self._acc.ready_since > 0.0 else 0
695
-
696
665
  return SpanPanelSnapshot(
697
666
  serial_number=self._acc.serial_number,
698
667
  firmware_version=firmware,
@@ -706,8 +675,10 @@ class HomieDeviceConsumer:
706
675
  dsm_state=dsm_state,
707
676
  current_run_config=current_run_config,
708
677
  door_state=door_state,
709
- proximity_proven=self._acc.is_ready(),
710
- uptime_s=uptime,
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(),
711
682
  eth0_link=eth0,
712
683
  wlan_link=wlan,
713
684
  wwan_link=wwan_connected,