From 3690c080b47770edccde31ee55c7d5cde48c2492 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:51:35 -0700 Subject: [PATCH 1/3] fix(control): a circuit the specification declares locked gets no topic `switch` 0.3, vendored at packages/schema-1/spec/catalogs/switch.json, declares `relay` "Settable when `relay-controllable = true`" and defines `relay-controllable` false as "locked (for example a circuit commissioned as permanently on)". `set_circuit_relay_target` and `set_circuit_priority_target` were pure string formatting from a circuit id and consulted no declaration, so both setters published to circuits the specification says are not settable -- while the same adapters were already reading exactly that into `is_user_controllable` and `is_never_backup` for the snapshot. Both now return `ControlTarget | None`, joining the two controls that have always refused this way, and the client raises `SpanPanelServerError` the way `set_evse_charge_limit` does. The condition lives in the catalog's prose: `settable: true` stands unconditionally in the JSON beside it, because that field describes the property across the capability while the narrowing applies per device. So the rule is encoded rather than derived, and a test reads the catalog and pins the clause -- a specification that stops saying it fails there rather than leaving a stale rule in force. Under parent/child both halves of the condition are on the wire and the relay refuses when either says no. SPAN reports a firmware defect in which the `$settable` re-toggle on the runtime re-commissioning path is skipped until the service restarts, so declaration and value can disagree on a live panel; the panel rejects an out-of-policy write regardless, so the conjunction only refuses writes the panel would refuse. Absence reads as locked on `switch/relay` and as settable on `load-shed/priority`, and that asymmetry is each catalog entry's: `switch` carries a condition, `load-shed` carries none. Flat predates capability nodes and declares settability per device type, so it cannot vary per circuit; it spells the same fact `always-on`, and the priority's `never-backup`. Nothing was user-visible, because the integration gates entity creation on `is_user_controllable`. That is not enough: re-commissioning a circuit in place republishes its `$description` with a new `$settable`, so an entity can outlive its own controllability, and `set_circuit_relay` is public API reachable without an entity. Route the refusals through the interceptor. Five refusals happened while resolving the address and so never reached `_publish_control`, which is where interception lives -- meaning `after_publish`, contracted to see every command including refusals, was missing precisely the commands a panel had refused. A consumer building a security audit on it would have had its hole where the relay is. `ControlCommand.topic` and `PublishOutcome.topic` become `str | None` so a command that never resolved to a topic is recorded truthfully rather than named a topic nothing would publish to. `before_publish` stays unconsulted for them: there is nothing to authorise, and a veto would replace a specific reason with "vetoed". Track the producer that made the reference capture, which is what blocked the fix. `ebus-panel-sim` is published by electrification-bus -- the organisation that writes the specification -- and is conformed against live panel output: the specification in runnable form, and the right thing to test a consumer against. Depending on it is correct. Depending on a frozen, unrecorded copy of it was not: the capture was taken once, nothing wrote down what made it, and three emitter releases went by while this repository asserted a producer defect as fact across roughly thirty test files. So `spec_lock.json` grows `peers`, keyed by name because both readers select a peer by identity and neither iterates for its own sake. `ebus-panel-sim` is pinned there with its repo, ref, commit, tag, released version and the specification commit it implements -- which is the same commit we pin, and a test now asserts that. panelbench keeps `fixtures`, paths inside panelbench that are byte-copied here; the emitter gets `produces`, paths inside this repository generated by its `capture_script` from its `manifest`. One key would have meant two things depending on which peer you read it from. `scripts/capture_parent_child_reference.py` substitutes the transport rather than reassembling the emitter, reads its expected release out of the lockfile rather than restating it, and refuses to write when the checkout disagrees. `scripts/reference_panel.yaml` is its input, committed and pinned: a capture whose input is not in the tree is the same problem as one whose producer is not recorded. It mirrors the emitter's own example key for key and marks its two divergences at the head of the file -- spec-legal shed priorities in place of a value the emitter degrades to UNKNOWN (electrification-bus/ distribution-enclosure-simulator#51, open), and the identity properties a real panel publishes. The capture was three producer releases behind, and the suite had been reading its errors as facts: - `switch/relay` loses `$settable` on the locked circuit and its `relay-requester` becomes CONFIGURATION -- the case this fix needed. - `connection/count` is gone; no configuration could publish it. - `power-flows/{pv,battery,grid}` and the BESS meter are in the frame `power-flows` 0.3 defines, where the old bytes carried the pre-0.6.0 one and did not balance. The parser was already right. Two tests contradicted each other under the old fixture: one derived a charging battery from the flows, the other asserted the snapshot reported it discharging. - The lugs integrate their own meter, so the capture runs three ticks -- two intervals, one importing and one exporting -- because one interval can now only populate one energy register. - `load-shed/priority` carries values a real panel publishes. UNKNOWN is spec-legal and every parser must handle it, so that obligation moves to where it belongs: a test parametrised over `load-shed` 0.3's declared `$format` rather than over whatever the capture happens to contain. Contract obligations come from the catalog; representativeness comes from the capture. `peer-drift.yml` gains an emitter job. It is a released distribution rather than a repository we copy out of, so "has it moved" has two answers and only one is a reason to recapture: the job asks PyPI for the latest release and reports commits past the pin as context. Schedule and dispatch only, never `pull_request`, for the reason the existing job states. `tests/fixtures/panelbench_unvalued_by_both.json` is refreshed from the panelbench commit `spec_lock.json` already pins. The vendored copy carried 32 `connection/count` entries that commit had itself already dropped, so the cross-check passed only because both sides were stale. --- .env.example | 17 + .github/actions/peer-checkouts/action.yml | 83 ++- .github/workflows/peer-drift.yml | 130 ++++- CHANGELOG.md | 31 ++ DEVELOPMENT.md | 55 +- README.md | 7 + packages/schema-0/CHANGELOG.md | 9 + .../src/span_panel_api_schema_0/adapter.py | 20 +- .../src/span_panel_api_schema_0/consumer.py | 32 ++ packages/schema-1/CHANGELOG.md | 50 ++ .../src/span_panel_api_schema_1/adapter.py | 52 +- .../span_panel_api_schema_1/charge_limit.py | 2 +- .../src/span_panel_api_schema_1/circuits.py | 87 ++- .../reference_payloads/README.md | 33 +- .../reference_payloads/parent_child_tree.json | 464 ++++++++-------- .../span_panel_api_schema_1/spec_lock.json | 39 +- scripts/capture_parent_child_reference.py | 502 ++++++++++++++++++ scripts/reference_panel.yaml | 155 ++++++ src/span_panel_api/mqtt/client.py | 123 ++++- src/span_panel_api/mqtt/control.py | 31 +- src/span_panel_api/protocol.py | 34 +- .../fixtures/panelbench_unvalued_by_both.json | 32 -- tests/test_control_interceptor.py | 129 +++++ tests/test_reference_tree_values.py | 35 +- tests/test_schema_one_adapter.py | 11 +- tests/test_schema_one_circuits.py | 71 ++- tests/test_schema_one_conformance.py | 113 +++- tests/test_schema_one_control_refusal.py | 295 ++++++++++ tests/test_schema_one_devices.py | 32 +- tests/test_schema_one_discovery.py | 7 +- tests/test_schema_one_panel.py | 18 +- tests/test_schema_one_snapshot.py | 2 +- tests/test_schema_zero_adapter.py | 50 +- 33 files changed, 2346 insertions(+), 405 deletions(-) create mode 100644 scripts/capture_parent_child_reference.py create mode 100644 scripts/reference_panel.yaml create mode 100644 tests/test_schema_one_control_refusal.py diff --git a/.env.example b/.env.example index 19c9207..2299911 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,23 @@ # simulator's `noise_factor` and an advancing clock. #PANELBENCH_DIR=/path/to/panelbench +# A checkout of the eBus emitter, the producer of the reference tree. +# +# git clone https://github.com/electrification-bus/distribution-enclosure-simulator +# +# The specification's own executable publisher — same organisation, conformed +# against live panel output — so this is the spec in runnable form rather than a +# third-party imitation of it. Position it at the tag `peers.ebus-panel-sim.tag` +# records before believing a failure. +# +# Enables checking that the emitter reads the same specification commit we do, +# and that the checkout is the release `spec_lock.json` says the reference tree +# was captured from. It is also what `scripts/capture_parent_child_reference.py` +# needs to regenerate that capture (the script takes PANEL_SIM_DIR too, and must +# be run from the emitter's own environment — it caps `ebus-sdk` below the +# version this repo installs). +#PANEL_SIM_DIR=/path/to/distribution-enclosure-simulator + # --------------------------------------------------------------------------- # A live SPAN panel running flat firmware (optional, and nothing needs it) # --------------------------------------------------------------------------- diff --git a/.github/actions/peer-checkouts/action.yml b/.github/actions/peer-checkouts/action.yml index f6ca792..1814ce8 100644 --- a/.github/actions/peer-checkouts/action.yml +++ b/.github/actions/peer-checkouts/action.yml @@ -1,32 +1,37 @@ name: Peer checkouts description: > - Clone the two repositories the schema_1 provenance checks verify against — the eBus - specification and SpanPanel/panelbench — and export EBUS_SPEC_DIR / PANELBENCH_DIR - for the steps that follow. + Clone the three repositories the schema_1 provenance checks verify against — the eBus + specification, SpanPanel/panelbench and the eBus emitter (ebus-panel-sim) — and export + EBUS_SPEC_DIR / PANELBENCH_DIR / PANEL_SIM_DIR for the steps that follow. Every value comes out of packages/schema-1/src/span_panel_api_schema_1/spec_lock.json, - which is the single home of the pin. A workflow that restated a commit here would - give the pin a second home, and the two would agree right up until the day someone + which is the single home of the pins. A workflow that restated a commit here would + give a pin a second home, and the two would agree right up until the day someone re-vendored and updated only one. + Both producers are publishers and both are pinned the same way. panelbench is the + SPAN-side producer this parser is developed against; ebus-panel-sim is the + specification's own executable publisher, from the organisation that writes the spec + and conformed against live panel output, and it is what produced the reference tree. + inputs: - panelbench-ref: + peer-ref: description: > - Which panelbench to clone, and it decides which question the job asks. + Which producer refs to clone, and it decides which question the job asks. - "pin" clones the exact commit peer.commit records, so the byte comparison asks - "do our vendored captures match the commit we claim they came from?" — a + "pin" clones the exact commit each peer's `commit` records, so the byte comparison + asks "do our vendored captures match the commits we claim they came from?" — a deterministic question with a deterministic answer, safe to block a merge on. - "default" clones peer.ref, the branch the producer develops on, so the same - comparison asks "has the producer moved past the pin?". That answer changes + "default" clones each peer's `ref`, the branch that producer develops on, so the + same comparison asks "has the producer moved past the pin?". That answer changes because someone else pushed, so it must never gate a pull request. required: false default: pin outputs: panelbench-pin: - description: The commit spec_lock.json pins, whichever ref was cloned. + description: The commit spec_lock.json pins for panelbench, whichever ref was cloned. value: ${{ steps.pins.outputs.panelbench-commit }} panelbench-repo: description: The panelbench repository, as owner/name. @@ -34,6 +39,21 @@ outputs: panelbench-checkout: description: The ref actually cloned — the pinned commit, or the producer's branch. value: ${{ steps.pins.outputs.panelbench-checkout }} + panel-sim-pin: + description: The commit spec_lock.json pins for ebus-panel-sim. + value: ${{ steps.pins.outputs.panel-sim-commit }} + panel-sim-repo: + description: The emitter repository, as owner/name. + value: ${{ steps.pins.outputs.panel-sim-repo }} + panel-sim-checkout: + description: The ref actually cloned — the pinned commit, or the producer's branch. + value: ${{ steps.pins.outputs.panel-sim-checkout }} + panel-sim-distribution: + description: The PyPI distribution the emitter releases as. + value: ${{ steps.pins.outputs.panel-sim-distribution }} + panel-sim-version: + description: The released version the reference tree was captured from. + value: ${{ steps.pins.outputs.panel-sim-version }} runs: using: composite @@ -42,7 +62,7 @@ runs: id: pins shell: bash env: - PANELBENCH_REF_MODE: ${{ inputs.panelbench-ref }} + PEER_REF_MODE: ${{ inputs.peer-ref }} run: | python3 - <<'PY' >> "$GITHUB_OUTPUT" import json @@ -50,26 +70,36 @@ runs: with open("packages/schema-1/src/span_panel_api_schema_1/spec_lock.json") as handle: lock = json.load(handle) - peer = lock["peer"] + peers = lock["peers"] def slug(url: str) -> str: """owner/name, which is what actions/checkout wants.""" return url.removeprefix("https://github.com/").removesuffix(".git") - mode = os.environ["PANELBENCH_REF_MODE"] + mode = os.environ["PEER_REF_MODE"] if mode not in ("pin", "default"): - raise SystemExit(f"::error::panelbench-ref must be 'pin' or 'default', got {mode!r}") + raise SystemExit(f"::error::peer-ref must be 'pin' or 'default', got {mode!r}") print(f"spec-repo={slug(lock['spec_repo'])}") print(f"spec-commit={lock['synced_commit']}") - print(f"panelbench-repo={slug(peer['repo'])}") - print(f"panelbench-commit={peer['commit']}") - print(f"panelbench-checkout={peer['commit'] if mode == 'pin' else peer['ref']}") + + # Emitted per peer under its own output prefix rather than as one blob, so a + # workflow step names the peer it is talking about and a typo is a missing + # value rather than the other producer's. + for name, prefix in (("panelbench", "panelbench"), ("ebus-panel-sim", "panel-sim")): + peer = peers[name] + print(f"{prefix}-repo={slug(peer['repo'])}") + print(f"{prefix}-commit={peer['commit']}") + print(f"{prefix}-checkout={peer['commit'] if mode == 'pin' else peer['ref']}") + + emitter = peers["ebus-panel-sim"] + print(f"panel-sim-distribution={emitter['distribution']}") + print(f"panel-sim-version={emitter['version']}") PY - # Both are public, so no token is involved. If either ever goes private this is + # All three are public, so no token is involved. If any ever goes private this is # the step that starts failing, and the fix is a PAT with read access in `token:` - # rather than anything about the pin. + # rather than anything about the pins. - name: Check out the eBus specification at synced_commit uses: actions/checkout@v7 with: @@ -84,13 +114,22 @@ runs: ref: ${{ steps.pins.outputs.panelbench-checkout }} # History only where it is read: the drift job counts commits between the pin # and the branch head, which a shallow clone cannot do. - fetch-depth: ${{ inputs.panelbench-ref == 'default' && '0' || '1' }} + fetch-depth: ${{ inputs.peer-ref == 'default' && '0' || '1' }} path: peers/panelbench + - name: Check out the eBus emitter + uses: actions/checkout@v7 + with: + repository: ${{ steps.pins.outputs.panel-sim-repo }} + ref: ${{ steps.pins.outputs.panel-sim-checkout }} + fetch-depth: ${{ inputs.peer-ref == 'default' && '0' || '1' }} + path: peers/panel-sim + - name: Point the provenance checks at them shell: bash run: | { echo "EBUS_SPEC_DIR=$GITHUB_WORKSPACE/peers/specification" echo "PANELBENCH_DIR=$GITHUB_WORKSPACE/peers/panelbench" + echo "PANEL_SIM_DIR=$GITHUB_WORKSPACE/peers/panel-sim" } >> "$GITHUB_ENV" diff --git a/.github/workflows/peer-drift.yml b/.github/workflows/peer-drift.yml index 903b4ff..af5850f 100644 --- a/.github/workflows/peer-drift.yml +++ b/.github/workflows/peer-drift.yml @@ -1,13 +1,19 @@ name: Peer drift -# Deliberately never `pull_request`. This asks whether the *producer* has moved past -# the commit we pin, and the answer changes because someone else pushed to panelbench. -# Failing an author's unrelated change for that would teach everyone to ignore it, -# which is how a check stops being a check. +# Deliberately never `pull_request`. This asks whether a *producer* has moved past the +# commit we pin, and the answer changes because someone else pushed. Failing an +# author's unrelated change for that would teach everyone to ignore it, which is how a +# check stops being a check. # # ci.yml asks the other half of the question -- do our vendored bytes still match the -# commit we claim they came from -- against the pinned commit, where the answer is +# commits we claim they came from -- against the pinned commits, where the answer is # deterministic and blocking a merge on it is fair. +# +# Two producers, one job each, and they are not the same shape. panelbench is a +# repository we byte-copy captures out of, so "has it moved" is a commit count. The +# eBus emitter is a released PyPI distribution that we *run* to generate a capture, so +# it has moved in two senses -- commits on main, and a newer release -- and only the +# second is a reason to regenerate. on: schedule: # Daily. The drift this exists to catch took nine days to be noticed by hand. @@ -35,7 +41,7 @@ jobs: id: peers uses: ./.github/actions/peer-checkouts with: - panelbench-ref: default + peer-ref: default # Ahead of the comparison, so the summary names the distance whichever way the job # goes. "N commits behind" with the subjects is what makes the result actionable; @@ -123,3 +129,115 @@ jobs: echo "For the specification commit, the two sides are reading different vocabularies" echo "until \`synced_commit\` and the vendored catalogs move together." } >> "$GITHUB_STEP_SUMMARY" + + emitter: + name: Has the eBus emitter released past the pin? + runs-on: ubuntu-latest + + # Two answers, and the release is the one that matters. `ebus-panel-sim` is a + # published distribution, and `scripts/capture_parent_child_reference.py` refuses + # to write a capture taken from any version other than the one spec_lock.json + # records -- so regenerating the reference tree is gated on a *release*, not on a + # commit. Commits on main are reported too, because they are what a release will + # be made of and seeing them early is free, but they are not a call to action. + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Check out the emitter's own branch, and the specification at its pin + id: peers + uses: ./.github/actions/peer-checkouts + with: + peer-ref: default + + # The question that decides whether to recapture. PyPI's JSON API is public and + # unauthenticated; a failure to reach it reports as unknown rather than as drift, + # because "we could not ask" and "there is a new release" are different facts and + # only one of them is actionable. + - name: Compare the pinned release against PyPI + env: + DISTRIBUTION: ${{ steps.peers.outputs.panel-sim-distribution }} + PINNED: ${{ steps.peers.outputs.panel-sim-version }} + run: | + python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import json + import os + import urllib.error + import urllib.request + + distribution = os.environ["DISTRIBUTION"] + pinned = os.environ["PINNED"] + print(f"## {distribution}\n") + + try: + with urllib.request.urlopen( + f"https://pypi.org/pypi/{distribution}/json", timeout=30 + ) as response: + latest = json.load(response)["info"]["version"] + except (urllib.error.URLError, TimeoutError, KeyError, json.JSONDecodeError) as error: + print(f"Could not ask PyPI for the latest release (`{error}`).\n") + print("Reported as unknown rather than as drift: not having asked is not the") + print("same fact as there being nothing new.") + raise SystemExit(0) + + if latest == pinned: + print(f"Latest release is `{latest}`, which is what we pin. The reference tree is current.") + raise SystemExit(0) + + print(f"Latest release is `{latest}`, we pin `{pinned}`.\n") + print("The reference tree was captured from the pinned release, so it now describes a") + print("producer that has been superseded. To follow:\n") + print("```bash") + print(f"# in a checkout of the emitter at v{latest}, from its own environment") + print("uv run python ../span-panel-api/scripts/capture_parent_child_reference.py \\") + print(" ../span-panel-api/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json") + print("```\n") + print("The script refuses until `peers.ebus-panel-sim.version` and `.commit` in") + print("`spec_lock.json` name the release you captured from, which is what keeps the") + print("bytes and the claim about them from drifting apart. Read the emitter's") + print("CHANGELOG for the wire changes before accepting the new capture: a diff") + print("confined to each `$description`'s `version` means nothing moved.") + PY + + # Reported after the release comparison because it is context for it, not the + # verdict. "N commits behind" with the subjects is what makes the result readable; + # a red check with no names is a chore. + - name: Report the distance from the pinned commit + env: + PIN: ${{ steps.peers.outputs.panel-sim-pin }} + REPO: ${{ steps.peers.outputs.panel-sim-repo }} + BRANCH: ${{ steps.peers.outputs.panel-sim-checkout }} + run: | + git() { command git -C "$GITHUB_WORKSPACE/peers/panel-sim" "$@"; } + head="$(git rev-parse HEAD)" + + if ! git merge-base --is-ancestor "$PIN" HEAD 2>/dev/null; then + { + echo + echo "### $REPO commits" + echo + echo "\`$PIN\` is not an ancestor of \`$BRANCH\` (\`${head:0:12}\`)." + echo + echo "The pin names a commit this branch does not contain — a branch that was" + echo "rebased, squash-merged or deleted. \`peers.ebus-panel-sim.ref\` in" + echo "\`spec_lock.json\` needs to name a ref the pinned commit is actually on." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + behind="$(git rev-list --count "$PIN"..HEAD)" + { + echo + echo "### $REPO commits" + echo + echo "\`$BRANCH\` is at \`${head:0:12}\`, we pin \`${PIN:0:12}\` — **$behind commits behind**." + if [ "$behind" -gt 0 ]; then + echo + echo "Unreleased work, so not itself a reason to recapture — the release comparison" + echo "above is." + echo + echo '```' + git log --oneline --no-decorate "$PIN"..HEAD + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d58ca8..b8e822b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,30 @@ the adapter distribution does not, and a 1.0.0 adapter against this bootstrap is ### Fixed +- **A relay or shed-priority command aimed at a circuit the panel declares non-commandable is refused instead of published.** `set_circuit_relay_target` and `set_circuit_priority_target` were pure string formatting from a circuit id and consulted no + declaration at all, so both setters published to a circuit commissioned always-on or never-backup — while the same adapters were already reading exactly that refusal into `SpanCircuitSnapshot.is_user_controllable` and `.is_never_backup`. Both now return + `ControlTarget | None`, matching the two controls that already refused, and `set_circuit_relay` / `set_circuit_priority` raise `SpanPanelServerError` the way `set_evse_charge_limit` does. + + Nothing was user-visible, because the Home Assistant integration gates entity creation on `is_user_controllable`. That is not sufficient for two reasons. **Settability changes at runtime** — re-commissioning a circuit in place cycles that child device's + `$state` and republishes its `$description` with a new `$settable`, so an entity can outlive its own controllability and a setup-time gate cannot see it. And `set_circuit_relay` is **public API**: any caller can reach it without an entity, and the + library is where the refusal contract belongs. + + **The refusal is the eBus specification's rule, not either adapter's.** `switch` 0.3 declares `relay` "Settable when `relay-controllable = true`" and defines `relay-controllable` false as "locked (for example a circuit commissioned as permanently on)", + so a consumer publishing to a locked circuit is writing to a property the specification says is not settable on that device. Under the parent/child schema both halves of the condition are on the wire and the relay refuses when **either** says no — + `$settable` absent from `switch/relay`, or `switch/relay-controllable` published `false`. The redundancy is deliberate: SPAN reports a firmware defect in which the `$settable` re-toggle on the runtime re-commissioning path is skipped until the service + restarts, so a consumer can meet a panel whose declaration is stale while the value is current, and the panel rejects an out-of-policy write regardless of what `$settable` last advertised. Across the two production enclosures captured — 27 circuits — the + two agree without exception. The flat schema predates capability nodes and declares settability per device _type_, so it cannot vary per circuit; it spells the same fact `always-on`, and the priority's `never-backup`. Each is the same reading the + snapshot already exposes. + + A locked relay keeps a settable priority, which is the combination real panels publish and which `switch` 0.3 and `load-shed` 0.3 scope separately. + +- **A control the library refused before resolving an address is no longer invisible to `ControlInterceptor`.** `after_publish` is contracted to see every command, refusals included, but five refusals happened while resolving the target and therefore never + reached the publish path at all: a relay declared non-commandable, a priority declared locked, a charger with no settable limit, a panel with no islanding control, and an adopted property that is not settable. A consumer building a security audit on + `after_publish` — which is what the Home Assistant integration does — would have had a hole in it exactly where the interesting cases are, the highest-consequence control in the system among them. + + Those now produce an `after_publish` record with `PublishState.FAILED` and a `detail` naming the refusal, before the `SpanPanelServerError` is raised. `before_publish` is deliberately **not** consulted for them: there is nothing to authorise, and a veto + would replace a specific reason with "vetoed". + - **A control command that was never sent no longer looks like one that succeeded.** All five setters returned `None` on three separate paths that published nothing: after `close()` (the adapter survives, the bridge does not, so the setter returned having done nothing at all), with no paho client, and — the one that matters — while the broker was unreachable. A caller had no way to tell any of them from a breaker that actually opened. - **A publish while the broker is known to be down is refused instead of queued.** paho keeps a QoS-1 publish in its outbound queue across a disconnect and sends it when the connection returns, reusing the same client, so a relay command issued during an @@ -71,6 +95,13 @@ the adapter distribution does not, and a 1.0.0 adapter against this bootstrap is ### Changed +- **`ControlCommand.topic` and `PublishOutcome.topic` become `str | None`.** A refusal made while resolving the address has no topic, and a command reported with one would name a string nothing was ever going to publish to. `None` appears only alongside + `PublishState.FAILED`. Additive for a consumer that only reads `state` and `detail`; an interceptor that passes `command.topic` somewhere expecting a `str` is the one that has to change, and does so under mypy rather than silently. + +- **BREAKING FOR IMPLEMENTERS: `set_circuit_relay_target` and `set_circuit_priority_target` return `ControlTarget | None`.** `SchemaAdapter` declares the wider type, joining `set_dominant_power_source_target` and `set_evse_charge_limit_target`, which have + always refused this way. `ADAPTER_CONTRACT_VERSION` does not move, and the direction is why: an adapter still returning a bare `ControlTarget` satisfies the wider declaration — a narrower return is a valid implementation — and simply never exercises the + refusal, which is the pre-fix behaviour and no worse than it. The direction the contract version does not protect, a newer adapter against an older bootstrap, is unchanged by this. + - **BREAKING FOR IMPLEMENTERS: the five control-protocol setters return `PublishOutcome` instead of `None`.** `CircuitControlProtocol`, `PanelControlProtocol`, `EvseControlProtocol` and `AdoptedControlProtocol` all move. **This is additive for callers** — a call site that ignores the return value compiles and behaves exactly as before — **and breaking for implementers**: any class type-checked against one of these protocols with `-> None` stops conforming. Test fakes, simulators, and any `Callable[..., Awaitable[None]]` typed against a setter are precisely that, and they must be updated in the same upgrade. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5fe1a65..f6a381c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -41,22 +41,23 @@ python scripts/coverage.py --full ## Conformance against the specification and the producer -Some tests verify this library against two things it does not contain: the eBus **specification** (the capability catalogs vendored under `packages/schema-1/spec/catalogs/`) and **panelbench**, the producer whose captures are vendored as reference -payloads. Both are reached through a local checkout named by an environment variable. Locally, both **skip when the variable is unset or wrong** — not every developer keeps sibling checkouts. **Under `CI` they fail instead**, because CI clones both, so an -absent path there means the wiring came undone rather than that the checkout is unavailable. +Some tests verify this library against things it does not contain: the eBus **specification** (the capability catalogs vendored under `packages/schema-1/spec/catalogs/`) and the two **producers** whose output is vendored here — panelbench, whose captures +are byte-copied, and the eBus emitter, which produces the reference tree. All are reached through a local checkout named by an environment variable. Locally, they **skip when the variable is unset or wrong** — not every developer keeps sibling checkouts. +**Under `CI` they fail instead**, because CI clones all three, so an absent path there means the wiring came undone rather than that the checkout is unavailable. Copy `.env.example` to `.env` and point them at real checkouts: ```bash EBUS_SPEC_DIR=/path/to/ebus/specification PANELBENCH_DIR=/path/to/span/panelbench +PANEL_SIM_DIR=/path/to/distribution-enclosure-simulator ``` ### A skip here is not a pass -This is worth stating plainly because it has already cost us. `test_the_vendored_captures_match_the_simulator` compares the vendored capture byte-for-byte against panelbench's `golden_tree.json` and pins `peer.commit` in `spec_lock.json`. It is the check -that catches the vendored fixture going stale while the producer moves on — which is exactly what happened during the v1.0 capability catch-up, where the reference tree left MID `info/*`, BESS `info/{part,serial,firmware}` and PV `info/firmware-version` -unvalued long after panelbench published all of them. The drift was found by hand. +This is worth stating plainly because it has already cost us. `test_the_vendored_captures_match_the_simulator` compares the vendored capture byte-for-byte against panelbench's `golden_tree.json` and pins `peers.panelbench.commit` in `spec_lock.json`. It is +the check that catches the vendored fixture going stale while the producer moves on — which is exactly what happened during the v1.0 capability catch-up, where the reference tree left MID `info/*`, BESS `info/{part,serial,firmware}` and PV +`info/firmware-version` unvalued long after panelbench published all of them. The drift was found by hand. The test did not fail, because it never ran: `PANELBENCH_DIR` named a directory that did not exist, so it skipped, and a skip renders in a summary line exactly like a pass. @@ -73,16 +74,47 @@ It cost us a second time on 2026-08-20, in the other vendored capture. `tests/fi lower-case, which is the flat half of a change panelbench made on the v1.0 side the same week. Nothing compared the capture to its source, so for nine days the two vendored captures named the same charger differently. `scripts/capture_flat_reference.py` now records the simulator commit its output came from, for the same reason `spec_lock.json` records the other two. +### Regenerating a vendored capture + +Two scripts, one per producer, and neither is run automatically — a capture is a deliberate act. + +| Artifact | Producer | Script | +| ----------------------------------------------------------------- | ---------------- | ------------------------------------------- | +| `tests/fixtures/flat_wire.json` | `simulator` | `scripts/capture_flat_reference.py` | +| `packages/schema-1/.../reference_payloads/parent_child_tree.json` | `ebus-panel-sim` | `scripts/capture_parent_child_reference.py` | + +Both run from the **producer's** environment rather than this one — each producer caps a dependency this repo installs above — and both substitute the transport rather than reassembling the emitter, because a capture taken through different wiring than a +real panel uses is a capture of the wiring. Point them at a checkout with `SIMULATOR_DIR` / `PANEL_SIM_DIR`. + +`capture_parent_child_reference.py` goes one step further than documenting its producer: it reads the release it is a capture of out of `spec_lock.json` (`peers.ebus-panel-sim.version`) and **refuses to write** when the installed package disagrees. The pin +therefore has exactly one home, and re-capturing against a newer emitter is a two-place change made together — that peer block, and the provenance section of `reference_payloads/README.md`. That is what stops the bytes and the claim about them drifting +apart, and the drift is not hypothetical: it is how a producer defect in `$settable` on a locked relay reached about thirty test files across two repositories with no conformance gate objecting. + +Its input is committed too, as `scripts/reference_panel.yaml`, pinned as `peers.ebus-panel-sim.manifest` — a capture whose input is not in the tree is the same class of problem as one whose producer is not recorded. That manifest is a synthetic `example-*` +panel that mirrors the emitter's own `examples/forty_tab_minimal.yaml` key for key and marks its two deliberate divergences at the head of the file: spec-legal shed priorities in place of a value the emitter degrades to `UNKNOWN` +(electrification-bus/distribution-enclosure-simulator#51), and the identity properties a real panel publishes. Read that file before assuming ours has drifted from theirs. + +Expect a recapture to move every `$description`'s `version`, which is minted from the wall clock. A diff confined to those thirteen lines means the producer did not move. + +### The producer is the specification, executable + +`ebus-panel-sim` is not a third-party imitation to be second-guessed. It is published by electrification-bus, the organisation that writes the eBus specification, and is conformed against live panel output — the designated checkpoint for whether a consumer +reads a conforming tree correctly. Its `.ebus-spec.json` names the specification commit it implements, `spec_lock.json` records ours, and `test_the_emitters_pin_matches_ours` compares them, so a disagreement between this parser and the reference capture is +a disagreement about one document rather than about two. + +So the lesson from the stale capture is not "depend on it less". It is that a dependency nobody can see cannot be maintained: the capture was taken once, nothing recorded what made it, and three emitter releases went by while this repository asserted a +producer defect as fact. Both producers are now tracked the same way — pinned in `spec_lock.json`, cloned by the same composite action, and watched by `peer-drift.yml`. + ### Two questions, two workflows -The peer checks answer a question whose shape depends on which panelbench you point them at, and the two answers belong in different places. +The peer checks answer a question whose shape depends on which producer revision you point them at, and the two answers belong in different places. - **`.github/workflows/ci.yml`** clones both peers at the commits `spec_lock.json` pins, via the `.github/actions/peer-checkouts` composite action, and runs the whole suite against them. The question is _do our vendored bytes match the commit we claim they came from?_ — deterministic, answerable on any commit, and fair to block a merge on. It catches an accidental local edit to a vendored file. -- **`.github/workflows/peer-drift.yml`** runs on a schedule, never on a pull request, and clones panelbench at `peer.ref` — the branch the producer develops on. The question is _has the producer moved past the pin?_ Its answer changes because someone else - pushed, so it must not fail an author's unrelated change. It reports the distance from the pin in the job summary either way, and goes red only when the comparison itself fails, so panelbench advancing with a change we do not vendor stays green. +- **`.github/workflows/peer-drift.yml`** runs on a schedule, never on a pull request, and clones each producer at its `ref` — the branch that producer develops on. The question is _has the producer moved past the pin?_ Its answer changes because someone + else pushed, so it must not fail an author's unrelated change. It reports the distance from the pin in the job summary either way, and goes red only when the comparison itself fails, so panelbench advancing with a change we do not vendor stays green. -Both repositories are public, so neither checkout needs a token. If either ever goes private, the checkout step in the composite action is what starts failing, and the fix is a read-scoped PAT in its `token:` — the pin is not involved. +All three repositories are public, so no checkout needs a token. If either ever goes private, the checkout step in the composite action is what starts failing, and the fix is a read-scoped PAT in its `token:` — the pin is not involved. The commits come out of `packages/schema-1/src/span_panel_api_schema_1/spec_lock.json` at run time rather than being written into the workflows, so the pin keeps exactly one home. A workflow that restated a commit would agree with the lock file right up until the day someone re-vendored and updated only one of them. @@ -91,7 +123,8 @@ until the day someone re-vendored and updated only one of them. A failure means the vendored capture and panelbench have diverged. That is information, not an obstacle — decide which side is right: -- **Panelbench moved and we should follow**: re-capture the fixture, and update `peer.commit` in `spec_lock.json` to the panelbench commit you captured from. Both, together — a capture without a commit bump records where the bytes came from as a guess. +- **Panelbench moved and we should follow**: re-capture the fixture, and update `peers.panelbench.commit` in `spec_lock.json` to the panelbench commit you captured from. Both, together — a capture without a commit bump records where the bytes came from as + a guess. - **We diverged deliberately** (the reference tree is trimmed and renamed to synthetic `example-*` identifiers, so it is not a verbatim copy): the comparison covers the artifacts that _are_ meant to match. Do not loosen it to accommodate a local edit. ### Catalogs are pinned by commit, not version diff --git a/README.md b/README.md index 589af17..ae1e688 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,10 @@ silent policy rejection by the panel until SPAN ships a reason code. `FAILED` is deliver it minutes later. `CONFIRMED` is strong evidence rather than proof: the panel coalesces every API client into a single `USER` requester, so an observed transition cannot be attributed to one specific write. Nothing is retried — a relay write is not idempotent in its physical effect. +**A control the panel declares non-commandable raises rather than returning an outcome**, because there is no topic to publish to and so nothing to report on. `SpanPanelServerError` is raised for a relay on a circuit commissioned always-on, for the shed +priority of a circuit commissioned never-backup, and for a charger with no settable charge-current limit. The same facts are on the snapshot ahead of the call — `SpanCircuitSnapshot.is_user_controllable` and `.is_never_backup` — so a consumer that offers +the control only where the panel offers it will not meet this; it is the backstop for the case a setup-time gate cannot see, since re-commissioning a circuit in place changes its settability while an entity built from the earlier snapshot is still alive. + ### Control Interception A consumer with a notion of who is asking can refuse a command before it is published, and record every command in one place rather than in five setters that will drift: @@ -370,6 +374,9 @@ One interceptor at a time, replaceable; pass `None` to remove it. A veto's excep refusals too — with `FAILED` and a `vetoed` detail — because an audit that silently omits refusals is worse than no audit; it runs as a task rather than being awaited, so a sink that hangs cannot stall every control call, and ordering across commands is therefore not guaranteed. +That includes the refusals this library makes on the panel's behalf, which never reach a topic at all: a relay the panel declares non-commandable arrives at `after_publish` with `FAILED`, a `detail` naming the refusal, and `command.topic` set to `None`. +`before_publish` is not consulted for those — there is nothing to authorise, and a veto would replace a specific reason with "vetoed" — so an interceptor must treat `topic` as optional and read `state` and `detail` as the machine-readable half. + **This is a boundary against callers of this library and nothing more.** Anything holding the broker credential publishes to the panel directly and never reaches this code. ### Pinning the Panel CA diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md index 185f758..b7ad454 100644 --- a/packages/schema-0/CHANGELOG.md +++ b/packages/schema-0/CHANGELOG.md @@ -22,6 +22,15 @@ Requires `span-panel-api` **3.1.0 or newer**, and the two must be upgraded toget Renamed rather than re-typed under the old name so that the mismatch is caught at discovery, where the remedy can be named, instead of surfacing as an `AttributeError` on a `str` deep inside a setter. `ADAPTER_CONTRACT` stays **1** — the contract's member list changed, which discovery already checks by name. +- **`set_circuit_relay_target` and `set_circuit_priority_target` return `ControlTarget | None`, and refuse a circuit the panel declares non-commandable.** Both formatted a topic from a node id and consulted nothing, so a command aimed at an always-on relay + or a never-backup priority was published — while `consumer.py` was already reading `always-on` into `is_user_controllable` and `never-backup` into `is_never_backup` for the snapshot. `HomieDeviceConsumer` gains `relay_is_settable` and + `priority_is_settable`, so the command path and the snapshot make one reading rather than two. + + Absence reads as permission on both flags, which is what they mean: each marks the exception, and defaulting to locked would refuse every circuit on a panel that omits them. Flat publishes no `$settable`, so there is one signal here where the + parent/child adapter reads two — a statement about the schema rather than a weaker rule. + + A locked relay keeps a settable priority: always-on is not never-backup on either schema. + ## [1.0.0] First release as a standalone distribution. Requires `span-panel-api` 3.0.0 or newer. diff --git a/packages/schema-0/src/span_panel_api_schema_0/adapter.py b/packages/schema-0/src/span_panel_api_schema_0/adapter.py index 1c36d06..f63e671 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/adapter.py +++ b/packages/schema-0/src/span_panel_api_schema_0/adapter.py @@ -63,10 +63,26 @@ def circuit_nodes_missing_names(self) -> list[str]: def find_node_by_type(self, type_str: str) -> str | None: return self._consumer.find_node_by_type(type_str) - def set_circuit_relay_target(self, circuit_id: str) -> ControlTarget: + def set_circuit_relay_target(self, circuit_id: str) -> ControlTarget | None: + """Where this circuit's relay is commanded, or None if it may not be. + + None on an always-on circuit. `_target` is pure string formatting from a + node id, so without the lookup this aimed a write at a relay the panel + commissioned as permanently closed — and the refusal was already in the + values this adapter parses, as `is_user_controllable`. + """ + if not self._consumer.relay_is_settable(circuit_id): + return None return self._target(circuit_id, "relay") - def set_circuit_priority_target(self, circuit_id: str) -> ControlTarget: + def set_circuit_priority_target(self, circuit_id: str) -> ControlTarget | None: + """Where this circuit's shed priority is written, or None if it may not be. + + None on a never-backup circuit, which is the flat spelling of the + `$settable` lock v1.0 publishes on `load-shed/priority`. + """ + if not self._consumer.priority_is_settable(circuit_id): + return None return self._target(circuit_id, "shed-priority") def set_dominant_power_source_target(self) -> ControlTarget | None: diff --git a/packages/schema-0/src/span_panel_api_schema_0/consumer.py b/packages/schema-0/src/span_panel_api_schema_0/consumer.py index 83f7a4e..256540b 100644 --- a/packages/schema-0/src/span_panel_api_schema_0/consumer.py +++ b/packages/schema-0/src/span_panel_api_schema_0/consumer.py @@ -101,6 +101,38 @@ def register_property_callback( """Delegate to accumulator.register_property_callback().""" return self._acc.register_property_callback(callback) + def relay_is_settable(self, node_id: str) -> bool: + """Whether this circuit's relay may be commanded. + + The rule is the eBus ``switch`` capability's — a relay is settable only + while it is controllable, and a circuit commissioned permanently on is + locked — and it predates the vocabulary that states it. Flat has no + capability nodes and no per-circuit ``$settable`` to read: its schema + document declares ``relay`` settable once, for the *device type*, which + cannot vary per circuit. So the whole signal here is the published + ``always-on`` boolean, which is the flat spelling of + ``relay-controllable`` inverted, and the same one ``_build_circuit`` + already reads into ``is_user_controllable``. + + One signal rather than the parent/child adapter's two, and that is a + statement about the schema rather than a weaker rule: flat publishes no + second opinion to consult. + + Absent reads as commandable, for the reason the flag exists — it marks + the exception, and defaulting to locked would refuse every relay on a + panel that omits it. + """ + return not _parse_bool(self._acc.get_prop(node_id, "always-on")) + + def priority_is_settable(self, node_id: str) -> bool: + """Whether this circuit's shed priority may be written. + + ``never-backup`` is the flat spelling of what v1.0 expresses as + mutability of ``load-shed/priority``, and it is already read into + ``SpanCircuitSnapshot.is_never_backup``. Same reading, second surface. + """ + return not _parse_bool(self._acc.get_prop(node_id, "never-backup")) + def circuit_nodes_missing_names(self) -> list[str]: """Return circuit-like node IDs that have no ``name`` property yet.""" missing: list[str] = [] diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index a3b0e7a..03fe3aa 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -22,6 +22,56 @@ Requires `span-panel-api` **3.1.0 or newer**, and the two must be upgraded toget Renamed rather than re-typed under the old name so that the mismatch is caught at discovery, where the remedy can be named, instead of surfacing as an `AttributeError` on a `str` deep inside a setter. `ADAPTER_CONTRACT` stays **1** — the contract's member list changed, which discovery already checks by name. +- **`set_circuit_relay_target` and `set_circuit_priority_target` return `ControlTarget | None`, and refuse a circuit the panel declares non-commandable.** Both were `_set_topic` — string formatting from a device id, with no declaration lookup — so a + command aimed at a locked relay or a never-backup priority produced a topic and got published, while `circuits.py` was reading exactly that refusal into `is_user_controllable` and `is_never_backup` for the snapshot. The refusal was in the tree; nothing + on the command path consulted it. + + **The rule is the specification's, not an inference from a producer.** `switch` 0.3 — vendored at `packages/schema-1/spec/catalogs/switch.json` — declares `relay` "Settable when `relay-controllable = true`", and defines `relay-controllable` false as + "locked (for example a circuit commissioned as permanently on)". The condition lives in the catalog's prose, because `settable: true` in the JSON describes the property across the capability while the narrowing applies per device, so this is a rule the + code encodes rather than derives — and `test_the_catalog_still_states_the_condition_this_refusal_encodes` reads the catalog and pins the clause, so a specification that stops saying it fails here instead of leaving a stale rule in force. + + **The relay refuses when either signal says no**: `$settable` absent from `switch/relay`, or `switch/relay-controllable` published `false`. Absence reads as locked here — Homie 5 defaults the attribute to false, and the catalog's condition means a + publisher describing a locked relay correctly omits it — which is the opposite of how the same attribute is read on `load-shed/priority`, whose catalog entry carries no condition at all, so mutability is its ordinary state and a lock is an announcement. + Reading both signals is a hedge against a documented SPAN firmware defect: the `$settable` re-toggle on the runtime re-commissioning path is skipped until the service restarts, so declaration and value can disagree on a live panel. Across the two + production enclosures captured, 27 circuits, they never do — which is the catalogued rule showing up in hardware. + + The priority reads `$settable` on `load-shed/priority` — the same attribute `is_never_backup` reports, now named `priority_is_settable` and public alongside the new `relay_is_settable`. A locked relay keeps a settable priority, which is what real panels + publish and what `switch` 0.3 and `load-shed` 0.3 scope separately. + + Also `None` for a circuit id the tree does not carry, rather than a topic addressed to a device nobody published. + +- **`spec_lock.json`: `peer` becomes `peers`, and the eBus emitter is tracked as one.** `ebus-panel-sim` produced the reference tree and was recorded nowhere, which is the whole reason that capture went three releases stale without anything objecting. It + now carries a pin of the same shape panelbench has — repo, ref, role, commit, tag, released version, and the specification commit it implements — so a scheduled job can ask whether the producer has moved and a test can ask whether our checkout is the + release the bytes are attributed to. + + Keyed by name rather than made a list: both readers of the block — `tests/test_schema_one_conformance.py` and `.github/actions/peer-checkouts` — select a peer by identity and never iterate for its own sake, so a list would push a lookup into every call + site. Two fixture keys rather than one, deliberately: panelbench's `fixtures` names paths _inside panelbench_ that are byte-copied here, while the emitter's `produces` names paths _inside this repository_ generated by its `capture_script` from its + `manifest`. One key would have meant two things depending on which peer you read it from. + + This is not a statement of distrust in the emitter. It is published by electrification-bus, the organisation that writes the specification, and is conformed against live panel output — the specification in runnable form, and the right thing to test a + consumer against. What was wrong was depending on a frozen, unrecorded copy of it, which is a dependency nobody can see and therefore nobody can maintain. + + `reference_payloads/README.md` describes the same pin in prose, and `scripts/reference_panel.yaml` — the capture's input — is committed and pinned alongside it, because a capture whose input is not in the tree is the same class of problem as one whose + producer is not written down. + +### Fixed + +- **The reference tree was three producer releases stale, and the suite had been reading its errors as facts.** `reference_payloads/parent_child_tree.json` is regenerated from `ebus-panel-sim` 0.7.0 by the new `scripts/capture_parent_child_reference.py`, + which injects a recording transport into the emitter's own bring-your-own-transport seam and refuses to write a capture taken from any other release. What moved: + + - `switch/relay` no longer carries `$settable` on the locked circuit and its `switch/relay-requester` is `CONFIGURATION` rather than `NONE` (producer 0.7.0) — the fixture this refusal could not be tested against before. + - `connection/count` is gone from every circuit and both lugs: no configuration could ever publish it (producer 0.6.0). + - `power-flows/{pv,battery,grid}` and the BESS's `meter/active-power` are in the sign frame `power-flows` 0.3 defines — the node balance, every term positive when power flows into the thing it names — where the old capture carried the pre-0.6.0 frame and + did not balance. The parser is unchanged and was already right; the fixture was not, and two tests contradicted each other under it (one derived a _charging_ battery from the flows, the other asserted the snapshot reported it discharging). + - The lugs integrate their own meter rather than the gross sum of the circuits behind them (producer 0.6.0), so the capture now runs three ticks — two integration intervals, one importing and one exporting — because after that fix a single interval can + only ever populate one of `imported-energy` / `exported-energy`. + - `load-shed/priority` carries `SOC_THRESHOLD` where two circuits previously carried `UNKNOWN`. The simulator's shipped example commissions them `NICE_TO_HAVE`, a REST-generation value with no v1.0 representation that the emitter maps to `UNKNOWN`; no + production capture has ever published `UNKNOWN`. `UNKNOWN` remains a legal enum member the parser accepts, covered by a synthetic mutation rather than by a fixture pretending a panel publishes it. + + `load-shed/priority`'s missing `UNKNOWN` is a change of _where the obligation comes from_, not a loss of coverage. The catalog declares `format: "UNKNOWN,NEVER,OFF_GRID,SOC_THRESHOLD"` and calls three of those the baseline every host must publish, so + handling them is a contract obligation regardless of what hardware emits — and `test_every_catalogued_priority_is_carried_through_rather_than_repaired` is now parametrised over the catalog's declared format rather than over whatever the capture happens + to contain. Contract obligations come from the catalog; representativeness comes from the capture. A value added upstream reaches that test the moment the catalog is re-vendored. + ## [1.0.0] First release as a standalone distribution, and the first parser for the parent/child data model. Requires `span-panel-api` 3.0.0 or newer, and `ebus-sdk` `>=0.19,<0.24`. diff --git a/packages/schema-1/src/span_panel_api_schema_1/adapter.py b/packages/schema-1/src/span_panel_api_schema_1/adapter.py index 2422d03..c7efe7d 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/adapter.py +++ b/packages/schema-1/src/span_panel_api_schema_1/adapter.py @@ -24,6 +24,7 @@ from span_panel_api.models import ControlTarget from span_panel_api_schema_1.charge_limit import ChargeLimitProperty, ChargeLimitSurface, resolve_charge_limit +from span_panel_api_schema_1.circuits import priority_is_settable, relay_is_settable from span_panel_api_schema_1.const import ( HOMIE_DOMAIN, HOMIE_VERSION, @@ -196,10 +197,44 @@ def find_node_by_type(self, type_str: str) -> str | None: # The adapter names the topic and the transport publishes it, so commanding # a panel needs no connection here either. - def set_circuit_relay_target(self, circuit_id: str) -> ControlTarget: + def set_circuit_relay_target(self, circuit_id: str) -> ControlTarget | None: + """Where this circuit's relay is commanded, or None if it may not be. + + **None where the panel declares the relay non-commandable**, by either of + the two signals `relay_is_settable` reads. `_target` is pure string + formatting from a device id, so building the topic unconditionally aimed + a write at a circuit the panel commissioned as always-on — every part of + the refusal was already in the tree, and nothing consulted it. + + The consumer gating entity creation on `is_user_controllable` is not a + substitute. Settability changes at runtime — re-commissioning a circuit + in place cycles that child's `$state` and republishes its `$description` + — so an entity can outlive its own controllability, and this is a public + method any caller can reach without an entity at all. + + None also where no such device is in the tree, rather than formatting a + topic for a circuit id nothing published. + """ + device = self._child(circuit_id) + if device is None or not relay_is_settable(device): + return None return self._target(circuit_id, NODE_SWITCH, PROP_RELAY) - def set_circuit_priority_target(self, circuit_id: str) -> ControlTarget: + def set_circuit_priority_target(self, circuit_id: str) -> ControlTarget | None: + """Where this circuit's shed priority is written, or None if it may not be. + + The signal is `$settable` on `load-shed/priority` — the same attribute + `priority_is_settable` reads for `SpanCircuitSnapshot.is_never_backup`, + so a circuit this refuses is exactly one the snapshot already reports as + never-backup. One reading, two surfaces. + + Deliberately independent of the relay: a locked relay keeps a settable + priority on real panels, and `switch` 0.3 and `load-shed` 0.3 scope the + two separately. + """ + device = self._child(circuit_id) + if device is None or not priority_is_settable(device): + return None return self._target(circuit_id, NODE_LOAD_SHED, PROP_PRIORITY) def set_dominant_power_source_target(self) -> ControlTarget | None: @@ -368,6 +403,19 @@ def _require_root(self) -> DiscoveredDevice: def _children(self) -> list[DiscoveredDevice]: return list(self._controller.get_descendants(self._serial_number)) + def _child(self, device_id: str) -> DiscoveredDevice | None: + """One descendant by wire id, or None if the tree carries no such device. + + Searched rather than indexed because the tree is repopulated on every + reconnect and a cached map would answer for a panel that is being + replaced. Thirteen devices on a full enclosure makes the linear scan + irrelevant beside a broker round trip. + """ + for device in self._children(): + if device.device_id == device_id: + return device + return None + def _awaiting_descriptions(self, root: DiscoveredDevice) -> tuple[str, ...]: """Devices the tree declares that have not described themselves yet. diff --git a/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py b/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py index 31d7e4d..dc1a2e5 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py +++ b/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py @@ -30,7 +30,7 @@ **Settability is read, never assumed.** The two properties of a spelling differ by exactly one Homie attribute — the ceiling declares no ``settable``, the limit declares ``settable: true`` — so a reader that treated an absent attribute as -"settable", the way :func:`circuits._priority_is_settable` correctly does for +"settable", the way :func:`circuits.priority_is_settable` correctly does for ``load-shed/priority``, would offer to write the installer's ceiling. The defaults are opposite because the questions are: there, locking is the exception a panel announces; here, writability is. diff --git a/packages/schema-1/src/span_panel_api_schema_1/circuits.py b/packages/schema-1/src/span_panel_api_schema_1/circuits.py index b6ec879..3cd8990 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/circuits.py +++ b/packages/schema-1/src/span_panel_api_schema_1/circuits.py @@ -139,29 +139,88 @@ def _tabs(device: DiscoveredDevice) -> list[int]: return tabs -def _priority_is_settable(device: DiscoveredDevice) -> bool: - """Whether ``load-shed/priority`` is user-settable on this circuit. - - This is the successor to the flat ``never-backup`` boolean, and it is read - from the description rather than from a value topic — v1.0 expresses - never-backup as *mutability*, so the signal is the Homie ``$settable`` - attribute on the property definition. +def _declared_settable(device: DiscoveredDevice, node: str, prop: str, *, when_absent: bool) -> bool: + """Read the Homie ``$settable`` attribute off one property's definition. - Absent means settable: locking is the exception a panel announces, so - treating an unannounced circuit as locked would mark every circuit - never-backup on a panel that does not publish the attribute. + ``when_absent`` is the answer for a property that carries no such attribute, + and it is a per-property judgement rather than one rule — which is why it is + a parameter instead of a default. See the two callers below for why they + answer it differently. """ - definition = device.get_node_properties(NODE_LOAD_SHED).get(PROP_PRIORITY) + definition = device.get_node_properties(node).get(prop) if not isinstance(definition, dict): - return True + return when_absent settable = definition.get(ATTR_SETTABLE) if settable is None: - return True + return when_absent if isinstance(settable, bool): return settable return str(settable).strip().lower() != "false" +def priority_is_settable(device: DiscoveredDevice) -> bool: + """Whether ``load-shed/priority`` is user-settable on this circuit. + + ``load-shed`` 0.3 declares ``priority`` settable and states no condition on + it, so mutability is the property's ordinary state and a lock is something a + panel announces. This is the successor to the flat ``never-backup`` boolean, + and it is read from the description rather than from a value topic — v1.0 + expresses never-backup as *mutability*, so the signal is the Homie + ``$settable`` attribute on the property definition. + + Absent therefore means settable: treating an unannounced circuit as locked + would mark every circuit never-backup on a panel that does not publish the + attribute. + """ + return _declared_settable(device, NODE_LOAD_SHED, PROP_PRIORITY, when_absent=True) + + +def relay_is_settable(device: DiscoveredDevice) -> bool: + """Whether this circuit's relay may be commanded. + + **The rule is the specification's, not an inference from what a producer + does.** ``switch`` 0.3 — vendored at ``packages/schema-1/spec/catalogs/ + switch.json`` — declares ``relay`` as *"Settable when ``relay-controllable = + true``"*, and defines ``relay-controllable`` as *"True = the relay can be + opened and closed by command or automatic shed. False = locked (for example + a circuit commissioned as permanently on)."* A consumer that published to a + circuit whose ``relay-controllable`` is ``false`` would be writing to a + property the specification says is not settable on that device. + + The condition is stated in the catalog's *prose*, where ``settable: true`` + stands unconditionally in the JSON beside it — the machine-readable field + describes the property across the capability, and the condition that narrows + it per device is not expressible there. So this reads the catalog and + encodes the rule; it cannot derive it. `test_schema_one_control_refusal.py` + pins the clause this depends on, so a specification that stops saying it + fails here rather than leaving a stale rule in force. + + **Both signals, and it refuses when either says no.** The declaration and + the value answer the same question, and SPAN reports a firmware defect in + which the ``$settable`` re-toggle on the runtime re-commissioning path is + skipped until the service restarts — so a consumer can meet a panel whose + declaration is stale while the published value is current. The panel rejects + an out-of-policy write regardless of what ``$settable`` last advertised, so + the conjunction only ever refuses a write the panel would have refused. + + **Absent ``$settable`` reads as locked here, the opposite of + `priority_is_settable`.** Homie 5 defaults the attribute to false, and the + catalog's condition means a locked relay is *not* settable, so a publisher + describing one correctly omits the attribute rather than publishing + ``false``. Absence is therefore the announcement. Priority answers the other + way because its catalog entry carries no condition at all. The two + properties are not making the same kind of claim. + + Across the two production enclosures we hold captures from — 27 circuits — + ``$settable`` is present on ``switch/relay`` exactly when + ``relay-controllable`` is ``true``, without exception, which is the + specification's rule showing up in hardware. + """ + return _declared_settable(device, NODE_SWITCH, PROP_RELAY, when_absent=False) and _flag( + device, NODE_SWITCH, PROP_RELAY_CONTROLLABLE, default=True + ) + + def build_circuit( device: DiscoveredDevice, device_type: str = "circuit", relative_position: str = "" ) -> SpanCircuitSnapshot: @@ -173,7 +232,7 @@ def build_circuit( relay_controllable = _flag(device, NODE_SWITCH, PROP_RELAY_CONTROLLABLE, default=True) priority = _text(device, NODE_LOAD_SHED, PROP_PRIORITY, UNKNOWN) - priority_settable = _priority_is_settable(device) + priority_settable = priority_is_settable(device) return SpanCircuitSnapshot( circuit_id=device.device_id, diff --git a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md index 1249232..add042d 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md +++ b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md @@ -4,7 +4,38 @@ Shipped as package data and read through `span_panel_api_schema_1.reference_payl ## `parent_child_tree.json` -Retained topics captured off a `panel_sim` parent/child tree for a 40-space panel: 13 devices — the panel, both lugs, a BESS with its MID, a PV, an EVSE, and the circuits. +Retained topics captured off the eBus emitter's parent/child tree for a 40-space panel: 13 devices — the panel, both lugs, a BESS with its MID, a PV, an EVSE, and the circuits. Shape is `{device_id: {topic: payload}}`, every value a string, exactly as the broker retains them. `$description` is therefore a **JSON string**, not a nested object; `device_from_topics` replays it the way the transport does. `bess-mid` is typed `energy.ebus.device.mid`, not `.bess` — a consumer filtering the tree by type marker has to expect the MID to survive a BESS filter. + +### Provenance + +Recorded machine-readably in `spec_lock.json` as `peers.ebus-panel-sim`, which is the single home of the pin — this section describes it, and the capture script reads it. + +| | | +| -------------- | --------------------------------------------------------- | +| Producer | `ebus-panel-sim` 0.7.0 | +| Repository | electrification-bus/distribution-enclosure-simulator | +| Commit | `156b6ef14fbd00ca9e79ca2fc4bcd2ca4a6348f3` (tag `v0.7.0`) | +| Capture script | `scripts/capture_parent_child_reference.py` | +| Manifest | `scripts/reference_panel.yaml` | + +**The producer is the specification in runnable form.** `ebus-panel-sim` is published by electrification-bus — the organisation that writes the eBus specification — and is conformed against live panel output. Its own `.ebus-spec.json` names the +specification commit it implements, and `test_the_emitters_pin_matches_ours` checks that against ours, so a disagreement between this parser and this capture is a disagreement about one document rather than about two. Testing against it is correct. + +What was wrong was depending on a **frozen, unrecorded** copy of it. This file used to state what the capture _contained_ and not what _made_ it, so when the emitter was corrected the capture silently was not — and a producer defect in `$settable` on a +locked relay reached about thirty test files across two repositories before anyone compared them. The pin, the script that reads it, and the script's refusal to write a capture from any other release are the three halves of that fix. See #161 and #162. + +**The manifest is this repository's, not the emitter's example.** `scripts/reference_panel.yaml` is committed and pinned for the same reason the producer version is: a capture whose input is not in the tree is the same class of problem as one whose +producer is not written down. It mirrors `examples/forty_tab_minimal.yaml` key for key so the two can be diffed, and marks its two deliberate divergences at the head of the file: + +1. **Shed priorities.** The example commissions two circuits `NICE_TO_HAVE`, a REST-generation value with no v1.0 representation that the emitter degrades to `UNKNOWN` (electrification-bus/distribution-enclosure-simulator#51, open). Across the two + production enclosures we hold captures from — 27 circuits — no panel has ever published `UNKNOWN`, so this manifest uses values a real panel publishes. **`UNKNOWN` is still a legal enum member and this parser must handle it**; that obligation comes from + `load-shed` 0.3's declared `$format` and is tested from the catalog in `tests/test_schema_one_circuits.py`, not from this capture. Contract obligations come from the catalog; representativeness comes from the capture. +2. **Identity properties.** The BESS's part/serial/firmware, the MID's model, firmware and hardware version, and the PV's firmware are all published by real panels and unset in the example. A capture omitting them understates what a consumer has to parse — + which is what left four library tests injecting those values by hand, reading as coverage while asking nothing about what a panel sends. Every value is synthetic (`example-40t-001`, `EXAMPLE-BESS-40T-001`), because these bytes ship in a wheel. + +The cost of that choice is real: the capture is no longer reproducible by running an example anyone can find in the emitter. The committed manifest is what buys it back. + +**Shape-stable, not byte-stable.** Each `$description` carries a `version` minted from the wall clock, so all thirteen move on every recapture. Nothing reads it; a diff confined to those lines means the producer did not move. diff --git a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json index 0aee1b8..c6700de 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json +++ b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json @@ -1,234 +1,234 @@ { - "0ab966b95f92a6a51ec548485aa85f54": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "15", - "info/name": "Kitchen Lights", - "info/spaces": "1", - "load-shed/priority": "UNKNOWN", - "meter/active-power": "-121.0", - "meter/current": "1.0083333333333333", - "meter/exported-energy": "2.0166666666666666", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "1", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "573066aaddd7b75114c4563ce3af18c4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "30", - "connection/feeds-device-id": "pv", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.pv", - "info/name": "Solar Inverter", - "info/spaces": "36,38", - "load-shed/priority": "NEVER", - "meter/active-power": "8500.0", - "meter/current": "35.416666666666664", - "meter/exported-energy": "0.0", - "meter/imported-energy": "141.66666666666666", - "pcs/managed": "false", - "pcs/priority": "5", - "switch/relay": "CLOSED", - "switch/relay-controllable": "false", - "switch/relay-requester": "NONE" - }, - "62d0e03897b337b57101aae82f1e9ba2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "50", - "connection/feeds-device-id": "evse", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.evse", - "info/name": "SPAN Drive - Garage", - "info/spaces": "32,34", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "-2410.0", - "meter/current": "10.041666666666666", - "meter/exported-energy": "40.166666666666664", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "3", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "bess": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/firmware-version": "example-bess/v0.1.0", - "info/model": "Example BESS", - "info/nameplate-capacity": "13.5", - "info/part-number": "SPN-BESS-001", - "info/serial-number": "EXAMPLE-BESS-40T-001", - "info/vendor-name": "Span", - "meter/active-power": "-3500.0", - "soc/soc": "50.410493827160494", - "soc/soe": "6.805416666666667", - "status/communication-state": "OK" - }, - "bess-mid": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", - "$state": "ready", - "grid/grid-forming-entity": "GRID", - "grid/grid-state": "UP", - "grid/islanding-state": "ON_GRID", - "info/firmware-version": "example-mid/v0.1.0", - "info/hardware-version": "rev1", - "info/model": "SPAN MID", - "info/serial-number": "EXAMPLE-BESS-40T-001-mid", - "info/vendor-name": "Span" - }, - "d3724e0d660ba506aa79c1cafe5d1181": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "1", - "breaker/rating": "20", - "info/name": "Garage Outlet", - "info/spaces": "2", - "load-shed/priority": "UNKNOWN", - "meter/active-power": "-122.0", - "meter/current": "1.0166666666666666", - "meter/exported-energy": "2.033333333333333", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "2", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "evse": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "example/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-example-40t-001", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "CHARGING", - "switch/lock-state": "LOCKED" - }, - "evse-2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "config/max-charge-current": "32", - "config/user-max-charge-current": "32", - "info/firmware-version": "example/v0.1.0", - "info/model": "SPAN Drive", - "info/part-number": "SPN-DRV-001", - "info/serial-number": "SIM-EVSE-example-40t-001-2", - "info/vendor-name": "SPAN", - "meter/advertised-current": "32.0", - "status/status": "AVAILABLE", - "switch/lock-state": "UNLOCKED" - }, - "example-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", - "$state": "ready", - "breaker/rating": "200", - "door/state": "CLOSED", - "info/data-model-version": "1.0", - "info/firmware-version": "example/v0.1.0", - "info/hardware-version": "rev2", - "info/model": "MAIN_40", - "info/serial-number": "example-40t-001", - "info/vendor-name": "Span", - "meter/voltage-a": "120.0", - "meter/voltage-b": "120.0", - "pcs/active": "false", - "pcs/binding-constraint": "NONE", - "pcs/enabled": "false", - "pcs/feed-import-limit": "0.0", - "pcs/feed-import-limit-active": "false", - "pcs/feed-import-limit-enablement": "UNCONFIGURED", - "pcs/import-limit": "0.0", - "pcs/off-grid-import-limit": "0.0", - "pcs/off-grid-import-limit-active": "false", - "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", - "pcs/operator-import-limit": "0.0", - "pcs/operator-import-limit-active": "false", - "pcs/operator-import-limit-enablement": "UNCONFIGURED", - "pcs/requested-import-limit": "0.0", - "pcs/requested-import-limit-active": "false", - "pcs/requested-import-limit-enablement": "UNCONFIGURED", - "power-flows/battery": "-3500.0", - "power-flows/grid": "-2347.0", - "power-flows/pv": "8500.0", - "power-flows/site": "2653.0", - "shed-forecast/confidence": "HIGH", - "shed-forecast/full-charge-time-to-priority-shed": "3038", - "shed-forecast/full-charge-total-time-remaining": "4320", - "shed-forecast/time-to-priority-shed": "3037", - "shed-forecast/total-time-remaining": "4320", - "shed/asserted-islanding-state": "NONE", - "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", - "status/cloud-connection": "CONNECTED", - "status/ethernet": "true", - "status/postal-code": "94103", - "status/relay": "CLOSED", - "status/time-zone": "America/Los_Angeles", - "status/wifi": "true", - "status/wifi-ssid": "example-wifi" - }, - "fe8b85c15bc9610c1b8b4ebc6f82488d": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "breaker/poles": "2", - "breaker/rating": "50", - "connection/feeds-device-id": "evse-2", - "connection/feeds-device-status": "OK", - "connection/feeds-device-type": "energy.ebus.device.evse", - "info/name": "SPAN Drive - Driveway", - "info/spaces": "35,37", - "load-shed/priority": "OFF_GRID", - "meter/active-power": "0.0", - "meter/current": "0.0", - "meter/exported-energy": "0.0", - "meter/imported-energy": "0.0", - "pcs/managed": "true", - "pcs/priority": "4", - "switch/relay": "CLOSED", - "switch/relay-controllable": "true", - "switch/relay-requester": "NONE" - }, - "lugs-downstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/direction": "DOWNSTREAM", - "meter/active-power": "-5847.0", - "meter/current-a": "46.46666666666666", - "meter/current-b": "46.474999999999994", - "meter/exported-energy": "141.66666666666666", - "meter/imported-energy": "44.21666666666666" - }, - "lugs-upstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "connection/fed-by-device-id": "bess", - "connection/fed-by-device-status": "OK", - "connection/fed-by-device-type": "energy.ebus.device.bess", - "info/direction": "UPSTREAM", - "meter/active-power": "-5847.0", - "meter/current-a": "46.46666666666666", - "meter/current-b": "46.474999999999994", - "meter/exported-energy": "141.66666666666666", - "meter/imported-energy": "44.21666666666666" - }, - "pv": { - "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", - "$state": "ready", - "info/firmware-version": "example-pv/v0.1.0", - "info/model": "IQ8PLUS-72-2-US", - "info/nominal-power": "10000.0", - "info/vendor-name": "Enphase" - } + "0ab966b95f92a6a51ec548485aa85f54": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163063, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Kitchen Lights", + "info/spaces": "1", + "load-shed/priority": "SOC_THRESHOLD", + "meter/active-power": "-121.0", + "meter/current": "1.0083333333333333", + "meter/exported-energy": "4.033333333333333", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "1", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "573066aaddd7b75114c4563ce3af18c4": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "connection/feeds-device-id": "pv", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.pv", + "info/name": "Solar Inverter", + "info/spaces": "36,38", + "load-shed/priority": "NEVER", + "meter/active-power": "8500.0", + "meter/current": "35.416666666666664", + "meter/exported-energy": "0.0", + "meter/imported-energy": "182.16666666666666", + "pcs/managed": "false", + "pcs/priority": "5", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "CONFIGURATION" + }, + "62d0e03897b337b57101aae82f1e9ba2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163063, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Garage", + "info/spaces": "32,34", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-2410.0", + "meter/current": "10.041666666666666", + "meter/exported-energy": "80.33333333333333", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "3", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "bess": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/firmware-version": "example-bess/v0.1.0", + "info/model": "Example BESS", + "info/nameplate-capacity": "13.5", + "info/part-number": "SPN-BESS-001", + "info/serial-number": "EXAMPLE-BESS-40T-001", + "info/vendor-name": "Span", + "meter/active-power": "3500.0", + "soc/soc": "50.410493827160494", + "soc/soe": "6.805416666666667", + "status/communication-state": "OK" + }, + "bess-mid": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", + "$state": "ready", + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UP", + "grid/islanding-state": "ON_GRID", + "info/firmware-version": "example-mid/v0.1.0", + "info/hardware-version": "rev1", + "info/model": "SPAN MID", + "info/serial-number": "EXAMPLE-BESS-40T-001-mid", + "info/vendor-name": "Span" + }, + "d3724e0d660ba506aa79c1cafe5d1181": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163063, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Garage Outlet", + "info/spaces": "2", + "load-shed/priority": "SOC_THRESHOLD", + "meter/active-power": "-122.0", + "meter/current": "1.0166666666666666", + "meter/exported-energy": "4.066666666666666", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "2", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "evse": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "CHARGING", + "switch/lock-state": "LOCKED" + }, + "evse-2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001-2", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "example-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$state": "ready", + "breaker/rating": "200", + "door/state": "CLOSED", + "info/data-model-version": "1.0", + "info/firmware-version": "example/v0.1.0", + "info/hardware-version": "rev2", + "info/model": "MAIN_40", + "info/serial-number": "example-40t-001", + "info/vendor-name": "Span", + "meter/voltage-a": "120.0", + "meter/voltage-b": "120.0", + "pcs/active": "false", + "pcs/binding-constraint": "NONE", + "pcs/enabled": "false", + "pcs/feed-import-limit": "0.0", + "pcs/feed-import-limit-active": "false", + "pcs/feed-import-limit-enablement": "UNCONFIGURED", + "pcs/import-limit": "0.0", + "pcs/off-grid-import-limit": "0.0", + "pcs/off-grid-import-limit-active": "false", + "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", + "pcs/operator-import-limit": "0.0", + "pcs/operator-import-limit-active": "false", + "pcs/operator-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit": "0.0", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "power-flows/battery": "3500.0", + "power-flows/grid": "2347.0", + "power-flows/pv": "-8500.0", + "power-flows/site": "2653.0", + "shed-forecast/confidence": "HIGH", + "shed-forecast/full-charge-time-to-priority-shed": "3038", + "shed-forecast/full-charge-total-time-remaining": "4320", + "shed-forecast/time-to-priority-shed": "3037", + "shed-forecast/total-time-remaining": "4320", + "shed/asserted-islanding-state": "NONE", + "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", + "status/cloud-connection": "CONNECTED", + "status/ethernet": "true", + "status/postal-code": "94103", + "status/relay": "CLOSED", + "status/time-zone": "America/Los_Angeles", + "status/wifi": "true", + "status/wifi-ssid": "example-wifi" + }, + "fe8b85c15bc9610c1b8b4ebc6f82488d": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163063, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse-2", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Driveway", + "info/spaces": "35,37", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "40.333333333333336", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "4", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "lugs-downstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/direction": "DOWNSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "97.45", + "meter/imported-energy": "44.05" + }, + "lugs-upstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "connection/fed-by-device-id": "bess", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.bess", + "info/direction": "UPSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "97.45", + "meter/imported-energy": "44.05" + }, + "pv": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787706163064, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/firmware-version": "example-pv/v0.1.0", + "info/model": "IQ8PLUS-72-2-US", + "info/nominal-power": "10000.0", + "info/vendor-name": "Enphase" + } } diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index 3745b2a..ca05b43 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -10,16 +10,33 @@ "synced_commit": "7ee7ca93b19c3de3d61be44f01887ba9557dd803", "synced_date": "2026-08-21", "framework": "0.9", - "peer": { - "repo": "https://github.com/SpanPanel/panelbench", - "ref": "main", - "role": "publisher", - "commit": "e7579104a04dfb381ba89bfa1cbbe76e2c2d2058", - "synced_commit": "7ee7ca93b19c3de3d61be44f01887ba9557dd803", - "firmware_range": "r202633+", - "fixtures": { - "tree": "tests/conformance/fixtures/golden_tree.json", - "wire": "tests/conformance/fixtures/golden_wire.json" + "peers": { + "panelbench": { + "repo": "https://github.com/SpanPanel/panelbench", + "ref": "main", + "role": "publisher", + "commit": "e7579104a04dfb381ba89bfa1cbbe76e2c2d2058", + "synced_commit": "7ee7ca93b19c3de3d61be44f01887ba9557dd803", + "firmware_range": "r202633+", + "fixtures": { + "tree": "tests/conformance/fixtures/golden_tree.json", + "wire": "tests/conformance/fixtures/golden_wire.json" + } + }, + "ebus-panel-sim": { + "repo": "https://github.com/electrification-bus/distribution-enclosure-simulator", + "ref": "main", + "role": "publisher", + "commit": "156b6ef14fbd00ca9e79ca2fc4bcd2ca4a6348f3", + "tag": "v0.7.0", + "distribution": "ebus-panel-sim", + "version": "0.7.0", + "synced_commit": "7ee7ca93b19c3de3d61be44f01887ba9557dd803", + "capture_script": "scripts/capture_parent_child_reference.py", + "manifest": "scripts/reference_panel.yaml", + "produces": { + "tree": "packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json" + } } }, "implements": { @@ -51,5 +68,5 @@ "device-types": "0.5" } }, - "notes": "role=consumer: span-panel-api-schema-1 parses the Homie 5 distribution-enclosure tree that SPAN firmware r202633+ publishes, and is hot-loaded by span-panel-api through the span_panel_api.schema_adapters entry-point group. It is the consumer counterpart to SpanPanel/panelbench (role=publisher), which is pinned to the same synced_commit; the shared anchor between them is the firmware range above, not this commit, because the spec says what a device class MAY publish while a panel publishes one specific tree. PROVENANCE: packages/schema-1/spec/catalogs/*.json are byte copies of the specification's capabilities/ at synced_commit, and spec/registries/device-types.md is a byte copy of that registry. They are verified by byte comparison when a specification checkout is available (EBUS_SPEC_DIR); the comparison skips when none is, so the conformance check below always runs while the provenance check is opportunistic. Never hand-edit anything under spec/ -- an edit makes the byte comparison meaningless. WHAT IS VENDORED AND WHY SO LITTLE: only the 16 capability catalogs this adapter addresses, because a consumer needs the vocabulary it reads and nothing else. Datatypes, units and formats are deliberately NOT taken from these catalogs at runtime: the adapter reads them from each device's $description, because the same capability exposes different properties on different device classes (meter is voltage on the panel, power and energy on a circuit, both currents on lugs) and the catalog is the superset across all hardware rather than a statement about this panel. The vendored copies exist to be checked against, not to be parsed in production. ABSTRACT UNITS: four catalog properties carry unit: energy, a dimension rather than a unit (conventions/property-json.md 0.2). Being description-driven makes this adapter correct here by construction, and a test asserts it rather than leaving it to luck. EXTENSIONS: SPAN publishes properties no catalog defines -- per-phase meter readings, panel status links, circuit spaces. Those are legal under the specification and are enumerated as an explicit allowlist in tests/test_schema_one_conformance.py, so a name that is absent from the catalog has to be declared deliberately rather than assumed. One whole *node* is an extension: SPAN's EVSE declares `config` with `max-charge-current` / `user-max-charge-current`, and no capability of that name exists upstream -- the catalogued surface is `charge-limit` 0.1 (`installer-max` / `owner-limit`), which is vendored above and which this adapter reads whenever a charger declares it. Both spellings are read because the device's $description is the authority on which one it publishes, and no capture can settle it: the panels we can reach carry no EVSE. PINNING RULE: pin what this adapter actually reads AND that exists in the current spec. pv/evse/mid/lugs have no standalone versioned device model upstream and are covered transitively as child device_types of distribution-enclosure 0.12, so they are not separately pinned." + "notes": "role=consumer: span-panel-api-schema-1 parses the Homie 5 distribution-enclosure tree that SPAN firmware r202633+ publishes, and is hot-loaded by span-panel-api through the span_panel_api.schema_adapters entry-point group. It is the consumer counterpart to SpanPanel/panelbench (role=publisher), which is pinned to the same synced_commit; the shared anchor between them is the firmware range above, not this commit, because the spec says what a device class MAY publish while a panel publishes one specific tree. TWO PEERS, BOTH PUBLISHERS: `peers` is keyed by name because both readers -- tests/test_schema_one_conformance.py and .github/actions/peer-checkouts -- select a peer by identity rather than by position, and a peer's name is a natural stable key. `panelbench` is the SPAN-side publisher this parser is developed against; `ebus-panel-sim` is the eBus specification's own executable publisher, from the same organisation that writes the spec and conformed against live panel output, which is why testing against it is testing against the specification in runnable form. Depending on it is correct. What was wrong was depending on a FROZEN, UNRECORDED copy of it: the reference tree was captured once, the version that produced it was written down nowhere, and when the emitter was corrected the capture silently was not -- so this repository went on asserting a producer defect as fact across roughly thirty test files. The pin above is the fix, and it is machine-readable so a scheduled job can ask whether the producer has moved. TWO FIXTURE KEYS, DELIBERATELY NOT ONE: panelbench carries `fixtures`, whose paths are inside PANELBENCH and are byte-copied here; ebus-panel-sim carries `produces`, whose paths are inside THIS repository and are generated by `capture_script` from `manifest`. The same key would have meant two different things depending on which peer you read it from, which is the kind of ambiguity a lockfile exists to remove. PROVENANCE: packages/schema-1/spec/catalogs/*.json are byte copies of the specification's capabilities/ at synced_commit, and spec/registries/device-types.md is a byte copy of that registry. They are verified by byte comparison when a specification checkout is available (EBUS_SPEC_DIR); the comparison skips when none is, so the conformance check below always runs while the provenance check is opportunistic. Never hand-edit anything under spec/ -- an edit makes the byte comparison meaningless. WHAT IS VENDORED AND WHY SO LITTLE: only the 16 capability catalogs this adapter addresses, because a consumer needs the vocabulary it reads and nothing else. Datatypes, units and formats are deliberately NOT taken from these catalogs at runtime: the adapter reads them from each device's $description, because the same capability exposes different properties on different device classes (meter is voltage on the panel, power and energy on a circuit, both currents on lugs) and the catalog is the superset across all hardware rather than a statement about this panel. The vendored copies exist to be checked against, not to be parsed in production. ABSTRACT UNITS: four catalog properties carry unit: energy, a dimension rather than a unit (conventions/property-json.md 0.2). Being description-driven makes this adapter correct here by construction, and a test asserts it rather than leaving it to luck. EXTENSIONS: SPAN publishes properties no catalog defines -- per-phase meter readings, panel status links, circuit spaces. Those are legal under the specification and are enumerated as an explicit allowlist in tests/test_schema_one_conformance.py, so a name that is absent from the catalog has to be declared deliberately rather than assumed. One whole *node* is an extension: SPAN's EVSE declares `config` with `max-charge-current` / `user-max-charge-current`, and no capability of that name exists upstream -- the catalogued surface is `charge-limit` 0.1 (`installer-max` / `owner-limit`), which is vendored above and which this adapter reads whenever a charger declares it. Both spellings are read because the device's $description is the authority on which one it publishes, and no capture can settle it: the panels we can reach carry no EVSE. PINNING RULE: pin what this adapter actually reads AND that exists in the current spec. pv/evse/mid/lugs have no standalone versioned device model upstream and are covered transitively as child device_types of distribution-enclosure 0.12, so they are not separately pinned." } diff --git a/scripts/capture_parent_child_reference.py b/scripts/capture_parent_child_reference.py new file mode 100644 index 0000000..e3bd5be --- /dev/null +++ b/scripts/capture_parent_child_reference.py @@ -0,0 +1,502 @@ +"""Capture the parent/child emitter's retained surface, without a broker. + +Produces `packages/schema-1/src/span_panel_api_schema_1/reference_payloads/ +parent_child_tree.json`, the schema_1 reference tree that ships as package data +and that fifteen test modules here replay through `devices_from_tree`. + +Run it from the **emitter's** environment, not this one — it imports +`ebus_panel_sim`, which caps `ebus-sdk` below the version this repo installs: + + cd ../distribution-enclosure-simulator + uv run python ../span-panel-api/scripts/capture_parent_child_reference.py \\ + ../span-panel-api/packages/schema-1/src/span_panel_api_schema_1/\\ +reference_payloads/parent_child_tree.json + +`PANEL_SIM_DIR` overrides where the checkout is looked for; it defaults to a +`distribution-enclosure-simulator` directory beside this repo. Passing no output +path writes `parent_child_capture.json` in the working directory, which is the +safe way to look at a capture before adopting it. + +**What the emitter is, and why depending on it is right.** `ebus-panel-sim` is +published by electrification-bus, the organisation that writes the eBus +specification, and is conformed against live panel output. It is the +specification in runnable form and the designated checkpoint for correctness — +not a third-party imitation to be second-guessed. Its `.ebus-spec.json` names the +specification commit it implements, and `test_the_emitters_pin_matches_ours` +checks that against ours, so a disagreement between this parser and a capture is +a disagreement about one document rather than about two. + +What went wrong was never the dependency. It was depending on a **frozen, +unrecorded** copy: the reference tree was captured once, nothing wrote down what +made it, and when the emitter was corrected the capture silently was not — so +this repository went on asserting a producer defect as fact across roughly thirty +test files. Three things fix that, and all three are here: the pin lives in +`spec_lock.json`, this script reads it rather than restating it, and the capture +is refused when the installed emitter is not the pinned release. + +Substitutes the transport rather than reassembling the emitter: the recorder is +handed to `Emitter(mqttc=...)`, the producer's own bring-your-own-transport +seam, and `start()` / `publish_tick()` then run their ordinary path — real +graph builder, real profiles, real BESS dispatch, real relay resolution, real +diff/publish loop. Only the socket is different, which is the point of the +capture. Reassembling instead would prove less than it appears to, because a +capture taken through different wiring than a real panel uses is a capture of +the wiring. `examples/run_forty_tab_minimal.py` in the emitter is the reference +for how it is driven; the difference is that it reads the tree back through a +broker and this records it at the transport. + +**The manifest is `scripts/reference_panel.yaml`, in this repository.** Not the +emitter's `examples/forty_tab_minimal.yaml`, and that file says at its head +exactly which two things it changes and why — spec-legal shed priorities in place +of a value the emitter degrades to `UNKNOWN` +(electrification-bus/distribution-enclosure-simulator#51), and the identity +properties a real panel publishes. The cost of that choice is real and worth +naming: the capture is no longer reproducible by running an example anyone can +find in the emitter, so the manifest is committed here and pinned in +`spec_lock.json` as `peers.ebus-panel-sim.manifest`. + +**Shape-stable, not byte-stable.** Every `$description` carries a `version` +minted from the wall clock when its device is built, so all thirteen move on +every run. Nothing here reads it — it is Homie's own change counter — but it +does mean a recapture always shows thirteen diffs, and that a diff confined to +those lines says the producer did not move. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +import hashlib +import json +import os +import pathlib +import sys + +_REPO = pathlib.Path(__file__).resolve().parent.parent +SIM = pathlib.Path(os.environ.get("PANEL_SIM_DIR", _REPO.parent / "distribution-enclosure-simulator")) +if not (SIM / "src").is_dir(): + raise SystemExit(f"no emitter checkout at {SIM}; set PANEL_SIM_DIR") +sys.path.insert(0, str(SIM / "src")) + +import yaml # noqa: E402 + +from ebus_panel_sim import ( # noqa: E402 + BESSConfig, + ChargeMode, + DeviceInstance, + DeviceManifest, + Emitter, + PanelEnvelopeTick, + SetterRegistry, + TickInputs, + __version__ as PRODUCER_VERSION, +) + +LOCK = _REPO / "packages" / "schema-1" / "src" / "span_panel_api_schema_1" / "spec_lock.json" +MANIFEST = _REPO / "scripts" / "reference_panel.yaml" +PEER = "ebus-panel-sim" + +OUT = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path("parent_child_capture.json") + +_ID_NAMESPACE = "panel-sim-example" +_VALID_RELAY_BEHAVIORS = frozenset({"controllable", "non-controllable", "always-on"}) +_VALID_INVERTER_TYPES = frozenset({"hybrid", "ac-coupled"}) + + +# --------------------------------------------------------------------------- +# Reading YAML without giving up on types +# --------------------------------------------------------------------------- + + +def _mapping(value: object, where: str) -> dict[str, object]: + if not isinstance(value, Mapping): + raise SystemExit(f"{where} must be a mapping, got {type(value).__name__}") + return {str(key): item for key, item in value.items()} + + +def _optional_mapping(value: object) -> dict[str, object]: + return _mapping(value, "") if isinstance(value, Mapping) else {} + + +def _sequence(value: object, where: str) -> list[object]: + if not isinstance(value, Sequence) or isinstance(value, str | bytes): + raise SystemExit(f"{where} must be a list, got {type(value).__name__}") + return list(value) + + +def _mappings(value: object, where: str) -> list[dict[str, object]]: + return [_mapping(item, f"{where}[{index}]") for index, item in enumerate(_sequence(value, where))] + + +def _text(source: Mapping[str, object], key: str, default: str) -> str: + value = source.get(key) + return default if value is None else str(value) + + +def _number(value: object, where: str) -> float: + if isinstance(value, bool) or not isinstance(value, int | float | str): + raise SystemExit(f"{where} must be a number, got {value!r}") + return float(value) + + +def _decimal(source: Mapping[str, object], key: str, default: float) -> float: + value = source.get(key) + return default if value is None else _number(value, key) + + +def _flag(source: Mapping[str, object], key: str, *, default: bool = False) -> bool: + value = source.get(key) + return default if value is None else bool(value) + + +def _bool_str(value: bool) -> str: + return "true" if value else "false" + + +def circuit_id(source_id: str) -> str: + """The emitter example's own circuit-id derivation, reproduced exactly. + + Hashed rather than named so the ids in the capture look like the opaque + 32-hex ids a real panel publishes, and reproducible so a recapture does not + rewrite every circuit key. + """ + return hashlib.sha256(f"{_ID_NAMESPACE}:{source_id}".encode()).hexdigest()[:32] + + +# --------------------------------------------------------------------------- +# The pin, which lives in exactly one place +# --------------------------------------------------------------------------- + + +def pinned_release() -> str: + """The `ebus-panel-sim` release this capture is a capture of. + + Read out of `spec_lock.json` rather than restated here. A constant in this + file would be a second home for the pin, and the two would agree right up + until somebody recaptured and updated only one -- which is the failure this + whole change exists to make impossible. + """ + with LOCK.open(encoding="utf-8") as handle: + lock: object = json.load(handle) + peers = _mapping(_mapping(lock, "spec_lock.json")["peers"], "peers") + return _text(_mapping(peers[PEER], f"peers.{PEER}"), "version", "") + + +# --------------------------------------------------------------------------- +# The manifest +# --------------------------------------------------------------------------- + + +class Profile: + """`reference_panel.yaml`, read once and answered from.""" + + def __init__(self, path: pathlib.Path) -> None: + with path.open(encoding="utf-8") as handle: + loaded: object = yaml.safe_load(handle) + self._root = _mapping(loaded, str(path)) + self.panel = _mapping(self._root["panel_config"], "panel_config") + self.bess = _optional_mapping(self._root.get("bess")) + self.templates = _optional_mapping(self._root.get("circuit_templates")) + self.circuits = _mappings(self._root.get("circuits", []), "circuits") + self.tick_rows = _mappings(self._root.get("ticks", []), "ticks") + + @property + def panel_id(self) -> str: + return _text(self.panel, "serial_number", "") + + def template_of(self, circuit: Mapping[str, object]) -> dict[str, object]: + return _optional_mapping(self.templates.get(_text(circuit, "template", ""))) + + def circuits_of_type(self, device_type: str) -> list[dict[str, object]]: + return [c for c in self.circuits if _text(self.template_of(c), "device_type", "") == device_type] + + +def relay_behavior(template: Mapping[str, object]) -> str: + candidate = _text(template, "relay_behavior", "controllable").lower().replace("_", "-") + return candidate if candidate in _VALID_RELAY_BEHAVIORS else "controllable" + + +def circuit_instance(profile: Profile, circuit: Mapping[str, object], pcs_priority: int) -> DeviceInstance: + template = profile.template_of(circuit) + behavior = relay_behavior(template) + tabs = ",".join(str(int(_number(tab, "tabs"))) for tab in _sequence(circuit["tabs"], "tabs")) + return DeviceInstance( + "circuit", + circuit_id(_text(circuit, "id", "")), + _text(circuit, "name", _text(circuit, "id", "")), + metadata={ + "tab-numbers": tabs, + "breaker-rating-a": str(_decimal(template, "breaker_rating", _decimal(circuit, "breaker_rating", 20.0))), + "default-priority": _text(template, "priority", "").upper(), + "relay-behavior": behavior, + "placement": _text(circuit, "placement", "downstream-of-lugs"), + "always-on": _bool_str(behavior == "always-on"), + "pcs-priority": str(pcs_priority), + }, + ) + + +def bess_instances(profile: Profile) -> list[DeviceInstance]: + """The battery, and the MID an islandable enclosure hosts beside it. + + Together because the MID's identity is derived from the battery's -- it is + the `-mid` child a grid-forming BESS exposes on a real panel, and + its serial is the battery's with a suffix. + """ + if not _flag(profile.bess, "enabled"): + return [] + bess_id = _text(profile.bess, "instance_id", "bess") + vendor = _text(profile.bess, "vendor", "Span") + serial = _text(profile.bess, "serial_number", "") + battery = DeviceInstance( + "bess", + bess_id, + "Battery", + metadata={ + "vendor-name": vendor, + "model": _text(profile.bess, "product_name", "Battery"), + "part-number": _text(profile.bess, "part_number", ""), + "serial-number": serial, + "firmware-version": _text(profile.bess, "firmware_version", ""), + "nameplate-capacity-kwh": str(_decimal(profile.bess, "nameplate_capacity_kwh", 13.5)), + "initial-soe-kwh": str(_decimal(profile.bess, "initial_soe_kwh", 0.0)), + "relative-position": _text(profile.bess, "relative_position", "UPSTREAM"), + }, + ) + if not _flag(profile.panel, "islandable"): + return [battery] + mid = DeviceInstance( + "mid", + f"{bess_id}-mid", + "Microgrid Interconnect Device", + metadata={ + "vendor-name": vendor, + "model": _text(profile.bess, "mid_product_name", ""), + "serial-number": f"{serial}-mid", + "firmware-version": _text(profile.bess, "mid_firmware_version", ""), + "hardware-version": _text(profile.bess, "mid_hardware_version", ""), + }, + ) + return [battery, mid] + + +def pv_instance(profile: Profile) -> list[DeviceInstance]: + feeds = profile.circuits_of_type("pv") + if not feeds: + return [] + template = profile.template_of(feeds[0]) + inverter = _text(template, "inverter_type", "ac-coupled").lower().replace("_", "-") + return [ + DeviceInstance( + "pv", + "pv", + "Solar", + metadata={ + "vendor-name": "Enphase", + "model": "IQ8PLUS-72-2-US", + "firmware-version": _text(template, "firmware_version", ""), + "nominal-power-w": str(_decimal(template, "nameplate_capacity_w", 5000.0)), + "inverter-type": inverter if inverter in _VALID_INVERTER_TYPES else "ac-coupled", + "relative-position": "IN_PANEL", + "feed": circuit_id(_text(feeds[0], "id", "")), + }, + ) + ] + + +def evse_instances(profile: Profile) -> list[DeviceInstance]: + instances: list[DeviceInstance] = [] + for index, circuit in enumerate(profile.circuits_of_type("evse"), start=1): + suffix = "" if index == 1 else f"-{index}" + instances.append( + DeviceInstance( + "evse", + f"evse{suffix}", + _text(circuit, "name", "EV Charger"), + metadata={ + "vendor-name": "SPAN", + "model": "SPAN Drive", + "part-number": "SPN-DRV-001", + "serial-number": f"SIM-EVSE-{profile.panel_id}{suffix}", + "firmware-version": _text(profile.panel, "firmware_version", ""), + "max-current-a": "32.0", + "feed": circuit_id(_text(circuit, "id", "")), + }, + ) + ) + return instances + + +def manifest(profile: Profile) -> DeviceManifest: + """The commissioned panel this capture describes.""" + total_tabs = int(_decimal(profile.panel, "total_tabs", 40)) + instances: list[DeviceInstance] = [ + DeviceInstance( + "panel", + profile.panel_id, + _text(profile.panel, "display_name", "Panel"), + metadata={ + "vendor-name": "Span", + "serial-number": profile.panel_id, + "firmware-version": _text(profile.panel, "firmware_version", ""), + "hardware-version": _text(profile.panel, "hardware_version", ""), + "panel-size": str(total_tabs), + "main-breaker-rating-a": str(int(_decimal(profile.panel, "main_size", 200))), + "panel-model": f"MAIN_{total_tabs}", + "postal-code": _text(profile.panel, "postal_code", ""), + "time-zone": _text(profile.panel, "time_zone", ""), + "service-voltage-v": str(_decimal(profile.panel, "service_voltage_v", 240.0)), + "line-voltage-v": str(_decimal(profile.panel, "line_voltage_v", 120.0)), + "islandable": _bool_str(_flag(profile.panel, "islandable")), + }, + ), + DeviceInstance("lugs", "lugs-upstream", "Upstream lugs", {"direction": "upstream"}), + DeviceInstance("lugs", "lugs-downstream", "Downstream lugs", {"direction": "downstream"}), + ] + instances.extend( + circuit_instance(profile, circuit, index) for index, circuit in enumerate(profile.circuits, start=1) + ) + instances.extend(bess_instances(profile)[:1]) + instances.extend(pv_instance(profile)) + instances.extend(evse_instances(profile)) + # The MID last, matching the emitter example's ordering. Order is not load + # bearing -- the capture is regrouped by device id and written sorted -- but + # matching it keeps the two manifests diffable. + instances.extend(bess_instances(profile)[1:]) + return DeviceManifest(instances=tuple(instances)) + + +def bess_config(profile: Profile) -> tuple[BESSConfig, ...]: + if not _flag(profile.bess, "enabled"): + return () + mode: ChargeMode = "backup-only" if _text(profile.bess, "charge_mode", "") == "backup-only" else "self-consumption" + return ( + BESSConfig( + instance_id=_text(profile.bess, "instance_id", "bess"), + nameplate_capacity_kwh=_decimal(profile.bess, "nameplate_capacity_kwh", 13.5), + max_charge_w=_decimal(profile.bess, "max_charge_w", 3500.0), + max_discharge_w=_decimal(profile.bess, "max_discharge_w", 3500.0), + backup_reserve_pct=_decimal(profile.bess, "backup_reserve_pct", 20.0), + charge_mode=mode, + ), + ) + + +def ticks(profile: Profile) -> list[TickInputs]: + """The driving signal, one entry per `ticks:` row in the manifest.""" + envelope = PanelEnvelopeTick(wifi_ssid=_text(profile.panel, "wifi_ssid", "") or None) + evse_feeds = { + ("evse" if index == 1 else f"evse-{index}"): circuit_id(_text(circuit, "id", "")) + for index, circuit in enumerate(profile.circuits_of_type("evse"), start=1) + } + built: list[TickInputs] = [] + for row in profile.tick_rows: + powers = { + circuit_id(str(source_id)): _number(power, f"ticks.circuits.{source_id}") + for source_id, power in _mapping(row["circuits"], "ticks.circuits").items() + } + built.append( + TickInputs( + current_time=_decimal(row, "current_time", float(len(built) * 60)), + grid_online=_flag(row, "grid_online", default=True), + circuits=powers, + evse={evse_id: powers.get(feed, 0.0) for evse_id, feed in evse_feeds.items()}, + envelope=envelope, + ) + ) + return built + + +# --------------------------------------------------------------------------- +# The recorder +# --------------------------------------------------------------------------- + + +class RecordingTransport: + """Satisfies `ebus_sdk.MqttDeviceTransport`, keeping last-wins retained state. + + Last-wins because that is what a broker's retained store holds, and therefore + what a consumer replays on connect. Non-retained publishes are dropped for + the same reason: they are not in the store a consumer subscribes to. + + The SDK never starts or stops an injected client, which is why this has no + lifecycle methods to implement — see `MqttTransport`'s own note on that. + """ + + is_running = True + + def __init__(self) -> None: + self.retained: dict[str, str] = {} + + def publish(self, topic: str, data: str, qos: int = 1, retain: bool = False) -> object: + del qos + if retain: + self.retained[topic] = data + return None + + def subscribe(self, sub: str, param: object, qos: int = 1) -> object: + del sub, param, qos + return None + + def is_connected(self) -> bool: + return True + + +def as_capture(retained: dict[str, str]) -> dict[str, dict[str, str]]: + """Regroup `ebus/5///` topics by device. + + The shape `device_from_topics` replays: `{device_id: {topic: payload}}`, + every value a string, `$description` a JSON *string* exactly as retained. + """ + devices: dict[str, dict[str, str]] = {} + for topic, payload in sorted(retained.items()): + parts = topic.split("/") + if len(parts) < 4: + continue + devices.setdefault(parts[2], {})["/".join(parts[3:])] = payload + return devices + + +def main() -> None: + expected = pinned_release() + if PRODUCER_VERSION != expected: + raise SystemExit( + f"{SIM} is ebus-panel-sim {PRODUCER_VERSION}, and spec_lock.json records the reference " + f"tree as a capture of {expected}. Capturing anyway would put bytes in the wheel that " + "the lockfile attributes to a release that did not make them. Move the checkout to the " + "pinned release, or take the new capture deliberately: update peers.ebus-panel-sim's " + "version, tag and commit in spec_lock.json, and the provenance section of " + "reference_payloads/README.md, in the same change." + ) + + profile = Profile(MANIFEST) + recorder = RecordingTransport() + emitter = Emitter(manifest(profile), SetterRegistry(), mqttc=recorder, bess_configs=bess_config(profile)) + emitter.start() + try: + for tick in ticks(profile): + emitter.publish_tick(tick) + # Read the store while the tree is up. `stop()` republishes `$state`, and + # a capture of a panel shutting down is not what a consumer replays. + capture = as_capture(recorder.retained) + finally: + emitter.stop(graceful=True) + + # An injected transport publishes nothing the SDK does not ask it to, so check + # the two topics a consumer cannot reach `ready` without rather than trusting + # that they landed. + body = capture.get(profile.panel_id, {}) + missing = [key for key in ("$description", "$state") if key not in body] + if missing: + raise SystemExit(f"capture is unusable: {missing} never landed") + if body["$state"] != "ready": + raise SystemExit(f"capture is of a panel in {body['$state']!r}, not ready") + + OUT.write_text(json.dumps(capture, indent=2, sort_keys=True) + "\n") + + topics = sum(len(value) for value in capture.values()) + print(f"producer: ebus-panel-sim {PRODUCER_VERSION} manifest: {MANIFEST.name}") + print(f"devices: {len(capture)} topics: {topics} -> {OUT}") + print("device ids:", sorted(capture)) + + +main() diff --git a/scripts/reference_panel.yaml b/scripts/reference_panel.yaml new file mode 100644 index 0000000..73a8311 --- /dev/null +++ b/scripts/reference_panel.yaml @@ -0,0 +1,155 @@ +# The panel the reference capture describes. +# +# Read by `scripts/capture_parent_child_reference.py`, which drives the eBus +# emitter (`ebus-panel-sim`) with it and records the retained topics as +# `packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json`. +# Recorded in `spec_lock.json` as `peers.ebus-panel-sim.manifest`, so the capture's +# input is pinned the same way its producer is: a capture whose input is not in the +# tree is the same class of problem as one whose producer version is not written down. +# +# Laid out to mirror the emitter's own `examples/forty_tab_minimal.yaml` key for key, +# so the two can be read side by side and `diff`ed. Every difference is deliberate and +# is marked ==> below. There are two kinds. +# +# ==> DIVERGENCE 1: shed priorities. +# The emitter's example commissions the two ordinary loads as `NICE_TO_HAVE`. That is +# a REST-generation value with no representation in the v1.0 `load-shed` vocabulary, +# and the emitter degrades it to `UNKNOWN` on the wire +# (electrification-bus/distribution-enclosure-simulator#51, open). `UNKNOWN` is a +# legal enum member and this library must parse it -- that obligation is covered by a +# synthetic test built from the catalog's declared `$format`, in +# `tests/test_schema_one_circuits.py`. But it is not a commissioning: across the two +# production enclosures we hold captures from, 27 circuits, no panel has ever +# published it. A reference capture's job is to represent a real panel, so this +# manifest uses values a real panel publishes. When #51 is fixed, someone can decide +# whether to converge with the example again. +# +# ==> DIVERGENCE 2: identity properties. +# Real panels publish serials, part numbers and firmware versions on the BESS, the +# MID and the PV. The emitter's example leaves them unset, so a capture taken from it +# understates what a consumer has to parse -- which is exactly the gap that had four +# library tests injecting those values by hand, reading as coverage while asking +# nothing about what a panel sends. Every value here is synthetic (`example-*`), +# because these bytes ship inside a wheel. + +panel_config: + serial_number: example-40t-001 + display_name: Example 40-tab Panel + total_tabs: 40 + main_size: 200 + postal_code: "94103" + time_zone: America/Los_Angeles + islandable: true + # ==> DIVERGENCE 2. The enclosure's own identity, and the SSID whose absence hid a + # flat -> v1.0 regression: nothing read `status/wifi-ssid` because nothing published + # it, and nothing published it because the capture had never been refreshed. + firmware_version: example/v0.1.0 + hardware_version: rev2 + service_voltage_v: 240.0 + line_voltage_v: 120.0 + wifi_ssid: example-wifi + +bess: + enabled: true + instance_id: bess + vendor: Span + product_name: Example BESS + nameplate_capacity_kwh: 13.5 + initial_soe_kwh: 6.75 + relative_position: UPSTREAM + charge_mode: backup-only + max_charge_w: 3500.0 + max_discharge_w: 3500.0 + # ==> DIVERGENCE 2. The battery's identity, and its integrated MID's. + part_number: SPN-BESS-001 + serial_number: EXAMPLE-BESS-40T-001 + firmware_version: example-bess/v0.1.0 + mid_product_name: SPAN MID + mid_firmware_version: example-mid/v0.1.0 + mid_hardware_version: rev1 + +circuit_templates: + lighting: + relay_behavior: controllable + # ==> DIVERGENCE 1. The example says NICE_TO_HAVE. + priority: SOC_THRESHOLD + breaker_rating: 15 + outlet: + relay_behavior: controllable + # ==> DIVERGENCE 1. The example says NICE_TO_HAVE. + priority: SOC_THRESHOLD + breaker_rating: 20 + span_drive: + relay_behavior: controllable + priority: OFF_GRID + breaker_rating: 50 + device_type: evse + solar: + # The locked circuit, and the reason this capture can test a refusal at all: a + # non-controllable relay publishes no `$settable` and reports requester + # CONFIGURATION. + relay_behavior: non-controllable + priority: NEVER + breaker_rating: 30 + device_type: pv + nameplate_capacity_w: 10000.0 + # ==> DIVERGENCE 2. + firmware_version: example-pv/v0.1.0 + +circuits: + - id: kitchen_lights + name: Kitchen Lights + template: lighting + tabs: [1] + - id: garage_outlet + name: Garage Outlet + template: outlet + tabs: [2] + - id: span_drive_garage + name: SPAN Drive - Garage + template: span_drive + tabs: [32, 34] + - id: span_drive_driveway + name: SPAN Drive - Driveway + template: span_drive + tabs: [35, 37] + - id: solar_inverter + name: Solar Inverter + template: solar + tabs: [36, 38] + +# Three ticks, which is two integration intervals, and the count is forced by what a +# lugs device measures. Since `ebus-panel-sim` 0.6.0 the lugs register their own meter +# rather than the gross sum of the circuits behind them, so one interval can only ever +# advance one of `imported-energy` / `exported-energy` -- the lugs carry power in one +# direction at a time. The first interval imports from the utility and the second +# exports a PV surplus, so both registers carry a value and both mappings are +# answerable. The emitter's example runs two ticks, which is the last two here. +ticks: + - name: night load, importing from the utility + current_time: 0.0 + grid_online: true + circuits: + kitchen_lights: 121.0 + garage_outlet: 122.0 + span_drive_garage: 2410.0 + span_drive_driveway: 2420.0 + solar_inverter: 0.0 + - name: loads and EVSE with some PV + current_time: 60.0 + grid_online: true + circuits: + kitchen_lights: 121.0 + garage_outlet: 122.0 + span_drive_garage: 2410.0 + span_drive_driveway: 2420.0 + solar_inverter: -2430.0 + - name: PV surplus charges BESS and exports + current_time: 120.0 + grid_online: true + circuits: + kitchen_lights: 121.0 + garage_outlet: 122.0 + span_drive_garage: 2410.0 + span_drive_driveway: 0.0 + solar_inverter: -8500.0 diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index 7ff33de..82d5fd8 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -16,7 +16,7 @@ import logging import ssl import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NoReturn from span_panel_api.schema_drift import log_schema_drift @@ -692,8 +692,21 @@ async def set_circuit_relay(self, circuit_id: str, state: str) -> PublishOutcome Returns: What happened to the command. `PublishState.UNCONFIRMED` is not an error -- see `PublishState`. + + Raises: + SpanPanelServerError: the panel declares this circuit's relay + non-commandable, so there is nothing to publish to. Raised the + way `set_evse_charge_limit` raises for a charger with no + settable limit, and recorded through the interceptor first. """ target = self._require_adapter().set_circuit_relay_target(circuit_id) + if target is None: + await self._refuse_control( + device_id=circuit_id, + value=state, + detail="relay not commandable", + message=f"Circuit {circuit_id!r} declares its relay non-commandable", + ) return await self._publish_control(target, state, self._control_deadlines.relay) async def set_circuit_priority(self, circuit_id: str, priority: str) -> PublishOutcome: @@ -705,8 +718,19 @@ async def set_circuit_priority(self, circuit_id: str, priority: str) -> PublishO Returns: What happened to the command. See `PublishState`. + + Raises: + SpanPanelServerError: the circuit is commissioned never-backup, so + its priority is not writable. """ target = self._require_adapter().set_circuit_priority_target(circuit_id) + if target is None: + await self._refuse_control( + device_id=circuit_id, + value=priority, + detail="priority not settable", + message=f"Circuit {circuit_id!r} declares its shed priority not settable", + ) return await self._publish_control(target, priority, self._control_deadlines.priority) # -- PanelControlProtocol ---------------------------------------------- @@ -726,10 +750,21 @@ async def set_dominant_power_source(self, value: str) -> PublishOutcome: adapter = self._require_adapter() target = adapter.set_dominant_power_source_target() if target is None: - raise SpanPanelServerError("Core node not found in panel topology") + await self._refuse_control( + device_id=self._serial_number, + value=value, + detail="no such control", + message="Core node not found in panel topology", + ) payload = adapter.dominant_power_source_payload(value) if payload is None: - raise SpanPanelServerError(f"{value!r} has no representation on this schema's control") + await self._refuse_control( + target=target, + device_id=target.device_id, + value=value, + detail="value has no representation", + message=f"{value!r} has no representation on this schema's control", + ) return await self._publish_control(target, payload, self._control_deadlines.dominant_power_source) # -- EvseControlProtocol ----------------------------------------------- @@ -751,10 +786,21 @@ async def set_evse_charge_limit(self, node_id: str, amps: int) -> PublishOutcome adapter = self._require_adapter() target = adapter.set_evse_charge_limit_target(node_id) if target is None: - raise SpanPanelServerError(f"No settable charge-current limit on EVSE {node_id!r}") + await self._refuse_control( + device_id=node_id, + value=str(amps), + detail="no such control", + message=f"No settable charge-current limit on EVSE {node_id!r}", + ) payload = adapter.evse_charge_limit_payload(node_id, amps) if payload is None: - raise SpanPanelServerError(f"{amps} A is outside what EVSE {node_id!r} accepts") + await self._refuse_control( + target=target, + device_id=target.device_id, + value=str(amps), + detail="value out of range", + message=f"{amps} A is outside what EVSE {node_id!r} accepts", + ) return await self._publish_control(target, payload, self._control_deadlines.evse_charge_limit) # -- AdoptedControlProtocol -------------------------------------------- @@ -788,7 +834,14 @@ async def set_adopted_property(self, device_id: str, node_id: str, property_id: """ surface = self._adopted_property(device_id, node_id, property_id) if surface is None or surface.set_topic is None: - raise SpanPanelServerError(f"No settable adopted property {node_id}/{property_id} on device {device_id!r}") + await self._refuse_control( + device_id=device_id, + node_id=node_id, + property_id=property_id, + value=value, + detail="no settable property", + message=f"No settable adopted property {node_id}/{property_id} on device {device_id!r}", + ) target = ControlTarget( topic=surface.set_topic, device_id=device_id, @@ -849,6 +902,64 @@ def _on_property_value(self, device_id: str, node_id: str, property_id: str, val if verification.key == key and verification.expected == value and not verification.observed.done(): verification.observed.set_result(True) + async def _refuse_control( + self, + *, + device_id: str, + value: str, + detail: str, + message: str, + target: ControlTarget | None = None, + node_id: str = "", + property_id: str = "", + ) -> NoReturn: + """Record a command this library refused, then raise it to the caller. + + The one place a refusal that happens *before* `_publish_control` becomes + visible. Every such refusal is an address that did not resolve -- a relay + the panel declares non-commandable, a charger with no settable limit, a + value with no representation on this schema -- and it therefore never + reached the interceptor at all. `after_publish` is contracted to see + every command, and the commands it was missing were precisely the ones a + panel refused, which is the half of an audit worth having. + + `before_publish` is deliberately not consulted. It exists to authorise a + command that would otherwise be published, and this one would not be + under any answer it could give; running it would let a veto replace a + specific reason with "vetoed", and would ask a consumer's policy to rule + on something the library has already ruled out. + + `target` is passed where the refusal happened *after* the address + resolved -- a payload this schema cannot represent -- so the audit row + carries the real topic. Where it is absent the row says so with None + rather than a topic nothing would have been published to, and + `node_id` / `property_id` stay empty unless the caller knew them without + the adapter, which only `set_adopted_property` does. + + Raises: + SpanPanelServerError: always. The refusal is the point. + """ + interceptor = self._control_interceptor + if interceptor is not None: + command = ControlCommand( + device_id=target.device_id if target is not None else device_id, + node_id=target.node_id if target is not None else node_id, + property_id=target.property_id if target is not None else property_id, + value=value, + topic=target.topic if target is not None else None, + ) + self._fire_after_publish( + interceptor, + command, + PublishOutcome( + state=PublishState.FAILED, + topic=command.topic, + value=value, + detail=detail, + ), + ) + raise SpanPanelServerError(message) + async def _publish_control(self, target: ControlTarget, value: str, deadline: float) -> PublishOutcome: """Run one control command past the interceptor, then deliver it. diff --git a/src/span_panel_api/mqtt/control.py b/src/span_panel_api/mqtt/control.py index e1ba004..6414402 100644 --- a/src/span_panel_api/mqtt/control.py +++ b/src/span_panel_api/mqtt/control.py @@ -72,10 +72,16 @@ class PublishOutcome: `detail` is free text for a human reading a log or an audit row -- which refusal, which deadline. **It never carries a credential**, and nothing should parse it; `state` is the machine-readable half. + + `topic` is None for a command that never had one: a refusal made while + resolving the address -- a relay the panel declares non-commandable, a + charger with no settable limit -- happens before any topic exists, and + naming one anyway would put a string in an audit row that nothing was ever + going to publish to. `state` is `FAILED` whenever it is None. """ state: PublishState - topic: str + topic: str | None value: str no_op: bool = False detail: str | None = None @@ -110,13 +116,24 @@ class ControlCommand: payload, so the identifying fields are the ones actually on the wire rather than the caller's arguments -- a dominant-power-source request of `BATTERY` arrives here as the `OFF_GRID` that will be published under v1.0. + + **A command the library refused before resolving an address still arrives + here**, because `after_publish` is contracted to see every command and an + audit missing exactly the commands a panel declared unsafe is worse than no + audit. Such a command carries what is known and no more: `topic` is None, + and the identifying fields fall back to the caller's arguments, since the + wire spellings the adapter would have supplied are the very thing that did + not resolve. `node_id` and `property_id` are empty strings where even those + are unknown -- under the flat schema a relay lives at + `//relay` and under v1.0 at `/switch/relay`, and + the bootstrap is the one component that must not know which. """ device_id: str node_id: str property_id: str value: str - topic: str + topic: str | None @runtime_checkable @@ -157,6 +174,16 @@ async def before_publish(self, command: ControlCommand) -> None: async def after_publish(self, command: ControlCommand, outcome: PublishOutcome) -> None: """Called with the result of every command, refusals included. + Every command, including one this library refused before it had a topic + to publish to -- a relay the panel declares non-commandable is the + highest-consequence control in the system, and an audit that recorded + the attempts that reached the wire and not the ones that were stopped + would have its hole exactly where the interesting cases are. Those + arrive with `PublishState.FAILED`, a `detail` naming the refusal, and + `command.topic` / `outcome.topic` set to None. `before_publish` is not + consulted for them: there is nothing to authorise, and a veto would + replace a specific reason with "vetoed". + **Fired as a task and not awaited on the control path.** A sink that merely hangs -- a slow event bus, a blocked writer -- would otherwise stall every control call in the process. The consequences are the price: diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index b9ea634..f35a394 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -225,8 +225,8 @@ def circuit_nodes_missing_names(self) -> list[str]: ... def find_node_by_type(self, type_str: str) -> str | None: ... - def set_circuit_relay_target(self, circuit_id: str) -> ControlTarget: - """Where a relay command goes, and the property that reports it. + def set_circuit_relay_target(self, circuit_id: str) -> ControlTarget | None: + """Where a relay command goes, and the property that reports it, or None. Renamed from `set_circuit_relay_topic`, which returned a bare string. The rename is deliberate rather than a return-type change under the old @@ -236,10 +236,36 @@ def set_circuit_relay_target(self, circuit_id: str) -> ControlTarget: discovery, where the remedy -- upgrade both packages together -- can still be named. That is also why `ADAPTER_CONTRACT_VERSION` does not move: the change is additive plus a removal, not a redefinition. + + **None means the panel declares this circuit's relay non-commandable.** + The rule is the eBus `switch` capability's rather than either adapter's: + `relay` is *"Settable when `relay-controllable = true`"*, and + `relay-controllable` false means "locked (for example a circuit + commissioned as permanently on)". Under v1.0 both halves of that are on + the wire and either saying no is a refusal; the flat schema, which + predates capability nodes, spells the same fact `always-on`. The + transport must refuse rather than publish, the same contract + `set_evse_charge_limit_target` states: an address that resolves is the + authorisation, and a topic built by string formatting alone authorises + nothing. + + Widening the return type does not move `ADAPTER_CONTRACT_VERSION` + either, and the direction is why. An older adapter returns a + `ControlTarget` where this now permits `ControlTarget | None`, which is + a *narrower* return and therefore still a valid implementation -- it + simply never exercises the refusal, which is exactly the pre-fix + behaviour and no worse than it. A newer adapter against an older + bootstrap is the case that would break, and the contract version has + never protected that direction: the bootstrap is the one that reads it. """ - def set_circuit_priority_target(self, circuit_id: str) -> ControlTarget: - """Where a shed-priority command goes, and the property that reports it.""" + def set_circuit_priority_target(self, circuit_id: str) -> ControlTarget | None: + """Where a shed-priority command goes, and the property that reports it, or None. + + None where the panel declares the priority locked -- `never-backup` + under the flat schema, `$settable` on `load-shed/priority` under v1.0 -- + which is the same reading `SpanCircuitSnapshot.is_never_backup` reports. + """ def set_dominant_power_source_target(self) -> ControlTarget | None: """Where a dominant-power-source command goes, or None if the panel has no such control.""" diff --git a/tests/fixtures/panelbench_unvalued_by_both.json b/tests/fixtures/panelbench_unvalued_by_both.json index f3a5032..3fd47e6 100644 --- a/tests/fixtures/panelbench_unvalued_by_both.json +++ b/tests/fixtures/panelbench_unvalued_by_both.json @@ -1,123 +1,91 @@ [ - "energy.ebus.device.circuit::Bathroom Lights connection/count", "energy.ebus.device.circuit::Bathroom Lights connection/feeds-device-id", "energy.ebus.device.circuit::Bathroom Lights connection/feeds-device-status", "energy.ebus.device.circuit::Bathroom Lights connection/feeds-device-type", - "energy.ebus.device.circuit::Bedroom Lights connection/count", "energy.ebus.device.circuit::Bedroom Lights connection/feeds-device-id", "energy.ebus.device.circuit::Bedroom Lights connection/feeds-device-status", "energy.ebus.device.circuit::Bedroom Lights connection/feeds-device-type", - "energy.ebus.device.circuit::Chest Freezer connection/count", "energy.ebus.device.circuit::Chest Freezer connection/feeds-device-id", "energy.ebus.device.circuit::Chest Freezer connection/feeds-device-status", "energy.ebus.device.circuit::Chest Freezer connection/feeds-device-type", - "energy.ebus.device.circuit::Dishwasher connection/count", "energy.ebus.device.circuit::Dishwasher connection/feeds-device-id", "energy.ebus.device.circuit::Dishwasher connection/feeds-device-status", "energy.ebus.device.circuit::Dishwasher connection/feeds-device-type", - "energy.ebus.device.circuit::Electric Dryer connection/count", "energy.ebus.device.circuit::Electric Dryer connection/feeds-device-id", "energy.ebus.device.circuit::Electric Dryer connection/feeds-device-status", "energy.ebus.device.circuit::Electric Dryer connection/feeds-device-type", - "energy.ebus.device.circuit::Electric Oven/Range connection/count", "energy.ebus.device.circuit::Electric Oven/Range connection/feeds-device-id", "energy.ebus.device.circuit::Electric Oven/Range connection/feeds-device-status", "energy.ebus.device.circuit::Electric Oven/Range connection/feeds-device-type", - "energy.ebus.device.circuit::Exterior Lights connection/count", "energy.ebus.device.circuit::Exterior Lights connection/feeds-device-id", "energy.ebus.device.circuit::Exterior Lights connection/feeds-device-status", "energy.ebus.device.circuit::Exterior Lights connection/feeds-device-type", - "energy.ebus.device.circuit::Garage Outlets connection/count", "energy.ebus.device.circuit::Garage Outlets connection/feeds-device-id", "energy.ebus.device.circuit::Garage Outlets connection/feeds-device-status", "energy.ebus.device.circuit::Garage Outlets connection/feeds-device-type", - "energy.ebus.device.circuit::Garbage Disposal connection/count", "energy.ebus.device.circuit::Garbage Disposal connection/feeds-device-id", "energy.ebus.device.circuit::Garbage Disposal connection/feeds-device-status", "energy.ebus.device.circuit::Garbage Disposal connection/feeds-device-type", - "energy.ebus.device.circuit::Guest Room Outlets connection/count", "energy.ebus.device.circuit::Guest Room Outlets connection/feeds-device-id", "energy.ebus.device.circuit::Guest Room Outlets connection/feeds-device-status", "energy.ebus.device.circuit::Guest Room Outlets connection/feeds-device-type", - "energy.ebus.device.circuit::Heat Pump connection/count", "energy.ebus.device.circuit::Heat Pump connection/feeds-device-id", "energy.ebus.device.circuit::Heat Pump connection/feeds-device-status", "energy.ebus.device.circuit::Heat Pump connection/feeds-device-type", - "energy.ebus.device.circuit::Kitchen Outlets (Counter) connection/count", "energy.ebus.device.circuit::Kitchen Outlets (Counter) connection/feeds-device-id", "energy.ebus.device.circuit::Kitchen Outlets (Counter) connection/feeds-device-status", "energy.ebus.device.circuit::Kitchen Outlets (Counter) connection/feeds-device-type", - "energy.ebus.device.circuit::Kitchen Outlets (Island) connection/count", "energy.ebus.device.circuit::Kitchen Outlets (Island) connection/feeds-device-id", "energy.ebus.device.circuit::Kitchen Outlets (Island) connection/feeds-device-status", "energy.ebus.device.circuit::Kitchen Outlets (Island) connection/feeds-device-type", - "energy.ebus.device.circuit::Laundry Room Outlets connection/count", "energy.ebus.device.circuit::Laundry Room Outlets connection/feeds-device-id", "energy.ebus.device.circuit::Laundry Room Outlets connection/feeds-device-status", "energy.ebus.device.circuit::Laundry Room Outlets connection/feeds-device-type", - "energy.ebus.device.circuit::Living Room Lights connection/count", "energy.ebus.device.circuit::Living Room Lights connection/feeds-device-id", "energy.ebus.device.circuit::Living Room Lights connection/feeds-device-status", "energy.ebus.device.circuit::Living Room Lights connection/feeds-device-type", - "energy.ebus.device.circuit::Living Room Outlets connection/count", "energy.ebus.device.circuit::Living Room Outlets connection/feeds-device-id", "energy.ebus.device.circuit::Living Room Outlets connection/feeds-device-status", "energy.ebus.device.circuit::Living Room Outlets connection/feeds-device-type", - "energy.ebus.device.circuit::Main HVAC connection/count", "energy.ebus.device.circuit::Main HVAC connection/feeds-device-id", "energy.ebus.device.circuit::Main HVAC connection/feeds-device-status", "energy.ebus.device.circuit::Main HVAC connection/feeds-device-type", - "energy.ebus.device.circuit::Master Bedroom Lights connection/count", "energy.ebus.device.circuit::Master Bedroom Lights connection/feeds-device-id", "energy.ebus.device.circuit::Master Bedroom Lights connection/feeds-device-status", "energy.ebus.device.circuit::Master Bedroom Lights connection/feeds-device-type", - "energy.ebus.device.circuit::Master Bedroom Outlets connection/count", "energy.ebus.device.circuit::Master Bedroom Outlets connection/feeds-device-id", "energy.ebus.device.circuit::Master Bedroom Outlets connection/feeds-device-status", "energy.ebus.device.circuit::Master Bedroom Outlets connection/feeds-device-type", - "energy.ebus.device.circuit::Microwave connection/count", "energy.ebus.device.circuit::Microwave connection/feeds-device-id", "energy.ebus.device.circuit::Microwave connection/feeds-device-status", "energy.ebus.device.circuit::Microwave connection/feeds-device-type", - "energy.ebus.device.circuit::Office Outlets connection/count", "energy.ebus.device.circuit::Office Outlets connection/feeds-device-id", "energy.ebus.device.circuit::Office Outlets connection/feeds-device-status", "energy.ebus.device.circuit::Office Outlets connection/feeds-device-type", - "energy.ebus.device.circuit::Pool Pump connection/count", "energy.ebus.device.circuit::Pool Pump connection/feeds-device-id", "energy.ebus.device.circuit::Pool Pump connection/feeds-device-status", "energy.ebus.device.circuit::Pool Pump connection/feeds-device-type", - "energy.ebus.device.circuit::Refrigerator connection/count", "energy.ebus.device.circuit::Refrigerator connection/feeds-device-id", "energy.ebus.device.circuit::Refrigerator connection/feeds-device-status", "energy.ebus.device.circuit::Refrigerator connection/feeds-device-type", - "energy.ebus.device.circuit::SPAN Drive - Driveway connection/count", - "energy.ebus.device.circuit::SPAN Drive - Garage connection/count", - "energy.ebus.device.circuit::Smoke Detectors connection/count", "energy.ebus.device.circuit::Smoke Detectors connection/feeds-device-id", "energy.ebus.device.circuit::Smoke Detectors connection/feeds-device-status", "energy.ebus.device.circuit::Smoke Detectors connection/feeds-device-type", - "energy.ebus.device.circuit::Solar Inverter connection/count", - "energy.ebus.device.circuit::Washing Machine connection/count", "energy.ebus.device.circuit::Washing Machine connection/feeds-device-id", "energy.ebus.device.circuit::Washing Machine connection/feeds-device-status", "energy.ebus.device.circuit::Washing Machine connection/feeds-device-type", - "energy.ebus.device.circuit::Water Heater connection/count", "energy.ebus.device.circuit::Water Heater connection/feeds-device-id", "energy.ebus.device.circuit::Water Heater connection/feeds-device-status", "energy.ebus.device.circuit::Water Heater connection/feeds-device-type", - "energy.ebus.device.circuit::kitchen Lights connection/count", "energy.ebus.device.circuit::kitchen Lights connection/feeds-device-id", "energy.ebus.device.circuit::kitchen Lights connection/feeds-device-status", "energy.ebus.device.circuit::kitchen Lights connection/feeds-device-type", - "energy.ebus.device.lugs::Downstream lugs connection/count", "energy.ebus.device.lugs::Downstream lugs connection/fed-by-device-id", "energy.ebus.device.lugs::Downstream lugs connection/fed-by-device-status", "energy.ebus.device.lugs::Downstream lugs connection/fed-by-device-type", "energy.ebus.device.lugs::Downstream lugs connection/feeds-device-id", "energy.ebus.device.lugs::Downstream lugs connection/feeds-device-status", "energy.ebus.device.lugs::Downstream lugs connection/feeds-device-type", - "energy.ebus.device.lugs::Upstream lugs connection/count", "energy.ebus.device.lugs::Upstream lugs connection/feeds-device-id", "energy.ebus.device.lugs::Upstream lugs connection/feeds-device-status", "energy.ebus.device.lugs::Upstream lugs connection/feeds-device-type", diff --git a/tests/test_control_interceptor.py b/tests/test_control_interceptor.py index fe508bc..69a3ee8 100644 --- a/tests/test_control_interceptor.py +++ b/tests/test_control_interceptor.py @@ -7,6 +7,11 @@ none); the observation half is fired as a task (a sink that merely hangs must not stall control); and the interceptor sees the refusals and the no-op, not only the commands that reached the wire. + +The fifth edge is the one that was missing rather than merely untested: a +refusal made while *resolving the address* -- a relay the panel declares +non-commandable, a charger with no settable limit -- happens before the publish +path and so was invisible here entirely. See `TestRefusalBeforeATopicExists`. """ from __future__ import annotations @@ -16,6 +21,7 @@ import pytest +from span_panel_api.exceptions import SpanPanelServerError from span_panel_api.models import ControlTarget from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.connection import AsyncMqttBridge @@ -27,6 +33,7 @@ CIRCUIT = "aabbccdd112233445566778899001122" RELAY_TOPIC = f"ebus/5/{SERIAL}/{CIRCUIT}/relay/set" +DPS_TOPIC = f"ebus/5/{SERIAL}/core/dominant-power-source/set" class _Recorder: @@ -233,3 +240,125 @@ async def after_publish(self, command: ControlCommand, outcome: PublishOutcome) def test_a_conforming_object_satisfies_the_protocol(self) -> None: assert isinstance(_Recorder(), ControlInterceptor) + + +class TestRefusalBeforeATopicExists: + """The refusals that happen while resolving the address, not while publishing. + + These never reach `_publish_control`, so before this they were invisible to + the interceptor entirely -- and they are the highest-consequence commands in + the system, a relay the panel declares non-commandable among them. An audit + whose hole is exactly the commands a panel refused is worse than one with no + hole and less coverage. + """ + + @pytest.mark.asyncio + async def test_a_relay_with_no_target_raises(self) -> None: + client = _client() + assert isinstance(client._adapter, MagicMock) + client._adapter.set_circuit_relay_target.return_value = None + + with pytest.raises(SpanPanelServerError, match="non-commandable"): + await client.set_circuit_relay(CIRCUIT, "OPEN") + + @pytest.mark.asyncio + async def test_nothing_is_published(self) -> None: + client = _client() + assert isinstance(client._adapter, MagicMock) + client._adapter.set_circuit_relay_target.return_value = None + assert client._bridge is not None + + with pytest.raises(SpanPanelServerError): + await client.set_circuit_relay(CIRCUIT, "OPEN") + + client._bridge._client.publish.assert_not_called() + + @pytest.mark.asyncio + async def test_the_interceptor_records_it(self) -> None: + """With no topic, because there is none -- naming one would put a string + in the audit that nothing was ever going to publish to.""" + client = _client() + recorder = _Recorder() + client.set_control_interceptor(recorder) + assert isinstance(client._adapter, MagicMock) + client._adapter.set_circuit_relay_target.return_value = None + + with pytest.raises(SpanPanelServerError): + await client.set_circuit_relay(CIRCUIT, "OPEN") + await _settle() + + assert len(recorder.after) == 1 + command, outcome = recorder.after[0] + assert command.device_id == CIRCUIT + assert command.value == "OPEN" + assert command.topic is None + assert outcome.state is PublishState.FAILED + assert outcome.topic is None + assert outcome.detail == "relay not commandable" + + @pytest.mark.asyncio + async def test_before_publish_is_not_consulted(self) -> None: + """There is nothing to authorise, and a veto would replace a specific + reason with "vetoed".""" + client = _client() + recorder = _Recorder(veto=_Refusal("only admins may do that")) + client.set_control_interceptor(recorder) + assert isinstance(client._adapter, MagicMock) + client._adapter.set_circuit_relay_target.return_value = None + + with pytest.raises(SpanPanelServerError): + await client.set_circuit_relay(CIRCUIT, "OPEN") + await _settle() + + assert recorder.before == [] + assert recorder.after[0][1].detail == "relay not commandable" + + @pytest.mark.asyncio + async def test_a_priority_with_no_target_is_recorded_the_same_way(self) -> None: + client = _client() + recorder = _Recorder() + client.set_control_interceptor(recorder) + assert isinstance(client._adapter, MagicMock) + client._adapter.set_circuit_priority_target.return_value = None + + with pytest.raises(SpanPanelServerError, match="not settable"): + await client.set_circuit_priority(CIRCUIT, "NEVER") + await _settle() + + assert recorder.after[0][1].detail == "priority not settable" + + @pytest.mark.asyncio + async def test_a_refusal_after_the_address_resolved_keeps_its_topic(self) -> None: + """A payload this schema cannot represent is refused with the real topic. + + The distinction is worth keeping: "there is no such control" and "that + value may not be written to this control" send an investigation in + different directions, and only the second has an address to name. + """ + client = _client() + recorder = _Recorder() + client.set_control_interceptor(recorder) + assert isinstance(client._adapter, MagicMock) + client._adapter.set_dominant_power_source_target.return_value = ControlTarget( + topic=DPS_TOPIC, device_id=SERIAL, node_id="core", property_id="dominant-power-source" + ) + client._adapter.dominant_power_source_payload.return_value = None + + with pytest.raises(SpanPanelServerError, match="no representation"): + await client.set_dominant_power_source("NONSENSE") + await _settle() + + command, outcome = recorder.after[0] + assert command.topic == DPS_TOPIC + assert command.node_id == "core" + assert outcome.detail == "value has no representation" + + @pytest.mark.asyncio + async def test_no_interceptor_installed_still_raises(self) -> None: + """The refusal is the contract; the audit record is a side effect of it.""" + client = _client() + assert isinstance(client._adapter, MagicMock) + client._adapter.set_circuit_relay_target.return_value = None + + with pytest.raises(SpanPanelServerError): + await client.set_circuit_relay(CIRCUIT, "OPEN") diff --git a/tests/test_reference_tree_values.py b/tests/test_reference_tree_values.py index 94ccfaf..2b09f06 100644 --- a/tests/test_reference_tree_values.py +++ b/tests/test_reference_tree_values.py @@ -17,14 +17,14 @@ one. The drift was found by comparing the two artifacts by hand, which is a thing nobody does twice — hence this. -**Compared at device-type granularity, and it has to be.** This capture is -`sim-*` renamed to `example-*` and cut from 28 circuits to 5, with two of them -renamed in passing (`kitchen Lights` -> `Kitchen Lights`, `Garage Outlets` -> -`Garage Outlet`), so a per-device comparison would fail on the rename rather -than on a value. Type granularity is also the granularity the question is asked -at: five circuits declare the same properties, and the same one going unvalued -on all five is one gap, not five. The same choice the integration's -`test_declared_but_unread` makes, for the same reason. +**Compared at device-type granularity, and it has to be.** The two artifacts +describe different panels: this one is a five-circuit synthetic enclosure with +`example-*` identifiers, panelbench's is a twenty-eight-circuit one, so a +per-device comparison would fail on the names rather than on a value. Type +granularity is also the granularity the question is asked at: five circuits +declare the same properties, and the same one going unvalued on all five is one +gap, not five. The same choice the integration's `test_declared_but_unread` +makes, for the same reason. The reduction loses nothing here, and that is measured rather than assumed: reduced the same way, panelbench's baseline is exactly the declared-but-unvalued @@ -38,6 +38,16 @@ It is vendored verbatim rather than pre-reduced so that refreshing it is a copy whose correctness a reader can check with `diff`, and so the reduction stays here where it is explained. + +**A failure here does not say which side moved, and both have.** Refreshing the +vendored baseline is the fix when panelbench has already re-captured, which was +the case the first time this fired: the copy carried 32 `connection/count` +entries that the pinned panelbench commit had itself already dropped, so the two +artifacts agreed only because both were stale. Regenerating the reference tree +is the fix when the producer this side follows has moved -- see +`scripts/capture_parent_child_reference.py`, which reproduces every identifier +and every device in this capture, so the old instruction to port values in by +hand rather than recapture no longer applies. """ from __future__ import annotations @@ -109,10 +119,11 @@ def test_the_reference_tree_values_everything_the_producer_values() -> None: assert fixture == producer, ( "the reference tree and the producer disagree about what stays unvalued.\n" - f" unvalued here, valued by the producer (port the value in):\n {missing}\n" - f" unvalued by the producer, valued here (the capture invented it):\n {invented}\n\n" - "Port values into the existing artifact rather than recapturing it: the ids are " - "synthetic, the circuit set is trimmed, and the meter values are not reproducible." + f" unvalued here, valued by the producer (this capture is behind):\n {missing}\n" + f" unvalued by the producer, valued here (the baseline may be behind):\n {invented}\n\n" + "Decide which side moved: refresh tests/fixtures/panelbench_unvalued_by_both.json from " + "the panelbench commit spec_lock.json pins, or recapture the reference tree with " + "scripts/capture_parent_child_reference.py." ) diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py index 837f032..3facb13 100644 --- a/tests/test_schema_one_adapter.py +++ b/tests/test_schema_one_adapter.py @@ -21,6 +21,10 @@ PANEL = "example-40t-001" SOLAR_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" +KITCHEN_CIRCUIT = "0ab966b95f92a6a51ec548485aa85f54" +"""A controllable circuit. `SOLAR_CIRCUIT` is the capture's locked one, so it is +the wrong circuit to ask a question about topic *shape* -- it has no relay topic +at all. See `test_schema_one_control_refusal.py`.""" def _schema() -> V2HomieSchema: @@ -382,8 +386,11 @@ def test_field_metadata_is_empty_before_discovery() -> None: def test_command_topics_address_the_child_device(adapter: SchemaOneAdapter) -> None: """Under parent/child a circuit is its own device, so its command topic is rooted at the circuit rather than nested under the panel.""" - assert adapter.set_circuit_relay_target(SOLAR_CIRCUIT).topic == f"ebus/5/{SOLAR_CIRCUIT}/switch/relay/set" - assert adapter.set_circuit_priority_target(SOLAR_CIRCUIT).topic == f"ebus/5/{SOLAR_CIRCUIT}/load-shed/priority/set" + relay = adapter.set_circuit_relay_target(KITCHEN_CIRCUIT) + priority = adapter.set_circuit_priority_target(KITCHEN_CIRCUIT) + + assert relay is not None and relay.topic == f"ebus/5/{KITCHEN_CIRCUIT}/switch/relay/set" + assert priority is not None and priority.topic == f"ebus/5/{KITCHEN_CIRCUIT}/load-shed/priority/set" def test_dominant_power_source_writes_the_panel_assertion(adapter: SchemaOneAdapter) -> None: diff --git a/tests/test_schema_one_circuits.py b/tests/test_schema_one_circuits.py index 259f6c6..78ccfd9 100644 --- a/tests/test_schema_one_circuits.py +++ b/tests/test_schema_one_circuits.py @@ -1,13 +1,21 @@ """Mapping a v1.0 circuit device onto SpanCircuitSnapshot. -Driven from the tree this distribution ships as package data, captured off a -real `panel_sim` parent/child tree rather than hand-written, so the shapes are -the firmware's rather than my idea of them. +Driven from the tree this distribution ships as package data, captured off the +eBus emitter rather than hand-written, so the shapes are a conforming +publisher's rather than my idea of them. See +`scripts/capture_parent_child_reference.py` and the manifest beside it. + +Two kinds of question live here and they take their inputs from different +places. What a real panel *looks like* comes from the capture. What this parser +is *obliged* to handle comes from the vendored catalogs, which is why the +priority-value tests below read `load-shed.json` and mutate a device rather than +expecting the capture to carry every enum member. """ from __future__ import annotations import json +from pathlib import Path import pytest @@ -17,6 +25,7 @@ from span_panel_api_schema_1.circuits import build_circuit _TREE = parent_child_tree() +_CATALOGS = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" / "catalogs" # From the fixture: a 1-pole load, and a 2-pole backfeeding PV breaker. KITCHEN_LIGHTS = "0ab966b95f92a6a51ec548485aa85f54" @@ -69,8 +78,8 @@ def test_energy_accumulators_are_swapped_to_the_circuit_perspective(solar: Disco *from* the circuit, which the circuit produced.""" circuit = build_circuit(solar) - assert solar.get_property("meter", "imported-energy") == "141.66666666666666" - assert circuit.produced_energy_wh == pytest.approx(141.666666, rel=1e-6) + assert solar.get_property("meter", "imported-energy") == "182.16666666666666" + assert circuit.produced_energy_wh == pytest.approx(182.166666, rel=1e-6) assert circuit.consumed_energy_wh == 0.0 @@ -128,7 +137,7 @@ def test_relay_controllable_defaults_to_controllable_when_absent(kitchen: Discov def test_sheddable_is_computed_not_read(kitchen: DiscoveredDevice, solar: DiscoveredDevice) -> None: """Retired with no replacement property: the guide defines it as `priority != NEVER and relay-controllable`.""" - # Kitchen: priority UNKNOWN (not NEVER) and controllable -> sheddable + # Kitchen: priority SOC_THRESHOLD (not NEVER) and controllable -> sheddable assert build_circuit(kitchen).is_sheddable is True # Solar: priority NEVER and not controllable -> not sheddable assert solar.get_property("load-shed", "priority") == "NEVER" @@ -162,6 +171,56 @@ def test_an_unannounced_settable_means_settable(kitchen: DiscoveredDevice) -> No assert build_circuit(kitchen).is_never_backup is False +def _catalogued_priorities() -> list[str]: + """Every value `load-shed` 0.3 declares for `priority`, in catalog order. + + Read from the vendored catalog rather than listed here, so the obligation + this drives is the specification's current one. A value added upstream + arrives in this test the moment the catalog is re-vendored, which is the + only way a contract test stays a contract test. + """ + with (_CATALOGS / "load-shed.json").open(encoding="utf-8") as handle: + catalog = json.load(handle) + declared = str(catalog["properties"]["priority"]["format"]) + return [value.strip() for value in declared.split(",") if value.strip()] + + +def test_the_catalogued_priority_values_are_the_ones_worth_testing() -> None: + """The premise, so a catalog that loses `UNKNOWN` does not quietly end the test below.""" + values = _catalogued_priorities() + + assert "UNKNOWN" in values, "`load-shed` no longer declares UNKNOWN; the contract below has changed" + assert set(values) >= {"UNKNOWN", "NEVER", "OFF_GRID"}, "the baseline every host must publish has moved" + + +@pytest.mark.parametrize("declared", _catalogued_priorities()) +def test_every_catalogued_priority_is_carried_through_rather_than_repaired(kitchen: DiscoveredDevice, declared: str) -> None: + """Each declared enum member reaches the snapshot as itself. + + **Synthetic, and from the catalog rather than from the capture, on purpose.** + These are two different obligations and only one of them is about panels. + `load-shed` 0.3 declares the format and calls `UNKNOWN`, `NEVER` and + `OFF_GRID` the baseline for every host, so a parser that mishandles any of + them is broken against the specification no matter what hardware emits. The + reference capture answers the other question -- what a real panel looks like + -- and no production capture has ever published `UNKNOWN` (27 circuits, + two enclosures), so putting one in the fixture would misrepresent a panel in + order to test a contract. Contract obligations come from the catalog; + representativeness comes from the capture. + + Sheddability is asserted alongside because it is the derivation that would + hide a repaired value: `priority != NEVER and relay-controllable` makes every + member except `NEVER` sheddable, which is the answer the rule gives and not + one this reader should soften. + """ + kitchen.update_property("load-shed", "priority", declared) + + circuit = build_circuit(kitchen) + + assert circuit.priority == declared + assert circuit.is_sheddable is (declared != "NEVER") + + # --------------------------------------------------------------------------- # Robustness # --------------------------------------------------------------------------- diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index 03d4dd3..bc71172 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -21,10 +21,11 @@ a vendored copy, so it needs neither network nor a sibling checkout. - **Coverage** — this adapter against a captured tree from the SPAN simulator, the producer our development is done against. Always runs, from a vendored copy. -- **Provenance** — the vendored copies against their sources, which need - `EBUS_SPEC_DIR` / `PANELBENCH_DIR` to name checkouts. Skipped without them on a - developer machine and **failed** without them under `CI`, where the workflow - clones both: see `_unconfigured`, and DEVELOPMENT.md's "A skip here is not a pass". +- **Provenance** — the vendored copies and the recorded pins against their + sources, which need `EBUS_SPEC_DIR` / `PANELBENCH_DIR` / `PANEL_SIM_DIR` to name + checkouts. Skipped without them on a developer machine and **failed** without + them under `CI`, where the workflow clones all three: see `_unconfigured`, and + DEVELOPMENT.md's "A skip here is not a pass". Provenance proves we copied the right bytes; it cannot prove we understood them. The first two are where the understanding gets checked, which is why they are the @@ -68,20 +69,43 @@ def _lock() -> dict[str, object]: return loaded -def _peer() -> dict[str, object]: - peer = _lock()["peer"] - assert isinstance(peer, dict) +PANELBENCH = "panelbench" +"""The SPAN-side publisher this parser is developed against.""" + +PANEL_SIM = "ebus-panel-sim" +"""The eBus specification's own executable publisher, and the producer of the +reference tree. Same organisation as the specification, conformed against live +panel output — the spec in runnable form rather than a third-party imitation of +it. Pinned here for the reason panelbench is: an unrecorded producer is a +dependency nobody can see, and this one went stale for exactly that reason.""" + + +def _peers() -> dict[str, object]: + peers = _lock()["peers"] + assert isinstance(peers, dict) + return peers + + +def _peer(name: str = PANELBENCH) -> dict[str, object]: + """One peer by name. + + Keyed rather than positional: both readers of this block — this module and + `.github/actions/peer-checkouts` — want a specific peer, never "the first + one", so a list would make every call site restate a lookup. + """ + peer = _peers()[name] + assert isinstance(peer, dict), f"peers.{name} should be an object" return peer -def _peer_str(key: str) -> str: - value = _peer()[key] - assert isinstance(value, str), f"peer.{key} should be a string" +def _peer_str(key: str, name: str = PANELBENCH) -> str: + value = _peer(name)[key] + assert isinstance(value, str), f"peers.{name}.{key} should be a string" return value def _peer_fixtures() -> dict[str, str]: - """The captures vendored from the peer, by kind. + """The captures vendored from panelbench, by kind. Two of them, answering different questions: `tree` is `$description` documents and is what the conformance profile is computed from; `wire` adds @@ -89,8 +113,13 @@ def _peer_fixtures() -> dict[str, str]: parser end to end. A consumer checked against declarations alone has been checked for understanding the shape of a panel, not for building the right snapshot from one. + + Paths *inside panelbench*, because these are byte copies of files that live + there. The other peer's artifact is generated rather than copied, so it is + recorded under `produces` with paths inside this repository — one key would + have meant two things depending on which peer you read it from. """ - fixtures = _peer()["fixtures"] + fixtures = _peer(PANELBENCH)["fixtures"] assert isinstance(fixtures, dict) return {str(kind): str(path) for kind, path in fixtures.items()} @@ -101,8 +130,8 @@ def _unconfigured(reason: str) -> NoReturn: Locally, skipping is right — not every developer keeps sibling checkouts, and a provenance check is not what they are running the suite for. - In CI it is the opposite. The workflow clones both peers and exports both - variables, so an unset or wrong path there does not mean "unavailable", it means + In CI it is the opposite. The workflow clones every peer and exports every + variable, so an unset or wrong path there does not mean "unavailable", it means the wiring that makes these checks run has come undone. Skipping on that reads in the summary line exactly like passing, which is how these checks stayed silent for the nine days it took the vendored capture to go stale. A check that can be @@ -115,7 +144,7 @@ def _unconfigured(reason: str) -> NoReturn: """ if os.environ.get("CI"): pytest.fail( - f"{reason}. CI configures both peer checkouts, so this is the provenance " + f"{reason}. CI configures every peer checkout, so this is the provenance " "wiring being broken rather than a check that is unavailable — and a skip " "here is indistinguishable from a pass." ) @@ -680,6 +709,60 @@ def test_the_peer_record_matches_the_simulator_lockfile() -> None: ) +def test_the_emitters_pin_matches_ours() -> None: + """The producer of the reference tree reads the same specification we do. + + `ebus-panel-sim` publishes an `.ebus-spec.json` of exactly this shape, from + the same organisation that writes the specification — so this is not two + third parties happening to agree, it is the executable form of the spec + stating which commit it implements. When the two pins match, a divergence + between our parser and that capture is a disagreement about the same + document rather than about two different ones, which is the only condition + under which the capture is evidence at all. + + That is the whole reason the pin belongs in a lockfile instead of only in a + README: the reference tree went stale precisely because nothing could ask + this question mechanically. + """ + sim_dir = _checkout("PANEL_SIM_DIR", "an ebus-panel-sim checkout to verify the emitter pin") + + with (sim_dir / ".ebus-spec.json").open() as handle: + theirs = json.load(handle) + assert theirs["role"] == _peer_str( + "role", PANEL_SIM + ), "the emitter is not publishing; this pairing is not what it claims" + assert theirs["synced_commit"] == _peer_str("synced_commit", PANEL_SIM), ( + f"the emitter now pins {theirs['synced_commit']}, we recorded {_peer_str('synced_commit', PANEL_SIM)}. " + "Re-capture and update both, or the capture was produced against a vocabulary this parser is not reading." + ) + + +def test_the_captured_tree_names_the_emitter_release_that_made_it() -> None: + """The capture script, the lockfile and the checkout agree on one version. + + Three places could disagree, and the failure mode of each is the same: bytes + in the wheel attributed to a producer that did not make them. The script + reads its expected version *out of this lockfile* rather than carrying a + constant of its own, so there is one pin and this test proves the checkout + is on it. + + Skipped without a checkout like every other provenance check, and failed + under CI for the same reason — a skip reads in a summary line exactly like a + pass. + """ + sim_dir = _checkout("PANEL_SIM_DIR", "an ebus-panel-sim checkout to verify the capture's producer") + + recorded = _peer_str("version", PANEL_SIM) + source = (sim_dir / "src" / "ebus_panel_sim" / "__init__.py").read_text(encoding="utf-8") + installed = re.search(r'^__version__ = "([^"]+)"', source, re.MULTILINE) + + assert installed is not None, f"{sim_dir} carries no __version__; is it an ebus-panel-sim checkout?" + assert installed.group(1) == recorded, ( + f"{sim_dir} is ebus-panel-sim {installed.group(1)}, and spec_lock.json records the reference " + f"tree as a capture of {recorded}. Move the checkout, or re-capture and re-pin together." + ) + + def test_an_unconfigured_peer_checkout_fails_in_ci_and_skips_locally(monkeypatch: pytest.MonkeyPatch) -> None: """The guard on the guard. diff --git a/tests/test_schema_one_control_refusal.py b/tests/test_schema_one_control_refusal.py new file mode 100644 index 0000000..f16ac2b --- /dev/null +++ b/tests/test_schema_one_control_refusal.py @@ -0,0 +1,295 @@ +"""A control the panel declares non-commandable produces no target to publish to. + +**The rule is the specification's.** `switch` 0.3, vendored at +`packages/schema-1/spec/catalogs/switch.json`, declares `relay` "Settable when +`relay-controllable = true`" and defines `relay-controllable` false as locked. So +this is not a behaviour inferred from what one producer happens to emit; it is +the catalogued contract, and the first test below reads the catalog and says so, +which is what stops the rule outliving the sentence it came from. + +The refusal was in the tree long before it was in the code: the adapter derived +`is_user_controllable` and `is_never_backup` correctly for the snapshot, while +`set_circuit_relay_target` formatted a topic from a device id and consulted +nothing. A consumer gating entity creation on the snapshot hid that, but only +for entities -- these are public methods, and settability changes at runtime when +a circuit is re-commissioned in place. + +Driven from the shipped capture wherever the capture has the case, and from a +mutated copy of it where it does not. The distinction matters: the locked relay +below is what `ebus-panel-sim` 0.7.0 -- the specification's own executable +publisher -- emits for a circuit commissioned `non-controllable`, so those tests +are evidence that the catalogued rule and a conforming producer agree. The +never-backup circuit is a mutation, because no circuit in the capture is +commissioned that way, and it says so. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from span_panel_api.models import V2HomieSchema +from span_panel_api_schema_1 import SchemaOneAdapter +from span_panel_api_schema_1.reference_payloads import RetainedTopicTree, parent_child_tree + +_CATALOGS = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" / "catalogs" + +PANEL = "example-40t-001" +LOCKED_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" +"""Solar Inverter, commissioned `non-controllable`: `relay-controllable` is +`false` and `switch/relay` carries no `$settable`.""" + +CONTROLLABLE_CIRCUIT = "0ab966b95f92a6a51ec548485aa85f54" +"""Kitchen Lights, its ordinary sibling.""" + + +def _schema() -> V2HomieSchema: + return V2HomieSchema( + firmware_version="spanos2/r202633/01", + types_schema_hash="sha256:test", + types={}, + data_model_version="1.0", + ) + + +def _adapter(tree: RetainedTopicTree) -> SchemaOneAdapter: + """Replay a whole capture into an adapter, panel first. + + Panel first because the SDK gates a child's subscription on its parent + reaching `ready`. + """ + adapter = SchemaOneAdapter(PANEL, _schema()) + for device_id in [PANEL, *[d for d in tree if d != PANEL]]: + topics = tree[device_id] + adapter.handle_message(f"ebus/5/{device_id}/$description", topics["$description"]) + adapter.handle_message(f"ebus/5/{device_id}/$state", topics.get("$state", "ready")) + for topic, payload in topics.items(): + if not topic.startswith("$"): + adapter.handle_message(f"ebus/5/{device_id}/{topic}", payload) + return adapter + + +def _copy() -> dict[str, dict[str, str]]: + """A writable copy of the capture. + + Copied rather than mutated in place because `parent_child_tree()` reads the + shipped package data once per process and every other module in the suite is + reading the same object. + """ + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _redeclared(device_id: str, node: str, prop: str, *, settable: bool | None) -> dict[str, dict[str, str]]: + """The capture with one property's `$settable` set, or removed when None. + + Expressed as an edit to the declaration rather than as a hand-written + description, so the mutation is one attribute away from what the producer + published and everything else about the device stays the producer's. + """ + tree = _copy() + description = json.loads(tree[device_id]["$description"]) + definition: dict[str, object] = description["nodes"][node]["properties"][prop] + if settable is None: + definition.pop("settable", None) + else: + definition["settable"] = settable + tree[device_id]["$description"] = json.dumps(description) + return tree + + +@pytest.fixture(name="adapter") +def _shipped_adapter() -> SchemaOneAdapter: + return _adapter(parent_child_tree()) + + +# --------------------------------------------------------------------------- +# Where the rule comes from +# --------------------------------------------------------------------------- + + +def _catalogued(capability: str, property_id: str) -> dict[str, object]: + with (_CATALOGS / f"{capability}.json").open(encoding="utf-8") as handle: + catalog = json.load(handle) + definition: dict[str, object] = catalog["properties"][property_id] + return definition + + +def test_the_catalog_still_states_the_condition_this_refusal_encodes() -> None: + """The premise of the whole module, read from the specification we vendor. + + `relay_is_settable` implements a rule it cannot derive: `switch` 0.3 puts + `settable: true` on `relay` unconditionally in the JSON and states the + narrowing condition in the prose beside it, because the machine-readable + field describes the property across the whole capability while the condition + applies per device. There is nothing to compute, so the rule is written into + the code -- and a rule written into code outlives the sentence it came from + unless something checks. + + This is that check, and it is deliberately about the *catalog* rather than + about any producer. If the specification rewords this clause, update the + substring here and move on. If it **removes** the condition, or redefines + what `relay-controllable` false means, that is a decision to re-take: the + refusal in `relay_is_settable` would no longer have a source, and no test + that reads a capture could tell you so. + """ + relay = _catalogued("switch", "relay") + controllable = _catalogued("switch", "relay-controllable") + + assert relay["settable"] is True, "the catalog no longer declares `relay` settable at all" + assert "Settable when `relay-controllable = true`" in str(relay["description"]), ( + "`switch` no longer conditions `relay`'s settability on `relay-controllable`. " + "That condition is the entire basis for refusing a relay command in " + "`circuits.relay_is_settable`; re-read the catalog before adjusting either." + ) + assert "locked" in str(controllable["description"]), ( + "`relay-controllable` no longer describes false as locked, which is the half of " + "the rule that says a refusal is correct rather than merely cautious." + ) + + +def test_the_catalog_puts_no_such_condition_on_the_shed_priority() -> None: + """Why the two properties read an absent `$settable` in opposite directions. + + `load-shed` 0.3 declares `priority` settable and states no condition, so + mutability is its ordinary state and a lock is an announcement. `switch` + conditions `relay`, so a publisher describing a locked relay correctly omits + the attribute. Neither default is a house style; each follows from its own + catalog entry, and this is what records that they were read separately. + """ + priority = _catalogued("load-shed", "priority") + + assert priority["settable"] is True + assert "settable" not in str(priority["description"]).lower(), ( + "`load-shed` has grown a condition on `priority`'s settability. " + "`circuits.priority_is_settable` treats an absent `$settable` as permission on the " + "strength of there being none; that is now a decision to re-take." + ) + + +# --------------------------------------------------------------------------- +# The relay, which is what the capture already carries +# --------------------------------------------------------------------------- + + +def test_the_capture_carries_a_locked_relay_and_a_controllable_one(adapter: SchemaOneAdapter) -> None: + """The premise of everything below, asserted rather than assumed. + + Both halves: a fixture regenerated with every circuit controllable would + make the refusal tests pass by having nothing to refuse, and a fixture with + every circuit locked would make the permission tests vacuous. Either failure + reads as a producer change here rather than as a mystery three tests down. + """ + tree = parent_child_tree() + + locked = json.loads(tree[LOCKED_CIRCUIT]["$description"])["nodes"]["switch"]["properties"]["relay"] + controllable = json.loads(tree[CONTROLLABLE_CIRCUIT]["$description"])["nodes"]["switch"]["properties"]["relay"] + + assert "settable" not in locked + assert tree[LOCKED_CIRCUIT]["switch/relay-controllable"] == "false" + assert controllable["settable"] is True + assert tree[CONTROLLABLE_CIRCUIT]["switch/relay-controllable"] == "true" + + +def test_a_locked_relay_yields_no_target(adapter: SchemaOneAdapter) -> None: + assert adapter.set_circuit_relay_target(LOCKED_CIRCUIT) is None + + +def test_a_controllable_sibling_still_yields_one(adapter: SchemaOneAdapter) -> None: + """The refusal has to be about the circuit, not about the control.""" + target = adapter.set_circuit_relay_target(CONTROLLABLE_CIRCUIT) + + assert target is not None + assert target.topic == f"ebus/5/{CONTROLLABLE_CIRCUIT}/switch/relay/set" + assert (target.device_id, target.node_id, target.property_id) == (CONTROLLABLE_CIRCUIT, "switch", "relay") + + +def test_a_relay_target_exists_exactly_where_relay_controllable_is_true(adapter: SchemaOneAdapter) -> None: + """The invariant the hardware holds, asserted over every circuit at once. + + Across the two production enclosures we hold captures from -- 27 circuits -- + `$settable` on `switch/relay` is present exactly when `relay-controllable` is + `true`, without exception. That makes the published value a sufficient + predictor of whether a target should exist, and asserting it over the whole + capture catches a refusal that is right on one circuit for the wrong reason. + """ + tree = parent_child_tree() + circuits = { + device_id: topics + for device_id, topics in tree.items() + if json.loads(topics["$description"])["type"].endswith(".circuit") + } + assert len(circuits) == 5 + + for device_id, topics in circuits.items(): + controllable = topics["switch/relay-controllable"] == "true" + assert (adapter.set_circuit_relay_target(device_id) is not None) is controllable, device_id + + +def test_either_signal_alone_is_enough_to_refuse() -> None: + """Refuses when either says no, which is the point of reading both. + + SPAN reports a firmware defect in which the `$settable` re-toggle on the + runtime re-commissioning path is skipped until the service restarts, so the + declaration and the value can disagree on a real panel. Each disagreement is + built here from the controllable circuit, one signal at a time, so neither + test can pass on the other signal's account. + """ + + declaration_stale = _adapter(_redeclared(CONTROLLABLE_CIRCUIT, "switch", "relay", settable=None)) + assert declaration_stale.set_circuit_relay_target(CONTROLLABLE_CIRCUIT) is None + + tree = _copy() + tree[CONTROLLABLE_CIRCUIT]["switch/relay-controllable"] = "false" + value_stale = _adapter(tree) + assert value_stale.set_circuit_relay_target(CONTROLLABLE_CIRCUIT) is None + + +def test_a_circuit_the_tree_does_not_carry_yields_no_target(adapter: SchemaOneAdapter) -> None: + """`_target` is string formatting, so an unknown id used to produce a topic.""" + assert adapter.set_circuit_relay_target("0" * 32) is None + assert adapter.set_circuit_priority_target("0" * 32) is None + + +# --------------------------------------------------------------------------- +# The priority, which the capture has no case for +# --------------------------------------------------------------------------- + + +def test_priority_stays_settable_on_a_locked_relay(adapter: SchemaOneAdapter) -> None: + """The one combination that would be easy to conflate, and real panels have it. + + `switch` 0.3 and `load-shed` 0.3 scope the two separately, and a circuit + commissioned always-on is not thereby commissioned never-backup. Refusing + the priority alongside the relay would take a control away from every locked + circuit on every panel. + """ + assert adapter.set_circuit_relay_target(LOCKED_CIRCUIT) is None + + target = adapter.set_circuit_priority_target(LOCKED_CIRCUIT) + assert target is not None + assert target.topic == f"ebus/5/{LOCKED_CIRCUIT}/load-shed/priority/set" + + +def test_a_never_backup_circuit_yields_no_priority_target() -> None: + """Mutated, because no circuit in the capture is commissioned never-backup.""" + + adapter = _adapter(_redeclared(CONTROLLABLE_CIRCUIT, "load-shed", "priority", settable=False)) + + assert adapter.set_circuit_priority_target(CONTROLLABLE_CIRCUIT) is None + # And only the priority: the relay is a separate commissioning flag. + assert adapter.set_circuit_relay_target(CONTROLLABLE_CIRCUIT) is not None + + +def test_an_unannounced_priority_settable_is_still_writable() -> None: + """Absence means settable here and locked on the relay, and the asymmetry is deliberate. + + Never-backup is the exception a panel announces on top of an otherwise + mutable property, so defaulting silence to locked would refuse the priority + on every circuit of a firmware that publishes no attribute at all. + """ + + adapter = _adapter(_redeclared(CONTROLLABLE_CIRCUIT, "load-shed", "priority", settable=None)) + + assert adapter.set_circuit_priority_target(CONTROLLABLE_CIRCUIT) is not None diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py index 63b77bc..574e185 100644 --- a/tests/test_schema_one_devices.py +++ b/tests/test_schema_one_devices.py @@ -192,13 +192,14 @@ def test_the_capture_is_a_charging_battery() -> None: A sign convention can only be tested against a known physical state, and "negative means charging" is the claim under test, so reading the state off the sign would be circular. The enclosure's four power flows balance instead - -- ``pv + battery + grid == site``, with ``grid`` positive when importing -- - and solving that identity says which way the battery is going without - appealing to any convention this library chose. + -- ``pv + battery + grid + site == 0``, the node balance `power-flows` 0.3 + describes, in which every term is positive when power flows *into* the thing + it names -- and solving that identity says which way the battery is going + without appealing to any convention this library chose. In this capture 8500 W of PV meets 2653 W of site load and exports 2347 W; the 3500 W left over is going into the battery. So the battery is charging, - and both the enclosure and the BESS publish that as a negative number. + and both the enclosure and the BESS publish that as a positive number. Were the capture ever retaken with the battery discharging, this fails first and says so, rather than the negation tests failing and reading as a mapper @@ -206,13 +207,13 @@ def test_the_capture_is_a_charging_battery() -> None: """ flows = {name: float(_published("example-40t-001", f"power-flows/{name}")) for name in ("pv", "battery", "grid", "site")} - assert flows["pv"] + flows["battery"] + flows["grid"] == pytest.approx(flows["site"]) + assert flows["pv"] + flows["battery"] + flows["grid"] + flows["site"] == pytest.approx(0.0, abs=1e-9) # PV alone exceeds the site load, so the surplus has nowhere to go but the # battery and the grid -- and the grid term is an export. - assert flows["pv"] > flows["site"] - assert flows["grid"] < 0 - assert flows["battery"] < 0 - assert float(_published("bess", BESS_POWER_TOPIC)) < 0 + assert -flows["pv"] > flows["site"] + assert flows["grid"] > 0 + assert flows["battery"] > 0 + assert float(_published("bess", BESS_POWER_TOPIC)) > 0 def test_battery_power_is_the_negation_of_the_wire() -> None: @@ -234,13 +235,22 @@ def test_battery_power_is_the_negation_of_the_wire() -> None: -- a producer in self-consumption with the grid at zero, PV and battery together meeting the load, leaves no room to argue which way the battery is going. + + The *wire* input flipped under the producer at `ebus-panel-sim` 0.6.0, which + is why the asserted sign moved without the mapper changing: what an enclosure + proxies for a battery it hosts is the enclosure's reading of that battery, + positive while charging, and `power-flows/battery` in the same capture says + the same thing about the same instant. The reference tree carried the earlier + frame until it was recaptured, so this assertion used to read `> 0` on a + capture the test above calls a charging battery -- the two contradicted each + other, and only the fixture was wrong. """ raw = float(_published("bess", BESS_POWER_TOPIC)) battery = build_battery(_device("bess"), []) assert battery.power_w == -raw - assert battery.power_w is not None and battery.power_w > 0 + assert battery.power_w is not None and battery.power_w < 0 def test_battery_power_follows_a_republished_value() -> None: @@ -252,7 +262,7 @@ def test_battery_power_follows_a_republished_value() -> None: # Charging became discharging, so the snapshot's sign flips with it. assert battery.power_w == -discharging - assert battery.power_w is not None and battery.power_w < 0 + assert battery.power_w is not None and battery.power_w > 0 def test_a_battery_at_rest_reports_zero_and_not_negative_zero() -> None: diff --git a/tests/test_schema_one_discovery.py b/tests/test_schema_one_discovery.py index ee40cef..4490527 100644 --- a/tests/test_schema_one_discovery.py +++ b/tests/test_schema_one_discovery.py @@ -357,7 +357,10 @@ def test_retained_says_whether_a_value_has_arrived_and_never_what_it_is() -> Non """`retained` is the declared-but-never-valued signal, and the only value question asked.""" rows = _discovered() assert rows["discovered.distribution-enclosure/status/time-zone"].retained is True - assert rows["discovered.circuit/connection/count"].retained is False + # The PV's serial, which `test_the_held_pv_serial_is_still_the_only_singleton_left` + # pins as deliberately declared and never published. `connection/count` used to + # stand here, until the producer removed a property no configuration could value. + assert rows["discovered.pv/info/serial-number"].retained is False tree = _tree() del tree[PANEL_DEVICE_ID]["status/time-zone"] @@ -533,7 +536,7 @@ def test_a_subtyped_device_does_not_report_its_parents_mapped_properties() -> No rows = build_discovery(_devices(tree)) assert not [path for path in rows if path.startswith("discovered.lugs.upstream/meter/")] - assert "discovered.lugs.upstream/connection/count" in rows + assert "discovered.lugs.upstream/connection/feeds-device-type" in rows def test_the_charge_current_pair_is_addressed_by_resolution_not_by_a_table() -> None: diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py index 3d8550f..5ef643b 100644 --- a/tests/test_schema_one_panel.py +++ b/tests/test_schema_one_panel.py @@ -108,9 +108,13 @@ def test_voltages_come_from_the_panel_meter(fields: PanelFields) -> None: def test_power_flows(fields: PanelFields) -> None: - assert fields.power_flow_pv == 8500.0 - assert fields.power_flow_battery == -3500.0 - assert fields.power_flow_grid == -2347.0 + """Relayed in the panel's own frame, which `power-flows` 0.3 defines as the + node balance: every term positive when power flows into the thing it names, + so `pv` is negative while producing and the four sum to zero. The adapter + negates none of them, and this is what says so.""" + assert fields.power_flow_pv == -8500.0 + assert fields.power_flow_battery == 3500.0 + assert fields.power_flow_grid == 2347.0 assert fields.power_flow_site == 2653.0 @@ -133,10 +137,10 @@ def test_main_meter_energy_maps_imported_to_consumed(fields: PanelFields) -> Non """Opposite of a circuit: the panel imports from the grid, so imported energy is what the house consumed.""" upstream = _device("lugs-upstream") - assert upstream.get_property("meter", "imported-energy") == "44.21666666666666" + assert upstream.get_property("meter", "imported-energy") == "44.05" - assert fields.main_meter_energy_consumed_wh == pytest.approx(44.2166, rel=1e-4) - assert fields.main_meter_energy_produced_wh == pytest.approx(141.6666, rel=1e-4) + assert fields.main_meter_energy_consumed_wh == pytest.approx(44.05, rel=1e-4) + assert fields.main_meter_energy_produced_wh == pytest.approx(97.45, rel=1e-4) def test_per_phase_currents(fields: PanelFields) -> None: @@ -149,7 +153,7 @@ def test_per_phase_currents(fields: PanelFields) -> None: def test_feedthrough_comes_from_the_downstream_lugs(fields: PanelFields) -> None: assert fields.feedthrough_power_w == -5847.0 - assert fields.feedthrough_energy_consumed_wh == pytest.approx(44.2166, rel=1e-4) + assert fields.feedthrough_energy_consumed_wh == pytest.approx(44.05, rel=1e-4) def test_lugs_are_found_by_declared_direction_not_device_id() -> None: diff --git a/tests/test_schema_one_snapshot.py b/tests/test_schema_one_snapshot.py index e4c0a44..f346c11 100644 --- a/tests/test_schema_one_snapshot.py +++ b/tests/test_schema_one_snapshot.py @@ -90,7 +90,7 @@ def test_der_snapshots_are_populated(snapshot: SpanPanelSnapshot) -> None: def test_panel_and_lugs_values_reach_the_snapshot(snapshot: SpanPanelSnapshot) -> None: assert snapshot.instant_grid_power_w == -5847.0 - assert snapshot.power_flow_pv == 8500.0 + assert snapshot.power_flow_pv == -8500.0 assert snapshot.grid_state == "ON_GRID" assert snapshot.l1_voltage == 120.0 diff --git a/tests/test_schema_zero_adapter.py b/tests/test_schema_zero_adapter.py index 2ccd035..ed8bf9f 100644 --- a/tests/test_schema_zero_adapter.py +++ b/tests/test_schema_zero_adapter.py @@ -37,10 +37,54 @@ def test_subscribes_to_the_single_panel_wildcard(adapter: SchemaZeroAdapter) -> assert adapter.topics_to_subscribe() == [f"ebus/5/{SERIAL}/#"] +CIRCUIT = "ac3dccda46a94b98878a227df6fed588" + + def test_circuit_setter_topics_address_the_panel_device(adapter: SchemaZeroAdapter) -> None: - circuit = "ac3dccda46a94b98878a227df6fed588" - assert adapter.set_circuit_relay_target(circuit).topic == f"ebus/5/{SERIAL}/{circuit}/relay/set" - assert adapter.set_circuit_priority_target(circuit).topic == f"ebus/5/{SERIAL}/{circuit}/shed-priority/set" + relay = adapter.set_circuit_relay_target(CIRCUIT) + priority = adapter.set_circuit_priority_target(CIRCUIT) + + assert relay is not None and relay.topic == f"ebus/5/{SERIAL}/{CIRCUIT}/relay/set" + assert priority is not None and priority.topic == f"ebus/5/{SERIAL}/{CIRCUIT}/shed-priority/set" + + +def _publish(adapter: SchemaZeroAdapter, node: str, prop: str, value: str) -> None: + adapter.handle_message(f"ebus/5/{SERIAL}/{node}/{prop}", value) + + +def test_an_always_on_circuit_yields_no_relay_target(adapter: SchemaZeroAdapter) -> None: + """The same refusal the v1.0 side makes, from the value flat publishes for it. + + `always-on` is already read into `is_user_controllable`, so the panel had + told this adapter the relay was locked and only the snapshot listened; the + topic builder was pure string formatting from a node id. + """ + _publish(adapter, CIRCUIT, "always-on", "true") + + assert adapter.set_circuit_relay_target(CIRCUIT) is None + # Only the relay. Always-on is not never-backup, on either schema. + assert adapter.set_circuit_priority_target(CIRCUIT) is not None + + +def test_a_never_backup_circuit_yields_no_priority_target(adapter: SchemaZeroAdapter) -> None: + _publish(adapter, CIRCUIT, "never-backup", "true") + + assert adapter.set_circuit_priority_target(CIRCUIT) is None + assert adapter.set_circuit_relay_target(CIRCUIT) is not None + + +def test_a_published_false_reads_as_permission(adapter: SchemaZeroAdapter) -> None: + """A panel that publishes the flags as `false` must not be refused. + + Absence already reads as permission; this is the other half, and it is the + one a producer actually exercises -- a clone of a real panel writes + `always-on: "false"` out rather than omitting it. + """ + _publish(adapter, CIRCUIT, "always-on", "false") + _publish(adapter, CIRCUIT, "never-backup", "false") + + assert adapter.set_circuit_relay_target(CIRCUIT) is not None + assert adapter.set_circuit_priority_target(CIRCUIT) is not None def test_dominant_power_source_topic_is_none_before_the_core_node_is_known( From 114dd7726e6b28eeaa852f23547696302bf4b462 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:00:31 -0700 Subject: [PATCH 2/3] fix(spec-lock): the schema-1 pin names the firmware family it actually targets `firmware.family` read `spanos2` while `firmware.range` read `r202633+`. The range is right and the family is stale: production enclosures on r202633 report a `spanos3` build, which is also what the live panel this range was pinned from returns from `GET /api/v2/status`. Nothing read the field -- `test_the_peer_targets_the_same_firmware` asserts on `range` alone -- so this corrected no behaviour. It is provenance, and this file just became the thing peer-drift reads to decide whether a producer has moved past its pin, which makes a wrong value here worse than it was. The `spanos2` strings elsewhere are left alone deliberately: schema-0's changelog records the flat capture at `spanos2/r202603/05`, which is the family that firmware genuinely was, and the test fixtures carry synthetic version strings that assert nothing about the family. Noted in SpanPanel/span-panel-api#161. --- packages/schema-1/src/span_panel_api_schema_1/spec_lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json index ca05b43..95416e6 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -2,7 +2,7 @@ "$schema": "https://ebus.energy/schemas/ebus-spec.json", "role": "consumer", "firmware": { - "family": "spanos2", + "family": "spanos3", "range": "r202633+", "data_model_version": ">=1.0,<2.0" }, From 0df02aa5f03a57ccac0a57969f7df797c735d9df Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:23:47 -0700 Subject: [PATCH 3/3] fix(peer-drift): the emitter job goes red when it has something to say Two corrections from review of this branch. The remediation text in the panelbench job still named `peer.ref` and `peer.commit`, keys that stopped existing when `spec_lock.json` moved to a `peers` map. Advice that names a key the file does not have is worse than no advice: it sends the reader looking for something they will not find. More substantially, the emitter job could not notify anyone. Its release comparison printed an accurate, actionable summary -- here is the newer version, here is the command to regenerate -- and then exited 0, so the daily run went green in every outcome. GitHub notifies on a failed scheduled run and says nothing about a successful one, so the one path that had something to report was the one nobody would hear. This job exists because the last drift of its kind "took nine days to be noticed by hand", and a buried step summary would have repeated that exactly. It now exits non-zero when PyPI's latest is not the pinned release, and stays red until the capture is regenerated or the pin moves -- which is the honest state of a reference tree describing a producer that has been superseded. The job is schedule- and dispatch-only and gates no pull request, so red costs a notification and nothing else. PyPI being unreachable still reports unknown rather than drift: not having asked is not the same fact as there being nothing new. The commit-distance step gains `if: always()`, because it is context for that verdict rather than part of it. Without it, going red would suppress the commit subjects that make the failure worth reading, and the job header's reasoning -- "a red check with no names is a chore" -- would defeat itself. --- .github/workflows/peer-drift.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/peer-drift.yml b/.github/workflows/peer-drift.yml index af5850f..26d14ea 100644 --- a/.github/workflows/peer-drift.yml +++ b/.github/workflows/peer-drift.yml @@ -62,7 +62,7 @@ jobs: echo "\`$PIN\` is not an ancestor of \`$BRANCH\` (\`${head:0:12}\`)." echo echo "The pin names a commit this branch does not contain — a branch that was" - echo "rebased, squash-merged or deleted. \`peer.ref\` in \`spec_lock.json\` needs" + echo "rebased, squash-merged or deleted. \`peers.panelbench.ref\` in \`spec_lock.json\` needs" echo "to name a ref the pinned commit is actually on." } >> "$GITHUB_STEP_SUMMARY" exit 0 @@ -122,7 +122,7 @@ jobs: echo " packages/schema-1/spec/fixtures/simulator_wire.json" echo '```' echo - echo "then set \`peer.commit\` in \`packages/schema-1/src/span_panel_api_schema_1/spec_lock.json\`" + echo "then set \`peers.panelbench.commit\` in \`packages/schema-1/src/span_panel_api_schema_1/spec_lock.json\`" echo "to the commit you copied from. A capture without a commit bump records where the" echo "bytes came from as a guess." echo @@ -140,6 +140,9 @@ jobs: # records -- so regenerating the reference tree is gated on a *release*, not on a # commit. Commits on main are reported too, because they are what a release will # be made of and seeing them early is free, but they are not a call to action. + # + # So the release comparison is the verdict and fails the job; the commit distance + # runs either way and only ever reports. steps: - name: Checkout code uses: actions/checkout@v7 @@ -197,12 +200,21 @@ jobs: print("bytes and the claim about them from drifting apart. Read the emitter's") print("CHANGELOG for the wire changes before accepting the new capture: a diff") print("confined to each `$description`'s `version` means nothing moved.") + + # Red, deliberately. A scheduled run that succeeds notifies nobody, and this + # job exists because the last drift of this kind "took nine days to be noticed + # by hand" -- a step summary nobody is told to read would repeat that. It gates + # no pull request, so failing costs a notification and nothing else. It stays + # red until the capture is regenerated or the pin is moved, which is the honest + # state of a reference tree that describes a superseded producer. + raise SystemExit(1) PY # Reported after the release comparison because it is context for it, not the # verdict. "N commits behind" with the subjects is what makes the result readable; # a red check with no names is a chore. - name: Report the distance from the pinned commit + if: always() env: PIN: ${{ steps.peers.outputs.panel-sim-pin }} REPO: ${{ steps.peers.outputs.panel-sim-repo }}