diff --git a/.env.example b/.env.example index 1c8e0b3..c5caed4 100644 --- a/.env.example +++ b/.env.example @@ -6,65 +6,9 @@ # needed. A value already exported in your shell wins over anything here — the # file supplies defaults, it does not override an intentional choice. # -# Everything below is optional *here*. Without it the suite runs in full and the -# checks that need a sibling checkout skip themselves rather than fail. They are the -# *provenance* half of the schema_1 conformance suite: they verify that the vendored -# copies still match their sources. The conformance and coverage checks, which are -# the ones that catch real defects, run regardless. -# -# CI is not optional: it clones both peers at the commits spec_lock.json pins, and -# those checks fail rather than skip when `CI` is set. A skip reads in a summary line -# exactly like a pass, and that is how a stale vendored capture went unnoticed for -# nine days. See DEVELOPMENT.md, "A skip here is not a pass". -# -# These answer one question of two. A sibling checkout answers "do our bytes still -# match the pin?" -- a byte comparison against a producer sitting where spec_lock.json -# says it sits. It cannot answer "has the producer moved past the pin?", because what -# is on your disk knows nothing about what has been pushed or released since. -# `scripts/peer_drift.py` asks that one, over the network and needing no checkout at -# all, and the pre-commit hook runs it on every local commit -- so a producer release -# is noticed on the commit that should have moved the pin, rather than by a scheduled -# job the next morning. CI skips that hook deliberately: a producer's push must not -# fail somebody's pull request. Nothing below configures the check either way -- it -# reads every pin, path and repository out of spec_lock.json. - -# A checkout of the eBus specification. -# -# git clone https://github.com/electrification-bus/specification -# -# Enables the byte comparison of `packages/schema-1/spec/catalogs/*.json` against -# the specification's `capabilities/`. Position the checkout at the commit -# `spec_lock.json` pins (`synced_commit`) before believing a failure — a checkout -# on a newer HEAD reports differences that are drift, not corruption. -#EBUS_SPEC_DIR=/path/to/specification - -# A checkout of SpanPanel/panelbench, the publisher this parser is developed -# against. -# -# git clone git@github.com:SpanPanel/panelbench.git -# -# Enables verifying the two vendored captures and the recorded peer pins against -# the producer itself. The tree capture is compared byte for byte; the wire -# capture is compared on shape, because its values are perturbed by the -# 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 +# Nothing in the test suite needs this file except the live-panel differential +# below. `uv sync --all-packages` is the whole setup: the schema_1 provenance +# checks compare against the installed `ebus-panel-sim` wheel, and nothing skips. # --------------------------------------------------------------------------- # 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 deleted file mode 100644 index 6610973..0000000 --- a/.github/actions/peer-checkouts/action.yml +++ /dev/null @@ -1,125 +0,0 @@ -name: Peer checkouts -description: > - 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 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: - peer-ref: - description: > - Which producer refs to clone, and it decides which question the job asks. - - "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 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 - -# Only what a workflow step cannot read for itself. Everything else a job needs out of -# the lockfile — the emitter's distribution and released version, each peer's repository -# — is read straight from `spec_lock.json` by `scripts/peer_drift.py`, which asks the -# producers whether they have moved. Exposing those here as well would put a second -# reader between a job and the pin for no gain. What is left is the pair a `git` command -# in a checkout needs: which commit, and which ref was cloned. -outputs: - panelbench-pin: - description: The commit spec_lock.json pins for panelbench, whichever ref was cloned. - value: ${{ steps.pins.outputs.panelbench-commit }} - 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-checkout: - description: The ref actually cloned — the pinned commit, or the producer's branch. - value: ${{ steps.pins.outputs.panel-sim-checkout }} - -runs: - using: composite - steps: - - name: Read the peer pins out of spec_lock.json - id: pins - shell: bash - env: - PEER_REF_MODE: ${{ inputs.peer-ref }} - run: | - python3 - <<'PY' >> "$GITHUB_OUTPUT" - import json - import os - - with open("packages/schema-1/src/span_panel_api_schema_1/spec_lock.json") as handle: - lock = json.load(handle) - 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["PEER_REF_MODE"] - if mode not in ("pin", "default"): - 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']}") - - # 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']}") - PY - - # 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 pins. - - name: Check out the eBus specification at synced_commit - uses: actions/checkout@v7 - with: - repository: ${{ steps.pins.outputs.spec-repo }} - ref: ${{ steps.pins.outputs.spec-commit }} - path: peers/specification - - - name: Check out panelbench - uses: actions/checkout@v7 - with: - repository: ${{ steps.pins.outputs.panelbench-repo }} - 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.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/dependabot.yml b/.github/dependabot.yml index 6bafcdb..f55d5be 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,9 +5,17 @@ updates: directory: "/" schedule: interval: "weekly" - open-pull-requests-limit: 1 + # Raised from 1 for `ebus-panel-sim`. It is the producer of the schema_1 + # reference capture, so its bump is a wire question and not a tooling one: the + # PR has to re-run scripts/capture_parent_child_reference.py, and the suite is + # what answers whether the wire moved. A limit of 1 let a pytest bump take the + # only slot and hold that question back for a week. + open-pull-requests-limit: 5 allow: - dependency-type: "all" + # `ebus-panel-sim` is deliberately in none of these groups, and must stay that + # way. A grouped bump arrives as one commit with one review, and a wire change + # buried in a pytest bump is a wire change nobody read. groups: # Group development dependencies together dev-dependencies: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0e87ac..22815c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,22 +34,6 @@ jobs: with: python-version: ${{ matrix.python-version }} - # The schema_1 provenance checks compare vendored bytes against the two - # repositories they were copied from, and skip when neither is reachable. They - # skipped in every run this workflow has ever done, which reads in the summary - # line exactly like passing -- see DEVELOPMENT.md, "A skip here is not a pass". - # Cloning both at the commits spec_lock.json pins turns them into a question with - # a deterministic answer: do our vendored bytes match the commit we say they came - # from? Whether the *producer* has moved past that pin is a different question - # with a moving answer, and it lives in peer-drift.yml so it cannot fail a pull - # request for something the author did not do. - # - # CI is set by the runner, and tests/test_schema_one_conformance.py fails rather - # than skips when it is -- so removing this step breaks the build instead of - # quietly switching the checks back off. - - name: Check out the peers the provenance checks verify against - uses: ./.github/actions/peer-checkouts - - name: Install uv uses: astral-sh/setup-uv@v7 with: @@ -58,12 +42,10 @@ jobs: - name: Install dependencies run: uv sync --all-packages - # SKIP because `peer-drift` asks whether a *producer* has moved, and that answer - # changes because somebody else pushed — peer-drift.yml is where it is asked, on - # a schedule, so no pull request fails for a push its author did not make. + # The schema_1 provenance checks compare the vendored catalogs against + # `ebus-panel-sim`'s own copies. `uv sync` above is all they need: the emitter is + # a pinned dev dependency, so there is nothing to clone and nothing that can skip. - name: Run pre-commit hooks - env: - SKIP: peer-drift run: | uv run pre-commit run --all-files @@ -150,29 +132,34 @@ jobs: print(f'{wheel}: py.typed present') " - # Reference captures are test data. They shipped in both wheels until 3.1.0 - # -- not through any packaging declaration, but because a directory inside a - # package directory ships -- and no runtime path ever read them. Nothing in - # the manifests would object to that happening again, so the built artifact - # is where it has to be asserted. Every wheel, not the two known ones: an - # adapter added under packages/ is covered the day it exists. - - name: Verify no wheel ships reference payloads + # Each adapter ships the reference capture its consumers test against, so a + # downstream suite reads the bytes its pinned version was built against rather + # than vendoring a copy. Nothing in the manifests declares that -- hatchling + # ships a directory inside a package directory either way -- so the built + # artifact is where it has to be asserted. + - name: Verify each adapter wheel ships its reference capture run: | python -c " - import glob, posixpath, sys, zipfile + import glob, sys, zipfile + expected = { + 'span_panel_api_schema_0': 'span_panel_api_schema_0/reference/homie_schema.json', + 'span_panel_api_schema_1': 'span_panel_api_schema_1/reference/parent_child_tree.json', + } wheels = glob.glob('dist/*.whl') if not wheels: sys.exit('::error::no wheels were built') + seen = set() for wheel in wheels: names = zipfile.ZipFile(wheel).namelist() - carried = [ - n for n in names - if 'reference_payloads' in n.split('/') - or posixpath.basename(n) in ('homie_schema.json', 'parent_child_tree.json') - ] - if carried: - sys.exit(f'::error::{wheel} ships test data: {carried}. Reference captures are fixtures under tests/reference_payloads; a directory inside a package directory ships whether or not the manifest names it.') - print(f'{wheel}: no reference payloads') + for package, path in expected.items(): + if any(n.startswith(package + '/') for n in names): + seen.add(package) + if path not in names: + sys.exit(f'::error::{wheel} ships {package} without {path}; downstream test suites read that file out of this wheel') + print(f'{wheel}: {path} present') + missing = sorted(set(expected) - seen) + if missing: + sys.exit(f'::error::no wheel was built for {missing}') " # The configuration entry-point discovery exists to support, and the one diff --git a/.github/workflows/peer-drift.yml b/.github/workflows/peer-drift.yml deleted file mode 100644 index a2b25ba..0000000 --- a/.github/workflows/peer-drift.yml +++ /dev/null @@ -1,160 +0,0 @@ -name: Peer drift - -# 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 -# commits we claim they came from -- against the pinned commits, where the answer is -# deterministic and blocking a merge on it is fair. -# -# Both jobs run `scripts/peer_drift.py --strict`, which is where the comparisons and -# what they mean now live -- including why the two producers are not the same shape, -# and what to do about either one. The same script runs from `.pre-commit-config.yaml` -# on a local commit, non-strict, so drift is usually caught on the commit that should -# have moved the pin rather than by this job the next morning; ci.yml skips that hook -# (`SKIP: peer-drift`) so the paragraph above holds for pull requests too. What stays -# here is the one thing a script with no clone cannot do: name the commits. -on: - schedule: - # Daily. The drift this exists to catch took nine days to be noticed by hand. - - cron: "17 6 * * *" - workflow_dispatch: - -permissions: - contents: read - -jobs: - panelbench: - name: Has panelbench moved past the pin? - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: "3.14" - - - name: Check out panelbench's own branch, and the specification at its pin - id: peers - uses: ./.github/actions/peer-checkouts - with: - peer-ref: default - - # Needs none of the checkouts above -- it asks GitHub what changed between the pin - # and the branch. Red only when the answer names a file this repository vendors, so - # panelbench advancing with a change we do not copy stays green. GITHUB_TOKEN for - # the API rate limit; the script sends it to no other host. - - name: Ask panelbench whether it moved past the pin - env: - GH_TOKEN: ${{ github.token }} - run: | - python3 scripts/peer_drift.py --strict --peer panelbench >> "$GITHUB_STEP_SUMMARY" - - # `if: always()`, so the list lands under the verdict whichever way it went. Naming - # the commits is what makes a red run actionable, and it is the part the script has - # no way to do -- counting or listing commits needs a clone, and this job has one. - - name: List the commits since the pin - if: always() - env: - PIN: ${{ steps.peers.outputs.panelbench-pin }} - BRANCH: ${{ steps.peers.outputs.panelbench-checkout }} - run: | - git() { command git -C "$GITHUB_WORKSPACE/peers/panelbench" "$@"; } - - if ! git merge-base --is-ancestor "$PIN" HEAD 2>/dev/null; then - echo "The pinned commit is not on \`$BRANCH\`, so there is no list of commits since it." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - behind="$(git rev-list --count "$PIN"..HEAD)" - if [ "$behind" -gt 0 ]; then - { - echo - echo "The $behind commits, which only a clone can name:" - echo - echo '```' - git log --oneline --no-decorate "$PIN"..HEAD - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - fi - - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - - - name: Install dependencies - run: uv sync --all-packages - - # The same checks ci.yml runs, pointed at panelbench's branch instead of the pin. - # Reusing them rather than reimplementing a diff here is the point: whatever the - # byte comparison means, it means the same thing in both jobs, and there is no - # second definition of "the captures match" to drift. - # - # It runs after the drift verdict rather than instead of it, and the two cannot - # disagree: the compare API reports the net diff between the pin and the branch, so - # a vendored file it names is a vendored file whose bytes differ. The script says - # so first and cheaply, with the instructions attached; this is the comparison that - # settles it against the bytes themselves. - - name: Compare the vendored captures against panelbench's branch - run: | - uv run pytest tests/test_schema_one_conformance.py -v -rs - - emitter: - name: Has the eBus emitter released past the pin? - runs-on: ubuntu-latest - - 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 release is the verdict and the commits are context -- the script says why, and - # says it in the summary. Red, deliberately, when the emitter has released past the - # pin: 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. It gates no pull - # request, so failing costs a notification and nothing else, and 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. - - name: Ask the emitter whether it released past the pin - run: | - python3 scripts/peer_drift.py --strict --peer ebus-panel-sim >> "$GITHUB_STEP_SUMMARY" - - # After the verdict because it is context for it, and `if: always()` because - # unreleased commits are worth reading on exactly the day the release comparison - # went red. The script reports that the branch moved; only a clone can say by how - # much and which commits, so that part stays here. - - name: List the commits since the pin - if: always() - env: - PIN: ${{ steps.peers.outputs.panel-sim-pin }} - BRANCH: ${{ steps.peers.outputs.panel-sim-checkout }} - run: | - git() { command git -C "$GITHUB_WORKSPACE/peers/panel-sim" "$@"; } - - if ! git merge-base --is-ancestor "$PIN" HEAD 2>/dev/null; then - echo "The pinned commit is not on \`$BRANCH\`, so there is no list of commits since it." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - behind="$(git rev-list --count "$PIN"..HEAD)" - if [ "$behind" -gt 0 ]; then - { - echo - echo "The $behind unreleased commits, which only a clone can name:" - echo - echo '```' - git log --oneline --no-decorate "$PIN"..HEAD - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - fi diff --git a/.gitignore b/.gitignore index 60bfa2b..cf89f56 100644 --- a/.gitignore +++ b/.gitignore @@ -40,8 +40,3 @@ coverage_output.log # of which belongs in a repository. The differential that reads them commits its # *verdict* only, never the capture, and skips when the file is absent. tests/fixtures/live_*.json - -# Peer checkouts. CI clones the eBus specification and SpanPanel/panelbench here so -# the provenance checks have something to compare vendored bytes against; the same -# layout works locally if you would rather not point .env at siblings. -/peers/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index dac34f2..16509c1 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -42,13 +42,6 @@ // `globs` above scans the tree directly, so pre-commit's `exclude` cannot // filter this out -- it has to be ignored here. "packages/schema-1/spec/**", - // The peer checkouts CI clones for the provenance checks: the eBus - // specification and panelbench, cloned into a gitignored `peers/`. Same - // reasoning as the vendored spec above and more so -- these are whole - // upstream repositories, and 835 findings in somebody else's prose were - // enough to fail the job before the tests ran. `globs` scans the tree - // directly, so being gitignored is not enough to keep them out. - "peers/**", ".venv/**", "venv/**", "node_modules/**", diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9513fd..a4046ad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,16 +33,16 @@ repos: hooks: # Run formatter first - exclude tests to avoid conflicts with black # - # `scripts/` is excluded except for `peer_drift.py` and `_lock.py`, which run - # on every commit and in CI and are held to the library's standard; the - # hand-run tools beside them are not. `pyproject.toml` names the same two - # exclusions for a bare `ruff check .`. + # `scripts/` is excluded except for `capture_parent_child_reference.py`, which + # produces the fixture the schema_1 suite is written against and is held to the + # library's standard; the hand-run tools beside it are not. `pyproject.toml` + # names the same exclusions for a bare `ruff check .`. - id: ruff-format - exclude: '^src/span_panel_api/generated_client/.*|generate_client\.py|scripts/(?!peer_drift\.py$|_lock\.py$).*|tests/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*' + exclude: '^src/span_panel_api/generated_client/.*|generate_client\.py|scripts/(?!capture_parent_child_reference\.py$).*|tests/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*' # Then linter - exclude tests and the hand-run scripts from strict linting - id: ruff-check args: ['--fix'] - exclude: '^src/span_panel_api/generated_client/.*|generate_client\.py|scripts/(?!peer_drift\.py$|_lock\.py$).*|tests/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*' + exclude: '^src/span_panel_api/generated_client/.*|generate_client\.py|scripts/(?!capture_parent_child_reference\.py$).*|tests/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*' # Black for line length handling and test file formatting - repo: https://github.com/psf/black @@ -75,22 +75,26 @@ repos: args: ['--config', '.markdownlint-cli2.jsonc'] exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*' - # MyPy for type checking - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.16.0 + # MyPy for type checking, run out of the project environment rather than out of + # one pre-commit builds. + # + # `mirrors-mypy` needs every typed import restated as `additional_dependencies`, + # which is a second dependency declaration that drifts: it had already gone stale + # once and needed `ebus-sdk` added by hand, and adding the emitter would have put + # a hardcoded `ebus-panel-sim==` here that a Dependabot bump of the real + # pin would silently leave behind. `uv run` resolves from `pyproject.toml` and + # `uv.lock`, so the checker sees exactly what the suite sees, and the pin has one + # home. It also fixes a quieter divergence: the mirror was three minor versions + # behind the mypy a developer gets from `uv run mypy`, so the hook and the + # terminal could disagree about the same file. + - repo: local hooks: - id: mypy - additional_dependencies: - - httpx - - click - - typing-extensions - - pytest - - types-PyYAML - - paho-mqtt - # schema-1 parses the parent/child tree with the eBus SDK, which - # ships py.typed — so the hook needs it installed to resolve those - # types rather than silently reporting import-not-found. - - ebus-sdk>=0.19.0 + name: mypy + entry: uv run mypy + language: system + types_or: [python, pyi] + require_serial: true args: ['--config-file=pyproject.toml'] # `tests/reference_payloads/` is the one thing under tests/ this hook does # check. Its five accessors were type-checked as package data until 3.1.0 @@ -99,11 +103,11 @@ repos: # names tests/, which is fine — that flag governs directory discovery, and # a file passed by name is checked regardless. # - # `scripts/peer_drift.py` and `scripts/_lock.py` are checked for the same - # reason: they decide whether a commit goes through, which is not the place - # for the one part of the tree nothing type-checks. The hand-run scripts - # beside them stay out — see `pyproject.toml`, which names them one by one. - exclude: '^src/span_panel_api/generated_client/.*|scripts/(?!peer_drift\.py$|_lock\.py$).*|tests/(?!reference_payloads/).*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*' + # `scripts/capture_parent_child_reference.py` is checked for the same reason: + # every schema_1 test is written against what it writes, which is not the place + # for the one part of the tree nothing type-checks. The hand-run scripts beside + # it stay out — see `pyproject.toml`, which names them one by one. + exclude: '^src/span_panel_api/generated_client/.*|scripts/(?!capture_parent_child_reference\.py$).*|tests/(?!reference_payloads/).*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*' # Pylint for code quality - repo: https://github.com/pycqa/pylint @@ -152,36 +156,6 @@ repos: pass_filenames: false files: ^pyproject\.toml$|^uv\.lock$ - # Have the producers moved past the pins in spec_lock.json? - # - # On a local commit, because CI finding this out is finding it out too late: the - # drift this catches is a producer release nobody here noticed until a scheduled - # job went red the next morning, by which time the commits that should have moved - # the pin were already in. - # - # `stages: [pre-commit]` and `SKIP: peer-drift` in ci.yml keep it off pull - # requests, and that is not an oversight to tidy up later: this question's answer - # changes because somebody else pushed, so asking it of an author's unrelated - # change would fail their build for a thing they did not do. peer-drift.yml asks - # it daily, with --strict, where failing costs a notification and nothing else. - # - # It asks PyPI and GitHub, so it is the one hook that needs the network — and - # deliberately non-strict, so it does not. A producer that cannot be reached - # reports as unknown and the commit goes through; only a producer that has - # actually moved past a pin stops one. Five calls at a ten-second timeout, so the - # worst case is under a minute and only against a network that accepts - # connections and then says nothing; a refused connection or a failed lookup - # comes back at once. - - repo: local - hooks: - - id: peer-drift - name: peers still at their pins - entry: uv run python scripts/peer_drift.py - language: system - pass_filenames: false - always_run: true - stages: [pre-commit] - # Quick coverage check (total only, no details) - repo: local hooks: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 48d4887..1a9e72c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -41,120 +41,92 @@ python scripts/coverage.py --full ## Conformance against the specification and the producer -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. +Some schema_1 tests verify this library against things it does not contain: the eBus capability catalogs vendored under `packages/schema-1/spec/catalogs/`, and the producer whose output the whole suite is written against. -Copy `.env.example` to `.env` and point them at real checkouts: +Both come from one place, and it is an ordinary dependency. `ebus-panel-sim` is pinned in the `dev` group in `pyproject.toml`, so `uv sync --all-packages` installs the emitter, its own copies of the catalogs, and the code that made the reference tree. +There is nothing to clone, no environment variable to set, and **nothing that can skip** — which is the point. The previous arrangement reached three sibling checkouts through `EBUS_SPEC_DIR` / `PANELBENCH_DIR` / `PANEL_SIM_DIR` and skipped when they were +absent, and a skip renders in a summary line exactly like a pass: that is how the reference capture went nine days stale while the check that would have said so never ran. -```bash -EBUS_SPEC_DIR=/path/to/ebus/specification -PANELBENCH_DIR=/path/to/span/panelbench -PANEL_SIM_DIR=/path/to/distribution-enclosure-simulator -``` +So there is one procedure for following the producer, and it has three steps: -### A skip here is not a pass +1. Bump `ebus-panel-sim==` in `pyproject.toml`, then `uv lock && uv sync --all-packages`. Dependabot raises this PR on its own, ungrouped, so a wire change is never buried in a tooling bump. +2. Re-run the capture: `uv run python scripts/capture_parent_child_reference.py`. +3. Run the suite. It is what says whether the wire moved. -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. +**Nothing else records which release made the capture, and nothing should.** `capture()` in that script is importable, and `test_the_shipped_reference_tree_is_what_the_pinned_emitter_produces` runs it in-process on every test run and compares the result to +the committed bytes. A written record can go stale in silence, which is precisely how this went wrong; a regeneration fails on the commit that moved the pin. If step 1 lands without step 2, the suite is red. -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. +### A skip is still not a pass -So: **if your run reports skips in `test_schema_one_conformance.py`, the provenance checks did not happen.** Run with `-rs` to see which and why: +Nothing in `test_schema_one_conformance.py` skips any more, and that is now a property of the design rather than of your machine. If you see a skip there, something is wrong with the environment, not with the check. Run with `-rs` to see which and why: ```bash uv run pytest tests/ -q -rs ``` -A correctly configured run has no skips in that file. The only skips you should expect are in `test_live_flat_differential.py`, which needs a live panel capture that is deliberately gitignored (see `scripts/capture_live_flat.py`); those are flat-firmware -differentials and are not part of schema_1 work. - -It cost us a second time on 2026-08-20, in the other vendored capture. `tests/fixtures/flat_wire.json` was taken from the flat simulator at v1.0.15 and described as frozen; 1.0.16 then made an EVSE's node id its drive serial and forced that serial -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. - -### The other question: has the producer moved? - -A sibling checkout answers _do our bytes still match the pin?_ It cannot answer _has the producer moved past it?_, because what is on your disk knows nothing about what has been pushed or released since. `scripts/peer_drift.py` asks that one — PyPI for the -emitter's latest release, GitHub's compare API for what panelbench changed, `git ls-remote` for where a branch is — reading every pin, path and repository out of `spec_lock.json`, and needing no checkout at all: - -```bash -uv run python scripts/peer_drift.py # all three producers -uv run python scripts/peer_drift.py --peer panelbench # one of them -``` - -It runs as a pre-commit hook so that drift is caught on the commit that should have moved the pin. Non-strict there: a producer that cannot be reached reports as **UNKNOWN** and the commit still goes through, because not having asked is not the same fact -as there being nothing new, and a laptop with no network still has to be able to commit. Only a producer that has actually moved past a pin stops one. +The only skips to expect anywhere are in `test_live_flat_differential.py`, which needs a live panel capture that is deliberately gitignored (see `scripts/capture_live_flat.py`); those are flat-firmware differentials and are not part of schema_1 work. -**Local commits ask; pull requests do not.** `ci.yml` runs the same hooks with `SKIP: peer-drift`, and the hook is `stages: [pre-commit]`, so a producer that moved cannot fail somebody's unrelated pull request — the answer changes because a third party -pushed, and failing an author for that is how a check gets ignored. `peer-drift.yml` asks it daily with `--strict`, where being unable to reach a producer is a broken run rather than bad wifi, and where a red result costs a notification and nothing else. - -Two comparisons are verdicts, and both are verdicts about bytes here having gone stale: the emitter's **release**, because the reference tree is a capture of one, and a **panelbench commit that touches a file we vendor**. Everything else is reported and -stays green — unreleased commits on the emitter's branch, panelbench commits that change nothing we copy, and the specification itself, which says what a device class may publish rather than what one does. +The lesson that produced this is worth keeping, because it cost us twice. The reference tree was captured once, nothing recorded what made it, and three emitter releases went by while this repository asserted a producer defect as fact across roughly thirty +test files. Then on 2026-08-20 `tests/fixtures/flat_wire.json` did the same thing from the other side: taken at simulator v1.0.15 and described as frozen, while 1.0.16 made an EVSE's node id its drive serial and forced it lower-case. A capture is only +evidence if something mechanical can still reproduce it — which is now true of the parent/child capture and, because that producer is not a distribution, still only a written commit note for the flat one. ### 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` | -| `tests/reference_payloads/parent_child_tree.json` | `ebus-panel-sim` | `scripts/capture_parent_child_reference.py` | +| Artifact | Producer | Script | +| -------------------------------------------------------------------------------- | ---------------- | ------------------------------------------- | +| `tests/fixtures/flat_wire.json` | `simulator` | `scripts/capture_flat_reference.py` | +| `packages/schema-1/src/span_panel_api_schema_1/reference/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`. +The two are not run the same way, and the difference is the whole change. `capture_parent_child_reference.py` runs from this repository with this environment, because its producer is installed here; it writes over the shipped capture by default, and its +`capture()` function is what the suite re-runs. `capture_flat_reference.py` still runs from the flat simulator's own checkout, named by `SIMULATOR_DIR`, because that producer is not published as a distribution and caps a dependency this repo installs +above. -`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 `tests/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. +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. -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` +The parent/child capture's input is committed too, as `scripts/reference_panel.yaml`, in exactly one place — 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 fourteen 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. +Expect a recapture to move every `$description`'s `version`, which is minted from the wall clock. A diff confined to those fourteen lines means the producer did not move — and that field is exactly what the regeneration test normalises away, so it is the +one thing a recapture may change without the suite calling it a wire change. -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`. +### Where the captures live: package data, deliberately -### Two questions, two workflows +Each capture ships inside the adapter that parses it — `span_panel_api_schema_1/reference/parent_child_tree.json` and `span_panel_api_schema_0/reference/homie_schema.json` — and `tests/reference_payloads/` holds only the loaders, which read them through +`importlib.resources`. -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. +No runtime path in either distribution opens them. They ship anyway, because the cost of not shipping them is paid downstream: the integration was vendoring copies and then maintaining a provenance guard to keep the copies honest, which is more machinery +than 59 KB in two wheels. This reverses the 3.1.0 decision (#166, issue #162), whose reasoning — that no runtime consumer reads them — was true and turned out not to be the deciding cost. `tests/test_packaging.py` and a CI step over the built wheels both +assert each adapter carries its capture; the bootstrap carries neither, since it parses nothing. -- **`.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. 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 runs - `scripts/peer_drift.py --strict`, which asks the producers directly and needs no clone; the clones it still takes are there to name the commits, which is the one thing that script cannot do. It goes red only on a verdict — a new emitter release, or a - panelbench commit touching a file we vendor — so panelbench advancing with a change we do not copy stays green. - -The second question is also asked on every local commit, by the `peer-drift` pre-commit hook running the same script non-strict — and skipped in `ci.yml`, so it stays off pull requests for the same reason the workflow does. The workflow is the backstop -rather than the check: a producer that moved is best found by the commit that should have moved the pin with it. +### The producer is the specification, executable -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. +`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. -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. +That is also why it is the source the vendored catalogs are checked against. `packages/schema-1/spec/catalogs/*.json` are byte copies of the specification's `capabilities/`, and the emitter carries its own copies of the same files at +`ebus_panel_sim/wire/catalogs/` — it publishes _against_ them rather than beside them. `test_vendored_catalogs_are_byte_identical_to_the_emitters` compares the two sets, so our vocabulary and the producer's are one set of bytes or the test says where they +differ. -### When the peer check fails +**Integrity, deliberately not currency.** Whether upstream has moved past the release we pin is a different question, and it belongs to pip: Dependabot raises the bump. Conflating the two is what made the old comparison unreliable — it failed whenever a +sibling clone had merely moved ahead of the pin. -A failure means the vendored capture and panelbench have diverged. That is information, not an obstacle — decide which side is right: +One catalog has no source in the wheel: `grid-forming.json`. The emitter models the BESS as a single device with no inverter child, so it publishes no grid-forming capability and ships no catalog for one. That file is vendored from the specification at +`synced_commit` and is listed in `_UNSOURCED_CATALOGS` with the reason. `spec/registries/device-types.md` is the same case. Both directions are checked: a new unsourced catalog fails until somebody records why, and an entry the emitter has since started +shipping fails until it is removed. -- **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. +The emitter caps `ebus-sdk` below 0.23 while this adapter permits up to 0.24, so with the emitter installed the suite resolves `ebus-sdk` 0.22.x: the top of the declared range is exercised by consumers, not here. That is a known consequence of pinning the +emitter, accepted until its cap lifts; if a change here leans on SDK behaviour above 0.22, test it against that SDK deliberately. ### Catalogs are pinned by commit, not version `spec_lock.json` records `synced_commit`, not a specification version. That is deliberate: the 2026-07-31 spec changelog changed circuit sign-frame semantics **in place** with no version bump and stated no re-pin was required. A version pin would not have noticed. +It is a record rather than a check. The emitter ships no `.ebus-spec.json` in its wheel, so nothing compares that commit to the producer's — what is checked mechanically is the bytes, which is the thing that can quietly be wrong. + ### The acknowledged-divergence register `test_schema_one_conformance.py` asks whether the names this adapter reads exist in the catalogs. `test_catalog_divergence.py` asks the next question, and it is the one that corrupts readings when the answer is wrong: **does the `unit` and `datatype` a diff --git a/README.md b/README.md index 7c02b5c..34eaed5 100644 --- a/README.md +++ b/README.md @@ -527,11 +527,25 @@ The `PanelCapability` flag enum advertises transport features at runtime: ## Reference Payloads -Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree — live in [`tests/reference_payloads/`](tests/reference_payloads/README.md), with their provenance. +Captures of what a panel actually serves — the `GET /api/v2/homie/schema` document and a full 40-space parent/child retained-topic tree — ship as package data of the adapter that parses each, and their provenance is documented in +[`tests/reference_payloads/`](tests/reference_payloads/README.md) beside the loaders that read them. -**They are fixtures of this repository, not package data.** Until 3.1.0 they sat inside the two source packages and were therefore carried in the wheels, which no runtime path ever read. `span_panel_api.reference_payloads` and -`span_panel_api_schema_1.reference_payloads` no longer exist; a consumer that was importing them should vendor the bytes it needs and record the release it took them from, asserting that against `importlib.metadata.version(...)` so a moved pin that outruns -the copy fails loudly instead of testing against a schema no panel runs. +| Capture | Read from | +| ------------------------ | ---------------------------------------------------------- | +| `homie_schema.json` | `span_panel_api_schema_0/reference/homie_schema.json` | +| `parent_child_tree.json` | `span_panel_api_schema_1/reference/parent_child_tree.json` | + +**Test-support data, and no runtime path reads either.** They ship so a downstream test suite pinned to a version of an adapter reads the same bytes that version was tested against, out of its own site-packages: + +```python +from importlib.resources import files +import json + +schema = json.loads((files("span_panel_api_schema_0") / "reference" / "homie_schema.json").read_text(encoding="utf-8")) +``` + +The bootstrap distribution ships neither: it registers no adapter and parses nothing. `span_panel_api.reference_payloads` and `span_panel_api_schema_1.reference_payloads` — the importable modules that existed until 3.1.0 — are still gone; these are data +files read through `importlib.resources`, not an import surface. ## Project Structure @@ -562,13 +576,15 @@ src/span_panel_api/ # distribution: span-panel-api (no parser) packages/schema-0/ # distribution: span-panel-api-schema-0 └── src/span_panel_api_schema_0/ - # Flat parser: HomiePropertyAccumulator, HomieLifecycle, + ├── reference/ # homie_schema.json — test-support package data, not read at runtime + └── ... # Flat parser: HomiePropertyAccumulator, HomieLifecycle, # HomieDeviceConsumer, field metadata, SCHEMA_ANCHOR packages/schema-1/ # distribution: span-panel-api-schema-1 -├── spec/ # eBus capability catalogs, byte-copied; checked against, never parsed +├── spec/ # eBus capability catalogs, byte-copied from the emitter wheel; checked against, never parsed └── src/span_panel_api_schema_1/ - # Parent/child parser: ControllerRoutes, snapshot mapper, + ├── reference/ # parent_child_tree.json — test-support package data, not read at runtime + └── ... # Parent/child parser: ControllerRoutes, snapshot mapper, # adoption, catalog validator, spec_lock.json ``` diff --git a/RELEASE.md b/RELEASE.md index 4d59c60..316e193 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -208,7 +208,13 @@ Versions like `3.0.0b1` are pre-releases in both places that matter: The publish workflow itself does not care — `on: release: published` fires either way. -## When a producer releases +## When the emitter releases -Releasing the eBus emitter past the version we pin, or landing a panelbench commit that touches a capture we vendor, leaves this repository's `peers` block describing a producer that has been superseded — so the next commit here fails the `peer-drift` -pre-commit hook until `spec_lock.json` moves with it. That is the intended order: re-vendor or re-capture and re-pin in one change, then commit. `gh workflow run peer-drift.yml` asks the same question on demand rather than waiting for the daily run. +`ebus-panel-sim` is a pinned dev dependency, so a release past the pin arrives the way every other dependency's does: as a Dependabot pull request, ungrouped and on its own. Following it is bump the pin, re-run `scripts/capture_parent_child_reference.py`, +run the suite — and the suite is what says whether the wire moved. See DEVELOPMENT.md, "Conformance against the specification and the producer". If the capture changes, that is a release of `span-panel-api-schema-1`, for the reason the next section gives. + +## A capture change is a release of the adapter that ships it + +Each adapter carries the reference capture its consumers test against — `span_panel_api_schema_1/reference/parent_child_tree.json` and `span_panel_api_schema_0/reference/homie_schema.json`. Those are package data, so re-running a capture changes what the +wheel contains even when no parser did, and that is a release of that adapter like any other change to its contents. Bump the adapter's version and add a CHANGELOG entry saying the capture moved; downstream test suites read these bytes out of the version +they pin, and a version that silently means two different trees is the thing this arrangement exists to prevent. diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md index a15fda3..c0e52df 100644 --- a/packages/schema-0/CHANGELOG.md +++ b/packages/schema-0/CHANGELOG.md @@ -9,6 +9,14 @@ rather than by this version number. A release here means this parser changed, ne Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the last public release, never against the beta before it. +## [1.1.2] + +The reference schema document this parser is tested against now ships in the wheel. + +### Added + +- **`span_panel_api_schema_0/reference/homie_schema.json`** — the captured `GET /api/v2/homie/schema` response, shipped as package data so a downstream test suite reads the bytes its pinned version was tested against instead of vendoring a copy. + ## [1.1.1] Still requires `span-panel-api` **3.1.0 or newer**, unchanged. diff --git a/packages/schema-0/pyproject.toml b/packages/schema-0/pyproject.toml index 6edad03..1494e77 100644 --- a/packages/schema-0/pyproject.toml +++ b/packages/schema-0/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-0" -version = "1.1.1" +version = "1.1.2" description = "Flat-schema (data-model-version absent) parser for span-panel-api" authors = [ {name = "SpanPanel"} diff --git a/tests/reference_payloads/homie_schema.json b/packages/schema-0/src/span_panel_api_schema_0/reference/homie_schema.json similarity index 100% rename from tests/reference_payloads/homie_schema.json rename to packages/schema-0/src/span_panel_api_schema_0/reference/homie_schema.json diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 2f198ec..6b5e0c8 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -9,6 +9,18 @@ number. A release here means this parser changed, never that the panel did. Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the last public release, never against the beta before it. +## [1.1.3] + +`spec_lock.json` ships inside this wheel, so its shape changing is a release here even though the parser did not change. + +### Added + +- **`span_panel_api_schema_1/reference/parent_child_tree.json`** — the reference capture, shipped as package data so a downstream test suite replays the bytes its pinned version was tested against instead of vendoring a copy. + +### Changed + +- **`spec_lock.json` loses its `peers` block** — the producer this parser is developed against is now an ordinary pinned dev dependency of the repository rather than a peer recorded in the lockfile. + ## [1.1.2] A refreshed peer pin: `spec_lock.json` ships inside this wheel, so the reference capture's producer moving is a release here even though the parser did not change. diff --git a/packages/schema-1/README.md b/packages/schema-1/README.md index 2441d48..56f2e57 100644 --- a/packages/schema-1/README.md +++ b/packages/schema-1/README.md @@ -41,16 +41,28 @@ span-panel-api's transport covers the whole tree and each message is routed to w ## Conformance -`spec_lock.json` ships with the package and records what this parser targets: the firmware range, the eBus specification commit its vocabulary was read from, and the version of every capability, device and registry it implements. The capability catalogs it -addresses are byte-copied under `spec/`. +`spec_lock.json` ships with the package and is this parser's declaration as a consumer: the firmware range it reads, the eBus specification commit its vocabulary was read from, and the version of every capability, device and registry it implements. The +capability catalogs it addresses are byte-copied into the repository under `spec/`, and the repository checks them against the copies the eBus emitter carries in its own wheel. Those copies exist to be **checked against, never parsed in production** — units and datatypes come from each device's `$description`, since a catalog is the superset across all hardware rather than a statement about the panel in front of you. The suite asks the consumer's question rather than the publisher's: is every name this adapter _reads_ one the specification defines? A consumer addressing a name that no longer exists does not fail loudly, it goes quiet — the property never arrives, metadata lookup returns `None`, and an entity disappears. -## Reference payloads +## Reference payload -A retained-topic capture of a full 40-space parent/child panel, and the replay that turns it back into devices, are fixtures of the repository's test suite at `tests/reference_payloads/`. +`span_panel_api_schema_1/reference/parent_child_tree.json` ships in this wheel: a retained-topic capture of a full 40-space parent/child panel — 14 devices, `{device_id: {topic: payload}}`, every value a string exactly as a broker retains it. -**`span_panel_api_schema_1.reference_payloads` no longer exists.** It was package data until 1.1.0 — carried in this wheel because it sat inside the package directory, though no runtime path read it. A consumer that was importing it should vendor the bytes -it needs and record the release it took them from, asserting that against `importlib.metadata.version("span-panel-api-schema-1")` so a moved pin that outruns the copy fails loudly instead of testing against a tree no panel publishes. +**Test-support data, and no runtime path reads it.** It ships so a downstream test suite pinned to a version of this adapter replays the bytes that version was built and tested against, out of its own site-packages: + +```python +from importlib.resources import files +import json + +tree = json.loads((files("span_panel_api_schema_1") / "reference" / "parent_child_tree.json").read_text(encoding="utf-8")) +``` + +Vendoring a copy instead means also maintaining a guard to keep the copy honest, which is what this replaces. It was package data until 1.1.0, a fixture of the repository's test suite for 1.1.2, and package data again from 1.1.3 — for the cost, not for the +principle: no runtime path has ever read it. + +The bytes are produced by `ebus-panel-sim`, pinned in the repository's dev dependencies, and the repository's suite regenerates the capture in-process on every run and compares it, so a tree the pinned producer does not reproduce is a test failure rather +than a claim in a document. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index e0cedbd..98d1890 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-1" -version = "1.1.2" +version = "1.1.3" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} diff --git a/packages/schema-1/spec/fixtures/simulator_tree.json b/packages/schema-1/spec/fixtures/simulator_tree.json deleted file mode 100644 index ee75fd6..0000000 --- a/packages/schema-1/spec/fixtures/simulator_tree.json +++ /dev/null @@ -1,4853 +0,0 @@ -{ - "13044bfbcbe5554b8f3dba126bce828f": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Kitchen Outlets (Island)", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834687 - }, - "1bfdc7ecebb0547bbe87a3696cddb0c0": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "SPAN Drive - Driveway", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834690 - }, - "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Smoke Detectors", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834689 - }, - "2140a7e253ed54e3bc90a959081df615": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Refrigerator", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state" - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834688 - }, - "249a2f59782e5f1ab317c4632e79afad": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "SPAN Drive - Garage", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834690 - }, - "3d9d86f303cc50d1827be57d4c667e53": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Bedroom Lights", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834686 - }, - "3eeb0eb1605e5a7eadac41994b7a096c": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Master Bedroom Outlets", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834686 - }, - "43a0521737db516f99f14a9964ea4af0": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Washing Machine", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834688 - }, - "4aeb08c46c2c5905a944166413f2f1ef": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Garbage Disposal", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834689 - }, - "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Water Heater", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834690 - }, - "4d1deb6acb065746b13207b1358f8ca7": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Dishwasher", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834688 - }, - "516694a326a35cd88600b3520e8a981a": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Pool Pump", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834689 - }, - "6fcb352679ad5bfb8c8a8eab06829b9f": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Solar Inverter", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state" - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834690 - }, - "770e2de52c33508a8a9ee8878064b46f": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Master Bedroom Lights", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834685 - }, - "80a4fada833156ab8112f9d50e252b8f": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Kitchen Outlets (Counter)", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834687 - }, - "9429f828509e58d59cb5f0f9f5fee523": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Living Room Lights", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834686 - }, - "948dea7788aa5c959b99df0edfabead2": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Heat Pump", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834689 - }, - "af731c49a6785a4cb2ea5549fb8bce7e": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Main HVAC", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834689 - }, - "afe90839f2725e3e962fb05afa2b6d43": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Chest Freezer", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state" - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834688 - }, - "b24483358d29589d8e91d3bf11113269": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Office Outlets", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834687 - }, - "b9fa08f1eaaf5d129bd5c78e1d5d937f": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "kitchen Lights", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834691 - }, - "be7742043a06554aab2a1e38cc776603": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Electric Oven/Range", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834690 - }, - "c058aa11287f50f9b81e5160a0678869": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Bathroom Lights", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834686 - }, - "c339ec7ce7ff521ca7646f9606baff9f": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Guest Room Outlets", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834688 - }, - "d1ff145887a05b839ede89409c27b398": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Garage Outlets", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834687 - }, - "e0ac90e169e6550ea83fe0b1942f1d0e": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Living Room Outlets", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834687 - }, - "e0bc156c85015a609d4132084dfcd6fe": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Microwave", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834688 - }, - "edee3425d50d51ffb022ee999053b2b4": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Laundry Room Outlets", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834687 - }, - "ef972f063451539e8b2ad88e831d87b6": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Electric Dryer", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834689 - }, - "f515a0f43b6555b1a196fbb62728c24e": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Exterior Lights", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "poles": { - "datatype": "integer", - "format": "1:4:1", - "name": "Number of breaker poles" - }, - "rating": { - "datatype": "integer", - "name": "Circuit breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "connection": { - "name": "connection", - "properties": { - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this circuit" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "name": { - "datatype": "string", - "name": "Circuit name" - }, - "spaces": { - "datatype": "string", - "name": "Circuit breaker space number(s) within the load center (comma-separated for multi-pole)" - } - }, - "type": "energy.ebus.capability.info" - }, - "load-shed": { - "name": "load-shed", - "properties": { - "priority": { - "datatype": "enum", - "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER", - "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)", - "settable": true - } - }, - "type": "energy.ebus.capability.load-shed" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Measured active power", - "unit": "W" - }, - "current": { - "datatype": "float", - "name": "Measured current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Measured energy exported", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Measured energy imported", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "managed": { - "datatype": "boolean", - "name": "Is circuit managed by PCS?" - }, - "priority": { - "datatype": "integer", - "name": "Circuit PCS priority ranking" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "switch": { - "name": "switch", - "properties": { - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Circuit relay state", - "settable": true - }, - "relay-controllable": { - "datatype": "boolean", - "name": "Can the circuit's relay be commanded by the user?" - }, - "relay-requester": { - "datatype": "enum", - "format": "UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT", - "name": "Actor requesting the relay state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.circuit", - "version": 1787808834686 - }, - "sim-40t-001": { - "children": [ - "sim-40t-001-SIM-BESS-40T-001", - "770e2de52c33508a8a9ee8878064b46f", - "9429f828509e58d59cb5f0f9f5fee523", - "3d9d86f303cc50d1827be57d4c667e53", - "c058aa11287f50f9b81e5160a0678869", - "f515a0f43b6555b1a196fbb62728c24e", - "3eeb0eb1605e5a7eadac41994b7a096c", - "e0ac90e169e6550ea83fe0b1942f1d0e", - "80a4fada833156ab8112f9d50e252b8f", - "13044bfbcbe5554b8f3dba126bce828f", - "b24483358d29589d8e91d3bf11113269", - "d1ff145887a05b839ede89409c27b398", - "edee3425d50d51ffb022ee999053b2b4", - "c339ec7ce7ff521ca7646f9606baff9f", - "2140a7e253ed54e3bc90a959081df615", - "4d1deb6acb065746b13207b1358f8ca7", - "43a0521737db516f99f14a9964ea4af0", - "e0bc156c85015a609d4132084dfcd6fe", - "afe90839f2725e3e962fb05afa2b6d43", - "4aeb08c46c2c5905a944166413f2f1ef", - "516694a326a35cd88600b3520e8a981a", - "1eeeb748eeaa58edb7e9b7e9dbbdeca7", - "ef972f063451539e8b2ad88e831d87b6", - "af731c49a6785a4cb2ea5549fb8bce7e", - "948dea7788aa5c959b99df0edfabead2", - "be7742043a06554aab2a1e38cc776603", - "4ce8b30e8d3f5c49b9e0ab0c8caf4832", - "249a2f59782e5f1ab317c4632e79afad", - "1bfdc7ecebb0547bbe87a3696cddb0c0", - "6fcb352679ad5bfb8c8a8eab06829b9f", - "b9fa08f1eaaf5d129bd5c78e1d5d937f", - "sim-40t-001-sim-evse-sim-40t-001", - "sim-40t-001-sim-evse-sim-40t-001-2", - "sim-40t-001-lugs-up", - "sim-40t-001-lugs-dn", - "sim-40t-001-pv-1" - ], - "extensions": [], - "homie": "5.0", - "name": "Span Panel", - "nodes": { - "breaker": { - "name": "breaker", - "properties": { - "rating": { - "datatype": "integer", - "name": "Main breaker rating", - "unit": "A" - } - }, - "type": "energy.ebus.capability.breaker" - }, - "door": { - "name": "door", - "properties": { - "state": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Door state" - } - }, - "type": "energy.ebus.capability.door" - }, - "info": { - "name": "info", - "properties": { - "data-model-version": { - "datatype": "string", - "name": "eBus data-model version (parent/child schema discriminator)" - }, - "firmware-version": { - "datatype": "string", - "name": "Firmware version" - }, - "hardware-version": { - "datatype": "string", - "name": "Hardware version" - }, - "model": { - "datatype": "enum", - "format": "MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48", - "name": "Model" - }, - "serial-number": { - "datatype": "string", - "name": "Serial number" - }, - "vendor-name": { - "datatype": "string", - "name": "Vendor name" - } - }, - "type": "energy.ebus.capability.info" - }, - "meter": { - "name": "meter", - "properties": { - "voltage-a": { - "datatype": "float", - "name": "L1 voltage", - "unit": "V" - }, - "voltage-b": { - "datatype": "float", - "name": "L2 voltage", - "unit": "V" - } - }, - "type": "energy.ebus.capability.meter" - }, - "pcs": { - "name": "pcs", - "properties": { - "active": { - "datatype": "boolean", - "name": "PCS system actively controlling one (or more) loads" - }, - "binding-constraint": { - "datatype": "enum", - "format": "FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN", - "name": "Which constraint class currently sets the import limit" - }, - "enabled": { - "datatype": "boolean", - "name": "PCS system enabled" - }, - "feed-import-limit": { - "datatype": "float", - "name": "Limit of maximum power feeding the distribution enclosure", - "unit": "A" - }, - "feed-import-limit-active": { - "datatype": "boolean", - "name": "Is feed-import-limit currently being enforced?" - }, - "feed-import-limit-enablement": { - "datatype": "enum", - "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", - "name": "Enablement status of the feed-import-limit" - }, - "import-limit": { - "datatype": "float", - "name": "The power import limit currently being managed to", - "unit": "A" - }, - "off-grid-import-limit": { - "datatype": "float", - "name": "Off-Grid limit maximum import power", - "unit": "A" - }, - "off-grid-import-limit-active": { - "datatype": "boolean", - "name": "Is off-grid-import-limit currently being enforced?" - }, - "off-grid-import-limit-enablement": { - "datatype": "enum", - "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", - "name": "Enablement status of the off-grid-import-limit" - }, - "operator-import-limit": { - "datatype": "float", - "name": "Operator-imposed maximum import limit", - "unit": "A" - }, - "operator-import-limit-active": { - "datatype": "boolean", - "name": "Is operator-import-limit currently being enforced?" - }, - "operator-import-limit-enablement": { - "datatype": "enum", - "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", - "name": "Enablement status of the operator-import-limit" - }, - "requested-import-limit": { - "datatype": "float", - "name": "Requested limit maximum import power", - "unit": "A" - }, - "requested-import-limit-active": { - "datatype": "boolean", - "name": "Is requested-import-limit currently being enforced?" - }, - "requested-import-limit-enablement": { - "datatype": "enum", - "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", - "name": "Enablement status of the requested-import-limit" - } - }, - "type": "energy.ebus.capability.pcs" - }, - "power-flows": { - "name": "power-flows", - "properties": { - "battery": { - "datatype": "float", - "name": "Battery/BESS power flow", - "unit": "W" - }, - "grid": { - "datatype": "float", - "name": "Grid power flow", - "unit": "W" - }, - "pv": { - "datatype": "float", - "name": "PV power flow", - "unit": "W" - }, - "site": { - "datatype": "float", - "name": "Site power flow", - "unit": "W" - } - }, - "type": "energy.ebus.capability.power-flows" - }, - "shed": { - "name": "shed", - "properties": { - "asserted-islanding-state": { - "datatype": "enum", - "format": "NONE,ON_GRID,OFF_GRID", - "name": "Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)", - "settable": true - }, - "policy": { - "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\"}}}}}", - "name": "Shed policy (algorithm and parameters)" - } - }, - "type": "energy.ebus.capability.shed" - }, - "shed-forecast": { - "name": "shed-forecast", - "properties": { - "confidence": { - "datatype": "enum", - "format": "LOW,MEDIUM,HIGH", - "name": "Confidence of the shed-forecast estimate" - }, - "full-charge-time-to-priority-shed": { - "datatype": "integer", - "name": "Estimated time to next priority shed assuming BESS starts at full charge", - "unit": "min" - }, - "full-charge-total-time-remaining": { - "datatype": "integer", - "name": "Estimated total time assuming BESS starts at full charge", - "unit": "min" - }, - "time-to-priority-shed": { - "datatype": "integer", - "name": "Estimated time before the next priority tier is shed", - "unit": "min" - }, - "total-time-remaining": { - "datatype": "integer", - "name": "Estimated total time before all sheddable circuits are shed (off-grid runtime)", - "unit": "min" - } - }, - "type": "energy.ebus.capability.shed-forecast" - }, - "status": { - "name": "status", - "properties": { - "cloud-connection": { - "datatype": "enum", - "format": "UNKNOWN,UNCONNECTED,CONNECTED", - "name": "Device connected to vendor cloud?" - }, - "ethernet": { - "datatype": "boolean", - "name": "Is Ethernet network interface operational?" - }, - "postal-code": { - "datatype": "string", - "name": "Postal (Zip) code" - }, - "relay": { - "datatype": "enum", - "format": "UNKNOWN,OPEN,CLOSED", - "name": "Main relay" - }, - "time-zone": { - "datatype": "string", - "name": "Time zone" - }, - "wifi": { - "datatype": "boolean", - "name": "Is Wi-Fi network interface operational?" - }, - "wifi-ssid": { - "datatype": "string", - "name": "SSID to which Wi-Fi network interface is connected" - } - }, - "type": "energy.ebus.capability.status" - } - }, - "type": "energy.ebus.device.distribution-enclosure", - "version": 1787808834691 - }, - "sim-40t-001-SIM-BESS-40T-001": { - "children": [ - "sim-40t-001-SIM-BESS-40T-001-mid" - ], - "extensions": [], - "homie": "5.0", - "name": "Battery", - "nodes": { - "info": { - "name": "info", - "properties": { - "firmware-version": { - "datatype": "string", - "name": "Firmware version" - }, - "model": { - "datatype": "string", - "name": "Model" - }, - "nameplate-capacity": { - "datatype": "float", - "name": "Nameplate capacity", - "unit": "kWh" - }, - "part-number": { - "datatype": "string", - "name": "Part number" - }, - "serial-number": { - "datatype": "string", - "name": "Serial number" - }, - "vendor-name": { - "datatype": "string", - "name": "Vendor name" - } - }, - "type": "energy.ebus.capability.info" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Active power", - "unit": "W" - } - }, - "type": "energy.ebus.capability.meter" - }, - "soc": { - "name": "soc", - "properties": { - "soc": { - "datatype": "float", - "name": "State of charge", - "unit": "%" - }, - "soe": { - "datatype": "float", - "name": "State of energy", - "unit": "kWh" - } - }, - "type": "energy.ebus.capability.soc" - }, - "status": { - "name": "status", - "properties": { - "communication-state": { - "datatype": "enum", - "format": "OK,DEGRADED,LOST,UNKNOWN", - "name": "Communication state" - } - }, - "type": "energy.ebus.capability.status" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.bess", - "version": 1787808834691 - }, - "sim-40t-001-SIM-BESS-40T-001-mid": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Microgrid Interconnect Device", - "nodes": { - "grid": { - "name": "grid", - "properties": { - "grid-forming-entity": { - "datatype": "string", - "name": "Identity of the currently grid-forming entity" - }, - "grid-state": { - "datatype": "enum", - "format": "UP,DOWN,DEGRADED,UNKNOWN", - "name": "Sensed grid condition" - }, - "islanding-state": { - "datatype": "enum", - "format": "ON_GRID,OFF_GRID,UNKNOWN", - "name": "Islanding state of the BESS-integrated grid-forming device" - } - }, - "type": "energy.ebus.capability.grid" - }, - "info": { - "name": "info", - "properties": { - "firmware-version": { - "datatype": "string", - "name": "Firmware version" - }, - "hardware-version": { - "datatype": "string", - "name": "Hardware version" - }, - "model": { - "datatype": "string", - "name": "Model" - }, - "serial-number": { - "datatype": "string", - "name": "Serial number" - }, - "vendor-name": { - "datatype": "string", - "name": "Vendor name" - } - }, - "type": "energy.ebus.capability.info" - } - }, - "parent": "sim-40t-001-SIM-BESS-40T-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.mid", - "version": 1787808834691 - }, - "sim-40t-001-lugs-dn": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Downstream lugs", - "nodes": { - "connection": { - "name": "connection", - "properties": { - "fed-by-device-id": { - "datatype": "string", - "name": "Homie device-id of the upstream device feeding this lugs" - }, - "fed-by-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the upstream device" - }, - "fed-by-device-type": { - "datatype": "string", - "name": "Homie $type of the upstream device" - }, - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this lugs" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "direction": { - "datatype": "enum", - "format": "UPSTREAM,DOWNSTREAM", - "name": "Lugs feed direction: upstream or downstream" - } - }, - "type": "energy.ebus.capability.info" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Active power", - "unit": "W" - }, - "current-a": { - "datatype": "float", - "name": "L1 current", - "unit": "A" - }, - "current-b": { - "datatype": "float", - "name": "L2 current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Exported energy", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Imported energy", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.lugs", - "version": 1787808834691 - }, - "sim-40t-001-lugs-up": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Upstream lugs", - "nodes": { - "connection": { - "name": "connection", - "properties": { - "fed-by-device-id": { - "datatype": "string", - "name": "Homie device-id of the upstream device feeding this lugs" - }, - "fed-by-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the upstream device" - }, - "fed-by-device-type": { - "datatype": "string", - "name": "Homie $type of the upstream device" - }, - "feeds-device-id": { - "datatype": "string", - "name": "Homie device-id of the downstream device fed by this lugs" - }, - "feeds-device-status": { - "datatype": "enum", - "format": "OK,LOST,DEGRADED", - "name": "Panel's view of comm health to the downstream device" - }, - "feeds-device-type": { - "datatype": "string", - "name": "Homie $type of the downstream device" - } - }, - "type": "energy.ebus.capability.connection" - }, - "info": { - "name": "info", - "properties": { - "direction": { - "datatype": "enum", - "format": "UPSTREAM,DOWNSTREAM", - "name": "Lugs feed direction: upstream or downstream" - } - }, - "type": "energy.ebus.capability.info" - }, - "meter": { - "name": "meter", - "properties": { - "active-power": { - "datatype": "float", - "name": "Active power", - "unit": "W" - }, - "current-a": { - "datatype": "float", - "name": "L1 current", - "unit": "A" - }, - "current-b": { - "datatype": "float", - "name": "L2 current", - "unit": "A" - }, - "exported-energy": { - "datatype": "float", - "name": "Exported energy", - "unit": "Wh" - }, - "imported-energy": { - "datatype": "float", - "name": "Imported energy", - "unit": "Wh" - } - }, - "type": "energy.ebus.capability.meter" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.lugs", - "version": 1787808834691 - }, - "sim-40t-001-pv-1": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "Solar", - "nodes": { - "info": { - "name": "info", - "properties": { - "firmware-version": { - "datatype": "string", - "name": "Firmware version" - }, - "model": { - "datatype": "string", - "name": "Model" - }, - "nominal-power": { - "datatype": "float", - "name": "Nominal power", - "unit": "W" - }, - "serial-number": { - "datatype": "string", - "name": "Serial number" - }, - "vendor-name": { - "datatype": "string", - "name": "Vendor name" - } - }, - "type": "energy.ebus.capability.info" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.pv", - "version": 1787808834691 - }, - "sim-40t-001-sim-evse-sim-40t-001": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "SPAN Drive - Garage", - "nodes": { - "config": { - "name": "config", - "properties": { - "max-charge-current": { - "datatype": "integer", - "name": "Commissioned maximum EVSE charge current (installer-configured)", - "unit": "A" - }, - "user-max-charge-current": { - "datatype": "integer", - "name": "User-configured maximum EVSE charge current (ceiling)", - "settable": true, - "unit": "A" - } - }, - "type": "energy.ebus.capability.config" - }, - "info": { - "name": "info", - "properties": { - "firmware-version": { - "datatype": "string", - "name": "Firmware version" - }, - "model": { - "datatype": "string", - "name": "Model" - }, - "part-number": { - "datatype": "string", - "name": "Part number" - }, - "serial-number": { - "datatype": "string", - "name": "Serial number" - }, - "vendor-name": { - "datatype": "string", - "name": "Vendor name" - } - }, - "type": "energy.ebus.capability.info" - }, - "meter": { - "name": "meter", - "properties": { - "advertised-current": { - "datatype": "float", - "name": "Current EVSE is advertising to the EV", - "unit": "A" - } - }, - "type": "energy.ebus.capability.meter" - }, - "status": { - "name": "status", - "properties": { - "status": { - "datatype": "enum", - "format": "AVAILABLE,PREPARING,CHARGING,UNAVAILABLE", - "name": "Status" - } - }, - "type": "energy.ebus.capability.status" - }, - "switch": { - "name": "switch", - "properties": { - "lock-state": { - "datatype": "enum", - "format": "UNLOCKED,LOCKED", - "name": "Lock state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.evse", - "version": 1787808834691 - }, - "sim-40t-001-sim-evse-sim-40t-001-2": { - "children": [], - "extensions": [], - "homie": "5.0", - "name": "SPAN Drive - Driveway", - "nodes": { - "config": { - "name": "config", - "properties": { - "max-charge-current": { - "datatype": "integer", - "name": "Commissioned maximum EVSE charge current (installer-configured)", - "unit": "A" - }, - "user-max-charge-current": { - "datatype": "integer", - "name": "User-configured maximum EVSE charge current (ceiling)", - "settable": true, - "unit": "A" - } - }, - "type": "energy.ebus.capability.config" - }, - "info": { - "name": "info", - "properties": { - "firmware-version": { - "datatype": "string", - "name": "Firmware version" - }, - "model": { - "datatype": "string", - "name": "Model" - }, - "part-number": { - "datatype": "string", - "name": "Part number" - }, - "serial-number": { - "datatype": "string", - "name": "Serial number" - }, - "vendor-name": { - "datatype": "string", - "name": "Vendor name" - } - }, - "type": "energy.ebus.capability.info" - }, - "meter": { - "name": "meter", - "properties": { - "advertised-current": { - "datatype": "float", - "name": "Current EVSE is advertising to the EV", - "unit": "A" - } - }, - "type": "energy.ebus.capability.meter" - }, - "status": { - "name": "status", - "properties": { - "status": { - "datatype": "enum", - "format": "AVAILABLE,PREPARING,CHARGING,UNAVAILABLE", - "name": "Status" - } - }, - "type": "energy.ebus.capability.status" - }, - "switch": { - "name": "switch", - "properties": { - "lock-state": { - "datatype": "enum", - "format": "UNLOCKED,LOCKED", - "name": "Lock state" - } - }, - "type": "energy.ebus.capability.switch" - } - }, - "parent": "sim-40t-001", - "root": "sim-40t-001", - "type": "energy.ebus.device.evse", - "version": 1787808834691 - } -} diff --git a/tests/reference_payloads/parent_child_tree.json b/packages/schema-1/src/span_panel_api_schema_1/reference/parent_child_tree.json similarity index 95% rename from tests/reference_payloads/parent_child_tree.json rename to packages/schema-1/src/span_panel_api_schema_1/reference/parent_child_tree.json index 42a5e79..4408e2b 100644 --- a/tests/reference_payloads/parent_child_tree.json +++ b/packages/schema-1/src/span_panel_api_schema_1/reference/parent_child_tree.json @@ -1,6 +1,6 @@ { "0ab966b95f92a6a51ec548485aa85f54": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298458, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675280, \"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", @@ -18,7 +18,7 @@ "switch/relay-requester": "NONE" }, "573066aaddd7b75114c4563ce3af18c4": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675280, \"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", @@ -39,7 +39,7 @@ "switch/relay-requester": "CONFIGURATION" }, "62d0e03897b337b57101aae82f1e9ba2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298458, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675280, \"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", @@ -60,7 +60,7 @@ "switch/relay-requester": "NONE" }, "acf35888f35522c721501d35e66503e6": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"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\"}}}, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675280, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Pool Pump\", \"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\"}}}, \"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", @@ -78,7 +78,7 @@ "switch/relay-requester": "NONE" }, "bess": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675281, \"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", @@ -92,7 +92,7 @@ "status/communication-state": "OK" }, "bess-mid": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675281, \"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", @@ -104,7 +104,7 @@ "info/vendor-name": "Span" }, "d3724e0d660ba506aa79c1cafe5d1181": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298458, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675280, \"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", @@ -122,7 +122,7 @@ "switch/relay-requester": "NONE" }, "evse": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675281, \"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", @@ -136,7 +136,7 @@ "switch/lock-state": "LOCKED" }, "evse-2": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675281, \"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", @@ -150,7 +150,7 @@ "switch/lock-state": "UNLOCKED" }, "example-40t-001": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"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\", \"acf35888f35522c721501d35e66503e6\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675281, \"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\", \"acf35888f35522c721501d35e66503e6\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", "$state": "ready", "breaker/rating": "200", "door/state": "CLOSED", @@ -198,7 +198,7 @@ "status/wifi-ssid": "example-wifi" }, "fe8b85c15bc9610c1b8b4ebc6f82488d": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298458, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675280, \"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", @@ -219,7 +219,7 @@ "switch/relay-requester": "NONE" }, "lugs-downstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675281, \"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": "-4617.0", @@ -229,7 +229,7 @@ "meter/imported-energy": "64.55" }, "lugs-upstream": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298459, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675281, \"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", @@ -242,7 +242,7 @@ "meter/imported-energy": "64.55" }, "pv": { - "$description": "{\"homie\": \"5.0\", \"version\": 1787869298460, \"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\": []}", + "$description": "{\"homie\": \"5.0\", \"version\": 1787880675281, \"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", 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 2271280..0873b4b 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 @@ -8,37 +8,7 @@ }, "spec_repo": "https://github.com/electrification-bus/specification", "synced_commit": "7ee7ca93b19c3de3d61be44f01887ba9557dd803", - "synced_date": "2026-08-21", "framework": "0.9", - "peers": { - "panelbench": { - "repo": "https://github.com/SpanPanel/panelbench", - "ref": "main", - "role": "publisher", - "commit": "8d1895025a6e239ab0778dede3f20c7eb5e8853d", - "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": "171bb94f0960ccd2f62282c83ec203017bd6aa7f", - "tag": "v0.8.0", - "distribution": "ebus-panel-sim", - "version": "0.8.0", - "synced_commit": "7ee7ca93b19c3de3d61be44f01887ba9557dd803", - "capture_script": "scripts/capture_parent_child_reference.py", - "manifest": "scripts/reference_panel.yaml", - "produces": { - "tree": "tests/reference_payloads/parent_child_tree.json" - } - } - }, "implements": { "capabilities": { "breaker": "0.2", @@ -68,5 +38,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. 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." + "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. NO PEERS: the producer this parser is developed against is `ebus-panel-sim`, an ordinary pinned dev dependency in the root pyproject.toml. It is the eBus specification's own executable publisher -- same organisation that writes the spec, conformed against live panel output -- so testing against it is testing against the specification in runnable form, and pip is the whole mechanism. Following a release is bump the pin, re-run scripts/capture_parent_child_reference.py, run the suite; the suite is what says whether the wire moved. PROVENANCE: packages/schema-1/spec/catalogs/*.json are byte copies of the specification's capabilities/. Fifteen of the sixteen are verified byte-for-byte against the installed emitter wheel's own ebus_panel_sim/wire/catalogs/, which always runs because the source is installed rather than cloned. grid-forming.json is the exception -- the emitter models the BESS as one device with no inverter child, so it publishes no grid-forming capability and ships no catalog for it; that copy is vendored from the specification at synced_commit and has no automatic source. spec/registries/device-types.md is the same case: the wheel does not carry it. The emitter ships no .ebus-spec.json either, so synced_commit above is a record of what this parser was written against and nothing compares it to the emitter's. 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/pyproject.toml b/pyproject.toml index 23a4e8f..8afbb5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,12 @@ dev = [ # two distributions together, which is the configuration users will run. "span-panel-api-schema-0", "span-panel-api-schema-1", + # The producer of `span_panel_api_schema_1/reference/parent_child_tree.json`, pinned + # exactly because a reference capture is only evidence if what made it is + # known. Dependabot raises the bump on its own; the bump PR re-runs + # `scripts/capture_parent_child_reference.py` and the suite then says whether + # the wire moved. + "ebus-panel-sim==0.8.0", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "pytest-cov", @@ -102,7 +108,7 @@ dev = [ # transitive of `twine -> keyring -> secretstorage`, which is marked # `sys_platform == 'linux'` -- so the whole module ran in CI and silently # skipped on every macOS checkout, which reads in the summary line exactly - # like passing. See DEVELOPMENT.md, "A skip here is not a pass". + # like passing. See DEVELOPMENT.md, "A skip is still not a pass". # # Floored at 50.0.0 rather than at whatever the lock happens to hold: four # advisories cover the range below it (the highest first patched in 50.0.0), @@ -155,12 +161,12 @@ include = [ line-length = 125 # `scripts/` is excluded file by file rather than as a directory, so that a new # script is linted by default and only the ones written before that was true are -# grandfathered out. `peer_drift.py` and `_lock.py` are the ones in scope: they -# run on every commit and in CI, and the rest are hand-run tools. +# grandfathered out. `capture_parent_child_reference.py` is the one in scope: it +# produces a fixture the whole schema_1 suite is written against, and the rest are +# hand-run tools. exclude = [ "scripts/capture_flat_reference.py", "scripts/capture_live_flat.py", - "scripts/capture_parent_child_reference.py", "scripts/coverage.py", "scripts/format_markdown.py", "scripts/test_live_auth.py", @@ -176,10 +182,10 @@ exclude = [ [tool.ruff.lint.per-file-ignores] # Exclude tests from ALL linting checks (formatting still applies) "tests/**/*.py" = ["ALL"] -# Its whole output is a markdown summary on stdout, which the workflow appends to -# the job summary and the hook shows on failure. T20 is right for the library and -# wrong for a reporter. -"scripts/peer_drift.py" = ["T201"] +# Its whole output is a report on stdout naming the producer, the device count and +# where the capture landed, which is what an operator reads to decide whether to +# adopt it. T20 is right for the library and wrong for a hand-run tool. +"scripts/capture_parent_child_reference.py" = ["T201"] [tool.ruff.format] quote-style = "double" @@ -233,13 +239,12 @@ warn_unused_configs = true disallow_untyped_defs = true explicit_package_bases = true mypy_path = "src" -# Excluded by name for the reason `[tool.ruff]` gives above: the two scripts the -# drift check is made of are checked like the library, and the hand-run tools -# written before that was expected are not. +# Excluded by name for the reason `[tool.ruff]` gives above: the capture script is +# checked like the library, and the hand-run tools written before that was expected +# are not. exclude = [ "scripts/capture_flat_reference.py", "scripts/capture_live_flat.py", - "scripts/capture_parent_child_reference.py", "scripts/coverage.py", "scripts/format_markdown.py", "scripts/test_live_auth.py", diff --git a/scripts/_lock.py b/scripts/_lock.py deleted file mode 100644 index 6f86c47..0000000 --- a/scripts/_lock.py +++ /dev/null @@ -1,72 +0,0 @@ -"""`spec_lock.json`, and the shape checks that reading it honestly needs. - -Two scripts ask this file for a pin, and they ask different questions of it. -`capture_parent_child_reference.py` asks what release the reference tree is a -capture of, so it can refuse to write one taken from any other. `peer_drift.py` -asks what every producer was pinned at, so it can ask that producer whether it -has moved. Neither restates a pin — and the path to the lockfile is not a third -place to get it wrong either, so it is named here once and imported. - -`json.load` returns `object`, and a reader under strict typing is not allowed to -pretend otherwise. The helpers below are how that stays true without a `cast`: -each one narrows exactly one shape and says what it found when the shape is -wrong. A malformed lockfile is fatal in both callers — it is a file this -repository owns, and a broken one makes every pin in it a guess — so they exit -with a sentence naming the key rather than raising for a caller that has nothing -useful to do about it. -""" - -from __future__ import annotations - -from collections.abc import Mapping -import json -import pathlib - -REPO = pathlib.Path(__file__).resolve().parent.parent -LOCK = REPO / "packages" / "schema-1" / "src" / "span_panel_api_schema_1" / "spec_lock.json" - - -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 required(source: Mapping[str, object], key: str, where: str) -> object: - """One key that has to be there, reported the way everything else here is. - - Indexing straight into the mapping says the same thing as a bare `KeyError` - traceback, which is the one failure mode that makes a reader work out what - the caller wanted. Every other malformed input exits with a sentence naming - it. - """ - if key not in source: - raise SystemExit(f"{where} has no {key!r} entry") - return source[key] - - -def string(source: Mapping[str, object], key: str, where: str) -> str: - """A required key whose value the lockfile promises is a string.""" - value = required(source, key, where) - if not isinstance(value, str): - raise SystemExit(f"{where}.{key} must be a string, got {type(value).__name__}") - return value - - -def load() -> dict[str, object]: - with LOCK.open(encoding="utf-8") as handle: - document: object = json.load(handle) - return mapping(document, LOCK.name) - - -def peer(name: str, lock: Mapping[str, object] | None = None) -> dict[str, object]: - """One producer's block, by name. - - Keyed rather than positional, for the reason `spec_lock.json` says at - `peers`: every reader wants a specific producer, never "the first one". - Callers that have already read the lockfile pass it in rather than reading - it again; the ones that want a single pin and nothing else do not have to. - """ - document = load() if lock is None else lock - peers = mapping(required(document, "peers", LOCK.name), "peers") - return mapping(required(peers, name, "peers"), f"peers.{name}") diff --git a/scripts/capture_parent_child_reference.py b/scripts/capture_parent_child_reference.py index f060304..fff4466 100644 --- a/scripts/capture_parent_child_reference.py +++ b/scripts/capture_parent_child_reference.py @@ -1,39 +1,42 @@ """Capture the parent/child emitter's retained surface, without a broker. -Produces `tests/reference_payloads/parent_child_tree.json`, the schema_1 -reference tree that fifteen test modules here replay through `devices_from_tree`. -A repository fixture, not package data: it sat inside the adapter's package -directory until 3.1.0 and was carried in the wheel for it, and no consumer of -either distribution reads it at runtime. - -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/tests/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, +Produces `parent_child_tree.json`, the schema_1 reference tree the schema_1 test +modules here replay through `devices_from_tree`. It is **package data of +`span-panel-api-schema-1`**, so a downstream test suite pinned to a version of +that distribution reads the same bytes out of its own site-packages instead of +vendoring a copy and maintaining a guard to keep the copy honest. Test-support +data all the same: no runtime path in either distribution opens it. + +Run it from this repository, with this repository's environment: + + uv run python scripts/capture_parent_child_reference.py + +Writing over the shipped file is the default, because `capture()` below is what +the test suite runs and a capture nobody adopts is a capture nobody compares. +Pass a path to write somewhere else and look at it first. + +**The emitter is an ordinary pinned dev dependency.** `ebus-panel-sim` sits in +the `dev` group in `pyproject.toml`, so `ebus_panel_sim` imports out of this +environment like anything else, and that pin is the *only* statement anywhere of +which release made the shipped tree. Nothing else records it, because nothing +else has to: `capture()` is importable, and +`test_the_shipped_reference_tree_is_what_the_pinned_emitter_produces` runs it +in-process and compares the result to the committed bytes. A tree the pinned +producer does not reproduce fails the suite. Following a release is therefore two +steps: bump the pin, re-run this — and the suite says whether the wire moved. + +**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. + +What went wrong before 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. +test files. Regenerating the capture on every test run is what stops that, and it +is stronger than any written record could be. Substitutes the transport rather than reassembling the emitter: the recorder is handed to `Emitter(mqttc=...)`, the producer's own bring-your-own-transport @@ -53,14 +56,14 @@ (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`. +find in the emitter, so the manifest is committed here beside this script. **Shape-stable, not byte-stable.** Every `$description` carries a `version` minted from the wall clock when its device is built, so all fourteen move on every run. Nothing here reads it — it is Homie's own change counter — but it does mean a recapture always shows fourteen diffs, and that a diff confined to -those lines says the producer did not move. +those lines says the producer did not move. It is also the one field the +comparison test normalises away; everything else is held to the byte. """ from __future__ import annotations @@ -68,28 +71,10 @@ from collections.abc import Mapping, Sequence import hashlib import json -import os import pathlib import sys -_REPO = pathlib.Path(__file__).resolve().parent.parent -# Run as a file — which is the only way this is run, and from the emitter's -# working directory at that — the interpreter puts *this* directory on the path -# and not the repository above it, so `scripts._lock` is not importable until we -# say where the repository is. Appended rather than prepended: this process is -# somebody else's, and the emitter's own imports get to resolve first. -sys.path.append(str(_REPO)) - -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 scripts._lock import mapping as _mapping, peer, string # noqa: E402 - -from ebus_panel_sim import ( # noqa: E402 +from ebus_panel_sim import ( BESSConfig, ChargeMode, DeviceInstance, @@ -100,11 +85,28 @@ TickInputs, __version__ as PRODUCER_VERSION, ) +import yaml + +_REPO = pathlib.Path(__file__).resolve().parent.parent -MANIFEST = _REPO / "scripts" / "reference_panel.yaml" -PEER = "ebus-panel-sim" +MANIFEST = pathlib.Path(__file__).resolve().parent / "reference_panel.yaml" +"""The capture's input, and there is one copy of it. -OUT = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path("parent_child_capture.json") +Beside this script rather than in the schema-1 package data, because only this +module reads it: `capture()` takes the path so a caller can drive a different +panel, and the default is the file this repository commits. Shipping it would be +a second copy of an input nothing downstream runs. +""" + +SHIPPED = _REPO / "packages" / "schema-1" / "src" / "span_panel_api_schema_1" / "reference" / "parent_child_tree.json" +"""Where the committed capture lives: package data of `span-panel-api-schema-1`. + +Named here because writing it is this script's whole purpose and because +`test_the_shipped_reference_tree_is_what_the_pinned_emitter_produces` regenerates +it in-process and compares. That test is why no file records which release made +these bytes: the pin in `pyproject.toml` is the only statement, and the +comparison is what holds it true. +""" _ID_NAMESPACE = "panel-sim-example" _VALID_RELAY_BEHAVIORS = frozenset({"controllable", "non-controllable", "always-on"}) @@ -114,13 +116,21 @@ # --------------------------------------------------------------------------- # Reading YAML without giving up on types # -# The mapping check itself is `scripts/_lock.py`'s, because reading the lockfile -# needs the same one and two copies of it would be two things to keep honest. -# What is below is the rest of the manifest's vocabulary, which only this script -# reads. +# `yaml.safe_load` returns `object`, and a reader under strict typing is not +# allowed to pretend otherwise. Each helper below narrows exactly one shape and +# says what it found when the shape is wrong. A malformed manifest is fatal — +# it is a file this repository owns, and a broken one makes the whole capture a +# guess — so they exit with a sentence naming the key rather than raising for a +# caller that has nothing useful to do about it. # --------------------------------------------------------------------------- +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 {} @@ -170,22 +180,6 @@ def circuit_id(source_id: str) -> str: 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. - """ - return string(peer(PEER), "version", f"peers.{PEER}") - - # --------------------------------------------------------------------------- # The manifest # --------------------------------------------------------------------------- @@ -363,9 +357,7 @@ def manifest(profile: Profile) -> DeviceManifest: 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(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)) @@ -467,19 +459,16 @@ def as_capture(retained: dict[str, str]) -> dict[str, dict[str, str]]: 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 this repository " - "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 " - "tests/reference_payloads/README.md, in the same change." - ) +def capture(manifest_path: pathlib.Path = MANIFEST) -> dict[str, dict[str, str]]: + """Drive the installed emitter over a manifest and return the retained tree. - profile = Profile(MANIFEST) + The capture itself, with no filesystem side effect, so the test suite can run + it in-process and compare the result against the bytes this repository ships. + That comparison is the whole provenance mechanism now: a shipped tree the + pinned producer does not reproduce is a test failure rather than a claim in a + document nobody re-reads. + """ + profile = Profile(manifest_path) recorder = RecordingTransport() emitter = Emitter(manifest(profile), SetterRegistry(), mqttc=recorder, bess_configs=bess_config(profile)) emitter.start() @@ -488,26 +477,37 @@ def main() -> None: 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) + recorded = 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, {}) + body = recorded.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") + return recorded + + +def serialise(recorded: Mapping[str, Mapping[str, str]]) -> str: + """The on-disk form, so the writer and the comparison cannot disagree on it.""" + return json.dumps(recorded, indent=2, sort_keys=True) + "\n" + - OUT.write_text(json.dumps(capture, indent=2, sort_keys=True) + "\n") +def main(argv: Sequence[str]) -> None: + out = pathlib.Path(argv[0]) if argv else SHIPPED + recorded = capture() + out.write_text(serialise(recorded)) - topics = sum(len(value) for value in capture.values()) + topics = sum(len(value) for value in recorded.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)) + print(f"devices: {len(recorded)} topics: {topics} -> {out}") + print("device ids:", sorted(recorded)) -main() +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/scripts/peer_drift.py b/scripts/peer_drift.py deleted file mode 100644 index 9289de0..0000000 --- a/scripts/peer_drift.py +++ /dev/null @@ -1,856 +0,0 @@ -"""Has a producer moved past the pin? - -`spec_lock.json` records three producers this repository copies from or is -generated by: the eBus **specification**, whose capability catalogs are vendored -under `packages/schema-1/spec/`; **panelbench**, whose captures are byte-copied -into that same directory; and the **eBus emitter**, a released distribution that -produced the reference tree twenty test modules replay. Any of them can move -without a file here being touched, and when one does the pin stops describing a -producer and starts describing a moment. - -The provenance tests answer the other half of the question — do our bytes still -match the commit we claim they came from — and they answer it against sibling -checkouts, which says nothing about what a producer has *published* since. This -asks the producers themselves, over the network and with no checkout at all: -PyPI for the emitter's latest release, GitHub's compare API for what panelbench -changed, `git ls-remote` for where a branch is. - -It is a script rather than a workflow step because it runs in two places. -`.github/workflows/peer-drift.yml` runs it daily under `--strict`, which is the -backstop. `.pre-commit-config.yaml` runs it on every commit, which is where it is -meant to catch things: the drift this exists to prevent was a producer release -that nobody here noticed until a scheduled job went red, and a check only CI runs -is a check that finds out late. - -**A verdict, or context.** Two comparisons are verdicts, and both are verdicts -about bytes in this repository having gone stale: - -- the emitter's *release*, because the reference tree is a capture of one and - `capture_parent_child_reference.py` refuses to write a capture from any other - version. Commits on the emitter's branch are unreleased work, reported because - they are what a release will be made of, never a call to action. -- a panelbench commit that touches a file we vendor. Commits that touch nothing - we copy are reported and stay green: a README commit there is not a defect - here. - -The specification is context only and never a verdict. It says what a device -class *may* publish, the vendored catalogs are byte-compared against -`synced_commit` by `ci.yml`, and a specification that has moved is something to -read rather than something to fix. - -**Not having asked is not the same fact as there being nothing new.** A producer -that could not be reached is reported as UNKNOWN, loudly, and exits 0 — a laptop -on a plane still has to be able to commit. `--strict` turns unknown into a -failure, because in CI there is no such excuse. - -Nothing here restates a pin, a path or a repository. Every one of them is read -from `spec_lock.json`, which is their single home; a constant in this file would -be a second one, agreeing right up until the day somebody moved only one. -""" - -from __future__ import annotations - -import argparse -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass -import json -import os -import pathlib -import subprocess -import sys -from typing import Literal, Protocol, TextIO -import urllib.error -import urllib.parse -import urllib.request - -_REPO = pathlib.Path(__file__).resolve().parent.parent -# Run as a file (`python scripts/peer_drift.py`) the interpreter puts *this* -# directory on the path, not the repository, so the sibling module holding the -# lockfile reader is not importable as `scripts._lock` until we say where the -# repository is. Imported as `scripts.peer_drift`, which is how the tests read -# it, the entry is already there and this is a no-op. -if str(_REPO) not in sys.path: - sys.path.append(str(_REPO)) - -from scripts._lock import LOCK, REPO, load, mapping, peer as pinned_peer, required, string # noqa: E402 - -PANELBENCH = "panelbench" -"""The SPAN-side publisher this parser is developed against, and the one whose -captures are byte-copied into this repository.""" - -PANEL_SIM = "ebus-panel-sim" -"""The eBus specification's own executable publisher, and the producer of the -reference tree. A released distribution, which is why its release is the -verdict.""" - -SPECIFICATION = "specification" -"""Not a peer in `spec_lock.json` — it is the document both peers implement, and -it is pinned at the top level as `spec_repo` / `synced_commit`.""" - -TIMEOUT = 10.0 -"""Seconds per network call. Short deliberately: this runs in a pre-commit hook, -and a producer that has not answered in ten seconds is a producer we will report -as unknown rather than one worth waiting for.""" - -COMPARE_FILE_LIMIT = 300 -"""GitHub's compare API lists at most this many changed files. A comparison that -hits the limit cannot be read as "nothing we vendor changed", because the file -that did may be the one the API declined to name — so it is reported as unknown, -not as current.""" - -EBUS_SPEC_FILE = ".ebus-spec.json" -"""The producer's own record of which specification commit it implements. -Not vendored, but read by `test_the_peer_record_matches_the_simulator_lockfile`, -so a change to it is a change to something this repository depends on.""" - -VENDORED = { - "tree": "packages/schema-1/spec/fixtures/simulator_tree.json", - "wire": "packages/schema-1/spec/fixtures/simulator_wire.json", -} -"""Where each panelbench capture is copied *to*. The paths it is copied *from* -are the pin's own (`peers.panelbench.fixtures`) and are read from the lockfile; -these are paths inside this repository, which that peer's block deliberately does -not record — see `spec_lock.json`'s note on why `fixtures` and `produces` are two -keys rather than one.""" - -GITHUB_API = "https://api.github.com" -PYPI_JSON = "https://pypi.org/pypi" - -Verdict = Literal["current", "drift", "unknown"] - - -class Unreachable(Exception): - """A producer could not be asked. - - Deliberately not an answer. Every caller turns this into `unknown` and says - so in the summary, because the alternative — treating silence as agreement — - is how a check reports green on the day it had nothing to report at all. - """ - - -class HttpGet(Protocol): - """Fetch a URL, or raise `Unreachable`. Injected so tests never leave the machine.""" - - def __call__(self, url: str) -> str: ... - - -class LsRemote(Protocol): - """Resolve a ref in a remote repository to a commit, or raise `Unreachable`.""" - - def __call__(self, repo: str, ref: str) -> str: ... - - -@dataclass(frozen=True) -class Producer: - """One producer, asked and answered. - - `pinned` and `observed` are the same fact as `detail`, in the currency the - comparison was made in — a release for the emitter, a commit for the other - two. The prose is what a person reads in a job summary; these are what a test - can assert on without matching sentences. - """ - - name: str - pinned: str - observed: str - verdict: Verdict - detail: str - advisory: bool = False - """Reported, never counted. Set for the specification, whose movement is - context for reading a disagreement rather than a reason to change anything - here, and so must not fail a commit or a job.""" - - def section(self) -> str: - return f"## {self.name}\n\n{self.detail.rstrip()}\n" - - -@dataclass(frozen=True) -class Comparison: - """GitHub's answer to `compare/...`, narrowed to what we read. - - `behind_by` rather than the API's own `status` word: what we need to know is - whether the pin is an ancestor of the branch, and a non-zero count of commits - the base has and the head does not is exactly that question answered. - """ - - ahead_by: int - behind_by: int - files: tuple[str, ...] - - @property - def capped(self) -> bool: - return len(self.files) >= COMPARE_FILE_LIMIT - - -@dataclass(frozen=True) -class Index: - """What PyPI says about a distribution, narrowed to the two things we ask. - - `latest` alone was not enough. It answers "is there something newer?" only if - the pin is still a release PyPI serves, and it is not the same question as - "is what we pinned still installable" — a yanked or withdrawn release leaves - `latest` looking perfectly ordinary while the reference tree describes - something nobody can get. - """ - - latest: str - pinned_files: int | None - """How many files the index lists for the pinned release; `None` when it does - not list that release at all.""" - pinned_yanked: bool - """Every file of the pinned release withdrawn. PyPI yanks per file, so this - is the whole release only when they all carry the flag.""" - - -# --------------------------------------------------------------------------- -# Asking, over the network -# --------------------------------------------------------------------------- - - -def _headers(url: str) -> dict[str, str]: - """Authenticate to the GitHub API when a token is around, and nowhere else. - - CI has one and wants the higher rate limit; a developer machine usually does - not, and 60 unauthenticated requests an hour is ample for a hook that makes - at most one such request per commit. The host check is not decoration: a - credential sent to a service that did not ask for it is a credential - disclosed, and PyPI has no use for a GitHub token. - """ - if not url.startswith(GITHUB_API): - return {} - headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} - token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") or "" - if token: - headers["Authorization"] = f"Bearer {token}" - return headers - - -def http_get(url: str) -> str: - request = urllib.request.Request(url, headers=_headers(url)) - try: - with urllib.request.urlopen(request, timeout=TIMEOUT) as response: - body: bytes = response.read() - except urllib.error.HTTPError as error: - if error.code in (403, 429) and error.headers.get("x-ratelimit-remaining") == "0": - raise Unreachable( - "GitHub's rate limit is spent; set GH_TOKEN to raise it from the unauthenticated 60/hr" - ) from error - raise Unreachable(f"HTTP {error.code} {error.reason}") from error - except (urllib.error.URLError, TimeoutError, OSError) as error: - raise Unreachable(str(error)) from error - return body.decode("utf-8", errors="replace") - - -def ls_remote(repo: str, ref: str) -> str: - """The commit a remote ref names, without cloning anything. - - `GIT_TERMINAL_PROMPT=0` because this runs in a pre-commit hook: a repository - that has gone private must report as unreachable rather than stop a commit at - a password prompt nobody is watching for. - """ - environment = dict(os.environ, GIT_TERMINAL_PROMPT="0") - try: - completed = subprocess.run( - ["git", "ls-remote", repo, ref], - capture_output=True, - text=True, - timeout=TIMEOUT, - check=False, - env=environment, - ) - except subprocess.TimeoutExpired as error: - raise Unreachable(f"git ls-remote did not answer within {TIMEOUT:.0f}s") from error - except OSError as error: - raise Unreachable(str(error)) from error - if completed.returncode != 0: - detail = completed.stderr.strip().splitlines() - raise Unreachable(detail[-1] if detail else f"git ls-remote exited {completed.returncode}") - fields = completed.stdout.split() - if not fields: - raise Unreachable(f"{repo} has no {ref}") - return fields[0] - - -# --------------------------------------------------------------------------- -# Reading what came back, which is somebody else's JSON -# --------------------------------------------------------------------------- - - -def _answer(body: str, who: str) -> dict[str, object]: - try: - loaded: object = json.loads(body) - except json.JSONDecodeError as error: - raise Unreachable(f"{who} did not answer with JSON ({error})") from error - if not isinstance(loaded, Mapping): - raise Unreachable(f"{who} answered with {type(loaded).__name__}, not an object") - return {str(key): value for key, value in loaded.items()} - - -def _member(source: Mapping[str, object], key: str, who: str) -> dict[str, object]: - value = source.get(key) - if not isinstance(value, Mapping): - raise Unreachable(f"{who} answered with no {key!r} object") - return {str(inner): item for inner, item in value.items()} - - -def _field(source: Mapping[str, object], key: str, who: str) -> str: - value = source.get(key) - if not isinstance(value, str): - raise Unreachable(f"{who} answered with no {key!r} string") - return value - - -def _count(source: Mapping[str, object], key: str, who: str) -> int: - value = source.get(key) - if isinstance(value, bool) or not isinstance(value, int): - raise Unreachable(f"{who} answered with no {key!r} count") - return value - - -def _objects(listed: object, key: str, who: str) -> list[dict[str, object]]: - """A list of JSON objects, which is the shape both producers answer files in.""" - if not isinstance(listed, Sequence) or isinstance(listed, str | bytes): - raise Unreachable(f"{who} answered with a {key!r} that is not a list") - entries: list[dict[str, object]] = [] - for entry in listed: - if not isinstance(entry, Mapping): - raise Unreachable(f"{who} answered with a {key!r} entry that is not an object") - entries.append({str(inner): value for inner, value in entry.items()}) - return entries - - -def _filenames(source: Mapping[str, object], who: str) -> tuple[str, ...]: - """The changed files, which the API omits entirely when there are none.""" - return tuple(_field(entry, "filename", who) for entry in _objects(source.get("files", []), "files", who)) - - -# --------------------------------------------------------------------------- -# Comparing, which is pure -# --------------------------------------------------------------------------- - - -def slug(url: str) -> str: - """owner/name, which is what the GitHub API addresses a repository by.""" - return url.removeprefix("https://github.com/").removesuffix(".git") - - -def short(commit: str) -> str: - return commit[:12] - - -def fixtures(peer: Mapping[str, object]) -> dict[str, str]: - """The captures byte-copied out of panelbench, by kind, as the pin names them. - - Paths *inside panelbench*: `spec_lock.json` records this peer's artifacts - under `fixtures` for exactly that reason, and the other peer's under - `produces`, whose paths are inside this repository. - """ - listed = mapping(required(peer, "fixtures", f"peers.{PANELBENCH}"), f"peers.{PANELBENCH}.fixtures") - return {str(kind): str(path) for kind, path in listed.items()} - - -def watched(peer: Mapping[str, object]) -> tuple[str, ...]: - """The files in panelbench whose movement is our problem. - - The captures we byte-copy, named by the pin, plus the producer's own record - of the specification commit it implements. Everything else in that repository - may change freely. - """ - return (*sorted(fixtures(peer).values()), EBUS_SPEC_FILE) - - -def touched(changed: Sequence[str], watch: Sequence[str]) -> tuple[str, ...]: - """Those of `watch` the producer changed, in the order `watch` names them.""" - changes = set(changed) - return tuple(path for path in watch if path in changes) - - -def _release_numbers(version: str) -> tuple[int, ...] | None: - """The dotted integers of a version, or `None` when it is not only those. - - No `packaging` here, and not from thrift: the emitter job runs this under the - runner's system `python3`, before uv has installed anything, so the standard - library is the whole toolbox. Dotted integers are the part of a version this - can order correctly; anything with a pre-release, post or local suffix is - handed back as unorderable rather than guessed at. - """ - parts = version.split(".") - if not all(part.isdigit() for part in parts): - return None - return tuple(int(part) for part in parts) - - -def newer_release(candidate: str, than: str) -> bool | None: - """Whether `candidate` is a later release than `than`; `None` if unanswerable. - - Three-valued on purpose. "I cannot order these two" is a different fact from - "this one is not newer", and only one of them is a reason to leave the pin - alone quietly. - """ - left = _release_numbers(candidate) - right = _release_numbers(than) - if left is None or right is None: - return None - width = max(len(left), len(right)) - return _padded(left, width) > _padded(right, width) - - -def _padded(numbers: tuple[int, ...], width: int) -> tuple[int, ...]: - """A two-segment version and a three-segment one can name the same release, - so both are compared at the wider of the two.""" - return numbers + (0,) * (width - len(numbers)) - - -def pin_problem(index: Index, pinned: str) -> tuple[str, str] | None: - """Why the pinned release cannot be compared against, headline and reason. - - Checked before `latest` is looked at, because these hold whatever `latest` - says: a pin PyPI does not serve is not a pin that "has not drifted", and - reporting it as current would be the check quietly agreeing with a release - that is no longer there. - """ - if index.pinned_files is None: - return ( - f"PyPI lists no release `{pinned}` at all", - "The pin names a version this project has not published, or one that has since " - "been removed from the index. Nothing can be compared against it, and the " - "reference tree claims to be a capture of it.", - ) - if index.pinned_files == 0: - return ( - f"PyPI lists release `{pinned}` with no files", - "The release exists in the index with nothing left to install under it, so the " - "version the reference tree names cannot be obtained or re-captured from.", - ) - if index.pinned_yanked: - return ( - f"the release we pin (`{pinned}`) has been yanked", - "The producer withdrew it. A capture taken from a yanked release describes a " - "producer nobody should install, whether or not anything newer exists — read the " - "emitter's reason for the yank before deciding which release to move the pin to.", - ) - return None - - -def released_tag(peer: Mapping[str, object], version: str) -> str: - """`version` spelled the way that producer tags a release. - - Derived from the pinned pair rather than assumed: the lockfile records both - `tag` and `version`, so whatever prefix the producer puts on one is readable - from the other. - """ - tag = string(peer, "tag", f"peers.{PANEL_SIM}") - pinned = string(peer, "version", f"peers.{PANEL_SIM}") - return f"{tag.removesuffix(pinned)}{version}" if tag.endswith(pinned) else version - - -def unasked(lock: Mapping[str, object]) -> tuple[str, ...]: - """Producers the lockfile pins that this script has no way to ask about. - - A new peer added to `spec_lock.json` and not here would be silently exempt - from the only check that notices it moving, which is the failure this whole - script exists to prevent, one level up. - """ - peers = mapping(required(lock, "peers", LOCK.name), "peers") - return tuple(sorted(set(peers) - set(BUILDERS))) - - -def unvendored(lock: Mapping[str, object]) -> tuple[str, ...]: - """Capture kinds the pin names that this script knows no destination for. - - `VENDORED` is the one thing here that is not read from the lockfile, because - the destinations are paths in this repository and that peer's block records - only its sources. A kind in one and not the other would still be *detected* - as drift — `watched` reads the pin — but the summary would go quiet about - where to copy it, which is the half a reader actually acts on. - """ - return tuple(sorted(set(fixtures(pinned_peer(PANELBENCH, lock))) - set(VENDORED))) - - -def _unknown(who: str, error: Exception) -> str: - return ( - f"**UNKNOWN — could not ask {who}:** `{error}`\n\n" - "Not having asked is not the same fact as there being nothing new, so this is " - "reported rather than passed over. It does not fail a commit; `--strict`, which " - "CI passes, makes it fail there, where being unable to reach a producer is a " - "problem with the run rather than with the network in a coffee shop." - ) - - -def _inconclusive(headline: str, why: str) -> str: - """Asked, answered, and the answer is not a verdict. - - A different shape of unknown from `_unknown`: the producer replied, and what - it said cannot be read as either "the capture is current" or "the capture is - stale". Reporting it as drift would attach instructions that make things - worse — re-capturing from a release that is older, withdrawn or absent. - """ - return ( - f"**UNKNOWN — {headline}.**\n\n{why}\n\n" - "Neither current nor drift, so it is reported rather than acted on. `--strict`, " - "which CI passes, fails on it; a commit is not stopped by it." - ) - - -# --------------------------------------------------------------------------- -# The producers, one shape each -# --------------------------------------------------------------------------- - - -def emitter(lock: Mapping[str, object], get: HttpGet, ls: LsRemote) -> Producer: - """The released distribution the reference tree was captured from. - - The release is the verdict and the branch is context, because the capture - script gates on a release: it refuses to write a tree taken from any version - other than the pinned one, so unreleased commits cannot produce a capture we - would accept even if we wanted one. - """ - peer = pinned_peer(PANEL_SIM, lock) - distribution = string(peer, "distribution", f"peers.{PANEL_SIM}") - pinned = string(peer, "version", f"peers.{PANEL_SIM}") - context = _emitter_commits(peer, ls) - who = f"PyPI for {distribution}" - - try: - index = _index(get(f"{PYPI_JSON}/{urllib.parse.quote(distribution)}/json"), pinned, who) - except Unreachable as error: - return Producer(PANEL_SIM, pinned, "", "unknown", f"{_unknown(who, error)}\n\n{context}") - - verdict, detail = _release_reading(peer, pinned, index) - return Producer(PANEL_SIM, pinned, index.latest, verdict, f"{detail}\n\n{context}") - - -def _index(body: str, pinned: str, who: str) -> Index: - """PyPI's answer, read once for both questions we ask of it.""" - answer = _answer(body, who) - latest = _field(_member(answer, "info", who), "version", who) - listed = _member(answer, "releases", who).get(pinned) - if listed is None: - return Index(latest, None, pinned_yanked=False) - files = _objects(listed, f"releases.{pinned}", who) - return Index(latest, len(files), pinned_yanked=bool(files) and all(entry.get("yanked") is True for entry in files)) - - -def _release_reading(peer: Mapping[str, object], pinned: str, index: Index) -> tuple[Verdict, str]: - """The verdict on the pinned release, and what to say about it. - - Only one branch here is drift, and it is the narrow one: a release that - exists, is not withdrawn, and is genuinely later than ours. Everything else - the index can say — a pin it does not serve, a pin it has yanked, a `latest` - that is older or that carries a suffix this cannot order — is a disagreement - we have no safe instruction for, so it is reported and not acted on. - """ - problem = pin_problem(index, pinned) - if problem is not None: - return "unknown", _inconclusive(*problem) - if index.latest == pinned: - return "current", f"Latest release is `{index.latest}`, which is what we pin. The reference tree is current." - - newer = newer_release(index.latest, pinned) - if newer is None: - return "unknown", _inconclusive( - f"PyPI's latest (`{index.latest}`) cannot be ordered against the pin (`{pinned}`)", - "One of them carries a pre-release, post-release or local suffix. Ordering dotted " - "integers is what the standard library gives us, and guessing at the rest would make " - "a verdict out of a guess — read the emitter's releases and move the pin by hand.", - ) - if not newer: - return "unknown", _inconclusive( - f"PyPI's latest (`{index.latest}`) is not newer than the pin (`{pinned}`)", - "The index serves an older release than the one we pin — a newer release withdrawn " - "after we pinned it, or an index that has not caught up. Re-capturing would take the " - "reference tree backwards, which is why this is not reported as drift.", - ) - return "drift", _emitter_drift(peer, pinned, index.latest) - - -def _emitter_drift(peer: Mapping[str, object], pinned: str, latest: str) -> str: - """What to do about a reference tree captured from a superseded release.""" - capture = string(peer, "capture_script", f"peers.{PANEL_SIM}") - produces = mapping(required(peer, "produces", f"peers.{PANEL_SIM}"), f"peers.{PANEL_SIM}.produces") - tree = str(required(produces, "tree", f"peers.{PANEL_SIM}.produces")) - return ( - f"Latest release is `{latest}`, we pin `{pinned}`.\n\n" - "The reference tree was captured from the pinned release, so it now describes a\n" - "producer that has been superseded. To follow:\n\n" - "```bash\n" - f"# in a checkout of the emitter at {released_tag(peer, latest)}, from its own environment\n" - f"uv run python ../{REPO.name}/{capture} \\\n" - f" ../{REPO.name}/{tree}\n" - "```\n\n" - f"The script refuses until `peers.{PANEL_SIM}.version` and `.commit` in\n" - f"`{LOCK.name}` name the release you captured from, which is what keeps the\n" - "bytes and the claim about them from drifting apart. Read the emitter's CHANGELOG\n" - "for the wire changes before accepting the new capture: a diff confined to each\n" - "`$description`'s `version` means nothing moved." - ) - - -def _emitter_commits(peer: Mapping[str, object], ls: LsRemote) -> str: - """Where the emitter's branch is, which is context and says so.""" - repo = string(peer, "repo", f"peers.{PANEL_SIM}") - ref = string(peer, "ref", f"peers.{PANEL_SIM}") - commit = string(peer, "commit", f"peers.{PANEL_SIM}") - heading = f"### {slug(repo)} commits" - try: - head = ls(repo, f"refs/heads/{ref}") - except Unreachable as error: - return f"{heading}\n\nCould not ask where `{ref}` is (`{error}`)." - if head == commit: - return f"{heading}\n\n`{ref}` is at `{short(commit)}`, the commit we pin." - return ( - f"{heading}\n\n" - f"`{ref}` is at `{short(head)}`, we pin `{short(commit)}` — the branch head has\n" - "moved past the pin. Unreleased work, so not itself a reason to recapture; the\n" - "release comparison above is. How many commits, and which, needs a clone — the\n" - "scheduled `peer-drift.yml` run has one and lists them." - ) - - -def panelbench(lock: Mapping[str, object], get: HttpGet, ls: LsRemote) -> Producer: - """The repository whose captures are byte-copied here. - - Two questions, cheapest first. `git ls-remote` says whether the branch has - moved at all, and on the ordinary day — the pin at the branch head — that is - the whole answer and no API request is made. Only a branch that has moved is - worth spending a compare on, and only then does *what* it changed matter. - """ - peer = pinned_peer(PANELBENCH, lock) - repo = string(peer, "repo", f"peers.{PANELBENCH}") - ref = string(peer, "ref", f"peers.{PANELBENCH}") - commit = string(peer, "commit", f"peers.{PANELBENCH}") - name = slug(repo) - who = f"GitHub what {name} changed" - - try: - head = ls(repo, f"refs/heads/{ref}") - except Unreachable as error: - return Producer(PANELBENCH, commit, "", "unknown", _unknown(f"{name} where `{ref}` is", error)) - if head == commit: - return Producer( - PANELBENCH, - commit, - head, - "current", - f"`{ref}` is at `{short(commit)}`, the commit we pin. Nothing to re-vendor.", - ) - - try: - # Against the commit `ls-remote` just resolved, not against the branch - # name: the branch could move between the two calls, and a summary that - # names one commit while comparing another is two facts pretending to be - # one. - comparison = compare(repo, commit, head, get, who) - except Unreachable as error: - return Producer(PANELBENCH, commit, head, "unknown", _unknown(who, error)) - - moved = f"`{ref}` is at `{short(head)}`, we pin `{short(commit)}`" - verdict, detail = _panelbench_reading(comparison, moved, watched(peer), fixtures(peer)) - return Producer(PANELBENCH, commit, head, verdict, detail) - - -def _panelbench_reading( - comparison: Comparison, moved: str, watch: Sequence[str], sources: Mapping[str, str] -) -> tuple[Verdict, str]: - """What the comparison means, once it has been obtained.""" - if comparison.behind_by: - detail = ( - f"**UNKNOWN — {moved}, and the pin is not an ancestor of it.**\n\n" - "The pin names a commit this branch does not contain — a branch that was\n" - f"rebased, squash-merged or deleted. `peers.{PANELBENCH}.ref` in `{LOCK.name}`\n" - "needs to name a ref the pinned commit is actually on, and until it does\n" - "nothing here can say whether what we vendor is current." - ) - return "unknown", detail - - changed = touched(comparison.files, watch) - if not changed and comparison.capped: - # Asked in this order because a file we vendor appearing in a truncated - # list is still a file we vendor having changed. Only the *absence* of one - # is unsafe to conclude from a list the API cut short. - detail = ( - f"**UNKNOWN — {moved}, {comparison.ahead_by} commits ahead, and the comparison\n" - f"named {COMPARE_FILE_LIMIT} changed files, which is all the API will list.**\n\n" - 'A truncated list cannot be read as "nothing we vendor changed", because the\n' - "file that did may be one it declined to name. Compare against a checkout — the\n" - "byte comparison in `tests/test_schema_one_conformance.py` is the answer that\n" - "does not truncate." - ) - return "unknown", detail - - if not changed: - detail = ( - f"{moved} — {comparison.ahead_by} commits ahead, nothing we copy changed.\n\n" - "Green deliberately: this repository vendors two captures and reads one\n" - "lockfile out of that branch, and a commit touching none of them is the\n" - "producer's business rather than ours." - ) - return "current", detail - - return "drift", _panelbench_drift(moved, comparison, changed, sources) - - -def _panelbench_drift(moved: str, comparison: Comparison, changed: Sequence[str], sources: Mapping[str, str]) -> str: - """What to do about a producer that changed something we keep a copy of.""" - lines = [ - f"**{moved} — {comparison.ahead_by} commits ahead, and it changed what we vendor:**", - "", - *(f"- `{path}`" for path in changed), - "", - ] - captures = sorted((source, VENDORED[kind]) for kind, source in sources.items() if source in changed) - if captures: - lines += [ - "Re-vendor and re-pin together, in one change:", - "", - "```bash", - *(f"cp $PANELBENCH_DIR/{source} \\\n {destination}" for source, destination in captures), - "```", - "", - f"then set `peers.{PANELBENCH}.commit` in `{LOCK.relative_to(REPO)}` to the", - "commit you copied from. A capture without a commit bump records where the bytes", - "came from as a guess.", - "", - ] - if EBUS_SPEC_FILE in changed: - lines += [ - f"`{EBUS_SPEC_FILE}` is the producer's own record of the specification commit it", - f"implements. The two sides are reading different vocabularies until `peers.{PANELBENCH}`'s", - "`synced_commit` and the vendored catalogs move together.", - "", - ] - lines += [ - "The byte comparison in `tests/test_schema_one_conformance.py` is what settles it,", - "and it is the same one `ci.yml` runs — this only says the producer moved first.", - ] - return "\n".join(lines) - - -def compare(repo: str, base: str, head: str, get: HttpGet, who: str) -> Comparison: - """`base...head`, as GitHub answers it, needing no clone of either.""" - url = f"{GITHUB_API}/repos/{slug(repo)}/compare/{urllib.parse.quote(base)}...{urllib.parse.quote(head)}" - answer = _answer(get(url), who) - return Comparison( - ahead_by=_count(answer, "ahead_by", who), - behind_by=_count(answer, "behind_by", who), - files=_filenames(answer, who), - ) - - -def specification(lock: Mapping[str, object], ls: LsRemote) -> Producer: - """The document both producers implement. Context, never a verdict. - - A specification that has moved does not make anything here wrong: it says - what a device class *may* publish, and what this repository vendors out of it - is byte-compared against `synced_commit` by `ci.yml`. Reported so that a - disagreement with a producer can be read against the right revision of the - document, and marked advisory so it can never fail a commit. - """ - repo = string(lock, "spec_repo", LOCK.name) - pinned = string(lock, "synced_commit", LOCK.name) - name = slug(repo) - try: - head = ls(repo, "HEAD") - except Unreachable as error: - return Producer(SPECIFICATION, pinned, "", "unknown", _unknown(f"{name} where HEAD is", error), advisory=True) - if head == pinned: - return Producer( - SPECIFICATION, - pinned, - head, - "current", - f"`{name}` is at `{short(head)}`, the commit we sync to.", - advisory=True, - ) - detail = ( - f"`{name}` is at `{short(head)}`, we sync to `{short(pinned)}`.\n\n" - "Context, not a verdict, and it fails nothing. The specification says what a device\n" - "class may publish rather than what one does, and the catalogs vendored from it are\n" - "byte-compared against the pinned commit by `ci.yml`. Worth reading when a producer\n" - "and this parser disagree — the two sides may be reading different revisions of it." - ) - return Producer(SPECIFICATION, pinned, head, "drift", detail, advisory=True) - - -BUILDERS: dict[str, Callable[[Mapping[str, object], HttpGet, LsRemote], Producer]] = { - PANELBENCH: panelbench, - PANEL_SIM: emitter, - SPECIFICATION: lambda lock, get, ls: specification(lock, ls), -} -"""One builder per producer, because they are not the same shape. Keyed by the -name the lockfile uses, so `--peer` names what `spec_lock.json` names.""" - - -# --------------------------------------------------------------------------- -# Saying it -# --------------------------------------------------------------------------- - - -def render(found: Sequence[Producer]) -> str: - return "\n".join(producer.section() for producer in found) - - -def exit_code(found: Sequence[Producer], *, strict: bool) -> int: - """1 when a producer we depend on has moved past its pin, 0 when none has. - - Unknown is 0 unless `--strict`: a network this script could not reach is not - evidence about a producer, and a commit is not the place to insist on one. - Advisory producers count for neither — see `Producer.advisory`. - """ - decisive = [producer for producer in found if not producer.advisory] - if any(producer.verdict == "drift" for producer in decisive): - return 1 - if strict and any(producer.verdict == "unknown" for producer in decisive): - return 1 - return 0 - - -def main( - argv: Sequence[str] | None = None, - *, - get: HttpGet = http_get, - ls: LsRemote = ls_remote, - out: TextIO | None = None, -) -> int: - parser = argparse.ArgumentParser( - prog="peer_drift.py", - description="Ask each producer pinned in spec_lock.json whether it has moved past the pin.", - ) - parser.add_argument( - "--strict", - action="store_true", - help="fail when a producer could not be asked, not only when one has drifted (CI passes this)", - ) - parser.add_argument( - "--peer", - choices=sorted(BUILDERS), - help="ask one producer instead of all of them", - ) - namespace = parser.parse_args(argv) - strict = bool(namespace.strict) - only = None if namespace.peer is None else str(namespace.peer) - - lock = load() - missing = unasked(lock) - if missing: - raise SystemExit( - f"{LOCK.name} pins {', '.join(missing)}, which this script has no way to ask. " - "Add a builder to scripts/peer_drift.py: a producer nobody asks about is a " - "producer that moves unnoticed, which is the whole thing this check exists to stop." - ) - unplaced = unvendored(lock) - if unplaced: - raise SystemExit( - f"peers.{PANELBENCH}.fixtures names a {', '.join(unplaced)} capture and " - "scripts/peer_drift.py records no path in this repository it is copied to. " - "Add it to VENDORED, or a drift report will name the file and not what to do about it." - ) - - asked = [name for name in (PANELBENCH, PANEL_SIM, SPECIFICATION) if only is None or name == only] - found = [BUILDERS[name](lock, get, ls) for name in asked] - print(render(found), end="", file=sys.stdout if out is None else out) - return exit_code(found, strict=strict) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/reference_panel.yaml b/scripts/reference_panel.yaml index ce2fdcb..8a94503 100644 --- a/scripts/reference_panel.yaml +++ b/scripts/reference_panel.yaml @@ -2,10 +2,10 @@ # # Read by `scripts/capture_parent_child_reference.py`, which drives the eBus # emitter (`ebus-panel-sim`) with it and records the retained topics as -# `tests/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. +# `span_panel_api_schema_1/reference/parent_child_tree.json`, package data of the +# schema-1 adapter. +# Committed here beside that script, because 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 diff --git a/scripts/verify_reconnect.py b/scripts/verify_reconnect.py index c28128e..ce5e611 100644 --- a/scripts/verify_reconnect.py +++ b/scripts/verify_reconnect.py @@ -39,7 +39,7 @@ --broker-host 127.0.0.1 --broker-port 1883 --no-tls \ --data-model-version 1.0 \ --adapter span_panel_api_schema_1:SchemaOneAdapter \ - --seed tests/reference_payloads/parent_child_tree.json + --seed packages/schema-1/src/span_panel_api_schema_1/reference/parent_child_tree.json Exits non-zero if any check fails. """ diff --git a/tests/conftest.py b/tests/conftest.py index 84fe134..af34e56 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,15 +26,15 @@ def _load_dotenv() -> None: """Populate the environment from `.env`, without overriding what is set. - Read directly rather than through python-dotenv: this supplies developer - defaults for the optional provenance checks (`EBUS_SPEC_DIR`, - `PANELBENCH_DIR`), and taking a dependency to parse two lines would put a - package in the test path to save nothing. + Read directly rather than through python-dotenv: this supplies the credentials + for the one check that needs a real panel (`LIVE_PANEL_*`), and taking a + dependency to parse four lines would put a package in the test path to save + nothing. `setdefault`, never assignment. An exported value is a deliberate choice for - this run — pointing at a different checkout to reproduce something — and a - file silently winning over it is the kind of surprise that costs an - afternoon. See `.env.example`; absence is fine, the checks skip. + this run — pointing at a second panel to reproduce something — and a file + silently winning over it is the kind of surprise that costs an afternoon. See + `.env.example`; absence is fine, `test_live_flat_differential.py` skips. """ if not _DOTENV.exists(): return diff --git a/packages/schema-1/spec/fixtures/simulator_wire.json b/tests/fixtures/panelbench_wire.json similarity index 100% rename from packages/schema-1/spec/fixtures/simulator_wire.json rename to tests/fixtures/panelbench_wire.json diff --git a/tests/fixtures/v2/README.md b/tests/fixtures/v2/README.md index 20a2c41..76e538d 100644 --- a/tests/fixtures/v2/README.md +++ b/tests/fixtures/v2/README.md @@ -10,5 +10,5 @@ Captured from a live SPAN Panel running firmware `spanos2/r202603/05`. Serial nu ## Moved -`homie_schema.json` lives at [`tests/reference_payloads/homie_schema.json`](../../reference_payloads/README.md) and is read through `reference_payloads.bootstrap.homie_schema()`. It was package data under `src/span_panel_api/` between 3.0.0 and 3.1.0; -nothing at runtime read it there, so it is an ordinary fixture again. Its provenance, schema hash and node-type table live in the README next to it. +`homie_schema.json` is package data of `span-panel-api-schema-0`, at `span_panel_api_schema_0/reference/homie_schema.json`, and is read through `reference_payloads.bootstrap.homie_schema()`. Nothing at runtime reads it; it ships so a downstream test suite +pinned to a version of that adapter reads the same bytes it was tested against. Its provenance, schema hash and node-type table live in [`tests/reference_payloads/README.md`](../../reference_payloads/README.md), beside the loader. diff --git a/tests/reference_payloads/README.md b/tests/reference_payloads/README.md index c92379a..3d32e87 100644 --- a/tests/reference_payloads/README.md +++ b/tests/reference_payloads/README.md @@ -2,8 +2,16 @@ Captures of what a panel actually serves, read by this repository's test suite through `reference_payloads.bootstrap` and `reference_payloads.schema_one`. -**These are repository fixtures, not package data.** Until 3.1.0 they sat inside `src/span_panel_api/` and `packages/schema-1/src/span_panel_api_schema_1/`, so both wheels carried them — not through any packaging declaration, but because a directory inside -a package directory ships. Nothing at runtime read them, and nothing does now. `tests/test_packaging.py` fails if a payload directory reappears inside a shipped package, and CI asserts the same against the built wheels. +**The bytes are not here.** This directory holds the loaders; each capture is package data of the adapter that parses it, and the loaders read it with `importlib.resources`: + +| Capture | Ships in | At | +| ------------------------ | ------------------------- | ---------------------------------------------------------- | +| `homie_schema.json` | `span-panel-api-schema-0` | `span_panel_api_schema_0/reference/homie_schema.json` | +| `parent_child_tree.json` | `span-panel-api-schema-1` | `span_panel_api_schema_1/reference/parent_child_tree.json` | + +**Test-support data, shipped deliberately, and never read at runtime.** No path in either distribution opens these files; a real consumer gets the schema document from the panel and the tree off a broker. They ship so that a downstream test suite pinned to +a version of an adapter reads the same bytes that version was built and tested against, out of its own site-packages. The alternative is what the integration was doing: vendoring copies, and then maintaining a guard to keep the copies honest — more +machinery than 59 KB in two wheels. That reverses the 3.1.0 decision, whose reasoning (no runtime path reads them) was true and turned out not to be the deciding cost. `tests/test_packaging.py` and CI both assert each adapter wheel carries its capture. ## `homie_schema.json` @@ -37,24 +45,23 @@ and splitting the two would put the same twelve lines in each of the modules tha ### 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.8.0 | -| Repository | electrification-bus/distribution-enclosure-simulator | -| Commit | `171bb94f0960ccd2f62282c83ec203017bd6aa7f` (tag `v0.8.0`) | -| Capture script | `scripts/capture_parent_child_reference.py` | -| Manifest | `scripts/reference_panel.yaml` | +**No document states which release made this file, and that is the design.** `ebus-panel-sim` is pinned in `pyproject.toml`'s `dev` group, and `test_the_shipped_reference_tree_is_what_the_pinned_emitter_produces` regenerates the capture in-process on every +run and compares it to the committed bytes. A written record can go stale in silence — that is exactly how this tree went three emitter releases out of date while thirty test files asserted a producer defect as fact. A regeneration cannot. Following a +release is two steps: bump the pin, run `uv run python scripts/capture_parent_child_reference.py`. -**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. +| | | +| -------------- | ---------------------------------------------------- | +| Producer | `ebus-panel-sim`, pinned in `pyproject.toml` | +| Repository | electrification-bus/distribution-enclosure-simulator | +| Capture script | `scripts/capture_parent_child_reference.py` | +| Manifest | `scripts/reference_panel.yaml` | -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 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, so testing against it is correct. Its wheel +also carries the capability catalogs it publishes against, and `test_vendored_catalogs_are_byte_identical_to_the_emitters` compares those to the ones vendored under `packages/schema-1/spec/catalogs/` — so a disagreement between this parser and this capture +is a disagreement about one vocabulary rather than about two. See #161 and #162 for what depending on a frozen, unrecorded copy of it cost. -**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: +**The manifest is this repository's, not the emitter's example.** `scripts/reference_panel.yaml` is the capture's input, committed beside the script and existing in exactly one place: 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 @@ -64,4 +71,5 @@ producer is not written down. It mirrors `examples/forty_tab_minimal.yaml` key f 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 fourteen move on every recapture. Nothing reads it; a diff confined to those lines means the producer did not move. +**Shape-stable, not byte-stable.** Each `$description` carries a `version` minted from the wall clock, so all fourteen move on every recapture. Nothing reads it; a diff confined to those lines means the producer did not move. It is also the one field the +regeneration test normalises away — everything else is compared to the byte. diff --git a/tests/reference_payloads/bootstrap.py b/tests/reference_payloads/bootstrap.py index c1c3a3c..f1e7553 100644 --- a/tests/reference_payloads/bootstrap.py +++ b/tests/reference_payloads/bootstrap.py @@ -6,19 +6,24 @@ the other half of that story and lives in `schema_one`, beside the replay that can interpret it. -Read by path rather than through `importlib.resources`: this is a file in a test -tree now, not package data, and saying so in the loader is part of the point. +**The bytes come out of the installed wheel, not out of this tree.** They are +package data of `span-panel-api-schema-0` — read through `importlib.resources`, +so a downstream test suite pinned to a version of that distribution loads the +same bytes this suite does, from its own site-packages, without vendoring a copy +and without a guard to keep that copy honest. Test-support data, never read on a +runtime path: nothing in the adapter opens it, and `V2HomieSchema` is +constructed from a live panel response in production. """ from __future__ import annotations from collections.abc import Mapping +from importlib.resources import files import json -from pathlib import Path from span_panel_api.models import HomieSchemaTypes -_HOMIE_SCHEMA = Path(__file__).parent / "homie_schema.json" +_HOMIE_SCHEMA = files("span_panel_api_schema_0") / "reference" / "homie_schema.json" def homie_schema() -> Mapping[str, object]: diff --git a/tests/reference_payloads/schema_one.py b/tests/reference_payloads/schema_one.py index d6b9d3e..466e63b 100644 --- a/tests/reference_payloads/schema_one.py +++ b/tests/reference_payloads/schema_one.py @@ -10,15 +10,20 @@ topics through `DiscoveredDevice` first, and separating the two would put the same twelve lines in each of the test modules that read it. -Read by path rather than through `importlib.resources`: this is a file in a test -tree now, not package data, and saying so in the loader is part of the point. +**The bytes come out of the installed wheel, not out of this tree.** The capture +is package data of `span-panel-api-schema-1` — read through +`importlib.resources`, so a downstream test suite pinned to a version of that +distribution replays the same bytes this suite does, from its own site-packages, +without vendoring a copy and without a guard to keep that copy honest. +Test-support data, never read on a runtime path: nothing in the adapter opens +it, and a real consumer gets this tree off a broker. """ from __future__ import annotations from collections.abc import Mapping +from importlib.resources import files import json -from pathlib import Path from ebus_sdk.homie import DiscoveredDevice @@ -29,7 +34,7 @@ wire exactly as the panel publishes it, and `update_description` parses it. """ -_PARENT_CHILD_TREE = Path(__file__).parent / "parent_child_tree.json" +_PARENT_CHILD_TREE = files("span_panel_api_schema_1") / "reference" / "parent_child_tree.json" _DEFAULT_STATE = "ready" _DOMAIN = "ebus" diff --git a/tests/test_catalog_divergence.py b/tests/test_catalog_divergence.py index e22f72b..be4576a 100644 --- a/tests/test_catalog_divergence.py +++ b/tests/test_catalog_divergence.py @@ -69,11 +69,7 @@ _SPEC = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" _CATALOGS = _SPEC / "catalogs" -_SIMULATOR_TREE = _SPEC / "fixtures" / "simulator_tree.json" -_SIMULATOR_WIRE = _SPEC / "fixtures" / "simulator_wire.json" -SIMULATOR_TREE = "simulator-tree" -SIMULATOR_WIRE = "simulator-wire" REFERENCE_TREE = "reference-tree" FLAT_SCHEMA = "flat-schema" @@ -118,7 +114,7 @@ class Acknowledged: recorded="2026-08-20", ), Divergence("info", "model", Divergent.DATATYPE, "enum", "string"): Acknowledged( - observed_in=(REFERENCE_TREE, SIMULATOR_TREE, SIMULATOR_WIRE), + observed_in=(REFERENCE_TREE,), reason=( "The catalog types `model` as `string` while its own description invites a publisher to " "advertise the valid set 'via Homie `$format` on the property' -- which Homie 5 permits " @@ -213,9 +209,9 @@ def _untyped_nodes(descriptions: Sequence[dict[str, object]]) -> list[str]: ] -def _tree_descriptions() -> list[dict[str, object]]: - """The simulator capture that is already a tree of parsed descriptions.""" - return list(_objects(_json_object(_SIMULATOR_TREE)).values()) +def _reference_descriptions() -> list[dict[str, object]]: + """Every `$description` in the reference capture, parsed.""" + return _wire_descriptions(tree_payloads.parent_child_tree()) def _wire_descriptions(tree: Mapping[str, Mapping[str, str]]) -> list[dict[str, object]]: @@ -236,13 +232,6 @@ def _wire_descriptions(tree: Mapping[str, Mapping[str, str]]) -> list[dict[str, return descriptions -def _simulator_wire() -> Mapping[str, Mapping[str, str]]: - return { - device_id: {str(topic): str(payload) for topic, payload in topics.items()} - for device_id, topics in _objects(_json_object(_SIMULATOR_WIRE)).items() - } - - # --------------------------------------------------------------------------- # The flat producer, joined to the catalogued vocabulary # --------------------------------------------------------------------------- @@ -302,13 +291,7 @@ def _flat_declared() -> list[Declared]: def _surface() -> dict[str, list[Declared]]: """Every declaration this check judges, by producer.""" return { - SIMULATOR_TREE: [d for description in _tree_descriptions() for d in _from_description(description)], - SIMULATOR_WIRE: [d for description in _wire_descriptions(_simulator_wire()) for d in _from_description(description)], - REFERENCE_TREE: [ - d - for description in _wire_descriptions(tree_payloads.parent_child_tree()) - for d in _from_description(description) - ], + REFERENCE_TREE: [d for description in _reference_descriptions() for d in _from_description(description)], FLAT_SCHEMA: _flat_declared(), } @@ -427,9 +410,7 @@ def test_every_acknowledgement_justifies_itself() -> None: assert not undated, f"acknowledgements with no ISO date: {undated}" misfiled = sorted( - str(divergence) - for divergence, entry in _REGISTER.items() - if set(entry.observed_in) - {SIMULATOR_TREE, SIMULATOR_WIRE, REFERENCE_TREE, FLAT_SCHEMA} + str(divergence) for divergence, entry in _REGISTER.items() if set(entry.observed_in) - {REFERENCE_TREE, FLAT_SCHEMA} ) assert not misfiled, f"acknowledgements naming a producer this check does not survey: {misfiled}" @@ -443,8 +424,8 @@ def test_a_property_no_catalog_defines_is_never_reported_as_a_mismatch() -> None """The EVSE `config` node is the case, and it is not a defect. `config` is not an eBus capability at all — the specification has no catalog - of that name, which `test_an_unvendored_node_is_one_the_specification_really_does_not_define` - checks against a real checkout, and both its properties are declared + of that name, which `test_an_unvendored_node_is_one_the_emitter_really_does_not_publish` + checks against the installed emitter's catalogs, and both its properties are declared extensions in `_SPAN_EXTENSIONS`. Comparing its `unit` against a catalog that does not exist would report SPAN's own vocabulary as a mislabel, twice per property. @@ -616,10 +597,7 @@ def test_every_captured_node_names_a_capability() -> None: the skip from becoming a way for the surface to shrink unnoticed — a node that lost its `$type` would drop off the comparison silently. """ - descriptions = ( - _tree_descriptions() + _wire_descriptions(_simulator_wire()) + _wire_descriptions(tree_payloads.parent_child_tree()) - ) - untyped = sorted(set(_untyped_nodes(descriptions))) + untyped = sorted(set(_untyped_nodes(_reference_descriptions()))) assert not untyped, f"captured nodes declaring no eBus capability type: {untyped}" @@ -637,9 +615,9 @@ def test_a_relabelled_unit_in_a_capture_is_reported() -> None: real captures are untouched — the point is that the reader, not a fixture, is what notices. """ - mutated = copy.deepcopy(_json_object(_SIMULATOR_TREE)) + mutated = copy.deepcopy(_reference_descriptions()) relabelled = 0 - for device in _objects(mutated).values(): + for device in mutated: meter = declared_nodes(device).get(NODE_METER, {}) for property_id, definition in declared_properties(meter).items(): if property_id == "active-power" and definition.get("unit") == "W": @@ -647,12 +625,12 @@ def test_a_relabelled_unit_in_a_capture_is_reported() -> None: relabelled += 1 assert relabelled, "no captured device declares meter/active-power in W; the mutation proves nothing" - surface = {SIMULATOR_TREE: [d for device in _objects(mutated).values() for d in _from_description(device)]} + surface = {REFERENCE_TREE: [d for device in mutated for d in _from_description(device)]} reported = _divergences(surface) mislabel = Divergence("meter", "active-power", Divergent.UNIT, "kW", "W") assert mislabel in reported, f"a relabelled unit was not reported; found {sorted(str(d) for d in reported)}" - assert reported[mislabel] == frozenset({SIMULATOR_TREE}), "the finding names the wrong producer" + assert reported[mislabel] == frozenset({REFERENCE_TREE}), "the finding names the wrong producer" assert mislabel in _REGISTER, "the register happens to carry this one, from the flat schema" assert _REGISTER[mislabel].observed_in == (FLAT_SCHEMA,), ( @@ -667,9 +645,9 @@ def test_a_relabelled_datatype_in_a_capture_is_reported() -> None: `unit` and `datatype` are compared by different rules — one family-aware, one exact — so proving one bites does not prove the other does. """ - mutated = copy.deepcopy(_json_object(_SIMULATOR_TREE)) + mutated = copy.deepcopy(_reference_descriptions()) relabelled = 0 - for device in _objects(mutated).values(): + for device in mutated: breaker = declared_nodes(device).get("breaker", {}) for property_id, definition in declared_properties(breaker).items(): if property_id == "rating": @@ -677,7 +655,7 @@ def test_a_relabelled_datatype_in_a_capture_is_reported() -> None: relabelled += 1 assert relabelled, "no captured device declares breaker/rating; the mutation proves nothing" - surface = {SIMULATOR_TREE: [d for device in _objects(mutated).values() for d in _from_description(device)]} + surface = {REFERENCE_TREE: [d for device in mutated for d in _from_description(device)]} reported = _divergences(surface) assert ( diff --git a/tests/test_packaging.py b/tests/test_packaging.py index b2f1e03..8911fa7 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -15,24 +15,18 @@ _REPO_ROOT = Path(__file__).resolve().parents[1] -_CAPTURES = Path(__file__).parent / "reference_payloads" -"""Where the reference captures live, and the source of the names below. - -Read off the fixture directory rather than listed here so a capture added there -is covered the day it exists — the same reason `_wheel_source_packages` reads -the manifests. +_REFERENCE_CAPTURES = { + "span-panel-api-schema-0": "homie_schema.json", + "span-panel-api-schema-1": "parent_child_tree.json", +} +"""The capture each adapter distribution ships, under `/reference/`. + +Keyed by distribution because the question is per-distribution: the bootstrap +ships none, and each adapter ships exactly the capture a consumer of *that* +parser tests against. """ -def _is_reference_capture(path: Path) -> bool: - """Would this file be one of the reference captures, wherever it sits? - - Two questions, because there are two ways to reintroduce the problem: move - the directory back inside a package, or drop one capture in beside a module. - """ - return "reference_payloads" in path.parts or path.name in {capture.name for capture in _CAPTURES.glob("*.json")} - - def _wheel_source_packages() -> list[tuple[str, Path]]: """Every importable package each distribution in the workspace ships. @@ -91,23 +85,31 @@ def test_every_shipped_package_carries_a_py_typed_marker(distribution: str, pack _wheel_source_packages(), ids=lambda value: value.name if isinstance(value, Path) else str(value), ) -def test_no_shipped_package_carries_a_reference_capture(distribution: str, package_dir: Path) -> None: - """Test data must not sit inside a package directory. +def test_every_adapter_ships_its_reference_capture(distribution: str, package_dir: Path) -> None: + """Each adapter carries the capture its consumers test against. + + Deliberately shipped, and the reasoning reversed from 3.1.0's. It is true + that no runtime path reads these — that is why they were pulled out — but the + cost of *not* shipping them is paid downstream: the integration vendored + copies and then needed a provenance guard to keep the copies honest, which is + more machinery than 59 KB in two wheels. Shipping them means a consumer + pinned to a version of this adapter reads the same bytes that version was + built and tested against, out of its own site-packages. Nothing declares what ships: hatchling takes the whole of `packages = [...]`, - so a directory dropped inside one is package data by position alone. That is - how both wheels came to carry a reference capture between 3.0.0 and 3.1.0 — - 56 KB no runtime path reads, in every install, plus an import surface the - distributions never meant to promise and could not remove without a breaking - change. - - The captures are fixtures now, under `tests/reference_payloads`. This is the - cheap half of holding that: CI asserts the same thing against the built - wheels, where it is finally true rather than inferred, but a failure here - names the file before anyone builds one. + so a directory inside one is package data by position alone. That cuts both + ways, which is why this is asserted rather than assumed — CI asserts the same + against the built wheels, where it is finally true rather than inferred, but a + failure here names the file before anyone builds one. + + The bootstrap ships neither: it registers no adapter and parses nothing, so + there is no capture that belongs to it. """ - payloads = [path for path in package_dir.rglob("*.json") if _is_reference_capture(path)] - assert not payloads, ( - f"{distribution} would ship {[str(p.relative_to(package_dir)) for p in payloads]} — " - "reference captures belong in tests/reference_payloads, not inside a package directory" - ) + expected = _REFERENCE_CAPTURES.get(distribution) + if expected is None: + stray = sorted(str(path.relative_to(package_dir)) for path in package_dir.rglob("reference/*.json")) + assert not stray, f"{distribution} parses nothing, so it should ship no reference capture; found {stray}" + return + + capture = package_dir / "reference" / expected + assert capture.is_file(), f"{distribution} ships no {expected}; run its capture script" diff --git a/tests/test_peer_drift.py b/tests/test_peer_drift.py deleted file mode 100644 index f61a8cf..0000000 --- a/tests/test_peer_drift.py +++ /dev/null @@ -1,545 +0,0 @@ -"""`scripts/peer_drift.py` — the check that asks a producer whether it moved. - -Every test here hands the script fake fetchers, so nothing in this file touches -the network: the point of `HttpGet` / `LsRemote` being injectable is that the -comparisons can be exercised on a laptop with no connection, and that a suite -which passes today passes on the day panelbench cuts a release. - -What is deliberately *not* faked is the lockfile. `main` reads the real -`spec_lock.json`, because "the pins live in exactly one place" is the property -these tests are here to hold, and a fixture standing in for it would test a copy. -""" - -from __future__ import annotations - -from collections.abc import Callable, Mapping, Sequence -import email.message -import io -import json -from pathlib import Path -import re -import subprocess -import urllib.error -import urllib.request - -import pytest - -from span_panel_api_schema_1 import const - -from scripts.peer_drift import ( - EBUS_SPEC_FILE, - PANEL_SIM, - PANELBENCH, - SPECIFICATION, - VENDORED, - HttpGet, - LsRemote, - Unreachable, - _headers, - emitter, - exit_code, - fixtures, - http_get, - ls_remote, - main, - newer_release, - panelbench, - specification, - unasked, - unvendored, -) - -_LOCK = Path(const.__file__).parent / "spec_lock.json" -_SCRIPTS = Path(__file__).parent.parent / "scripts" - -_HEAD = "a" * 40 -"""A branch head that is not any pinned commit, and obviously synthetic.""" - - -def _lock() -> dict[str, object]: - """The real lockfile, read here rather than through the code under test.""" - with _LOCK.open(encoding="utf-8") as handle: - document: dict[str, object] = json.load(handle) - return document - - -def _peer(name: str) -> dict[str, object]: - peers = _lock()["peers"] - assert isinstance(peers, dict) - block = peers[name] - assert isinstance(block, dict) - return block - - -def _pinned(name: str, key: str) -> str: - value = _peer(name)[key] - assert isinstance(value, str) - return value - - -def _files(*, yanked: bool = False) -> list[dict[str, object]]: - return [{"filename": "example-0.whl", "yanked": yanked}] - - -def _pypi(latest: str, releases: Mapping[str, Sequence[Mapping[str, object]]] | None = None) -> str: - """PyPI's answer, with the pinned release healthy unless a test says otherwise.""" - listed: Mapping[str, Sequence[Mapping[str, object]]] = ( - {_pinned(PANEL_SIM, "version"): _files(), latest: _files()} if releases is None else releases - ) - return json.dumps({"info": {"version": latest}, "releases": listed}) - - -def _comparison(ahead: int, behind: int, files: Sequence[str]) -> str: - return json.dumps( - { - "status": "diverged" if behind else "ahead", - "ahead_by": ahead, - "behind_by": behind, - "files": [{"filename": name} for name in files], - } - ) - - -def _http(answers: Mapping[str, str]) -> HttpGet: - """Answer by URL fragment; anything unmatched is unreachable, loudly.""" - - def get(url: str) -> str: - for fragment, body in answers.items(): - if fragment in url: - return body - raise Unreachable(f"no fake answer for {url}") - - return get - - -def _refuses_http() -> HttpGet: - def get(url: str) -> str: - raise AssertionError(f"asked {url}, which this comparison should not need") - - return get - - -def _remote(heads: Mapping[str, str]) -> LsRemote: - def resolve(repo: str, ref: str) -> str: - for fragment, commit in heads.items(): - if fragment in repo: - return commit - raise Unreachable(f"no fake head for {repo} {ref}") - - return resolve - - -def _at_their_pins() -> LsRemote: - """Every producer sitting exactly where the lockfile says it is.""" - return _remote( - { - "panelbench": _pinned(PANELBENCH, "commit"), - "distribution-enclosure-simulator": _pinned(PANEL_SIM, "commit"), - "specification": str(_lock()["synced_commit"]), - } - ) - - -def _unreachable() -> LsRemote: - def resolve(repo: str, ref: str) -> str: - raise Unreachable("Name or service not known") - - return resolve - - -# --------------------------------------------------------------------------- -# The pins have one home -# --------------------------------------------------------------------------- - - -def test_the_script_states_no_pin_of_its_own() -> None: - """No commit and no version literal in either module. - - The whole failure this check exists to catch is a pin that has two homes and - stops agreeing with itself. A script that hardcoded either half would be that - failure, in the tool meant to find it. - """ - commit = re.compile(r"\b[0-9a-f]{40}\b") - version = re.compile(r"\b\d+\.\d+\.\d+\b") - for module in ("peer_drift.py", "_lock.py"): - source = (_SCRIPTS / module).read_text(encoding="utf-8") - assert not commit.findall(source), f"{module} names a commit; read it from spec_lock.json" - assert not version.findall(source), f"{module} names a version; read it from spec_lock.json" - - -def test_every_producer_it_reports_is_pinned_where_it_says() -> None: - lock = _lock() - reported = [ - (panelbench(lock, _refuses_http(), _at_their_pins()).pinned, _pinned(PANELBENCH, "commit")), - (emitter(lock, _all_current(), _at_their_pins()).pinned, _pinned(PANEL_SIM, "version")), - (specification(lock, _at_their_pins()).pinned, str(lock["synced_commit"])), - ] - assert [pair for pair in reported if pair[0] != pair[1]] == [] - - -def test_every_pinned_producer_can_be_asked() -> None: - """A peer added to the lockfile and not here would be silently exempt.""" - assert unasked(_lock()) == () - assert unvendored(_lock()) == () - - -def test_a_token_reaches_github_and_nowhere_else(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("GH_TOKEN", "not-a-real-token") - assert "Authorization" in _headers("https://api.github.com/repos/o/n/compare/a...b") - assert _headers("https://pypi.org/pypi/anything/json") == {} - - -# --------------------------------------------------------------------------- -# panelbench: a commit is only drift if it touched what we copy -# --------------------------------------------------------------------------- - - -def test_a_branch_at_the_pin_asks_github_nothing() -> None: - """The ordinary day, and it costs one `ls-remote` and no API request.""" - found = panelbench(_lock(), _refuses_http(), _at_their_pins()) - assert found.verdict == "current" - assert "the commit we pin" in found.detail - - -def test_commits_that_change_nothing_we_copy_stay_green() -> None: - found = panelbench( - _lock(), - _http({"compare": _comparison(3, 0, ["README.md", "docs/index.md"])}), - _remote({"panelbench": _HEAD}), - ) - assert found.verdict == "current" - assert found.observed == _HEAD - assert "3 commits ahead, nothing we copy changed" in found.detail - - -def test_a_changed_capture_is_drift_and_says_where_to_copy_it() -> None: - source = fixtures(_peer(PANELBENCH))["tree"] - # Answered only for the resolved head, so a comparison against the branch - # name -- a later push away from what the summary reports -- goes unanswered. - since_the_pin = f"compare/{_pinned(PANELBENCH, 'commit')}...{_HEAD}" - found = panelbench( - _lock(), - _http({since_the_pin: _comparison(1, 0, [source, "README.md"])}), - _remote({"panelbench": _HEAD}), - ) - assert found.verdict == "drift" - assert f"- `{source}`" in found.detail - assert f"cp $PANELBENCH_DIR/{source}" in found.detail - assert VENDORED["tree"] in found.detail - assert VENDORED["wire"] not in found.detail - - -def test_a_changed_producer_lockfile_is_drift() -> None: - found = panelbench( - _lock(), - _http({"compare": _comparison(1, 0, [EBUS_SPEC_FILE])}), - _remote({"panelbench": _HEAD}), - ) - assert found.verdict == "drift" - assert "reading different vocabularies" in found.detail - assert "cp $PANELBENCH_DIR" not in found.detail - - -def test_a_truncated_file_list_is_unknown_not_current() -> None: - """300 files is the API's ceiling, not a statement that the 301st is safe.""" - found = panelbench( - _lock(), - _http({"compare": _comparison(400, 0, [f"src/file_{index}.py" for index in range(300)])}), - _remote({"panelbench": _HEAD}), - ) - assert found.verdict == "unknown" - assert "all the API will list" in found.detail - - -def test_a_truncated_list_that_names_a_capture_is_still_drift() -> None: - """The ceiling makes an absence unreliable, not a presence.""" - source = fixtures(_peer(PANELBENCH))["wire"] - padding = [f"src/file_{index}.py" for index in range(299)] - found = panelbench( - _lock(), - _http({"compare": _comparison(400, 0, [source, *padding])}), - _remote({"panelbench": _HEAD}), - ) - assert found.verdict == "drift" - assert VENDORED["wire"] in found.detail - - -def test_a_pin_the_branch_does_not_contain_is_unknown() -> None: - found = panelbench( - _lock(), - _http({"compare": _comparison(4, 2, [])}), - _remote({"panelbench": _HEAD}), - ) - assert found.verdict == "unknown" - assert "not an ancestor" in found.detail - assert f"peers.{PANELBENCH}.ref" in found.detail - - -def test_a_producer_that_cannot_be_reached_is_unknown() -> None: - found = panelbench(_lock(), _refuses_http(), _unreachable()) - assert found.verdict == "unknown" - assert found.observed == "" - assert "not having asked is not the same fact" in found.detail.lower() - - -# --------------------------------------------------------------------------- -# the emitter: the release is the verdict, the branch is context -# --------------------------------------------------------------------------- - - -def test_a_newer_release_is_drift_and_names_the_capture_script() -> None: - found = emitter(_lock(), _http({"pypi.org": _pypi("99.0.0")}), _at_their_pins()) - assert found.verdict == "drift" - assert found.observed == "99.0.0" - assert _pinned(PANEL_SIM, "capture_script") in found.detail - produces = _peer(PANEL_SIM)["produces"] - assert isinstance(produces, dict) - assert str(produces["tree"]) in found.detail - assert "at v99.0.0" in found.detail, "the tag prefix comes from the pinned tag, not from a guess" - - -def test_the_emitters_branch_moving_is_reported_and_not_a_verdict() -> None: - found = emitter( - _lock(), - _http({"pypi.org": _pypi(_pinned(PANEL_SIM, "version"))}), - _remote({"distribution-enclosure-simulator": _HEAD}), - ) - assert found.verdict == "current" - assert "the branch head has" in found.detail - assert "not itself a reason to recapture" in found.detail - - -def test_asking_the_emitter_never_reaches_the_github_api() -> None: - """PyPI answers the verdict and `ls-remote` the context; neither is the API. - - Worth holding: the unauthenticated GitHub limit is the scarce thing here, and - the emitter has no claim on it. - """ - - def get(url: str) -> str: - assert "api.github.com" not in url, f"asked the GitHub API for {url}" - return _pypi(_pinned(PANEL_SIM, "version")) - - code, report = _run(strict=True, get=get, ls=_at_their_pins(), peer=PANEL_SIM) - assert code == 0 - assert "The reference tree is current." in report - - -@pytest.mark.parametrize( - ("body", "expected"), - [ - pytest.param( - lambda pin: _pypi(pin, {pin: _files(yanked=True)}), - "has been yanked", - id="the pinned release was withdrawn", - ), - pytest.param( - lambda pin: _pypi("99.0.0", {"99.0.0": _files()}), - "lists no release", - id="the index does not carry the pin at all", - ), - pytest.param( - lambda pin: _pypi(pin, {pin: []}), - "with no files", - id="the pinned release has nothing left under it", - ), - pytest.param( - lambda pin: _pypi("0.0.1", {pin: _files(), "0.0.1": _files()}), - "is not newer than the pin", - id="the index serves something older than the pin", - ), - pytest.param( - lambda pin: _pypi("99.0.0b1", {pin: _files(), "99.0.0b1": _files()}), - "cannot be ordered against the pin", - id="the latest is a pre-release this cannot order", - ), - ], -) -def test_a_release_that_is_not_plainly_newer_is_never_drift(body: Callable[[str], str], expected: str) -> None: - """Five ways PyPI can disagree with the pin without the capture being stale. - - Every one of them used to read as drift, whose instructions say to re-capture - from `info.version` — which for a yanked, absent or older release is worse - than doing nothing. - """ - pinned = _pinned(PANEL_SIM, "version") - found = emitter(_lock(), _http({"pypi.org": body(pinned)}), _at_their_pins()) - assert found.verdict == "unknown" - assert expected in found.detail - assert "capture_parent_child_reference" not in found.detail, "no instructions for a non-verdict" - - -@pytest.mark.parametrize( - ("candidate", "than", "expected"), - [ - ("1.9.0", "1.10.0", False), - ("1.10.0", "1.9.0", True), - ("2.0", "2.0.0", False), - ("2.0.1", "2.0", True), - ("2.0.0rc1", "2.0.0", None), - ("2.0.0", "2.0.0.dev1", None), - ], -) -def test_versions_are_ordered_as_numbers_or_not_at_all(candidate: str, than: str, expected: bool | None) -> None: - """Segment by segment as integers, and `None` the moment a suffix appears.""" - assert newer_release(candidate, than) is expected - - -def test_pypi_being_unreachable_still_reports_the_branch() -> None: - found = emitter(_lock(), _http({}), _at_their_pins()) - assert found.verdict == "unknown" - assert "UNKNOWN" in found.detail - assert "commits" in found.detail, "the context is still worth printing when the verdict is not" - - -# --------------------------------------------------------------------------- -# the specification: reported, never decisive -# --------------------------------------------------------------------------- - - -def test_the_specification_moving_decides_nothing() -> None: - found = specification(_lock(), _remote({"specification": _HEAD})) - assert found.advisory - assert found.verdict == "drift" - assert exit_code([found], strict=True) == 0 - - -# --------------------------------------------------------------------------- -# the fetchers themselves, which every other test replaces -# --------------------------------------------------------------------------- - - -def _http_error(code: int, reason: str, *, spent: bool = False) -> urllib.error.HTTPError: - headers = email.message.Message() - if spent: - headers["x-ratelimit-remaining"] = "0" - return urllib.error.HTTPError("https://api.github.com/repos/o/n", code, reason, headers, None) - - -@pytest.mark.parametrize( - ("failure", "expected"), - [ - pytest.param(_http_error(404, "Not Found"), "HTTP 404", id="a repository or release that is not there"), - pytest.param(_http_error(403, "Forbidden", spent=True), "rate limit", id="the unauthenticated limit spent"), - pytest.param(_http_error(429, "Too Many Requests", spent=True), "rate limit", id="asked too fast"), - pytest.param(urllib.error.URLError("Name or service not known"), "Name or service", id="no network"), - ], -) -def test_http_get_turns_every_failure_into_unreachable( - monkeypatch: pytest.MonkeyPatch, failure: Exception, expected: str -) -> None: - """No failure mode of the real fetcher may escape as anything but `Unreachable`. - - Everything above this line runs against fakes, so this is the only test that - holds the seam between them: an exception the fetcher does not convert would - reach `main` as a traceback and fail a commit for a producer that is merely - unreachable. - """ - - def raise_it(request: object, timeout: float) -> object: - raise failure - - monkeypatch.setattr(urllib.request, "urlopen", raise_it) - with pytest.raises(Unreachable) as caught: - http_get("https://api.github.com/repos/o/n/compare/a...b") - assert expected in str(caught.value) - - -def test_http_get_returns_the_body_it_was_given(monkeypatch: pytest.MonkeyPatch) -> None: - class Response: - def __enter__(self) -> Response: - return self - - def __exit__(self, *_: object) -> bool: - return False - - def read(self) -> bytes: - return b'{"info": {}}' - - def answer(request: object, timeout: float) -> Response: - return Response() - - monkeypatch.setattr(urllib.request, "urlopen", answer) - assert http_get("https://pypi.org/pypi/example/json") == '{"info": {}}' - - -def _repository(tmp_path: Path) -> tuple[str, str]: - """A real repository with one commit on a branch, to resolve refs against.""" - path = tmp_path / "producer" - path.mkdir() - _git(path, "init", "-b", "probe") - _git(path, "-c", "user.email=nobody@example.invalid", "-c", "user.name=Nobody", "commit", "--allow-empty", "-m", "one") - return str(path), _git(path, "rev-parse", "HEAD") - - -def _git(path: Path, *arguments: str) -> str: - completed = subprocess.run(["git", *arguments], cwd=path, check=True, capture_output=True, text=True) - return completed.stdout.strip() - - -def test_ls_remote_resolves_a_ref_that_exists(tmp_path: Path) -> None: - repo, head = _repository(tmp_path) - assert ls_remote(repo, "refs/heads/probe") == head - - -def test_ls_remote_reports_a_ref_that_does_not(tmp_path: Path) -> None: - """A branch renamed or deleted answers cleanly, and `git` exits 0 saying nothing.""" - repo, _ = _repository(tmp_path) - with pytest.raises(Unreachable) as caught: - ls_remote(repo, "refs/heads/renamed-away") - assert "has no refs/heads/renamed-away" in str(caught.value) - - -def test_ls_remote_reports_a_repository_that_is_not_there(tmp_path: Path) -> None: - with pytest.raises(Unreachable) as caught: - ls_remote(str(tmp_path / "nowhere"), "HEAD") - assert str(caught.value), "git's own last line, rather than an empty report" - - -# --------------------------------------------------------------------------- -# exit codes -# --------------------------------------------------------------------------- - - -def _run(*, strict: bool, get: HttpGet, ls: LsRemote, peer: str | None = None) -> tuple[int, str]: - out = io.StringIO() - argv = ["--strict"] if strict else [] - if peer is not None: - argv += ["--peer", peer] - return main(argv, get=get, ls=ls, out=out), out.getvalue() - - -def _all_current() -> HttpGet: - return _http({"pypi.org": _pypi(_pinned(PANEL_SIM, "version"))}) - - -def test_a_run_where_nothing_moved_passes() -> None: - code, report = _run(strict=True, get=_all_current(), ls=_at_their_pins()) - assert code == 0 - assert [line for line in report.splitlines() if line.startswith("## ")] == [ - f"## {PANELBENCH}", - f"## {PANEL_SIM}", - f"## {SPECIFICATION}", - ] - - -def test_drift_fails_a_commit_and_a_ci_run_alike() -> None: - drifted = _http({"pypi.org": _pypi("99.0.0")}) - assert _run(strict=False, get=drifted, ls=_at_their_pins(), peer=PANEL_SIM)[0] == 1 - assert _run(strict=True, get=drifted, ls=_at_their_pins(), peer=PANEL_SIM)[0] == 1 - - -def test_unknown_passes_a_commit_and_fails_ci() -> None: - """A laptop on a plane still commits; a CI run with no answer does not pass.""" - code, report = _run(strict=False, get=_http({}), ls=_unreachable()) - assert code == 0 - assert "UNKNOWN" in report - - assert _run(strict=True, get=_http({}), ls=_unreachable())[0] == 1 - - -def test_peer_asks_one_producer() -> None: - code, report = _run(strict=True, get=_refuses_http(), ls=_at_their_pins(), peer=PANELBENCH) - assert code == 0 - assert report.count("## ") == 1 - assert report.startswith(f"## {PANELBENCH}") diff --git a/tests/test_reference_tree_values.py b/tests/test_reference_tree_values.py index 278ea0e..247ba9a 100644 --- a/tests/test_reference_tree_values.py +++ b/tests/test_reference_tree_values.py @@ -41,7 +41,7 @@ **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 +entries that panelbench 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 @@ -121,8 +121,8 @@ def test_the_reference_tree_values_everything_the_producer_values() -> None: 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." + "panelbench's own baseline, or bump the ebus-panel-sim pin and recapture the reference " + "tree with scripts/capture_parent_child_reference.py." ) diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py index be0c037..d9b14bc 100644 --- a/tests/test_schema_migration_delta.py +++ b/tests/test_schema_migration_delta.py @@ -41,8 +41,16 @@ being fixed. 1.0.16 corrected an EVSE's node id to be its drive serial, the capture was not re-taken because it was believed it never needed to be, and the two vendored captures spent nine days naming the same charger differently. `tests/fixtures/ -flat_wire.json` now records the simulator commit it came from, the way the v1.0 -capture records panelbench's — see `scripts/capture_flat_reference.py`. +flat_wire.json` now records the simulator commit it came from — see +`scripts/capture_flat_reference.py`. + +**The pair is what this module is.** `flat_wire.json` and `panelbench_wire.json` +are two captures of one 30-circuit panel, one per firmware generation, and only a +matched pair can answer the question above — the reference tree the rest of the +suite runs on is a different, six-circuit panel, so it cannot stand in for either +half. They sit beside each other in `tests/fixtures/` because that is what they +are: committed fixtures with prose provenance in the scripts that took them, not +peers of this distribution. *Telemetry is attested.* The simulator models the BESS and the Drives, and the integration renders their entities correctly against it — which is real evidence @@ -117,7 +125,7 @@ _FIXTURES = Path(__file__).parent / "fixtures" _FLAT = _FIXTURES / "flat_wire.json" -_PC = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" / "fixtures" / "simulator_wire.json" +_PC = _FIXTURES / "panelbench_wire.json" _SERIAL = "sim-40t-001" EXPECTED_ORPHANS: dict[str, str] = { diff --git a/tests/test_schema_one_against_simulator.py b/tests/test_schema_one_against_simulator.py deleted file mode 100644 index 607325a..0000000 --- a/tests/test_schema_one_against_simulator.py +++ /dev/null @@ -1,260 +0,0 @@ -"""Drive the parser end to end from what the simulator actually publishes. - -Every other schema_1 test runs on the reference tree in -`tests/reference_payloads`, which was captured off the upstream *generic* eBus panel simulator. That fixture is fine -for exercising the mapper, but it is not SPAN: it has never carried the -extensions and divergences that are SPAN's own vocabulary, which is precisely -the part a generic panel cannot produce. - -This runs on a capture from SPAN's own publisher — the same panel the -conformance and coverage checks are written against — fed in exactly as the -transport feeds it: one retained message at a time, in whatever order the store -replays them. - -**Values are deliberately not asserted.** The simulator's config carries -`noise_factor` and its clock advances, so power and current differ every capture. -Pinning a wattage here would produce a test that fails whenever the fixture is -refreshed, for a reason nobody can act on. What is asserted is what must hold for -any capture of a 40-space panel: that the parser reaches ready, sizes the panel, -finds every circuit, and populates the fields the integration consumes. -""" - -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 - -_WIRE = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" / "fixtures" / "simulator_wire.json" -_PANEL = "sim-40t-001" -_TOPIC_PREFIX = "ebus/5" - - -def _schema() -> V2HomieSchema: - return V2HomieSchema( - firmware_version="spanos2/r202633/01", - types_schema_hash="sha256:simulator-capture", - types={}, - data_model_version="1.0", - ) - - -@pytest.fixture(name="adapter") -def _adapter() -> SchemaOneAdapter: - """Feed the capture the way the broker replays it. - - Sorted by topic rather than tree order, on purpose: the retained store has no - notion of parents before children, and the ordering bug fixed before the - first release was exactly a case of that assumption being made silently. - """ - with _WIRE.open() as handle: - capture: dict[str, dict[str, str]] = json.load(handle) - - adapter = SchemaOneAdapter(_PANEL, _schema()) - messages = [ - (f"{_TOPIC_PREFIX}/{device_id}/{key}", payload) - for device_id, body in capture.items() - for key, payload in body.items() - ] - for topic, payload in sorted(messages): - adapter.handle_message(topic, payload) - return adapter - - -def test_the_parser_reaches_ready_on_the_simulators_own_capture(adapter: SchemaOneAdapter) -> None: - """The claim that matters: this parser can complete a connection to SPAN's - publisher, not merely to a generic eBus panel.""" - assert adapter.is_ready(), "the parser never reached ready on a full capture of the simulator's tree" - - -def test_the_panel_is_sized_from_the_model_the_simulator_declares(adapter: SchemaOneAdapter) -> None: - """Panel size drives the unmapped-position entries the integration builds - from total-minus-occupied, so a wrong size is missing entities, not an error.""" - snapshot = adapter.build_snapshot() - - assert snapshot.panel_size == 40, "the simulator declares MAIN_40; PANEL_SIZE_BY_MODEL must know it" - - -def test_every_circuit_the_simulator_publishes_is_parsed(adapter: SchemaOneAdapter) -> None: - """30 circuits in the tracked config; the remainder of the 40 spaces are the - unmapped positions the integration expects to exist.""" - snapshot = adapter.build_snapshot() - real = [circuit_id for circuit_id in snapshot.circuits if not circuit_id.startswith("unmapped_tab_")] - - assert len(real) == 30, f"expected the config's 30 circuits, parsed {len(real)}" - assert all(snapshot.circuits[circuit_id].name for circuit_id in real), "a circuit arrived with no name" - - -def test_no_der_declares_a_model_it_never_publishes(adapter: SchemaOneAdapter) -> None: - """The `info/model` half of the producer gap, closed on 2026-08-08. - - This asserted `["bess", "pv", "evse", "evse-2"]` until the producer adopted - the upstream emitter and its DER metadata keys. All four declared - `info/model` and never sent a value. - - Scope is exactly `model`, because that is what `circuit_nodes_missing_names()` - measures for a DER — `PROP_MODEL` declared with no value, alongside circuits - missing `PROP_NAME`. The wider declared-but-unpublished question is - `test_the_pv_still_declares_an_identity_field_it_never_publishes` below, - which is not empty. - - The consumer symptom is specific: an entity is created from the declaration, - waits for a value that never arrives, and never updates. - - Worth keeping the note that panelbench's own conformance checker **cannot** - see this. It compares declarations against catalogs, so a property declared - and never published is conformant by construction. Only a capture carrying - values catches it, which remains the argument for this fixture. - - Asserted empty rather than deleted: zero is the state worth defending. - """ - assert adapter.circuit_nodes_missing_names() == [], ( - "these devices declare info/model and never publish it, which creates entities " - "that never update. This was empty as of the 2026-08-08 recapture, so it is a " - "producer regression rather than a known gap." - ) - - -_DER_TYPES = frozenset( - { - "energy.ebus.device.bess", - "energy.ebus.device.pv", - "energy.ebus.device.evse", - } -) -"""The proxied DER classes, which are what the over-declaration check covers.""" - - -def test_the_pv_still_declares_an_identity_field_it_never_publishes() -> None: - """The rest of §5.2, which adopting the upstream emitter did *not* close. - - `circuit_nodes_missing_names()` looks only at `info/model`, so it reports - clean while declared properties still arrive with no value. Reading the - capture directly is the only way to see the whole class, and leaving it - unmeasured would let "the model gap closed" read as "the gap closed". - - It has done that job twice now, and both times the expectation shrank rather - than grew: the BESS pair closed on 2026-08-10, and PV `info/firmware-version` - on 2026-08-20. One declaration is left. - - Pinned as an exact set so it fails in either direction: a new over-declaration - appears, or the last one is finally published and the expectation should - shrink again. - - **Keyed by device type, not device id.** The ids are `-` - and move with the panel serial and the DER's own serial, so keying on them - would make this fail whenever a config changed — for a reason that has nothing - to do with what it measures. Type is the stable discriminator, and it is what - the mapper itself resolves on. - """ - with _WIRE.open() as handle: - wire = json.load(handle) - with (_WIRE.parent / "simulator_tree.json").open() as handle: - tree = json.load(handle) - - gaps: dict[str, list[str]] = {} - for device_id, description in tree.items(): - device_type = str(description.get("type") or "") - if device_type not in _DER_TYPES: - continue - declared = { - f"{node}/{prop}" - for node, body in (description.get("nodes") or {}).items() - for prop in (body.get("properties") or {}) - } - published = {key for key in wire[device_id] if not key.startswith("$")} - if absent := sorted(declared - published): - already = gaps.setdefault(device_type, absent) - assert already == absent, ( - f"two {device_type} devices disagree on which declarations go unpublished " - f"({already} vs {absent}); collapsing by type would hide one of them" - ) - - assert gaps == { - # The BESS pair closed on 2026-08-10 and PV `info/firmware-version` on - # 2026-08-20, both because panelbench supplied a value where the declaration - # had been empty. Synthetic values, so they attest the mapping and not what - # real firmware sends. - # - # PV `info/serial-number` is the one left, and it is unpublished on purpose - # rather than overlooked. Valuing it moves the PV's device id from - # `-pv-1` to `-`, because the producer's identifier - # derivation prefers a serial over an instance id -- and that id is what a - # consumer's device-registry entry is built from, so the upgrade rehearsal - # would stop comparing one PV and start comparing two. Closing it means - # settling the flat side's PV id first, which is a question about the upgrade - # path rather than about a config value. - "energy.ebus.device.pv": ["info/serial-number"], - }, f"the declared-but-unpublished set moved: {gaps}" - - -def test_the_fields_the_integration_consumes_are_populated(adapter: SchemaOneAdapter) -> None: - """Presence, not values. A field left None reaches a user as an entity that - exists and never updates, which is the failure this whole exercise is about. - """ - snapshot = adapter.build_snapshot() - - assert snapshot.instant_grid_power_w is not None - assert snapshot.main_meter_energy_consumed_wh is not None - assert snapshot.main_meter_energy_produced_wh is not None - assert snapshot.battery.soe_percentage is not None - assert snapshot.l1_voltage is not None - assert snapshot.l2_voltage is not None - - -def test_field_metadata_covers_what_the_snapshot_carries(adapter: SchemaOneAdapter) -> None: - """Metadata is read from each device's `$description`, so a capture is the - only way to check it against a real publisher rather than against a schema - document that describes every panel ever built.""" - metadata = adapter.build_field_metadata() - - assert metadata, "no field metadata was built from a full capture" - assert all( - entry.unit != "energy" for entry in metadata.values() - ), "an abstract unit token reached field metadata; units must come from the device description" - - -def test_grid_state_is_read_from_the_mid(adapter: SchemaOneAdapter) -> None: - """The gap this used to pin, now closed and asserted from the other side. - - Until 2026-08-08 this test asserted `grid_state is None`, because the - simulator supported a MID fully — profile, resolvers, snapshot field — and no - config instantiated one, so the mapping had no evidence behind it. The - producer now publishes a MID and this reads a real value, so the expectation - inverts rather than disappears: the mapping is exercised, and going back to - `None` would be a regression, not a return to normal. - - `ON_GRID` and not `UP` is the substance. The MID publishes both - `grid/islanding-state` (`ON_GRID`) and `grid/grid-state` (`UP`), and reading - the wrong one is precisely the defect corrected on 2026-08-06 — flat-schema - vocabulary sitting in a v1.0 property. Asserting the value proves which - property the reader reached, where asserting "not None" would pass either way. - """ - assert adapter.build_snapshot().grid_state == "ON_GRID", ( - "grid_state must come from the MID's grid/islanding-state. 'UP' or 'DOWN' means " - "the reader has drifted onto grid/grid-state; None means the producer stopped " - "publishing a MID and the mapping is unexercised again." - ) - - -def test_the_mid_identity_is_its_serial(adapter: SchemaOneAdapter) -> None: - """The path that decides whether a consumer can keep the MID device still. - - Its Homie device id is `-mid`, so it inherits the BESS's proxied form -- - `--mid` -- and with it the instability `devices/proxy.md` - describes: a proxied id changes if the device is ever published natively, and moves - with the panel serial besides. `info/serial-number` is what survives, and it is what - a device-registry identifier should be built from. Same reasoning as EVSE, applied - before there are any users to break rather than after. - """ - mid = adapter.build_snapshot().mid - - assert mid is not None, "the SPAN capture publishes a MID; the snapshot should carry it" - assert mid.serial_number is not None - assert mid.node_id == mid.serial_number, "identity must be the serial, not the proxied device id" - assert not mid.node_id.startswith(_PANEL), f"the panel prefix is the proxied form, got {mid.node_id!r}" - assert mid.islanding_state == "ON_GRID" diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py index ad866ee..f11fd78 100644 --- a/tests/test_schema_one_conformance.py +++ b/tests/test_schema_one_conformance.py @@ -19,31 +19,32 @@ - **Conformance** — this adapter against the vendored catalogs. Always runs, from 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 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". +- **Coverage** — this adapter against the reference tree, captured from the + emitter our development is done against. Always runs, from a committed capture. +- **Provenance** — the vendored catalogs against their source, which is the + `ebus-panel-sim` wheel's own `wire/catalogs/`. Always runs too, because the + emitter is a pinned dev dependency and its files are installed rather than + cloned. Nothing here skips. 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 -ones that must run everywhere. +The first two are where the understanding gets checked. """ from __future__ import annotations import ast +from collections.abc import Mapping import importlib +import importlib.resources import json -import os -import subprocess from pathlib import Path import re -from typing import NoReturn -import pytest +from ebus_panel_sim import __version__ as EMITTER_VERSION +import ebus_panel_sim + +from reference_payloads.schema_one import RetainedTopicTree, devices_from_tree, parent_child_tree +from scripts import capture_parent_child_reference as capture_reference from span_panel_api_schema_1 import const from span_panel_api_schema_1.charge_limit import SPELLINGS @@ -54,132 +55,64 @@ # module that happens to hold most of the vocabulary. from span_panel_api_schema_1.panel import PROP_ISLANDING_STATE -_SPEC = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" +_REPO = Path(__file__).parent.parent +_SPEC = _REPO / "packages" / "schema-1" / "spec" _CATALOGS = _SPEC / "catalogs" _DEVICE_TYPES = _SPEC / "registries" / "device-types.md" -_SIMULATOR_TREE = _SPEC / "fixtures" / "simulator_tree.json" -_SIMULATOR_WIRE = _SPEC / "fixtures" / "simulator_wire.json" _SOURCE = Path(const.__file__).parent _LOCK = _SOURCE / "spec_lock.json" +_EMITTER_CATALOGS = Path(str(importlib.resources.files(ebus_panel_sim) / "wire" / "catalogs")) +"""The emitter wheel's own copies of the capability catalogs. -def _lock() -> dict[str, object]: - with _LOCK.open() as handle: - loaded: dict[str, object] = json.load(handle) - return loaded - - -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, 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 panelbench, by kind. +The source our vendored copies are checked against, and it is installed rather +than cloned — `ebus-panel-sim` is a pinned dev dependency, so this directory is +there for every developer and every CI run alike. That is the whole reason the +provenance check below has no skip in it: the sibling checkout it used to need +was a thing an environment could fail to provide, and a check an environment can +switch off is one nobody can rely on. +""" - Two of them, answering different questions: `tree` is `$description` - documents and is what the conformance profile is computed from; `wire` adds - `$state` and every property value, and is the only one that can drive this - 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. +_UNSOURCED_CATALOGS = { + "grid-forming": ( + "the emitter models the BESS as a single device with no inverter child, so it " + "publishes no grid-forming capability and ships no catalog for one. This copy comes " + "from the specification at `synced_commit` and is the one catalog with no installed " + "source to compare against." + ) +} +"""Catalogs we vendor that the emitter does not ship, and why. - 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(PANELBENCH)["fixtures"] - assert isinstance(fixtures, dict) - return {str(kind): str(path) for kind, path in fixtures.items()} +Listed rather than tolerated, for the same reason `_SPAN_EXTENSIONS` is: a file +with no source and a file whose source moved are indistinguishable from a diff, +and only one of them is deliberate. +""" -def _unconfigured(reason: str) -> NoReturn: - """Not configured: skip on a developer machine, fail in CI. +def _lock() -> dict[str, object]: + with _LOCK.open() as handle: + loaded: dict[str, object] = json.load(handle) + return loaded - 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 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 - switched off by a missing environment variable is a check nobody can rely on. +def _without_description_versions(tree: RetainedTopicTree) -> dict[str, dict[str, object]]: + """A capture with each `$description`'s wall-clock `version` dropped. - `CI` rather than a variable of our own, because it is what GitHub Actions and every - other runner already set — an environment that stops supplying a path has to opt - *out* of being an environment, which is not something a workflow edit does by - accident. - """ - if os.environ.get("CI"): - pytest.fail( - 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." - ) - pytest.skip(reason) - - -def _checkout(variable: str, what: str, expect: str | None = None) -> Path: - """A sibling checkout named by an environment variable, or unconfigured. - - A variable that is unset and one pointing at a directory that is gone are the - same situation — the checkout is not available — and both take the same exit. - Letting a stale path through instead produces a FileNotFoundError from somewhere - deep in a comparison, which reads as a broken test rather than an unconfigured one. - Set them in `.env`; see `.env.example`. - - "Gone" includes *emptied*, which is the form this actually takes. A checkout under - a temp directory keeps its `.git` and its directory tree while the reaper removes - the files, so `is_dir()` was true at every level and the comparison still raised. - Presence of a directory proves nothing here; the caller names one that must hold - at least one `.json`, which is what distinguishes a populated checkout from the - skeleton of a reaped one. - - Each of the three states keeps its own message, because they call for different - actions — set the variable, fix the path, or re-clone — and collapsing them would - make the most confusing one, the reaped skeleton, look like the simplest one. + Parsed rather than string-edited, so the comparison is over documents and a + reformatting of the same declaration is not reported as a wire change. + `$description` is the only topic reached into; every other payload is + compared exactly as retained. """ - configured = os.environ.get(variable) - if not configured: - _unconfigured(f"set {variable} to {what}") - path = Path(configured) - if not path.is_dir(): - _unconfigured(f"{variable}={configured} does not exist; point it at {what}") - if expect is not None and not any((path / expect).glob("*.json")): - _unconfigured(f"{variable}={configured} has no files under {expect}/ — the checkout is empty or is not {what}") - return path + normalised: dict[str, dict[str, object]] = {} + for device_id, topics in tree.items(): + body: dict[str, object] = dict(topics) + raw = topics.get("$description") + if raw is not None: + described: object = json.loads(raw) + assert isinstance(described, Mapping), f"{device_id}'s $description is not a JSON object" + body["$description"] = {key: value for key, value in described.items() if key != "version"} + normalised[device_id] = body + return normalised def _catalog(node: str) -> dict[str, object]: @@ -248,20 +181,23 @@ def _read_pairs() -> set[tuple[str, str]]: return pairs -def _simulator_declared() -> set[tuple[str, str]]: - """Every ``(node, property)`` the captured simulator tree declares anywhere. +def _emitter_declared() -> set[tuple[str, str]]: + """Every ``(node, property)`` the reference tree declares anywhere. Flattened across devices rather than kept per device type, matching the granularity of the catalogs: a capability's property set is the same wherever that capability appears. + + Read through the same replay every other test uses rather than off the raw + JSON, because the capture holds `$description` as a retained *string* — the + shape a broker serves — and reaching into it by hand here would be a second + parser for a document `device_from_topics` already knows how to read. """ - with _SIMULATOR_TREE.open() as handle: - tree: dict[str, dict[str, object]] = json.load(handle) return { (node_id, property_id) - for device in tree.values() - for node_id, node in (device.get("nodes") or {}).items() # type: ignore[union-attr] - for property_id in (node.get("properties") or {}) + for device in devices_from_tree(parent_child_tree()) + for node_id in device.get_nodes() + for property_id in device.get_node_properties(node_id) } @@ -313,26 +249,17 @@ def _simulator_declared() -> set[tuple[str, str]]: } -# Properties this adapter reads that the captured simulator tree never declares. +# Properties this adapter reads that the reference tree never declares. # # Not defects on either side, but the precise list of what our development # producer does not exercise — which is exactly the part of the parser that gets # no evidence from testing against it. # -# Empty as of 2026-08-08, and that is a measurement rather than a default. Its -# one entry was grid/islanding-state, excused because the simulator modelled a -# MID but no tracked config published one. The producer now publishes a MID, so -# the entry stopped being true and the check below said so. Every property this -# parser reads is now exercised by the capture it is developed against. -# -# The mechanism stays for the next gap. An empty dict is the honest state, and it -# is load-bearing: the coverage check holds every other mapping with nothing -# excused, so a future producer regression fails rather than lands here. -# -# Non-empty again as of 2026-08-10, with one entry and a different cause than the -# last: not a config that failed to enable a device, but a device class the -# producer does not model at all. -_NOT_EXERCISED_BY_SIMULATOR: dict[tuple[str, str], str] = { +# The mechanism is load-bearing: the coverage check holds every other mapping +# with nothing excused, so a producer regression fails here rather than landing +# quietly. An entry earns its place by naming a reason the emitter cannot +# publish the property, not by recording that it does not. +_NOT_EXERCISED_BY_THE_EMITTER: dict[tuple[str, str], str] = { ("grid-forming", "capable"): ( "BESS model 0.14 decomposes a BESS into `battery` / `inverter` / `mid` child " "roles and puts grid-forming on the inverter. The emitter models the BESS as a " @@ -410,20 +337,25 @@ def test_every_capability_node_this_adapter_reads_has_a_vendored_catalog() -> No assert not missing, f"capability nodes read but not vendored: {sorted(missing)}" -def test_an_unvendored_node_is_one_the_specification_really_does_not_define() -> None: - """The claim behind an excused node, checked against the specification. +def test_an_unvendored_node_is_one_the_emitter_really_does_not_publish() -> None: + """The claim behind an excused node, checked against the emitter. + + `_fully_excused_nodes` says "no catalog exists to vendor". Nothing in the + files we chose to copy can check that, so a capability adopted upstream under + an excused name would stay invisible exactly as long as nobody re-read the + spec — which used to mean until somebody cloned it. - `_fully_excused_nodes` says "no catalog exists to vendor". Nothing else can - check that, because the check runs against the files we chose to copy — so - a capability adopted upstream under an excused name would stay invisible - exactly as long as nobody re-read the spec. Opportunistic, like every other - provenance check here. + Asked of the emitter's catalog set instead, and the narrowing is worth + stating: this now answers "has the producer started carrying a catalog for + this node?" rather than "does the specification define it?". The producer is + the spec in runnable form and vendors these files from it, so an adoption it + publishes against reaches here — and this runs on every machine rather than + on the ones with a checkout, which the previous version did not. """ - spec = _checkout("EBUS_SPEC_DIR", "a specification checkout to verify vendored bytes", expect="capabilities") - adopted = sorted(node for node in _fully_excused_nodes() if (spec / "capabilities" / f"{node}.json").exists()) + adopted = sorted(node for node in _fully_excused_nodes() if (_EMITTER_CATALOGS / f"{node}.json").exists()) assert not adopted, ( - f"the specification now defines these capabilities: {adopted}. Vendor the catalog, " + f"ebus-panel-sim {EMITTER_VERSION} now carries catalogs for {adopted}. Vendor each one, " "pin it in spec_lock.json, and compare what it specifies against what SPAN publishes." ) @@ -538,291 +470,160 @@ def test_an_abstract_unit_is_never_taken_from_the_catalog() -> None: # --------------------------------------------------------------------------- -def test_the_peer_is_pinned_to_the_same_specification_commit() -> None: - """Publisher and consumer must be reading the same vocabulary. - - Checked against the recorded peer rather than a live checkout so it runs - everywhere. Its real job is to make bumping our own pin without looking at - the other side impossible to do quietly. - """ - assert _peer_str("synced_commit") == _lock()["synced_commit"], ( - "this adapter and the simulator it is developed against are pinned to different " - "specification commits; re-vendor both, or record why they may differ." - ) - - -def test_the_recorded_capture_path_names_a_file_that_is_there() -> None: - """`produces.tree` is the one machine-readable statement of where the capture - lives, and three prose readers point at it — DEVELOPMENT.md, the payload - README and the capture script's own usage. - - A path recorded in a lockfile has no compiler: when the capture moved out of - the package directory in 3.1.0 nothing here objected, because nothing - resolved it. Resolving it is the whole check — a rename that leaves this - behind fails at the rename rather than the next time somebody re-captures. - """ - recorded = _peer(PANEL_SIM)["produces"] - assert isinstance(recorded, dict), "peers.ebus-panel-sim.produces should be an object" - tree = recorded["tree"] - assert isinstance(tree, str) - - capture = Path(__file__).parent.parent / tree - assert capture.is_file(), f"spec_lock.json records the reference tree at {tree}, which is not there" - - -def test_the_peer_targets_the_same_firmware() -> None: - """The firmware range is the anchor the two sides actually share — the spec - says what a device class *may* publish, while a panel publishes one tree.""" - firmware = _lock()["firmware"] - assert isinstance(firmware, dict) - - assert _peer_str("firmware_range") == firmware["range"] - - -def test_every_property_read_is_exercised_by_the_simulator() -> None: +def test_every_property_read_is_exercised_by_the_emitter() -> None: """What the producer never publishes, testing against it never proves. - An entry in `_NOT_EXERCISED_BY_SIMULATOR` is not a defect on either side; it - is a precise statement of where this parser has no evidence, which is worth - knowing before trusting a passing suite. + An entry in `_NOT_EXERCISED_BY_THE_EMITTER` is not a defect on either side; + it is a precise statement of where this parser has no evidence, which is + worth knowing before trusting a passing suite. """ - declared = _simulator_declared() + declared = _emitter_declared() unexercised = sorted( f"{node}/{property_id}" for node, property_id in _read_pairs() - if (node, property_id) not in declared and (node, property_id) not in _NOT_EXERCISED_BY_SIMULATOR + if (node, property_id) not in declared and (node, property_id) not in _NOT_EXERCISED_BY_THE_EMITTER ) assert not unexercised, ( - "properties this adapter reads that the captured simulator tree never declares:\n " + "properties this adapter reads that the reference tree never declares:\n " + "\n ".join(unexercised) - + "\n\nEither the simulator should publish them, or record why it does not in " - "_NOT_EXERCISED_BY_SIMULATOR." + + "\n\nEither the emitter should publish them, or record why it does not in " + "_NOT_EXERCISED_BY_THE_EMITTER." ) -def test_nothing_is_recorded_as_unexercised_once_the_simulator_publishes_it() -> None: +def test_nothing_is_recorded_as_unexercised_once_the_emitter_publishes_it() -> None: """When the producer starts covering a gap, the entry stops being true. Left in place it would go on excusing a property that is now testable.""" - declared = _simulator_declared() + declared = _emitter_declared() now_covered = sorted( - f"{node}/{property_id}" for node, property_id in _NOT_EXERCISED_BY_SIMULATOR if (node, property_id) in declared + f"{node}/{property_id}" for node, property_id in _NOT_EXERCISED_BY_THE_EMITTER if (node, property_id) in declared ) assert not now_covered, ( - "the simulator now declares these; drop them from _NOT_EXERCISED_BY_SIMULATOR " + "the emitter now declares these; drop them from _NOT_EXERCISED_BY_THE_EMITTER " "and let the coverage check hold them:\n " + "\n ".join(now_covered) ) # --------------------------------------------------------------------------- -# Provenance — opportunistic, because it needs checkouts +# Provenance — against the installed emitter, so nothing here can be skipped # --------------------------------------------------------------------------- -def test_vendored_catalogs_are_byte_identical_to_the_specification() -> None: +def test_vendored_catalogs_are_byte_identical_to_the_emitters() -> None: """Are the bytes we vendored the bytes we claim they are? - Read out of git **at `synced_commit`** rather than from the checkout's working - tree, so the answer does not depend on what that clone happens to be sitting - on. This used to read the working tree while its own docstring claimed - otherwise, and `synced_commit` appeared only in the failure message. That is - wrong three ways, and one of them is the dangerous one: - - * it **fails** when the clone has moved *ahead* of the pin, which is ordinary - currency drift and not a defect here -- observed the day the specification - went to `power-flows` 0.3; - * it **fails spuriously** with the clone on an unrelated branch; - * it **passes falsely** with a clone itself stale at the pinned commit while - the specification has moved on. - - **Integrity, deliberately not currency.** Whether upstream has moved past our - pin is a separate question whose answer is normally "yes, a little", and it - must not fail a build. Conflating the two is what made this unreliable. - Currency is not checked by anything automatic here, and wants a scheduled job - rather than a gate. - - The upstream reference producer fixed the same defect in its own copy of this - check (`distribution-enclosure-simulator` #47), which is where the framing - comes from. + Compared against `ebus-panel-sim`'s own copies, which are the specification's + `capabilities/` carried in a wheel — the emitter is written by the + organisation that writes the spec, and it publishes against these files + rather than beside them. So this asks the integrity question of the same + artifact the reference capture came out of: our vocabulary and the producer's + are one set of bytes or the comparison says where they differ. + + **Integrity, deliberately not currency.** Whether upstream has moved past the + release we pin is a different question, and it is pip's: Dependabot raises the + bump, the bump PR re-captures, and the suite says whether the wire moved. + Conflating the two is what made the previous version of this check + unreliable — it failed on a sibling clone that had merely moved ahead. + + Nothing here skips. The old version needed `EBUS_SPEC_DIR` to name a + checkout, so it ran only where somebody had cloned the specification, and a + skip reads in a summary line exactly like a pass — which is how a stale + vendored capture went unnoticed for nine days. A pinned dependency is + installed for everyone or the environment is broken outright. """ - spec = _checkout( - "EBUS_SPEC_DIR", - "a specification checkout to verify vendored bytes", - expect="capabilities", - ) - commit = _lock()["synced_commit"] - differing: list[str] = [] - for path in sorted(_CATALOGS.glob("*.json")): - blob = subprocess.run( - ["git", "-C", str(spec), "show", f"{commit}:capabilities/{path.name}"], - capture_output=True, - # Stripped, because `-C` does not beat them. Git hooks export `GIT_DIR` - # and `GIT_INDEX_FILE` pointing at the repository being committed to, - # and an exported `GIT_DIR` wins over directory discovery -- so under - # pre-commit this read the *consumer's* object store, could not find a - # specification commit there, and failed with a fetch instruction for a - # commit the clone already had. Caught by the hook that causes it. - env={k: v for k, v in os.environ.items() if not k.startswith("GIT_")}, - ) - if blob.returncode != 0: - # A clone that cannot resolve the pin fails rather than skipping: a - # silent skip reads exactly like a pass on the one check that proves - # the vendored bytes are what the lockfile says. - pytest.fail( - f"{spec} cannot resolve {commit} (needed to read capabilities/{path.name}). " - f"Fetch it: git -C {spec} fetch origin {commit}" - ) - if blob.stdout != path.read_bytes(): - differing.append(path.name) + differing = [ + path.name + for path in sorted(_CATALOGS.glob("*.json")) + if path.stem not in _UNSOURCED_CATALOGS and path.read_bytes() != (_EMITTER_CATALOGS / path.name).read_bytes() + ] assert not differing, ( - f"vendored catalogs differ from the specification at {commit}: {differing}. " - "These are byte copies, so this is a vendoring defect rather than upstream having moved." + f"vendored catalogs differ from ebus-panel-sim {EMITTER_VERSION}: {differing}. These are byte " + "copies, so either re-vendor them from the installed wheel or the emitter changed what it " + "publishes against — and the reference capture was taken through the second one." ) -def test_the_vendored_captures_match_the_simulator() -> None: - """Both captures against the simulator that produced them. +def test_every_vendored_catalog_has_a_source_or_a_recorded_reason() -> None: + """The comparison above is worth what its inputs are, and a file the emitter + does not ship is silently exempt from it. - Byte comparison for the tree, whose content is deterministic. The wire - capture carries values perturbed by `noise_factor` and an advancing clock, so - it is compared on shape: same devices, same topics. Holding it to bytes would - fail on every recapture for a reason nobody can act on. + Both directions, because both fail quietly. A new unsourced catalog would be + vendored bytes nothing checks; an entry in `_UNSOURCED_CATALOGS` that the + emitter has since started shipping would go on excusing a file that can now + be compared. """ - sim_dir = _checkout("PANELBENCH_DIR", "a panelbench checkout to verify the captured fixtures") - fixtures = _peer_fixtures() - ref, commit = _peer_str("ref"), _peer_str("commit") - - tree_source = sim_dir / fixtures["tree"] - assert tree_source.exists(), f"{tree_source} is missing; is {sim_dir} on {ref}?" - assert ( - tree_source.read_bytes() == _SIMULATOR_TREE.read_bytes() - ), f"the captured tree differs from {tree_source}. Re-capture it and update peer.commit (recorded: {commit})." - - wire_source = sim_dir / fixtures["wire"] - assert wire_source.exists(), f"{wire_source} is missing; is {sim_dir} on {ref}?" - with wire_source.open() as handle: - theirs = json.load(handle) - with _SIMULATOR_WIRE.open() as handle: - ours = json.load(handle) - - assert set(theirs) == set(ours), "the simulator now publishes a different device set than the vendored capture" - differing = sorted(device for device in ours if set(ours[device]) != set(theirs[device])) - assert not differing, ( - f"these devices publish different topics than the vendored capture: {differing}. " - f"Re-vendor from {wire_source} and update peer.commit (recorded: {commit})." - ) - - -def test_the_peer_record_matches_the_simulator_lockfile() -> None: - """What we believe the producer pins, against what it actually pins.""" - sim_dir = _checkout("PANELBENCH_DIR", "a panelbench checkout to verify the peer record") + vendored = {path.stem for path in _CATALOGS.glob("*.json")} + shipped = {path.stem for path in _EMITTER_CATALOGS.glob("*.json")} - with (sim_dir / ".ebus-spec.json").open() as handle: - theirs = json.load(handle) - assert theirs["role"] == _peer_str("role"), "the peer is not publishing; this pairing is not what it claims" - assert theirs["synced_commit"] == _peer_str("synced_commit"), ( - f"the simulator now pins {theirs['synced_commit']}, we recorded {_peer_str('synced_commit')}. " - "Re-vendor and update both, or the two sides are reading different vocabularies." + unchecked = sorted(vendored - shipped - set(_UNSOURCED_CATALOGS)) + assert not unchecked, ( + f"these vendored catalogs have no source in ebus-panel-sim {EMITTER_VERSION} and nothing " + f"says why: {unchecked}. Nothing compares them, so add each to _UNSOURCED_CATALOGS with the " + "reason the emitter does not publish that capability." ) - -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." + now_shipped = sorted(name for name in _UNSOURCED_CATALOGS if name in shipped) + assert not now_shipped, ( + f"ebus-panel-sim {EMITTER_VERSION} now ships {now_shipped}; drop the entry from " + "_UNSOURCED_CATALOGS and let the byte comparison hold them." ) -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 tree 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. +def test_the_shipped_reference_tree_is_what_the_pinned_emitter_produces() -> None: + """Regenerate the capture and compare it to the bytes this repository ships. - 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") + This is the whole provenance mechanism, and it replaces every document that + used to say which release made the tree. Nothing records that any more: the + pin in `pyproject.toml` is the only statement, and this is what holds it true. + A record can go stale silently — that is exactly how the tree went three + emitter releases out of date while thirty test files asserted a producer + defect as fact. A regeneration cannot: it fails on the commit that moved the + pin. - 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) + In-process, so it needs no network, no checkout and no broker — the producer + is installed. On a Dependabot bump of `ebus-panel-sim` this goes red exactly + when the wire moved, and stays green when it did not. - 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." + **One field is normalised: each `$description`'s `version`.** Homie's own + change counter, minted from the wall clock when a device is built, so all + fourteen differ on every run and none of them is a fact about the wire. + Nothing in this library reads it. Everything else — every topic, every + payload, every declaration — is held to the byte. + """ + # Through `serialise` rather than compared as returned. That is the function + # the script writes with, so this compares what *would be committed* against + # what is — and it settles the payload types on the way, because the SDK hands + # the recorder a `DeviceState` for `$state` where its own signature says + # `str`. Comparing the live objects would have leaned on that enum's string + # equality to pass, which is not a thing to depend on. + regenerated = _without_description_versions(json.loads(capture_reference.serialise(capture_reference.capture()))) + shipped = _without_description_versions(parent_child_tree()) + + assert regenerated == shipped, ( + f"ebus-panel-sim {EMITTER_VERSION} no longer produces the reference tree this repository " + "ships. Adopt the new capture and read the diff — it is a wire change:\n" + " uv run python scripts/capture_parent_child_reference.py" ) -def test_an_unconfigured_peer_checkout_fails_in_ci_and_skips_locally(monkeypatch: pytest.MonkeyPatch) -> None: - """The guard on the guard. +def test_the_comparison_would_notice_a_moved_wire() -> None: + """The guard on the guard: a normalisation that swallowed too much would make + the check above pass on any capture at all. - Everything above this line is worth exactly as much as the thing that decides - whether it runs, and that thing is one `if`. It has already gone wrong once in the - other direction: `PANELBENCH_DIR` named a directory that did not exist, every peer - check skipped, and nine days of drift accumulated behind a summary line that read - like a pass. - - So the skip and the failure are both asserted, in both environments, for all three - of the states `_checkout` distinguishes. Asserting only the CI half would leave the - local half free to become a failure, which is the change that makes a developer - delete the check rather than configure it. - - `_checkout` is exercised through its public behaviour — the exception it raises — - rather than by inspecting `_unconfigured`, so this keeps holding if the branch - moves into the callers. + So the one field it drops is dropped by name, and a payload changed anywhere + else has to survive it. `$description` is where a normalisation is most + likely to go wrong, because that is the field being reached into. """ - outcomes = (pytest.fail.Exception, pytest.skip.Exception) - missing = "/nonexistent/peer/checkout" - - monkeypatch.delenv("CI", raising=False) - monkeypatch.setenv("PANELBENCH_DIR", missing) - with pytest.raises(outcomes, match="does not exist") as local: - _checkout("PANELBENCH_DIR", "a panelbench checkout") - assert local.type is pytest.skip.Exception, ( - f"off CI an unavailable checkout must skip, got {local.typename}. Failing instead is " - "what makes a developer without sibling checkouts delete the check rather than configure it" + tree = parent_child_tree() + device = next(iter(tree)) + described = json.loads(tree[device]["$description"]) + described["name"] = "a panel by another name" + mutated = {**tree, device: {**tree[device], "$description": json.dumps(described)}} + + assert _without_description_versions(mutated) != _without_description_versions(tree), ( + "the normalisation drops more than the wall-clock version stamp, so the comparison " + "above would accept a capture whose declarations had changed" ) - - monkeypatch.setenv("CI", "true") - for variable, value, expect, why in ( - ("PANELBENCH_DIR", "", None, "unset"), - ("PANELBENCH_DIR", missing, None, "a path that is gone"), - ("EBUS_SPEC_DIR", str(_SPEC), "no-such-directory", "a checkout reaped to an empty skeleton"), - ): - monkeypatch.setenv(variable, value) - with pytest.raises(outcomes) as raised: - _checkout(variable, "a peer checkout", expect=expect) - assert raised.type is pytest.fail.Exception, ( - f"under CI, {why} must fail rather than {raised.typename.lower()}: a peer check that " - "skips is one an environment can switch off, and the summary line cannot tell the " - "difference between that and a pass" - ) diff --git a/uv.lock b/uv.lock index 4f97670..eac7b5b 100644 --- a/uv.lock +++ b/uv.lock @@ -308,16 +308,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/62/d20526aa3f4c9ebeadf127ff73a6bc608dfe4f310d947ac4f910af1eea36/ebus_mqtt_client-0.5.0-py3-none-any.whl", hash = "sha256:cb9b6599b39c0e28e05283b6d811017ff53c30d31b4eaf35270648fba71a6a77", size = 20225, upload-time = "2026-08-22T04:45:26.31Z" }, ] +[[package]] +name = "ebus-panel-sim" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ebus-sdk" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/e3/8c8804ec873fe17d9f55084d7eef7fe06e9dcbe485c8db4ee40de4544448/ebus_panel_sim-0.8.0.tar.gz", hash = "sha256:f6ee87bf5739b0709b009ea31c09d9f6ab6a0ea198bbd5f481d15cd78d8ceec3", size = 188102, upload-time = "2026-08-27T05:02:56.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/02/cf373b5a2e5aee9fa4801e2f946fbe666596868473b186018a8de42ee020/ebus_panel_sim-0.8.0-py3-none-any.whl", hash = "sha256:d1d1a5bce715a81b6c10932911bee93673b0db14fdae0129ef67512b1b9ef990", size = 97663, upload-time = "2026-08-27T05:02:54.577Z" }, +] + [[package]] name = "ebus-sdk" -version = "0.23.1" +version = "0.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ebus-mqtt-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/de/b50c928bb5639fea939ed7b1dd4bb8e9300e852514a82babcb9bccce6b17/ebus_sdk-0.23.1.tar.gz", hash = "sha256:1ac444c018c011319da29084def7002b87a733b0083dc7d5ee78aa72d71f8312", size = 205970, upload-time = "2026-08-21T15:04:36.568Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/2789cbd4d7a84cabaa6d8cf82cb04c66b35abcb1dfee22b1e9c744dd24e7/ebus_sdk-0.22.0.tar.gz", hash = "sha256:cf31a671cec3737ee17a60e36f444b86ed619382f288a52e8f3727472335386c", size = 200217, upload-time = "2026-08-21T01:17:54.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/0e/e59d94cdd3ed926ab7339cc74fb6f026eccc2d6c94811b4e4671e199329e/ebus_sdk-0.23.1-py3-none-any.whl", hash = "sha256:7d99e136cffe81cbffe13240e4791b38ea0c52c21c818de243c31dec00aa6047", size = 117308, upload-time = "2026-08-21T15:04:35.343Z" }, + { url = "https://files.pythonhosted.org/packages/32/12/d74a67cd9fe615eca54be0266fcf63321cca3997209f0da4aa72d7215d3e/ebus_sdk-0.22.0-py3-none-any.whl", hash = "sha256:8097663199856c0b5f09c5838a1817dda14725f30c45b2bae9f628bb5cac1647", size = 114664, upload-time = "2026-08-21T01:17:52.671Z" }, ] [[package]] @@ -984,6 +997,7 @@ dev = [ { name = "black" }, { name = "coverage" }, { name = "cryptography" }, + { name = "ebus-panel-sim" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pylint" }, @@ -1015,6 +1029,7 @@ dev = [ { name = "black" }, { name = "coverage" }, { name = "cryptography", specifier = ">=50.0.0" }, + { name = "ebus-panel-sim", specifier = "==0.8.0" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pylint" }, @@ -1032,7 +1047,7 @@ dev = [ [[package]] name = "span-panel-api-schema-0" -version = "1.1.1" +version = "1.1.2" source = { editable = "packages/schema-0" } dependencies = [ { name = "span-panel-api" }, @@ -1043,7 +1058,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "1.1.2" +version = "1.1.3" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" },