span-panel-api-schema-0 1.0.0__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.0.0/.gitignore +47 -0
- span_panel_api_schema_0-1.0.0/CHANGELOG.md +54 -0
- span_panel_api_schema_0-1.0.0/PKG-INFO +45 -0
- span_panel_api_schema_0-1.0.0/README.md +33 -0
- span_panel_api_schema_0-1.0.0/pyproject.toml +38 -0
- span_panel_api_schema_0-1.0.0/src/span_panel_api_schema_0/__init__.py +11 -0
- span_panel_api_schema_0-1.0.0/src/span_panel_api_schema_0/accumulator.py +274 -0
- span_panel_api_schema_0-1.0.0/src/span_panel_api_schema_0/adapter.py +115 -0
- span_panel_api_schema_0-1.0.0/src/span_panel_api_schema_0/const.py +83 -0
- span_panel_api_schema_0-1.0.0/src/span_panel_api_schema_0/consumer.py +647 -0
- span_panel_api_schema_0-1.0.0/src/span_panel_api_schema_0/field_metadata.py +205 -0
- span_panel_api_schema_0-1.0.0/src/span_panel_api_schema_0/py.typed +0 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
build/
|
|
3
|
+
dist/
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
.cursor/
|
|
7
|
+
.cursorignore
|
|
8
|
+
.cursor
|
|
9
|
+
.cursorindexingignore
|
|
10
|
+
xnotes/*
|
|
11
|
+
|
|
12
|
+
# pyenv
|
|
13
|
+
.python-version
|
|
14
|
+
|
|
15
|
+
# Environments
|
|
16
|
+
.env
|
|
17
|
+
.envrc
|
|
18
|
+
.venv
|
|
19
|
+
.vscode/settings.json
|
|
20
|
+
|
|
21
|
+
# mypy
|
|
22
|
+
.mypy_cache/
|
|
23
|
+
.dmypy.json
|
|
24
|
+
dmypy.json
|
|
25
|
+
|
|
26
|
+
# ruff
|
|
27
|
+
.ruff_cache/
|
|
28
|
+
|
|
29
|
+
# JetBrains
|
|
30
|
+
.idea/
|
|
31
|
+
|
|
32
|
+
/coverage.xml
|
|
33
|
+
/.coverage
|
|
34
|
+
coverage_output.log
|
|
35
|
+
**/.DS_Store
|
|
36
|
+
.local_coverage_data
|
|
37
|
+
|
|
38
|
+
# Captures taken from a real panel. These carry the panel's serial (which is also
|
|
39
|
+
# its MQTT username), the household's circuit names, and real consumption — none
|
|
40
|
+
# of which belongs in a repository. The differential that reads them commits its
|
|
41
|
+
# *verdict* only, never the capture, and skips when the file is absent.
|
|
42
|
+
tests/fixtures/live_*.json
|
|
43
|
+
|
|
44
|
+
# Peer checkouts. CI clones the eBus specification and SpanPanel/panelbench here so
|
|
45
|
+
# the provenance checks have something to compare vendored bytes against; the same
|
|
46
|
+
# layout works locally if you would rather not point .env at siblings.
|
|
47
|
+
/peers/
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `span-panel-api-schema-0` are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is fixed — the flat single-device schema, SPAN firmware `r202603` through `r202627` — and is identified by `SUPPORTS_DATA_MODEL_VERSIONS`
|
|
8
|
+
rather than by this version number. A release here means this parser changed, never that the panel did.
|
|
9
|
+
|
|
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
|
+
|
|
12
|
+
## [1.0.0]
|
|
13
|
+
|
|
14
|
+
First release as a standalone distribution. Requires `span-panel-api` 3.0.0 or newer.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **The flat-schema parser, extracted from `span-panel-api` 2.6.4.** Relocated from `span_panel_api._impl.schema_0` to `span_panel_api_schema_0`, and registered as `schema_0` under the `span_panel_api.schema_adapters` entry-point group, which is the only
|
|
19
|
+
way `span-panel-api` reaches it — the bootstrap never imports this package. Installing it is what makes flat-schema panels work; `span-panel-api` alone connects and then raises `SpanPanelAdapterMissingError` naming the adapter it could not find.
|
|
20
|
+
- **`HomieLifecycle`, `HomiePropertyAccumulator` and `HomieDeviceConsumer` live here now.** All three left the bootstrap because they are flat-schema-specific rather than Homie-convention-level: the accumulator filters every topic against a single device's
|
|
21
|
+
prefix and stores `node → prop`, and `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description received ⇒ ready".
|
|
22
|
+
- **`SCHEMA_ANCHOR`** (`sha256:d347556a07d98f40`, firmware `spanos2/r202603/05`) — the schema revision every hardcoded fact in this package was read from, with `SCHEMA_ANCHOR_FIELD` naming the field it comes from (`typesSchemaHash`). The field is
|
|
23
|
+
per-adapter: parent/child firmware renames it to `deviceClassesSchemaHash` along with the block it covers, so `schema_1` declares its own rather than inheriting one that does not exist on its firmware.
|
|
24
|
+
- **`ADAPTER_CONTRACT = 1`**, declaring which version of the bootstrap-to-adapter contract this parser was built against. Declared as a literal rather than imported from `span_panel_api.protocol`: a value read from the installed bootstrap would agree with
|
|
25
|
+
every bootstrap, which is exactly the disagreement the check exists to find.
|
|
26
|
+
- **`dominant_power_source_payload`.** Flat already speaks this vocabulary, so the value passes through — the method exists because `schema_1` must translate, and a caller should not have to know which schema is underneath. Validated rather than passed
|
|
27
|
+
blindly: an unrecognised value returns `None` and the transport refuses the command, matching `schema_1` rather than putting a string outside the enum on the wire.
|
|
28
|
+
- **`set_evse_charge_limit_topic` and `evse_charge_limit_payload`.** Both are required of every adapter, because `_derive_required_members` makes each public protocol member mandatory of every adapter wheel — an adapter without them is rejected at
|
|
29
|
+
discovery no matter which panel it would have parsed. Flat firmware publishes no charge-limit surface, so this distribution answers for the absence rather than for a topic; the point is that answering is not optional.
|
|
30
|
+
- **`adopted_devices` reports empty.** Adoption is a parent/child idea: a flat panel is one device with no unmodelled children to adopt, so the honest answer is a stable empty tuple rather than an unimplemented member. `set_adopted_property` therefore
|
|
31
|
+
raises on a flat panel for the same reason it raises for a device that does not exist, which is what makes the snapshot lookup an authorization rather than a lookup.
|
|
32
|
+
- **Panel size is derived here**, by reading the circuit `space` format out of the flat schema's `types` block — knowledge that belongs to this package rather than to the transport, which previously did it on every adapter's behalf.
|
|
33
|
+
- **Provenance tests** asserting that all 64 hardcoded `(node_type, property_id)` pairs still resolve against the captured schema, that `HOMIE_DOMAIN` / `HOMIE_VERSION` still match it, and that the two lugs subtypes real firmware publishes remain absent
|
|
34
|
+
from the schema _and_ present in the metadata alias table. This is the only signal that catches schema drift before release; every other symptom reaches production as a silent absence.
|
|
35
|
+
- **A `py.typed` marker**, so consumers type-check against this package's real annotations rather than resolving everything it exports as `Any`.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **BREAKING — DER identity is translated into the parent/child vocabulary rather than mirroring flat's names.** `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike; `product_name` is retired on all three. Flat
|
|
40
|
+
is the irregular side: it puts the SKU in `bess/model` and in `evse/part-number` — the same concept under two names — and gives PV neither. Mirroring that would have permanently encoded flat's irregularity in the snapshot, so this adapter normalises
|
|
41
|
+
instead: `bess/model` → `part_number`, `bess/product-name` → `model`. **`battery.model` changes value for existing flat users at this upgrade.** Measured: every EVSE identity field now reads identically on both adapters, so for that device class identity
|
|
42
|
+
stops being a migration delta at all.
|
|
43
|
+
|
|
44
|
+
### Known deviations from the published schema
|
|
45
|
+
|
|
46
|
+
- **Circuit `active-power` is treated as watts, though the schema declares kilowatts.** Real panels publish watts; this was established against live hardware and the 1000× correction was removed accordingly. A test asserts the schema still says `kW`, so
|
|
47
|
+
the day SPAN corrects it we find out rather than discovering it as a factor-of-1000 error.
|
|
48
|
+
- **`energy.ebus.device.lugs.upstream` / `.downstream` are parsed but undeclared.** Firmware publishes these node types in `$description`; the schema declares only the base `energy.ebus.device.lugs`. Property metadata for them resolves through an alias to
|
|
49
|
+
the base type.
|
|
50
|
+
|
|
51
|
+
### Retirement
|
|
52
|
+
|
|
53
|
+
SPAN retires the flat schema in the same firmware release that introduces the parent/child model (`r202633`; fleet rollout projected, not committed, for the first two weeks of September 2026). This package stops being published once the fleet has moved.
|
|
54
|
+
Published versions remain on PyPI for anyone still running older firmware.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: span-panel-api-schema-0
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Flat-schema (data-model-version absent) parser for span-panel-api
|
|
5
|
+
Project-URL: Homepage, https://github.com/SpanPanel/span-panel-api
|
|
6
|
+
Project-URL: Issues, https://github.com/SpanPanel/span-panel-api/issues
|
|
7
|
+
Author: SpanPanel
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Python: <4.0,>=3.14
|
|
10
|
+
Requires-Dist: span-panel-api<4.0,>=3.0.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# span-panel-api-schema-0
|
|
14
|
+
|
|
15
|
+
The **flat-schema** parser for [`span-panel-api`](https://github.com/SpanPanel/span-panel-api): the single-device Homie model published by SPAN firmware `r202603` through `r202627`, which carries no `data-model-version`.
|
|
16
|
+
|
|
17
|
+
## Why this is a separate distribution
|
|
18
|
+
|
|
19
|
+
`span-panel-api` is a transport and a dispatcher. It knows how to connect to a panel's MQTT broker, route messages, and choose a parser — but it contains no parsing code and no Homie type strings. Each wire format ships as its own distribution and
|
|
20
|
+
registers itself under the `span_panel_api.schema_adapters` entry-point group.
|
|
21
|
+
|
|
22
|
+
That split exists because the two halves break on different axes. The wire format changes when SPAN ships firmware; the library API changes when we do. Separate distributions let each carry its own version, so a consumer can pin them independently and add
|
|
23
|
+
support for a new panel schema by installing a package rather than by upgrading the transport.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```console
|
|
28
|
+
pip install "span-panel-api[schema-0]"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Installing this package is what makes flat-schema panels work. `span-panel-api` on its own will connect and then raise `SpanPanelAdapterMissingError` naming the adapter it could not find.
|
|
32
|
+
|
|
33
|
+
A consumer that wants to support panels on either schema installs both adapters:
|
|
34
|
+
|
|
35
|
+
```console
|
|
36
|
+
pip install "span-panel-api[schema-0,schema-1]"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Dispatch happens at runtime, per panel, from the `data-model-version` the panel reports. The extras are the recommended spelling because they give `pip install -U` a correct upgrade path — the dependency arrow runs from adapter to bootstrap, so upgrading
|
|
40
|
+
the bootstrap alone would otherwise leave a stale adapter wheel that discovery then rejects, with pip reporting success. Naming the distributions directly works too.
|
|
41
|
+
|
|
42
|
+
## Retirement
|
|
43
|
+
|
|
44
|
+
SPAN retires the flat schema in the same release that introduces the parent/child model (`r202633`, fleet rollout projected for early September 2026). When the fleet has moved, consumers drop this package from their requirements. Published versions stay on
|
|
45
|
+
PyPI for anyone still running older firmware.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# span-panel-api-schema-0
|
|
2
|
+
|
|
3
|
+
The **flat-schema** parser for [`span-panel-api`](https://github.com/SpanPanel/span-panel-api): the single-device Homie model published by SPAN firmware `r202603` through `r202627`, which carries no `data-model-version`.
|
|
4
|
+
|
|
5
|
+
## Why this is a separate distribution
|
|
6
|
+
|
|
7
|
+
`span-panel-api` is a transport and a dispatcher. It knows how to connect to a panel's MQTT broker, route messages, and choose a parser — but it contains no parsing code and no Homie type strings. Each wire format ships as its own distribution and
|
|
8
|
+
registers itself under the `span_panel_api.schema_adapters` entry-point group.
|
|
9
|
+
|
|
10
|
+
That split exists because the two halves break on different axes. The wire format changes when SPAN ships firmware; the library API changes when we do. Separate distributions let each carry its own version, so a consumer can pin them independently and add
|
|
11
|
+
support for a new panel schema by installing a package rather than by upgrading the transport.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```console
|
|
16
|
+
pip install "span-panel-api[schema-0]"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Installing this package is what makes flat-schema panels work. `span-panel-api` on its own will connect and then raise `SpanPanelAdapterMissingError` naming the adapter it could not find.
|
|
20
|
+
|
|
21
|
+
A consumer that wants to support panels on either schema installs both adapters:
|
|
22
|
+
|
|
23
|
+
```console
|
|
24
|
+
pip install "span-panel-api[schema-0,schema-1]"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Dispatch happens at runtime, per panel, from the `data-model-version` the panel reports. The extras are the recommended spelling because they give `pip install -U` a correct upgrade path — the dependency arrow runs from adapter to bootstrap, so upgrading
|
|
28
|
+
the bootstrap alone would otherwise leave a stale adapter wheel that discovery then rejects, with pip reporting success. Naming the distributions directly works too.
|
|
29
|
+
|
|
30
|
+
## Retirement
|
|
31
|
+
|
|
32
|
+
SPAN retires the flat schema in the same release that introduces the parent/child model (`r202633`, fleet rollout projected for early September 2026). When the fleet has moved, consumers drop this package from their requirements. Published versions stay on
|
|
33
|
+
PyPI for anyone still running older firmware.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "span-panel-api-schema-0"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Flat-schema (data-model-version absent) parser for span-panel-api"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "SpanPanel"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.14,<4.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
# Stated as a stable version rather than as the prerelease the floor tracked
|
|
13
|
+
# during development: naming a prerelease in a specifier is pip's own signal
|
|
14
|
+
# that prereleases are acceptable for that requirement.
|
|
15
|
+
"span-panel-api>=3.0.0,<4.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/SpanPanel/span-panel-api"
|
|
20
|
+
Issues = "https://github.com/SpanPanel/span-panel-api/issues"
|
|
21
|
+
|
|
22
|
+
# The whole point of this distribution. The bootstrap finds this adapter by
|
|
23
|
+
# discovering the group, never by importing this package.
|
|
24
|
+
[project.entry-points."span_panel_api.schema_adapters"]
|
|
25
|
+
schema_0 = "span_panel_api_schema_0:SchemaZeroAdapter"
|
|
26
|
+
|
|
27
|
+
# Resolve the bootstrap from the workspace when developing here. Published
|
|
28
|
+
# wheels are unaffected: this table is uv-only metadata and the dependency
|
|
29
|
+
# above is what a consumer installing from PyPI sees.
|
|
30
|
+
[tool.uv.sources]
|
|
31
|
+
span-panel-api = { workspace = true }
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/span_panel_api_schema_0"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Flat-schema adapter package (data-model-version absent)."""
|
|
2
|
+
|
|
3
|
+
from span_panel_api_schema_0.adapter import SchemaZeroAdapter
|
|
4
|
+
|
|
5
|
+
# Re-exported from the adapter rather than restated. The protocol requires the
|
|
6
|
+
# range as a class attribute, so the class is the source of truth; a second
|
|
7
|
+
# literal here would be free to drift, and nothing would notice until a panel
|
|
8
|
+
# reported a version this adapter claims — falsely — to support.
|
|
9
|
+
SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = SchemaZeroAdapter.SUPPORTS_DATA_MODEL_VERSIONS
|
|
10
|
+
|
|
11
|
+
__all__ = ["SUPPORTS_DATA_MODEL_VERSIONS", "SchemaZeroAdapter"]
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Homie v5 property accumulator with lifecycle state machine.
|
|
2
|
+
|
|
3
|
+
Generic Homie protocol layer that knows nothing about SPAN-specific
|
|
4
|
+
concepts. Stores property values, tracks lifecycle, and exposes a
|
|
5
|
+
query API for higher-level consumers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
import enum
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from span_panel_api.mqtt.const import HOMIE_STATE_DISCONNECTED, HOMIE_STATE_LOST, HOMIE_STATE_READY
|
|
17
|
+
from span_panel_api_schema_0.const import TOPIC_PREFIX
|
|
18
|
+
|
|
19
|
+
_LOGGER = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Callback signature: (node_id, prop_id, value, old_value)
|
|
22
|
+
PropertyCallback = Callable[[str, str, str, str | None], None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HomieLifecycle(enum.Enum):
|
|
26
|
+
"""Homie device lifecycle states."""
|
|
27
|
+
|
|
28
|
+
DISCONNECTED = "disconnected"
|
|
29
|
+
CONNECTED = "connected"
|
|
30
|
+
DESCRIPTION_RECEIVED = "description_received"
|
|
31
|
+
READY = "ready"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HomiePropertyAccumulator:
|
|
35
|
+
"""Accumulate Homie v5 property values and track device lifecycle.
|
|
36
|
+
|
|
37
|
+
Topic prefix: ``ebus/5/{serial_number}``
|
|
38
|
+
|
|
39
|
+
All methods must be called from the asyncio event loop thread.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, serial_number: str) -> None:
|
|
43
|
+
self._serial_number = serial_number
|
|
44
|
+
self._topic_prefix = f"{TOPIC_PREFIX}/{serial_number}"
|
|
45
|
+
|
|
46
|
+
# Lifecycle
|
|
47
|
+
self._lifecycle = HomieLifecycle.DISCONNECTED
|
|
48
|
+
self._received_state_ready = False
|
|
49
|
+
self._received_description = False
|
|
50
|
+
self._ready_since: float = 0.0
|
|
51
|
+
|
|
52
|
+
# Property storage
|
|
53
|
+
self._property_values: dict[str, dict[str, str]] = {}
|
|
54
|
+
self._property_timestamps: dict[str, dict[str, int]] = {}
|
|
55
|
+
self._target_values: dict[str, dict[str, str]] = {}
|
|
56
|
+
|
|
57
|
+
# Node type mapping from $description
|
|
58
|
+
self._node_types: dict[str, str] = {}
|
|
59
|
+
|
|
60
|
+
# Dirty tracking
|
|
61
|
+
self._dirty_nodes: set[str] = set()
|
|
62
|
+
|
|
63
|
+
# Callbacks
|
|
64
|
+
self._property_callbacks: list[PropertyCallback] = []
|
|
65
|
+
|
|
66
|
+
# -- Public properties ---------------------------------------------------
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def serial_number(self) -> str:
|
|
70
|
+
"""Serial number this accumulator tracks."""
|
|
71
|
+
return self._serial_number
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def lifecycle(self) -> HomieLifecycle:
|
|
75
|
+
"""Current lifecycle state."""
|
|
76
|
+
return self._lifecycle
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def ready_since(self) -> float:
|
|
80
|
+
"""Monotonic timestamp of the last READY transition, 0.0 if never ready."""
|
|
81
|
+
return self._ready_since
|
|
82
|
+
|
|
83
|
+
def is_ready(self) -> bool:
|
|
84
|
+
"""True when lifecycle is READY."""
|
|
85
|
+
return self._lifecycle == HomieLifecycle.READY
|
|
86
|
+
|
|
87
|
+
# -- Message routing -----------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def handle_message(self, topic: str, payload: str) -> None:
|
|
90
|
+
"""Route an MQTT message to the appropriate handler."""
|
|
91
|
+
prefix_with_sep = f"{self._topic_prefix}/"
|
|
92
|
+
if not topic.startswith(prefix_with_sep):
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
suffix = topic[len(prefix_with_sep) :]
|
|
96
|
+
|
|
97
|
+
if suffix == "$state":
|
|
98
|
+
self._handle_state(payload)
|
|
99
|
+
elif suffix == "$description":
|
|
100
|
+
self._handle_description(payload)
|
|
101
|
+
elif suffix.endswith("/set"):
|
|
102
|
+
return # ignore /set topics
|
|
103
|
+
elif "/" in suffix:
|
|
104
|
+
parts = suffix.split("/", 1)
|
|
105
|
+
node_id = parts[0]
|
|
106
|
+
prop_part = parts[1]
|
|
107
|
+
if prop_part.endswith("/$target"):
|
|
108
|
+
# Target value: {node_id}/{prop_id}/$target
|
|
109
|
+
prop_id = prop_part[: -len("/$target")]
|
|
110
|
+
self._handle_target(node_id, prop_id, payload)
|
|
111
|
+
else:
|
|
112
|
+
# Reported value: {node_id}/{prop_id}
|
|
113
|
+
self._handle_property(node_id, prop_part, payload)
|
|
114
|
+
|
|
115
|
+
# -- Query API -----------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def get_prop(self, node_id: str, prop_id: str, default: str = "") -> str:
|
|
118
|
+
"""Get a property's reported value."""
|
|
119
|
+
return self._property_values.get(node_id, {}).get(prop_id, default)
|
|
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)
|
|
124
|
+
|
|
125
|
+
def get_target(self, node_id: str, prop_id: str) -> str | None:
|
|
126
|
+
"""Get a property's target value, or None if no target set."""
|
|
127
|
+
return self._target_values.get(node_id, {}).get(prop_id)
|
|
128
|
+
|
|
129
|
+
def has_target(self, node_id: str, prop_id: str) -> bool:
|
|
130
|
+
"""True if a target value exists for the given property."""
|
|
131
|
+
return prop_id in self._target_values.get(node_id, {})
|
|
132
|
+
|
|
133
|
+
def find_node_by_type(self, type_str: str) -> str | None:
|
|
134
|
+
"""Find the first node ID matching a given type string."""
|
|
135
|
+
for node_id, node_type in self._node_types.items():
|
|
136
|
+
if node_type == type_str:
|
|
137
|
+
return node_id
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def nodes_by_type(self, type_str: str) -> list[str]:
|
|
141
|
+
"""Return all node IDs matching a given type string."""
|
|
142
|
+
return [nid for nid, ntype in self._node_types.items() if ntype == type_str]
|
|
143
|
+
|
|
144
|
+
def get_node_type(self, node_id: str) -> str:
|
|
145
|
+
"""Get the type string for a node, or empty string if unknown."""
|
|
146
|
+
return self._node_types.get(node_id, "")
|
|
147
|
+
|
|
148
|
+
def all_node_types(self) -> dict[str, str]:
|
|
149
|
+
"""Return a copy of the node_id → type mapping."""
|
|
150
|
+
return dict(self._node_types)
|
|
151
|
+
|
|
152
|
+
def dirty_node_ids(self) -> frozenset[str]:
|
|
153
|
+
"""Return the set of node IDs with changed properties since last mark_clean."""
|
|
154
|
+
return frozenset(self._dirty_nodes)
|
|
155
|
+
|
|
156
|
+
def mark_clean(self) -> None:
|
|
157
|
+
"""Clear the dirty set."""
|
|
158
|
+
self._dirty_nodes.clear()
|
|
159
|
+
|
|
160
|
+
def register_property_callback(self, callback: PropertyCallback) -> Callable[[], None]:
|
|
161
|
+
"""Register a callback fired on property value changes.
|
|
162
|
+
|
|
163
|
+
Callback signature: (node_id, prop_id, new_value, old_value).
|
|
164
|
+
Only fires when the value actually changes, not on every message.
|
|
165
|
+
|
|
166
|
+
Returns an unregister function.
|
|
167
|
+
"""
|
|
168
|
+
self._property_callbacks.append(callback)
|
|
169
|
+
|
|
170
|
+
def unregister() -> None:
|
|
171
|
+
try:
|
|
172
|
+
self._property_callbacks.remove(callback)
|
|
173
|
+
except ValueError:
|
|
174
|
+
_LOGGER.debug("Callback already unregistered")
|
|
175
|
+
|
|
176
|
+
return unregister
|
|
177
|
+
|
|
178
|
+
# -- Internal handlers ---------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _handle_state(self, payload: str) -> None:
|
|
181
|
+
"""Handle $state topic and drive lifecycle transitions."""
|
|
182
|
+
if payload == HOMIE_STATE_READY:
|
|
183
|
+
self._received_state_ready = True
|
|
184
|
+
if self._received_description:
|
|
185
|
+
self._transition_to_ready()
|
|
186
|
+
else:
|
|
187
|
+
# State ready but no description yet
|
|
188
|
+
if self._lifecycle == HomieLifecycle.DISCONNECTED:
|
|
189
|
+
self._lifecycle = HomieLifecycle.CONNECTED
|
|
190
|
+
elif payload in (HOMIE_STATE_DISCONNECTED, HOMIE_STATE_LOST):
|
|
191
|
+
self._lifecycle = HomieLifecycle.DISCONNECTED
|
|
192
|
+
self._received_state_ready = False
|
|
193
|
+
self._received_description = False
|
|
194
|
+
else:
|
|
195
|
+
# init, sleeping, alert, etc. — connected but not ready
|
|
196
|
+
if self._lifecycle == HomieLifecycle.DISCONNECTED:
|
|
197
|
+
self._lifecycle = HomieLifecycle.CONNECTED
|
|
198
|
+
self._received_state_ready = False
|
|
199
|
+
|
|
200
|
+
_LOGGER.debug("Homie $state: %s → lifecycle=%s", payload, self._lifecycle.value)
|
|
201
|
+
|
|
202
|
+
def _handle_description(self, payload: str) -> None:
|
|
203
|
+
"""Parse $description JSON and extract node type mappings."""
|
|
204
|
+
try:
|
|
205
|
+
desc = json.loads(payload)
|
|
206
|
+
except json.JSONDecodeError:
|
|
207
|
+
_LOGGER.warning("Invalid $description JSON")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
self._received_description = True
|
|
211
|
+
self._node_types.clear()
|
|
212
|
+
|
|
213
|
+
nodes = desc.get("nodes", {})
|
|
214
|
+
if isinstance(nodes, dict):
|
|
215
|
+
for node_id, node_def in nodes.items():
|
|
216
|
+
if isinstance(node_def, dict):
|
|
217
|
+
node_type = node_def.get("type", "")
|
|
218
|
+
if isinstance(node_type, str):
|
|
219
|
+
self._node_types[str(node_id)] = node_type
|
|
220
|
+
|
|
221
|
+
# Mark all known nodes dirty
|
|
222
|
+
self._dirty_nodes.update(self._node_types.keys())
|
|
223
|
+
|
|
224
|
+
_LOGGER.debug("Parsed $description with %d nodes", len(self._node_types))
|
|
225
|
+
|
|
226
|
+
# Lifecycle transition
|
|
227
|
+
if self._received_state_ready:
|
|
228
|
+
self._transition_to_ready()
|
|
229
|
+
elif self._lifecycle in (HomieLifecycle.DISCONNECTED, HomieLifecycle.CONNECTED):
|
|
230
|
+
self._lifecycle = HomieLifecycle.DESCRIPTION_RECEIVED
|
|
231
|
+
|
|
232
|
+
def _handle_property(self, node_id: str, prop_id: str, value: str) -> None:
|
|
233
|
+
"""Handle a reported property value update."""
|
|
234
|
+
now_s = int(time.time())
|
|
235
|
+
|
|
236
|
+
if node_id not in self._property_values:
|
|
237
|
+
self._property_values[node_id] = {}
|
|
238
|
+
self._property_timestamps[node_id] = {}
|
|
239
|
+
|
|
240
|
+
old_value = self._property_values[node_id].get(prop_id)
|
|
241
|
+
|
|
242
|
+
if old_value == value:
|
|
243
|
+
return # no change — no dirty, no callbacks, no timestamp bump
|
|
244
|
+
|
|
245
|
+
self._property_values[node_id][prop_id] = value
|
|
246
|
+
self._property_timestamps[node_id][prop_id] = now_s
|
|
247
|
+
self._dirty_nodes.add(node_id)
|
|
248
|
+
|
|
249
|
+
self._fire_callbacks(node_id, prop_id, value, old_value)
|
|
250
|
+
|
|
251
|
+
def _handle_target(self, node_id: str, prop_id: str, value: str) -> None:
|
|
252
|
+
"""Handle a $target property value."""
|
|
253
|
+
if node_id not in self._target_values:
|
|
254
|
+
self._target_values[node_id] = {}
|
|
255
|
+
|
|
256
|
+
old_target = self._target_values[node_id].get(prop_id)
|
|
257
|
+
if old_target == value:
|
|
258
|
+
return # no change
|
|
259
|
+
|
|
260
|
+
self._target_values[node_id][prop_id] = value
|
|
261
|
+
self._dirty_nodes.add(node_id)
|
|
262
|
+
|
|
263
|
+
def _transition_to_ready(self) -> None:
|
|
264
|
+
"""Transition lifecycle to READY."""
|
|
265
|
+
self._lifecycle = HomieLifecycle.READY
|
|
266
|
+
self._ready_since = time.monotonic()
|
|
267
|
+
|
|
268
|
+
def _fire_callbacks(self, node_id: str, prop_id: str, value: str, old_value: str | None) -> None:
|
|
269
|
+
"""Fire all registered property callbacks, catching exceptions."""
|
|
270
|
+
for cb in self._property_callbacks:
|
|
271
|
+
try:
|
|
272
|
+
cb(node_id, prop_id, value, old_value)
|
|
273
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
274
|
+
_LOGGER.debug("Property callback error for %s/%s", node_id, prop_id, exc_info=True)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Flat-schema (data-model-version absent) adapter.
|
|
2
|
+
|
|
3
|
+
Composes the existing accumulator + consumer and owns the flat wire format:
|
|
4
|
+
a single Homie device whose node ids are circuit UUIDs and capability names.
|
|
5
|
+
Nothing outside this package constructs a flat-schema topic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator
|
|
14
|
+
from span_panel_api_schema_0.const import PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT
|
|
15
|
+
from span_panel_api_schema_0.consumer import HomieDeviceConsumer
|
|
16
|
+
from span_panel_api_schema_0.field_metadata import build_field_metadata
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SchemaZeroAdapter:
|
|
23
|
+
"""Parser for the flat single-device schema (firmware r202603-r202627)."""
|
|
24
|
+
|
|
25
|
+
# A literal, deliberately not imported from span_panel_api.protocol: a value
|
|
26
|
+
# read from the installed bootstrap would agree with every bootstrap, which
|
|
27
|
+
# is the disagreement the check exists to find. Bump when this adapter is
|
|
28
|
+
# rebuilt against a new contract, never to match what happens to be installed.
|
|
29
|
+
ADAPTER_CONTRACT: int = 1
|
|
30
|
+
schema_major = "schema_0"
|
|
31
|
+
SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=0", "<1.0")
|
|
32
|
+
|
|
33
|
+
def __init__(self, serial_number: str, schema: V2HomieSchema) -> None:
|
|
34
|
+
self._serial_number = serial_number
|
|
35
|
+
# `panel_size` is derived here rather than handed in, because deriving
|
|
36
|
+
# it means reading the flat schema's `types` block for the circuit
|
|
37
|
+
# `space` format — knowledge that belongs to this package. The
|
|
38
|
+
# transport used to do this on every adapter's behalf, which only
|
|
39
|
+
# worked while every adapter was this one.
|
|
40
|
+
self._schema = schema
|
|
41
|
+
self._accumulator = HomiePropertyAccumulator(serial_number)
|
|
42
|
+
self._consumer = HomieDeviceConsumer(self._accumulator, schema.panel_size)
|
|
43
|
+
|
|
44
|
+
def topics_to_subscribe(self) -> list[str]:
|
|
45
|
+
return [WILDCARD_TOPIC_FMT.format(serial=self._serial_number)]
|
|
46
|
+
|
|
47
|
+
def handle_message(self, topic: str, payload: str) -> None:
|
|
48
|
+
self._consumer.handle_message(topic, payload)
|
|
49
|
+
|
|
50
|
+
def is_ready(self) -> bool:
|
|
51
|
+
return self._consumer.is_ready()
|
|
52
|
+
|
|
53
|
+
def build_snapshot(self) -> SpanPanelSnapshot:
|
|
54
|
+
return self._consumer.build_snapshot()
|
|
55
|
+
|
|
56
|
+
def build_field_metadata(self) -> dict[str, FieldMetadata]:
|
|
57
|
+
return build_field_metadata(self._schema.types)
|
|
58
|
+
|
|
59
|
+
def circuit_nodes_missing_names(self) -> list[str]:
|
|
60
|
+
return self._consumer.circuit_nodes_missing_names()
|
|
61
|
+
|
|
62
|
+
def find_node_by_type(self, type_str: str) -> str | None:
|
|
63
|
+
return self._consumer.find_node_by_type(type_str)
|
|
64
|
+
|
|
65
|
+
def set_circuit_relay_topic(self, circuit_id: str) -> str:
|
|
66
|
+
return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="relay")
|
|
67
|
+
|
|
68
|
+
def set_circuit_priority_topic(self, circuit_id: str) -> str:
|
|
69
|
+
return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="shed-priority")
|
|
70
|
+
|
|
71
|
+
def set_dominant_power_source_topic(self) -> str | None:
|
|
72
|
+
core_node = self._consumer.find_node_by_type(TYPE_CORE)
|
|
73
|
+
if core_node is None:
|
|
74
|
+
return None
|
|
75
|
+
return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=core_node, prop="dominant-power-source")
|
|
76
|
+
|
|
77
|
+
def dominant_power_source_payload(self, value: str) -> str | None:
|
|
78
|
+
"""Flat speaks this vocabulary already, so the caller's value passes through.
|
|
79
|
+
|
|
80
|
+
The method exists because `schema_1` has to translate — its successor
|
|
81
|
+
property accepts `NONE`/`ON_GRID`/`OFF_GRID`, not a source class — and a
|
|
82
|
+
caller should not have to know which schema it is talking to. Here the
|
|
83
|
+
translation is the identity.
|
|
84
|
+
|
|
85
|
+
Validated rather than passed blindly: an unrecognised value returns None
|
|
86
|
+
and the transport refuses the command, which matches `schema_1`'s
|
|
87
|
+
behaviour and is better than putting a string outside the enum on the
|
|
88
|
+
wire.
|
|
89
|
+
"""
|
|
90
|
+
allowed = {"GRID", "BATTERY", "PV", "GENERATOR", "NONE", "UNKNOWN"}
|
|
91
|
+
candidate = value.strip().upper()
|
|
92
|
+
return candidate if candidate in allowed else None
|
|
93
|
+
|
|
94
|
+
def set_evse_charge_limit_topic(self, node_id: str) -> str | None: # pylint: disable=unused-argument
|
|
95
|
+
"""None: flat firmware publishes no charge-current ceiling to write.
|
|
96
|
+
|
|
97
|
+
The flat `energy.ebus.device.evse` type carries `advertised-current` —
|
|
98
|
+
what the charger is offering the vehicle, read-only — and nothing that
|
|
99
|
+
sets it. There is no property to aim a set topic at, so the transport
|
|
100
|
+
refuses the command rather than publishing to a topic no panel of this
|
|
101
|
+
generation subscribes to.
|
|
102
|
+
|
|
103
|
+
`node_id` is accepted and unused for the same reason
|
|
104
|
+
`set_dominant_power_source_topic` takes no arguments and still returns
|
|
105
|
+
None on a panel with no core node: the answer does not depend on which
|
|
106
|
+
charger is asked.
|
|
107
|
+
"""
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def evse_charge_limit_payload(self, node_id: str, amps: int) -> str | None: # pylint: disable=unused-argument
|
|
111
|
+
"""None, for the same reason: no property, so no representable value."""
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]:
|
|
115
|
+
return self._consumer.register_property_callback(callback)
|