diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..19c9207 --- /dev/null +++ b/.env.example @@ -0,0 +1,59 @@ +# Local-developer environment variables for span-panel-api. +# +# Copy to `.env` and fill in. `.env` is gitignored and must stay that way. +# +# `tests/conftest.py` reads this file directly, so no direnv or dotenv package is +# 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". + +# 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 live SPAN panel running flat firmware (optional, and nothing needs it) +# --------------------------------------------------------------------------- +# +# Enables `scripts/capture_live_flat.py`, which takes a retained capture from a +# real panel so the frozen flat simulator can be measured against firmware rather +# than trusted. Without it, `test_live_flat_differential.py` skips. +# +# The username IS the panel serial, so treat both of these as secrets and keep +# them here. The capture the script writes is gitignored for the same reason: it +# carries the serial, the household's circuit names and real consumption. Only the +# differential's verdict is ever committed. +# +# TLS is on and certificate validation is off: the panel presents a self-signed +# certificate. +#LIVE_PANEL_HOST=192.168.1.50 +#LIVE_PANEL_PORT=8883 +#LIVE_PANEL_USERNAME=your-panel-serial +#LIVE_PANEL_PASSWORD= diff --git a/.github/actions/peer-checkouts/action.yml b/.github/actions/peer-checkouts/action.yml new file mode 100644 index 0000000..f6ca792 --- /dev/null +++ b/.github/actions/peer-checkouts/action.yml @@ -0,0 +1,96 @@ +name: Peer checkouts +description: > + Clone the two repositories the schema_1 provenance checks verify against — the eBus + specification and SpanPanel/panelbench — and export EBUS_SPEC_DIR / PANELBENCH_DIR + for the steps that follow. + + Every value comes out of packages/schema-1/src/span_panel_api_schema_1/spec_lock.json, + which is the single home of the pin. A workflow that restated a commit here would + give the pin a second home, and the two would agree right up until the day someone + re-vendored and updated only one. + +inputs: + panelbench-ref: + description: > + Which panelbench to clone, and it decides which question the job asks. + + "pin" clones the exact commit peer.commit records, so the byte comparison asks + "do our vendored captures match the commit we claim they came from?" — a + deterministic question with a deterministic answer, safe to block a merge on. + + "default" clones peer.ref, the branch the producer develops on, so the same + comparison asks "has the producer moved past the pin?". That answer changes + because someone else pushed, so it must never gate a pull request. + required: false + default: pin + +outputs: + panelbench-pin: + description: The commit spec_lock.json pins, whichever ref was cloned. + value: ${{ steps.pins.outputs.panelbench-commit }} + panelbench-repo: + description: The panelbench repository, as owner/name. + value: ${{ steps.pins.outputs.panelbench-repo }} + panelbench-checkout: + description: The ref actually cloned — the pinned commit, or the producer's branch. + value: ${{ steps.pins.outputs.panelbench-checkout }} + +runs: + using: composite + steps: + - name: Read the peer pins out of spec_lock.json + id: pins + shell: bash + env: + PANELBENCH_REF_MODE: ${{ inputs.panelbench-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) + peer = lock["peer"] + + def slug(url: str) -> str: + """owner/name, which is what actions/checkout wants.""" + return url.removeprefix("https://github.com/").removesuffix(".git") + + mode = os.environ["PANELBENCH_REF_MODE"] + if mode not in ("pin", "default"): + raise SystemExit(f"::error::panelbench-ref must be 'pin' or 'default', got {mode!r}") + + print(f"spec-repo={slug(lock['spec_repo'])}") + print(f"spec-commit={lock['synced_commit']}") + print(f"panelbench-repo={slug(peer['repo'])}") + print(f"panelbench-commit={peer['commit']}") + print(f"panelbench-checkout={peer['commit'] if mode == 'pin' else peer['ref']}") + PY + + # Both are public, so no token is involved. If either ever goes private this is + # the step that starts failing, and the fix is a PAT with read access in `token:` + # rather than anything about the pin. + - 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.panelbench-ref == 'default' && '0' || '1' }} + path: peers/panelbench + + - 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" + } >> "$GITHUB_ENV" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2efc1e0..8647d9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: + # One entry because `requires-python` is `>=3.14`: the declared floor and + # the version tested are the same, which is the only arrangement where a + # green run actually proves the range. Widen `requires-python` and this + # list has to grow with it -- a floor no job runs is a claim, not a + # guarantee. python-version: ["3.14"] steps: @@ -28,21 +33,44 @@ 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: enable-cache: true - name: Install dependencies - run: uv sync + run: uv sync --all-packages - name: Run pre-commit hooks run: | uv run pre-commit run --all-files + # -rs so a skip that does survive is named in the log rather than counted. The + # only ones expected here are test_live_flat_differential.py, which needs a + # capture from a real panel that is deliberately gitignored. - name: Run tests with pytest run: | - uv run pytest tests/ -v --cov=src/span_panel_api --cov-report=xml --cov-report=term-missing + uv run pytest tests/ -v -rs \ + --cov=src/span_panel_api \ + --cov=packages/schema-0/src/span_panel_api_schema_0 \ + --cov=packages/schema-1/src/span_panel_api_schema_1 \ + --cov-report=xml --cov-report=term-missing @@ -63,11 +91,11 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync + run: uv sync --all-packages - name: Run Bandit security scan run: | - uv run bandit -r src/ -f json -o bandit-report.json || true + uv run bandit -r src/ packages/ -f json -o bandit-report.json || true - name: Upload Bandit scan results uses: actions/upload-artifact@v7 @@ -92,14 +120,40 @@ jobs: enable-cache: true - name: Install dependencies - run: uv sync + run: uv sync --all-packages - - name: Build package - run: uv build + - name: Build packages + run: uv build --all-packages - - name: Check package + - name: Check packages run: uv run twine check dist/* + # Every distribution here is fully annotated, so every distribution has to + # carry the marker that lets a consumer's type checker see those annotations. + # Without it the package resolves to Any downstream and the typing is inert. + - name: Verify every wheel ships a py.typed marker + run: | + python -c " + import glob, sys, zipfile + wheels = glob.glob('dist/*.whl') + if not wheels: + sys.exit('::error::no wheels were built') + for wheel in wheels: + if not any(n.endswith('/py.typed') for n in zipfile.ZipFile(wheel).namelist()): + sys.exit(f'::error::{wheel} ships no py.typed marker; downstream type checking would resolve it as Any') + print(f'{wheel}: py.typed present') + " + + # The configuration entry-point discovery exists to support, and the one + # nothing else in CI exercises: the bootstrap wheel installed with no + # adapter present. It must import, and it must fail by name rather than + # with ModuleNotFoundError. + - name: Verify the bootstrap installs without an adapter + run: | + uv venv /tmp/bootstrap-only + VIRTUAL_ENV=/tmp/bootstrap-only uv pip install dist/span_panel_api-*.whl + VIRTUAL_ENV=/tmp/bootstrap-only uv run --no-project python scripts/verify_adapterless_install.py + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/peer-drift.yml b/.github/workflows/peer-drift.yml new file mode 100644 index 0000000..903b4ff --- /dev/null +++ b/.github/workflows/peer-drift.yml @@ -0,0 +1,125 @@ +name: Peer drift + +# Deliberately never `pull_request`. This asks whether the *producer* has moved past +# the commit we pin, and the answer changes because someone else pushed to panelbench. +# Failing an author's unrelated change for that would teach everyone to ignore it, +# which is how a check stops being a check. +# +# ci.yml asks the other half of the question -- do our vendored bytes still match the +# commit we claim they came from -- against the pinned commit, where the answer is +# deterministic and blocking a merge on it is fair. +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: + panelbench-ref: default + + # Ahead of the comparison, so the summary names the distance whichever way the job + # goes. "N commits behind" with the subjects is what makes the result actionable; + # a red check with no names is a chore. + - name: Report the distance from the pin + env: + PIN: ${{ steps.peers.outputs.panelbench-pin }} + REPO: ${{ steps.peers.outputs.panelbench-repo }} + BRANCH: ${{ steps.peers.outputs.panelbench-checkout }} + run: | + git() { command git -C "$GITHUB_WORKSPACE/peers/panelbench" "$@"; } + head="$(git rev-parse HEAD)" + + if ! git merge-base --is-ancestor "$PIN" HEAD 2>/dev/null; then + { + echo "## $REPO" + echo + echo "\`$PIN\` is not an ancestor of \`$BRANCH\` (\`${head:0:12}\`)." + echo + echo "The pin names a commit this branch does not contain — a branch that was" + echo "rebased, squash-merged or deleted. \`peer.ref\` in \`spec_lock.json\` needs" + echo "to name a ref the pinned commit is actually on." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + behind="$(git rev-list --count "$PIN"..HEAD)" + { + echo "## $REPO" + echo + echo "\`$BRANCH\` is at \`${head:0:12}\`, we pin \`${PIN:0:12}\` — **$behind commits behind**." + if [ "$behind" -gt 0 ]; then + echo + echo '```' + git log --oneline --no-decorate "$PIN"..HEAD + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" + + - 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. + # + # So a red run means the producer changed something we vendor, not merely that it + # advanced -- a README commit leaves this green while still being reported above. + - name: Compare the vendored captures against panelbench's branch + run: | + uv run pytest tests/test_schema_one_conformance.py -v -rs + + - name: Say what a failure means + if: failure() + env: + PIN: ${{ steps.peers.outputs.panelbench-pin }} + run: | + { + echo + echo "### We have fallen behind the producer" + echo + echo "panelbench has changed something this repository keeps a copy of, and the copy" + echo "still reflects \`${PIN:0:12}\`. Read the failing assertion above for which:" + echo "a capture, or the specification commit the producer itself pins." + echo + echo "For a capture, re-vendor and re-pin together, in one change:" + echo + echo '```bash' + echo "cp \$PANELBENCH_DIR/tests/conformance/fixtures/golden_tree.json \\" + echo " packages/schema-1/spec/fixtures/simulator_tree.json" + echo "cp \$PANELBENCH_DIR/tests/conformance/fixtures/golden_wire.json \\" + echo " packages/schema-1/spec/fixtures/simulator_wire.json" + echo '```' + echo + echo "then set \`peer.commit\` in \`packages/schema-1/src/span_panel_api_schema_1/spec_lock.json\`" + echo "to the commit you copied from. A capture without a commit bump records where the" + echo "bytes came from as a guess." + echo + echo "For the specification commit, the two sides are reading different vocabularies" + echo "until \`synced_commit\` and the vendored catalogs move together." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index f838ffa..60bfa2b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,14 @@ dmypy.json coverage_output.log **/.DS_Store .local_coverage_data + +# Captures taken from a real panel. These carry the panel's serial (which is also +# its MQTT username), the household's circuit names, and real consumption — none +# 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 c759e95..dac34f2 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -36,6 +36,19 @@ }, "globs": ["**/*.md"], "ignores": [ + // Byte copies of the eBus specification, verified by byte comparison in + // tests/test_schema_one_conformance.py. Upstream's line lengths are not + // ours to correct, and a fix here would invalidate that comparison. + // `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 7ae6a9a..cc6c95c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,10 +3,15 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: + # `packages/schema-1/spec/` holds byte copies of the eBus specification, + # verified by byte comparison in tests/test_schema_one_conformance.py. Any + # hook that rewrites a file must skip it: a "fix" there would silently + # invalidate the comparison that makes the copies trustworthy. Non-mutating + # checks (check-json) deliberately still run, since a corrupt copy should fail. - id: trailing-whitespace - exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' + exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' - id: end-of-file-fixer - exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' + exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' - id: check-yaml exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' - id: check-toml @@ -20,7 +25,7 @@ repos: exclude: '^src/span_panel_api/generated_client/.*|generate_client\.py|scripts/.*|tests/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*' - id: mixed-line-ending args: ['--fix=lf'] - exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' + exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*' # Ruff for formatting and linting - repo: https://github.com/astral-sh/ruff-pre-commit @@ -55,7 +60,7 @@ repos: - id: prettier types: [markdown] args: ['--config', '.prettierrc.json'] - exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*' + exclude: '^src/span_panel_api/generated_client/.*|^packages/schema-1/spec/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*' # Markdownlint for markdown files (after Prettier formatting) - repo: https://github.com/DavidAnson/markdownlint-cli2 @@ -63,7 +68,7 @@ repos: hooks: - id: markdownlint-cli2 args: ['--config', '.markdownlint-cli2.jsonc'] - exclude: '^src/span_panel_api/generated_client/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|node_modules/.*|htmlcov/.*' + 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 @@ -77,6 +82,10 @@ repos: - 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 args: ['--config-file=pyproject.toml'] exclude: '^src/span_panel_api/generated_client/.*|scripts/.*|tests/.*|docs/.*|examples/.*|\..*_cache/.*|dist/.*|venv/.*' @@ -92,6 +101,9 @@ repos: - pytest - pyyaml - paho-mqtt + # schema-1 imports the eBus SDK; without it here the hook reports + # import-error for a dependency that is correctly declared. + - ebus-sdk>=0.19.0 exclude: '^src/span_panel_api/generated_client/.*|tests/.*|generate_client\.py|scripts/.*|\..*_cache/.*|dist/.*|venv/.*|\.venv/.*|^examples/.*' # Check for common security issues @@ -108,7 +120,7 @@ repos: hooks: - id: vulture name: vulture - entry: bash -c 'uv run vulture src/span_panel_api/ --min-confidence 80' + entry: bash -c 'uv run vulture src/span_panel_api/ packages/schema-0/src/span_panel_api_schema_0/ packages/schema-1/src/span_panel_api_schema_1/ --min-confidence 80' language: system types: [python] pass_filenames: false @@ -131,6 +143,6 @@ repos: name: coverage summary entry: bash language: system - args: ['-c', 'output=$(uv run pytest tests/ --cov=src/span_panel_api --cov-config=pyproject.toml --cov-fail-under=85 -q 2>&1); status=$?; echo "$output"; exit "$status"'] + args: ['-c', 'output=$(uv run pytest tests/ --cov=src/span_panel_api --cov=packages/schema-0/src/span_panel_api_schema_0 --cov=packages/schema-1/src/span_panel_api_schema_1 --cov-config=pyproject.toml --cov-fail-under=85 -q 2>&1); status=$?; echo "$output"; exit "$status"'] pass_filenames: false verbose: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 58882ac..3643167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,139 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +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. What one +beta corrected in an earlier beta does not appear at all: from the point of view of somebody upgrading between released versions, it never happened. + +## [3.0.0] + +`span-panel-api` becomes a transport and a dispatcher that contains **no parser**. Wire formats ship as separate distributions and register themselves through the `span_panel_api.schema_adapters` entry-point group, so support for a new panel schema arrives +by installing a package rather than by upgrading the transport. + +### Removed + +- **BREAKING: `span-panel-api` no longer contains a parser.** Installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. A parser is an install: + + ```console + # flat-schema panels, firmware r202603-r202627 + pip install "span-panel-api[schema-0]" + + # parent/child panels, firmware r202633+ + pip install "span-panel-api[schema-1]" + ``` + + The adapter distributions can equally be named directly; the extras exist because the dependency arrow runs the other way — an adapter declares a floor on the bootstrap, the bootstrap requires no adapter — so upgrading the bootstrap alone would otherwise + leave a stale adapter wheel that discovery then rejects, with pip reporting success. The bootstrap never imports an adapter, and supporting a new panel schema on an existing install is an install rather than an upgrade. + +- **BREAKING: `HomieLifecycle`, `HomiePropertyAccumulator` and `HomieDeviceConsumer` are no longer exported** from `span_panel_api` or `span_panel_api.mqtt`. All three are flat-schema-specific rather than Homie-convention-level: the accumulator filters + every topic against a single device's prefix and stores `node → prop`, which drops nearly every message under the parent/child model; `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description + received ⇒ ready", which is the flat readiness model. They now live in `span_panel_api_schema_0`. +- **Removed dead constants** `DEVICE_TOPIC_FMT`, `STATE_TOPIC_FMT`, `DESCRIPTION_TOPIC_FMT`, `PROPERTY_TOPIC_FMT` (unreferenced) and `TYPE_PCS` (a real schema type this library does not consume). + +### Changed + +- **BREAKING — DER identity speaks the parent/child vocabulary on every device class.** `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike. `product_name` is retired on all three. Flat is the inconsistent side: + it puts the SKU in `bess/model` and in `evse/part-number`, the same concept under two names, and gives PV neither. Mirroring that would have permanently encoded flat's irregularity in the snapshot, so `schema_0` translates flat into the normalised shape + instead. Measured: every EVSE identity field reads identically on both adapters, so for that device class identity stops being a migration delta at all. **`battery.model` changes value for existing flat users at this upgrade** — it gains the designation + where it carried the SKU. That is the deliberate trade: a change scheduled in a library release beats the same change arriving unplanned during a firmware upgrade a user did not choose the timing of. +- **Consumers reading `product_name` must move to `model` in the same release.** The Home Assistant integration builds its device-registry model from it; left unchanged, device cards go blank. +- **Dispatch refuses an unreadable `data-model-version` instead of assuming flat.** Absence still means the flat schema — that is a real signal, since the property was introduced by the firmware that introduced parent/child. A value whose major _can_ be + read but whose form is non-canonical (`1`, `1.0-beta`) dispatches on that major and logs the deviation. A value with no extractable major raises `SpanPanelSchemaVersionError`. Previously all three fell through to the flat parser, which does not fail — it + produces plausible but wrong power and energy figures. +- **`get_homie_schema()` tells "not ready yet" apart from "will not fix itself".** Any 5xx raises `SpanPanelServerError`, a transport failure raises `SpanPanelConnectionError`, and a `200` carrying a truncated or empty body raises `SpanPanelServerError` + rather than surfacing as a parse error. A booting panel brings its network stack and reverse proxy up before the application behind them, so it answers rather than refuses; the distinction is what lets a caller retry that and not retry a 4xx. + +### Added + +#### Adapter architecture + +- **The `SchemaAdapter` protocol, and `ADAPTER_CONTRACT` alongside it.** Member presence is not the whole contract — a Protocol cannot express signatures at runtime, so an adapter carrying every required name and the wrong `__init__` arity would pass + discovery and fail much later inside the transport, as a bare `TypeError` about an argument count. Every adapter declares `ADAPTER_CONTRACT` as a **literal** and discovery rejects anything that does not match this package's `ADAPTER_CONTRACT_VERSION`; a + value read from the installed bootstrap would agree with every bootstrap, which is the disagreement being looked for. The required-member set is derived from every public member the protocol declares, not only the callable ones. +- **`installed_adapter_keys()` and `SpanMqttClient.installed_adapters`.** Enumeration reads distribution metadata only; an adapter is imported the first time a panel asks for that key. A flat panel therefore never imports `schema_1`, and with it never + imports the eBus SDK or jsonschema, for a parser it would not call. The async paths run both in a thread, and resolution stays cached per key, which is what keeps the synchronous pre-rebuild callback free of I/O. +- **`resolve_adapter(key, reason)`** — the single place a missing adapter becomes a named error, used by both dispatch and the transport's default path. +- **`span_panel_api.dispatch.select_adapter_key`**, so the transport can dispatch without importing the factory. `adapters.py` answers "what is installed"; `dispatch.py` answers "what does this panel need". +- **`SpanPanelAdapterMissingError`, `SpanPanelSchemaVersionError` and `SpanPanelAdapterIncompatibleError`**, all exported from the top-level package. The three are separate because the remedy differs: missing means install something, a schema version no + adapter can even be named for means there is nothing to install yet, and incompatible means installing more cannot help. Reporting the third as the first sends someone to install a package they already have. Discovery only _logs_ a rejection, so one + unusable third-party adapter cannot take down a panel whose own adapter is fine; the error surfaces only when the rejected adapter turns out to be the one required. +- **`SpanMqttClient(adapter_factory=...)` is optional.** When omitted the parser is resolved through entry-point discovery at `_build_adapter()`. Resolution is lazy by design: constructing a client must not require an adapter to be installed, only building + a parser must. Dispatch happens wherever a parser is built, so a directly constructed client dispatches exactly as the factory path does. +- **`V2HomieSchema.data_model_version`**, carrying the `dataModelVersion` field and `None` when the panel omits it. Absence is the flat signal and stays distinct from an empty string. + +#### Surviving a firmware upgrade + +- **A panel that changes schema generation mid-life is redispatched rather than reloaded.** The schema is refetched over REST and the parser swapped in place, so an install that upgrades from flat to parent/child keeps running. The new adapter is resolved + **before** any state is touched, so a flat-only install that meets a parent/child panel logs which package is missing and keeps the parser it has instead of raising into a background task. +- **The wait for a panel to finish rebooting does not give up.** Any bound here is sized against a reboot somebody measured, and the next reboot is not that reboot — a live firmware upgrade has been observed taking four minutes from MQTT dropping to the + broker returning, still answering `502` at that point. Giving up has nothing to recommend it: the only things that start another attempt are the reconnect edge and the panel republishing its data-model version, and a panel that finishes booting after the + wait expired produces neither, so running out of attempts means stranded until somebody reloads by hand. +- **The retry interval settles at thirty seconds rather than growing.** Backing off without a ceiling would mean a panel that took a while to return was then ignored for longer than it took. The gap goes 1, 2, 4, 8, 16, 30 and stays there, so once your + panel is answering it is noticed within half a minute however long the wait has already run. Waiting costs nothing you were relying on — energy sensors hold their last reading through an outage on their own grace period, which is untouched by this — and + what is left is one request every thirty seconds to a device on your own network. +- **Nothing escapes the redispatch task.** An unexpected failure there used to surface as a bare `Task exception was never retrieved` while the parser silently stayed on the old generation. It is logged at ERROR naming the consequence and the remedy, + because a reload is the user's only move and nothing else was going to tell them. + +#### Injected HTTP client on the runtime path + +- **`SpanMqttClient` accepts an `httpx_client`, and so does `create_span_client`.** Four config-flow-facing entry points already took an injected client; the runtime path was the one that did not, so every schema read built a throwaway — including the + retry loop that runs during a firmware upgrade, which built one per attempt at exactly the moment the panel was mid-reboot. Optional and defaulted, so nothing outside Home Assistant changes. The ownership rule is the one the existing entry points already + state: a client handed in is never closed here, and its timeouts, limits and headers are the caller's, which is why the per-call `timeout` defaults are ignored when one is given. + +#### Reference payloads shipped in the wheel + +- **`span_panel_api.reference_payloads`, shipping `homie_schema.json` as package data.** The captured `GET /api/v2/homie/schema` response is reached by `homie_schema()` and `homie_schema_types()` rather than by path. It was already being consumed outside + this repository — the Home Assistant integration checks the field paths it declares against what an adapter can actually produce — by vendoring a byte copy with a README explaining where the copy came from. A copy has no version: it goes stale in + silence, and a stale one turns the integration's conformance gate into a check against a schema no panel runs. Shipped, the payload carries the version of the release it came with. `homie_schema_types()` returns `HomieSchemaTypes`, precisely what + `span_panel_api_schema_0.field_metadata.build_field_metadata` accepts, so a caller building metadata never reaches into an untyped document to get it. The parent/child device tree is the other half and ships from `span-panel-api-schema-1`, with the + parser that can interpret it. + +#### New snapshot surface + +Everything below is additive. Each field is `None` or empty on a panel that publishes no such thing, and no flat panel publishes any of it unless stated. + +- **`SpanMidSnapshot` and `SpanPanelSnapshot.mid`.** The parent/child model puts the `grid` capability on a Microgrid Interconnect Device rather than on the enclosure, so islanding state, grid state and the grid-forming entity live there. Presence is + `snapshot.mid is not None` rather than a sentinel field, and identity is `info/serial-number` rather than the Homie device id, which the proxy model warns is not stable across a proxy-to-native transition. +- **`dsm_state` and `current_run_config` are read from the MID.** Both are existing entities that would otherwise degrade to `UNKNOWN` on a parent/child panel: `schema_0` _derives_ them from a multi-signal heuristic, and the parent/child model states the + answer outright. Sensed from a ready MID, falling back to the user's `shed/asserted-islanding-state` when it is not ready, then to a `power-flows/grid` heuristic when there is no MID at all, and unknown otherwise. A missing MID never reports on-grid — it + means SPAN is not the islanding authority, not that the site is on grid, and a generator-fed island is the counterexample. `PANEL_BACKUP` versus `PANEL_OFF_GRID` becomes authoritative rather than guessed. +- **`grid_islandable` is mapped to `grid-forming/capable`** over the BESS's inverter children, as the disjunction — a panel does not island, its DER does, and flat expressed a property of the DER as a property of the enclosure. It returns `None` rather + than `False` when nothing publishes it, so absence stays a gap instead of becoming a claim. No producer publishes it today, which is recorded rather than worked around. +- **`SpanPanelSnapshot.lugs_at_service_entrance`, saying whether this enclosure's upstream lugs are the utility connection point.** `instant_grid_power_w` is those lugs' `meter/active-power`, and the name holds only at the service entrance: a BESS wired + ahead of the main lugs, or an enclosure fed by another enclosure, leaves the lugs metering panel-side flow while the utility side differs by whatever that device contributes or absorbs. `power_flow_grid` stays site-level and correct in both, so the two + legitimately disagree — and before this a consumer seeing them disagree could not tell a topology from a fault. Sourced from the lugs' `connection/fed-by-device-id`, which `power-flows` 0.3 names as the detection mechanism when it qualifies its own + negation table. Defaults `True`, because flat firmware predates chaining and a flat panel's lugs really are its service entrance. +- **`SpanBatterySnapshot.power_w` and `SpanBatterySnapshot.communication_state`.** The battery device has always published `meter/active-power` and `status/communication-state` and neither reached a field, so a consumer could show the enclosure's + arbitrated `power_flow_battery` and nothing the BESS itself reports. `power_w` is **discharge-positive**: the enclosure meters the BESS the way it meters a circuit it feeds, so positive means power flowing _out of_ the battery, matching the eBus rule for + a device's own meter. The asymmetry with `panel.power_flow_battery` is deliberate — the enclosure's arbitrated figure is passed through untouched by both adapters and is charge-positive, so it reads negative for the same discharging battery that makes + `power_w` positive. The two describe the same physical power in opposite frames, and a consumer rendering both negates one of them. `communication_state` stays the published enum string (`OK`/`DEGRADED`/`LOST`/`UNKNOWN`) rather than collapsing to a bool, + because `DEGRADED` is neither `OK` nor `LOST`; it is deliberately not merged into `battery.connected`, which is the _enclosure's_ view of the same link. +- **`SpanEvseSnapshot.connected` and `SpanPVSnapshot.connected`.** `battery.connected` has carried the enclosure's view of the link to the BESS from the upstream lugs' `connection/fed-by-device-status`; the other half of the same capability — a circuit's + `connection/feeds-device-status` — reached nothing, so only one of a panel's three DER classes had a link-health field. `None` is the specification's "unknown" and is load-bearing: the enum is `OK,LOST,DEGRADED` with no `UNKNOWN` member, and a mixed-load + or unsurveyed circuit publishes no connection record at all, which is the normal state for most of a panel's circuits. So absence is never a fault. `DEGRADED` collapses to `False`, because the question this field answers is whether the enclosure can talk + to the device. The charger's link is not the charger's session: `evse.status` is the OCPP-style state the charger reports about the cable in front of it, and a charger mid-session over a lost link publishes `CHARGING` and `connected=False` at once. +- **Five `shed-forecast` fields**: `shed_time_to_priority_shed_min`, `shed_total_time_remaining_min`, `shed_full_charge_time_to_priority_shed_min`, `shed_full_charge_total_time_remaining_min` and `shed_forecast_confidence`. The backup-planning numbers — + how long before my battery starts shedding circuits, how long before it is exhausted — were on the wire and stopped at the transport. All four times are `integer` minutes as the capability declares, parsed so that a publisher serialising a whole number + with a decimal point still resolves; `confidence` stays the raw `LOW`/`MEDIUM`/`HIGH` string, because it qualifies the four times rather than standing alone. `None` is load-bearing here too: zero minutes is a legitimate reading — shedding starts now — so + a defaulted zero would be indistinguishable from the worst forecast the capability can report. +- **`SpanPanelSnapshot.adopted_devices`, reporting a device type this library models nothing for rather than dropping it.** The schema is explicitly vendor-extensible, so an unmodelled device is an expected arrival rather than a hypothetical one; before + this it produced no field, no metadata row and no sign it was there. `AdoptedDevice` carries the device's identity and its readings. **The unit is a device, never a property**: a new property on a device already modelled is a curation task with a short + turnaround, and surfacing it automatically would spend a consumer's entity identity permanently on a shape a human would likely have chosen differently. An unmodelled _type_ is the opposite case — no curation is coming, so silence is the only + alternative. Extra instances of a modelled type are deliberately not adopted either: a second BESS is a multiplicity limit, not an unmodelled device. +- **`AdoptedDevice.parent` and `AdoptedDevice.proxied`**, carrying the proxy link a device declares. Carried rather than acted on — an adopted device is still registered under the enclosure — because a _proxied_ unmodelled device is a real shape that would + otherwise be flattened away unrecorded. The nesting is deliberately not built: proxied ids differ by design and consumers correlate by `info/serial-number` rather than by device id, and the tree model is being reshaped upstream, so the fields capture the + evidence and the topology waits. +- **`AdoptedProperty.set_topic`, `SpanMqttClient.set_adopted_property` and `AdoptedControlProtocol`**, so a settable property on an adopted device can be written and the write cannot reach anything else. The topic is populated only for a settable property + on a device `is_modelled` rejects, so it is the scoping that authorises the write rather than a check a caller has to remember: the transport resolves the property against the current snapshot's `adopted_devices` and publishes to the topic that property + carries, no topic is accepted from the caller, and a device this library models produces no `AdoptedDevice` to find. There is deliberately no translation and no bounds check on an adopted write — both exist on curated controls because this library knows + what those properties mean, and inventing a bound for somebody else's hardware would be inventing a fact. `AdoptedControlProtocol` lets a consumer ask `isinstance` before offering the control, exactly as it does for circuit, panel and EVSE control. +- **`SpanPanelSnapshot.extension_properties`, `ExtensionProperty` and `ExtensionSubject`**, so a vendor property on a device this library _already_ models reaches a consumer instead of stopping at diagnostics. Adoption covers the unmodelled-device half; + this covers the other one, where a new property on the BESS, a charger, a circuit or the panel would otherwise be a declaration with no value, visible only to a maintainer reading a diagnostics attachment. The subject names which modelled snapshot + subject a property hangs off — `battery`, `mid`, `pv`, `panel`, `lugs` with `upstream`/`downstream`, and `evse`/`circuit` with the instance key the snapshot's own maps use — so a consumer resolves the device with a lookup it already performs. What is + _not_ exposed is the field-level mapping: the subject is one value per device and cannot drift, while the wire-property-to-snapshot-field map is the adapter's internal business and exporting it would freeze it as API. +- **An extension property's value never reaches diagnostics, structurally.** `ExtensionProperty` is deliberately not a `FieldMetadata`, so it cannot enter the map `partition()` walks and has no path into a payload that leaves the machine. The discovery + rows keep flowing unchanged: the same property appears in both surfaces on purpose, joined by its `{node}/{property}` path — a declaration for the maintainer, a reading for the user. It is read-only by construction: it carries `settable` for curation + triage and no set topic, and there is no member a write path could be built from. + ## [2.6.4] - 05/2026 ### Fixed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 68dc0d2..5fe1a65 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -2,9 +2,19 @@ ## Prerequisites -- Python 3.10+ (CI tests 3.13 and 3.14) +- Python 3.14 (every manifest declares `>=3.14,<4.0`, and CI runs the suite on 3.14) - [uv](https://docs.astral.sh/uv/) for dependency management +**The declared floor and the tested version are the same on purpose.** A `requires-python` no job runs is a claim rather than a guarantee, and the two drift apart easily, because every developer and every other workflow here is on the newest interpreter. +Keeping them identical means a green run proves the whole declared range instead of one end of it. If the floor is ever widened, the CI matrix has to widen with it in the same change. + +The floor tracks the consumer. Home Assistant requires Python `>=3.12` from 2025.1, `>=3.13.2` from 2025.10 and `>=3.14.2` from 2026.3 — and the SPAN integration that consumes this library requires HA 2026.8 or newer, which puts every install that reaches +this code on 3.14. Declaring anything lower would describe a configuration nobody runs and nothing verifies. + +Two older versions are worth naming as specifically ruled out, so that a future "why not support 3.10?" gets answered without re-deriving it. `tests/test_packaging.py` imports `tomllib`, stdlib only from 3.11. More seriously, Python 3.10 replaces a +`Protocol`'s `__init__` with `(*args, **kwargs)`, so `SchemaAdapter`'s declared constructor signature is not introspectable there and `test_schema_adapter_construction_signature_matches_its_implementation` has nothing to read — the check that stops two +independently-versioned wheels disagreeing about how an adapter is constructed would be inert. For a library built around exactly that seam, that is the wrong place to have a hole. + ## Setup ```bash @@ -29,6 +39,180 @@ python scripts/coverage.py --check --threshold 85 python scripts/coverage.py --full ``` +## Conformance against the specification and the producer + +Some tests verify this library against two things it does not contain: the eBus **specification** (the capability catalogs vendored under `packages/schema-1/spec/catalogs/`) and **panelbench**, the producer whose captures are vendored as reference +payloads. Both are reached through a local checkout named by an environment variable. Locally, both **skip when the variable is unset or wrong** — not every developer keeps sibling checkouts. **Under `CI` they fail instead**, because CI clones both, so an +absent path there means the wiring came undone rather than that the checkout is unavailable. + +Copy `.env.example` to `.env` and point them at real checkouts: + +```bash +EBUS_SPEC_DIR=/path/to/ebus/specification +PANELBENCH_DIR=/path/to/span/panelbench +``` + +### A skip here is not a pass + +This is worth stating plainly because it has already cost us. `test_the_vendored_captures_match_the_simulator` compares the vendored capture byte-for-byte against panelbench's `golden_tree.json` and pins `peer.commit` in `spec_lock.json`. It is the check +that catches the vendored fixture going stale while the producer moves on — which is exactly what happened during the v1.0 capability catch-up, where the reference tree left MID `info/*`, BESS `info/{part,serial,firmware}` and PV `info/firmware-version` +unvalued long after panelbench published all of them. The drift was found by hand. + +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. + +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: + +```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. + +### Two questions, two workflows + +The peer checks answer a question whose shape depends on which panelbench you point them at, and the two answers belong in different places. + +- **`.github/workflows/ci.yml`** clones both peers at the commits `spec_lock.json` pins, via the `.github/actions/peer-checkouts` composite action, and runs the whole suite against them. The question is _do our vendored bytes match the commit we claim they + came from?_ — deterministic, answerable on any commit, and fair to block a merge on. It catches an accidental local edit to a vendored file. +- **`.github/workflows/peer-drift.yml`** runs on a schedule, never on a pull request, and clones panelbench at `peer.ref` — the branch the producer develops on. The question is _has the producer moved past the pin?_ Its answer changes because someone else + pushed, so it must not fail an author's unrelated change. It reports the distance from the pin in the job summary either way, and goes red only when the comparison itself fails, so panelbench advancing with a change we do not vendor stays green. + +Both repositories are public, so neither checkout needs a token. If either ever goes private, the checkout step in the composite action is what starts failing, and the fix is a read-scoped PAT in its `token:` — the pin is not involved. + +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. + +### When the peer check fails + +A failure means the vendored capture and panelbench have diverged. That is information, not an obstacle — decide which side is right: + +- **Panelbench moved and we should follow**: re-capture the fixture, and update `peer.commit` in `spec_lock.json` to the panelbench commit you captured from. Both, together — a capture without a commit bump records where the bytes came from as a guess. +- **We diverged deliberately** (the reference tree is trimmed and renamed to synthetic `example-*` identifiers, so it is not a verbatim copy): the comparison covers the artifacts that _are_ meant to match. Do not loosen it to accommodate a local edit. + +### Catalogs are pinned by commit, not version + +`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. + +### 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 +producer declares for a property agree with the catalog's definition of it?** Agreement is silence. Disagreement is a finding, and it is never resolved silently in either direction — do not change a wire reader to agree with the catalog, and do not assume +the catalog is right. Both have been wrong. + +Four producers are surveyed: the two vendored simulator captures, the reference parent/child tree, and the flat schema document captured from a live panel. The flat document has no capability nodes, so its properties reach the catalogued vocabulary through +the snapshot field path both adapters' metadata tables name — and only where the two spell the property identically, so a pre-catalog **rename** (`dipole` for `breaker/poles`) is left out rather than reported as a divergence. + +When a finding is real, record it in `_REGISTER` with what the wire says, what the catalog says, which producers show it, a reason, and a date. That is a human saying "SPAN ships this and we compensate", and it fails in both directions like every other +baseline here: a new divergence fails until somebody records it, and a **recorded divergence that has disappeared fails until its line is removed**. The second direction is what makes the register self-cleaning when a firmware or a catalog is fixed, and it +is why the register is not a suppression list. + +Two rules keep it from producing false findings: + +- **An abstract unit is a dimension, not a unit.** The catalog gives `soc/soe` and `info/nameplate-capacity` as `unit: "energy"` and requires the publisher to substitute a real one — a BESS in kWh, a water heater in Wh. A member of the family is silent; + echoing the token back is not. Membership is enumerated in `catalog.py`'s `UNIT_FAMILIES`, and a catalog unit token that is neither a known family nor a known concrete unit fails until a human classifies it. +- **An absence is terminal.** A property no catalog defines — the EVSE's `config` node, which is not an eBus capability at all — is reported once as absent and never as a unit or datatype mismatch against a definition that does not exist. + +### What a panel declares that this library reads nothing from + +`schema_1`'s `build_field_metadata` returns a second kind of row alongside the curated ones: for every property a device's `$description` declares that the adapter addresses nowhere, a row under the `discovered.` namespace carrying the declared `datatype`, +the declared `unit`, and whether a value has been published for it. Never the value — these rows exist to be forwarded in a consumer's diagnostics, which leave the machine they were generated on. + +It is additive by construction. There is no new `SchemaAdapter` member and no `ADAPTER_CONTRACT` bump: `_derive_required_members` makes every public protocol member required of every adapter distribution, so adding one would reject every built wheel. An +adapter that emits no such rows is indistinguishable from one built before the namespace existed. + +The flat adapter emits none, deliberately. Its metadata comes from the REST `types` document, which the migration guide describes as the superset across all hardware rather than what one panel has — so "declared and unaddressed" there would describe the +schema document and could not answer the question this exists to ask. + +**The report is only as good as the enumerations behind it, so they are proved rather than trusted.** The adapter decides "addressed" from four tables: `_PROPERTY_FIELD_MAP`, the lugs direction tables, the charge-limit resolution, and +`_CONSUMED_WITHOUT_A_ROW` — the properties the snapshot mapper reads that carry no metadata row because they are identity, topology, or a qualifier rather than a reading. A stale entry in the last of those fails _silently_, by keeping a property out of the +report, and a smaller report looks exactly like a panel with nothing new on it. + +`test_schema_one_discovery.py` closes that by experiment: it republishes every property the reference tree declares with a legal different value, rebuilds the snapshot through the real mapper, and asserts both directions — every claimed-read property moves +a snapshot field, and every reported property moves none. `_CONSUMED_OFF_SNAPSHOT` holds the three declarations consumed by a route no snapshot field can show (tier-1 dispatch, the shadowed islanding tier, the unreached feedthrough branch); each names the +code that reads it, and each fails the day its property does move a field. + +## Devices this library models nothing for + +`TreeRoles` sorts a v1.0 tree into the roles a snapshot needs. Anything matching none of them used to fall off the end silently — a panel publishing a device type nobody modelled produced no field, no metadata row and no sign it was there. The eBus schema +is explicitly vendor-extensible, so that is an expected arrival rather than a hypothetical. + +`span_panel_api_schema_1.adoption` builds an `AdoptedDevice` for each such child, and `build_snapshot` puts them on `SpanPanelSnapshot.adopted_devices`. + +### The two rules that keep it from being a firehose + +**The unit is a device, never a property.** A new property on a device this adapter already models is a curation task with a short turnaround, and surfacing it automatically would spend a consumer's entity identity permanently on a shape a human would +likely have chosen differently. An unmodelled _type_ is the opposite case: no curation is coming, so the alternative is silence. + +**Extra instances of a modelled type are not adopted.** `TreeRoles` keeps the first BESS and ignores the rest, which is a real gap — but adopting the extra one would stand a machine-named record beside a curated one describing the same hardware. The gap +stays visible as a gap. + +`MODELLED_TYPES` states the modelled set once, and `tests/test_adoption.py` parametrises over it through `build_snapshot` rather than through the classifier. That is what stops the tuple drifting from the builder: a type dropped from `TreeRoles` while left +in the tuple would make its devices invisible to both paths at once. + +### `info` and `connection` resolve away from readings + +`ADOPTION_IDENTITY_NODE` (`info`) becomes the device's card fields; `ADOPTION_TOPOLOGY_NODE` (`connection`) is dropped, because it is a device-tree question rather than a reading. + +Keyed on the **node**, not on property names. The catalogs carry no marker for "this string is a device reference", so a name list is the only alternative — and it goes stale silently: `ebus-sdk`'s own `topology.py` covers `feeds-device-id` and +`fed-by-device-id` and omits `grid-forming-entity`, which lives on the `grid` capability. A node is what the vocabulary defines. + +### `AdoptedProperty` carries the value; `DiscoveredMetadata` must not + +The two answer opposite questions and are separate types so that conflating them is a type error rather than a leak: + +| Type | Question | Destination | Carries a value | +| -------------------- | -------------------------------------------- | --------------------------------- | --------------- | +| `DiscoveredMetadata` | "we model this device and read nothing here" | consumer diagnostics, which leave | **no** | +| `AdoptedProperty` | "nothing here models this device at all" | an entity on the same machine | **yes** | + +`AdoptedProperty` also carries the declared `format` and `settable` flag, which together are the value domain a consumer needs to build a control rather than a reading. + +### Writing to an adopted property + +`AdoptedProperty.set_topic` is populated **only** for a settable property on a device `is_modelled` rejects. That scoping is the authorisation rather than a check somebody has to remember: `SpanMqttClient.set_adopted_property` resolves the property against +the current snapshot's `adopted_devices` and publishes to the topic that property carries, and accepts no topic from its caller. + +The alternative — a `set_property_topic(device, node, property)` member on `SchemaAdapter` — was rejected twice over: + +- It would put every curated control one argument away, and two of them do real work on the way out. `dominant_power_source_payload` translates `GRID` into the `ON_GRID` the v1.0 islanding assertion accepts, and `evse_charge_limit_payload` **refuses** a + value above the commissioned ceiling because publishing past it is the one write here with a physical consequence. +- `_derive_required_members` derives the required set from the protocol, so the member would be required of every adapter package. An installation carrying an older adapter wheel would fail at **discovery** — the whole integration, not one feature. + +No translation and no bounds check on the way out. Both exist on curated controls because this library knows what those properties mean; it knows nothing about an adopted one beyond its declaration. + +### The proxy link is carried, not acted on + +`AdoptedDevice.parent` holds the device id the device declares as its parent, and `AdoptedDevice.proxied` says whether that parent is a peer rather than the tree root. Neither changes topology: an adopted device is registered under the enclosure like every +other sub-device. + +They exist because a _proxied_ unmodelled device is a real shape and we would otherwise flatten it away without noticing. The reference tree already contains one — `bess-mid` declares `parent: bess`, which is the `{proxier-id}-{proxied-id}` naming of the +specification's `devices/proxy.md`. A vendor gateway proxying its own sub-devices arrives the same way, and the parent link is the only structural information about how they relate. + +`proxied` is computed here rather than left to the consumer because `root` is in hand here and is deliberately not carried onto the record: device ids are opaque, so a consumer holding one device cannot tell the enclosure's id from a sibling's. + +**Why the nesting is not built.** [python-sdk#49](https://github.com/electrification-bus/python-sdk/issues/49#issuecomment-5359203067) settled two things that bear on it. Proxied ids differ by design — the prefix is the proxier's own id, so several +enclosures on a shared broker each proxying the same physical device produce different ids on purpose, and consumers are told to correlate by `info/serial-number` and never by device id. And `ebus-sdk` 0.21.0 shipped `DeviceSpec` and `DeviceTreeBuilder` +([python-sdk#57](https://github.com/electrification-bus/python-sdk/issues/57)), with the maintainer's stated next step being to reconcile the existing graph builder against it rather than land both. + +So the tree model is under active reconciliation upstream. Carrying the two fields costs nothing and captures the evidence; building nesting semantics against a shape being reshaped this week would be building against a moving target. + +That same comment strengthens two choices already made here. Its deferral mechanism — `device_id` accepts a callable, `None` defers the device, and `resolve_deferred()` resumes when the identifier arrives — is the producer-side form of resolving identity +_before_ a device exists, which is what a consumer's freeze-at-first-sighting does from the other end. And "there is deliberately no existence predicate … expressing it by not calling `add()` is right" is the rule `TreeRoles` and the capability gates +already follow: presence in the tree is the signal, and there is no flag to consult. + +### Additive by construction + +`adopted_devices` defaults to `()`. schema_0 never populates it — flat has no device tree to find an unmodelled device in, and panels upgrade to v1.0 and stay there, so adoption operates in the schema that is the terminus. + +A defaulted snapshot field rather than a `SchemaAdapter` member, deliberately: the protocol derives its required members from itself, so a member there would be required of every adapter package and would invalidate built adapter wheels. +`ADAPTER_CONTRACT_VERSION` does not move. + ## Linting and Formatting Pre-commit hooks run automatically on commit. To run all hooks manually: @@ -70,10 +254,41 @@ To install pre-commit hooks: This installs dependencies (if needed) and configures git pre-commit hooks. +## Workspace layout + +This repository is a uv workspace publishing more than one distribution: the bootstrap (`span-panel-api`, at the root) and one parser package per panel schema (`packages/schema-N/`). `uv sync` installs the workspace, so the test suite runs against every +distribution together. + +To work with the packages individually: + +```bash +# Install the workspace including every member +uv sync --all-packages + +# Build every distribution +uv build --all-packages + +# Build just one +uv build --package span-panel-api-schema-0 +``` + +## Releasing + +See [RELEASE.md](RELEASE.md) — each distribution versions and publishes independently, and the tag name selects which one is published. + ## Contributing 1. Fork and clone the repository 2. Install dev dependencies: `uv sync` 3. Make changes and add tests -4. Ensure all checks pass: `uv run pytest && uv run mypy src/ && uv run ruff check src/` +4. Ensure all checks pass across every distribution, not just the bootstrap: + + ```bash + uv run pytest + uv run mypy src packages + uv run ruff check . + ``` + + `uv run pre-commit run --all-files` is what CI actually runs, and it covers these plus the markdown, security and dead-code hooks. + 5. Submit a pull request diff --git a/README.md b/README.md index 2a95b7a..c1c628b 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,40 @@ A Python client library for the SPAN Panel v2 API, using MQTT/Homie for real-tim ## Installation +Two packages: the transport, and a parser for your panel's schema. `span-panel-api` contains **no parser** — installing it alone gives a client that connects and then raises `SpanPanelAdapterMissingError`. + ```bash -pip install span-panel-api +# flat schema, firmware r202603-r202627 +pip install "span-panel-api[schema-0]" + +# parent/child schema, firmware r202633+ (data-model-version 1.x) +pip install "span-panel-api[schema-1]" + +# support either panel from one install +pip install "span-panel-api[schema-0,schema-1]" ``` +The extras are the recommended spelling because they give `pip install -U` a correct upgrade path; naming `span-panel-api-schema-0` / `span-panel-api-schema-1` directly works too. + +### The parser is hot-loaded, not imported + +`span-panel-api` never imports a parser. Each wire format is its own distribution, registering itself under the `span_panel_api.schema_adapters` entry-point group, and the transport reaches it by key at runtime: + +1. **Ask the panel first.** Before the broker is opened, the client fetches `GET /api/v2/homie/schema` over REST and reads `dataModelVersion`. Absence means the flat schema — a real signal, since the property arrived with the firmware that introduced + parent/child. A value whose major can be read but whose form is non-canonical (`1`, `1.0-beta`) dispatches on that major and logs the deviation; one with no extractable major raises `SpanPanelSchemaVersionError` rather than guessing. +2. **Enumerate without importing.** `installed_adapter_keys()` reads distribution metadata only. Nothing is imported to find out what is installed, so a flat panel never pays for `span-panel-api-schema-1` — nor for the eBus SDK underneath it. +3. **Resolve on demand, once.** The adapter for the selected key is imported the first time a panel asks for it, then cached. The async paths run enumeration and resolution in a thread, so neither blocks the event loop. +4. **Verify the contract before trusting it.** Every adapter declares `ADAPTER_CONTRACT` as a literal, and discovery rejects any that does not match this package's `ADAPTER_CONTRACT_VERSION`. Member presence is not the whole contract — a Protocol cannot + express signatures at runtime — so this is what stops two packages built against different versions of each other failing much later as a bare `TypeError` inside the transport. A rejection is logged rather than raised, so one unusable third-party + adapter cannot take down a panel whose own adapter is fine. +5. **Re-dispatch when the panel changes underneath you.** A panel that upgrades firmware from flat to parent/child mid-life drops MQTT, reboots and comes back on a new schema. The client refetches, resolves the new adapter **before** touching any state, + and swaps the parser in place — no reload. An install with no adapter for the new generation logs which package to install and keeps the parser it has. + +Three errors keep the failure modes apart, because the remedy differs: `SpanPanelAdapterMissingError` (install something), `SpanPanelSchemaVersionError` (a schema no adapter can even be named for), and `SpanPanelAdapterIncompatibleError` (installing more +cannot help). All are exported from the top-level package. + +The consequence worth planning around: **supporting a new panel schema is an install, not an upgrade.** The distributions version independently — see [RELEASE.md](RELEASE.md). + ### Dependencies - `httpx` — v2 authentication and detection endpoints @@ -37,10 +67,15 @@ pip install span-panel-api ### Transport -The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. A two-layer architecture separates generic Homie v5 protocol handling from SPAN-specific interpretation: +The `SpanMqttClient` connects to the panel's MQTT broker (MQTTS or WebSocket) and subscribes to the Homie device tree. It owns the connection, the subscription and the dispatch decision — and nothing else. Everything that knows what a topic _means_ lives +in the adapter for that panel's schema: + +- **The transport** (this package) makes one wildcard subscription, routes messages, tracks connection state, publishes commands, and hands raw messages to whichever parser was resolved for this panel. +- **The parser** (`span-panel-api-schema-0` or `span-panel-api-schema-1`) accumulates properties, decides when the panel is ready to read, and builds typed `SpanPanelSnapshot` dataclasses from what it has. -- **`HomiePropertyAccumulator`** — handles message routing, property and `$target` storage, dirty-node tracking, and an explicit lifecycle state machine (`HomieLifecycle`). Protocol-only; no SPAN domain knowledge. -- **`HomieDeviceConsumer`** — reads from the accumulator via a query API and builds typed `SpanPanelSnapshot` dataclasses. Handles power sign normalization, DSM derivation, unmapped tab synthesis, and dirty-node-aware snapshot caching. +That boundary is why `HomiePropertyAccumulator`, `HomieLifecycle` and `HomieDeviceConsumer` are **not** exported from this package: all three are flat-schema-specific rather than Homie-convention-level. The accumulator filters every topic against a single +device's prefix and stores `node → prop`, which drops nearly every message under the parent/child model, and `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description received ⇒ ready". They live +in `span_panel_api_schema_0`, where that model is correct. The parent/child parser reaches the same result differently, replaying the retained tree through the eBus SDK and waiting for every declared device to describe itself at any depth. Changes are pushed to consumers via callbacks. Dirty-node tracking allows the snapshot builder to skip unchanged nodes, reducing per-scan CPU cost on constrained hardware. @@ -61,7 +96,7 @@ This means the library can be dropped into any asyncio application — including Circuit names arrive as MQTT retained messages that may land after the Homie device transitions to `$state=ready`. The client handles this with a bounded wait during `connect()`: -1. After the device reaches ready state, the client polls `HomieDeviceConsumer.circuit_nodes_missing_names()` every 250ms. +1. After the device reaches ready state, the client polls the resolved adapter's `circuit_nodes_missing_names()` every 250ms — a `SchemaAdapter` member, so both parsers answer it in their own terms. 2. As retained name properties arrive, the consumer stores them. Once all circuit-type nodes have a name, the wait returns immediately. 3. If names have not all arrived within 10 seconds, the timeout expires (non-fatal) and the client proceeds — circuits without names will use fallback identifiers. @@ -69,28 +104,44 @@ This ensures that the first `get_snapshot()` after connect returns human-readabl ### Protocols -The library defines three structural subtyping protocols (PEP 544) that both the MQTT transport and the simulation engine implement: +The library defines structural subtyping protocols (PEP 544). All are `runtime_checkable`, so a consumer asks `isinstance` before offering a control rather than assuming the panel in front of it supports one: | Protocol | Purpose | | -------------------------- | ------------------------------------------------------------------------------------------ | | `SpanPanelClientProtocol` | Core lifecycle: `connect`, `close`, `ping`, `get_snapshot`, `register_connection_callback` | | `CircuitControlProtocol` | Relay and shed-priority control: `set_circuit_relay`, `set_circuit_priority` | | `PanelControlProtocol` | Panel-level control: `set_dominant_power_source` | +| `EvseControlProtocol` | Per-charger control: `set_evse_charge_limit(node_id, amps)` | +| `AdoptedControlProtocol` | Write to a settable property of a device this library models nothing for | | `StreamingCapableProtocol` | Push-based updates: `register_snapshot_callback`, `start_streaming`, `stop_streaming` | -Integration code programs against these protocols, not transport-specific classes. +The first five differ in subject, not just in name. `EvseControlProtocol` is separate from `PanelControlProtocol` because several chargers may be commissioned at once and every call names which one. `AdoptedControlProtocol` differs in kind: the curated +setters name a control this library understands and translate or bound the value on the way out, while this one names a property by its wire address and passes the value through, because the declaration is all anybody here knows about it. That write is +authorised by the snapshot rather than by its arguments — the transport resolves the property against the current `adopted_devices` and refuses anything it does not find carrying a set topic, so a device this library _does_ model cannot be addressed +through it. + +A seventh protocol, `SchemaAdapter`, is the bootstrap-to-parser contract rather than a consumer-facing one; it is what an adapter distribution implements and what discovery checks. Integration code programs against the protocols above, not against +transport-specific classes. ### Snapshots All panel state is represented as immutable, frozen dataclasses: -| Dataclass | Content | -| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, circuits, battery, PV, EVSE | -| `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, `$target` pending state | -| `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, vendor/product metadata, nameplate capacity | -| `SpanPVSnapshot` | PV inverter: vendor/product metadata, nameplate capacity | -| `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, vendor/product/serial/version metadata | +| Dataclass | Content | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `SpanPanelSnapshot` | Complete panel state: power, energy, grid/DSM state, hardware status, per-leg voltages, power flows, lugs current, shed forecast, circuits, battery, PV, EVSE, MID | +| `SpanCircuitSnapshot` | Per-circuit: power, energy, relay state, priority, tabs, device type, breaker rating, current, `$target` pending state | +| `SpanBatterySnapshot` | BESS: SoC percentage, SoE kWh, own meter reading, communication state, link health, `model` / `part_number`, nameplate capacity | +| `SpanPVSnapshot` | PV inverter: link health, `model` / `part_number`, nameplate capacity | +| `SpanEvseSnapshot` | EVSE (EV charger): status, lock state, advertised current, link health, `model` / `part_number` / serial / version metadata | +| `SpanMidSnapshot` | Microgrid Interconnect Device: islanding state, grid state, grid-forming entity | +| `AdoptedDevice` | A device type this library models nothing for, carried whole: identity, readings, proxy link | +| `ExtensionProperty` | A vendor property on a device this library _does_ model, with its value and the subject it hangs off | + +Identity is normalised across every DER class: **`model` is the human designation and `part_number` is the SKU**, on `battery`, `evse` and `pv` alike. `product_name` was retired in 3.0.0 — see the changelog, because `battery.model` changes value for +existing flat users at that upgrade. + +`mid`, `adopted_devices`, `extension_properties` and the per-DER link-health fields exist only under the parent/child schema. They are `None` or empty on a flat panel rather than absent, so a consumer reads the same snapshot type either way. ## Usage @@ -113,7 +164,15 @@ async def main(): # Get a point-in-time snapshot snapshot = await client.get_snapshot() - print(f"Grid power: {snapshot.instant_grid_power_w}W") + # The upstream lugs' own meter. That is grid flow only where the lugs are + # the utility connection point; a BESS wired ahead of them, or a panel fed + # by another panel, makes it this panel's feed instead. `power_flow_grid` + # is the site-level figure in every topology. + if snapshot.lugs_at_service_entrance: + print(f"Grid power: {snapshot.instant_grid_power_w}W") + else: + print(f"Panel feed: {snapshot.instant_grid_power_w}W") + print(f"Grid power: {snapshot.power_flow_grid}W") print(f"Firmware: {snapshot.firmware_version}") print(f"Circuits: {len(snapshot.circuits)}") @@ -331,10 +390,21 @@ All exceptions inherit from `SpanPanelError`: | `SpanPanelTimeoutError` | Request or connection timed out | | `SpanPanelValidationError` | Data validation failure | | `SpanPanelAPIError` | Unexpected HTTP response from v2 endpoints | -| `SpanPanelServerError` | Panel returned HTTP 500 | +| `SpanPanelServerError` | Panel answered 5xx, or answered `200` with a body that cannot be used — "not ready yet" | + +Three more are specific to the hot-loading model, and they are separate because the remedy differs: + +| Exception | Cause | Remedy | +| ----------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------- | +| `SpanPanelAdapterMissingError` | Known schema, no installed parser for it | Install the named package | +| `SpanPanelSchemaVersionError` | The panel reports a `data-model-version` no adapter can even be named for | Nothing to install yet — report the value | +| `SpanPanelAdapterIncompatibleError` | The required adapter is installed but was built against another contract | Installing more cannot help — align versions | + +Reporting the third as the first would send someone to install a package they already have. `SpanPanelStaleDataError` is distinct from `SpanPanelConnectionError`: the former means the client is running but data cannot be trusted right now (transient disconnect, or panel-declared not-ready); the latter means the initial connect failed and the -client cannot be used at all. +client cannot be used at all. `SpanPanelServerError` covers the whole 5xx class deliberately: a booting panel brings its network stack and reverse proxy up before the application behind them, so it _answers_ rather than refuses, and that has to be +distinguishable from a 4xx that will not fix itself on its own. ```python from span_panel_api import ( @@ -370,28 +440,67 @@ The `PanelCapability` flag enum advertises transport features at runtime: | `CIRCUIT_CONTROL` | Can set relay state and shed priority | | `BATTERY_SOE` | Battery state-of-energy available | +## Reference Payloads + +Captures of what a panel actually serves, shipped as package data so a consumer can check its own assumptions against real bytes without vendoring a copy that silently goes stale: + +```python +from span_panel_api.reference_payloads import homie_schema, homie_schema_types + +document = homie_schema() # the captured GET /api/v2/homie/schema response +types = homie_schema_types() # its `types` map, typed as HomieSchemaTypes +``` + +`homie_schema_types()` returns exactly what `span_panel_api_schema_0.field_metadata.build_field_metadata` accepts, so building real adapter metadata to compare against is two lines and no file handling. + +The parent/child device tree is the schema_1 counterpart and ships from that adapter, with the parser that can interpret it: + +```python +from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree + +devices = devices_from_tree(parent_child_tree()) +``` + +Each payload carries the version of the release it shipped in. Pin a version and you read the bytes that version was written against. + ## Project Structure +One repository, three distributions. The bootstrap is at the root; each parser is a workspace member under `packages/`, published separately and versioned on its own axis. + ```text -src/span_panel_api/ -├── __init__.py # Public API exports -├── auth.py # v2 HTTP provisioning (register, cert, schema, passphrase) -├── const.py # Panel state constants (DSM, relay) -├── detection.py # detect_api_version() → DetectionResult -├── exceptions.py # Exception hierarchy -├── factory.py # create_span_client() → SpanMqttClient -├── models.py # Snapshot dataclasses (panel, circuit, battery, PV) -├── phase_validation.py # Electrical phase utilities -├── protocol.py # PEP 544 protocols + PanelCapability flags +src/span_panel_api/ # distribution: span-panel-api (no parser) +├── __init__.py # Public API exports +├── _http.py # Shared httpx plumbing / client ownership rules +├── adapters.py # installed_adapter_keys(), resolve_adapter() — metadata, then lazy import +├── auth.py # v2 HTTP provisioning (register, cert, schema, passphrase) +├── const.py # Panel state constants (DSM, relay) +├── detection.py # detect_api_version() → DetectionResult +├── dispatch.py # select_adapter_key() — what does this panel need? +├── exceptions.py # Exception hierarchy +├── factory.py # create_span_client() → SpanMqttClient +├── models.py # Snapshot dataclasses (panel, circuit, battery, PV, EVSE, MID, adopted) +├── phase_validation.py # Electrical phase utilities +├── protocol.py # PEP 544 protocols, SchemaAdapter, PanelCapability flags +├── schema_drift.py # Reporting a panel that outruns what we can read +├── reference_payloads/ # Captured GET /api/v2/homie/schema, shipped as package data └── mqtt/ ├── __init__.py - ├── accumulator.py # HomiePropertyAccumulator (Homie v5 protocol layer) - ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern) - ├── client.py # SpanMqttClient (all three protocols) - ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads) - ├── const.py # MQTT/Homie constants + UUID helpers - ├── homie.py # HomieDeviceConsumer (SPAN snapshot builder) - └── models.py # MqttClientConfig, MqttTransport + ├── async_client.py # NullLock + AsyncMQTTClient (HA core pattern) + ├── client.py # SpanMqttClient (transport + control protocols) + ├── connection.py # AsyncMqttBridge (event-loop-driven, no threads) + ├── const.py # MQTT/Homie constants + UUID helpers + └── models.py # MqttClientConfig, MqttTransport + +packages/schema-0/ # distribution: span-panel-api-schema-0 +└── src/span_panel_api_schema_0/ + # 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 +└── src/span_panel_api_schema_1/ + # Parent/child parser: ControllerRoutes, snapshot mapper, + # adoption, catalog validator, spec_lock.json, reference payloads ``` ## Development diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..9ff1a45 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,209 @@ +# Releasing + +This repository publishes **more than one PyPI distribution** from a single source tree. That makes releasing less obvious than `git tag && push`, so this document is the reference: what lives where, how a tag selects what gets published, and what an +administrator has to do to release everything. + +## Layout + +One repository, one [uv workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/), independent distributions: + +| Distribution | Directory | Manifest | Purpose | +| ------------------------- | -------------------- | ---------------------------------- | ----------------------------------------------------------------------- | +| `span-panel-api` | repository root | `pyproject.toml` | The **bootstrap** — transport, dispatch, protocols. Contains no parser. | +| `span-panel-api-schema-0` | `packages/schema-0/` | `packages/schema-0/pyproject.toml` | Flat-schema parser (firmware `r202603`–`r202627`) | + +Adapters are discovered at runtime through the `span_panel_api.schema_adapters` entry-point group. The bootstrap never imports an adapter, and adding an adapter to the field is an install, not an upgrade. Future adapters follow the same pattern under +`packages/schema-N/`. + +Consequences for releasing: + +- **Each distribution has its own version number** in its own manifest. +- **Each distribution is its own PyPI project**, with its own trusted publisher. +- **A release publishes exactly one distribution.** Releasing "the repo" means cutting one release per distribution. + +## Two version axes + +The bootstrap and the adapters do not share a version, and this is deliberate rather than an oversight. + +- **The bootstrap** versions on its own library API — the transport and the `SchemaAdapter` protocol. +- **An adapter** versions on _its_ library API. The wire format it parses is fixed and is declared by `SUPPORTS_DATA_MODEL_VERSIONS`, not by the version number. A release of `span-panel-api-schema-0` means the parser changed, never that the panel did. + +So `span-panel-api 3.0.0` and `span-panel-api-schema-0 1.0.0` are unrelated numbers, and either can move without the other. + +Adapters declare a floor on the bootstrap (`span-panel-api>=3.0.0,<4.0`). That dependency is why the versions committed in the manifests are load-bearing: they participate in resolution, so they are not placeholders that a release process may overwrite. + +Those floors name **stable** versions on purpose. A specifier that names a prerelease is pip's own signal that prereleases are acceptable for that requirement, so a floor left pointing at a beta would leave a released install willing to resolve a future +beta of its sibling without anyone asking for one. + +## How a tag selects a distribution + +`.github/workflows/release.yml` runs on `release: published` and derives everything from the tag name. There is no lookup table — the manifest path is computed by convention: + +| Tag | Distribution published | Manifest read | +| ----------------- | ------------------------- | ---------------------------------- | +| `vX.Y.Z` | `span-panel-api` | `pyproject.toml` | +| `schema-N-vX.Y.Z` | `span-panel-api-schema-N` | `packages/schema-N/pyproject.toml` | + +Worked example for `schema-0-v1.0.0b1`: + +```text +TAG = schema-0-v1.0.0b1 +${TAG#schema-} → 0-v1.0.0b1 strip leading "schema-" +${SCHEMA%%-v*} → 0 strip trailing "-v…" ⇒ schema number +PACKAGE = span-panel-api-schema-0 +MANIFEST = packages/schema-0/pyproject.toml +VERSION = ${TAG#schema-0-v} → 1.0.0b1 +``` + +Because the schema number is _extracted_ rather than enumerated, a future `schema-1-v0.1.0` resolves to `packages/schema-1/pyproject.toml` with no change to the workflow. + +A tag matching neither form (`1.2.3`, `nightly`) fails immediately with a message naming both accepted forms. + +## The tag does not set the version + +The workflow **verifies** the version; it does not write it. + +```text +tag schema-0-v1.0.0b1 + ⇒ packages/schema-0/pyproject.toml must declare version = "1.0.0b1" + ⇒ otherwise the job fails without publishing +``` + +This means the release ritual is **bump, commit, then tag** — never tag-and-let-CI-stamp. Earlier versions of this workflow rewrote the version from the tag with `sed`, which cannot work here: there is no single manifest to stamp, and the adapter's +dependency floor on the bootstrap means a stamped version could silently disagree with what resolution actually uses. + +A mismatch is a hard failure with both numbers in the message, so the common mistake — tagging before committing the bump — is caught before anything reaches PyPI. + +## Releasing one distribution + +1. **Bump the version** in that distribution's manifest, and record the change in its `CHANGELOG.md` (the root one for the bootstrap, `packages/schema-N/CHANGELOG.md` for an adapter). + + **Changelogs carry public versions only.** A beta gets no heading of its own: fold its changes into the entry for the public version it is working towards, described against the **last public release** rather than against the beta before it. A fix that + only repairs something an earlier beta broke does not appear at all — from the point of view of somebody upgrading between released versions, it never happened. This keeps the file answering the question a reader actually has ("what changes if I + upgrade?") instead of narrating development. + +2. **Merge to `develop`** (or `main`, once this work is no longer prototype) and let CI go green. +3. **Create a GitHub Release:** + - **Tag** — `vX.Y.Z` or `schema-N-vX.Y.Z`, per the table above. + - **Target** — the branch holding the bump. This defaults to the repository's default branch, which is the easiest thing to get wrong; a tag cut from the wrong branch builds the wrong version and fails the verification step. + - **Set as a pre-release** — tick this for any `aN` / `bN` / `rcN` version. +4. **Watch the run.** `gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId -q '.[0].databaseId')"` + +The job prints exactly what it resolved, which is the first thing to read if something looks wrong: + +```text +Tag 'schema-0-v1.0.0b1' releases span-panel-api-schema-0 1.0.0b1 from packages/schema-0/pyproject.toml +Version 1.0.0b1 confirmed. +``` + +## Releasing every distribution + +There is no "release everything" button, and that is intentional — the distributions version independently, so a coordinated release is a sequence of single-distribution releases rather than one action. + +To release the whole workspace: + +1. Bump every manifest that changed, in one branch, with its changelog entry. +2. If the bootstrap's version moved and adapters need the new floor, update `span-panel-api>=…` in each adapter manifest **in the same branch**. Do not release an adapter whose floor points at a bootstrap version that is not yet on PyPI. +3. Merge and let CI go green. +4. Cut the releases **bootstrap first, then each adapter**: + + ```text + v3.0.0 → span-panel-api + schema-0-v1.0.0 → span-panel-api-schema-0 + schema-1-v1.0.0 → span-panel-api-schema-1 + ``` + + PyPI accepts them in any order, but bootstrap-first means there is never a window in which an adapter is installable and its dependency is not. + +5. Verify from PyPI rather than from CI — see below. + +Only bump and release what actually changed. A distribution with no changes does not need a release just because a sibling had one. + +## Adding a new adapter + +When `packages/schema-N/` lands, the workflow needs no edit — but PyPI does, and this is the step that will be forgotten: + +1. **Create the PyPI project and its trusted publisher before the first release.** Because the project does not exist yet, this is a _pending publisher_, added from account/organization publishing settings rather than from the (non-existent) project page: + + | Field | Value | + | ----------------- | ------------------------- | + | PyPI Project Name | `span-panel-api-schema-N` | + | Owner | `SpanPanel` | + | Repository name | `span-panel-api` | + | Workflow name | `release.yml` | + | Environment name | `release` | + + Every field except the project name is identical across all distributions here, since they all publish from the same repository and workflow. Once the project exists, the same entry is visible and editable at + `https://pypi.org/manage/project//settings/publishing/`. + +2. **Add the package to the workspace** — it is matched by `members = ["packages/*"]` automatically, but the root `[tool.uv.sources]` and the dev dependency group need an entry if the test suite is to exercise it. +3. **Ship a `py.typed` marker** in the new package. CI fails the build without it. + +Trusted publishing verifies repository, workflow filename, and environment — it cannot distinguish _which_ distribution a run is building. That is inherent to a monorepo, and it is why the workflow builds only the tagged package: `dist/` never contains a +sibling that could be uploaded by accident. + +## What the workflow checks + +In order, all before anything is uploaded: + +1. **Tag names a known distribution** — otherwise fail, naming both accepted forms. +2. **The derived manifest exists** — catches a `schema-N` tag with no matching directory. +3. **The tag version equals the committed version** — read with `tomllib`, compared exactly. +4. **Only the tagged package is built** — `uv build --package `, so `dist/` holds exactly one distribution. +5. **Every built wheel ships `py.typed`** — a fully annotated distribution that omits it resolves as `Any` for every downstream consumer, silently undoing the strict typing this repository maintains. + +| Failure | Meaning | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `Tag '…' names no distribution` | Tag is malformed. Use `vX.Y.Z` or `schema-N-vX.Y.Z`. | +| `resolves to '…', which does not exist` | Tag names a schema whose directory is not in this commit — usually a tag cut from the wrong branch. | +| `declares version 'A' but the tag says 'B'` | The bump was not committed, or the release targets the wrong branch. | +| `ships no py.typed marker` | The new package is missing the marker file. | +| OIDC / trusted publishing rejection | The PyPI publisher for that project is missing or does not match. Nothing was uploaded; fix and re-run the job. | + +A failed release is safe. Every check runs before upload, so a failure means nothing reached PyPI and the same tag can be re-run once the cause is fixed. + +## Verifying a release + +CI going green proves the build, not the install. The seam this repository is built around — a bootstrap that finds a parser it never imports — can only be exercised across a real package boundary, so verify from PyPI: + +```bash +# 1. The bootstrap alone must fail by name, not with ModuleNotFoundError +python3 -m venv .solo && ./.solo/bin/pip install span-panel-api +./.solo/bin/python -c " +from span_panel_api.adapters import installed_adapter_keys, resolve_adapter, DEFAULT_ADAPTER_KEY +from span_panel_api.exceptions import SpanPanelAdapterMissingError +print('adapters:', installed_adapter_keys()) +try: + resolve_adapter(DEFAULT_ADAPTER_KEY, 'release check') +except SpanPanelAdapterMissingError as exc: + print('raised as designed:', exc.needed, exc.available) +" + +# 2. Both packages: the adapter resolves through discovery +python3 -m venv .both && ./.both/bin/pip install "span-panel-api[schema-0]" +./.both/bin/python -c " +from span_panel_api.adapters import installed_adapter_keys +print('adapters:', installed_adapter_keys()) +" + +# 3. The extra is the upgrade path, so check it resolves the adapter too +python3 -m venv .all && ./.all/bin/pip install "span-panel-api[schema-0,schema-1]" +./.all/bin/python -c " +from span_panel_api.adapters import installed_adapter_keys +print('adapters:', installed_adapter_keys()) +" +``` + +Expected: `adapters: []` then a named `SpanPanelAdapterMissingError` in the first, `adapters: ['schema_0']` in the second, and both keys in the third. + +Add `--pre` only when the versions being verified are pre-releases. It is not the default verb any more: from 3.0.0 onwards every distribution here publishes stable versions, and no floor in any manifest names a prerelease — which is deliberate, since a +specifier that names one is pip's own signal that prereleases are acceptable for that requirement. + +## Pre-releases + +Versions like `3.0.0b1` are pre-releases in both places that matter: + +- **PyPI** will not install them without `--pre`, so `pip install span-panel-api` continues to resolve the last stable release. +- **GitHub** should have "Set as a pre-release" ticked, which keeps them out of the repository's "Latest release" slot. + +The publish workflow itself does not care — `on: release: published` fires either way. diff --git a/conftest.py b/conftest.py index 2689752..72a68ee 100644 --- a/conftest.py +++ b/conftest.py @@ -11,7 +11,7 @@ @pytest.fixture -def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: +def event_loop() -> Generator[asyncio.AbstractEventLoop]: """Provide a new asyncio event loop for each test (for pytest-homeassistant compatibility).""" loop = asyncio.new_event_loop() yield loop diff --git a/packages/schema-0/CHANGELOG.md b/packages/schema-0/CHANGELOG.md new file mode 100644 index 0000000..e5f4035 --- /dev/null +++ b/packages/schema-0/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +All notable changes to `span-panel-api-schema-0` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is fixed — the flat single-device schema, SPAN firmware `r202603` through `r202627` — and is identified by `SUPPORTS_DATA_MODEL_VERSIONS` +rather than by this version 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.0.0] + +First release as a standalone distribution. Requires `span-panel-api` 3.0.0 or newer. + +### Added + +- **The flat-schema parser, extracted from `span-panel-api` 2.6.4.** Relocated from `span_panel_api._impl.schema_0` to `span_panel_api_schema_0`, and registered as `schema_0` under the `span_panel_api.schema_adapters` entry-point group, which is the only + way `span-panel-api` reaches it — the bootstrap never imports this package. Installing it is what makes flat-schema panels work; `span-panel-api` alone connects and then raises `SpanPanelAdapterMissingError` naming the adapter it could not find. +- **`HomieLifecycle`, `HomiePropertyAccumulator` and `HomieDeviceConsumer` live here now.** All three left the bootstrap because they are flat-schema-specific rather than Homie-convention-level: the accumulator filters every topic against a single device's + prefix and stores `node → prop`, and `HomieLifecycle`'s members are not Homie 5 `$state` values but a consumer-side progression encoding "one description received ⇒ ready". +- **`SCHEMA_ANCHOR`** (`sha256:d347556a07d98f40`, firmware `spanos2/r202603/05`) — the schema revision every hardcoded fact in this package was read from, with `SCHEMA_ANCHOR_FIELD` naming the field it comes from (`typesSchemaHash`). The field is + per-adapter: parent/child firmware renames it to `deviceClassesSchemaHash` along with the block it covers, so `schema_1` declares its own rather than inheriting one that does not exist on its firmware. +- **`ADAPTER_CONTRACT = 1`**, declaring which version of the bootstrap-to-adapter contract this parser was built against. Declared as a literal rather than imported from `span_panel_api.protocol`: a value read from the installed bootstrap would agree with + every bootstrap, which is exactly the disagreement the check exists to find. +- **`dominant_power_source_payload`.** Flat already speaks this vocabulary, so the value passes through — the method exists because `schema_1` must translate, and a caller should not have to know which schema is underneath. Validated rather than passed + blindly: an unrecognised value returns `None` and the transport refuses the command, matching `schema_1` rather than putting a string outside the enum on the wire. +- **`set_evse_charge_limit_topic` and `evse_charge_limit_payload`.** Both are required of every adapter, because `_derive_required_members` makes each public protocol member mandatory of every adapter wheel — an adapter without them is rejected at + discovery no matter which panel it would have parsed. Flat firmware publishes no charge-limit surface, so this distribution answers for the absence rather than for a topic; the point is that answering is not optional. +- **`adopted_devices` reports empty.** Adoption is a parent/child idea: a flat panel is one device with no unmodelled children to adopt, so the honest answer is a stable empty tuple rather than an unimplemented member. `set_adopted_property` therefore + raises on a flat panel for the same reason it raises for a device that does not exist, which is what makes the snapshot lookup an authorization rather than a lookup. +- **Panel size is derived here**, by reading the circuit `space` format out of the flat schema's `types` block — knowledge that belongs to this package rather than to the transport, which previously did it on every adapter's behalf. +- **Provenance tests** asserting that all 64 hardcoded `(node_type, property_id)` pairs still resolve against the captured schema, that `HOMIE_DOMAIN` / `HOMIE_VERSION` still match it, and that the two lugs subtypes real firmware publishes remain absent + from the schema _and_ present in the metadata alias table. This is the only signal that catches schema drift before release; every other symptom reaches production as a silent absence. +- **A `py.typed` marker**, so consumers type-check against this package's real annotations rather than resolving everything it exports as `Any`. + +### Changed + +- **BREAKING — DER identity is translated into the parent/child vocabulary rather than mirroring flat's names.** `model` is the human designation and `part_number` the SKU, on `battery`, `evse` and `pv` alike; `product_name` is retired on all three. Flat + is the irregular side: it puts the SKU in `bess/model` and in `evse/part-number` — the same concept under two names — and gives PV neither. Mirroring that would have permanently encoded flat's irregularity in the snapshot, so this adapter normalises + instead: `bess/model` → `part_number`, `bess/product-name` → `model`. **`battery.model` changes value for existing flat users at this upgrade.** Measured: every EVSE identity field now reads identically on both adapters, so for that device class identity + stops being a migration delta at all. + +### Known deviations from the published schema + +- **Circuit `active-power` is treated as watts, though the schema declares kilowatts.** Real panels publish watts; this was established against live hardware and the 1000× correction was removed accordingly. A test asserts the schema still says `kW`, so + the day SPAN corrects it we find out rather than discovering it as a factor-of-1000 error. +- **`energy.ebus.device.lugs.upstream` / `.downstream` are parsed but undeclared.** Firmware publishes these node types in `$description`; the schema declares only the base `energy.ebus.device.lugs`. Property metadata for them resolves through an alias to + the base type. + +### Retirement + +SPAN retires the flat schema in the same firmware release that introduces the parent/child model (`r202633`; fleet rollout projected, not committed, for the first two weeks of September 2026). This package stops being published once the fleet has moved. +Published versions remain on PyPI for anyone still running older firmware. diff --git a/packages/schema-0/README.md b/packages/schema-0/README.md new file mode 100644 index 0000000..68b5e14 --- /dev/null +++ b/packages/schema-0/README.md @@ -0,0 +1,33 @@ +# span-panel-api-schema-0 + +The **flat-schema** parser for [`span-panel-api`](https://github.com/SpanPanel/span-panel-api): the single-device Homie model published by SPAN firmware `r202603` through `r202627`, which carries no `data-model-version`. + +## Why this is a separate distribution + +`span-panel-api` is a transport and a dispatcher. It knows how to connect to a panel's MQTT broker, route messages, and choose a parser — but it contains no parsing code and no Homie type strings. Each wire format ships as its own distribution and +registers itself under the `span_panel_api.schema_adapters` entry-point group. + +That split exists because the two halves break on different axes. The wire format changes when SPAN ships firmware; the library API changes when we do. Separate distributions let each carry its own version, so a consumer can pin them independently and add +support for a new panel schema by installing a package rather than by upgrading the transport. + +## Installation + +```console +pip install "span-panel-api[schema-0]" +``` + +Installing this package is what makes flat-schema panels work. `span-panel-api` on its own will connect and then raise `SpanPanelAdapterMissingError` naming the adapter it could not find. + +A consumer that wants to support panels on either schema installs both adapters: + +```console +pip install "span-panel-api[schema-0,schema-1]" +``` + +Dispatch happens at runtime, per panel, from the `data-model-version` the panel reports. The extras are the recommended spelling because they give `pip install -U` a correct upgrade path — the dependency arrow runs from adapter to bootstrap, so upgrading +the bootstrap alone would otherwise leave a stale adapter wheel that discovery then rejects, with pip reporting success. Naming the distributions directly works too. + +## Retirement + +SPAN retires the flat schema in the same release that introduces the parent/child model (`r202633`, fleet rollout projected for early September 2026). When the fleet has moved, consumers drop this package from their requirements. Published versions stay on +PyPI for anyone still running older firmware. diff --git a/packages/schema-0/pyproject.toml b/packages/schema-0/pyproject.toml new file mode 100644 index 0000000..2562a46 --- /dev/null +++ b/packages/schema-0/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "span-panel-api-schema-0" +version = "1.0.0" +description = "Flat-schema (data-model-version absent) parser for span-panel-api" +authors = [ + {name = "SpanPanel"} +] +readme = "README.md" +license = "MIT" +requires-python = ">=3.14,<4.0" +dependencies = [ + # Stated as a stable version rather than as the prerelease the floor tracked + # during development: naming a prerelease in a specifier is pip's own signal + # that prereleases are acceptable for that requirement. + "span-panel-api>=3.0.0,<4.0", +] + +[project.urls] +Homepage = "https://github.com/SpanPanel/span-panel-api" +Issues = "https://github.com/SpanPanel/span-panel-api/issues" + +# The whole point of this distribution. The bootstrap finds this adapter by +# discovering the group, never by importing this package. +[project.entry-points."span_panel_api.schema_adapters"] +schema_0 = "span_panel_api_schema_0:SchemaZeroAdapter" + +# Resolve the bootstrap from the workspace when developing here. Published +# wheels are unaffected: this table is uv-only metadata and the dependency +# above is what a consumer installing from PyPI sees. +[tool.uv.sources] +span-panel-api = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/span_panel_api_schema_0"] diff --git a/packages/schema-0/src/span_panel_api_schema_0/__init__.py b/packages/schema-0/src/span_panel_api_schema_0/__init__.py new file mode 100644 index 0000000..6b7b783 --- /dev/null +++ b/packages/schema-0/src/span_panel_api_schema_0/__init__.py @@ -0,0 +1,11 @@ +"""Flat-schema adapter package (data-model-version absent).""" + +from span_panel_api_schema_0.adapter import SchemaZeroAdapter + +# Re-exported from the adapter rather than restated. The protocol requires the +# range as a class attribute, so the class is the source of truth; a second +# literal here would be free to drift, and nothing would notice until a panel +# reported a version this adapter claims — falsely — to support. +SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = SchemaZeroAdapter.SUPPORTS_DATA_MODEL_VERSIONS + +__all__ = ["SUPPORTS_DATA_MODEL_VERSIONS", "SchemaZeroAdapter"] diff --git a/src/span_panel_api/mqtt/accumulator.py b/packages/schema-0/src/span_panel_api_schema_0/accumulator.py similarity index 98% rename from src/span_panel_api/mqtt/accumulator.py rename to packages/schema-0/src/span_panel_api_schema_0/accumulator.py index a102ac8..eeae58f 100644 --- a/src/span_panel_api/mqtt/accumulator.py +++ b/packages/schema-0/src/span_panel_api_schema_0/accumulator.py @@ -13,7 +13,8 @@ import logging import time -from .const import HOMIE_STATE_DISCONNECTED, HOMIE_STATE_LOST, HOMIE_STATE_READY, TOPIC_PREFIX +from span_panel_api.mqtt.const import HOMIE_STATE_DISCONNECTED, HOMIE_STATE_LOST, HOMIE_STATE_READY +from span_panel_api_schema_0.const import TOPIC_PREFIX _LOGGER = logging.getLogger(__name__) diff --git a/packages/schema-0/src/span_panel_api_schema_0/adapter.py b/packages/schema-0/src/span_panel_api_schema_0/adapter.py new file mode 100644 index 0000000..9e14bed --- /dev/null +++ b/packages/schema-0/src/span_panel_api_schema_0/adapter.py @@ -0,0 +1,115 @@ +"""Flat-schema (data-model-version absent) adapter. + +Composes the existing accumulator + consumer and owns the flat wire format: +a single Homie device whose node ids are circuit UUIDs and capability names. +Nothing outside this package constructs a flat-schema topic. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.const import PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT +from span_panel_api_schema_0.consumer import HomieDeviceConsumer +from span_panel_api_schema_0.field_metadata import build_field_metadata + +if TYPE_CHECKING: + from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema + + +class SchemaZeroAdapter: + """Parser for the flat single-device schema (firmware r202603-r202627).""" + + # A literal, deliberately not imported from span_panel_api.protocol: a value + # read from the installed bootstrap would agree with every bootstrap, which + # is the disagreement the check exists to find. Bump when this adapter is + # rebuilt against a new contract, never to match what happens to be installed. + ADAPTER_CONTRACT: int = 1 + schema_major = "schema_0" + SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=0", "<1.0") + + def __init__(self, serial_number: str, schema: V2HomieSchema) -> None: + self._serial_number = serial_number + # `panel_size` is derived here rather than handed in, because deriving + # it means reading the flat schema's `types` block for the circuit + # `space` format — knowledge that belongs to this package. The + # transport used to do this on every adapter's behalf, which only + # worked while every adapter was this one. + self._schema = schema + self._accumulator = HomiePropertyAccumulator(serial_number) + self._consumer = HomieDeviceConsumer(self._accumulator, schema.panel_size) + + def topics_to_subscribe(self) -> list[str]: + return [WILDCARD_TOPIC_FMT.format(serial=self._serial_number)] + + def handle_message(self, topic: str, payload: str) -> None: + self._consumer.handle_message(topic, payload) + + def is_ready(self) -> bool: + return self._consumer.is_ready() + + def build_snapshot(self) -> SpanPanelSnapshot: + return self._consumer.build_snapshot() + + def build_field_metadata(self) -> dict[str, FieldMetadata]: + return build_field_metadata(self._schema.types) + + def circuit_nodes_missing_names(self) -> list[str]: + return self._consumer.circuit_nodes_missing_names() + + def find_node_by_type(self, type_str: str) -> str | None: + return self._consumer.find_node_by_type(type_str) + + def set_circuit_relay_topic(self, circuit_id: str) -> str: + return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="relay") + + def set_circuit_priority_topic(self, circuit_id: str) -> str: + return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="shed-priority") + + def set_dominant_power_source_topic(self) -> str | None: + core_node = self._consumer.find_node_by_type(TYPE_CORE) + if core_node is None: + return None + return PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=core_node, prop="dominant-power-source") + + def dominant_power_source_payload(self, value: str) -> str | None: + """Flat speaks this vocabulary already, so the caller's value passes through. + + The method exists because `schema_1` has to translate — its successor + property accepts `NONE`/`ON_GRID`/`OFF_GRID`, not a source class — and a + caller should not have to know which schema it is talking to. Here the + translation is the identity. + + Validated rather than passed blindly: an unrecognised value returns None + and the transport refuses the command, which matches `schema_1`'s + behaviour and is better than putting a string outside the enum on the + wire. + """ + allowed = {"GRID", "BATTERY", "PV", "GENERATOR", "NONE", "UNKNOWN"} + candidate = value.strip().upper() + return candidate if candidate in allowed else None + + def set_evse_charge_limit_topic(self, node_id: str) -> str | None: # pylint: disable=unused-argument + """None: flat firmware publishes no charge-current ceiling to write. + + The flat `energy.ebus.device.evse` type carries `advertised-current` — + what the charger is offering the vehicle, read-only — and nothing that + sets it. There is no property to aim a set topic at, so the transport + refuses the command rather than publishing to a topic no panel of this + generation subscribes to. + + `node_id` is accepted and unused for the same reason + `set_dominant_power_source_topic` takes no arguments and still returns + None on a panel with no core node: the answer does not depend on which + charger is asked. + """ + return None + + def evse_charge_limit_payload(self, node_id: str, amps: int) -> str | None: # pylint: disable=unused-argument + """None, for the same reason: no property, so no representable value.""" + return None + + def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]: + return self._consumer.register_property_callback(callback) diff --git a/packages/schema-0/src/span_panel_api_schema_0/const.py b/packages/schema-0/src/span_panel_api_schema_0/const.py new file mode 100644 index 0000000..e94dc5c --- /dev/null +++ b/packages/schema-0/src/span_panel_api_schema_0/const.py @@ -0,0 +1,83 @@ +"""Constants for the flat-schema (Homie v5) parsing implementation.""" + +# --------------------------------------------------------------------------- +# Provenance anchor — the schema revision every fact in this module was read +# from. `tests/test_schema_provenance.py` fails when a captured schema reports a +# different one, which is the only pre-release signal that this adapter has +# drifted from the wire it claims to parse. +# +# The field name is per-adapter, not per-bootstrap: flat firmware publishes +# `typesSchemaHash` over a `types` block, while parent/child renames it to +# `deviceClassesSchemaHash` over `deviceClasses` — the hash is renamed with the +# block it covers, so schema_1 declares its own. +# +# Content-derived, not build-derived: SPAN defines it as the SHA-256 of the +# canonicalized schema object and states the schema "may remain unchanged across +# multiple firmware releases". So it moves when the schema moves, not on every +# release — which is what makes it usable as an anchor rather than noise. +# --------------------------------------------------------------------------- +SCHEMA_ANCHOR_FIELD = "typesSchemaHash" +SCHEMA_ANCHOR = "sha256:d347556a07d98f40" +SCHEMA_ANCHOR_FIRMWARE = "spanos2/r202603/05" + +# Homie v5 topic structure +HOMIE_VERSION = 5 +HOMIE_DOMAIN = "ebus" +TOPIC_PREFIX = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}" + +# Topic patterns (serial_number substituted at runtime). +# The adapter subscribes with the wildcard and publishes with the set pattern; +# per-topic read formats are not needed because every message arrives through +# the one wildcard subscription. +PROPERTY_SET_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}/set" +WILDCARD_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/#" + +# --------------------------------------------------------------------------- +# Homie type strings. +# +# Two namespaces that are easy to conflate and are NOT the same set: +# +# * the `types` block of GET /api/v2/homie/schema, which declares the +# properties, units and datatypes available to a type; and +# * the `type` string a node actually carries in its $description on the wire. +# +# Every constant below is a node type observed on the wire. The ones in the +# first group are also declared in the schema, so metadata lookup finds them +# directly. See tests/test_schema_provenance.py, which asserts that. +# --------------------------------------------------------------------------- +TYPE_CORE = "energy.ebus.device.distribution-enclosure.core" +TYPE_LUGS = "energy.ebus.device.lugs" +TYPE_CIRCUIT = "energy.ebus.device.circuit" +TYPE_BESS = "energy.ebus.device.bess" +TYPE_PV = "energy.ebus.device.pv" +TYPE_EVSE = "energy.ebus.device.evse" +TYPE_POWER_FLOWS = "energy.ebus.device.power-flows" + +# Wire-only subtypes: real node types published by real firmware (confirmed +# against a live panel in 1eef0dc), but NOT declared in the schema's `types` +# block, which carries only the base `energy.ebus.device.lugs`. Firmware uses +# one convention or the other — typed nodes, or generic nodes plus a +# `direction` property — and _find_lugs_node handles both. +# +# Because the schema does not declare them, every one of these needs an entry +# in field_metadata._LUGS_FALLBACK mapping it to a declared type, or property +# metadata silently comes back empty for those nodes. The provenance test +# asserts that pairing rather than trusting it. +TYPE_LUGS_UPSTREAM = "energy.ebus.device.lugs.upstream" +TYPE_LUGS_DOWNSTREAM = "energy.ebus.device.lugs.downstream" + +# Lugs direction values +LUGS_UPSTREAM = "UPSTREAM" +LUGS_DOWNSTREAM = "DOWNSTREAM" + + +def normalize_circuit_id(node_id: str) -> str: + """Strip dashes from Homie UUID for entity stability.""" + return node_id.replace("-", "") + + +def denormalize_circuit_id(circuit_id: str) -> str: + """Restore dashes to a 32-char dashless UUID (8-4-4-4-12 format).""" + if len(circuit_id) == 32 and "-" not in circuit_id: + return f"{circuit_id[:8]}-{circuit_id[8:12]}-{circuit_id[12:16]}-{circuit_id[16:20]}-{circuit_id[20:]}" + return circuit_id diff --git a/src/span_panel_api/mqtt/homie.py b/packages/schema-0/src/span_panel_api_schema_0/consumer.py similarity index 96% rename from src/span_panel_api/mqtt/homie.py rename to packages/schema-0/src/span_panel_api_schema_0/consumer.py index 88767e8..83f7a4e 100644 --- a/src/span_panel_api/mqtt/homie.py +++ b/packages/schema-0/src/span_panel_api_schema_0/consumer.py @@ -12,9 +12,15 @@ import time from typing import ClassVar -from ..models import SpanBatterySnapshot, SpanCircuitSnapshot, SpanEvseSnapshot, SpanPanelSnapshot, SpanPVSnapshot -from .accumulator import HomiePropertyAccumulator -from .const import ( +from span_panel_api.models import ( + SpanBatterySnapshot, + SpanCircuitSnapshot, + SpanEvseSnapshot, + SpanPanelSnapshot, + SpanPVSnapshot, +) +from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.const import ( LUGS_DOWNSTREAM, LUGS_UPSTREAM, TYPE_BESS, @@ -316,8 +322,12 @@ def _build_battery(self) -> SpanBatterySnapshot: soe_percentage=_parse_float(soc_str) if soc_str else None, soe_kwh=_parse_float(soe_str) if soe_str else None, vendor_name=vn if vn else None, - product_name=pn if pn else None, - model=mdl if mdl else None, + # Flat is the irregular side: it puts the SKU in `model` on the BESS and in + # `part-number` on the EVSE, for the same concept. The snapshot speaks v1.0's + # vocabulary now, so translate rather than mirror -- `product-name` is the + # designation and flat's `bess/model` is the SKU. + model=pn if pn else None, + part_number=mdl if mdl else None, serial_number=sn if sn else None, software_version=sw if sw else None, nameplate_capacity_kwh=_parse_float(nc) if nc else None, @@ -332,13 +342,15 @@ def _build_pv(self) -> SpanPVSnapshot: vn = self._acc.get_prop(pv_node, "vendor-name") pn = self._acc.get_prop(pv_node, "product-name") + sw = self._acc.get_prop(pv_node, "software-version") nc = self._acc.get_prop(pv_node, "nameplate-capacity") feed = self._acc.get_prop(pv_node, "feed") rel_pos = self._acc.get_prop(pv_node, "relative-position") return SpanPVSnapshot( vendor_name=vn if vn else None, - product_name=pn if pn else None, + model=pn if pn else None, + software_version=sw if sw else None, nameplate_capacity_w=_parse_float(nc) if nc else None, feed_circuit_id=normalize_circuit_id(feed) if feed else None, relative_position=rel_pos.upper() if rel_pos else None, @@ -361,7 +373,7 @@ def _build_evse_devices(self) -> dict[str, SpanEvseSnapshot]: lock_state=self._acc.get_prop(node_id, "lock-state") or "UNKNOWN", advertised_current_a=_parse_float(adv) if adv else None, vendor_name=self._acc.get_prop(node_id, "vendor-name") or None, - product_name=self._acc.get_prop(node_id, "product-name") or None, + model=self._acc.get_prop(node_id, "product-name") or None, part_number=self._acc.get_prop(node_id, "part-number") or None, serial_number=self._acc.get_prop(node_id, "serial-number") or None, software_version=self._acc.get_prop(node_id, "software-version") or None, diff --git a/src/span_panel_api/mqtt/field_metadata.py b/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py similarity index 76% rename from src/span_panel_api/mqtt/field_metadata.py rename to packages/schema-0/src/span_panel_api_schema_0/field_metadata.py index a888506..be7c0b7 100644 --- a/src/span_panel_api/mqtt/field_metadata.py +++ b/packages/schema-0/src/span_panel_api_schema_0/field_metadata.py @@ -15,10 +15,8 @@ from __future__ import annotations -import logging - -from ..models import FieldMetadata, HomieSchemaTypes -from .const import ( +from span_panel_api.models import FieldMetadata, HomieSchemaTypes +from span_panel_api_schema_0.const import ( TYPE_BESS, TYPE_CIRCUIT, TYPE_CORE, @@ -30,14 +28,13 @@ TYPE_PV, ) -_LOGGER = logging.getLogger(__name__) - # --------------------------------------------------------------------------- # Static mapping: (node_type, property_id) → snapshot field path # # This encodes the library's internal knowledge of how _build_snapshot() # maps Homie properties to snapshot dataclass fields. The mapping must be -# kept in sync with homie.py. +# kept in sync with consumer.py (which held this class as homie.py before +# the Phase 0 relocation). # --------------------------------------------------------------------------- _PROPERTY_FIELD_MAP: tuple[tuple[str, str, str], ...] = ( @@ -85,8 +82,12 @@ (TYPE_BESS, "soc", "battery.soe_percentage"), (TYPE_BESS, "soe", "battery.soe_kwh"), (TYPE_BESS, "vendor-name", "battery.vendor_name"), - (TYPE_BESS, "product-name", "battery.product_name"), - (TYPE_BESS, "model", "battery.model"), + # Flat's irregularity, translated rather than mirrored: it puts the designation in + # `product-name` and the SKU in `model` on the BESS, where the EVSE puts the SKU in + # `part-number`. The snapshot speaks v1.0's vocabulary, so both land on the field + # that matches the concept. + (TYPE_BESS, "product-name", "battery.model"), + (TYPE_BESS, "model", "battery.part_number"), (TYPE_BESS, "serial-number", "battery.serial_number"), (TYPE_BESS, "software-version", "battery.software_version"), (TYPE_BESS, "nameplate-capacity", "battery.nameplate_capacity_kwh"), @@ -94,7 +95,14 @@ (TYPE_BESS, "grid-state", "panel.grid_state"), # --- PV → pv.* ----------------------------------------------------------- (TYPE_PV, "vendor-name", "pv.vendor_name"), - (TYPE_PV, "product-name", "pv.product_name"), + (TYPE_PV, "product-name", "pv.model"), + # Declared by the flat schema's `energy.ebus.device.pv` type and read here even + # though no capture we hold values it — neither the frozen simulator nor the live + # panel. The row is about what flat *can* say, not what one panel happened to send: + # `test_der_additions_are_provisional_or_attested_but_never_unexamined` classifies + # a v1.0-only field by whether flat has a property behind it, and without this row + # `pv.software_version` would be filed as introduced by v1.0 when flat declares it. + (TYPE_PV, "software-version", "pv.software_version"), (TYPE_PV, "nameplate-capacity", "pv.nameplate_capacity_w"), (TYPE_PV, "feed", "pv.feed_circuit_id"), (TYPE_PV, "relative-position", "pv.relative_position"), # IN_PANEL | UPSTREAM | DOWNSTREAM @@ -103,7 +111,7 @@ (TYPE_EVSE, "lock-state", "evse.lock_state"), (TYPE_EVSE, "advertised-current", "evse.advertised_current_a"), (TYPE_EVSE, "vendor-name", "evse.vendor_name"), - (TYPE_EVSE, "product-name", "evse.product_name"), + (TYPE_EVSE, "product-name", "evse.model"), (TYPE_EVSE, "part-number", "evse.part_number"), (TYPE_EVSE, "serial-number", "evse.serial_number"), (TYPE_EVSE, "software-version", "evse.software_version"), @@ -147,6 +155,21 @@ def _lookup_property( return None +def _type_declared(schema_types: HomieSchemaTypes, node_type: str) -> bool: + """Whether the schema carries a type block a property could have come from. + + Presence follows the same path `_lookup_property` does, fallback included: + firmware that publishes only the generic `…device.lugs` block still answers + for the typed rows, so a property dropped from it is a drop and not absent + hardware. There is no node dimension here — schema_0's rows address the + type-level REST schema directly — so the type block is the whole question. + """ + if isinstance(schema_types.get(node_type), dict): + return True + fallback_type = _LUGS_FALLBACK.get(node_type) + return fallback_type is not None and isinstance(schema_types.get(fallback_type), dict) + + def build_field_metadata( schema_types: HomieSchemaTypes, ) -> dict[str, FieldMetadata]: @@ -167,6 +190,9 @@ def build_field_metadata( for node_type, property_id, field_path in _PROPERTY_FIELD_MAP: prop_def = _lookup_property(schema_types, node_type, property_id) if prop_def is None: + if _type_declared(schema_types, node_type): + # The type block exists and omits the property — a genuine drop. + result[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) continue raw_unit = prop_def.get("unit") @@ -177,53 +203,3 @@ def build_field_metadata( result[field_path] = FieldMetadata(unit=unit, datatype=datatype) return result - - -def log_schema_drift( - previous: HomieSchemaTypes, - current: HomieSchemaTypes, -) -> None: - """Log property-level differences between two schema versions. - - Called by the client when the schema hash changes between connections. - All Homie-specific detail stays in this module — the integration never - sees this output, only the transport-agnostic field metadata. - """ - prev_types = set(previous.keys()) - curr_types = set(current.keys()) - - for node_type in sorted(curr_types - prev_types): - _LOGGER.debug("Schema drift: new node type '%s'", node_type) - - for node_type in sorted(prev_types - curr_types): - _LOGGER.debug("Schema drift: removed node type '%s'", node_type) - - for node_type in sorted(prev_types & curr_types): - prev_props = previous[node_type] - curr_props = current[node_type] - if not isinstance(prev_props, dict) or not isinstance(curr_props, dict): - continue - - for prop_id in sorted(set(curr_props) - set(prev_props)): - _LOGGER.debug("Schema drift: new property '%s/%s'", node_type, prop_id) - - for prop_id in sorted(set(prev_props) - set(curr_props)): - _LOGGER.debug("Schema drift: removed property '%s/%s'", node_type, prop_id) - - for prop_id in sorted(set(prev_props) & set(curr_props)): - prev_def = prev_props[prop_id] - curr_def = curr_props[prop_id] - if not isinstance(prev_def, dict) or not isinstance(curr_def, dict): - continue - for attr in ("datatype", "unit", "format"): - old_val = prev_def.get(attr) - new_val = curr_def.get(attr) - if old_val != new_val: - _LOGGER.debug( - "Schema drift: '%s/%s' %s changed: '%s' → '%s'", - node_type, - prop_id, - attr, - old_val, - new_val, - ) diff --git a/packages/schema-0/src/span_panel_api_schema_0/py.typed b/packages/schema-0/src/span_panel_api_schema_0/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md new file mode 100644 index 0000000..5f9bcb7 --- /dev/null +++ b/packages/schema-1/CHANGELOG.md @@ -0,0 +1,117 @@ +# Changelog + +All notable changes to `span-panel-api-schema-1` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Note that this package versions on the **library-API axis**, not the wire-format axis. The wire format it parses is the parent/child device tree SPAN firmware `r202633+` publishes, identified by `SUPPORTS_DATA_MODEL_VERSIONS` rather than by this version +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.0.0] + +First release as a standalone distribution, and the first parser for the parent/child data model. Requires `span-panel-api` 3.0.0 or newer, and `ebus-sdk` `>=0.19,<0.24`. + +### Added + +#### The adapter + +- **`SchemaOneAdapter`**, registered as `schema_1` under the `span_panel_api.schema_adapters` entry-point group. A panel reporting `data-model-version` `1.x` resolves to it; a panel without this package installed still gets the named + `SpanPanelAdapterMissingError`, so installing it is the opt-in. +- **`ADAPTER_CONTRACT = 1`**, declaring which version of the bootstrap-to-adapter contract this parser was built against. Declared as a literal rather than imported from `span_panel_api.protocol`: a value read from the installed bootstrap would agree with + every bootstrap, which is exactly the disagreement the check exists to find. +- **`ControllerRoutes`** — an `ebus_sdk.MqttControllerTransport` that records `Controller`'s subscriptions instead of making them, so the SDK parses the tree over span-panel-api's own connection to the panel's broker. The adapter is built before a + connection exists and never receives one; a single wildcard subscription made by the transport layer covers the whole tree, and this routes each message to whichever SDK callback asked for it. +- **The snapshot mapper.** Sorts the tree by declared device type — never by device id — and maps it onto `SpanPanelSnapshot`: circuits, both lugs, the MID, and the BESS/PV/EVSE devices. +- **Retained messages are held until their route exists.** `Controller` learns its topics as it walks the tree, but one subscription delivers the whole tree at once in whatever order the broker replays its retained store; seeded children-first, a 40-space + panel would otherwise parse as zero circuits. Unrouted messages are held and released when the matching route appears — the value a per-device subscription would have been given at subscribe time — with a ceiling so an unclaimed subtree cannot leak. +- **Readiness asks about every declared device, at any depth**, not only the root, so a connection cannot complete with a fraction of its circuits and no panel size. Child _state_ is deliberately not required, so an offline DER does not block a connection; + the model is required only when the root's description declares it. +- **Panel size from `info/model`** via `PANEL_SIZE_BY_MODEL`, which is what restores the unmapped-position entries a consumer builds from the difference between total and occupied spaces. `info/spaces` has no format and the panel publishes no size + property, so the model is the only source; `panel_model_drift()` reports a model the panel declares that we have no size for, because the alternative is a user noticing missing positions. +- **Field metadata read from each device's `$description`** rather than a schema document. The same capability type exposes different properties on different device classes — `meter` is voltage on the panel, power and energy on a circuit, both currents on + lugs — so the per-device description is what this panel actually has. +- **A `py.typed` marker**, so consumers type-check against this package's real annotations. + +#### Mapping decisions worth knowing + +- **`grid_state` reads the MID's `grid/islanding-state`, not its `grid/grid-state`.** The MID publishes both: `islanding-state` is `ON_GRID`/`OFF_GRID`/`UNKNOWN`, and `grid-state` is `UP`/`DOWN`/`DEGRADED`/`UNKNOWN`. Flat's `grid_state` was the BESS's + `grid-state`, an islanding answer, so its successor is `islanding-state`; matching on the property name rather than the value set would put `UP` where a consumer expects `ON_GRID` — an entity keeping its id and history while its vocabulary silently + changed. `grid/grid-state` asks whether the utility supply is healthy, is new in v1.0 with no flat equivalent, and is left unmapped as a new signal rather than a replacement. +- **`dominant_power_source` reports `GRID` on a panel with no MID**, rather than nothing. The field's source moved: flat published a closed enum of source classes on the panel, and v1.0 names the forming device on the MID's `grid` node — so a panel with no + battery has no MID, the property has no publisher, and the field would go `None`. Observed on a live install that read `Grid` on flat all night and went unknown the moment it upgraded, with nothing about the site having changed. + + A missing MID settles the answer by elimination rather than leaving it open. `BATTERY` needs a BESS and a BESS brings a MID; `PV` cannot form a grid alone, because anything that can is a grid-forming inverter and therefore a MID; `NONE` describes a panel + supplying nothing, which is a panel that is not publishing. What remains is a generator, and that is two cases of which only one reaches here: a generator wired through a MID is named by that MID and answered before this point, while a generator with no + MID interface is what SPAN treats as the grid — and it is the only kind an install with no MID can have. The elimination therefore keeps holding if MID-integrated generators arrive, because they bring a MID. A site running off-grid without storage is not + a counterexample: it goes dark at sunset. + + This deliberately does not follow `resolve_islanding_state`, which refuses the same shortcut, and the counterexample that defeats it there is what supports it here: a generator-fed island **is** islanded, so inferring on-grid from a missing MID would be + wrong, while its grid-forming entity really is what SPAN calls the grid. A MID that exists and has not answered still reports nothing — that is genuinely unknown, and distinct from there being no islanding authority at all. + +- **`battery.power_w` is discharge-positive.** The enclosure meters the BESS the way it meters a circuit it feeds, so a discharging battery publishes a negative `meter/active-power` and the mapper negates it. Positive therefore means power flowing _out of_ + the battery, which is the eBus rule for a device's own meter. It stays deliberately opposite to `panel.power_flow_battery`, the enclosure's arbitrated figure, which is passed through untouched by both adapters and is charge-positive. The two are the same + physical power in different frames, and a consumer rendering both negates one of them. + +#### Adoption and vendor extensions + +- **`adoption`, building `AdoptedDevice` records for device types this parser does not model**, with `set_topic` populated only where the declaration says the property is settable. Subtype-aware, so a curated device never lands in `adopted_devices`. +- **Vendor properties on modelled devices are emitted with their values.** Every property a modelled device declares that this adapter maps to no snapshot field — excluding `info` and `connection`, which resolve to the device card and the tree — arrives as + an `ExtensionProperty` carrying its subject, its declaration and its retained value. A battery vendor hanging `battery-2/cell-temperature` off the BESS would otherwise reach a consumer nowhere. +- **The two lugs devices are two extension subjects, not one.** A subject is an _identity_: a consumer keys an entity on `(kind, instance_key, node/property)`, so pairing both lugs with a single subject would give one identity for two readings, and + identical firmware on both lugs makes that the expected case rather than a coincidence. They are `kind="lugs"` with `upstream`/`downstream` as the instance key, matched on `info/direction` for the reason `find_lugs` documents: the reference tree's ids + are the simulator's naming, and the direction property is what the schema defines. A lugs device declaring no direction is left unpaired rather than keyed on something unstable — its properties stay in discovery, which is where an unidentifiable device + belongs. +- **`node_has_curated_siblings`**, one bit per row: whether this adapter reads any _other_ property of the same node. A vendor extending `meter` is probably extending the meter, and that is the whole of what the bit says — which fields are read stays + internal. `addressed_rows()` is shared with `build_discovery`, so the discovery rows and the extension rows cannot disagree about what "unaddressed" means. + +#### Conformance against the specification and the producer + +- **`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. It is the consumer + counterpart to the simulator's publisher lockfile, and both are pinned to the same specification commit — though the anchor shared between them is the **firmware range**, not that commit, because the specification says what a device class _may_ publish + while a panel publishes one specific tree. +- **The capability catalogs this adapter addresses are byte-copied under `spec/`**, along with the device-types registry. Vendored rather than depended on because the specification is a git repository of versioned documents, not a package. They exist to be + checked against, never parsed in production: units and datatypes still come from each device's `$description`, since the catalog is the superset across all hardware rather than a statement about the panel in front of us. Formatting hooks are excluded + from `spec/`, because a lint fix there would quietly invalidate the byte comparison that makes the copies worth having. +- **A conformance suite that asks the consumer's question rather than the publisher's.** A publisher asks whether everything it emits is legal, and for it an omission is unremarkable. This asks whether every name the adapter _reads_ is one the + specification defines — because a consumer addressing a name that no longer exists does not fail, it goes quiet: the property never arrives, metadata lookup returns `None`, and an entity disappears. The read set is derived from the source itself rather + than from the metadata table alone, so it cannot fall behind the code. +- **An explicit SPAN extension allowlist.** A number of the properties this adapter reads are absent from every catalog — per-phase meter readings, panel link states, circuit `spaces`, `info/direction`, the EVSE surface. All are legal, since the + specification permits properties it has never heard of. They are enumerated with reasons so that a name missing from the catalog must be a deliberate claim about SPAN's vocabulary rather than an unnoticed typo; at runtime the two are indistinguishable. + Tests also fail when an extension is later adopted upstream, or when one is declared for a property nothing reads. +- **The catalogs are used as a validator, not just as a vocabulary list.** `span_panel_api_schema_1.catalog` compares a declared `unit` or `datatype` against the catalog's definition of the same property, which is the comparison that catches a mislabel. + Agreement is silence; disagreement is surfaced, never silently resolved — a finding is not a licence to change a wire reader to match the catalog, nor to assume the catalog is right, since both sides have been wrong. Divergences are recorded with what + the wire says, what the catalog says, which producers show it, a reason and a date, and the baseline fails in **both** directions: a new divergence fails until somebody records it, and a recorded divergence that has disappeared fails until its line is + removed. That second direction is what keeps the register self-cleaning rather than a suppression list. +- **An abstract unit is a dimension, and comparing it as a string would report conformance as the defect.** `soc/soe`, `soc/total-energy-storage`, `soc/loadup-headroom` and `info/nameplate-capacity` are all `unit: "energy"`, which the specification + requires a publisher to substitute a real unit for — a BESS in kWh, a water heater in Wh. `UNIT_FAMILIES` enumerates membership rather than deriving it from an SI-prefix rule, so a member is silent, echoing the placeholder back is a finding, and an + energy unit nobody enumerated is a question for a human. +- **A peer record and a producer coverage check.** `spec_lock.json` records the producer this parser is developed against — the SPAN simulator, `role: publisher` — and a capture of the tree it publishes is vendored alongside the catalogs, so two sides + reading different vocabularies is a test failure rather than something noticed later. Of the `(capability, property)` pairs this adapter reads, the capture declares all but `grid/islanding-state`: the simulator models a MID but its tracked config + publishes none. That gap is recorded rather than left implicit, because a passing suite otherwise reads as coverage it does not have, and the entry is rejected once the simulator starts publishing it. +- **The parser is driven end to end from what the producer actually publishes.** `spec/fixtures/simulator_wire.json` is a capture from SPAN's publisher — descriptions, `$state` and all property values across every device — fed in sorted topic order, the + way a retained store replays it rather than the way a tree is walked. The parser reaches ready on it, sizes the panel from `MAIN_40`, and parses all 30 circuits. Values are deliberately not asserted: the producer's config carries `noise_factor` and its + clock advances, so pinning a wattage would fail on every recapture for a reason nobody could act on. +- **Provenance is opportunistic; conformance is not.** Byte comparison against a specification or simulator checkout is skipped unless `EBUS_SPEC_DIR` / `PANELBENCH_DIR` are set, so conformance and coverage run everywhere while the byte checks stay + opportunistic — and CI sets both, so a skip there is a failure. Provenance proves the right bytes were copied; it cannot prove they were understood, which is what the other two are for. + +#### Reference payloads + +- **`span_panel_api_schema_1.reference_payloads`, shipping `parent_child_tree.json` as package data.** The captured retained-topic tree of a full 40-space panel is reached by `parent_child_tree()` rather than by path. Consumers outside this repository need + a real capture to check an adapter's output against, and the only alternative to shipping one is vendoring a byte copy that has no version and goes stale in silence. It ships from _this_ distribution rather than the bootstrap because a retained topic + tree is only interpretable by the parser that speaks its vocabulary — and the eBus SDK that turns it back into devices is this distribution's dependency alone. +- **`devices_from_tree` and `device_from_topics`.** A tree is not directly usable: every consumer has to replay the retained topics through `DiscoveredDevice` first, and that replay is this parser's own knowledge of how the transport feeds it. Shipping the + capture without the replay would just move a copy of that logic into every consumer. `devices_from_tree` takes the tree rather than reading it, so a consumer can filter the capture first — dropping the BESS to model a panel that has none — and still + build devices the same way. + +### Known deviations and deliberate gaps + +- **`set_dominant_power_source_topic()` returns `None`.** The v1.0 property split into `grid-forming-entity` and `asserted-islanding-state`, which are different controls on different devices rather than a rename. `None` makes the transport reject the + command instead of publishing where nothing listens; which successor to expose is a product decision. +- **`pv.relative_position` has no v1.0 equivalent** and is left to the product decisions tracked separately. Fields the mapper declines carry no metadata row, so a consumer never validates against a field nothing populates. +- **`grid_islandable` returns `None` until something publishes it.** It maps to `grid-forming/capable` over the BESS's inverter children, as the disjunction — a panel does not island, its DER does. No producer publishes it today, which is recorded rather + than worked around; `None` keeps absence a gap instead of a claim. +- **Two producer-side gaps are pinned rather than left to be noticed.** `grid_state` stays `None` on the captured tree because nothing instantiates a MID, and every DER — BESS, PV and both EVSEs — declares `info/model` in its `$description` and never + publishes a value. The second breaks the single standing obligation eBus places on a publisher, to declare accurately what it publishes, and is invisible to a conformance checker: comparing declarations against catalogs cannot see a declaration nothing + fulfils. Only a capture carrying values can, which is the argument for that fixture existing. Both are asserted as current expectations, so closing either fails the test that describes it. diff --git a/packages/schema-1/README.md b/packages/schema-1/README.md new file mode 100644 index 0000000..10a94b8 --- /dev/null +++ b/packages/schema-1/README.md @@ -0,0 +1,64 @@ +# span-panel-api-schema-1 + +The **parent/child** schema parser for [`span-panel-api`](https://github.com/SpanPanel/span-panel-api): the multi-device Homie tree published by SPAN firmware `r202633+`, which reports `data-model-version` `1.x`. + +## Why this is a separate distribution + +`span-panel-api` is a transport and a dispatcher. It knows how to connect to a panel's MQTT broker, route messages, and choose a parser — but it contains no parsing code. Each wire format ships as its own distribution and registers itself under the +`span_panel_api.schema_adapters` entry-point group, so the bootstrap never imports this package until a panel asks for it by name. + +The split matters more here than anywhere else in the workspace: this parser depends on the [eBus SDK](https://github.com/electrification-bus/python-sdk) to turn the tree back into devices, and that dependency is this distribution's alone. A flat-panel +install never pulls it in. + +## Installation + +```console +pip install "span-panel-api[schema-1]" +``` + +Installing this package is what makes parent/child panels work. `span-panel-api` on its own will connect and then raise `SpanPanelAdapterMissingError` naming the adapter it could not find. + +A consumer that wants to support panels on either schema installs both adapters, and dispatch happens at runtime, per panel, from the `data-model-version` the panel reports: + +```console +pip install "span-panel-api[schema-0,schema-1]" +``` + +## What it parses + +`SchemaOneAdapter` maps the device tree onto the same `SpanPanelSnapshot` the flat adapter produces — circuits, both lugs devices, the BESS, PV and EVSE — plus the surface that only exists under the parent/child model: + +- **The MID** (`SpanPanelSnapshot.mid`). The enclosure model puts the `grid` capability on a Microgrid Interconnect Device rather than on the enclosure, so islanding state, grid state and the grid-forming entity live there. +- **Adopted devices** (`SpanPanelSnapshot.adopted_devices`). A device type this parser models nothing for is reported whole — identity and readings — rather than dropped. The schema is explicitly vendor-extensible, so an unmodelled device is an expected + arrival rather than a hypothetical one. +- **Extension properties** (`SpanPanelSnapshot.extension_properties`). A vendor property on a device this parser _does_ model, carried with its value and the snapshot subject it hangs off. + +Devices are sorted by declared device type, never by device id. Field metadata comes from each device's own `$description` rather than from a schema document, because the same capability exposes different properties on different device classes — `meter` is +voltage on the panel, power and energy on a circuit, and both currents on the lugs. + +`ControllerRoutes` is how the eBus SDK reaches the panel without opening its own connection: it is an `ebus_sdk.MqttControllerTransport` that records `Controller`'s subscriptions instead of making them, so a single wildcard subscription owned by +span-panel-api's transport covers the whole tree and each message is routed to whichever SDK callback asked for it. + +## 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/`. + +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 + +A retained-topic capture of a full 40-space parent/child panel ships as package data, with the replay that turns it back into devices: + +```python +from span_panel_api_schema_1.reference_payloads import devices_from_tree, parent_child_tree + +devices = devices_from_tree(parent_child_tree()) +``` + +It ships here rather than from the bootstrap because a retained topic tree is only interpretable by the parser that speaks its vocabulary, and the eBus SDK is this distribution's dependency alone. `devices_from_tree` takes the tree rather than reading it, +so a consumer can filter the capture first — dropping the BESS to model a panel that has none — and still build devices the same way. The bootstrap ships the schema document it fetches; see `span_panel_api.reference_payloads`. + +Each payload carries the version of the release it shipped in. Pin a version and you read the bytes that version was written against. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml new file mode 100644 index 0000000..45bcd14 --- /dev/null +++ b/packages/schema-1/pyproject.toml @@ -0,0 +1,66 @@ +[project] +name = "span-panel-api-schema-1" +version = "1.0.0" +description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" +authors = [ + {name = "SpanPanel"} +] +readme = "README.md" +license = "MIT" +requires-python = ">=3.14,<4.0" +dependencies = [ + # 3.0.0 is the first bootstrap that defines everything this parser imports -- + # `SpanMidSnapshot`, `ExtensionProperty`, `ExtensionSubject`, and a + # `SpanPanelSnapshot` that accepts `extension_properties`. A lower floor lets + # a resolver pair this wheel with a bootstrap that fails at import, the + # precise hazard RELEASE.md warns about under "Releasing every distribution". + # Stated as a stable version rather than as the prerelease the floor tracked + # during development: naming a prerelease in a specifier is pip's own signal + # that prereleases are acceptable for that requirement. + "span-panel-api>=3.0.0,<4.0", + # Only this distribution depends on the eBus SDK. The bootstrap and + # schema-0 stay clean, so a flat-panel install never pulls it in — which is + # what bounds the release coupling this dependency introduces to panels on + # r202633+. + # Ceiling set to the versions actually tested, and re-checked rather than + # extrapolated each time it moves. 0.x carries no compatibility contract, and + # this ships to hosts that are not quick to iterate on: widening later is a + # patch release, while narrowing after a user's host has resolved a bad + # pairing is not. + # + # Every bump so far has been publisher-side. Diffing the 0.22.0 and 0.23.1 + # wheels module by module, exactly two files change -- `__init__.py`, by the + # version string alone, and `declaration.py`, the declarative builder. Our + # whole surface is `Controller`, `homie.DiscoveredDevice` and structural + # conformance to `MqttControllerTransport`; nothing here imports + # `declaration`. The suite is green against 0.23.1 unchanged. + # + # The cost is a release here per SDK minor, which is real: 0.23.0 and 0.23.1 + # both shipped the same day the previous ceiling was set. Still the right + # trade while the SDK is pre-1.0. + "ebus-sdk>=0.19.0,<0.24", +] + +[project.urls] +Homepage = "https://github.com/SpanPanel/span-panel-api" +Issues = "https://github.com/SpanPanel/span-panel-api/issues" + +# The whole point of this distribution. Dispatch resolves `schema_1` for a 1.x +# panel by discovering this group, never by importing this package. +# +# Held back until the parser could answer for a panel end to end — mapper, +# field metadata, command topics, and recovery from a real broker outage. +# Installing this package remains the opt-in: a 1.x panel without it still gets +# the named SpanPanelAdapterMissingError rather than a silent misparse. +[project.entry-points."span_panel_api.schema_adapters"] +schema_1 = "span_panel_api_schema_1:SchemaOneAdapter" + +[tool.uv.sources] +span-panel-api = { workspace = true } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/span_panel_api_schema_1"] diff --git a/packages/schema-1/spec/catalogs/breaker.json b/packages/schema-1/spec/catalogs/breaker.json new file mode 100644 index 0000000..b3438fa --- /dev/null +++ b/packages/schema-1/spec/catalogs/breaker.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.breaker", + "version": "0.2", + "status": "DRAFT", + "date": "2026-08-20", + "properties": { + "rating": { + "datatype": "integer", + "unit": "A", + "req": "SHOULD", + "description": "Continuous current rating." + }, + "poles": { + "datatype": "integer", + "req": "MAY", + "description": "Number of poles (1-4). A US split-phase 240 V breaker is `2`." + }, + "interrupting-rating": { + "datatype": "integer", + "unit": "kA", + "req": "MAY", + "description": "Interrupting capacity (kAIC), e.g. `10`, `65`, `100`." + }, + "protection-functions": { + "datatype": "enum", + "format": "OVERCURRENT,SHORT_CIRCUIT,GROUND_FAULT,ARC_FAULT", + "req": "MAY", + "description": "Multi-valued set of the protections this breaker provides: `OVERCURRENT`, `SHORT_CIRCUIT`, `GROUND_FAULT` (GFCI), `ARC_FAULT` (AFCI)." + }, + "trip-curve": { + "datatype": "enum", + "format": "B,C,D,K", + "req": "MAY", + "description": "Instantaneous trip curve: `B`, `C`, `D`, `K`, …" + }, + "trip-state": { + "datatype": "enum", + "format": "OK,TRIPPED,STUCK,UNKNOWN,CLOSED", + "req": "SHOULD", + "description": "`OK`, `TRIPPED`, `STUCK`, `UNKNOWN`. A tripped breaker carries no current even if a co-located `switch/relay` reads `CLOSED`, so `trip-state` is not a relay state." + }, + "trip-cause": { + "datatype": "enum", + "format": "OVERCURRENT,SHORT_CIRCUIT,GROUND_FAULT,ARC_FAULT,OVERVOLTAGE,UNKNOWN", + "req": "MAY", + "description": "Cause of the most recent trip: `OVERCURRENT`, `SHORT_CIRCUIT`, `GROUND_FAULT`, `ARC_FAULT`, `OVERVOLTAGE`, `UNKNOWN`." + } + } +} diff --git a/packages/schema-1/spec/catalogs/charge-limit.json b/packages/schema-1/spec/catalogs/charge-limit.json new file mode 100644 index 0000000..456f725 --- /dev/null +++ b/packages/schema-1/spec/catalogs/charge-limit.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.charge-limit", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "installer-max": { + "datatype": "integer", + "unit": "A", + "req": "SHOULD", + "description": "Installer-configured maximum charge current (breaker rating, J1772 derating): the immutable ceiling." + }, + "owner-limit": { + "datatype": "integer", + "unit": "A", + "settable": true, + "req": "MAY", + "description": "The owner's charge-current ceiling. Held until changed (\"until further notice\"), not a bounded duration. MUST be `<= installer-max`." + }, + "requested-limit": { + "datatype": "integer", + "unit": "A", + "settable": true, + "req": "MAY", + "description": "An external controller's (HEMS / grid) charge-current ceiling." + }, + "requested-limit-cause": { + "datatype": "enum", + "format": "LOCAL_OPTIMIZATION,GRID_OPTIMIZATION,UNKNOWN", + "req": "MAY", + "description": "Why the external limit is set: `LOCAL_OPTIMIZATION`, `GRID_OPTIMIZATION`, `UNKNOWN`. Records who is reducing charging and why (for attribution and consent)." + } + } +} diff --git a/packages/schema-1/spec/catalogs/connection.json b/packages/schema-1/spec/catalogs/connection.json new file mode 100644 index 0000000..fad9721 --- /dev/null +++ b/packages/schema-1/spec/catalogs/connection.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.connection", + "version": "0.2", + "status": "DRAFT", + "date": "2026-08-20", + "properties": { + "feeds-device-id": { + "datatype": "string", + "req": "MAY", + "description": "Homie device ID of the device wired *downstream* of this connection point. Published only when the specific downstream device is known. Omitted when unknown, when mixed-load with no commissioned downstream device, or when nothing is connected." + }, + "feeds-device-type": { + "datatype": "string", + "req": "MAY", + "description": "`$description.type` of the downstream device (e.g. `energy.ebus.device.bess`, `.pv`, `.evse`, `.water-heater`, `.distribution-enclosure`, or a DER sub-device such as `.battery`). Published when the class is known even if the specific ID is not." + }, + "feeds-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "req": "MAY", + "description": "Publisher's view of communication-link health to the downstream device: `OK`, `LOST`, `DEGRADED`. Published only when `feeds-device-id` is published and the publisher has a communication integration with that device." + }, + "fed-by-device-id": { + "datatype": "string", + "req": "MAY", + "description": "Homie device ID of the device wired *upstream* of this connection point. Published only when known (e.g. an upstream BESS wired between the utility and the enclosure, or an upstream sister enclosure in a chain). Omitted when the upstream side is the utility, an implicit busbar, or unknown." + }, + "fed-by-device-type": { + "datatype": "string", + "req": "MAY", + "description": "`$description.type` of the upstream device. Published with `fed-by-device-id`." + }, + "fed-by-device-status": { + "datatype": "enum", + "format": "OK,LOST,DEGRADED", + "req": "MAY", + "description": "Publisher's view of communication-link health to the upstream device. Same value domain and applicability as `feeds-device-status`." + }, + "backed-up": { + "datatype": "enum", + "format": "BACKED_UP,NOT_BACKED_UP,UNKNOWN", + "req": "MAY", + "description": "Whether this path is on the backup (island) side of a microgrid interconnect device, and so stays energized off-grid: `BACKED_UP`, `NOT_BACKED_UP`, `UNKNOWN`. A wiring fact (which side of the interconnect), distinct from `load-shed/priority` (a shedding *policy*) and `grid/islanding-state` (the present *state*)." + }, + "feeds-role": { + "datatype": "enum", + "format": "LOADS,SUBPANEL,SOLAR,STORAGE,GENERATOR,MIXED,UNUSED", + "req": "MAY", + "description": "Summary role of a downstream node that is **not** published as its own eBus device, or that is surveyed-empty: `LOADS`, `SUBPANEL`, `SOLAR`, `STORAGE`, `GENERATOR`, `MIXED`, `UNUSED`. `UNUSED` positively records \"surveyed, nothing connected\" (which absence cannot express). Complements `feeds-device-*`, which is used when the downstream *is* an eBus device." + }, + "service-rating": { + "datatype": "integer", + "unit": "A", + "req": "MAY", + "description": "Utility service rating (service size) at a service-entrance connection point. Distinct from `pcs/feed-import-limit` (a PCS enforcement limit) and `breaker/rating` (a main breaker)." + }, + "overcurrent-protection": { + "datatype": "integer", + "unit": "A", + "req": "MAY", + "description": "Overcurrent-protection rating at a connection point that is not itself a breaker-protected circuit (for example a feeder conductor landing in unprotected lugs). Where the connection point *is* a breaker-protected circuit, the rating is `breaker/rating` instead." + }, + "count": { + "datatype": "integer", + "req": "MAY", + "description": "When the connected node aggregates multiple physical units behind a *single* connection point (e.g. 6 battery packs in one BESS, or 4 microinverters on one AC string reported as one solar device), how many." + } + } +} diff --git a/packages/schema-1/spec/catalogs/door.json b/packages/schema-1/spec/catalogs/door.json new file mode 100644 index 0000000..d0b35e7 --- /dev/null +++ b/packages/schema-1/spec/catalogs/door.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.door", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "state": { + "datatype": "enum", + "format": "OPEN,CLOSED,UNKNOWN", + "req": "MUST", + "description": "Door state: `OPEN`, `CLOSED`, `UNKNOWN`." + } + } +} diff --git a/packages/schema-1/spec/catalogs/grid-forming.json b/packages/schema-1/spec/catalogs/grid-forming.json new file mode 100644 index 0000000..5872568 --- /dev/null +++ b/packages/schema-1/spec/catalogs/grid-forming.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.grid-forming", + "version": "0.2", + "status": "DRAFT", + "date": "2026-08-20", + "properties": { + "capable": { + "datatype": "boolean", + "req": "MUST", + "description": "Static hardware capability: does this inverter support grid-forming operation at all? A publisher that does not know it does not publish this node; see §\"Absence semantics\"." + }, + "active": { + "datatype": "boolean", + "req": "SHOULD", + "description": "Current state: is this inverter actively grid-forming right now? When `false` and the inverter is energized, it is grid-following. Meaningful only when `capable = true`." + } + } +} diff --git a/packages/schema-1/spec/catalogs/grid.json b/packages/schema-1/spec/catalogs/grid.json new file mode 100644 index 0000000..e6d486f --- /dev/null +++ b/packages/schema-1/spec/catalogs/grid.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.grid", + "version": "0.2", + "status": "DRAFT", + "date": "2026-08-20", + "properties": { + "islanding-state": { + "datatype": "enum", + "format": "ON_GRID,OFF_GRID,UNKNOWN", + "req": "MAY", + "description": "Whether the site is connected to or islanded from the utility: `ON_GRID`, `OFF_GRID`, `UNKNOWN`. Reflects the interconnect **relay position**. Published by the islanding authority (a MID); a device that does not sense the interconnect (a utility meter) does not publish it." + }, + "grid-state": { + "datatype": "enum", + "format": "UP,DOWN,DEGRADED,UNKNOWN", + "req": "MAY", + "description": "Sensed condition of the utility AC supply: `UP`, `DOWN`, `DEGRADED`, `UNKNOWN`. `DEGRADED` (outside the `UP` band but not a declared outage) is optional; a publisher SHOULD distinguish it when it has the measurement capability (a black-box proxied MID typically reports only `UP` / `DOWN` / `UNKNOWN`). Published by any device that senses the supply (a MID, a utility meter)." + }, + "grid-forming-entity": { + "datatype": "string", + "req": "MAY", + "description": "Identity of the device establishing the AC voltage / frequency reference: `\"GRID\"` when grid-tied, or the Homie device ID of the grid-forming device (typically the DER parent device: a BESS, a V2H EVSE, a generator) when islanded. Empty string or absent during transitions or when unknown. Published by the islanding authority (a MID)." + }, + "last-outage-time": { + "datatype": "datetime", + "req": "MAY", + "description": "Timestamp (ISO-8601 UTC) of the most recent transition from `UP` / `DEGRADED` to `DOWN` observed." + }, + "last-restoration-time": { + "datatype": "datetime", + "req": "MAY", + "description": "Timestamp (ISO-8601 UTC) of the most recent transition from `DOWN` to `UP` / `DEGRADED` observed." + } + } +} diff --git a/packages/schema-1/spec/catalogs/info.json b/packages/schema-1/spec/catalogs/info.json new file mode 100644 index 0000000..a9a0be6 --- /dev/null +++ b/packages/schema-1/spec/catalogs/info.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.info", + "version": "0.3", + "status": "DRAFT", + "date": "2026-08-20", + "properties": { + "vendor-name": { + "datatype": "string", + "req": "SHOULD", + "description": "Manufacturer name (e.g., \"SPAN\", \"Tesla\", \"Rheem\")." + }, + "serial-number": { + "datatype": "string", + "req": "SHOULD", + "description": "Device serial number." + }, + "model": { + "datatype": "string", + "req": "SHOULD", + "description": "The human-facing model or product designation, the name a person recognizes (e.g., `Powerwall 3`, `IQ Battery 5P`, or a configuration code such as `MAIN_32`). This is the display designation, not the orderable part code (that is `part-number`). The valid set is publisher-defined and MAY be advertised via Homie `$format` on the property." + }, + "part-number": { + "datatype": "string", + "req": "MAY", + "description": "The vendor's orderable part or SKU code (e.g., Tesla `1232100-00-E`): the specific hardware variant beneath `model`, finer-grained so distinct part numbers (packaging, regional, or minor-revision variants) can share one `model`. A publisher that has both a coded identifier and a human-facing name publishes the code here and the designation in `model`, not a separate product-name property." + }, + "hardware-version": { + "datatype": "string", + "req": "MAY", + "description": "Hardware revision." + }, + "firmware-version": { + "datatype": "string", + "req": "SHOULD", + "description": "Firmware version. Published when the device has firmware; a bare or surveyed device (e.g., a dumb load center) omits it." + }, + "data-model-version": { + "datatype": "string", + "req": "SHOULD", + "description": "Version of the eBus data model this device publishes (e.g., `\"1.0\"`)." + }, + "nameplate-capacity": { + "datatype": "float", + "unit": "energy", + "req": "MAY", + "description": "Rated nameplate energy capacity, a term of art for energy-storage devices, reported in the device's native energy unit (a BESS in kWh electrical, a storage water heater in Wh thermal) via `$unit`. It is the static manufacturer rating: `soc` is roughly `soe` / `nameplate-capacity`, while the precise, dynamic denominator is `soc`'s `total-energy-storage` (which diverges from the nameplate figure as the reservoir degrades). Energy-storage device types (BESS, thermal storage) SHOULD publish it; power-rated devices (PV, inverter, EVSE) publish rated power via `nominal-power` instead." + } + } +} diff --git a/packages/schema-1/spec/catalogs/load-shed.json b/packages/schema-1/spec/catalogs/load-shed.json new file mode 100644 index 0000000..8b20c38 --- /dev/null +++ b/packages/schema-1/spec/catalogs/load-shed.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.load-shed", + "version": "0.3", + "status": "DRAFT", + "date": "2026-07-30", + "properties": { + "priority": { + "datatype": "enum", + "format": "UNKNOWN,NEVER,OFF_GRID,SOC_THRESHOLD", + "settable": true, + "req": "SHOULD", + "description": "The circuit's shedding class. Baseline (every host): `UNKNOWN`, `NEVER`, `OFF_GRID`. Optional additional triggers are advertised in the property's `$format`: `SOC_THRESHOLD` and future spec- or vendor-defined values." + } + } +} diff --git a/packages/schema-1/spec/catalogs/meter.json b/packages/schema-1/spec/catalogs/meter.json new file mode 100644 index 0000000..9f2586e --- /dev/null +++ b/packages/schema-1/spec/catalogs/meter.json @@ -0,0 +1,206 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.meter", + "version": "0.4", + "status": "DRAFT", + "date": "2026-08-20", + "properties": { + "active-power": { + "datatype": "float", + "unit": "W", + "req": "MAY", + "description": "Total active power. Sign per the reference-direction rule below." + }, + "reactive-power": { + "datatype": "float", + "unit": "var", + "req": "MAY", + "description": "Total reactive power." + }, + "apparent-power": { + "datatype": "float", + "unit": "VA", + "req": "MAY", + "description": "Total apparent power." + }, + "power-factor": { + "datatype": "float", + "format": "-1.0:1.0", + "req": "MAY", + "description": "System power factor, signed: positive = lagging (inductive), negative = leading (capacitive); range `[-1.0, 1.0]`." + }, + "frequency": { + "datatype": "float", + "unit": "Hz", + "req": "MAY", + "description": "Line frequency." + }, + "voltage": { + "datatype": "float", + "unit": "V", + "req": "MAY", + "description": "RMS voltage at a single-point meter (one measured conductor, e.g. a branch circuit or a device's single AC boundary). A split-phase or three-phase meter uses the per-conductor `voltage-{a,b,c}` instead." + }, + "current": { + "datatype": "float", + "unit": "A", + "req": "MAY", + "description": "RMS current at a single-point meter (one measured conductor). A split-phase or three-phase meter uses the per-conductor `current-{a,b,c,n}` instead." + }, + "imported-energy": { + "datatype": "float", + "unit": "Wh", + "req": "MAY", + "description": "Cumulative active energy imported: the energy counterpart of positive `active-power` (into the metered device / consumption in the default frame; which register accrues follows the reference-direction rule below). Monotonically non-decreasing." + }, + "exported-energy": { + "datatype": "float", + "unit": "Wh", + "req": "MAY", + "description": "Cumulative active energy exported: the energy counterpart of negative `active-power` (out of the metered device / production or backfeed in the default frame; which register accrues follows the reference-direction rule below). Monotonically non-decreasing." + }, + "imported-reactive-energy": { + "datatype": "float", + "unit": "varh", + "req": "MAY", + "description": "Cumulative reactive energy imported." + }, + "exported-reactive-energy": { + "datatype": "float", + "unit": "varh", + "req": "MAY", + "description": "Cumulative reactive energy exported." + }, + "apparent-energy-imported": { + "datatype": "float", + "unit": "VAh", + "req": "MAY", + "description": "Cumulative apparent energy imported." + }, + "apparent-energy-exported": { + "datatype": "float", + "unit": "VAh", + "req": "MAY", + "description": "Cumulative apparent energy exported." + }, + "shared-with-device-ids": { + "datatype": "string", + "req": "MAY", + "description": "Comma-separated Homie device IDs of the other devices this meter's hardware also measures. Omitted when it measures only this device. See §\"Shared metering hardware\"." + } + }, + "property_patterns": { + "voltage-{a,b,c}": { + "datatype": "float", + "unit": "V", + "req": "MAY", + "description": "RMS voltage on the named phase, line-to-neutral (or line-to-virtual-neutral on a delta service).", + "expand": [ + "a", + "b", + "c" + ] + }, + "current-{a,b,c,n}": { + "datatype": "float", + "unit": "A", + "req": "MAY", + "description": "RMS current on the named conductor. Neutral current (`current-n`) may be measured or imputed.", + "expand": [ + "a", + "b", + "c", + "n" + ] + }, + "active-power-{a,b,c}": { + "datatype": "float", + "unit": "W", + "req": "MAY", + "description": "Per-phase active power. Sign matches the system `active-power`.", + "expand": [ + "a", + "b", + "c" + ] + }, + "reactive-power-{a,b,c}": { + "datatype": "float", + "unit": "var", + "req": "MAY", + "description": "Per-phase reactive power.", + "expand": [ + "a", + "b", + "c" + ] + }, + "apparent-power-{a,b,c}": { + "datatype": "float", + "unit": "VA", + "req": "MAY", + "description": "Per-phase apparent power.", + "expand": [ + "a", + "b", + "c" + ] + }, + "power-factor-{a,b,c}": { + "datatype": "float", + "req": "MAY", + "description": "Per-phase power factor, signed as for the system value.", + "expand": [ + "a", + "b", + "c" + ] + }, + "voltage-angle-{a,b,c}": { + "datatype": "float", + "unit": "°", + "req": "MAY", + "description": "Voltage angle relative to phase-A voltage (`voltage-angle-a` = `0`).", + "expand": [ + "a", + "b", + "c" + ] + }, + "current-angle-{a,b,c}": { + "datatype": "float", + "unit": "°", + "req": "MAY", + "description": "Current angle relative to the same-phase voltage.", + "expand": [ + "a", + "b", + "c" + ] + }, + "imported-energy-{a,b,c}": { + "datatype": "float", + "unit": "Wh", + "req": "MAY", + "description": "Per-conductor cumulative imported energy.", + "expand": [ + "a", + "b", + "c" + ] + }, + "exported-energy-{a,b,c}": { + "datatype": "float", + "unit": "Wh", + "req": "MAY", + "description": "Per-conductor cumulative exported energy.", + "expand": [ + "a", + "b", + "c" + ] + } + } +} diff --git a/packages/schema-1/spec/catalogs/pcs.json b/packages/schema-1/spec/catalogs/pcs.json new file mode 100644 index 0000000..cb1e366 --- /dev/null +++ b/packages/schema-1/spec/catalogs/pcs.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.pcs", + "version": "0.3", + "status": "DRAFT", + "date": "2026-07-14", + "properties": { + "enabled": { + "datatype": "boolean", + "req": "SHOULD", + "description": "Is the PCS enabled on this enclosure?" + }, + "active": { + "datatype": "boolean", + "req": "SHOULD", + "description": "Is the PCS actively limiting import right now?" + }, + "import-limit": { + "datatype": "float", + "unit": "A", + "req": "SHOULD", + "description": "The **effective** enforced import limit: the `min()` across all active constraints reconciled to amps (the amps-native limits below, plus the reconciled `doe` and `voltage-response`)." + }, + "binding-constraint": { + "datatype": "enum", + "format": "FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN", + "req": "SHOULD", + "description": "Which constraint class currently sets `import-limit`: `FSR`, `DOE`, `VOLTAGE`, `OFF_GRID`, `REQUESTED`, `OPERATOR`, `NONE`, `UNKNOWN`. The provenance of the enforced limit; publishers MAY extend via `$format` (see the note on vendor-specific sources below)." + }, + "feed-import-limit": { + "datatype": "float", + "unit": "A", + "req": "SHOULD", + "description": "The **FSR**: commissioned firm feed / service capacity (premises-equipment protection), set at install. The always-on floor. May be less than the main-breaker rating when the upstream feed conductor is smaller (e.g. a 200 A panel on a 100 A service feed publishes `feed-import-limit = 100`)." + }, + "feed-import-limit-enablement": { + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "req": "SHOULD", + "description": "`UNSPECIFIED`, `UNCONFIGURED`, `DISABLED`, `ENABLED`." + }, + "feed-import-limit-active": { + "datatype": "boolean", + "req": "SHOULD", + "description": "Is this constraint currently enforcing (enabled **and** its activation conditions met)? Distinct from `binding-constraint`: several constraints may be active at once, but only the most restrictive is binding." + }, + "off-grid-import-limit": { + "datatype": "float", + "unit": "A", + "req": "MAY", + "description": "Import cap when islanded (from BESS / DER)." + }, + "off-grid-import-limit-enablement": { + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "req": "MAY", + "description": "Same domain." + }, + "off-grid-import-limit-active": { + "datatype": "boolean", + "req": "MAY", + "description": "Typically active only while islanded. See `feed-import-limit-active`." + }, + "requested-import-limit": { + "datatype": "float", + "unit": "A", + "req": "MAY", + "description": "A **voluntary**, self-imposed temporary limit requested by the homeowner or installer (e.g. via a mobile app). Self-revocable. Distinct from an externally imposed operator cap (`operator-import-limit`) and from the utility grid envelope (`doe`)." + }, + "requested-import-limit-enablement": { + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "req": "MAY", + "description": "Same domain." + }, + "requested-import-limit-active": { + "datatype": "boolean", + "req": "MAY", + "description": "See `feed-import-limit-active`." + }, + "operator-import-limit": { + "datatype": "float", + "unit": "A", + "req": "MAY", + "description": "An **externally imposed** cap set by a fleet / aggregator operator over a management API (a DER aggregator, VPP, or utility program acting through the vendor's fleet REST interface). Persists until the operator changes or clears it. Distinct from `requested-import-limit` (self-imposed) and from `doe` (the standardized IEEE 2030.5 / CSIP watts envelope): `operator-import-limit` is a vendor-API amps cap, not a CSIP DOE." + }, + "operator-import-limit-enablement": { + "datatype": "enum", + "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED", + "req": "MAY", + "description": "Same domain." + }, + "operator-import-limit-active": { + "datatype": "boolean", + "req": "MAY", + "description": "See `feed-import-limit-active`." + }, + "managed": { + "datatype": "boolean", + "req": "MAY", + "description": "Is this circuit managed by the host's PCS?" + }, + "priority": { + "datatype": "integer", + "req": "MAY", + "description": "PCS priority ranking, consulted when an active import limit is binding (which circuits shed first)." + } + } +} diff --git a/packages/schema-1/spec/catalogs/power-flows.json b/packages/schema-1/spec/catalogs/power-flows.json new file mode 100644 index 0000000..727cbfe --- /dev/null +++ b/packages/schema-1/spec/catalogs/power-flows.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.power-flows", + "version": "0.3", + "status": "DRAFT", + "date": "2026-08-20", + "properties": { + "grid": { + "datatype": "float", + "unit": "W", + "req": "SHOULD", + "description": "Grid power flow (positive = exporting to the grid)." + }, + "battery": { + "datatype": "float", + "unit": "W", + "req": "SHOULD", + "description": "Battery power flow (positive = charging)." + }, + "pv": { + "datatype": "float", + "unit": "W", + "req": "SHOULD", + "description": "Solar PV power flow (negative while producing)." + }, + "site": { + "datatype": "float", + "unit": "W", + "req": "SHOULD", + "description": "Total site power consumption (positive = consuming)." + } + } +} diff --git a/packages/schema-1/spec/catalogs/shed-forecast.json b/packages/schema-1/spec/catalogs/shed-forecast.json new file mode 100644 index 0000000..50bda42 --- /dev/null +++ b/packages/schema-1/spec/catalogs/shed-forecast.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.shed-forecast", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "total-time-remaining": { + "datatype": "integer", + "unit": "min", + "req": "SHOULD", + "description": "At current state of energy, total time before all backed-up loads go unpowered." + }, + "time-to-priority-shed": { + "datatype": "integer", + "unit": "min", + "req": "SHOULD", + "description": "At current state of energy, time until priority-shed (e.g. `SOC_THRESHOLD`) circuits are auto-shed." + }, + "full-charge-total-time-remaining": { + "datatype": "integer", + "unit": "min", + "req": "SHOULD", + "description": "At 100% state of energy, total backup-duration capability." + }, + "full-charge-time-to-priority-shed": { + "datatype": "integer", + "unit": "min", + "req": "SHOULD", + "description": "At 100% state of energy, capability time until the priority-shed event." + }, + "confidence": { + "datatype": "enum", + "format": "LOW,MEDIUM,HIGH", + "req": "SHOULD", + "description": "The algorithm's self-assessed confidence: `LOW`, `MEDIUM`, `HIGH`. Reflects accumulated usage history." + } + } +} diff --git a/packages/schema-1/spec/catalogs/shed.json b/packages/schema-1/spec/catalogs/shed.json new file mode 100644 index 0000000..ff1a1dd --- /dev/null +++ b/packages/schema-1/spec/catalogs/shed.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.shed", + "version": "0.2", + "status": "DRAFT", + "date": "2026-07-30", + "properties": { + "asserted-islanding-state": { + "datatype": "enum", + "format": "NONE,ON_GRID,OFF_GRID", + "settable": true, + "req": "MAY", + "description": "Consumer-asserted islanding-state for the host's own island scope, consulted only while the host has lost or degraded communication with the device that senses that state (its MID / BESS). Advertised in `$format` as `NONE`, `ON_GRID`, `OFF_GRID` (default `NONE`). See \"Asserted islanding-state\" below." + }, + "policy": { + "datatype": "json", + "settable": true, + "req": "MAY", + "description": "The host's shedding algorithm and its parameters: `{ \"algorithm\": , \"parameters\": { … } }`. The parameter object's shape is advertised as a JSON Schema in the property's `$format`. See \"Shed policy\" below." + } + } +} diff --git a/packages/schema-1/spec/catalogs/soc.json b/packages/schema-1/spec/catalogs/soc.json new file mode 100644 index 0000000..c26e46c --- /dev/null +++ b/packages/schema-1/spec/catalogs/soc.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.soc", + "version": "0.2", + "status": "DRAFT", + "date": "2026-08-20", + "properties": { + "soc": { + "datatype": "float", + "unit": "%", + "req": "MAY", + "description": "State of charge: the fraction of capacity currently held (`0` = empty, `100` = full). A dimensionless ratio, comparable across all reservoirs." + }, + "soe": { + "datatype": "float", + "unit": "energy", + "req": "MAY", + "description": "State of energy: the energy currently stored and available to draw (the discharge side). Reported in the device's native energy unit (a BESS in kWh electrical, a water heater in Wh thermal)." + }, + "total-energy-storage": { + "datatype": "float", + "unit": "energy", + "req": "MAY", + "description": "The reservoir's total energy capacity (empty to full), in the same unit as `soe`." + }, + "loadup-headroom": { + "datatype": "float", + "unit": "energy", + "req": "MAY", + "description": "The energy the reservoir can absorb **now** (the charge side), approximately `total-energy-storage − soe`. The dispatchable charge a load-up / charge action can take on." + } + } +} diff --git a/packages/schema-1/spec/catalogs/status.json b/packages/schema-1/spec/catalogs/status.json new file mode 100644 index 0000000..65f0731 --- /dev/null +++ b/packages/schema-1/spec/catalogs/status.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.status", + "version": "0.1", + "status": "DRAFT", + "date": "2026-07-11", + "properties": { + "fault-state": { + "datatype": "enum", + "format": "OK,FAULT,UNKNOWN", + "req": "MAY", + "description": "Overall fault state: `OK`, `FAULT`, `UNKNOWN`. Publishers MAY extend the value set via `$format` for device-specific fault categories." + }, + "communication-state": { + "datatype": "enum", + "format": "OK,DEGRADED,LOST,UNKNOWN", + "req": "MAY", + "description": "The publisher's view of its own communication / link health, to the device it represents (for a proxy) or to its backhaul (for a native device): `OK`, `DEGRADED`, `LOST`, `UNKNOWN`. Orthogonal to whether the eBus publisher is currently reporting to *its* consumers." + }, + "active-alerts": { + "datatype": "string", + "req": "MAY", + "description": "Human-readable current alert(s), when the device exposes them." + } + } +} diff --git a/packages/schema-1/spec/catalogs/switch.json b/packages/schema-1/spec/catalogs/switch.json new file mode 100644 index 0000000..46a3f1e --- /dev/null +++ b/packages/schema-1/spec/catalogs/switch.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://ebus.energy/schemas/property-catalog.json", + "schema_version": "property-schema-v1", + "kind": "capability-catalog", + "capability": "energy.ebus.capability.switch", + "version": "0.3", + "status": "DRAFT", + "date": "2026-08-20", + "properties": { + "relay": { + "datatype": "enum", + "format": "OPEN,CLOSED,UNKNOWN", + "settable": true, + "req": "MUST", + "description": "Relay state: `OPEN`, `CLOSED`, `UNKNOWN`. Settable when `relay-controllable = true`." + }, + "relay-controllable": { + "datatype": "boolean", + "req": "SHOULD", + "description": "True = the relay can be opened and closed by command or automatic shed. False = locked (for example a circuit commissioned as permanently on)." + }, + "relay-requester": { + "datatype": "enum", + "format": "USER,LOAD_SHED,PCS,CONFIGURATION,FAULT,NONE,UNKNOWN", + "req": "SHOULD", + "description": "Source attribution for the last relay change: `USER`, `LOAD_SHED`, `PCS`, `CONFIGURATION`, `FAULT`, `NONE`, `UNKNOWN`. Publishers MAY extend via `$format`." + }, + "shared-with-device-ids": { + "datatype": "string", + "req": "MAY", + "description": "Comma-separated Homie device IDs of the other devices this relay also switches. Omitted when it switches only this device. See §\"Shared switching hardware\"." + } + } +} diff --git a/packages/schema-1/spec/fixtures/simulator_tree.json b/packages/schema-1/spec/fixtures/simulator_tree.json new file mode 100644 index 0000000..8546434 --- /dev/null +++ b/packages/schema-1/spec/fixtures/simulator_tree.json @@ -0,0 +1,4984 @@ +{ + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627511 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627514 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627513 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627511 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627514 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627509 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627510 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627512 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627512 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627514 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627512 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627512 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627514 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627509 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627510 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627509 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627513 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627513 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627512 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627511 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627514 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627513 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627510 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627511 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627511 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627510 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627512 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627511 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627513 + }, + "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": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated downstream (e.g. microinverters, packs)" + }, + "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": 1786424627510 + }, + "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": 1786424627515 + }, + "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": 1786424627515 + }, + "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": 1786424627515 + }, + "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": 1786424627514 + }, + "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": 1786424627515 + }, + "sim-40t-001-lugs-dn": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Downstream lugs", + "nodes": { + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated up/downstream" + }, + "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": 1786424627515 + }, + "sim-40t-001-lugs-up": { + "children": [], + "extensions": [], + "homie": "5.0", + "name": "Upstream lugs", + "nodes": { + "connection": { + "name": "connection", + "properties": { + "count": { + "datatype": "integer", + "name": "Number of physical units aggregated up/downstream" + }, + "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": 1786424627515 + }, + "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": 1786424627515 + } +} diff --git a/packages/schema-1/spec/fixtures/simulator_wire.json b/packages/schema-1/spec/fixtures/simulator_wire.json new file mode 100644 index 0000000..7bd1bdb --- /dev/null +++ b/packages/schema-1/spec/fixtures/simulator_wire.json @@ -0,0 +1,684 @@ +{ + "13044bfbcbe5554b8f3dba126bce828f": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617685, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Island)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Kitchen Outlets (Island)", + "info/spaces": "10", + "load-shed/priority": "NEVER", + "meter/active-power": "-295.52615631181357", + "meter/current": "2.462717969265113", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "9", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "1bfdc7ecebb0547bbe87a3696cddb0c0": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617688, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "sim-40t-001-sim-evse-sim-40t-001-2", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Driveway", + "info/spaces": "35,37", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "28", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "1eeeb748eeaa58edb7e9b7e9dbbdeca7": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617687, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Smoke Detectors\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Smoke Detectors", + "info/spaces": "40", + "load-shed/priority": "NEVER", + "meter/active-power": "-4.79282953433586", + "meter/current": "0.0399402461194655", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "21", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "2140a7e253ed54e3bc90a959081df615": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617686, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Refrigerator\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Refrigerator", + "info/spaces": "15", + "load-shed/priority": "NEVER", + "meter/active-power": "-124.32888886191921", + "meter/current": "1.0360740738493268", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "false", + "pcs/priority": "14", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "NONE" + }, + "249a2f59782e5f1ab317c4632e79afad": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617688, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "sim-40t-001-sim-evse-sim-40t-001", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Garage", + "info/spaces": "32,34", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "27", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "3d9d86f303cc50d1827be57d4c667e53": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617684, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Bedroom Lights", + "info/spaces": "4", + "load-shed/priority": "NEVER", + "meter/active-power": "-32.54905694719264", + "meter/current": "0.27124214122660534", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "3", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "3eeb0eb1605e5a7eadac41994b7a096c": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617684, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Master Bedroom Outlets", + "info/spaces": "7", + "load-shed/priority": "NEVER", + "meter/active-power": "-156.07339750944152", + "meter/current": "1.3006116459120127", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "6", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "43a0521737db516f99f14a9964ea4af0": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617686, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Washing Machine\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Washing Machine", + "info/spaces": "17", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-738.3921387303916", + "meter/current": "6.153267822753263", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "16", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "4aeb08c46c2c5905a944166413f2f1ef": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617686, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garbage Disposal\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Garbage Disposal", + "info/spaces": "21", + "load-shed/priority": "NEVER", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "19", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "4ce8b30e8d3f5c49b9e0ab0c8caf4832": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617688, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Water Heater\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "info/name": "Water Heater", + "info/spaces": "31,33", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-4500.0", + "meter/current": "18.75", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "26", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "4d1deb6acb065746b13207b1358f8ca7": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617686, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Dishwasher\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Dishwasher", + "info/spaces": "16", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "15", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "516694a326a35cd88600b3520e8a981a": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617687, \"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\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Pool Pump", + "info/spaces": "39", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-217.26021119423217", + "meter/current": "1.8105017599519349", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "20", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "6fcb352679ad5bfb8c8a8eab06829b9f": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617688, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "connection/feeds-device-id": "sim-40t-001-pv-1", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.pv", + "info/name": "Solar Inverter", + "info/spaces": "36,38", + "load-shed/priority": "NEVER", + "meter/active-power": "2029.544536896322", + "meter/current": "8.456435570401341", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "false", + "pcs/priority": "29", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "NONE" + }, + "770e2de52c33508a8a9ee8878064b46f": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617683, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Master Bedroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Master Bedroom Lights", + "info/spaces": "1", + "load-shed/priority": "NEVER", + "meter/active-power": "-15.079182336548172", + "meter/current": "0.1256598528045681", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "1", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "80a4fada833156ab8112f9d50e252b8f": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617684, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Outlets (Counter)\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Kitchen Outlets (Counter)", + "info/spaces": "9", + "load-shed/priority": "NEVER", + "meter/active-power": "-313.35079337973053", + "meter/current": "2.6112566114977542", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "8", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "9429f828509e58d59cb5f0f9f5fee523": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617683, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Living Room Lights", + "info/spaces": "2", + "load-shed/priority": "NEVER", + "meter/active-power": "-20.77555195401851", + "meter/current": "0.17312959961682092", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "2", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "948dea7788aa5c959b99df0edfabead2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617687, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Heat 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\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "info/name": "Heat Pump", + "info/spaces": "27,29", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-1263.514205767015", + "meter/current": "5.264642524029229", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "24", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "af731c49a6785a4cb2ea5549fb8bce7e": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617687, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Main HVAC\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "info/name": "Main HVAC", + "info/spaces": "23,25", + "load-shed/priority": "NEVER", + "meter/active-power": "-618.5089366700175", + "meter/current": "2.5771205694584065", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "23", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "afe90839f2725e3e962fb05afa2b6d43": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617686, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Chest Freezer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Chest Freezer", + "info/spaces": "19", + "load-shed/priority": "NEVER", + "meter/active-power": "-86.21815758174944", + "meter/current": "0.7184846465145787", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "false", + "pcs/priority": "18", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "NONE" + }, + "b24483358d29589d8e91d3bf11113269": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617685, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Office Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Office Outlets", + "info/spaces": "11", + "load-shed/priority": "NEVER", + "meter/active-power": "-259.11072766424627", + "meter/current": "2.159256063868719", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "10", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "b9fa08f1eaaf5d129bd5c78e1d5d937f": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617688, \"type\": \"energy.ebus.device.circuit\", \"name\": \"kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "kitchen Lights", + "info/spaces": "3", + "load-shed/priority": "NEVER", + "meter/active-power": "-141.27576756242206", + "meter/current": "1.177298063020184", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "30", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "be7742043a06554aab2a1e38cc776603": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617687, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Oven/Range\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "40", + "info/name": "Electric Oven/Range", + "info/spaces": "28,30", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-5000.0", + "meter/current": "20.833333333333332", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "25", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "c058aa11287f50f9b81e5160a0678869": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617684, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Bathroom Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Bathroom Lights", + "info/spaces": "5", + "load-shed/priority": "NEVER", + "meter/active-power": "-11.460658123756456", + "meter/current": "0.09550548436463714", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "4", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "c339ec7ce7ff521ca7646f9606baff9f": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617685, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Guest Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Guest Room Outlets", + "info/spaces": "14", + "load-shed/priority": "NEVER", + "meter/active-power": "-137.99326566949193", + "meter/current": "1.1499438805790994", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "13", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "d1ff145887a05b839ede89409c27b398": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617685, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Garage Outlets", + "info/spaces": "12", + "load-shed/priority": "NEVER", + "meter/active-power": "-163.20433875732755", + "meter/current": "1.360036156311063", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "11", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "e0ac90e169e6550ea83fe0b1942f1d0e": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617684, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Living Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Living Room Outlets", + "info/spaces": "8", + "load-shed/priority": "NEVER", + "meter/active-power": "-244.30939312922507", + "meter/current": "2.035911609410209", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "7", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "e0bc156c85015a609d4132084dfcd6fe": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617686, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Microwave\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Microwave", + "info/spaces": "18", + "load-shed/priority": "NEVER", + "meter/active-power": "-1500.0", + "meter/current": "12.5", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "17", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "edee3425d50d51ffb022ee999053b2b4": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617685, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Laundry Room Outlets\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Laundry Room Outlets", + "info/spaces": "13", + "load-shed/priority": "NEVER", + "meter/active-power": "-129.62101242313324", + "meter/current": "1.0801751035261102", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "12", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "ef972f063451539e8b2ad88e831d87b6": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617687, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Electric Dryer\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "info/name": "Electric Dryer", + "info/spaces": "20,22", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-5000.0", + "meter/current": "20.833333333333332", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "22", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "f515a0f43b6555b1a196fbb62728c24e": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617684, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Exterior Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Exterior Lights", + "info/spaces": "6", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "5", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "sim-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span 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\": [\"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\": []}", + "$state": "ready", + "breaker/rating": "200", + "door/state": "CLOSED", + "info/data-model-version": "1.0", + "info/firmware-version": "sim/v0.1.0", + "info/hardware-version": "rev2", + "info/model": "MAIN_40", + "info/serial-number": "sim-40t-001", + "info/vendor-name": "Span", + "meter/voltage-a": "120.0", + "meter/voltage-b": "120.0", + "pcs/active": "false", + "pcs/binding-constraint": "NONE", + "pcs/enabled": "false", + "pcs/feed-import-limit": "0.0", + "pcs/feed-import-limit-active": "false", + "pcs/feed-import-limit-enablement": "UNCONFIGURED", + "pcs/import-limit": "0.0", + "pcs/off-grid-import-limit": "0.0", + "pcs/off-grid-import-limit-active": "false", + "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", + "pcs/operator-import-limit": "0.0", + "pcs/operator-import-limit-active": "false", + "pcs/operator-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit": "0.0", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "power-flows/battery": "3500.0", + "power-flows/grid": "15443.800133211684", + "power-flows/pv": "2029.544536896322", + "power-flows/site": "20973.344670108007", + "shed-forecast/confidence": "HIGH", + "shed-forecast/full-charge-time-to-priority-shed": "3038", + "shed-forecast/full-charge-total-time-remaining": "4320", + "shed-forecast/time-to-priority-shed": "3037", + "shed-forecast/total-time-remaining": "4320", + "shed/asserted-islanding-state": "NONE", + "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", + "status/cloud-connection": "CONNECTED", + "status/ethernet": "true", + "status/postal-code": "94103", + "status/relay": "CLOSED", + "status/time-zone": "America/Los_Angeles", + "status/wifi": "true", + "status/wifi-ssid": "sim-wifi" + }, + "sim-40t-001-SIM-BESS-40T-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"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\": [\"sim-40t-001-SIM-BESS-40T-001-mid\"], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/firmware-version": "sim-bess/v0.1.0", + "info/model": "SPAN Battery", + "info/nameplate-capacity": "13.5", + "info/part-number": "SPN-BESS-001", + "info/serial-number": "SIM-BESS-40T-001", + "info/vendor-name": "Span", + "meter/active-power": "3500.0", + "soc/soc": "50.0", + "soc/soe": "6.75", + "status/communication-state": "OK" + }, + "sim-40t-001-SIM-BESS-40T-001-mid": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"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\": \"sim-40t-001\", \"parent\": \"sim-40t-001-SIM-BESS-40T-001\", \"extensions\": []}", + "$state": "ready", + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UP", + "grid/islanding-state": "ON_GRID", + "info/firmware-version": "sim-mid/v0.1.0", + "info/hardware-version": "rev1", + "info/model": "SPAN MID", + "info/serial-number": "SIM-BESS-40T-001-mid", + "info/vendor-name": "Span" + }, + "sim-40t-001-lugs-dn": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/direction": "DOWNSTREAM", + "meter/active-power": "0", + "meter/current-a": "0.0", + "meter/current-b": "0.0", + "meter/exported-energy": "0", + "meter/imported-energy": "0" + }, + "sim-40t-001-lugs-up": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "connection/fed-by-device-id": "sim-40t-001-SIM-BESS-40T-001", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.bess", + "info/direction": "UPSTREAM", + "meter/active-power": "18943.800133211684", + "meter/current-a": "94.98295645861873", + "meter/current-b": "96.70778693308402", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0" + }, + "sim-40t-001-pv-1": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"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\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/firmware-version": "sim-pv/v0.1.0", + "info/model": "IQ8PLUS-72-2-US", + "info/nominal-power": "10000.0", + "info/vendor-name": "Enphase" + }, + "sim-40t-001-sim-evse-sim-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"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\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "sim/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "sim-evse-sim-40t-001", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "sim-40t-001-sim-evse-sim-40t-001-2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787186617689, \"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\": \"sim-40t-001\", \"parent\": \"sim-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "sim/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "sim-evse-sim-40t-001-2", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + } +} diff --git a/packages/schema-1/spec/registries/device-types.md b/packages/schema-1/spec/registries/device-types.md new file mode 100644 index 0000000..fb987b7 --- /dev/null +++ b/packages/schema-1/spec/registries/device-types.md @@ -0,0 +1,56 @@ +# Electrification Bus Device Type Registry + +**Status:** DRAFT v0.5 +**Date:** 2026-08-05 +**Authors:** Don Jackson + +## Purpose + +This document is the canonical registry of `energy.ebus.device.*` device-type identifiers used across all Electrification Bus (eBus for short) data models. Data-model documents reference identifiers from this registry; new identifiers are added to this registry when a data-model document introduces them. + +In the eBus Homie model, every device participating in the bus declares its device type via the `$type` attribute drawn from this namespace. A device-type identifier names a category of physical or logical device — for example, a distribution enclosure, a battery energy storage system, an electric vehicle supply equipment unit — and constrains what device structure (child devices, capabilities) the parent of that type is expected to expose. + +This registry is descriptive, not exhaustive: it lists what is currently registered. Consumers MUST tolerate unknown `$type` values (e.g., accept and persist them; apply only generic Homie handling). + +## Format rules + +- Identifiers are of the form `energy.ebus.device.`. +- The `` portion is lowercase kebab-case ASCII: lowercase letters, digits, and hyphens only. +- No leading or trailing hyphens; no consecutive hyphens. +- Identifiers are case-sensitive. + +## Registered device types + +The **Source** column references the data-model document where the identifier currently appears. For identifiers that appear only as forward references (the full data model has not yet been published), the source is the document that introduced the reference. + +| Identifier | Description | Source | +|---|---|---| +| `energy.ebus.device.distribution-enclosure` | Parent device for an electrical distribution enclosure (panel / load center / consumer unit / switchboard). Hosts child devices for circuits, feed points, and (in some installations) integrated DERs. | [`devices/distribution-enclosure.md`](../devices/distribution-enclosure.md) | +| `energy.ebus.device.circuit` | Child device representing one branch circuit within a distribution enclosure. | [`devices/distribution-enclosure.md`](../devices/distribution-enclosure.md) | +| `energy.ebus.device.lugs` | Child device representing a feed point (upstream or downstream lugs) on a distribution enclosure. Carries the meter for that feed. | [`devices/distribution-enclosure.md`](../devices/distribution-enclosure.md) | +| `energy.ebus.device.bess` | Battery Energy Storage System (whole-home grid-forming, plug-in / UPS, or grid-following-only). May be published natively (as its own Homie root) or proxied as a child of a distribution enclosure. | [`devices/bess.md`](../devices/bess.md) | +| `energy.ebus.device.pv` | Photovoltaic inverter. May be published natively or proxied as a child of a distribution enclosure. *Full data model pending — currently described in the dist-enclosure spec via the proxied-PV child structure.* | [`devices/distribution-enclosure.md`](../devices/distribution-enclosure.md) (forward reference) | +| `energy.ebus.device.evse` | Electric Vehicle Supply Equipment. May be published natively or proxied as a child of a distribution enclosure. *Full data model pending — currently described in the dist-enclosure spec via the proxied-EVSE child structure.* | [`devices/distribution-enclosure.md`](../devices/distribution-enclosure.md) (forward reference) | +| `energy.ebus.device.mid` | Microgrid Interconnect Device — the grid-relay + per-side meters + controller subsystem that handles islanding for a grid-forming-capable site. May appear as a child device of a distribution enclosure (enclosure-integrated MID) or a BESS (BESS-integrated MID or its proxied equivalent); may also be published as a first-class standalone device. *Full data model pending — currently described in the dist-enclosure spec's MID and proxied-BESS sections.* | [`devices/distribution-enclosure.md`](../devices/distribution-enclosure.md) (forward reference) | +| `energy.ebus.device.bridge` | Standalone proxy host — an entity whose sole role is to bridge one or more non-eBus-native devices into the eBus tree (e.g., a Linux service polling a Tesla cloud API and publishing the Powerwall as a proxy; a Modbus-to-eBus appliance; a CTA-2045 Universal Communications Module bridging a water heater). The bridge anchors the Homie tree as the root of its proxied children; the proxied devices are named per the `{proxier-id}-{proxied-id}` convention in `devices/proxy.md`. The bridge does not publish HEI-device capabilities of its own. Semantic parallel to Matter's *Bridge* device type. | [`framework.md`](../framework.md#standalone-proxy-hosts-bridges) | +| `energy.ebus.device.pdu` | Parent device for a Power Distribution Unit: distributes power to switchable, metered `outlet` children. No storage and no generation. | [`devices/pdu.md`](../devices/pdu.md) | +| `energy.ebus.device.outlet` | One switchable, metered output port (an AC receptacle, or a USB / DC port). Used as a child of a host (PDU, plug-in BESS / UPS) or standalone (a smart plug / smart receptacle). | [`devices/outlet.md`](../devices/outlet.md) | +| `energy.ebus.device.water-heater` | A storage water heater (heat-pump, electric-resistance, gas, or hybrid) modeled as a controllable, grid-flexible load and dispatchable thermal-storage resource. May be published natively or proxied (e.g., as the child of a CTA-2045 UCM bridge). | [`devices/water-heater.md`](../devices/water-heater.md) | +| `energy.ebus.device.utility-meter` | The revenue-grade metering device installed by an electric utility at a customer's service entrance, between the utility's distribution system and the premises wiring. The site's primary point of measurement for energy billing and its most authoritative observer of the utility supply. May be published natively or by a proxy publisher with access to the underlying values. | [`devices/utility-meter.md`](../devices/utility-meter.md) | +| `energy.ebus.device.battery` | Individual battery pack. Child of a BESS. | [`devices/bess.md`](../devices/bess.md) | +| `energy.ebus.device.inverter` | DC-AC inverter. May handle both battery and solar (e.g., Powerwall 3). Child of a BESS. | [`devices/bess.md`](../devices/bess.md) | +| `energy.ebus.device.meter` | Metering point within a larger system, used for site, load, or solar metering. Child of a BESS. Distinct from `utility-meter`, which is the utility's revenue meter at the service entrance. | [`devices/bess.md`](../devices/bess.md) | + +## Adding new device types + +New identifiers may be added to this registry as new data-model documents are published. The process: + +1. The proposed identifier follows the format rules above. +2. The proposed identifier is genuinely new — not a synonym of an existing entry. +3. A data-model document defines (or explicitly references) the device and is the source for the registry row. +4. The proposed identifier is added to this registry with a description and a source reference. +5. This document's version is bumped. + +Forward references (a data model that mentions a device type whose full model isn't published yet) are valid registry entries; they are marked as such in the Source column and re-pointed at the full data model when it lands. + +Producers and consumers SHOULD treat unknown `$type` values as opaque — accept and persist them, but apply only the generic Homie / eBus framework defaults. This permits forward-compatibility: a device using a newer type identifier than the consumer knows about should still be handled gracefully. diff --git a/packages/schema-1/src/span_panel_api_schema_1/__init__.py b/packages/schema-1/src/span_panel_api_schema_1/__init__.py new file mode 100644 index 0000000..ba158d0 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/__init__.py @@ -0,0 +1,6 @@ +"""Parent/child schema (data-model-version 1.x) parser for span-panel-api.""" + +from span_panel_api_schema_1.adapter import SchemaOneAdapter +from span_panel_api_schema_1.transport import ControllerRoutes + +__all__ = ["ControllerRoutes", "SchemaOneAdapter"] diff --git a/packages/schema-1/src/span_panel_api_schema_1/adapter.py b/packages/schema-1/src/span_panel_api_schema_1/adapter.py new file mode 100644 index 0000000..985e2d3 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/adapter.py @@ -0,0 +1,390 @@ +"""Parent/child adapter: `ebus_sdk.Controller` behind the `SchemaAdapter` protocol. + +The SDK does the Homie work — walking `$description.children`, gating each +child's subscription on its parent reaching `ready`, and cascading state down +the tree. This adapter supplies the transport it parses over, sorts the result +into a `SpanPanelSnapshot`, and builds the topics the transport publishes +commands to. + +**It never touches the connection.** `SchemaAdapter` instances are built before +one exists, so `Controller` is given a route table (`ControllerRoutes`) that +records its subscriptions instead of making them, and this adapter asks for one +broad subscription up front through `topics_to_subscribe()`. Every message then +arrives via `handle_message` and is routed to whichever SDK callback wanted it. +A reconnect re-subscribes that same static list, the broker replays the retained +tree, and the SDK repopulates — so there is no resync hook to wire or forget. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ebus_sdk import Controller + +from span_panel_api_schema_1.charge_limit import ChargeLimitProperty, ChargeLimitSurface, resolve_charge_limit +from span_panel_api_schema_1.const import ( + HOMIE_DOMAIN, + HOMIE_VERSION, + NODE_INFO, + NODE_LOAD_SHED, + NODE_SHED, + NODE_SWITCH, + PROP_ASSERTED_ISLANDING_STATE, + PROP_MODEL, + PROP_NAME, + PROP_PRIORITY, + PROP_RELAY, + STATE_READY, +) +from span_panel_api_schema_1.description import device_type +from span_panel_api_schema_1.field_metadata import build_field_metadata +from span_panel_api_schema_1.panel import integer +from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot, harmonised_evse_keys +from span_panel_api_schema_1.transport import ControllerRoutes + +if TYPE_CHECKING: + from collections.abc import Callable + + from ebus_sdk.homie import DiscoveredDevice + + from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema + +_LOGGER = logging.getLogger(__name__) + + +class SchemaOneAdapter: + """Parser for the parent/child schema (data-model-version 1.x).""" + + # A literal, deliberately not imported from span_panel_api.protocol: a value + # read from the installed bootstrap would agree with every bootstrap, which + # is the disagreement the check exists to find. Bump when this adapter is + # rebuilt against a new contract, never to match what happens to be installed. + ADAPTER_CONTRACT: int = 1 + schema_major = "schema_1" + SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] = (">=1.0", "<2.0") + + def __init__(self, serial_number: str, schema: V2HomieSchema) -> None: + self._serial_number = serial_number + self._schema = schema + self._routes = ControllerRoutes() + self._controller = Controller(root_device_id=serial_number, mqttc=self._routes) + self._property_callbacks: list[Callable[[str, str, str, str | None], None]] = [] + self._awaiting: tuple[str, ...] = () + self._controller.set_on_property_changed_callback(self._on_property_changed) + # Records the subscriptions the tree walk needs; nothing reaches the + # wire, because this object has no connection to reach it with. + self._controller.start_discovery() + + # -- SchemaAdapter ----------------------------------------------------- + + def topics_to_subscribe(self) -> list[str]: + """One subscription covering the whole tree. + + Deliberately broader than the SDK's own per-device subscriptions, + because the adapter is asked this once at connect and again after a + reconnect — it has no way to add one when a child announces later. The + flat adapter takes the same approach with `ebus/5/{serial}/#`; here the + wildcard spans devices, since children are peers of the panel in the + topic tree rather than nodes beneath it. + """ + return [f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/#"] + + def handle_message(self, topic: str, payload: str) -> None: + self._routes.dispatch(topic, payload) + + def is_ready(self) -> bool: + """Ready when the whole declared tree has described itself. + + The flat schema gets its entire topology in one `$description`, so + "described" and "complete" are the same event. Under parent/child the + topology arrives as one description per device, and the root's says + ready as soon as *its own* arrives — while its children are still + landing. Treating that as ready hands the transport a panel with a + handful of circuits and no model, which it reports as a healthy + connection. So readiness waits for every device the tree declares. + + Completeness comes from `Controller.is_tree_complete()` rather than a + walk of our own. It is the SDK's reconciling predicate for exactly this + question, it terminates on a declared cycle, and having one + implementation means our answer cannot drift from the tree the + controller actually holds. `_awaiting_descriptions` survives as the + diagnostic the predicate does not provide — which devices, not merely + whether. + + This is a predicate, never a barrier: the transport consults it on every + snapshot, so a device commissioned later correctly makes it False again + until that device describes itself. + + Child *state* is deliberately not required. A commissioned DER that is + currently offline publishes `lost` but keeps its retained description, + and a panel should not fail to connect because a battery is unplugged. + + The model is required only when the root's description declares it: the + panel's size comes from nowhere else, and a snapshot built a moment too + early reports zero spaces, which erases every unmapped position rather + than merely mis-stating a number. Asking only for what the panel itself + promised keeps a firmware that omits the property connectable — it + falls back to the drift warning in `panel_size_from_model`. + """ + root = self._controller.get_root(self._serial_number) + if root is None or root.state != STATE_READY or not root.description: + return False + # Diagnostic first, and unconditionally, so the pending set stays + # accurate: a tree that never completes then names the devices it is + # waiting on instead of expiring as a bare 30-second connect timeout, + # which `is_tree_complete()` alone cannot tell anyone. + self._awaiting_descriptions(root) + if not self._controller.is_tree_complete(self._serial_number): + return False + return self._model_arrived(root) + + def build_snapshot(self) -> SpanPanelSnapshot: + root = self._require_root() + return build_snapshot(root, self._children()) + + def build_field_metadata(self) -> dict[str, FieldMetadata]: + root = self._controller.get_root(self._serial_number) + devices = [] if root is None else [root, *self._children()] + return build_field_metadata(devices) + + def circuit_nodes_missing_names(self) -> list[str]: + """Devices whose retained identity has not arrived yet. + + The transport polls this during connect so the first snapshot carries + real names rather than falling back to identifiers. + + Readiness proves the tree's *shape* — every device the tree declares + has described itself. It cannot prove the tree's *labels*: a + description says which properties exist, and their retained values + arrive as separate messages that may land after the last description + does. That gap exists under the flat schema too; it just matters more + here, because a DER is its own device and the integration registers it + from this first snapshot. + + Named for the flat schema's circuits, where a missing name was the only + way to get a placeholder. Under parent/child every mapped device has + the same exposure, so a DER missing the model it declared is reported + alongside a circuit missing its name. + """ + roles = TreeRoles(self._children()) + missing = [circuit.device_id for circuit in roles.circuits if not circuit.get_property(NODE_INFO, PROP_NAME)] + ders = (roles.bess, roles.pv, *roles.evse) + missing.extend( + device.device_id + for device in ders + if device is not None + and PROP_MODEL in device.get_node_properties(NODE_INFO) + and device.get_property(NODE_INFO, PROP_MODEL) is None + ) + return missing + + def find_node_by_type(self, type_str: str) -> str | None: + """Return the id of the first device declaring `type_str`. + + Named for the flat schema's nodes; under parent/child the same question + is asked of devices, and the answer is a device id. + """ + for device in self._children(): + if device_type(device) == type_str: + return device.device_id + return None + + # -- Command topics ---------------------------------------------------- + # + # The adapter names the topic and the transport publishes it, so commanding + # a panel needs no connection here either. + + def set_circuit_relay_topic(self, circuit_id: str) -> str: + return self._set_topic(circuit_id, NODE_SWITCH, PROP_RELAY) + + def set_circuit_priority_topic(self, circuit_id: str) -> str: + return self._set_topic(circuit_id, NODE_LOAD_SHED, PROP_PRIORITY) + + def set_dominant_power_source_topic(self) -> str | None: + """The settable successor: `shed/asserted-islanding-state` on the panel. + + `dominant-power-source` split in two. The read half became + `grid/grid-forming-entity` on the MID; this is the write half, and it is + the only settable one, so it is what a caller of + `set_dominant_power_source` is reaching for. + + The catalog scopes it to exactly the case the control exists to serve: + "consulted only while the host has lost or degraded communication with + the device that senses that state (its MID / BESS)". Concretely — comms + to the BESS drop, the grid returns, the user asserts the grid is up, and + the BESS stops discharging. Returning None here, as this did until the + successor was decided, left that recovery unavailable during an outage. + + Payload translation is not optional: the flat enum this protocol speaks + is not the one the panel accepts. See `dominant_power_source_payload`. + """ + return self._set_topic(self._serial_number, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE) + + def dominant_power_source_payload(self, value: str) -> str | None: + """Translate a flat `dominant-power-source` value into an assertion. + + The published protocol speaks flat's vocabulary — `GRID`, `BATTERY`, + `PV`, `GENERATOR`, `NONE`, `UNKNOWN` — because that is the contract + callers were written against. The panel accepts `NONE`, `ON_GRID`, + `OFF_GRID`. Publishing the caller's string unchanged would put a value + outside the enum on the wire. + + The narrowing loses nothing, because the six values were a *source + class* pressed into service as a manual override and the job only ever + needed on-grid, off-grid, or no assertion. Anything not recognised + returns None rather than guessing, so the transport refuses the command + instead of asserting something the user did not ask for. + """ + return { + "GRID": "ON_GRID", + "BATTERY": "OFF_GRID", + "PV": "OFF_GRID", + "GENERATOR": "OFF_GRID", + "NONE": "NONE", + "UNKNOWN": "NONE", + }.get(value.strip().upper()) + + def set_evse_charge_limit_topic(self, node_id: str) -> str | None: + """The set topic for one charger's charge-current limit, or None. + + Every part of the topic is resolved at runtime. The device id comes from + matching `node_id` — the snapshot's harmonised key, which is the + charger's *serial* wherever it publishes one — back to the device the + tree actually carries, because the topic is addressed by device id and + those two are different strings on every panel that publishes a serial. + The node and property come from the charger's own `$description` through + `resolve_charge_limit`, so no spelling is baked in here. + + **None where the property is not declared settable**, which is the + refusal this control exists to make safe. Absence of `$settable` reads as + read-only (see `charge_limit`), so a charger that declares only its + commissioned ceiling gets no set topic at all rather than one aimed at a + property the panel will reject — or worse, accept. + """ + writable = self._writable_charge_limit(node_id) + if writable is None: + return None + device, surface, limit = writable + return self._set_topic(device.device_id, surface.node, limit.property_id) + + def evse_charge_limit_payload(self, node_id: str, amps: int) -> str | None: + """The payload to publish for `amps`, or None if it may not be published. + + Refuses above the commissioned ceiling. `charge-limit` 0.1 states it as a + MUST — `owner-limit` "MUST be `<= installer-max`" — and the ceiling is + derated hardware protection (breaker rating, J1772), so publishing past + it is the one write here with a physical consequence. The panel would be + entitled to clamp, reject, or fault; a consumer that clamped silently on + this side would report a limit the charger is not enforcing. + + Negative amps are refused for the same reason and no other: a + charge-only EVSE cannot be told to export by lowering a ceiling, so a + negative value is not a smaller limit but a malformed one. + + A charger that declares no ceiling is not second-guessed — the catalog + makes `installer-max` a SHOULD, and the value that bounds the write is + the one the panel published, not one this library invents. + """ + writable = self._writable_charge_limit(node_id) + if writable is None or amps < 0: + return None + device, surface, _limit = writable + ceiling = surface.ceiling + commissioned = None if ceiling is None else integer(device, surface.node, ceiling.property_id) + if commissioned is not None and amps > commissioned: + return None + return str(amps) + + def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]: + """Subscribe to per-property updates; returns an unregister callable.""" + self._property_callbacks.append(callback) + + def _unregister() -> None: + if callback in self._property_callbacks: + self._property_callbacks.remove(callback) + + return _unregister + + # -- internals --------------------------------------------------------- + + def _writable_charge_limit( + self, node_id: str + ) -> tuple[DiscoveredDevice, ChargeLimitSurface, ChargeLimitProperty] | None: + """The charger `node_id` names, its charge-limit surface, and the settable half. + + One resolution for both command methods, so the topic a write goes to + and the ceiling it is checked against can never come from different + chargers or different spellings. `None` means there is nothing to write: + no such charger, no charge-limit node on it, or a limit the charger does + not declare settable. + + The lookup goes through `harmonised_evse_keys`, the same function the + snapshot keys its EVSE map with, because `node_id` is a key out of that + map. Rebuilding the rule here is how a control ends up addressing the + wrong charger the day the harmonisation changes. + """ + for device, key in harmonised_evse_keys(TreeRoles(self._children()).evse).items(): + if key != node_id: + continue + surface = resolve_charge_limit(device) + if surface is None or surface.limit is None or not surface.limit.settable: + return None + return device, surface, surface.limit + return None + + def _set_topic(self, device_id: str, node: str, prop: str) -> str: + return f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/{device_id}/{node}/{prop}/set" + + def _require_root(self) -> DiscoveredDevice: + """The root, or a clear error if discovery has not finished. + + Checks readiness rather than existence: `start_discovery` pre-creates + the root entry so descendants have somewhere to attach, so the device + object exists from construction and proves nothing on its own. + """ + root = self._controller.get_root(self._serial_number) + if root is None or not self.is_ready(): + raise RuntimeError(f"Device tree for {self._serial_number!r} is not ready; build_snapshot called too early") + return root + + def _children(self) -> list[DiscoveredDevice]: + return list(self._controller.get_descendants(self._serial_number)) + + def _awaiting_descriptions(self, root: DiscoveredDevice) -> tuple[str, ...]: + """Devices the tree declares that have not described themselves yet. + + Walks declarations rather than discoveries, and at any depth: a child + may declare children of its own, and those count too. Logged when the + set changes, because the alternative diagnostic for a tree that never + completes is a bare 30-second connect timeout. + """ + described = {device.device_id: device for device in self._children() if device.description is not None} + awaiting = { + child_id + for device in (root, *described.values()) + for child_id in device.children_ids + if child_id not in described + } + pending = tuple(sorted(awaiting)) + if pending != self._awaiting: + self._awaiting = pending + if pending: + _LOGGER.debug("Waiting on %d declared devices: %s", len(pending), ", ".join(pending)) + return pending + + def _model_arrived(self, root: DiscoveredDevice) -> bool: + """Whether the panel has published the model it said it would.""" + if PROP_MODEL not in root.get_node_properties(NODE_INFO): + return True + return root.get_property(NODE_INFO, PROP_MODEL) is not None + + def _on_property_changed(self, device_id: str, node_id: str, property_id: str, value: str, _old: str | None) -> None: + """Fan a Controller property change out to registered consumers. + + Signature adapts the SDK's five arguments to the protocol's four: the + protocol has no place for the previous value, and consumers that need + one keep it themselves. + """ + for callback in list(self._property_callbacks): + callback(device_id, node_id, property_id, value) diff --git a/packages/schema-1/src/span_panel_api_schema_1/adoption.py b/packages/schema-1/src/span_panel_api_schema_1/adoption.py new file mode 100644 index 0000000..234a61d --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/adoption.py @@ -0,0 +1,176 @@ +"""Build ``AdoptedDevice`` records for tree devices this adapter models no fields for. + +The unit of adoption is a **device**, never a property. A new property on a +device this adapter already models is a curation task with a short turnaround, +and minting something for it automatically spends a consumer's entity identity +permanently on a shape a human would likely have chosen differently. A device +type nothing here models is the opposite case: no curation is coming for it, so +surfacing what it publishes is strictly better than the silence that ships today. + +The schema is explicitly vendor-extensible, so an unmodelled type is an expected +arrival rather than a hypothetical one. + +**Values, unlike :mod:`field_metadata`'s discovery rows.** Those rows exist to be +forwarded in consumer diagnostics, which leave the machine, so they carry +declarations only. These records exist to become entities on the machine that +built them, so they carry the reading. The two must not be conflated, and the +types are separate so that conflating them is a type error rather than a leak. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from span_panel_api.models import ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE, AdoptedDevice, AdoptedProperty +from span_panel_api_schema_1.const import ( + HOMIE_DOMAIN, + HOMIE_VERSION, + TYPE_BESS, + TYPE_CIRCUIT, + TYPE_EVSE, + TYPE_INVERTER, + TYPE_LUGS, + TYPE_MID, + TYPE_PANEL, + TYPE_PV, +) +from span_panel_api_schema_1.description import device_type, nodes, optional_str, properties + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + +MODELLED_TYPES: tuple[str, ...] = ( + TYPE_PANEL, + TYPE_CIRCUIT, + TYPE_LUGS, + TYPE_EVSE, + TYPE_BESS, + TYPE_PV, + TYPE_MID, + TYPE_INVERTER, +) +"""Every device type this adapter builds snapshot fields from. + +Stated once here and asserted against the snapshot builder by test, rather than +derived from it: `TreeRoles` sorts by a chain of comparisons that no expression +can read back, and a type silently dropping out of that chain while staying in +this tuple would make a device invisible to *both* paths -- unmodelled by the +builder and unadopted by this module. The test is what closes that. +""" + +PROP_VENDOR_NAME = "vendor-name" +PROP_MODEL = "model" +PROP_SERIAL_NUMBER = "serial-number" +PROP_FIRMWARE_VERSION = "firmware-version" +PROP_HARDWARE_VERSION = "hardware-version" + + +def is_modelled(declared: str) -> bool: + """Whether this adapter builds snapshot fields from a device of this type. + + Subtype-aware, because firmware may declare either a base type or a subtype + of it -- ``…device.lugs`` with a ``direction`` property, or + ``…device.lugs.upstream``. A subtype of something modelled is modelled: the + snapshot builder matches lugs by prefix for exactly this reason, and a + subtype arriving must not be adopted behind the builder's back. + """ + return any(declared == known or declared.startswith(f"{known}.") for known in MODELLED_TYPES) + + +def build_adopted_devices(children: list[DiscoveredDevice]) -> tuple[AdoptedDevice, ...]: + """Adopt every child whose declared type this adapter models nothing for. + + A device mid-discovery declares no type at all, which is a normal state + rather than an unmodelled device: it is skipped rather than adopted, and + adopted on a later snapshot once its description arrives. + + Extra instances of a modelled type are deliberately *not* adopted. A second + BESS is a multiplicity limitation of the snapshot model, not an unmodelled + device, and adopting it would stand a machine-named device card beside a + curated one describing the same class of hardware. + """ + adopted: list[AdoptedDevice] = [] + for device in children: + declared = device_type(device) + if not declared or is_modelled(declared): + continue + adopted.append(_adopt(device, declared)) + return tuple(adopted) + + +def _adopt(device: DiscoveredDevice, declared: str) -> AdoptedDevice: + """One device's identity, from ``info``, and its readings, from everything else.""" + description: dict[str, object] = device.description or {} + declared_nodes = nodes(description) + identity = properties(declared_nodes.get(ADOPTION_IDENTITY_NODE, {})) + + def card(property_id: str) -> str | None: + """An ``info`` property's value, for the device card rather than an entity.""" + if property_id not in identity: + return None + return optional_str(device.get_property(ADOPTION_IDENTITY_NODE, property_id)) + + parent = optional_str(description.get("parent")) + root = optional_str(description.get("root")) + return AdoptedDevice( + device_id=device.device_id, + device_type=declared, + name=optional_str(description.get("name")), + parent=parent, + # A peer proxies this device when its declared parent is something other + # than the tree root. Compared here because `root` is in hand here and is + # not carried onto the record: a consumer holding one device could not + # otherwise tell the enclosure's id from a sibling's, ids being opaque. + proxied=parent is not None and root is not None and parent != root, + vendor_name=card(PROP_VENDOR_NAME), + model=card(PROP_MODEL), + serial_number=card(PROP_SERIAL_NUMBER), + software_version=card(PROP_FIRMWARE_VERSION), + hardware_version=card(PROP_HARDWARE_VERSION), + properties=_readings(device, declared_nodes), + ) + + +def _readings(device: DiscoveredDevice, declared_nodes: dict[str, dict[str, object]]) -> tuple[AdoptedProperty, ...]: + """Every declared property outside the identity and topology nodes. + + Those two are excluded by *node*, which is what the eBus vocabulary defines, + rather than by property name. The catalogs carry no marker for "this string + is a device reference", so a name list is the only alternative -- and a name + list goes stale silently, as `ebus-sdk`'s own ``topology.py`` demonstrates by + covering two device-reference properties and omitting a third that lives on + a different capability. + """ + readings: list[AdoptedProperty] = [] + for node_id, node in declared_nodes.items(): + if node_id in (ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE): + continue + for property_id, definition in properties(node).items(): + raw = device.get_property(node_id, property_id) + settable = bool(definition.get("settable", False)) + readings.append( + AdoptedProperty( + node_id=node_id, + property_id=property_id, + datatype=str(definition.get("datatype") or "string"), + unit=optional_str(definition.get("unit")), + format=optional_str(definition.get("format")), + settable=settable, + value=None if raw is None else str(raw), + set_topic=_set_topic(device.device_id, node_id, property_id) if settable else None, + ) + ) + return tuple(readings) + + +def _set_topic(device_id: str, node_id: str, property_id: str) -> str: + """The Homie topic a write to one property is published to. + + The same three-part construction the adapter uses for every curated control, + repeated here rather than reached for, because that is the point: this + function is only ever called on a device `is_modelled` rejected and only for + a property the device declares settable, so no topic it can produce names + anything a curated setter owns. Sharing the adapter's builder would put the + whole address space one argument away. + """ + return f"{HOMIE_DOMAIN}/{HOMIE_VERSION}/{device_id}/{node_id}/{property_id}/set" diff --git a/packages/schema-1/src/span_panel_api_schema_1/catalog.py b/packages/schema-1/src/span_panel_api_schema_1/catalog.py new file mode 100644 index 0000000..f358940 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/catalog.py @@ -0,0 +1,211 @@ +"""Compare what a producer declares against what a capability catalog defines. + +The registry used as a validator. Every property a device's ``$description`` +declares carries a ``unit`` and a ``datatype``; the eBus capability catalog for +that node declares the same two fields for the same property. Agreement is +silence. Disagreement is a finding, surfaced for a human — never resolved +silently in either direction, because either side can be the wrong one. The +last mislabel this catches by machine (`meter/active-power` in ``kW``, values in +watts) was found because a person noticed a sibling device declaring the same +quantity differently. + +**This module compares; it never sources.** Nothing here may become the place a +unit is read from. `field_metadata` takes units from each device's own +declaration precisely because the catalog is the superset across all hardware +and carries abstract units, and `test_an_abstract_unit_is_never_taken_from_the_catalog` +holds that line. The rules below exist to *judge* a declaration, which is a +different job from supplying one. + +**Catalog definitions are passed in, never read from disk.** The vendored +catalogs live under ``packages/schema-1/spec/``, outside this distribution's +wheel, so a module that read them by path would work in the repository and fail +everywhere else. Taking them as an argument also lets a caller judge a +declaration against a catalog it fetched, which is what a live-panel diagnostic +would do. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from span_panel_api_schema_1.description import optional_str + +CAPABILITY_PREFIX = "energy.ebus.capability." +"""The namespace a node's declared ``$type`` uses, and a catalog's ``capability``.""" + + +def capability_of(declared_type: str | None) -> str | None: + """The bare capability name behind a node's declared ``$type``. + + A node id is conventionally the capability name and every capture we hold + agrees, but the *type* is what the specification makes authoritative — a + publisher may name the node anything. Reading the id instead would work + until the day one did. + """ + if declared_type is None or not declared_type.startswith(CAPABILITY_PREFIX): + return None + return declared_type[len(CAPABILITY_PREFIX) :] or None + + +class Divergent(Enum): + """What a finding is about. + + ``UNCATALOGUED`` is terminal and exclusive: a property no catalog defines + has nothing to compare a unit or a datatype against, so it is reported once + as absent rather than three times as every field disagreeing with nothing. + The EVSE's ``config`` node is the case that makes the distinction matter — + it is not an eBus capability at all, and reporting its two properties as + unit mismatches would be a claim about a catalog that does not exist. + """ + + UNIT = "unit" + DATATYPE = "datatype" + UNCATALOGUED = "uncatalogued" + + +@dataclass(frozen=True) +class Declaration: + """The two fields of a property declaration this check compares. + + Both optional, on both sides: a catalog property may carry no unit + (``power-factor``, every enum), and so may a declaration. + """ + + unit: str | None + datatype: str | None + + +def declaration(raw: dict[str, object]) -> Declaration: + """Narrow one property definition — from a ``$description`` or a catalog. + + The same reader for both, because the two documents declare a property the + same way. That is the whole reason this comparison is possible. + """ + return Declaration(unit=optional_str(raw.get("unit")), datatype=optional_str(raw.get("datatype"))) + + +@dataclass(frozen=True) +class Divergence: + """One disagreement between a producer and a catalog. + + Identity is the whole tuple, deliberately. A divergence whose values change + — ``kW`` becoming ``mW`` — is a different divergence, and reads as the old + one disappearing and a new one arriving rather than as an entry that + silently goes on covering something nobody looked at. + + Producer-independent: the same mislabel seen in three captures of one panel + is one finding, not three. Which producers show it is recorded beside the + acknowledgement instead, so it can be checked without multiplying entries. + """ + + capability: str + property_id: str + kind: Divergent + declared: str | None + catalogued: str | None + + def __str__(self) -> str: + """The line a human reads in a failure, and the line they sort by. + + Sorting reports on this rather than on the tuple, because `Divergent` is + an Enum and not orderable, and because the text is what a reader is + scanning — a report ordered by a key that is not visible in it reads as + unordered. + """ + if self.kind is Divergent.UNCATALOGUED: + return f"{self.capability}/{self.property_id}: no catalog defines it" + return ( + f"{self.capability}/{self.property_id}: declared {self.kind.value} " + f"{self.declared!r}, catalog says {self.catalogued!r}" + ) + + +UNIT_FAMILIES: dict[str, frozenset[str]] = { + "energy": frozenset({"Wh", "kWh", "MWh", "J", "kJ", "MJ"}), +} +"""Catalog unit tokens that name a dimension rather than a unit. + +``soc/soe``, ``soc/total-energy-storage``, ``soc/loadup-headroom`` and +``info/nameplate-capacity`` are all ``unit: "energy"``, and the catalog prose is +explicit about why: the quantity is "reported in the device's native energy unit +(a BESS in kWh electrical, a water heater in Wh thermal) via `$unit`". The token +is an instruction to substitute, not a unit to match — so a publisher declaring +``kWh`` there is *conforming*, and a string compare against it would report the +one thing the specification asks for as the defect. + +Membership is enumerated rather than derived from an SI-prefix rule, for the +reason the whole design doc argues: a rule gets the case nobody thought about +wrong, quietly. A device declaring an energy unit outside this set is a finding +a human should see, which is what an empty match produces. + +Echoing the token itself (``unit: "energy"`` on the wire) is *not* membership, +and that is the second thing this catches: a publisher that copied the +placeholder out of the catalog instead of substituting its own unit. +""" + +CATALOGUED_CONCRETE_UNITS: frozenset[str] = frozenset( + {"%", "A", "Hz", "V", "VA", "VAh", "W", "Wh", "kA", "min", "var", "varh"} +) +"""Every non-abstract unit token the vendored catalogs currently use. + +Pinned so that a token arriving upstream has to be classified by a human before +it is compared: `unclassified_units` fails on anything that is in neither this +set nor `UNIT_FAMILIES`. Without it, a new abstract family — ``power``, say — +would be string-compared against every concrete unit a publisher substitutes and +report the whole family as broken, which is exactly the false finding this +module's family rule exists to prevent. + +Not a list of legal *wire* units. A publisher may declare any unit it likes; +this is only the vocabulary of the reference side. +""" + + +def unclassified_units(catalogued: frozenset[str]) -> frozenset[str]: + """Catalog unit tokens this module has no classification for. + + The guard on the guard: the family rule is only sound while every token it + might meet is known to be either concrete or a dimension. + """ + return catalogued - CATALOGUED_CONCRETE_UNITS - frozenset(UNIT_FAMILIES) + + +def unit_agrees(declared: str | None, catalogued: str | None) -> bool: + """Does a declared unit satisfy the catalogued one? + + Three rules, in the order they apply: + + 1. A catalog property with no unit expects a declaration with none. A unit + appearing where the reference carries none is as much a disagreement as + the wrong unit — it says the two sides disagree about whether the + quantity is dimensioned at all. + 2. An abstract family is satisfied by any member of the family, and by + nothing else — including the family token itself. + 3. Everything else is an exact match. + """ + if catalogued is None: + return declared is None + members = UNIT_FAMILIES.get(catalogued) + if members is None: + return declared == catalogued + return declared is not None and declared in members + + +def compare(capability: str, property_id: str, declared: Declaration, catalogued: Declaration | None) -> list[Divergence]: + """Judge one declared property against its catalog definition. + + ``catalogued`` is None when no catalog defines the property — either the + capability has no catalog at all (``config``) or the catalog does not carry + this name (``status/wifi-ssid``). Both produce a single ``UNCATALOGUED`` + finding and stop: there is no reference to compare against, and saying so + once is the honest report. + """ + if catalogued is None: + return [Divergence(capability, property_id, Divergent.UNCATALOGUED, None, None)] + + found: list[Divergence] = [] + if not unit_agrees(declared.unit, catalogued.unit): + found.append(Divergence(capability, property_id, Divergent.UNIT, declared.unit, catalogued.unit)) + if declared.datatype != catalogued.datatype: + found.append(Divergence(capability, property_id, Divergent.DATATYPE, declared.datatype, catalogued.datatype)) + return found diff --git a/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py b/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py new file mode 100644 index 0000000..31d7e4d --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py @@ -0,0 +1,170 @@ +"""Where an EV charger publishes its charge-current ceiling — resolved, never assumed. + +This is the only *settable* surface the v1.0 catch-up reads, and it is the one +whose name we cannot look up. Two spellings exist and neither is disprovable +from here: + +- The reference tree, and the simulator it came from, declare node ``config`` + with ``max-charge-current`` (the commissioned ceiling) and + ``user-max-charge-current`` (``settable: true``). +- The eBus catalog has **no** ``config`` capability. It puts the same surface on + ``charge-limit`` 0.1 — ``installer-max`` (the immutable ceiling) and + ``owner-limit`` (``settable``, and specified as MUST be ``<= installer-max``). + +No capture can settle it: the panel we expect access to carries no SPAN Drive, +so no EVSE will describe itself to us. Waiting is not a plan that terminates. + +It does not need to. ``devices/distribution-enclosure.md`` states the rule — +"the authoritative property set for any capability node is always declared in +that device's ``$description``" — so a correct reader names no node in a +constant. It asks the charger which of the spellings it declares, reads that +one, and builds the set topic from the node and property it found. That is right +whichever spelling firmware ships, and it is the same rule +:mod:`field_metadata` already follows for units and datatypes. + +The spellings are ordered catalog-first, so a charger that grows the specified +node is read through the specified node even while it still declares the older +one. Adding a third spelling is one tuple entry; nothing else in the library +mentions either name. + +**Settability is read, never assumed.** The two properties of a spelling differ +by exactly one Homie attribute — the ceiling declares no ``settable``, the limit +declares ``settable: true`` — so a reader that treated an absent attribute as +"settable", the way :func:`circuits._priority_is_settable` correctly does for +``load-shed/priority``, would offer to write the installer's ceiling. The +defaults are opposite because the questions are: there, locking is the exception +a panel announces; here, writability is. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from span_panel_api_schema_1.const import ATTR_SETTABLE +from span_panel_api_schema_1.description import nodes, optional_str, properties + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + +# `energy.ebus.capability.charge-limit` 0.1, the catalogued spelling. +NODE_CHARGE_LIMIT = "charge-limit" +PROP_INSTALLER_MAX = "installer-max" +PROP_OWNER_LIMIT = "owner-limit" + +# The spelling the reference tree carries. No catalog defines a `config` +# capability, so this is a SPAN extension and is declared as one in the +# conformance suite's `_SPAN_EXTENSIONS`. +NODE_CONFIG = "config" +PROP_MAX_CHARGE_CURRENT = "max-charge-current" +PROP_USER_MAX_CHARGE_CURRENT = "user-max-charge-current" + + +@dataclass(frozen=True, slots=True) +class ChargeLimitSpelling: + """One node/property naming of the charge-current ceiling surface.""" + + node: str + ceiling: str + limit: str + + +SPELLINGS: tuple[ChargeLimitSpelling, ...] = ( + ChargeLimitSpelling(node=NODE_CHARGE_LIMIT, ceiling=PROP_INSTALLER_MAX, limit=PROP_OWNER_LIMIT), + ChargeLimitSpelling(node=NODE_CONFIG, ceiling=PROP_MAX_CHARGE_CURRENT, limit=PROP_USER_MAX_CHARGE_CURRENT), +) +"""Every naming this adapter recognises, most-specified first. + +Public because it *is* the adapter's read set for this surface, and the +conformance suite derives that set from here rather than from a second list — +the same reason `_read_pairs` walks the source instead of restating the +mappings. +""" + + +@dataclass(frozen=True, slots=True) +class ChargeLimitProperty: + """One declared property of the resolved surface. + + Carries the declaration's own unit and datatype so a caller never has to go + back to the ``$description`` for them: the value, its metadata and its set + topic are then all derived from one resolution and cannot disagree about + which property they describe. `_lugs_metadata` splits for the same reason. + """ + + property_id: str + unit: str | None + datatype: str + settable: bool + + +@dataclass(frozen=True, slots=True) +class ChargeLimitSurface: + """The charge-limit node one charger declares, and what is on it. + + Both members are optional because the catalog makes both optional: the + ceiling is SHOULD and the limit is MAY. A charger may publish a ceiling it + does not let anyone lower, and the reverse is legal too. Callers ask for the + half they need rather than being handed a surface that claims both exist. + """ + + node: str + ceiling: ChargeLimitProperty | None + limit: ChargeLimitProperty | None + + +def resolve_charge_limit(device: DiscoveredDevice | None) -> ChargeLimitSurface | None: + """The charge-limit surface this charger declares, or None if it declares none. + + None is the honest answer for a charger with no adjustable ceiling — + ``charge-limit.md``'s absence semantics say exactly that: "absence of the + ``charge-limit`` node means the EVSE has no adjustable charge-current + ceiling (it charges at a fixed rate)". + """ + if device is None: + return None + declared = nodes(device.description or {}) + for spelling in SPELLINGS: + node = declared.get(spelling.node) + if node is None: + continue + declarations = properties(node) + # A declared node carrying neither property names nothing we can read, + # and falling through to the next spelling is what lets a charger + # declare an unrelated `config` node without hiding a `charge-limit` one. + if spelling.ceiling not in declarations and spelling.limit not in declarations: + continue + return ChargeLimitSurface( + node=spelling.node, + ceiling=_property(spelling.ceiling, declarations.get(spelling.ceiling)), + limit=_property(spelling.limit, declarations.get(spelling.limit)), + ) + return None + + +def _property(property_id: str, definition: dict[str, object] | None) -> ChargeLimitProperty | None: + if definition is None: + return None + return ChargeLimitProperty( + property_id=property_id, + unit=optional_str(definition.get("unit")), + datatype=str(definition.get("datatype") or "string"), + settable=_declared_settable(definition), + ) + + +def _declared_settable(definition: dict[str, object]) -> bool: + """Whether the declaration says this property may be written. + + Absent means **not** settable. See the module docstring: the ceiling and the + limit differ by this attribute alone, so a permissive default would make the + installer's commissioned maximum look writable. + + A string ``"true"`` counts, because Homie attributes travel as text and a + publisher that serialises the description by hand may not re-type the + booleans. + """ + settable = definition.get(ATTR_SETTABLE) + if isinstance(settable, bool): + return settable + return str(settable).strip().lower() == "true" diff --git a/packages/schema-1/src/span_panel_api_schema_1/circuits.py b/packages/schema-1/src/span_panel_api_schema_1/circuits.py new file mode 100644 index 0000000..b6ec879 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/circuits.py @@ -0,0 +1,216 @@ +"""Map a v1.0 circuit device onto ``SpanCircuitSnapshot``. + +The snapshot's field names come from the v1 REST API and are preserved so the +integration's entities do not move. Three of them no longer have a property to +read, because v1.0 consolidated four flat mechanisms into two. Their +derivations are defined by the migration guide, not invented here: + +====================== =========================================================== +Flat property v1.0 source +====================== =========================================================== +``always-on`` ``switch/relay-controllable``, inverted +``never-backup`` ``$settable`` on ``load-shed/priority``, inverted +``sheddable`` computed: ``priority != NEVER and relay-controllable`` +====================== =========================================================== + +Sign and direction are unchanged from the flat schema, and both are the reverse +of what the property names suggest. Values are in the enclosure's reference +frame: a normal load reads **negative** ``active-power`` and accumulates +``exported-energy`` (the panel exported it *to* the circuit). The snapshot +reports consumption as positive, so power is negated and the two energy +accumulators are swapped. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from span_panel_api.models import SpanCircuitSnapshot +from span_panel_api_schema_1.const import ( + ATTR_SETTABLE, + NODE_BREAKER, + NODE_INFO, + NODE_LOAD_SHED, + NODE_METER, + NODE_PCS, + NODE_SWITCH, + PRIORITY_NEVER, + PROP_ACTIVE_POWER, + PROP_CURRENT, + PROP_EXPORTED_ENERGY, + PROP_IMPORTED_ENERGY, + PROP_MANAGED, + PROP_NAME, + PROP_POLES, + PROP_PRIORITY, + PROP_RATING, + PROP_RELAY, + PROP_RELAY_CONTROLLABLE, + PROP_RELAY_REQUESTER, + PROP_SPACES, + UNKNOWN, +) + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + + +def _text(device: DiscoveredDevice, node: str, prop: str, default: str = "") -> str: + value = device.get_property(node, prop) + return default if value is None else str(value) + + +def _number(device: DiscoveredDevice, node: str, prop: str) -> float | None: + """Read a numeric property, or None when it is absent or unparseable. + + Unparseable is treated as absent rather than as an error: a single + malformed value must not take down a whole snapshot, and the field it + feeds is optional. + """ + raw = device.get_property(node, prop) + if raw is None or raw == "": + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def _flag(device: DiscoveredDevice, node: str, prop: str, *, default: bool) -> bool: + """Read a Homie boolean. Absent means `default`, which is not always False. + + `relay-controllable` absent has to mean *controllable*, because the + property exists to mark the exception (an always-on circuit). Defaulting it + to False would silently make every circuit uncontrollable on a panel that + omits it. + """ + raw = device.get_property(node, prop) + if raw is None or raw == "": + return default + return str(raw).strip().lower() == "true" + + +def _optional_flag(device: DiscoveredDevice, node: str, prop: str) -> bool | None: + """A boolean that distinguishes "published false" from "not published". + + `_flag` above collapses the two onto a caller-chosen default, which is right + for the relay properties where absence has a defined meaning. It is wrong + for `pcs/managed`, which the capability marks `MAY`: a circuit that says + nothing about PCS participation has not said it is unmanaged, and reporting + `False` would put that claim on a dashboard. + """ + raw = device.get_property(node, prop) + if raw is None or raw == "": + return None + return str(raw).strip().lower() == "true" + + +def _optional_integer(device: DiscoveredDevice, node: str, prop: str) -> int | None: + """An `integer` property, or `None` when it is absent or unparseable. + + Parsed through `_number` for the reason `panel.integer` gives: a publisher + sending `1.0` for an integer property has made a formatting choice, not + withheld a reading. + """ + raw = _number(device, node, prop) + return None if raw is None else int(raw) + + +def _tabs(device: DiscoveredDevice) -> list[int]: + """Breaker spaces from ``info/spaces``. + + v1.0 publishes the occupied spaces literally (``"36,38"``), where the flat + schema published one space plus a `dipole` flag and left the consumer to + infer the second as ``space + 2``. Reading the list means a 3-pole breaker + reports three tabs instead of being silently truncated to two. + """ + raw = _text(device, NODE_INFO, PROP_SPACES) + if not raw: + return [] + tabs: list[int] = [] + for part in raw.split(","): + part = part.strip() + if not part: + continue + try: + tabs.append(int(part)) + except ValueError: + continue + return tabs + + +def _priority_is_settable(device: DiscoveredDevice) -> bool: + """Whether ``load-shed/priority`` is user-settable on this circuit. + + This is the successor to the flat ``never-backup`` boolean, and it is read + from the description rather than from a value topic — v1.0 expresses + never-backup as *mutability*, so the signal is the Homie ``$settable`` + attribute on the property definition. + + Absent means settable: locking is the exception a panel announces, so + treating an unannounced circuit as locked would mark every circuit + never-backup on a panel that does not publish the attribute. + """ + definition = device.get_node_properties(NODE_LOAD_SHED).get(PROP_PRIORITY) + if not isinstance(definition, dict): + return True + settable = definition.get(ATTR_SETTABLE) + if settable is None: + return True + if isinstance(settable, bool): + return settable + return str(settable).strip().lower() != "false" + + +def build_circuit( + device: DiscoveredDevice, device_type: str = "circuit", relative_position: str = "" +) -> SpanCircuitSnapshot: + """Build one circuit snapshot from its v1.0 device.""" + raw_power = _number(device, NODE_METER, PROP_ACTIVE_POWER) or 0.0 + # Negate so positive means consumption. The guard keeps -0.0 out of the + # snapshot, where it would compare equal to 0.0 but format as "-0.0". + instant_power_w = 0.0 if raw_power == 0.0 else -raw_power + + relay_controllable = _flag(device, NODE_SWITCH, PROP_RELAY_CONTROLLABLE, default=True) + priority = _text(device, NODE_LOAD_SHED, PROP_PRIORITY, UNKNOWN) + priority_settable = _priority_is_settable(device) + + return SpanCircuitSnapshot( + circuit_id=device.device_id, + name=_text(device, NODE_INFO, PROP_NAME), + relay_state=_text(device, NODE_SWITCH, PROP_RELAY, UNKNOWN), + instant_power_w=instant_power_w, + # The panel *imported* this energy from the circuit, so the circuit + # produced it. Named from the panel's perspective, reported from the + # circuit's. + produced_energy_wh=_number(device, NODE_METER, PROP_IMPORTED_ENERGY) or 0.0, + consumed_energy_wh=_number(device, NODE_METER, PROP_EXPORTED_ENERGY) or 0.0, + tabs=_tabs(device), + priority=priority, + # `always-on` is `not relay-controllable`, and the flat schema derived + # user-controllability from `always-on` — so this is the same answer by + # a shorter route. + is_user_controllable=relay_controllable, + is_sheddable=priority != PRIORITY_NEVER and relay_controllable, + is_never_backup=not priority_settable, + device_type=device_type, + relative_position=relative_position, + is_240v=(_number(device, NODE_BREAKER, PROP_POLES) or 1) >= 2, + current_a=_number(device, NODE_METER, PROP_CURRENT), + breaker_rating_a=_number(device, NODE_BREAKER, PROP_RATING), + always_on=not relay_controllable, + relay_requester=_text(device, NODE_SWITCH, PROP_RELAY_REQUESTER, UNKNOWN), + relay_state_target=device.get_property_target(NODE_SWITCH, PROP_RELAY), + priority_target=device.get_property_target(NODE_LOAD_SHED, PROP_PRIORITY), + # The circuit half of `energy.ebus.capability.pcs`: participation only. + # The arbitration that decides the enforced limit is the enclosure's, + # and lands on `SpanPanelSnapshot.pcs`. + # + # `pcs/priority` is *not* `load-shed/priority` above, and the two share + # neither a value space nor a purpose: this one is an integer shed + # ordering under an import limit, that one is the backup tier + # (`MUST_HAVE` / `NON_ESSENTIAL` / …). A circuit may participate in one + # policy, both, or neither, so they are read separately and named apart. + pcs_managed=_optional_flag(device, NODE_PCS, PROP_MANAGED), + pcs_priority=_optional_integer(device, NODE_PCS, PROP_PRIORITY), + ) diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py new file mode 100644 index 0000000..4a135aa --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -0,0 +1,204 @@ +"""Wire vocabulary for the parent/child schema (data-model-version 1.x). + +Every name here is a v1.0 device class, capability node, or property id. Nothing +in this module is shared with the flat schema: v1.0 moved each property from a +node on one device to a capability node on its own device, so even names that +look unchanged are addressed differently. +""" + +from __future__ import annotations + +# -- Device classes --------------------------------------------------------- + +DEVICE_TYPE_PREFIX = "energy.ebus.device." +"""The common stem every eBus device type in this vocabulary carries. + +Stripped when a type has to be *named* rather than matched — the discovery +namespace's ``{device type}/{node}/{property}`` rows, which a maintainer reads +beside a capability catalog and a gap inventory that both spell the type short. +Matching still uses the full strings below, because a subtype check has to see +the whole type. +""" + +TYPE_PANEL = "energy.ebus.device.distribution-enclosure" +TYPE_CIRCUIT = "energy.ebus.device.circuit" +TYPE_BESS = "energy.ebus.device.bess" +TYPE_PV = "energy.ebus.device.pv" +TYPE_EVSE = "energy.ebus.device.evse" +TYPE_MID = "energy.ebus.device.mid" +# BESS model 0.14 decomposes a BESS into child roles; grid-forming belongs to the +# inverter, so "can this panel island" becomes "can any inverter here form a grid". +TYPE_INVERTER = "energy.ebus.device.inverter" +TYPE_LUGS = "energy.ebus.device.lugs" + +# -- Capability nodes ------------------------------------------------------- + +NODE_BREAKER = "breaker" +NODE_CONNECTION = "connection" + +# The `connection` node's four property names. Here rather than in `devices`, +# which is where they started, because `panel` reads one of them and `devices` +# imports `panel` -- so the constant has to live below both of them. +PROP_FEEDS_DEVICE_ID = "feeds-device-id" +PROP_FEEDS_DEVICE_STATUS = "feeds-device-status" +PROP_FED_BY_DEVICE_ID = "fed-by-device-id" +PROP_FED_BY_DEVICE_STATUS = "fed-by-device-status" +NODE_DOOR = "door" +NODE_GRID = "grid" +NODE_INFO = "info" +NODE_LOAD_SHED = "load-shed" +NODE_METER = "meter" +NODE_PCS = "pcs" +NODE_POWER_FLOWS = "power-flows" +NODE_SHED = "shed" +# `energy.ebus.capability.shed-forecast` 0.1 -- the enclosure's backup-planning +# estimates. A separate node from `shed`, which carries the policy and the +# asserted islanding state: `shed` says what the panel will do, `shed-forecast` +# says when. Present only where the enclosure publishes it, so every consumer of +# these fields gates on the node rather than defaulting. +NODE_SHED_FORECAST = "shed-forecast" +NODE_GRID_FORMING = "grid-forming" +NODE_SOC = "soc" +NODE_STATUS = "status" +NODE_SWITCH = "switch" + +# -- Properties ------------------------------------------------------------- + +PROP_ACTIVE_POWER = "active-power" +PROP_CURRENT = "current" +PROP_EXPORTED_ENERGY = "exported-energy" +PROP_IMPORTED_ENERGY = "imported-energy" +PROP_NAME = "name" +PROP_POLES = "poles" +PROP_PRIORITY = "priority" +PROP_RATING = "rating" +PROP_RELAY = "relay" +PROP_RELAY_CONTROLLABLE = "relay-controllable" +PROP_RELAY_REQUESTER = "relay-requester" +PROP_SPACES = "spaces" + +# shed node +PROP_ASSERTED_ISLANDING_STATE = "asserted-islanding-state" +# The shed algorithm and its parameters, as a `json` property whose Homie +# `$format` is the JSON Schema the document conforms to. The schema is versioned +# in its own `$id` (`soc-priority.v1`), which is what lets a publisher ship a +# different algorithm without breaking a reader that pins this one: the document +# names the algorithm it used, so an unrecognised one degrades to the raw string +# rather than to a misread threshold. +PROP_POLICY = "policy" +SHED_POLICY_SOC_PRIORITY_V1 = "soc-priority.v1" + +# pcs node. `energy.ebus.capability.pcs` 0.3 publishes two disjoint property +# sets under one node type: the enclosure runs the arbitration and publishes the +# *system* surface, while a circuit publishes only its *participation*. Same +# capability, different publishers — the same split `meter` makes between the +# panel, a circuit and a lugs device. +PROP_ENABLED = "enabled" +PROP_ACTIVE = "active" +PROP_IMPORT_LIMIT = "import-limit" +PROP_BINDING_CONSTRAINT = "binding-constraint" +PROP_MANAGED = "managed" + +# The amps-native constraint classes the enclosure reconciles, in the catalog's +# order. Each publishes the same `{-import-limit, -enablement, -active}` +# triplet, and the catalog is explicit that "the number and naming of sources is +# not fixed by this spec": a vendor may publish further sources using the same +# shape. A tuple of prefixes rather than twelve constants is what lets the +# reader below be written once per triplet member instead of once per source. +PCS_LIMIT_SOURCES: tuple[str, ...] = ("feed", "operator", "off-grid", "requested") +PCS_LIMIT_SUFFIX = "-import-limit" +PCS_ENABLEMENT_SUFFIX = "-enablement" +PCS_ACTIVE_SUFFIX = "-active" + +# shed-forecast node. All four times are `integer` minutes; `confidence` is the +# enum LOW/MEDIUM/HIGH qualifying them. The `full-charge-*` pair answers the +# hypothetical "if the BESS were full", so it is a capability figure rather than +# a live countdown and moves only when the installation does. +PROP_TIME_TO_PRIORITY_SHED = "time-to-priority-shed" +PROP_TOTAL_TIME_REMAINING = "total-time-remaining" +PROP_FULL_CHARGE_TIME_TO_PRIORITY_SHED = "full-charge-time-to-priority-shed" +PROP_FULL_CHARGE_TOTAL_TIME_REMAINING = "full-charge-total-time-remaining" +PROP_CONFIDENCE = "confidence" +# `energy.ebus.capability.grid-forming` 0.1: "Static hardware capability: does this +# inverter support grid-forming operation at all?" -- the same *kind* of statement +# flat's `grid-islandable` made, and a MUST on the capability. +PROP_CAPABLE = "capable" +PROP_GRID_FORMING_ENTITY = "grid-forming-entity" + +# Panel-level +PROP_DATA_MODEL_VERSION = "data-model-version" +PROP_FIRMWARE_VERSION = "firmware-version" +PROP_SERIAL_NUMBER = "serial-number" +PROP_STATE = "state" +PROP_VOLTAGE_A = "voltage-a" +PROP_VOLTAGE_B = "voltage-b" + +# status node +PROP_CLOUD_CONNECTION = "cloud-connection" +PROP_ETHERNET = "ethernet" +PROP_WIFI = "wifi" +# The network the panel is joined to, not whether the radio is up -- `wifi` is +# that. Flat published the pair as `core/wifi` and `core/wifi-ssid` and the +# integration surfaces the SSID as an attribute today, so a v1.0 panel that did +# not read this one lost an attribute on upgrade. +PROP_WIFI_SSID = "wifi-ssid" +# `energy.ebus.capability.status` 0.1: the publisher's view of its own link to +# the device it represents (proxy) or to its backhaul (native). Enum +# OK/DEGRADED/LOST/UNKNOWN. Orthogonal to whether the eBus publisher is reporting +# to *its* consumers, and orthogonal to the enclosure's `connection/*` view of +# the same device -- see `devices.py`'s module docstring. +PROP_COMMUNICATION_STATE = "communication-state" + +# -- Values ----------------------------------------------------------------- + +PRIORITY_NEVER = "NEVER" +UNKNOWN = "UNKNOWN" +CLOUD_CONNECTED = "CONNECTED" + +PROP_MODEL = "model" +PROP_HARDWARE_VERSION = "hardware-version" +PROP_VENDOR_NAME = "vendor-name" + +# Topic root. Children are peers of the panel in the topic tree rather than +# nodes beneath it, so a subscription covering the tree spans the domain. +HOMIE_DOMAIN = "ebus" +HOMIE_VERSION = "5" + +STATE_READY = "ready" + +# Breaker spaces per panel model. +# +# This is the only source of the panel's total size in v1.0. The flat schema +# carried it in the Homie schema's `space` format (`"1:32:1"`, max = 32); its +# successor `info/spaces` is a plain string with no format, and the panel device +# publishes no size property. What v1.0 does publish is `info/model`, a **closed +# enum** — the topic reference, the migration guide, and the panel's own Homie +# `$format` all list exactly these five values — so this is a lookup over a +# defined value set, not an inference from a vendor string. +# +# The *sizes* are ours: neither the SDK nor the published schema states how many +# spaces a model has, only which model names are valid. `panel_model_drift` +# exists because of that split — the panel can tell us a model we have no size +# for, and we would rather say so than guess. +# +# Total size matters beyond a display field: unoccupied positions are only +# knowable as `total - occupied`, and synthesising them is what gives the +# integration its unmapped-circuit sensors. +PANEL_SIZE_BY_MODEL: dict[str, int] = { + "MAIN_16": 16, + "MLO_24": 24, + "MAIN_32": 32, + "MAIN_40": 40, + "MLO_48": 48, +} + +# Prefix for synthesised unoccupied-position entries. Must match the flat +# adapter's, because the integration keys entities off it and a rename would +# strand every existing unmapped-tab entity. +UNMAPPED_TAB_PREFIX = "unmapped_tab_" + +# The Homie attribute that carries what the flat schema published as the +# `never-backup` boolean. v1.0 retires the property and expresses it as +# mutability: a circuit commissioned never-backup has its priority locked, so +# the panel publishes `$settable = false` on `load-shed/priority`. +ATTR_SETTABLE = "settable" diff --git a/packages/schema-1/src/span_panel_api_schema_1/description.py b/packages/schema-1/src/span_panel_api_schema_1/description.py new file mode 100644 index 0000000..ac5e01d --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/description.py @@ -0,0 +1,66 @@ +"""Narrowing readers for a Homie ``$description`` document. + +Every level of a description is optional and the SDK hands it back as an +untyped mapping, so each reader has to narrow before it can index. Doing that +once here keeps the narrowing identical everywhere and keeps ``Any`` out of the +modules that read declarations — :mod:`field_metadata` for units and datatypes, +:mod:`charge_limit` for which spelling of a node a charger declares. + +These read the *declaration*, never a value. Property values come through +:mod:`panel`'s ``text`` / ``number`` / ``integer`` readers. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + + +def device_type(device: DiscoveredDevice) -> str: + """The device's declared type from its description, or '' before it arrives. + + A device exists in the tree from the moment its parent names it as a child, + so an empty type is the normal mid-discovery state rather than an error. + """ + description: dict[str, object] = device.description or {} + declared = description.get("type") + return str(declared) if declared else "" + + +def nodes(description: dict[str, object]) -> dict[str, dict[str, object]]: + """The capability nodes a description declares, by node id.""" + declared = description.get("nodes") + if not isinstance(declared, dict): + return {} + return {str(key): value for key, value in declared.items() if isinstance(value, dict)} + + +def properties(node: dict[str, object]) -> dict[str, dict[str, object]]: + """The properties one node declares, by property id.""" + declared = node.get("properties") + if not isinstance(declared, dict): + return {} + return {str(key): value for key, value in declared.items() if isinstance(value, dict)} + + +def node_properties(device: DiscoveredDevice | None, node_id: str) -> dict[str, dict[str, object]]: + """The properties one device declares on one node, or an empty mapping. + + The device-level entry point, for a caller that has a device rather than a + parsed description. A device mid-discovery has no description at all, which + is the normal state rather than an error, so it answers empty like a device + that declares the node with nothing on it. + """ + if device is None: + return {} + return properties(nodes(device.description or {}).get(node_id, {})) + + +def optional_str(value: object) -> str | None: + """A declaration's string attribute, with empty and absent both meaning None.""" + if value is None: + return None + text = str(value) + return text or None diff --git a/packages/schema-1/src/span_panel_api_schema_1/devices.py b/packages/schema-1/src/span_panel_api_schema_1/devices.py new file mode 100644 index 0000000..f0093bd --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/devices.py @@ -0,0 +1,412 @@ +"""Map the BESS, PV and EVSE devices onto their snapshot dataclasses. + +Two mappings here are deliberately *not* the obvious one, because v1.0 changed +what a name means rather than only where it lives. Both would produce a +plausible value that silently means something else: + +**The BESS model/part-number swap.** Flat ``bess/model`` was the SKU +(``1232100-00-E``); v1.0's ``info/model`` is the human designation +(``Powerwall 2 AC``) and the SKU moved to the new ``info/part-number``. Mapping +``info/model`` onto ``battery.model`` would keep the entity and change what it +displays, so the SKU is taken from ``part-number`` and the designation from +``model``. + +**Battery connectivity moved off the battery.** ``battery.connected`` is now the +panel-side owner's ``connection/*-device-status``, not anything the BESS +publishes about itself. The BESS's own ``status/communication-state`` looks like +the right property and is a different signal — the migration guide warns +against conflating them. Both are now carried, in separate fields +(``connected`` and ``communication_state``), because they answer different +questions: the panel's view of the link, and the publisher's view of its own. + +**Battery power is discharge-positive, and that is not the rule circuits follow.** +``build_circuit`` negates so that positive means power flowing *into* the metered +device, which is the convention the rest of this module states. ``build_battery`` +negates too, but its wire input is in the opposite frame, so it lands on the +opposite result: ``battery.power_w`` is positive while the battery *discharges*. + +Measured rather than reasoned. Driving the producer into self-consumption with +the grid at zero forces the direction: PV 4181 W plus battery 1917 W meeting a +6099 W load leaves nothing ambiguous, and the battery is discharging. The wire +publishes ``-1917.49`` and this module reports ``+1917.49``. + +That value is *correct* -- it is the frame the eBus specification asks for from a +device's own meter, "positive while discharging, that is, power flowing out of +the device". What was wrong was the name: this used to be ``_charge_positive``, +and the sentence above used to claim the into-the-device rule held everywhere. +It does not hold for the battery, and saying so was the defect. + +Both wire properties behind it carry the *same* sign as each other -- a live +panel capture and ``ebus-panel-sim`` 0.6.0 both publish the pair identically -- +so ``panel.power_flow_battery`` (passed through untouched) and +``battery.power_w`` (negated here) end up as each other's mirror, and a consumer +showing both sees one convention after applying one negation to either. + +Note that the alignment of those two wire properties is the specification's +*violation* rather than its rule: the spec defines ``power-flows/battery`` as the +negation of the BESS meter, and this firmware publishes them equal. Comparing the +two therefore tells a consumer which firmware it is on -- equal means today's, +opposite means a conformant future one -- which is what would let this conversion +stay correct across that change. Undecidable while the battery is idle and both +read zero. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from span_panel_api.models import SpanBatterySnapshot, SpanEvseSnapshot, SpanMidSnapshot, SpanPVSnapshot +from span_panel_api_schema_1.charge_limit import ChargeLimitProperty, ChargeLimitSurface, resolve_charge_limit +from span_panel_api_schema_1.const import ( + NODE_CONNECTION, + NODE_GRID, + NODE_INFO, + NODE_METER, + NODE_SOC, + NODE_STATUS, + NODE_SWITCH, + PROP_ACTIVE_POWER, + PROP_COMMUNICATION_STATE, + PROP_FED_BY_DEVICE_ID, + PROP_FED_BY_DEVICE_STATUS, + PROP_FEEDS_DEVICE_ID, + PROP_FEEDS_DEVICE_STATUS, + PROP_FIRMWARE_VERSION, + PROP_HARDWARE_VERSION, + PROP_MODEL, + PROP_SERIAL_NUMBER, + PROP_VENDOR_NAME, + UNKNOWN, +) +from span_panel_api_schema_1.panel import integer, number, resolve_grid_forming_device_name, text + +if TYPE_CHECKING: + from collections.abc import Mapping + + from ebus_sdk.homie import DiscoveredDevice + +# The `info` properties only a sub-device carries. The five the enclosure +# publishes too -- vendor name, model, serial, firmware and hardware revision -- +# are imported from `const` above rather than restated here: they name one wire +# property each, and two spellings of one property is how a rename reaches one +# reader and not the other. +PROP_NAMEPLATE_CAPACITY = "nameplate-capacity" +PROP_NOMINAL_POWER = "nominal-power" +PROP_PART_NUMBER = "part-number" + +PROP_SOC = "soc" +PROP_SOE = "soe" + +PROP_ADVERTISED_CURRENT = "advertised-current" +PROP_LOCK_STATE = "lock-state" +PROP_STATUS = "status" + +STATUS_OK = "OK" + + +def _optional(value: str) -> str | None: + """Empty means the panel did not publish it, which is not the same as ''.""" + return value or None + + +def feed_circuit_ids(circuits: list[DiscoveredDevice]) -> dict[str, str]: + """Map each fed device id to the circuit that feeds it. + + v1.0 states the relationship on the *circuit* (``connection/feeds-device-id``) + rather than on the DER, so it is read once here and handed to whichever + device needs it. + """ + feeds: dict[str, str] = {} + for circuit in circuits: + fed = text(circuit, NODE_CONNECTION, PROP_FEEDS_DEVICE_ID) + if fed: + feeds[fed] = circuit.device_id + return feeds + + +def connection_status_for(device_id: str, owners: list[DiscoveredDevice]) -> str | None: + """The connection status a panel-side owner reports *about* `device_id`. + + This is where ``battery.connected`` comes from. Returns None when nothing + claims the device, which is the honest answer for a panel whose owner has + not announced yet — distinct from a device that is known-disconnected. + """ + for owner in owners: + if text(owner, NODE_CONNECTION, PROP_FED_BY_DEVICE_ID) == device_id: + return _optional(text(owner, NODE_CONNECTION, PROP_FED_BY_DEVICE_STATUS)) + return None + + +def feed_connection_statuses(circuits: list[DiscoveredDevice]) -> dict[str, str]: + """The enclosure's view of the link to each circuit-fed device, by device id. + + The other half of the record ``feed_circuit_ids`` reads, and read alongside + it for the same reason: v1.0 states the relationship on the *circuit*, so a + DER's link health is published by whichever circuit feeds it rather than by + the DER. Same fact as ``connection_status_for``, opposite direction — that + one scans owners for a ``fed-by-*`` record naming the device, this one + indexes every ``feeds-*`` record a circuit publishes. + + A circuit is absent from the result unless it publishes *both* halves. An + id with no status cannot say anything about the link, and a status with no + id names no device to say it about; the enclosure model + (``distribution-enclosure.md``) makes an unpublished property the panel's + way of saying it does not know, so absence here is what a caller turns into + `None` rather than into a fault. + + Most circuits publish neither. A mixed-load or unsurveyed circuit feeds no + commissioned DER, so it has no connection record to publish — the spec calls + that normal, which is why nothing here treats a missing record as an error. + """ + statuses: dict[str, str] = {} + for circuit in circuits: + fed = text(circuit, NODE_CONNECTION, PROP_FEEDS_DEVICE_ID) + status = text(circuit, NODE_CONNECTION, PROP_FEEDS_DEVICE_STATUS) + if fed and status: + statuses[fed] = status + return statuses + + +def _connected(status: str | None) -> bool | None: + """Collapse a ``connection`` status enum to the snapshot's boolean. + + `None` stays `None`, so "nobody has said" remains distinct from "not OK". + The enum is ``OK,LOST,DEGRADED`` with no UNKNOWN member, so absence of the + property is the only unknown the wire can express and this is the one place + that decides what it means. + + DEGRADED collapses to `False` deliberately: the question a consumer asks of + this field is "can the enclosure talk to the device", and a degraded link is + not a working one. The distinction survives where it is a device's own + report — `battery.communication_state` keeps the enum string — but here it + is the panel's view, and the panel publishes no richer field for a consumer + to fall back on. + """ + return None if status is None else status == STATUS_OK + + +def _discharge_positive(raw_power_w: float | None) -> float | None: + """Flip the enclosure's meter frame to the BESS device's own. + + Positive means power flowing *out of the battery*, which is discharging, and + which is what the eBus specification asks of a device's own meter. Named for + what it produces after being called `_charge_positive` for as long as it + produced the opposite -- measured against a producer driven into + self-consumption, where the grid sits at zero and the direction cannot be + argued. + + `None` stays `None`: a BESS that publishes no `meter` node has no power + reading, which is not the same as zero. The `0.0` guard is `build_circuit`'s, + for the same reason — negating `0.0` yields `-0.0`, which compares equal to + `0.0` and formats as `"-0.0"`. + """ + if raw_power_w is None: + return None + return 0.0 if raw_power_w == 0.0 else -raw_power_w + + +def build_battery(bess: DiscoveredDevice | None, owners: list[DiscoveredDevice]) -> SpanBatterySnapshot: + """Build the battery snapshot. An uncommissioned panel yields the empty one.""" + if bess is None: + return SpanBatterySnapshot() + + status = connection_status_for(bess.device_id, owners) + + return SpanBatterySnapshot( + # Historically misnamed in the snapshot and kept that way: `soe_percentage` + # holds the percentage (`soc/soc`) and `soe_kwh` the energy (`soc/soe`). + # Renaming would break dashboards for a cosmetic gain. + soe_percentage=number(bess, NODE_SOC, PROP_SOC), + soe_kwh=number(bess, NODE_SOC, PROP_SOE), + vendor_name=_optional(text(bess, NODE_INFO, PROP_VENDOR_NAME)), + # No crossover any more. The snapshot speaks v1.0's vocabulary, so + # `info/model` is the designation and `info/part-number` is the SKU, on every + # device class. `schema_0` translates flat's irregular naming into this shape. + model=_optional(text(bess, NODE_INFO, PROP_MODEL)), + part_number=_optional(text(bess, NODE_INFO, PROP_PART_NUMBER)), + serial_number=_optional(text(bess, NODE_INFO, PROP_SERIAL_NUMBER)), + software_version=_optional(text(bess, NODE_INFO, PROP_FIRMWARE_VERSION)), + nameplate_capacity_kwh=number(bess, NODE_INFO, PROP_NAMEPLATE_CAPACITY), + # None when unclaimed, so "nobody has said" stays distinct from "not OK". + connected=_connected(status), + power_w=_discharge_positive(number(bess, NODE_METER, PROP_ACTIVE_POWER)), + # The BESS's own link health, kept as the published enum string rather + # than collapsed to a bool: DEGRADED is neither OK nor LOST, and a bool + # would have to pick one. + communication_state=_optional(text(bess, NODE_STATUS, PROP_COMMUNICATION_STATE)), + ) + + +def build_pv( + pv: DiscoveredDevice | None, + feeds: dict[str, str], + upstream_lugs: DiscoveredDevice | None = None, + downstream_lugs: DiscoveredDevice | None = None, + *, + feed_statuses: dict[str, str], +) -> SpanPVSnapshot: + """Build the PV snapshot. An uncommissioned panel yields the empty one.""" + if pv is None: + return SpanPVSnapshot() + + return SpanPVSnapshot( + connected=_connected(feed_statuses.get(pv.device_id)), + vendor_name=_optional(text(pv, NODE_INFO, PROP_VENDOR_NAME)), + model=_optional(text(pv, NODE_INFO, PROP_MODEL)), + software_version=_optional(text(pv, NODE_INFO, PROP_FIRMWARE_VERSION)), + nameplate_capacity_w=number(pv, NODE_INFO, PROP_NOMINAL_POWER), + feed_circuit_id=feeds.get(pv.device_id), + # Retired as a property in v1.0 and derived instead, per the enclosure model's + # own replacement rule. `None` where no owner references the DER, because the + # integration gates whether a control entity exists on this value. + relative_position=resolve_relative_position(pv.device_id, feeds, upstream_lugs, downstream_lugs), + ) + + +def build_evse( + evse: DiscoveredDevice, feeds: dict[str, str], *, node_id: str, feed_statuses: dict[str, str] +) -> SpanEvseSnapshot: + """Build one EVSE snapshot. + + `node_id` is supplied rather than taken from `evse.device_id`: it feeds the + integration's device-registry `identifiers`, so it has to be the harmonised + key, not the v1.0 device id. See `_harmonised_evse_keys`. + + Both lookups are keyed on `evse.device_id`, the v1.0 id, and not on + `node_id`: a connection record names the device the way the tree does, and a + panel with two chargers has two records to tell apart. Keying the status on + the harmonised serial would find nothing on every panel and, worse, would + find the *wrong* charger the moment two of them harmonised alike. + + The charge-current pair is read through `resolve_charge_limit` rather than + from named constants, because which node and properties carry it is a + question only this charger's `$description` can answer. See + `span_panel_api_schema_1.charge_limit`. + """ + limit = resolve_charge_limit(evse) + return SpanEvseSnapshot( + node_id=node_id, + feed_circuit_id=feeds.get(evse.device_id, ""), + connected=_connected(feed_statuses.get(evse.device_id)), + status=text(evse, NODE_STATUS, PROP_STATUS, UNKNOWN), + lock_state=text(evse, NODE_SWITCH, PROP_LOCK_STATE, UNKNOWN), + advertised_current_a=number(evse, NODE_METER, PROP_ADVERTISED_CURRENT), + vendor_name=_optional(text(evse, NODE_INFO, PROP_VENDOR_NAME)), + model=_optional(text(evse, NODE_INFO, PROP_MODEL)), + part_number=_optional(text(evse, NODE_INFO, PROP_PART_NUMBER)), + serial_number=_optional(text(evse, NODE_INFO, PROP_SERIAL_NUMBER)), + software_version=_optional(text(evse, NODE_INFO, PROP_FIRMWARE_VERSION)), + charge_current_limit_a=_limit_value(evse, limit, limit.limit) if limit else None, + charge_current_ceiling_a=_limit_value(evse, limit, limit.ceiling) if limit else None, + charge_current_limit_target_a=_limit_target(evse, limit), + charge_current_limit_settable=limit is not None and limit.limit is not None and limit.limit.settable, + ) + + +def _limit_value(evse: DiscoveredDevice, surface: ChargeLimitSurface, declaration: ChargeLimitProperty | None) -> int | None: + """One half of the resolved charge-limit pair, or None where it is not declared.""" + if declaration is None: + return None + return integer(evse, surface.node, declaration.property_id) + + +def _limit_target(evse: DiscoveredDevice, surface: ChargeLimitSurface | None) -> int | None: + """The pending write the charger is echoing on `$target`, if any. + + Parsed to `int` rather than passed through as the string the circuit targets + carry, because this one is compared against a number: a consumer showing + "pending 24 A" beside a reading of 32 has to know both are amps. A `$target` + that is not a number is not a pending amperage, so it reads as no pending + command rather than as a value the caller has to re-parse. + """ + if surface is None or surface.limit is None: + return None + raw = evse.get_property_target(surface.node, surface.limit.property_id) + if raw is None or raw == "": + return None + try: + return int(float(raw)) + except (TypeError, ValueError): + return None + + +PROP_ISLANDING_STATE = "islanding-state" +PROP_GRID_STATE = "grid-state" +PROP_GRID_FORMING_ENTITY = "grid-forming-entity" + + +def build_mid(mid: DiscoveredDevice | None, device_names: Mapping[str, str]) -> SpanMidSnapshot | None: + """Build the MID snapshot, or `None` when the panel publishes no MID. + + `None` is the presence signal, so there is nothing for a consumer to infer from a + sentinel field. Every value is optional except identity: the enclosure model makes + `islanding-state` MUST on a MID, but a device mid-discovery has a description and + no values yet, and reporting that as `ON_GRID` would be worse than reporting it as + unknown. + + Identity follows `SpanEvseSnapshot`: the serial where published, the Homie device + id otherwise. Here the device id is `-mid`, so it inherits the BESS's + proxied form and the instability `devices/proxy.md` warns about — the serial is the + part that survives a proxy-to-native transition. + """ + if mid is None: + return None + serial = _optional(text(mid, NODE_INFO, PROP_SERIAL_NUMBER)) + return SpanMidSnapshot( + node_id=serial or mid.device_id, + serial_number=serial, + vendor_name=_optional(text(mid, NODE_INFO, PROP_VENDOR_NAME)), + model=_optional(text(mid, NODE_INFO, PROP_MODEL)), + software_version=_optional(text(mid, NODE_INFO, PROP_FIRMWARE_VERSION)), + hardware_version=_optional(text(mid, NODE_INFO, PROP_HARDWARE_VERSION)), + islanding_state=_optional(text(mid, NODE_GRID, PROP_ISLANDING_STATE)), + grid_state=_optional(text(mid, NODE_GRID, PROP_GRID_STATE)), + grid_forming_entity=_optional(text(mid, NODE_GRID, PROP_GRID_FORMING_ENTITY)), + grid_forming_device_name=resolve_grid_forming_device_name(mid, device_names), + ) + + +POSITION_IN_PANEL = "IN_PANEL" +POSITION_UPSTREAM = "UPSTREAM" + + +def resolve_relative_position( + device_id: str, + feeds: dict[str, str], + upstream_lugs: DiscoveredDevice | None, + downstream_lugs: DiscoveredDevice | None, +) -> str | None: + """Where a DER sits relative to the enclosure, from the connection records. + + v1.0 removed `relative-position` as a property deliberately, and the enclosure model + says what replaces it: "The position of a DER relative to the enclosure is derivable + from which enclosure-side connection-owner references the DER." + + | owner referencing the DER | position | + | --- | --- | + | a circuit's `connection/feeds-device-id` | `IN_PANEL` | + | the downstream lugs' `connection/feeds-device-id` | `IN_PANEL`, via feedthrough | + | the upstream lugs' `connection/fed-by-device-id` | `UPSTREAM` | + | nothing | `None` — not commissioned to this enclosure, or not yet announced | + + Verified against the paired captures rather than reasoned: flat publishes + `pv/relative-position = IN_PANEL` and `bess/relative-position = UPSTREAM`, and this + derives exactly those from a circuit feeding the PV and the upstream lugs being fed + by the BESS. + + `None`, not a guess. The integration gates whether a *control entity exists at all* + on this value, so inventing one creates or removes a control. The guide is explicit + that where no owner references the DER, position is not derivable. + + The feedthrough branch is unreachable against every producer available today: no lugs + device can publish `connection/feeds-*` at all, which is + electrification-bus/distribution-enclosure-simulator#30. It is written because the + rule has three cases and omitting one would read as a claim that it cannot happen. + """ + if device_id in feeds: + return POSITION_IN_PANEL + if downstream_lugs is not None and text(downstream_lugs, NODE_CONNECTION, PROP_FEEDS_DEVICE_ID) == device_id: + return POSITION_IN_PANEL + if upstream_lugs is not None and text(upstream_lugs, NODE_CONNECTION, PROP_FED_BY_DEVICE_ID) == device_id: + return POSITION_UPSTREAM + return None diff --git a/packages/schema-1/src/span_panel_api_schema_1/extension.py b/packages/schema-1/src/span_panel_api_schema_1/extension.py new file mode 100644 index 0000000..f427e7e --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/extension.py @@ -0,0 +1,111 @@ +"""Build ``ExtensionProperty`` records for properties on devices this adapter *does* model. + +The other half of vendor extensibility from :mod:`adoption`. That module handles +a device type nothing here models; this one handles a new property on a device +something here does -- a battery vendor hanging ``battery-2/cell-temperature`` +off the BESS. Until this existed the second case reached a consumer nowhere: it +became a discovery row and stopped at diagnostics, which only a maintainer +reading an attachment ever sees. + +**Values, like :mod:`adoption` and unlike :mod:`field_metadata`'s discovery +rows.** The same property is described by both surfaces on purpose, joined by +its ``{node}/{property}`` path: a declaration for the maintainer, a reading for +the user. The types are separate so that conflating them is a type error rather +than a leak, and `ExtensionProperty` is deliberately not a `FieldMetadata` -- +`partition()` walks the metadata map, so a value carried here has no path into a +payload that leaves the machine. + +**Read-only, structurally.** No set topic is built here and `ExtensionProperty` +has no member to put one in. A settable extension property is carried with +``settable=True`` for curation triage and still surfaces as a reading, because +these properties live on exactly the devices whose curated controls do real +safety work -- the EVSE limit refuses a value above the commissioned ceiling, +and the islanding assertion translates ``GRID`` into ``ON_GRID``. A generic +write path would sit beside both, on the same wire, with neither. + +**Unaddressed is asked once.** The set comes from +:func:`field_metadata.addressed_rows`, so this module and the discovery rows +cannot disagree about which properties this adapter reads. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from span_panel_api.models import ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE, ExtensionProperty, ExtensionSubject +from span_panel_api_schema_1.description import nodes, optional_str, properties +from span_panel_api_schema_1.field_metadata import is_addressed + +if TYPE_CHECKING: + from collections.abc import Sequence + + from ebus_sdk.homie import DiscoveredDevice + + +def build_extension_properties( + subjects: Sequence[tuple[DiscoveredDevice, ExtensionSubject]], + addressed: set[tuple[str, str, str]], +) -> tuple[ExtensionProperty, ...]: + """Every declared-but-unaddressed property of the modelled devices given. + + The caller supplies the pairing rather than this module deriving it, because + the subject key for a multi-instance kind is the snapshot's own map key -- + the circuit id, the harmonised EVSE key -- and those are decided while the + snapshot is being assembled. Deriving them a second time here would be a + second implementation of the same decision, free to drift from the first. + + Subject resolution is therefore indifferent to proxying by construction: a + device the snapshot builder sorted into a role arrives here already paired + with that role, whether the tree proxied it or not. The reference tree's own + MID arrives proxied as ``bess-mid`` and is paired with ``mid`` like any + other. + """ + found: list[ExtensionProperty] = [] + for device, subject in subjects: + declared = _declared_type(device) + if not declared: + continue + for node_id, node in nodes(device.description or {}).items(): + if node_id in (ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE): + continue + declarations = properties(node) + unaddressed = { + property_id: definition + for property_id, definition in declarations.items() + if not is_addressed(addressed, declared, node_id, property_id) + } + if not unaddressed: + continue + # True when the node carries at least one property this adapter does + # read. One bit rather than the node-to-field map: a vendor + # extending `meter` is probably extending the meter, and that is all + # a consumer can act on. Exporting which fields would freeze this + # adapter's internals as API for a signal the design ranks last. + has_curated_siblings = len(unaddressed) < len(declarations) + for property_id, definition in unaddressed.items(): + raw = device.get_property(node_id, property_id) + found.append( + ExtensionProperty( + subject=subject, + node_id=node_id, + property_id=property_id, + datatype=str(definition.get("datatype") or "string"), + unit=optional_str(definition.get("unit")), + format=optional_str(definition.get("format")), + settable=bool(definition.get("settable", False)), + value=None if raw is None else str(raw), + node_has_curated_siblings=has_curated_siblings, + ) + ) + return tuple(found) + + +def _declared_type(device: DiscoveredDevice) -> str: + """The device's declared ``$type``, or empty when it has not arrived yet. + + A device mid-discovery declares no type, which is a normal state rather than + a finding: it is skipped and picked up on a later snapshot, the same way + :func:`adoption.build_adopted_devices` skips it. + """ + description: dict[str, object] = device.description or {} + return str(description.get("type") or "") diff --git a/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py new file mode 100644 index 0000000..573c1d7 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/field_metadata.py @@ -0,0 +1,615 @@ +"""Build transport-agnostic field metadata from the v1.0 device tree. + +Maps every property the snapshot mapper reads to a snapshot field path, then +takes the declared unit and datatype for each from the tree itself. The result +is a dict the integration consumes without any Homie knowledge, keyed +``{snapshot_type}.{field_name}``. + +**Read from each device's ``$description``, not from the REST schema.** The +migration guide is explicit that "the authoritative property set for any +capability node is always declared in that device's ``$description``", because +the same capability type exposes different properties on different device +classes — ``meter`` on the panel is voltage, on a circuit is power and energy, +on a lugs device is both currents. The REST ``deviceClasses`` document is the +superset across all hardware; the description is what *this* panel actually has. + +**Two kinds of row, one map.** Alongside the curated rows this builds a +discovery row for every property the tree declares that this adapter addresses +nowhere, namespaced so a consumer can partition the two before it reads either. +See `build_discovery`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from span_panel_api.models import DiscoveredMetadata, FieldMetadata, discovery_path +from span_panel_api_schema_1.charge_limit import ChargeLimitProperty, resolve_charge_limit +from span_panel_api_schema_1.const import ( + DEVICE_TYPE_PREFIX, + NODE_BREAKER, + NODE_CONNECTION, + NODE_DOOR, + NODE_GRID, + NODE_INFO, + NODE_LOAD_SHED, + NODE_METER, + NODE_PCS, + NODE_POWER_FLOWS, + NODE_SHED, + NODE_SHED_FORECAST, + NODE_SOC, + NODE_STATUS, + NODE_SWITCH, + PROP_ACTIVE_POWER, + PROP_EXPORTED_ENERGY, + PROP_IMPORTED_ENERGY, + TYPE_BESS, + TYPE_CIRCUIT, + TYPE_EVSE, + TYPE_LUGS, + TYPE_MID, + TYPE_PANEL, + TYPE_PV, +) +from span_panel_api_schema_1.description import ( + device_type as declared_type, + nodes as declared_nodes, + optional_str, + properties as declared_properties, +) +from span_panel_api_schema_1.panel import PROP_CURRENT_A, PROP_CURRENT_B, find_lugs + +if TYPE_CHECKING: + from ebus_sdk.homie import DiscoveredDevice + +# (device type, node, property) → snapshot field path. +# +# Encodes how the mapper reads the tree, so it has to move with it. Where the +# mapper deliberately declines a value — dsm_state, current_run_config, +# dominant_power_source, grid_islandable, relative_position — there is no row, +# because metadata for a field nothing populates would advertise a unit for a +# reading that never arrives. +_PROPERTY_FIELD_MAP: tuple[tuple[str, str, str, str], ...] = ( + # --- Panel --------------------------------------------------------------- + (TYPE_PANEL, NODE_INFO, "firmware-version", "panel.firmware_version"), + (TYPE_PANEL, NODE_DOOR, "state", "panel.door_state"), + (TYPE_PANEL, NODE_STATUS, "relay", "panel.main_relay_state"), + (TYPE_PANEL, NODE_STATUS, "ethernet", "panel.eth0_link"), + (TYPE_PANEL, NODE_STATUS, "wifi", "panel.wlan_link"), + # The SSID, not the link. Flat carries the same row (`core/wifi-ssid`), which + # is what makes this a plain both-adapters declaration on the consumer side + # rather than a schema-conditional one -- and what makes its absence here a + # regression rather than a new feature. + (TYPE_PANEL, NODE_STATUS, "wifi-ssid", "panel.wifi_ssid"), + (TYPE_PANEL, NODE_STATUS, "cloud-connection", "panel.vendor_cloud"), + (TYPE_PANEL, NODE_METER, "voltage-a", "panel.l1_voltage"), + (TYPE_PANEL, NODE_METER, "voltage-b", "panel.l2_voltage"), + (TYPE_PANEL, NODE_BREAKER, "rating", "panel.main_breaker_rating_a"), + (TYPE_PANEL, NODE_POWER_FLOWS, "pv", "panel.power_flow_pv"), + (TYPE_PANEL, NODE_POWER_FLOWS, "battery", "panel.power_flow_battery"), + (TYPE_PANEL, NODE_POWER_FLOWS, "grid", "panel.power_flow_grid"), + (TYPE_PANEL, NODE_POWER_FLOWS, "site", "panel.power_flow_site"), + # Only the two live estimates. The `full-charge-*` pair and `confidence` + # are read too, but a consumer renders them beside these rather than as + # readings of their own, so a unit row for them would advertise a surface + # that is not there. The pair below is what carries `min`, and with it the + # declared-gap signal: a panel that publishes the node while omitting one of + # these reports it as degradation rather than as absent hardware. + (TYPE_PANEL, NODE_SHED_FORECAST, "time-to-priority-shed", "panel.shed_time_to_priority_shed_min"), + (TYPE_PANEL, NODE_SHED_FORECAST, "total-time-remaining", "panel.shed_total_time_remaining_min"), + # --- Panel `pcs` → pcs.* ------------------------------------------------- + # Only the three the capability calls "the result", plus the state that + # decides whether there is a result at all. `capabilities/pcs.md` is + # explicit that `pcs` does not re-publish the other regimes' constraints: + # "what `pcs` publishes is the **result**: the effective `import-limit` and + # the `binding-constraint`". Those are what a consumer renders as readings, + # so those are what carry a unit row — `import-limit` in particular, whose + # `A` is validated against the sensor's declared unit. + # + # The four constraint families and `enabled` are read into the snapshot too + # and deliberately have no row: they qualify the effective limit rather than + # standing as readings, exactly as the `shed-forecast` full-charge pair + # does, and a unit row for them would advertise a surface that is not there. + (TYPE_PANEL, NODE_PCS, "import-limit", "pcs.import_limit_a"), + (TYPE_PANEL, NODE_PCS, "binding-constraint", "pcs.binding_constraint"), + (TYPE_PANEL, NODE_PCS, "active", "pcs.active"), + # --- Lugs → panel.* ------------------------------------------------------ + # Deliberately absent. Which device a lugs property belongs to comes from + # `info/direction` at read time, and a table keyed on (type, node, property) + # cannot express that — see `_DOWNSTREAM_LUGS_FIELDS` and `_lugs_metadata`. + # --- Circuit ------------------------------------------------------------- + (TYPE_CIRCUIT, NODE_INFO, "name", "circuit.name"), + (TYPE_CIRCUIT, NODE_INFO, "spaces", "circuit.tabs"), + (TYPE_CIRCUIT, NODE_SWITCH, "relay", "circuit.relay_state"), + (TYPE_CIRCUIT, NODE_SWITCH, "relay-requester", "circuit.relay_requester"), + (TYPE_CIRCUIT, NODE_SWITCH, "relay-controllable", "circuit.is_user_controllable"), + (TYPE_CIRCUIT, NODE_LOAD_SHED, "priority", "circuit.priority"), + (TYPE_CIRCUIT, NODE_METER, "active-power", "circuit.instant_power_w"), + (TYPE_CIRCUIT, NODE_METER, "current", "circuit.current_a"), + (TYPE_CIRCUIT, NODE_METER, "imported-energy", "circuit.produced_energy_wh"), + (TYPE_CIRCUIT, NODE_METER, "exported-energy", "circuit.consumed_energy_wh"), + (TYPE_CIRCUIT, NODE_BREAKER, "rating", "circuit.breaker_rating_a"), + (TYPE_CIRCUIT, NODE_BREAKER, "poles", "circuit.is_240v"), + # --- Circuit `connection` -> the DER the circuit feeds --------------------- + # The one place a row's device type and its field path deliberately disagree. + # v1.0 states the enclosure/DER relationship on the *circuit*, so the panel's + # view of the link to a PV or a charger is published by whichever circuit + # feeds it -- `build_pv` and `build_evse` read it through + # `feed_connection_statuses`, and the field it fills belongs to the DER. + # + # Two rows for one property, because one circuit's record describes a PV and + # another's describes a charger. That is what the property *is* on this + # device class; which DER a given instance names is a value, and a metadata + # row describes neither values nor instances. + # + # `feeds-device-id` carries no row on purpose: it is topology the mapper + # consumes into `feed_circuit_id`, `device_type` and `relative_position`, and + # a unit for a device id would describe a reading nothing renders. + (TYPE_CIRCUIT, NODE_CONNECTION, "feeds-device-status", "evse.connected"), + (TYPE_CIRCUIT, NODE_CONNECTION, "feeds-device-status", "pv.connected"), + # --- BESS ---------------------------------------------------------------- + (TYPE_BESS, NODE_SOC, "soc", "battery.soe_percentage"), + (TYPE_BESS, NODE_SOC, "soe", "battery.soe_kwh"), + (TYPE_BESS, NODE_INFO, "vendor-name", "battery.vendor_name"), + (TYPE_BESS, NODE_INFO, "model", "battery.model"), + (TYPE_BESS, NODE_INFO, "part-number", "battery.part_number"), + (TYPE_BESS, NODE_INFO, "serial-number", "battery.serial_number"), + (TYPE_BESS, NODE_INFO, "firmware-version", "battery.software_version"), + (TYPE_BESS, NODE_INFO, "nameplate-capacity", "battery.nameplate_capacity_kwh"), + # The BESS's own meter and its own link health. `battery.power_w` carries a + # sign flip (`build_battery` reports discharge-positive, the wire carries the + # enclosure's frame), which does not affect the unit or the datatype this row + # describes — a row states what the property *is*, not what the mapper does + # with it. + (TYPE_BESS, NODE_METER, "active-power", "battery.power_w"), + (TYPE_BESS, NODE_STATUS, "communication-state", "battery.communication_state"), + # --- PV ------------------------------------------------------------------ + (TYPE_PV, NODE_INFO, "vendor-name", "pv.vendor_name"), + (TYPE_PV, NODE_INFO, "model", "pv.model"), + (TYPE_PV, NODE_INFO, "nominal-power", "pv.nameplate_capacity_w"), + # --- EVSE ---------------------------------------------------------------- + (TYPE_EVSE, NODE_STATUS, "status", "evse.status"), + (TYPE_EVSE, NODE_SWITCH, "lock-state", "evse.lock_state"), + (TYPE_EVSE, NODE_METER, "advertised-current", "evse.advertised_current_a"), + # The charger's SKU. Flat maps `evse/part-number` to the same field, so this + # row is what lifts `evse.part_number` out of one-adapter exemption and into + # a declaration the producible gate covers on both. + (TYPE_EVSE, NODE_INFO, "part-number", "evse.part_number"), +) + + +def build_field_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMetadata]: + """Collect metadata for every mapped field the tree actually declares. + + A field with no declaring device is omitted rather than defaulted: the + integration compares these against its own sensor definitions, so an + invented unit would validate a reading the panel never sends. + """ + declared: dict[str, tuple[str | None, str]] = {} + # Presence is a (device type, node) question, not a device question. The + # power-flows rows are (TYPE_PANEL, NODE_POWER_FLOWS, ...) and the panel + # device is always present, so a device-level test would mark every + # panel.power_flow_* path unresolved on a panel that simply has no + # power-flows node. + # + # Collected from the node structure rather than from `declared`, because a + # node that declares no properties at all is exactly the degradation this + # is here to catch, and it contributes no `declared` keys to read back. + present_type_nodes: set[tuple[str, str]] = set() + for device in devices: + description: dict[str, object] = device.description or {} + device_type = str(description.get("type") or "") + if not device_type: + continue + for node_id, node in declared_nodes(description).items(): + present_type_nodes.add((device_type, node_id)) + for property_id, definition in declared_properties(node).items(): + declared[f"{device_type}|{node_id}|{property_id}"] = ( + optional_str(definition.get("unit")), + str(definition.get("datatype") or "string"), + ) + + metadata: dict[str, FieldMetadata] = {} + for device_type, node_id, property_id, field_path in _PROPERTY_FIELD_MAP: + found = _lookup(declared, device_type, node_id, property_id) + if found is not None: + unit, datatype = found + metadata[field_path] = FieldMetadata(unit=unit, datatype=datatype) + elif _node_declared(present_type_nodes, device_type, node_id): + # The node is here and does not declare the property: a real gap, + # distinct from the hardware simply not being installed. + metadata[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) + metadata.update(_lugs_metadata(devices, upstream=True, fields=_UPSTREAM_LUGS_FIELDS)) + metadata.update(_lugs_metadata(devices, upstream=False, fields=_DOWNSTREAM_LUGS_FIELDS)) + metadata.update(_charge_limit_metadata(devices)) + # Namespaced, so a consumer partitions them out before it reads the map as + # an inventory of produced fields. See `build_discovery`. + metadata.update(build_discovery(devices)) + return metadata + + +def _charge_limit_metadata(devices: list[DiscoveredDevice]) -> dict[str, FieldMetadata]: + """Metadata for the EVSE charge-current pair, resolved the way the value is. + + The table above cannot describe these, for the same reason it cannot + describe the lugs meter: it is keyed `(device type, node, property)`, and + the node and the property are precisely what a charger gets to choose here. + A row would have to name one spelling, which is the guess `charge_limit` + exists to avoid — and naming both would let a charger that declares neither + resolve through a row written for the other. + + So it goes through `resolve_charge_limit`, the same call `build_evse` makes, + which is what keeps the unit a field advertises and the value that fills it + describing the same property. + + The first charger declaring a surface answers for the path, matching + `_lookup`'s rule for every other type-keyed row: a field path is per snapshot + field, not per device, and two chargers on one panel declare one property + set each. A surface that declares only one of the pair leaves the other + `resolved=False` — the node is there and the property is not, which is a gap + rather than absent hardware. + """ + for device in devices: + if not declared_type(device).startswith(TYPE_EVSE): + continue + surface = resolve_charge_limit(device) + if surface is None: + continue + return { + "evse.charge_current_limit_a": _charge_limit_entry(surface.limit), + "evse.charge_current_ceiling_a": _charge_limit_entry(surface.ceiling), + } + return {} + + +def _charge_limit_entry(declaration: ChargeLimitProperty | None) -> FieldMetadata: + if declaration is None: + return FieldMetadata(unit=None, datatype="unknown", resolved=False) + return FieldMetadata(unit=declaration.unit, datatype=declaration.datatype) + + +def _node_declared(present_type_nodes: set[tuple[str, str]], device_type: str, node_id: str) -> bool: + """Whether any present device of this type declares this node. + + Mirrors `_lookup`'s subtype rule, and has to: presence and lookup must + agree about which devices answer for a row, or a subtyped device that + dropped a property would resolve through one and misclassify through the + other. Both are now exercised on non-lugs types, lugs having moved to a + direction-resolved lookup of their own. + """ + return any( + node == node_id and (declared_device_type == device_type or declared_device_type.startswith(f"{device_type}.")) + for declared_device_type, node in present_type_nodes + ) + + +# The ten fields `_PROPERTY_FIELD_MAP` cannot address, and why it cannot. +# +# The table is keyed `(device type, node, property)`, and the two lugs devices +# match on all three — same `energy.ebus.device.lugs`, same `meter` node, same +# property names — differing only in the `info/direction` value they publish. A +# table keyed that way cannot hold two different answers, so it cannot describe +# these ten fields at all: it can only describe *a* lugs device and label the +# result with one direction's field paths. +# +# Doing that was wrong in both directions at once. Whichever device `_lookup` +# reached first answered for the `upstream_*` paths, so a property the upstream +# device had dropped came back `resolved=True`, with a real unit, on the strength +# of the downstream device declaring it — and with no upstream device present at +# all, the downstream one described the whole main meter as working hardware that +# was not installed. Both are the false `resolved=True` this metadata exists to +# make impossible, and the silent kind: the integration validates against a unit +# for a reading that never arrives, and nothing anywhere reports a fault. +# +# The snapshot mapper never had the problem, because it resolves the pair by +# direction and reads each (`panel.py`, `PanelFields.__init__`). Resolving the +# metadata the same way is what keeps the two from disagreeing about which device +# is which — the property a field's unit describes is now the same property whose +# value fills it. +_UPSTREAM_LUGS_FIELDS: tuple[tuple[str, str], ...] = ( + (PROP_ACTIVE_POWER, "panel.instant_grid_power_w"), + (PROP_IMPORTED_ENERGY, "panel.main_meter_energy_consumed_wh"), + (PROP_EXPORTED_ENERGY, "panel.main_meter_energy_produced_wh"), + (PROP_CURRENT_A, "panel.upstream_l1_current_a"), + (PROP_CURRENT_B, "panel.upstream_l2_current_a"), +) + +_DOWNSTREAM_LUGS_FIELDS: tuple[tuple[str, str], ...] = ( + (PROP_ACTIVE_POWER, "panel.feedthrough_power_w"), + (PROP_IMPORTED_ENERGY, "panel.feedthrough_energy_consumed_wh"), + (PROP_EXPORTED_ENERGY, "panel.feedthrough_energy_produced_wh"), + (PROP_CURRENT_A, "panel.downstream_l1_current_a"), + (PROP_CURRENT_B, "panel.downstream_l2_current_a"), +) + + +def _lugs_metadata( + devices: list[DiscoveredDevice], *, upstream: bool, fields: tuple[tuple[str, str], ...] +) -> dict[str, FieldMetadata]: + """Metadata for one lugs device, resolved by direction rather than by type. + + Uses the same `find_lugs` the snapshot mapper uses, so the metadata and the + value can never disagree about which device is which. + + Carries the same three-way contract as the table-driven loop, on the same + (device, node) granularity: no lugs device in this direction, or no `meter` + node on it, means no entry, while a `meter` node that omits a property is a + declared gap. Both directions run through here so the two halves of + `panel.*` cannot drift into answering to different rules. + + A lugs device that publishes no `info/direction` is invisible to `find_lugs` + and so yields no entry, which is deliberate: the mapper reads its values + through the same call, so nothing would populate those fields either. + """ + lugs = find_lugs([d for d in devices if declared_type(d).startswith(TYPE_LUGS)], upstream=upstream) + if lugs is None: + return {} + + meter = declared_nodes(lugs.description or {}).get(NODE_METER) + if meter is None: + return {} + + declared = declared_properties(meter) + found: dict[str, FieldMetadata] = {} + for property_id, field_path in fields: + definition = declared.get(property_id) + if definition is None: + found[field_path] = FieldMetadata(unit=None, datatype="unknown", resolved=False) + continue + found[field_path] = FieldMetadata( + unit=optional_str(definition.get("unit")), + datatype=str(definition.get("datatype") or "string"), + ) + return found + + +def _lookup( + declared: dict[str, tuple[str | None, str]], device_type: str, node_id: str, property_id: str +) -> tuple[str | None, str] | None: + """Find a declaration, allowing a device type to be a subtype of the mapped one. + + eBus device types are hierarchical and a subtype carries its parent's + properties, so a device typed `X.Y` satisfies a row written for `X`. + + Lugs were the observed instance — `…device.lugs` versus a subtyped + `…device.lugs.upstream` — and they no longer come through here, because + which lugs device a property belongs to is a direction question the table + cannot ask. The rule is kept for every other mapped type rather than + retired with its first user: the same subtyping applies to all of them, and + `_LUGS_FALLBACK` in the flat adapter is evidence SPAN does ship it. + """ + exact = declared.get(f"{device_type}|{node_id}|{property_id}") + if exact is not None: + return exact + suffix = f"|{node_id}|{property_id}" + for key, value in declared.items(): + if key.endswith(suffix) and key[: -len(suffix)].startswith(device_type): + return value + return None + + +_CONSUMED_WITHOUT_A_ROW: tuple[tuple[str, str, str], ...] = ( + # Properties the snapshot mapper reads that carry no `_PROPERTY_FIELD_MAP` + # row, and never will: a row exists to state a *reading's* unit and + # datatype, and none of these is a reading. They are build identity, the + # topology the mapper resolves roles from, and the qualifiers a consumer + # renders beside a reading rather than as one. + # + # Without this table `build_discovery` would report all of them as + # unaddressed, because the only enumeration of what schema_1 reads was the + # metadata map — which is a third of it. Every entry is proved by + # experiment: `test_schema_one_discovery` republishes each one against the + # reference tree and fails if the snapshot does not move, so an entry that + # stops being true is a red build rather than a property that quietly + # disappears from discovery. + # + # --- Panel identity, read by the snapshot's panel fields ----------------- + (TYPE_PANEL, NODE_INFO, "hardware-version"), + (TYPE_PANEL, NODE_INFO, "model"), + (TYPE_PANEL, NODE_INFO, "serial-number"), + (TYPE_PANEL, NODE_INFO, "vendor-name"), + # --- The PCS arbitration's inputs --------------------------------------- + # `import-limit`, `binding-constraint` and `active` are the result and carry + # rows; these thirteen explain the result. See the `pcs` rows above. + (TYPE_PANEL, NODE_PCS, "enabled"), + (TYPE_PANEL, NODE_PCS, "feed-import-limit"), + (TYPE_PANEL, NODE_PCS, "feed-import-limit-active"), + (TYPE_PANEL, NODE_PCS, "feed-import-limit-enablement"), + (TYPE_PANEL, NODE_PCS, "off-grid-import-limit"), + (TYPE_PANEL, NODE_PCS, "off-grid-import-limit-active"), + (TYPE_PANEL, NODE_PCS, "off-grid-import-limit-enablement"), + (TYPE_PANEL, NODE_PCS, "operator-import-limit"), + (TYPE_PANEL, NODE_PCS, "operator-import-limit-active"), + (TYPE_PANEL, NODE_PCS, "operator-import-limit-enablement"), + (TYPE_PANEL, NODE_PCS, "requested-import-limit"), + (TYPE_PANEL, NODE_PCS, "requested-import-limit-active"), + (TYPE_PANEL, NODE_PCS, "requested-import-limit-enablement"), + # --- The shed policy document and the forecast's refinements ------------- + (TYPE_PANEL, NODE_SHED, "policy"), + (TYPE_PANEL, NODE_SHED_FORECAST, "confidence"), + (TYPE_PANEL, NODE_SHED_FORECAST, "full-charge-time-to-priority-shed"), + (TYPE_PANEL, NODE_SHED_FORECAST, "full-charge-total-time-remaining"), + # --- Circuit topology and PCS membership -------------------------------- + (TYPE_CIRCUIT, NODE_CONNECTION, "feeds-device-id"), + (TYPE_CIRCUIT, NODE_PCS, "managed"), + (TYPE_CIRCUIT, NODE_PCS, "priority"), + # --- Lugs direction and the upstream device's link ----------------------- + # `info/direction` decides which lugs device is the main meter and which is + # the feedthrough, so it moves ten panel fields without being one. + (TYPE_LUGS, NODE_CONNECTION, "fed-by-device-id"), + (TYPE_LUGS, NODE_CONNECTION, "fed-by-device-status"), + (TYPE_LUGS, NODE_INFO, "direction"), + # --- MID ------------------------------------------------------------------ + # The islanding authority. Every field it feeds is device-card identity or + # a state string, so the whole device is here rather than in the map. + (TYPE_MID, NODE_GRID, "grid-forming-entity"), + (TYPE_MID, NODE_GRID, "grid-state"), + (TYPE_MID, NODE_GRID, "islanding-state"), + (TYPE_MID, NODE_INFO, "firmware-version"), + (TYPE_MID, NODE_INFO, "hardware-version"), + (TYPE_MID, NODE_INFO, "model"), + (TYPE_MID, NODE_INFO, "serial-number"), + (TYPE_MID, NODE_INFO, "vendor-name"), + # --- DER identity --------------------------------------------------------- + (TYPE_EVSE, NODE_INFO, "firmware-version"), + (TYPE_EVSE, NODE_INFO, "model"), + (TYPE_EVSE, NODE_INFO, "serial-number"), + (TYPE_EVSE, NODE_INFO, "vendor-name"), + (TYPE_PV, NODE_INFO, "firmware-version"), +) +"""Declarations the mapper reads into the snapshot without a metadata row. + +The charge-current pair is deliberately absent: which node and which property +carry it is the charger's choice, so `build_discovery` resolves it through +`resolve_charge_limit` exactly as `_charge_limit_metadata` does, rather than +naming one spelling here and leaving the other reported as unaddressed. +""" + +_CONSUMED_OFF_SNAPSHOT: dict[tuple[str, str, str], str] = { + (TYPE_PANEL, NODE_INFO, "data-model-version"): ( + "tier-1 adapter dispatch (span_panel_api.dispatch) — it chooses which adapter " + "parses the tree, so it is consumed before any snapshot exists" + ), + (TYPE_PANEL, NODE_SHED, "asserted-islanding-state"): ( + "tier 2 of resolve_islanding_state (panel.py), shadowed wherever a MID answers " + "at tier 1, and the write target of set_dominant_power_source_topic" + ), + (TYPE_LUGS, NODE_CONNECTION, "feeds-device-id"): ( + "the downstream-lugs feedthrough branch of resolve_relative_position " + "(devices.py), which no producer currently reaches" + ), +} +"""Declarations this library reads by a route no snapshot field can show. + +The category the republish experiment cannot measure, and therefore the one at +risk of becoming an allowlist. It is held to the opposite assertion instead: +`test_an_off_snapshot_route_that_became_observable_must_be_retired` fails the +moment one of these does move a snapshot field, because at that point the route +is no longer the only thing consuming it and the entry is hiding a real reader. + +Three, and each names the code that reads it. The consumer-side mirror is +`_INTERNAL_ROUTES` in the integration's `test_declared_but_unread`; they agree +because they are answering the same question of the same tree, not because +either copies the other. +""" + +_ADDRESSED: frozenset[tuple[str, str, str]] = ( + frozenset((device_type, node_id, property_id) for device_type, node_id, property_id, _ in _PROPERTY_FIELD_MAP) + | frozenset( + (TYPE_LUGS, NODE_METER, property_id) for property_id, _ in (*_UPSTREAM_LUGS_FIELDS, *_DOWNSTREAM_LUGS_FIELDS) + ) + | frozenset(_CONSUMED_WITHOUT_A_ROW) + | frozenset(_CONSUMED_OFF_SNAPSHOT) +) +"""Every ``(device type, node, property)`` this library addresses, from all four tables. + +Derived rather than restated, so a new `_PROPERTY_FIELD_MAP` row leaves +discovery without anyone remembering to. The charge-current pair is added per +charger at build time; see `build_discovery`. +""" + + +def addressed_rows(devices: list[DiscoveredDevice]) -> set[tuple[str, str, str]]: + """Every `(device type, node, property)` this adapter reads a snapshot field from. + + `_ADDRESSED` states the static rows; the loop adds the EVSE charge-limit + surface, which is resolved per device because firmware publishes the limit + and its ceiling under node and property names this adapter discovers rather + than knows. + + Extracted rather than inlined because two callers must agree exactly on what + "unaddressed" means: `build_discovery` reports those properties to a + maintainer, and `build_extension_properties` turns them into readings for a + user. A property counted as addressed by one and not the other would either + appear as an entity the diagnostics claim is ignored, or be reported ignored + while a consumer renders it — both of which read as defects in the surface + that disagrees. + """ + addressed = set(_ADDRESSED) + for device in devices: + evse_type = declared_type(device) + if not evse_type.startswith(TYPE_EVSE): + continue + surface = resolve_charge_limit(device) + if surface is None: + continue + for declaration in (surface.limit, surface.ceiling): + if declaration is not None: + addressed.add((evse_type, surface.node, declaration.property_id)) + return addressed + + +def build_discovery(devices: list[DiscoveredDevice]) -> dict[str, DiscoveredMetadata]: + """Metadata rows for every property this tree declares that nothing here reads. + + The runtime half of the declared-but-unread question. A vendored capture can + only answer it for the panel that was captured; a panel in the field that + starts publishing a property fails nothing and tells nobody until someone + recaptures. These rows put the same answer in front of a maintainer for the + panel actually in front of the user. + + Keyed under `DISCOVERY_NAMESPACE`, never as a snapshot field path, because a + consumer's curated inventories are keyed by field path and a discovered row + reaching one of them would read as a produced field nothing renders — which + is the shape of a real defect. The namespace makes the partition one string + test applied once. + + **Declarations only.** A row carries the property's declared unit and + datatype and whether a value has arrived. It never carries the value: these + rows are built to be forwarded in consumer diagnostics, which leave the + machine they were generated on. + + Emitted by this adapter alone. schema_0 builds its metadata from the REST + ``types`` document, which the migration guide describes as the superset + across all hardware rather than what one panel has — so "declared and + unaddressed" there would describe the schema document and could not answer + the question this exists to ask. + """ + addressed = addressed_rows(devices) + + declarations: dict[str, tuple[str | None, str]] = {} + valued: set[str] = set() + for device in devices: + device_type = declared_type(device) + if not device_type: + continue + for node_id, node in declared_nodes(device.description or {}).items(): + for property_id, definition in declared_properties(node).items(): + if is_addressed(addressed, device_type, node_id, property_id): + continue + path = discovery_path(_short_type(device_type), node_id, property_id) + declarations.setdefault( + path, + (optional_str(definition.get("unit")), str(definition.get("datatype") or "string")), + ) + if device.get_property(node_id, property_id) is not None: + valued.add(path) + + return { + path: DiscoveredMetadata(unit=unit, datatype=datatype, retained=path in valued) + for path, (unit, datatype) in declarations.items() + } + + +def _short_type(device_type: str) -> str: + """The device type as the capability catalog and the gap inventories spell it.""" + if device_type.startswith(DEVICE_TYPE_PREFIX): + return device_type[len(DEVICE_TYPE_PREFIX) :] + return device_type + + +def is_addressed(addressed: set[tuple[str, str, str]], device_type: str, node_id: str, property_id: str) -> bool: + """Whether any addressed row covers this declaration, subtypes included. + + Carries `_lookup`'s subtype rule for the same reason it exists there: eBus + device types are hierarchical and a subtype carries its parent's properties, + so a device typed ``X.Y`` is served by a row written for ``X``. Without it a + panel that subtypes its lugs devices would report every mapped lugs property + as newly discovered — which is the false positive that would teach a + maintainer to stop reading this. + """ + return any( + node == node_id and prop == property_id and (device_type == mapped_type or device_type.startswith(f"{mapped_type}.")) + for mapped_type, node, prop in addressed + ) diff --git a/packages/schema-1/src/span_panel_api_schema_1/panel.py b/packages/schema-1/src/span_panel_api_schema_1/panel.py new file mode 100644 index 0000000..34cb74d --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/panel.py @@ -0,0 +1,815 @@ +"""Map the v1.0 device tree onto the panel-level fields of ``SpanPanelSnapshot``. + +Where the flat schema kept everything on one device's nodes, v1.0 spreads the +same information across the panel and its children: the service connection is the +upstream lugs device, feedthrough is the downstream lugs device, and grid state +lives on the MID. + +**The upstream lugs are not always the utility connection point.** A BESS wired +ahead of the main lugs, or an enclosure fed by another enclosure, sits between +the utility and this meter, so the lugs read panel-side flow while the grid +differs by whatever that device contributes or absorbs. `power-flows` 0.3 +qualified its own negation table to say so and named the detection mechanism, and +`lugs_at_service_entrance` carries the answer to the snapshot -- without it a +consumer sees `instant_grid_power_w` and `power_flow_grid` disagree and cannot +tell a topology from a fault. + +**Direction is per-device, and the two rules are opposites.** Everything is +stated in the enclosure's reference frame — power flowing *into* the panel is +positive — so: + +* **Circuits** need flipping (see ``circuits.py``): the panel exports to a load, + so a load reads negative and accumulates ``exported-energy``. +* **Lugs** do not: the panel imports from the grid, so drawing from the grid + reads positive and accumulates ``imported-energy``, which is already the + house's consumption. + +Reading the lugs with the circuit rule would inverting every grid figure while +leaving it plausible, which is why the two are separated here rather than +sharing a helper. +""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, NamedTuple + +from span_panel_api.models import SpanCircuitSnapshot, SpanPcsSnapshot +from span_panel_api_schema_1.const import ( + CLOUD_CONNECTED, + NODE_BREAKER, + NODE_CONNECTION, + NODE_DOOR, + NODE_GRID, + NODE_GRID_FORMING, + NODE_INFO, + NODE_METER, + NODE_PCS, + NODE_POWER_FLOWS, + NODE_SHED, + NODE_SHED_FORECAST, + NODE_STATUS, + PANEL_SIZE_BY_MODEL, + PCS_ACTIVE_SUFFIX, + PCS_ENABLEMENT_SUFFIX, + PCS_LIMIT_SUFFIX, + PROP_ACTIVE, + PROP_ACTIVE_POWER, + PROP_ASSERTED_ISLANDING_STATE, + PROP_BINDING_CONSTRAINT, + PROP_CAPABLE, + PROP_CLOUD_CONNECTION, + PROP_CONFIDENCE, + PROP_ENABLED, + PROP_ETHERNET, + PROP_EXPORTED_ENERGY, + PROP_FED_BY_DEVICE_ID, + PROP_FIRMWARE_VERSION, + PROP_FULL_CHARGE_TIME_TO_PRIORITY_SHED, + PROP_FULL_CHARGE_TOTAL_TIME_REMAINING, + PROP_GRID_FORMING_ENTITY, + PROP_HARDWARE_VERSION, + PROP_IMPORT_LIMIT, + PROP_IMPORTED_ENERGY, + PROP_MODEL, + PROP_POLICY, + PROP_RATING, + PROP_RELAY, + PROP_SERIAL_NUMBER, + PROP_STATE, + PROP_TIME_TO_PRIORITY_SHED, + PROP_TOTAL_TIME_REMAINING, + PROP_VENDOR_NAME, + PROP_VOLTAGE_A, + PROP_VOLTAGE_B, + PROP_WIFI, + PROP_WIFI_SSID, + SHED_POLICY_SOC_PRIORITY_V1, + TYPE_BESS, + TYPE_PV, + UNKNOWN, + UNMAPPED_TAB_PREFIX, +) + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from ebus_sdk.homie import DiscoveredDevice + +_LOGGER = logging.getLogger(__name__) + +# Lugs `meter` exposes per-phase current under these ids; circuits expose a +# single `current`. Same capability type, different property set — v1.0 defines +# capabilities as a semantic namespace rather than a fixed contract. +PROP_CURRENT_A = "current-a" +PROP_CURRENT_B = "current-b" + +PROP_GRID_STATE = "grid-state" +# The MID's islanding answer, and the true successor of the flat schema's +# `bess/grid-state`: same ON_GRID/OFF_GRID vocabulary. Kept next to +# PROP_GRID_STATE deliberately, because the two are easy to confuse and only +# one of them is what an existing consumer means by "grid state". +PROP_ISLANDING_STATE = "islanding-state" +PROP_DIRECTION = "direction" +DIRECTION_UPSTREAM = "UPSTREAM" + + +def text(device: DiscoveredDevice | None, node: str, prop: str, default: str = "") -> str: + if device is None: + return default + value = device.get_property(node, prop) + return default if value is None else str(value) + + +def number(device: DiscoveredDevice | None, node: str, prop: str) -> float | None: + if device is None: + return None + raw = device.get_property(node, prop) + if raw is None or raw == "": + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def integer(device: DiscoveredDevice | None, node: str, prop: str) -> int | None: + """A property the tree declares as `integer`, or `None` when it is not published. + + Separate from `number` rather than casting its result at the call site, + because the two answer different questions. `number` exists for `float` + properties and returns `float`; a caller that wanted an `int` would have to + remember that `int(float(...))` truncates, and a truncating conversion + written once per call site is one that eventually gets written wrong. + + Parsed through `float` first so a publisher that sends `3037.0` for an + integer property still resolves — the datatype is a declaration about the + quantity, and rejecting a decimal point would turn a formatting choice into + a missing entity. A value that is not a number at all yields `None`, which + is the same answer as not publishing: neither is a reading. + """ + raw = number(device, node, prop) + return None if raw is None else int(raw) + + +def flag(device: DiscoveredDevice | None, node: str, prop: str) -> bool: + return text(device, node, prop).strip().lower() == "true" + + +def optional_flag(device: DiscoveredDevice | None, node: str, prop: str) -> bool | None: + """`flag`, but distinguishing "published false" from "not published". + + `flag` collapses both to `False`, which is right for link-state properties where a + missing value means down. It is wrong for a static capability: reporting "cannot form + a grid" for a device that has not said would turn a gap into a claim. + """ + raw = text(device, node, prop).strip().lower() + if raw == "": + return None + return raw == "true" + + +def declares_node(device: DiscoveredDevice | None, node: str) -> bool: + """Whether a device's `$description` declares a capability node at all. + + The presence question a value cannot answer. A capability whose properties + are every one of them legally zero — `pcs` is the worked example — cannot be + detected by reading them, and a consumer that gates entity creation on a + value would delete a switched-off PCS's entities rather than showing it + switched off. + + The `$description` is the right place to ask, per the migration guide's rule + that "the authoritative property set for any capability node is always + declared in that device's `$description`". A node declared with no + properties still counts as declared: that is a degraded publisher, which + `build_field_metadata` reports as `resolved=False`, not absent hardware. + """ + if device is None: + return False + description: dict[str, object] = device.description or {} + nodes = description.get("nodes") + return isinstance(nodes, dict) and node in nodes + + +def panel_size_from_model(model: str) -> int: + """Total breaker spaces for a panel model, or 0 when the model is unknown. + + `info/model` is the only place v1.0 states the panel's size. The flat schema + carried it in the Homie schema's `space` format (`"1:32:1"`, max = 32); the + successor `info/spaces` is a plain string with no format, and the panel + device publishes no size property. + + The highest *occupied* space is not a substitute: it is a lower bound, so a + 40-space panel whose highest occupied slot is 36 would report 36 and every + position above it would silently cease to exist. Since unoccupied positions + are exactly `total - occupied`, that would delete the integration's + unmapped-circuit sensors rather than merely miscount a display value. + + Unknown models return 0 and log, because inventing a size is worse than + reporting none: a wrong total fabricates unmapped positions that are not + there, or hides real ones. + """ + size = PANEL_SIZE_BY_MODEL.get(model.strip().upper()) + if size is None: + if model: + _LOGGER.warning( + "Unknown panel model %r; panel size unavailable and unmapped positions cannot be derived. Known models: %s", + model, + ", ".join(sorted(PANEL_SIZE_BY_MODEL)), + ) + return 0 + return size + + +def panel_model_drift(panel: DiscoveredDevice) -> tuple[str, ...]: + """Models the panel says are valid that we have no size for. + + The panel advertises the model enum as a Homie ``$format`` on + ``info/model``, but nothing in the schema or the SDK states how many spaces + each model has — that half is ours. So the panel can legitimately announce + a model we cannot size, and this is how we find out at connect time rather + than through a user reporting missing positions. + + Same reasoning as the flat adapter's schema-drift detection: the failure is + a silent absence, so it needs a signal that does not depend on anyone + noticing an absence. + """ + definition = panel.get_node_properties(NODE_INFO).get(PROP_MODEL) + if not isinstance(definition, dict): + return () + advertised = str(definition.get("format", "")) + if not advertised: + return () + unknown = [ + value.strip() + for value in advertised.split(",") + if value.strip() and value.strip().upper() not in PANEL_SIZE_BY_MODEL + ] + if unknown: + _LOGGER.warning( + "Panel advertises model(s) %s that this adapter cannot size; " + "unmapped positions would be wrong for such a panel. Known: %s", + ", ".join(unknown), + ", ".join(sorted(PANEL_SIZE_BY_MODEL)), + ) + return tuple(unknown) + + +def build_unmapped_tabs(panel_size: int, occupied: set[int]) -> dict[str, SpanCircuitSnapshot]: + """Synthesise a zero-power entry for every unoccupied breaker position. + + The integration surfaces these as unmapped-circuit sensors, gated by its + own `enable_unmapped_circuit_sensors` option, and builds entity ids from + the circuit id — so the `unmapped_tab_` naming is a compatibility + contract with entities that already exist, not an internal detail. + + Reproducible under v1.0 only because the model gives a true total: the tree + itself lists occupied positions and says nothing about the rest. A panel + whose model is unrecognised yields nothing rather than a guess. + """ + unmapped: dict[str, SpanCircuitSnapshot] = {} + for tab in range(1, panel_size + 1): + if tab in occupied: + continue + circuit_id = f"{UNMAPPED_TAB_PREFIX}{tab}" + unmapped[circuit_id] = SpanCircuitSnapshot( + circuit_id=circuit_id, + name=f"Unmapped Tab {tab}", + relay_state="CLOSED", + instant_power_w=0.0, + produced_energy_wh=0.0, + consumed_energy_wh=0.0, + tabs=[tab], + priority=UNKNOWN, + is_user_controllable=False, + is_sheddable=False, + is_never_backup=False, + ) + return unmapped + + +def find_lugs(devices: list[DiscoveredDevice], upstream: bool) -> DiscoveredDevice | None: + """Locate a lugs device by its declared direction. + + Matched on `info/direction` rather than device id: the ids in the reference + tree (`lugs-upstream`) are the simulator's naming, while the direction + property is what the schema defines. + """ + want = DIRECTION_UPSTREAM + for device in devices: + direction = text(device, NODE_INFO, PROP_DIRECTION).strip().upper() + if not direction: + continue + if (direction == want) is upstream: + return device + return None + + +class _ShedPolicy(NamedTuple): + """What `shed/policy` says, as far as this reader understands it. + + Three fields rather than a parsed document, because a consumer renders three + values beside the shed state: which algorithm is in force, and the two SoC + thresholds that make its behaviour predictable. + """ + + algorithm: str | None + soc_threshold_shed_percent: int | None + soc_threshold_release_percent: int | None + + +_NO_SHED_POLICY = _ShedPolicy(None, None, None) + + +def _shed_policy(raw: str | None) -> _ShedPolicy: + """Parse `shed/policy`, degrading rather than raising at every step. + + The property is a `json` document whose Homie `$format` is the JSON Schema + it conforms to, and that schema is versioned in its own `$id` + (`soc-priority.v1`). Versioning the document rather than the property is the + publisher's way of saying a different algorithm may arrive, so a reader that + assumed this one would misreport the day one did. + + Hence the shape here: the algorithm name is taken from whatever parses, and + the two thresholds only from a document that says it is `soc-priority.v1`. + An unrecognised algorithm keeps its name and yields no thresholds, and the + raw string is retained beside this by the caller -- a consumer can still show + what the panel said, which is strictly more than an exception leaves it. + + Every failure lands on the same answer as "not published", because to a + consumer they are the same event: there is nothing here it can render. + """ + if not raw: + return _NO_SHED_POLICY + try: + document = json.loads(raw) + except ValueError: + _LOGGER.debug("shed/policy is not JSON, keeping the raw value: %r", raw) + return _NO_SHED_POLICY + if not isinstance(document, dict): + return _NO_SHED_POLICY + + algorithm = document.get("algorithm") + algorithm = algorithm if isinstance(algorithm, str) and algorithm else None + if algorithm != SHED_POLICY_SOC_PRIORITY_V1: + # A named algorithm nothing here knows how to read is still worth + # naming: it tells a consumer why the thresholds are absent. + return _ShedPolicy(algorithm, None, None) + + parameters = document.get("parameters") + if not isinstance(parameters, dict): + return _ShedPolicy(algorithm, None, None) + return _ShedPolicy( + algorithm, + _percent(parameters.get("soc-threshold-shed")), + _percent(parameters.get("soc-threshold-release")), + ) + + +def _percent(value: object) -> int | None: + """A declared-`integer` SoC threshold, or `None` for anything that is not one. + + `bool` is excluded explicitly: it is an `int` in Python, and a policy + document carrying `true` would otherwise read as a 1% threshold. + """ + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None + + +class PanelFields: + """Panel-level values gathered from the tree, ready for the snapshot. + + A class rather than a long argument list because the caller assembles a + frozen dataclass with ~30 fields, and passing them positionally is how a + voltage ends up in a current. + """ + + def __init__( + self, + panel: DiscoveredDevice, + upstream_lugs: DiscoveredDevice | None, + downstream_lugs: DiscoveredDevice | None, + mid: DiscoveredDevice | None, + ) -> None: + self.serial_number = text(panel, NODE_INFO, PROP_SERIAL_NUMBER, panel.device_id) + self.firmware_version = text(panel, NODE_INFO, PROP_FIRMWARE_VERSION) + # The enclosure's own build identity, for the device card a consumer + # renders. `None` rather than a default when the panel does not publish + # one: the consumer owns the fallback text it has always shown, and a + # default invented here would replace it with a different invention. + self.vendor_name = text(panel, NODE_INFO, PROP_VENDOR_NAME) or None + self.model = text(panel, NODE_INFO, PROP_MODEL) or None + self.hardware_version = text(panel, NODE_INFO, PROP_HARDWARE_VERSION) or None + self.main_relay_state = text(panel, NODE_STATUS, PROP_RELAY, UNKNOWN) + self.door_state = text(panel, NODE_DOOR, PROP_STATE, UNKNOWN) + + self.eth0_link = flag(panel, NODE_STATUS, PROP_ETHERNET) + self.wlan_link = flag(panel, NODE_STATUS, PROP_WIFI) + self.vendor_cloud = text(panel, NODE_STATUS, PROP_CLOUD_CONNECTION) or None + # v1 exposed a WWAN radio link; v2 has no such property, so the flat + # adapter reported cloud reachability instead. Kept identical here so + # the entity does not change meaning between adapters. + self.wwan_link = self.vendor_cloud == CLOUD_CONNECTED + + self.l1_voltage = number(panel, NODE_METER, PROP_VOLTAGE_A) + self.l2_voltage = number(panel, NODE_METER, PROP_VOLTAGE_B) + rating = number(panel, NODE_BREAKER, PROP_RATING) + self.main_breaker_rating_a = None if rating is None else int(rating) + + self.power_flow_pv = number(panel, NODE_POWER_FLOWS, "pv") + self.power_flow_battery = number(panel, NODE_POWER_FLOWS, "battery") + self.power_flow_grid = number(panel, NODE_POWER_FLOWS, "grid") + self.power_flow_site = number(panel, NODE_POWER_FLOWS, "site") + + # Whether the upstream lugs are the utility connection point, which is not + # a given: a BESS wired ahead of the main lugs, or a panel fed by another + # panel, puts a device between the utility and this meter. Read from the + # lugs' own `connection/fed-by-device-id`, the mechanism `power-flows` 0.3 + # names when it qualifies the `grid` row of its negation table. Empty + # string is the absence -- `text` defaults to it -- and absence is the + # ordinary case. + self.lugs_at_service_entrance = not text(upstream_lugs, NODE_CONNECTION, PROP_FED_BY_DEVICE_ID) + + # No sign flip: the enclosure frame already reports import-positive, which + # is what consumption means here. + # + # The name says grid, and that is only true when the lugs are the service + # entrance. Where they are not, this is the panel's own feed and + # `power_flow_grid` is the site-level figure; `lugs_at_service_entrance` + # above is how a consumer tells the two apart. The reading itself is + # correct in either topology -- it is the label that is conditional. + self.instant_grid_power_w = number(upstream_lugs, NODE_METER, PROP_ACTIVE_POWER) or 0.0 + self.main_meter_energy_consumed_wh = number(upstream_lugs, NODE_METER, PROP_IMPORTED_ENERGY) or 0.0 + self.main_meter_energy_produced_wh = number(upstream_lugs, NODE_METER, PROP_EXPORTED_ENERGY) or 0.0 + self.upstream_l1_current_a = number(upstream_lugs, NODE_METER, PROP_CURRENT_A) + self.upstream_l2_current_a = number(upstream_lugs, NODE_METER, PROP_CURRENT_B) + + self.feedthrough_power_w = number(downstream_lugs, NODE_METER, PROP_ACTIVE_POWER) or 0.0 + self.feedthrough_energy_consumed_wh = number(downstream_lugs, NODE_METER, PROP_IMPORTED_ENERGY) or 0.0 + self.feedthrough_energy_produced_wh = number(downstream_lugs, NODE_METER, PROP_EXPORTED_ENERGY) or 0.0 + self.downstream_l1_current_a = number(downstream_lugs, NODE_METER, PROP_CURRENT_A) + self.downstream_l2_current_a = number(downstream_lugs, NODE_METER, PROP_CURRENT_B) + + # Grid state moved to the MID device, which is where islanding is + # actually decided. Absent when the panel has no MID. + # + # **From `islanding-state`, not from `grid-state`.** The MID publishes + # both, and the names invite exactly the wrong choice: the flat schema's + # `grid_state` was the BESS's `grid-state`, an ON_GRID/OFF_GRID + # islanding answer, and its v1.0 successor is `grid/islanding-state` + # with that same value set. The MID's own `grid/grid-state` is a + # different question — whether the utility supply is UP, DOWN or + # DEGRADED — and is new in v1.0 with no flat equivalent. Matching on + # the property name puts UP where consumers expect ON_GRID: the entity + # keeps its id and its history, and every template comparing it simply + # stops being true. + self.grid_state = text(mid, NODE_GRID, PROP_ISLANDING_STATE) or None + + # `dominant-power-source` split into grid-forming-entity plus + # asserted-islanding-state — two controls on two devices, not one field + # moved — so there is no drop-in successor and this stays None until the + # decided replacement lands. + self.dominant_power_source: str | None = None + # `grid_islandable` is answered by `resolve_grid_islandable` over the BESS's + # inverter children, not from the panel, so it is not a PanelFields concern. + # Kept as an attribute only so nothing that reads it breaks; the snapshot + # takes the resolver's answer. + self.grid_islandable: bool | None = None + # `status/wifi-ssid`, the same value flat published as `core/wifi-ssid`. + # Read here rather than left `None` because the integration surfaces it + # as an attribute today: a v1.0 panel that did not read it lost that + # attribute on upgrade, silently, while every conformance check agreed + # nothing was wrong. + self.wifi_ssid = text(panel, NODE_STATUS, PROP_WIFI_SSID) or None + + # `shed/policy` -- the algorithm the panel sheds by, and its parameters. + self.shed_policy = text(panel, NODE_SHED, PROP_POLICY) or None + policy = _shed_policy(self.shed_policy) + self.shed_policy_algorithm = policy.algorithm + self.shed_soc_threshold_shed_percent = policy.soc_threshold_shed_percent + self.shed_soc_threshold_release_percent = policy.soc_threshold_release_percent + + # Backup-planning forecast. Every field stays `None` when the panel + # publishes no `shed-forecast` node, which is what lets a consumer gate + # entity creation on presence instead of showing a fabricated zero. + self.shed_time_to_priority_shed_min = integer(panel, NODE_SHED_FORECAST, PROP_TIME_TO_PRIORITY_SHED) + self.shed_total_time_remaining_min = integer(panel, NODE_SHED_FORECAST, PROP_TOTAL_TIME_REMAINING) + self.shed_full_charge_time_to_priority_shed_min = integer( + panel, NODE_SHED_FORECAST, PROP_FULL_CHARGE_TIME_TO_PRIORITY_SHED + ) + self.shed_full_charge_total_time_remaining_min = integer( + panel, NODE_SHED_FORECAST, PROP_FULL_CHARGE_TOTAL_TIME_REMAINING + ) + self.shed_forecast_confidence = text(panel, NODE_SHED_FORECAST, PROP_CONFIDENCE) or None + + +class _LimitTriplet(NamedTuple): + """One constraint class's `{limit, enablement, active}` triplet, as published. + + Named rather than a bare tuple because the three members are a float, a + string and a boolean read from three sibling properties, and positional + unpacking at four call sites is how an enablement ends up in an active flag. + """ + + limit_a: float | None + enablement: str | None + active: bool | None + + +def _limit_triplet(panel: DiscoveredDevice, source: str) -> _LimitTriplet: + """Read one amps-native constraint class off the enclosure's `pcs` node. + + Every source in `PCS_LIMIT_SOURCES` publishes the identical + `{-import-limit, -enablement, -active}` shape, which the capability + states as a rule rather than as a coincidence: a vendor "MAY publish further + amps-native limits using the same triplet". Reading them through one + function is what makes a fifth source a one-line addition instead of three. + + All three are optional independently. A publisher that reports a limit and + no enablement is conformant, and reporting `UNCONFIGURED` on its behalf + would invent a configuration state it never claimed. + """ + prefix = f"{source}{PCS_LIMIT_SUFFIX}" + return _LimitTriplet( + limit_a=number(panel, NODE_PCS, prefix), + enablement=text(panel, NODE_PCS, f"{prefix}{PCS_ENABLEMENT_SUFFIX}") or None, + active=optional_flag(panel, NODE_PCS, f"{prefix}{PCS_ACTIVE_SUFFIX}"), + ) + + +def build_pcs(panel: DiscoveredDevice) -> SpanPcsSnapshot | None: + """The enclosure's Power Control System, or `None` when it publishes no `pcs` node. + + Gated on the **declaration**, not on any value, because the capability + defines absence that way: "absence of the `pcs` node means the device does + not run (or participate in) a Power Control System". Every limit in the + reference capture is `0.0` with `UNCONFIGURED` enablement — a PCS that + exists and is switched off — and a value-based gate could not tell that from + a panel with no PCS at all. One is a capability reporting its state; the + other is hardware that is not there. + + Every field stays `None` where the node omits the property. The catalog + marks the system surface `SHOULD` and three of the four constraint classes + `MAY`, so a partial node is conformant firmware rather than a fault, and a + limit defaulted to `0.0` would read as "no import permitted" — the most + alarming reading the property has. + + Enablement and `binding-constraint` are kept as raw wire strings. Both are + enums the publisher may extend through its Homie `$format`, and + `binding-constraint` exists precisely to name a source, so normalising it + onto a set fixed here would discard the extension it was designed to carry. + """ + if not declares_node(panel, NODE_PCS): + return None + + feed = _limit_triplet(panel, "feed") + operator = _limit_triplet(panel, "operator") + off_grid = _limit_triplet(panel, "off-grid") + requested = _limit_triplet(panel, "requested") + + return SpanPcsSnapshot( + enabled=optional_flag(panel, NODE_PCS, PROP_ENABLED), + active=optional_flag(panel, NODE_PCS, PROP_ACTIVE), + import_limit_a=number(panel, NODE_PCS, PROP_IMPORT_LIMIT), + binding_constraint=text(panel, NODE_PCS, PROP_BINDING_CONSTRAINT) or None, + feed_import_limit_a=feed.limit_a, + feed_import_limit_enablement=feed.enablement, + feed_import_limit_active=feed.active, + operator_import_limit_a=operator.limit_a, + operator_import_limit_enablement=operator.enablement, + operator_import_limit_active=operator.active, + off_grid_import_limit_a=off_grid.limit_a, + off_grid_import_limit_enablement=off_grid.enablement, + off_grid_import_limit_active=off_grid.active, + requested_import_limit_a=requested.limit_a, + requested_import_limit_enablement=requested.enablement, + requested_import_limit_active=requested.active, + ) + + +# Matches `schema_0`'s epsilon so the no-MID heuristic answers identically on the two +# adapters — the tier exists precisely for panels where nothing authoritative is +# published, and disagreeing about the threshold would make it schema-dependent. +_GRID_POWER_EPSILON_W = 1.0 + +ISLANDING_ON_GRID = "ON_GRID" +ISLANDING_OFF_GRID = "OFF_GRID" +ASSERTION_NONE = "NONE" + + +def resolve_islanding_state(mid: DiscoveredDevice | None, panel: DiscoveredDevice) -> str | None: + """Islanding state by the recorded precedence, or `None` when nothing can say. + + | tier | condition | source | + | --- | --- | --- | + | 1 | MID `$state` is `ready` and `islanding-state` present | sensed | + | 2 | MID not `ready` | `shed/asserted-islanding-state`, when not `NONE` | + | 3 | no MID at all | `power-flows/grid` heuristic | + | 4 | none of the above | unknown | + + **Tier 2 is the reason the assertion control exists.** When comms to the BESS or MID + are lost and the grid returns, the user asserts the grid is up so the BESS stops + discharging. Declining to read it here would wire the control and then ignore it at + exactly the moment it matters. Nothing is hidden by doing so: the MID is a device, so + it goes *unavailable* in Home Assistant when it stops publishing, and the assertion is + itself visible as the control the user set. + + **Tier 3 never answers `OFF_GRID`, and never asserts on-grid from a missing MID.** An + earlier draft reasoned that no MID means no islanding authority means on-grid. That is + wrong: a missing MID means *SPAN* is not the islanding authority, and says nothing + about whether the site is islanded — a generator-fed island is the plain + counterexample. Grid power flowing is positive evidence of being on-grid; its absence + is not evidence of the opposite. + """ + if mid is not None: + if mid.state == "ready": + sensed = text(mid, NODE_GRID, PROP_ISLANDING_STATE) + if sensed: + return sensed + asserted = text(panel, NODE_SHED, PROP_ASSERTED_ISLANDING_STATE) + if asserted and asserted != ASSERTION_NONE: + return asserted + return None + + grid_power = number(panel, NODE_POWER_FLOWS, "grid") + if grid_power is not None and abs(grid_power) > _GRID_POWER_EPSILON_W: + return ISLANDING_ON_GRID + return None + + +def resolve_dsm_state(islanding: str | None) -> str: + """`dsm_state` in flat's vocabulary, read rather than derived. + + Flat inferred this from `bess/grid-state`, then `dominant-power-source`, then grid + power. v1.0 states it, so the heuristic tiers collapse into whatever + `resolve_islanding_state` could establish. Kept for entity stability: it adds nothing + over the MID's own value, and it is the entity a user already has. + """ + if islanding == ISLANDING_ON_GRID: + return "DSM_ON_GRID" + if islanding == ISLANDING_OFF_GRID: + return "DSM_OFF_GRID" + return UNKNOWN + + +def resolve_run_config( + mid: DiscoveredDevice | None, + islanding: str | None, + device_types: Mapping[str, str], +) -> str: + """`current_run_config`, from the grid-forming entity where one is published. + + | condition | result | + | --- | --- | + | `grid-forming-entity == "GRID"` | `PANEL_ON_GRID` | + | resolves to a device of class `bess` | `PANEL_BACKUP` | + | resolves to any other device | `PANEL_OFF_GRID` | + | absent, empty, or unresolvable | falls through below | + + This is the part that gets *better* than flat. Flat guessed `PANEL_BACKUP` versus + `PANEL_OFF_GRID` from `dominant-power-source`; v1.0 names the forming device and its + class is recoverable from the tree, so the distinction becomes authoritative. + + Falling through, the answer degrades honestly rather than guessing: an on-grid + islanding answer still gives `PANEL_ON_GRID`, but off-grid cannot be split into + backup versus off-grid without knowing what is forming the grid, so it reports + unknown rather than picking one. + """ + forming = text(mid, NODE_GRID, PROP_GRID_FORMING_ENTITY).strip() + if forming: + if forming.upper() == "GRID": + return "PANEL_ON_GRID" + resolved = device_types.get(forming) + if resolved == TYPE_BESS: + return "PANEL_BACKUP" + if resolved is not None: + return "PANEL_OFF_GRID" + + if islanding == ISLANDING_ON_GRID: + return "PANEL_ON_GRID" + return UNKNOWN + + +def resolve_grid_islandable(inverters: Sequence[DiscoveredDevice]) -> bool | None: + """Whether any inverter can form a grid — flat's `grid-islandable`, relocated. + + `grid-forming/capable` is *"Static hardware capability: does this inverter support + grid-forming operation at all?"*, the same kind of permanent statement flat made with + *"Capable of operating with power while disconnected from the grid."* BESS model 0.14 + puts it on the `inverter` child, so the panel-level answer is the disjunction: a panel + does not island, its DER does, and flat expressed a property of the DER as a property + of the enclosure. + + `None`, not `False`, when nothing publishes it. Absence means unknown — reporting + "cannot island" for a panel that simply has not told us would turn a gap into a claim, + and the integration declines to create the entity on `None`, which is the honest + outcome. No producer publishes this today: the emitter does not model the BESS child + roles, so this reads `None` against every capture we have. + """ + answers = [optional_flag(inverter, NODE_GRID_FORMING, PROP_CAPABLE) for inverter in inverters] + known = [answer for answer in answers if answer is not None] + if not known: + return None + return any(known) + + +# Flat's `dominant-power-source` enum, keyed by the device class v1.0 names instead. +# `GENERATOR` has no row because the device-type registry has no generator: flat's value +# came from the panel computing a source class, v1.0 names an actual device, and there is +# no generator device to name yet. One row when there is. +_POWER_SOURCE_BY_TYPE: dict[str, str] = { + TYPE_BESS: "BATTERY", + TYPE_PV: "PV", +} + + +def resolve_dominant_power_source( + mid: DiscoveredDevice | None, + device_types: Mapping[str, str], +) -> str | None: + """Flat's `dominant_power_source`, from the MID's grid-forming entity. + + The integration's entity for this field is already named `grid_forming_entity`, so + v1.0's `grid/grid-forming-entity` is the same concept it has always shown — not a + successor to negotiate. What changed is the encoding: flat published a closed enum of + source *classes*, v1.0 names the actual *device*. Dereferencing the id against the + tree recovers the class, so the entity keeps its value space and nothing comparing + against `BATTERY` stops matching. + + **Anything unresolvable becomes `UNKNOWN`, which is in flat's enum already.** A device + id naming something outside this tree, or a class with no row above, cannot escape as + a raw id — the device-type registry itself instructs consumers to tolerate unknown + `$type` values, and this is what tolerating one looks like from a consumer. + + The precision v1.0 adds — *which* battery, distinguishable when a site has two — is + not discarded, it is surfaced beside this rather than inside it, as + `SpanMidSnapshot.grid_forming_device_name`. Absorb the change in the state entity that + exists, surface the addition separately: a changed value breaks automations silently, + a new field cannot. + + **No MID at all means `GRID`, and that is an elimination rather than a guess.** + A commissioned MID is what SPAN has to island with, so its absence rules out every + other value this field can take. `BATTERY` needs a BESS, and a BESS brings a MID. + `PV` cannot form a grid on its own — anything that can is a grid-forming inverter, + which is a MID. `NONE` describes a panel supplying nothing, which is a panel that is + not publishing. That leaves a generator, which is two cases rather than one and only + one of them reaches here. A generator wired through a MID is named by that MID, so + the branch above answers and this one never runs. A generator with no MID interface + is what SPAN treats as the grid, and it is the only generator an install with no MID + can have. So the elimination holds now and keeps holding if MID-integrated generators + arrive: they bring a MID, and a MID is answered above. + + A site genuinely running off-grid without storage is not a counterexample; it goes + dark at sunset. + + This deliberately does **not** follow `resolve_islanding_state`, which refuses the + same shortcut. The two answer different questions and the counterexample that defeats + it there is the one that supports it here: a generator-fed island is islanded — so + inferring on-grid from a missing MID would be wrong — while its grid-forming entity + really is what SPAN calls the grid. Islanding is a safety fact about separation; this + is a class of source. + + It is also no worse than flat, which is the bar. Flat could not see an uninterfaced + generator either and published `GRID` regardless; a panel upgrading to v1.0 keeps the + answer it has been giving rather than losing it to the loss of a property. + + `None` only when a MID exists and has not answered. That is genuinely unknown — there + is an islanding authority and it has not said — and is distinct from there being none. + """ + if mid is None: + return "GRID" + + forming = text(mid, NODE_GRID, PROP_GRID_FORMING_ENTITY).strip() + if not forming: + return None + if forming.upper() == "GRID": + return "GRID" + return _POWER_SOURCE_BY_TYPE.get(device_types.get(forming, ""), UNKNOWN) + + +def resolve_grid_forming_device_name( + mid: DiscoveredDevice | None, + device_names: Mapping[str, str], +) -> str | None: + """The readable name of whatever is forming the grid, or `None` when it is the grid. + + The wire value is a Homie device id -- `sim-40t-001-SIM-BESS-40T-001`. That means + nothing to someone reading a dashboard: it is not a Home Assistant device id, and an + opaque string is worse than no string. Homie's `$description.name` is the device's + own display name (`Battery`, `Solar`, `SPAN Drive - Garage`), which is what a person + would recognise, so that is what gets surfaced. + + `None` when the grid is forming (there is no device to name), when no MID publishes + an answer, or when the id resolves to nothing -- the raw id is still on + `grid_forming_entity` for anyone who needs the literal value. + """ + forming = text(mid, NODE_GRID, PROP_GRID_FORMING_ENTITY).strip() + if not forming or forming.upper() == "GRID": + return None + return device_names.get(forming) diff --git a/packages/schema-1/src/span_panel_api_schema_1/py.typed b/packages/schema-1/src/span_panel_api_schema_1/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md new file mode 100644 index 0000000..1249232 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/README.md @@ -0,0 +1,10 @@ +# Reference payloads + +Shipped as package data and read through `span_panel_api_schema_1.reference_payloads`, never by path — a consumer that installs this distribution gets these bytes, and a consumer that pins a version gets the bytes that version's parser was written against. + +## `parent_child_tree.json` + +Retained topics captured off a `panel_sim` parent/child tree for a 40-space panel: 13 devices — the panel, both lugs, a BESS with its MID, a PV, an EVSE, and the circuits. + +Shape is `{device_id: {topic: payload}}`, every value a string, exactly as the broker retains them. `$description` is therefore a **JSON string**, not a nested object; `device_from_topics` replays it the way the transport does. `bess-mid` is typed +`energy.ebus.device.mid`, not `.bess` — a consumer filtering the tree by type marker has to expect the MID to survive a BESS filter. diff --git a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py new file mode 100644 index 0000000..05c360a --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/__init__.py @@ -0,0 +1,81 @@ +"""Reference wire payloads for the parent/child schema, shipped as package data. + +The counterpart to `span_panel_api.reference_payloads`, and here rather than +there for the reason that decides every placement in this workspace: a retained +topic tree is only interpretable by the parser that speaks its vocabulary, and +the eBus SDK that turns it back into devices is this distribution's dependency +alone. The bootstrap ships the document it fetches; this ships the tree it +cannot read. + +`devices_from_tree` is exported alongside the capture because a tree is not +directly usable — every consumer of it has to replay the retained topics +through `DiscoveredDevice` first, and that replay is the parser's own knowledge +of how the transport feeds it. Shipping the capture without the replay just +moves a copy of this module's logic into every consumer, which is the burden +the package data exists to remove. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from importlib import resources +import json + +from ebus_sdk.homie import DiscoveredDevice + +type RetainedTopicTree = Mapping[str, Mapping[str, str]] +"""A retained-topic capture: device id -> topic -> payload, all strings. + +`$description` is a JSON *string*, not a nested object — it is stored on the +wire exactly as the panel publishes it, and `update_description` parses it. +""" + +_PACKAGE = "span_panel_api_schema_1.reference_payloads" +_PARENT_CHILD_TREE = "parent_child_tree.json" + +_DEFAULT_STATE = "ready" +_DOMAIN = "ebus" + + +def parent_child_tree() -> RetainedTopicTree: + """The captured retained topics of a full 40-space panel. + + Thirteen devices: the panel, both lugs, a BESS with its MID, a PV, an EVSE + and the circuits — enough that a consumer can check what each device class + does and does not declare, including the absences. + """ + text = resources.files(_PACKAGE).joinpath(_PARENT_CHILD_TREE).read_text(encoding="utf-8") + tree: object = json.loads(text) + if not isinstance(tree, dict): + raise TypeError(f"{_PARENT_CHILD_TREE} is not a JSON object") + return tree + + +def device_from_topics(device_id: str, topics: Mapping[str, str]) -> DiscoveredDevice: + """Rebuild one discovered device from its retained topics. + + The same sequence the transport performs on a broker replay: describe, + state, then every non-`$` topic as a `node/property` value. A device with no + `$state` retained is treated as ready, which is what the transport assumes + for a device that described itself. + """ + device = DiscoveredDevice(device_id, _DOMAIN) + device.update_description(topics["$description"]) + device.update_state(topics.get("$state", _DEFAULT_STATE)) + for topic, value in topics.items(): + if topic.startswith("$"): + continue + node, _, prop = topic.partition("/") + if prop: + device.update_property(node, prop, value) + return device + + +def devices_from_tree(tree: RetainedTopicTree) -> list[DiscoveredDevice]: + """Rebuild every device in a capture. + + Takes the tree rather than reading it, so a consumer can filter the capture + first — dropping the BESS to model a panel that has none, say — and still + build devices the same way. + """ + return [device_from_topics(device_id, topics) for device_id, topics in tree.items()] diff --git a/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json new file mode 100644 index 0000000..0aee1b8 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json @@ -0,0 +1,234 @@ +{ + "0ab966b95f92a6a51ec548485aa85f54": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "15", + "info/name": "Kitchen Lights", + "info/spaces": "1", + "load-shed/priority": "UNKNOWN", + "meter/active-power": "-121.0", + "meter/current": "1.0083333333333333", + "meter/exported-energy": "2.0166666666666666", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "1", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "573066aaddd7b75114c4563ce3af18c4": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "30", + "connection/feeds-device-id": "pv", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.pv", + "info/name": "Solar Inverter", + "info/spaces": "36,38", + "load-shed/priority": "NEVER", + "meter/active-power": "8500.0", + "meter/current": "35.416666666666664", + "meter/exported-energy": "0.0", + "meter/imported-energy": "141.66666666666666", + "pcs/managed": "false", + "pcs/priority": "5", + "switch/relay": "CLOSED", + "switch/relay-controllable": "false", + "switch/relay-requester": "NONE" + }, + "62d0e03897b337b57101aae82f1e9ba2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Garage", + "info/spaces": "32,34", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "-2410.0", + "meter/current": "10.041666666666666", + "meter/exported-energy": "40.166666666666664", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "3", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "bess": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/firmware-version": "example-bess/v0.1.0", + "info/model": "Example BESS", + "info/nameplate-capacity": "13.5", + "info/part-number": "SPN-BESS-001", + "info/serial-number": "EXAMPLE-BESS-40T-001", + "info/vendor-name": "Span", + "meter/active-power": "-3500.0", + "soc/soc": "50.410493827160494", + "soc/soe": "6.805416666666667", + "status/communication-state": "OK" + }, + "bess-mid": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}", + "$state": "ready", + "grid/grid-forming-entity": "GRID", + "grid/grid-state": "UP", + "grid/islanding-state": "ON_GRID", + "info/firmware-version": "example-mid/v0.1.0", + "info/hardware-version": "rev1", + "info/model": "SPAN MID", + "info/serial-number": "EXAMPLE-BESS-40T-001-mid", + "info/vendor-name": "Span" + }, + "d3724e0d660ba506aa79c1cafe5d1181": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "1", + "breaker/rating": "20", + "info/name": "Garage Outlet", + "info/spaces": "2", + "load-shed/priority": "UNKNOWN", + "meter/active-power": "-122.0", + "meter/current": "1.0166666666666666", + "meter/exported-energy": "2.033333333333333", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "2", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "evse": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "CHARGING", + "switch/lock-state": "LOCKED" + }, + "evse-2": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "config/max-charge-current": "32", + "config/user-max-charge-current": "32", + "info/firmware-version": "example/v0.1.0", + "info/model": "SPAN Drive", + "info/part-number": "SPN-DRV-001", + "info/serial-number": "SIM-EVSE-example-40t-001-2", + "info/vendor-name": "SPAN", + "meter/advertised-current": "32.0", + "status/status": "AVAILABLE", + "switch/lock-state": "UNLOCKED" + }, + "example-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}", + "$state": "ready", + "breaker/rating": "200", + "door/state": "CLOSED", + "info/data-model-version": "1.0", + "info/firmware-version": "example/v0.1.0", + "info/hardware-version": "rev2", + "info/model": "MAIN_40", + "info/serial-number": "example-40t-001", + "info/vendor-name": "Span", + "meter/voltage-a": "120.0", + "meter/voltage-b": "120.0", + "pcs/active": "false", + "pcs/binding-constraint": "NONE", + "pcs/enabled": "false", + "pcs/feed-import-limit": "0.0", + "pcs/feed-import-limit-active": "false", + "pcs/feed-import-limit-enablement": "UNCONFIGURED", + "pcs/import-limit": "0.0", + "pcs/off-grid-import-limit": "0.0", + "pcs/off-grid-import-limit-active": "false", + "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", + "pcs/operator-import-limit": "0.0", + "pcs/operator-import-limit-active": "false", + "pcs/operator-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit": "0.0", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "power-flows/battery": "-3500.0", + "power-flows/grid": "-2347.0", + "power-flows/pv": "8500.0", + "power-flows/site": "2653.0", + "shed-forecast/confidence": "HIGH", + "shed-forecast/full-charge-time-to-priority-shed": "3038", + "shed-forecast/full-charge-total-time-remaining": "4320", + "shed-forecast/time-to-priority-shed": "3037", + "shed-forecast/total-time-remaining": "4320", + "shed/asserted-islanding-state": "NONE", + "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}", + "status/cloud-connection": "CONNECTED", + "status/ethernet": "true", + "status/postal-code": "94103", + "status/relay": "CLOSED", + "status/time-zone": "America/Los_Angeles", + "status/wifi": "true", + "status/wifi-ssid": "example-wifi" + }, + "fe8b85c15bc9610c1b8b4ebc6f82488d": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "breaker/poles": "2", + "breaker/rating": "50", + "connection/feeds-device-id": "evse-2", + "connection/feeds-device-status": "OK", + "connection/feeds-device-type": "energy.ebus.device.evse", + "info/name": "SPAN Drive - Driveway", + "info/spaces": "35,37", + "load-shed/priority": "OFF_GRID", + "meter/active-power": "0.0", + "meter/current": "0.0", + "meter/exported-energy": "0.0", + "meter/imported-energy": "0.0", + "pcs/managed": "true", + "pcs/priority": "4", + "switch/relay": "CLOSED", + "switch/relay-controllable": "true", + "switch/relay-requester": "NONE" + }, + "lugs-downstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/direction": "DOWNSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "141.66666666666666", + "meter/imported-energy": "44.21666666666666" + }, + "lugs-upstream": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "connection/fed-by-device-id": "bess", + "connection/fed-by-device-status": "OK", + "connection/fed-by-device-type": "energy.ebus.device.bess", + "info/direction": "UPSTREAM", + "meter/active-power": "-5847.0", + "meter/current-a": "46.46666666666666", + "meter/current-b": "46.474999999999994", + "meter/exported-energy": "141.66666666666666", + "meter/imported-energy": "44.21666666666666" + }, + "pv": { + "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}", + "$state": "ready", + "info/firmware-version": "example-pv/v0.1.0", + "info/model": "IQ8PLUS-72-2-US", + "info/nominal-power": "10000.0", + "info/vendor-name": "Enphase" + } +} diff --git a/packages/schema-1/src/span_panel_api_schema_1/snapshot.py b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py new file mode 100644 index 0000000..6086337 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/snapshot.py @@ -0,0 +1,299 @@ +"""Assemble a ``SpanPanelSnapshot`` from a discovered v1.0 device tree. + +Sorting the tree into roles is the one job here, and it is done by **declared +device type**, never by device id. The reference tree's ids (``bess``, ``pv``, +``lugs-upstream``) are the simulator's naming; real firmware uses whatever it +likes, and the type string is what the schema defines. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from span_panel_api.models import ExtensionSubject, SpanPanelSnapshot +from span_panel_api_schema_1.adoption import build_adopted_devices +from span_panel_api_schema_1.circuits import build_circuit +from span_panel_api_schema_1.const import ( + NODE_INFO, + PROP_MODEL, + PROP_SERIAL_NUMBER, + TYPE_BESS, + TYPE_CIRCUIT, + TYPE_EVSE, + TYPE_INVERTER, + TYPE_LUGS, + TYPE_MID, + TYPE_PV, +) +from span_panel_api_schema_1.description import device_type +from span_panel_api_schema_1.devices import ( + build_battery, + build_evse, + build_mid, + build_pv, + feed_circuit_ids, + feed_connection_statuses, +) +from span_panel_api_schema_1.extension import build_extension_properties +from span_panel_api_schema_1.field_metadata import addressed_rows +from span_panel_api_schema_1.panel import ( + PanelFields, + build_pcs, + build_unmapped_tabs, + find_lugs, + panel_size_from_model, + resolve_dominant_power_source, + resolve_dsm_state, + resolve_grid_islandable, + resolve_islanding_state, + resolve_run_config, + text, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + from ebus_sdk.homie import DiscoveredDevice + + +class TreeRoles: + """The tree sorted into the roles a snapshot needs. + + Matching is prefix-based for lugs, because firmware may declare either the + base ``…device.lugs`` type with a ``direction`` property or a subtyped + ``…device.lugs.upstream`` — the flat adapter already had to handle both + conventions, and there is no reason to assume v1.0 settled it. + """ + + def __init__(self, devices: list[DiscoveredDevice]) -> None: + self.circuits: list[DiscoveredDevice] = [] + self.lugs: list[DiscoveredDevice] = [] + self.evse: list[DiscoveredDevice] = [] + self.bess: DiscoveredDevice | None = None + self.pv: DiscoveredDevice | None = None + self.mid: DiscoveredDevice | None = None + + for device in devices: + declared = device_type(device) + if declared == TYPE_CIRCUIT: + self.circuits.append(device) + elif declared.startswith(TYPE_LUGS): + self.lugs.append(device) + elif declared == TYPE_EVSE: + self.evse.append(device) + elif declared == TYPE_BESS and self.bess is None: + self.bess = device + elif declared == TYPE_PV and self.pv is None: + self.pv = device + elif declared == TYPE_MID and self.mid is None: + self.mid = device + + +def build_snapshot(panel: DiscoveredDevice, children: list[DiscoveredDevice], ready_since: float = 0.0) -> SpanPanelSnapshot: + """Build a full snapshot from the panel and its descendants.""" + roles = TreeRoles(children) + upstream = find_lugs(roles.lugs, upstream=True) + downstream = find_lugs(roles.lugs, upstream=False) + fields = PanelFields(panel=panel, upstream_lugs=upstream, downstream_lugs=downstream, mid=roles.mid) + + feeds = feed_circuit_ids(roles.circuits) + # The other half of the same circuit-side records: which DER each circuit + # feeds, and what the enclosure says about the link to it. Read once here + # and handed to whichever DER it names, exactly as `feeds` is. + feed_statuses = feed_connection_statuses(roles.circuits) + # A DER's device type decides how its feeding circuit is labelled, so the + # circuit inherits it — matching the flat adapter, where the same circuit + # reports device_type "pv" rather than "circuit". + der_type_by_circuit = { + circuit_id: kind + for kind, device in (("pv", roles.pv), *(("evse", e) for e in roles.evse)) + if device is not None and (circuit_id := feeds.get(device.device_id)) + } + + circuits = {} + # Paired with their snapshot key as they are built, for `extension.py`. The + # key a multi-instance subject carries has to be the one the snapshot map + # uses, and this loop is where a circuit's is decided -- deriving it a + # second time downstream would be a second implementation free to drift. + circuit_subjects: list[tuple[DiscoveredDevice, ExtensionSubject]] = [] + for circuit in roles.circuits: + snapshot = build_circuit(circuit, device_type=der_type_by_circuit.get(circuit.device_id, "circuit")) + circuits[snapshot.circuit_id] = snapshot + circuit_subjects.append((circuit, ExtensionSubject(kind="circuit", instance_key=snapshot.circuit_id))) + + occupied = {tab for circuit in circuits.values() for tab in circuit.tabs} + # Unoccupied positions are `total - occupied`, so this is only meaningful + # when the model gave a real total. An unknown model yields size 0 and no + # unmapped entries rather than a fabricated set. + panel_size = panel_size_from_model(text(panel, NODE_INFO, PROP_MODEL)) + circuits.update(build_unmapped_tabs(panel_size, occupied)) + + # Owners are every device that can claim a DER through a `connection` node. + owners = [*roles.lugs, *roles.circuits, panel] + + # Grid answers are read from the MID rather than derived, per the recorded + # decision. `device_types` resolves `grid-forming-entity` to a device class, which + # is what makes PANEL_BACKUP distinguishable from PANEL_OFF_GRID authoritatively + # instead of guessed from a power source the way flat had to. + device_types = {device.device_id: device_type(device) for device in children} + device_names: dict[str, str] = {} + for device in children: + description: dict[str, object] = device.description or {} + name = description.get("name") + if name: + device_names[device.device_id] = str(name) + inverters = [device for device in children if device_type(device) == TYPE_INVERTER] + islanding = resolve_islanding_state(roles.mid, panel) + + # Vendor extensions on devices this adapter *does* model. The pairing is + # built here, where each subject's snapshot key is already decided: the + # singletons key on nothing, the EVSEs on the harmonised key their snapshot + # map uses, the circuits on the ids collected above. + evse_subjects = [ + (device, ExtensionSubject(kind="evse", instance_key=key)) for device, key in harmonised_evse_keys(roles.evse).items() + ] + # **Lugs are their own subject, keyed by direction.** Pairing both with the + # `panel` subject makes the subject non-unique: a consumer keys an identity on + # `(kind, instance_key, node/property)`, and the two lugs devices run the + # same firmware, so a vendor extension on one is the *expected* case of a + # vendor extension on both -- two wire addresses collapsing onto one + # identity, with whichever sorted first winning. `find_lugs` matches + # direction rather than device id for the reason it documents, and the same + # reasoning keys the subject: ids in the reference tree are the simulator's + # naming, while `info/direction` is what the schema defines. A lugs device + # declaring no direction is left unpaired rather than keyed on something + # unstable -- its properties stay in discovery, which is where an + # unidentifiable device belongs. + lugs_subjects = [ + (device, ExtensionSubject(kind="lugs", instance_key=key)) + for key, device in (("upstream", upstream), ("downstream", downstream)) + if device is not None + ] + singleton_subjects = [ + (device, ExtensionSubject(kind=kind)) + for kind, device in ( + ("panel", panel), + ("battery", roles.bess), + ("mid", roles.mid), + ("pv", roles.pv), + ) + if device is not None + ] + extension_properties = build_extension_properties( + [*singleton_subjects, *lugs_subjects, *evse_subjects, *circuit_subjects], + addressed_rows(children), + ) + + return SpanPanelSnapshot( + serial_number=fields.serial_number, + firmware_version=fields.firmware_version, + main_relay_state=fields.main_relay_state, + instant_grid_power_w=fields.instant_grid_power_w, + lugs_at_service_entrance=fields.lugs_at_service_entrance, + feedthrough_power_w=fields.feedthrough_power_w, + main_meter_energy_consumed_wh=fields.main_meter_energy_consumed_wh, + main_meter_energy_produced_wh=fields.main_meter_energy_produced_wh, + feedthrough_energy_consumed_wh=fields.feedthrough_energy_consumed_wh, + feedthrough_energy_produced_wh=fields.feedthrough_energy_produced_wh, + # Read, not derived. Flat had to infer both from `dominant-power-source` + # plus grid power because nothing stated them; v1.0 states them on the MID, + # so the multi-signal heuristic is gone and only the no-MID tier remains a + # heuristic. The user-visible value set is unchanged. + dsm_state=resolve_dsm_state(islanding), + current_run_config=resolve_run_config(roles.mid, islanding, device_types), + door_state=fields.door_state, + # The panel has no proximity sensor property; the flat adapter reports + # authenticated-and-ready, and the same holds here. + proximity_proven=True, + uptime_s=int(time.monotonic() - ready_since) if ready_since > 0.0 else 0, + eth0_link=fields.eth0_link, + wlan_link=fields.wlan_link, + wwan_link=fields.wwan_link, + panel_size=panel_size, + dominant_power_source=resolve_dominant_power_source(roles.mid, device_types), + grid_state=fields.grid_state, + grid_islandable=resolve_grid_islandable(inverters), + l1_voltage=fields.l1_voltage, + l2_voltage=fields.l2_voltage, + main_breaker_rating_a=fields.main_breaker_rating_a, + wifi_ssid=fields.wifi_ssid, + vendor_cloud=fields.vendor_cloud, + vendor_name=fields.vendor_name, + model=fields.model, + hardware_version=fields.hardware_version, + shed_policy=fields.shed_policy, + shed_policy_algorithm=fields.shed_policy_algorithm, + shed_soc_threshold_shed_percent=fields.shed_soc_threshold_shed_percent, + shed_soc_threshold_release_percent=fields.shed_soc_threshold_release_percent, + power_flow_pv=fields.power_flow_pv, + power_flow_battery=fields.power_flow_battery, + power_flow_grid=fields.power_flow_grid, + power_flow_site=fields.power_flow_site, + shed_time_to_priority_shed_min=fields.shed_time_to_priority_shed_min, + shed_total_time_remaining_min=fields.shed_total_time_remaining_min, + shed_full_charge_time_to_priority_shed_min=fields.shed_full_charge_time_to_priority_shed_min, + shed_full_charge_total_time_remaining_min=fields.shed_full_charge_total_time_remaining_min, + shed_forecast_confidence=fields.shed_forecast_confidence, + upstream_l1_current_a=fields.upstream_l1_current_a, + upstream_l2_current_a=fields.upstream_l2_current_a, + downstream_l1_current_a=fields.downstream_l1_current_a, + downstream_l2_current_a=fields.downstream_l2_current_a, + circuits=circuits, + battery=build_battery(roles.bess, owners), + pv=build_pv(roles.pv, feeds, upstream, downstream, feed_statuses=feed_statuses), + mid=build_mid(roles.mid, device_names), + # Gated on the node being declared, not on any value: every limit this + # capability publishes is legally `0.0`, so there is no reading that can + # distinguish a switched-off PCS from an absent one. See `build_pcs`. + pcs=build_pcs(panel), + # Every child whose type nothing above sorts into a role. Built from the + # same `children` the roles were sorted from, so a type dropping out of + # `TreeRoles` surfaces here rather than vanishing from both. + adopted_devices=build_adopted_devices(children), + extension_properties=extension_properties, + evse={ + key: build_evse(device, feeds, node_id=key, feed_statuses=feed_statuses) + for device, key in harmonised_evse_keys(roles.evse).items() + }, + ) + + +def harmonised_evse_keys(evse_devices: Sequence[DiscoveredDevice]) -> dict[DiscoveredDevice, str]: + """Key each EVSE by its serial, which is what flat firmware keys it by. + + Public because the command topics need the inverse of it: a caller holds a + snapshot key and the wire is addressed by device id, and rebuilding that + correspondence anywhere else is how a control ends up writing to the wrong + charger. + + **This library is the harmonisation layer.** The integration builds an EVSE + entity's `unique_id` and its device-registry `identifiers` from what it finds + here, so a key that changes between schemas orphans a user's charger and stands a + duplicate up beside it. Presenting the same handle for the same physical device is + this seam's job, not the integration's. + + On real flat firmware the EVSE **node id is the Drive's serial**. Confirmed on a + live panel in SpanPanel/span#214: the reporter's topic is + `ebus/5//`, their diagnostics show the snapshot keyed + `"evse": {"": ...}`, and the whole thread turns on that node id being + what the `unique_id` is built from. `schema_0` writes `result[node_id]` verbatim, + so against firmware it is already serial-keyed without knowing it. + + v1.0 names the same device `-`, the proxied form, so stripping to + the serial reproduces flat's key exactly. The proxy model prescribes the same + thing independently: `devices/proxy.md` says a proxied device id is *not* stable + across the proxy-to-native transition, and that "consumers that need + cross-transition stable identity use `info/serial-number`". + + **The flat simulator does not do this**, and it briefly cost us the wrong design. + It names EVSE nodes `evse` / `evse-2` -- positional slots no panel publishes -- + which made this look like an ordering problem, needing a rule to reconstruct + firmware's enumeration and needing SPAN to confirm that rule. There was no + ordering problem; there was an unfaithful fixture. + + A device with no serial keeps its v1.0 device id. Inventing an ordinal is the + thing this function exists to avoid, and an unkeyable EVSE is better left + obviously distinct than quietly merged with another. + """ + return {device: (text(device, NODE_INFO, PROP_SERIAL_NUMBER) or device.device_id) for device in evse_devices} 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 new file mode 100644 index 0000000..3745b2a --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/spec_lock.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://ebus.energy/schemas/ebus-spec.json", + "role": "consumer", + "firmware": { + "family": "spanos2", + "range": "r202633+", + "data_model_version": ">=1.0,<2.0" + }, + "spec_repo": "https://github.com/electrification-bus/specification", + "synced_commit": "7ee7ca93b19c3de3d61be44f01887ba9557dd803", + "synced_date": "2026-08-21", + "framework": "0.9", + "peer": { + "repo": "https://github.com/SpanPanel/panelbench", + "ref": "main", + "role": "publisher", + "commit": "e7579104a04dfb381ba89bfa1cbbe76e2c2d2058", + "synced_commit": "7ee7ca93b19c3de3d61be44f01887ba9557dd803", + "firmware_range": "r202633+", + "fixtures": { + "tree": "tests/conformance/fixtures/golden_tree.json", + "wire": "tests/conformance/fixtures/golden_wire.json" + } + }, + "implements": { + "capabilities": { + "breaker": "0.2", + "charge-limit": "0.1", + "connection": "0.2", + "door": "0.1", + "grid": "0.2", + "grid-forming": "0.2", + "info": "0.3", + "load-shed": "0.3", + "meter": "0.4", + "pcs": "0.3", + "power-flows": "0.3", + "shed": "0.2", + "shed-forecast": "0.1", + "soc": "0.2", + "status": "0.1", + "switch": "0.3" + }, + "devices": { + "distribution-enclosure": "0.14", + "circuit": "0.4", + "bess": "0.15" + }, + "registries": { + "capability-types": "0.19", + "device-types": "0.5" + } + }, + "notes": "role=consumer: span-panel-api-schema-1 parses the Homie 5 distribution-enclosure tree that SPAN firmware r202633+ publishes, and is hot-loaded by span-panel-api through the span_panel_api.schema_adapters entry-point group. It is the consumer counterpart to SpanPanel/panelbench (role=publisher), which is pinned to the same synced_commit; the shared anchor between them is the firmware range above, not this commit, because the spec says what a device class MAY publish while a panel publishes one specific tree. PROVENANCE: packages/schema-1/spec/catalogs/*.json are byte copies of the specification's capabilities/ at synced_commit, and spec/registries/device-types.md is a byte copy of that registry. They are verified by byte comparison when a specification checkout is available (EBUS_SPEC_DIR); the comparison skips when none is, so the conformance check below always runs while the provenance check is opportunistic. Never hand-edit anything under spec/ -- an edit makes the byte comparison meaningless. WHAT IS VENDORED AND WHY SO LITTLE: only the 16 capability catalogs this adapter addresses, because a consumer needs the vocabulary it reads and nothing else. Datatypes, units and formats are deliberately NOT taken from these catalogs at runtime: the adapter reads them from each device's $description, because the same capability exposes different properties on different device classes (meter is voltage on the panel, power and energy on a circuit, both currents on lugs) and the catalog is the superset across all hardware rather than a statement about this panel. The vendored copies exist to be checked against, not to be parsed in production. ABSTRACT UNITS: four catalog properties carry unit: energy, a dimension rather than a unit (conventions/property-json.md 0.2). Being description-driven makes this adapter correct here by construction, and a test asserts it rather than leaving it to luck. EXTENSIONS: SPAN publishes properties no catalog defines -- per-phase meter readings, panel status links, circuit spaces. Those are legal under the specification and are enumerated as an explicit allowlist in tests/test_schema_one_conformance.py, so a name that is absent from the catalog has to be declared deliberately rather than assumed. One whole *node* is an extension: SPAN's EVSE declares `config` with `max-charge-current` / `user-max-charge-current`, and no capability of that name exists upstream -- the catalogued surface is `charge-limit` 0.1 (`installer-max` / `owner-limit`), which is vendored above and which this adapter reads whenever a charger declares it. Both spellings are read because the device's $description is the authority on which one it publishes, and no capture can settle it: the panels we can reach carry no EVSE. PINNING RULE: pin what this adapter actually reads AND that exists in the current spec. pv/evse/mid/lugs have no standalone versioned device model upstream and are covered transitively as child device_types of distribution-enclosure 0.12, so they are not separately pinned." +} diff --git a/packages/schema-1/src/span_panel_api_schema_1/transport.py b/packages/schema-1/src/span_panel_api_schema_1/transport.py new file mode 100644 index 0000000..ea86025 --- /dev/null +++ b/packages/schema-1/src/span_panel_api_schema_1/transport.py @@ -0,0 +1,194 @@ +"""The seam that lets `ebus_sdk.Controller` parse a tree it owns no socket for. + +`Controller` normally holds an MQTT client and subscribes as it walks a device +tree — for the root first, then per child as each announces. A `SchemaAdapter` +cannot work that way: the transport builds the parser *before* the connection +exists and never hands it one, so a parser has no way to subscribe to anything. + +It turns out not to need one. `Controller` is given a transport that only +*records* its subscriptions, and the adapter asks the transport layer for one +broad subscription up front — the same thing the flat adapter does with +``ebus/5/{serial}/#``. Every message then arrives through +``SchemaAdapter.handle_message`` and is routed here to whichever SDK callback +asked for it. + +Two consequences, both load-bearing: + +* **The adapter stays connection-free**, so it works under a protocol that + hands it messages rather than a socket. +* **Reconnect needs no special handling.** The transport re-subscribes the same + static list on every reconnect, the broker replays the retained tree, and the + SDK repopulates from it. There is no hand-wired ``resync`` hook to forget — + which was the failure mode most likely to go unnoticed, because it produces + stale readings rather than an error. + +One thing the single subscription does have to make up for. `Controller` learns +which topics it wants *as it goes*: the root's routes exist from construction, +a child's only once the root's description has been parsed and the root has +reached ready. But one wire subscription delivers the whole tree in a single +burst, in whatever order the broker replays its retained store — and a broker +is under no obligation to hand back the parent before its children. A message +that arrives before the SDK asks for it is therefore held, and delivered the +moment the matching route is registered. Under a real per-device subscription +the SDK would have got that value as a retained message at subscribe time, so +holding it reproduces what it would otherwise have seen rather than inventing +anything. Dropping it instead is silent and total: an entire panel parses as +zero circuits. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from paho.mqtt.client import topic_matches_sub + +if TYPE_CHECKING: + from collections.abc import Callable + +_LOGGER = logging.getLogger(__name__) + +# Ceiling on messages held for a route that has not appeared. Sized well past a +# full panel — a 48-space enclosure with every DER runs to a few thousand +# topics — so reaching it means messages are arriving for a subtree the SDK +# will never ask about, and holding more would be a slow leak in a process that +# runs for months. +MAX_HELD_MESSAGES = 4096 + + +class ControllerRoutes: + """Record `Controller`'s subscriptions and route messages back to them. + + Structurally satisfies `ebus_sdk.MqttControllerTransport`. Receive-only by + design — see :meth:`publish`. + """ + + def __init__(self) -> None: + # Insertion-ordered; dispatch walks it in reverse — see dispatch(). + self._routes: dict[str, Callable[[str, bytes], None]] = {} + # Last payload per topic that matched no route yet, keyed by topic so a + # newer value supersedes an older one — the same last-value-wins rule + # the broker applies to the retained message this stands in for. + self._held: dict[str, str] = {} + self._discarded = 0 + + # -- MqttControllerTransport ------------------------------------------- + + def publish(self, topic: str, data: str, qos: int = 1, retain: bool = False) -> None: + """Not supported, and deliberately loud about it. + + Commands do not travel this way. `SchemaAdapter` exposes + ``set_circuit_relay_topic`` and friends, and the transport publishes to + the topic it is handed — so an adapter never needs a socket to command a + panel, and neither does this class. + + Raising beats a silent no-op: a dropped command leaves the panel in the + state the user was trying to change, with the UI reporting they changed + it. + """ + # Named exactly as `MqttClient.publish` names them, so a real client + # satisfies the same protocol this class does. Discarded rather than + # renamed, because nothing here is ever sent. + del topic, data, qos, retain + raise NotImplementedError( + "ControllerRoutes is receive-only. Publish through the adapter's " + "set_*_topic methods, which the transport layer sends for you." + ) + + def subscribe(self, sub: str, param: Any, qos: int = 1) -> None: # pylint: disable=unused-argument + """Record the callback for `sub`. Nothing reaches the wire. + + `param` is the SDK's name for the callback, and this signature mirrors + `MqttClient.subscribe` exactly — including its `Any` — so a real + `MqttClient` still satisfies the same protocol this class does. + + `qos` is accepted and ignored, which is why it is disabled above rather + than removed: the protocol fixes the signature, and quality of service + is a property of the one wire subscription the transport layer makes on + the adapter's behalf, not of a route recorded in a dict. + """ + # Pop before insert: assigning an existing key updates the value but + # keeps the key's original position, which would leave a re-registered + # pattern behind whatever was added after it in dispatch's match order. + self._routes.pop(sub, None) + self._routes[sub] = param + self._release(sub, param) + + def unsubscribe(self, sub: str) -> None: + """Forget the callback for `sub`. + + The broad wire subscription stays. Messages for a device the SDK has + dropped simply stop matching a route, and dispatch discards them. + """ + self._routes.pop(sub, None) + + # -- our side ---------------------------------------------------------- + + def dispatch(self, topic: str, payload: str) -> None: + """Deliver one message to the callback of the route that matches. + + Walked most-recent-first, which is defensive rather than currently + required. Tree-rooted discovery subscribes four **device-scoped** + patterns per device — `$state`, `$description`, `+/+`, `+/+/$target` + (`Controller._subscribe_device_topics`) — which cannot overlap each + other or another device's, so today exactly one route matches any topic. + + Pinned anyway because the SDK's wildcard discovery mode subscribes + `/5/+/$state`, overlapping every per-device `$state`. Under + insertion order that would hand a device's state to the wildcard + handler — a silent misattribution rather than an error. Preferring the + most recently recorded route costs nothing while overlap does not + occur, and is correct if it ever does. + + A topic matching no route is held rather than dropped, because the + route it belongs to may simply not exist yet — see the module + docstring. The wire subscription is broader than the SDK's interest by + construction, so some held messages are never claimed; the ceiling + keeps that from growing without bound. + + The SDK hands callbacks `bytes`; the transport hands us `str`. + """ + for sub in reversed(self._routes): + if topic_matches_sub(sub, topic): + self._routes[sub](topic, payload.encode()) + return + self._hold(topic, payload) + + def _hold(self, topic: str, payload: str) -> None: + """Keep a message for a route that has not been registered yet.""" + if topic not in self._held and len(self._held) >= MAX_HELD_MESSAGES: + self._discarded += 1 + if self._discarded == 1: + _LOGGER.warning( + "Holding %d unrouted topics; discarding %r and further new ones. " + "The device tree is larger than expected, or the broker carries " + "topics outside it.", + MAX_HELD_MESSAGES, + topic, + ) + return + self._held[topic] = payload + + def _release(self, sub: str, callback: Callable[[str, bytes], None]) -> None: + """Deliver everything held that this newly registered route matches. + + Re-entrant by necessity: a released `$description` makes the SDK + subscribe to that device's children, which releases their held messages + in turn, so a whole tree unfolds from one root subscription. The + candidate list is therefore taken up front and each entry re-checked, + since a nested release may have claimed it already. + """ + for topic in [held for held in self._held if topic_matches_sub(sub, held)]: + payload = self._held.pop(topic, None) + if payload is not None: + callback(topic, payload.encode()) + + @property + def routes(self) -> tuple[str, ...]: + """The recorded subscription patterns, most recent last. Diagnostics only.""" + return tuple(self._routes) + + @property + def held(self) -> int: + """Messages waiting for a route to be registered. Diagnostics only.""" + return len(self._held) diff --git a/pyproject.toml b/pyproject.toml index 8765872..99eb581 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "2.6.4" +version = "3.0.0" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} @@ -8,22 +8,56 @@ authors = [ readme = "README.md" license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.10,<4.0" +requires-python = ">=3.14,<4.0" dependencies = [ - "httpx>=0.28.1", + # Bounded, and the bound is load-bearing. httpx 1.0 is an API rewrite that + # removes `AsyncClient`, and 1.0.dev1..dev4 are on PyPI now. The bound was + # first set when every distribution here was a prerelease and `pip install + # --pre` was the prescribed verb, which made those dev releases resolvable; + # that particular exposure ended at 3.0.0, but the ceiling stays, because a + # caller may still pass `--pre` for its own reasons and because a ceiling + # cannot be added to a version already published. `paho-mqtt` has been + # bounded from the start; this was the one unbounded runtime dependency. + "httpx>=0.28.1,<1.0", "paho-mqtt>=2.0.0,<3.0.0", "pyyaml>=6.0.0", ] +[project.optional-dependencies] +# Not runtime dependencies: this distribution still registers no adapter and +# imports none, and `scripts/verify_adapterless_install.py` holds that line. +# These exist so `pip install -U "span-panel-api[schema-0,schema-1]"` has a +# correct upgrade path, because the dependency arrow runs the other way -- an +# adapter floors on the bootstrap, the bootstrap requires no adapter -- so +# upgrading the bootstrap alone leaves stale adapter wheels that +# `_derive_required_members` then rejects at discovery, with pip reporting +# success. An extra is the only thing pip can act on, and extras cannot be added +# to a version after it is published. +# +# Floors are stable versions deliberately. A specifier that names a prerelease +# is pip's own signal that prereleases are acceptable for that requirement, so a +# `>=1.0.0b5` floor here would leave a released install willing to resolve a +# future beta of the adapter without anyone asking for one. +schema-0 = ["span-panel-api-schema-0>=1.0.0"] +schema-1 = ["span-panel-api-schema-1>=1.0.0"] + [project.urls] Homepage = "https://github.com/SpanPanel/span-panel-api" Issues = "https://github.com/SpanPanel/span-panel-api/issues" -[project.scripts] -format-markdown = "scripts.format_markdown:main" +# No [project.entry-points."span_panel_api.schema_adapters"] block here, and that +# absence is the point of Phase 1: this distribution registers no adapter and +# imports none. Adapters are separate distributions that register themselves — +# see packages/schema-0. Adding a block here would silently re-couple the +# bootstrap to a parser and undo the split. [dependency-groups] dev = [ + # The adapter is a dev dependency, never a runtime one: the bootstrap must + # remain installable without it. It is here so the test suite exercises the + # two distributions together, which is the configuration users will run. + "span-panel-api-schema-0", + "span-panel-api-schema-1", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "pytest-cov", @@ -34,7 +68,12 @@ dev = [ "mypy", "pylint", "radon", - "twine", + # 7.0 or newer: hatchling emits `Metadata-Version: 2.5` and twine 6.2 rejects + # it as invalid. The build backend is resolved fresh at build time from an + # unpinned `[build-system] requires`, so the metadata version moves without + # anything in this repository changing -- which is how a green CI turned red + # on a commit that touched two changelogs and a dependency floor. + "twine>=7.0", "vulture>=2.14", "types-pyyaml>=6.0.12.20250915", "coverage", @@ -44,8 +83,41 @@ dev = [ requires = ["hatchling"] build-backend = "hatchling.build" +# One repo, independent distributions. The adapter is a workspace member so the +# test suite runs against both halves together, while `uv build` in each +# directory still produces a distribution that can be installed on its own — +# which is what the adapter-less install test depends on. +[tool.uv.workspace] +members = ["packages/*"] + +[tool.uv.sources] +span-panel-api-schema-0 = { workspace = true } +span-panel-api-schema-1 = { workspace = true } + [tool.hatch.build.targets.wheel] -packages = ["src/span_panel_api", "scripts"] +# `scripts/` is deliberately absent. Shipping it put `scripts/__init__.py` at the +# top level of every consumer's site-packages, so an unrelated `import scripts` +# in a Home Assistant venv resolved to this distribution, and it installed a +# markdown formatter as a console script for every user. It is a dev tool; +# `scripts/format.sh` runs it by path. +packages = ["src/span_panel_api"] + +[tool.hatch.build.targets.sdist] +# Explicit, because the default swept the whole tree: the root sdist contained +# packages/schema-0 and packages/schema-1 in full, contradicting the one +# invariant this distribution is built around -- that it registers no adapter and +# imports none. Anyone auditing the bootstrap sdist found both parsers inside it. +# Anchored with a leading slash: an unanchored "README.md" is a glob that matches +# at any depth, which pulled each adapter's own README, CHANGELOG and pyproject +# back in and left the bootstrap sdist still naming both parsers. +include = [ + "/src/span_panel_api", + "/tests", + "/README.md", + "/CHANGELOG.md", + "/LICENSE", + "/pyproject.toml", +] [tool.ruff] line-length = 125 @@ -94,9 +166,19 @@ ignore = [ force-sort-within-sections = true combine-as-imports = true split-on-trailing-comma = false +# Every distribution in this workspace is first-party. Stated explicitly because +# the repo has more than one source root, and inference from a single `src/` +# would classify the adapter packages as third-party. Each new adapter package +# has to be added here -- schema_1 was missing for several releases, which is +# the failure this comment exists to prevent and did not. +known-first-party = ["span_panel_api", "span_panel_api_schema_0", "span_panel_api_schema_1"] [tool.mypy] -python_version = "3.13" +# The declared floor. Type-checking as of the oldest supported interpreter is +# what catches a call into stdlib that only exists further up; checking as of a +# newer one would let it through. Floor and ceiling are the same version today, +# so this must move with `requires-python` rather than being left behind. +python_version = "3.14" strict = true warn_return_any = true warn_unused_configs = true @@ -123,7 +205,11 @@ ignore_missing_imports = true [tool.coverage.run] data_file = ".local_coverage_data" -source = ["src/span_panel_api"] +source = [ + "src/span_panel_api", + "packages/schema-0/src/span_panel_api_schema_0", + "packages/schema-1/src/span_panel_api_schema_1", +] omit = [ "tests/*", "*/tests/*", @@ -162,6 +248,11 @@ exclude_dirs = ["tests", "scripts"] [tool.pylint.main] load-plugins = ["pylint.extensions.no_self_use"] extension-pkg-allow-list = [] +# Every source root in the workspace, so cross-package imports resolve no matter +# which files a run happens to cover. Without this, pylint only finds +# `span_panel_api` when a run also includes a file under `src/` — so committing +# an adapter package on its own reports import-error for imports that are fine. +init-hook = "import sys; sys.path[:0] = ['src', 'packages/schema-0/src', 'packages/schema-1/src']" ignore-paths = [ "^tests/.*", "^scripts/.*", @@ -169,10 +260,21 @@ ignore-paths = [ [tool.pylint.messages_control] disable = [ + # Import order is enforced by ruff's isort rules (lint select "I"), which + # knows both workspace source roots via known-first-party. pylint has no + # equivalent setting — only known-standard-library and known-third-party — + # so it classifies span_panel_api_schema_0 as third-party and disagrees with + # ruff on every adapter module. One authority for import order; ruff is the + # one that can be told the truth about this layout. + "wrong-import-order", "missing-module-docstring", "missing-class-docstring", "missing-function-docstring", "too-few-public-methods", + # The transport implements four protocols, so its public surface is set by + # how many the composition asks for rather than by anything a split would + # improve. Every sibling in this family is already off for the same reason. + "too-many-public-methods", "too-many-arguments", "too-many-instance-attributes", "too-many-locals", diff --git a/scripts/capture_flat_reference.py b/scripts/capture_flat_reference.py new file mode 100644 index 0000000..695fdd5 --- /dev/null +++ b/scripts/capture_flat_reference.py @@ -0,0 +1,140 @@ +"""Capture the flat simulator's retained surface, without a broker. + +Produces `tests/fixtures/flat_wire.json`, the schema_0 side of the Phase 3 +classification. + +Run it from the **simulator's** environment, not this one — it imports the flat +emitter, whose `aiomqtt` dependency this repo does not carry: + + cd ../simulator + uv run python ../span-panel-api/scripts/capture_flat_reference.py \\ + ../span-panel-api/tests/fixtures/flat_wire.json + +`SIMULATOR_DIR` overrides where the checkout is looked for; it defaults to a +`simulator` directory beside this repo. + +Substitutes the transport rather than reassembling the emitter: `_AiomqttPublisher` +is swapped for a recorder and `start_clone` then runs its ordinary path — real +manifest builder, real BESS and load-shedding config, real Emitter, real +`start()`. Only the socket is different, which is the point of the capture. +Reassembling instead would prove less than it appears to, because a capture taken +through different wiring than a real panel uses is a capture of the wiring. + +The `mqttPublishFail` warnings this prints are the SDK's own redundant publish +path finding no paho client. Harmless: the lifecycle publishes `$state` and +`$description` through the injected transport, and both land in the capture. The +run asserts that rather than trusting it. + +**Where the vendored bytes came from.** SpanPanel/simulator +`826be47d123137e63dfa232e411e868721f92f6d` (main, 2026-08-19, version 1.0.16). +Recorded as a commit rather than as "the frozen simulator", because that phrase is +what let this go stale: the capture was taken at v1.0.15 and read as permanent, and +1.0.16 then made an EVSE's node id its drive serial and forced that serial +lower-case — the flat half of a change panelbench made on the v1.0 side the same +week. For nine days the two vendored captures named the same charger differently, +and the test that compares them was the only thing that could say so. + +So: re-run this whenever the flat simulator publishes something new, and update the +commit above in the same change. A capture without a commit records where the bytes +came from as a guess. + +**Shape-stable, not byte-stable.** `noise_factor` and an advancing clock move 53 +of the 559 topics on every run; the device set and the topic set do not move at +all. That is enough, because the classification this feeds compares which fields +are *populated*, never their values — and it is the same property the +parent/child capture has, for the same reason. +""" + +import asyncio +import json +import os +import pathlib +import sys + +_REPO = pathlib.Path(__file__).resolve().parent.parent +SIM = pathlib.Path(os.environ.get("SIMULATOR_DIR", _REPO.parent / "simulator")) +if not (SIM / "src").is_dir(): + raise SystemExit(f"no simulator checkout at {SIM}; set SIMULATOR_DIR") +sys.path.insert(0, str(SIM / "src")) + +from span_panel_simulator.emitter_adapter import runtime as flat_runtime # noqa: E402 +from span_panel_simulator.engine import DynamicSimulationEngine # noqa: E402 + +CONFIG = SIM / "configs" / "default_MAIN_40.yaml" +OUT = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path("flat_capture.json") + + +class RecordingPublisher: + """Satisfies the emitter's duck-typed MQTT interface, keeping last-wins state. + + Last-wins because that is what a broker's retained store holds, and therefore + what a consumer replays on connect. + """ + + def __init__(self, **kwargs: object) -> None: + self.retained: dict[str, bytes] = {} + self._kwargs = kwargs + LAST.append(self) + + async def connect(self) -> None: + return None + + async def disconnect(self) -> None: + return None + + def is_connected(self) -> bool: + return True + + async def publish( + self, topic: str, payload: bytes, qos: int = 0, retain: bool = False + ) -> None: + del qos + if retain: + self.retained[topic] = payload if isinstance(payload, bytes) else str(payload).encode() + + async def subscribe(self, topic: str) -> None: + del topic + return None + + +LAST: list[RecordingPublisher] = [] + + +def as_capture(retained: dict[str, bytes]) -> dict[str, dict[str, str]]: + """Regroup flat topics into the device-keyed shape a consumer sees.""" + devices: dict[str, dict[str, str]] = {} + for topic, payload in sorted(retained.items()): + parts = topic.split("/") + if len(parts) < 4: + continue + devices.setdefault(parts[2], {})["/".join(parts[3:])] = payload.decode() + return devices + + +async def main() -> None: + flat_runtime._AiomqttPublisher = RecordingPublisher # type: ignore[assignment] + + engine = DynamicSimulationEngine(config_path=CONFIG) + await engine.initialize_async() + + runtime = await flat_runtime.start_clone(engine) + await flat_runtime.publish_tick(runtime) + + recorder = LAST[-1] + capture = as_capture(recorder.retained) + + # The SDK's redundant publish path fails silently against no paho client, so + # check the two topics a consumer cannot reach ready without. + body = capture.get("sim-40t-001", {}) + missing = [key for key in ("$description", "$state") if key not in body] + if missing: + raise SystemExit(f"capture is unusable: {missing} never landed") + + OUT.write_text(json.dumps(capture, indent=2, sort_keys=True) + "\n") + + topics = sum(len(v) for v in capture.values()) + print(f"devices: {len(capture)} topics: {topics} $state={body['$state']!r}") + print("device ids:", sorted(capture)) + + +asyncio.run(main()) diff --git a/scripts/capture_live_flat.py b/scripts/capture_live_flat.py new file mode 100644 index 0000000..3374c43 --- /dev/null +++ b/scripts/capture_live_flat.py @@ -0,0 +1,147 @@ +"""Capture the retained tree from a live SPAN panel running flat firmware. + +Produces `tests/fixtures/live_flat_wire.json`, which is **gitignored**. That file +carries the panel's serial (which is also its MQTT username), the household's +circuit names and real consumption, so it stays on the machine that took it. What +gets committed is the verdict of `tests/test_live_flat_differential.py`, never the +capture. + +Reads credentials from `.env` (see `.env.example`): + + LIVE_PANEL_HOST LIVE_PANEL_PORT LIVE_PANEL_USERNAME LIVE_PANEL_PASSWORD + +Run: + + uv run python scripts/capture_live_flat.py + +Why it exists: the flat side of the migration classification is the frozen +simulator, a proxy for firmware. This measures the proxy. Where the panel and the +simulator agree, the simulator is attested; where they differ, the panel is +ground truth and the simulator is wrong. + +TLS with verification off, matching how the panel is reached in practice — it +presents a self-signed certificate. +""" + +import json +import os +import pathlib +import ssl +import sys +import threading +import time + +import paho.mqtt.client as mqtt + +_REPO = pathlib.Path(__file__).resolve().parent.parent +OUT = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else _REPO / "tests" / "fixtures" / "live_flat_wire.json" + +# Stop when nothing new has arrived for this long. A retained store replays in a +# burst on subscribe, so silence is the signal that the burst is over. +QUIET_SECONDS = 5.0 +MAX_SECONDS = 60.0 + + +def _load_dotenv() -> None: + path = _REPO / ".env" + if not path.exists(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def main() -> int: + _load_dotenv() + + host = os.environ.get("LIVE_PANEL_HOST", "") + port = int(os.environ.get("LIVE_PANEL_PORT") or 8883) + username = os.environ.get("LIVE_PANEL_USERNAME", "") + password = os.environ.get("LIVE_PANEL_PASSWORD", "") + + missing = [ + name + for name, value in ( + ("LIVE_PANEL_HOST", host), + ("LIVE_PANEL_USERNAME", username), + ("LIVE_PANEL_PASSWORD", password), + ) + if not value + ] + if missing: + print(f"missing in .env: {', '.join(missing)} — see .env.example") + return 2 + + retained: dict[str, str] = {} + last_message = [time.monotonic()] + connected = threading.Event() + failed: list[str] = [] + + def on_connect(client: mqtt.Client, _u: object, _f: object, reason: object, _p: object = None) -> None: + code = getattr(reason, "value", reason) + if code != 0: + failed.append(f"connect refused: {reason}") + connected.set() + return + # The panel publishes its whole tree under its own serial. + client.subscribe(f"ebus/5/{username}/#", qos=1) + connected.set() + + def on_message(_c: object, _u: object, message: mqtt.MQTTMessage) -> None: + if message.retain: + retained[message.topic] = message.payload.decode(errors="replace") + last_message[0] = time.monotonic() + + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="span-flat-capture") + client.username_pw_set(username, password) + client.tls_set(cert_reqs=ssl.CERT_NONE) + client.tls_insecure_set(True) + client.on_connect = on_connect + client.on_message = on_message + + print(f"connecting to {host}:{port} …") + client.connect(host, port, keepalive=30) + client.loop_start() + + if not connected.wait(timeout=20): + client.loop_stop() + print("timed out waiting for CONNACK") + return 1 + if failed: + client.loop_stop() + print(failed[0]) + return 1 + + started = time.monotonic() + while time.monotonic() - started < MAX_SECONDS: + if retained and time.monotonic() - last_message[0] > QUIET_SECONDS: + break + time.sleep(0.25) + + client.loop_stop() + client.disconnect() + + if not retained: + print("connected but received no retained messages; is the topic prefix right?") + return 1 + + devices: dict[str, dict[str, str]] = {} + for topic, payload in sorted(retained.items()): + parts = topic.split("/") + if len(parts) < 4: + continue + devices.setdefault(parts[2], {})["/".join(parts[3:])] = payload + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(devices, indent=2, sort_keys=True) + "\n") + + topics = sum(len(v) for v in devices.values()) + print(f"devices: {len(devices)} topics: {topics}") + print(f"written to {OUT} (gitignored)") + return 0 + + +raise SystemExit(main()) diff --git a/scripts/format.sh b/scripts/format.sh index 0fd6c1d..dc46bbe 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -20,6 +20,6 @@ uv run ruff check src/ \ --exclude=src/span_panel_api/generated_client/** # Format markdown files -uv run format-markdown +uv run python scripts/format_markdown.py echo "✅ Formatting complete!" diff --git a/scripts/verify_adapterless_install.py b/scripts/verify_adapterless_install.py new file mode 100644 index 0000000..fbdbd98 --- /dev/null +++ b/scripts/verify_adapterless_install.py @@ -0,0 +1,87 @@ +"""Verify the bootstrap distribution works with no adapter installed. + +This is the acceptance check for the Phase 1 packaging split, and it cannot be +written as a unit test: the thing under test *is* the installed distribution +metadata — which wheel carries the entry point, and whether the bootstrap's +import graph reaches a parser. A test running in the development workspace +always has the adapter importable, so it can never observe the failure this +guards against. + +Run it in a virtualenv that has ONLY span-panel-api installed: + + uv venv /tmp/bootstrap-only + VIRTUAL_ENV=/tmp/bootstrap-only uv pip install dist/span_panel_api-*.whl + VIRTUAL_ENV=/tmp/bootstrap-only uv run --no-project \ + python scripts/verify_adapterless_install.py + +Exits non-zero with a description of the first failure. +""" + +from __future__ import annotations + +import sys + + +def _fail(message: str) -> None: + print(f"FAIL: {message}", file=sys.stderr) + raise SystemExit(1) + + +def main() -> None: + # 1. The transport must import. Before the split this raised + # ModuleNotFoundError, because mqtt/__init__ and mqtt/client both reached + # into _impl/schema_0 at module scope. + try: + import span_panel_api # noqa: F401 + from span_panel_api.mqtt.client import SpanMqttClient + except ModuleNotFoundError as exc: + _fail(f"bootstrap import reaches an adapter package: {exc}") + + # 2. No adapter should be registered. If one is, the bootstrap wheel is + # still carrying the entry point and the split did not actually happen. + from span_panel_api.adapters import DEFAULT_ADAPTER_KEY, installed_adapter_keys + + if keys := installed_adapter_keys(): + _fail(f"bootstrap-only install registers adapters {keys}; the entry point did not move") + + # 3. Constructing a client must still work — only building a parser needs an + # adapter. This is what keeps the failure at an actionable point. + from span_panel_api.exceptions import SpanPanelAdapterMissingError + from span_panel_api.mqtt.models import MqttClientConfig + + client = SpanMqttClient( + "panel.local", + "SERIAL123", + MqttClientConfig(broker_host="broker.local", username="u", password="p"), + ) + + # 4. Building a parser must raise the named error, not an opaque one, and + # must say which adapter was wanted. A flat schema is used because that + # is the case a bootstrap-only install is expected to fail on: every + # panel in the field today reports no data-model-version, so dispatch + # asks for the default key and finds nothing providing it. + from span_panel_api.models import V2HomieSchema + + flat_schema = V2HomieSchema( + firmware_version="spanos2/r202603/05", + types_schema_hash="sha256:0000000000000000", + types={"energy.ebus.device.circuit": {"space": {"datatype": "integer", "format": "1:32:1"}}}, + ) + + try: + client._build_adapter(flat_schema) # pylint: disable=protected-access + except SpanPanelAdapterMissingError as exc: + if exc.needed != DEFAULT_ADAPTER_KEY: + _fail(f"error names adapter {exc.needed!r}, expected {DEFAULT_ADAPTER_KEY!r}") + if exc.available: + _fail(f"error reports installed adapters {exc.available} in a bootstrap-only install") + except Exception as exc: # pylint: disable=broad-exception-caught + _fail(f"expected SpanPanelAdapterMissingError, got {type(exc).__name__}: {exc}") + else: + _fail("building a parser with no adapter installed did not raise") + + print(f"OK: span-panel-api {span_panel_api.__version__} imports and fails by name with no adapter installed") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_reconnect.py b/scripts/verify_reconnect.py new file mode 100644 index 0000000..ef9c523 --- /dev/null +++ b/scripts/verify_reconnect.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +"""Verify that a live MQTT session recovers from a broker outage. + +The bridge's reconnect and rebuild machinery has unit coverage against a mocked +paho client, which proves the control flow. What it cannot prove is the part +only a broker can answer: that after a real socket drop the client +re-subscribes, the broker replays its retained tree, and the parser repopulates +to the same panel it described before. + +A severable TCP passthrough sits between the client and the broker, so the +outage is a real network failure — the broker keeps running and keeps its +retained state, exactly as when an integration loses its route to the panel. +Cutting closes the listener as well as the live sockets, so reconnect attempts +during the outage are refused rather than left hanging. + +Two outages are exercised, because the client recovers from them differently: + + brief Restored at once, so the reconnect loop succeeds before the + rebuild threshold. The parser instance survives, and has to + absorb a second delivery of the retained tree it already holds. + + sustained Held past MQTT_FULL_REBUILD_AFTER_FAILURES, so the bridge rebuilds + its paho client and the transport swaps in a *fresh* parser. + Recovery then comes entirely from retained state. + +Usage — flat schema against the SPAN simulator (TLS, real CA re-fetch): + + uv run python scripts/verify_reconnect.py \ + --serial sim-40t-001 \ + --panel-host 127.0.0.1 --panel-http-port 8081 \ + --broker-host 127.0.0.1 --broker-port 18883 \ + --broker-username span --broker-password + +Usage — parent/child schema against a plain broker seeded from the captured +tree. schema_1 registers no entry point yet, so its factory is named outright: + + uv run python scripts/verify_reconnect.py \ + --serial example-40t-001 \ + --broker-host 127.0.0.1 --broker-port 1883 --no-tls \ + --data-model-version 1.0 \ + --adapter span_panel_api_schema_1:SchemaOneAdapter \ + --seed packages/schema-1/src/span_panel_api_schema_1/reference_payloads/parent_child_tree.json + +Exits non-zero if any check fails. +""" + +from __future__ import annotations + +import argparse +import asyncio +from collections.abc import Callable +import contextlib +from dataclasses import dataclass, field +import importlib +import json +from pathlib import Path +import socket +import sys +import time +from typing import TYPE_CHECKING + +from span_panel_api.exceptions import SpanPanelStaleDataError +from span_panel_api.models import SpanPanelSnapshot, V2HomieSchema +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.const import MQTT_FULL_REBUILD_AFTER_FAILURES +from span_panel_api.mqtt.models import MqttClientConfig + +if TYPE_CHECKING: + from span_panel_api.protocol import SchemaAdapter + +# How long to allow for each stage. The rebuild threshold is three failures +# with 1s/2s/4s backoff, so a sustained outage needs headroom past ~7s. +DISCONNECT_TIMEOUT_S = 15.0 +REBUILD_TIMEOUT_S = 45.0 +RECOVERY_TIMEOUT_S = 60.0 +# Retained messages arrive in a burst on re-subscribe. Ongoing traffic is only +# distinguishable from that burst once it has drained. +BURST_DRAIN_S = 3.0 +LIVENESS_WINDOW_S = 5.0 + + +# --------------------------------------------------------------------------- +# The severable link +# --------------------------------------------------------------------------- + + +class SeverableLink: + """A TCP passthrough to the broker that can be cut and restored.""" + + def __init__(self, target_host: str, target_port: int) -> None: + self._target_host = target_host + self._target_port = target_port + self.port = _free_port() + self._server: asyncio.Server | None = None + self._live: set[asyncio.StreamWriter] = set() + + async def open(self) -> None: + """Start accepting connections on the reserved port.""" + self._server = await asyncio.start_server(self._handle, "127.0.0.1", self.port, reuse_address=True) + + async def cut(self) -> None: + """Refuse new connections and drop every live one. + + Sockets are closed before the listener is awaited: ``wait_closed`` also + waits for the handlers still pumping those sockets, so closing in the + other order deadlocks. + """ + for writer in list(self._live): + with contextlib.suppress(OSError): + writer.close() + self._live.clear() + server, self._server = self._server, None + if server is not None: + server.close() + with contextlib.suppress(Exception): + await asyncio.wait_for(server.wait_closed(), timeout=5.0) + + async def close(self) -> None: + await self.cut() + + async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + upstream_reader, upstream_writer = await asyncio.open_connection(self._target_host, self._target_port) + except OSError: + writer.close() + return + self._live.update({writer, upstream_writer}) + try: + await asyncio.gather( + self._pump(reader, upstream_writer), + self._pump(upstream_reader, writer), + ) + finally: + self._live.difference_update({writer, upstream_writer}) + + async def _pump(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + while chunk := await reader.read(65536): + writer.write(chunk) + await writer.drain() + except (OSError, asyncio.CancelledError): + pass + finally: + with contextlib.suppress(OSError): + writer.close() + + +def _free_port() -> int: + """Reserve a port number the link can rebind to after each cut.""" + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- + + +@dataclass +class Report: + """Accumulated check results.""" + + checks: list[tuple[str, bool, str]] = field(default_factory=list) + + def check(self, name: str, ok: bool, detail: str = "") -> bool: + self.checks.append((name, ok, detail)) + print(f" [{'PASS' if ok else 'FAIL'}] {name}{f' — {detail}' if detail else ''}") + return ok + + @property + def failed(self) -> list[str]: + return [name for name, ok, _ in self.checks if not ok] + + +# --------------------------------------------------------------------------- +# Snapshot comparison +# --------------------------------------------------------------------------- + + +def _fingerprint(snapshot: SpanPanelSnapshot) -> dict[str, object]: + """Structure that must survive an outage unchanged. + + Deliberately excludes readings: power and energy are expected to move while + the client is away, and demanding they match would test the panel's + stability rather than the client's recovery. + """ + return { + "serial_number": snapshot.serial_number, + "panel_size": snapshot.panel_size, + "circuits": sorted( + (circuit_id, circuit.name, tuple(circuit.tabs), circuit.device_type) + for circuit_id, circuit in snapshot.circuits.items() + ), + "evse": sorted(snapshot.evse), + "battery_serial": snapshot.battery.serial_number, + "pv_product": snapshot.pv.product_name, + } + + +def _describe_difference(before: dict[str, object], after: dict[str, object]) -> str: + changed = [key for key in before if before[key] != after.get(key)] + if not changed: + return "identical" + return "; ".join(f"{key}: {_brief(before[key])} -> {_brief(after.get(key))}" for key in changed) + + +def _brief(value: object) -> str: + """A value short enough to read in a result line.""" + if isinstance(value, list): + return f"{len(value)} entries" if len(value) > 3 else repr(value) + return repr(value) + + +# --------------------------------------------------------------------------- +# Session +# --------------------------------------------------------------------------- + + +class Session: + """A connected client plus the observations the checks are made from.""" + + def __init__(self, client: SpanMqttClient) -> None: + self.client = client + self.edges: list[bool] = [] + self.dispatches = 0 + client.register_connection_callback(self.edges.append) + client.register_snapshot_callback(self._count) + + async def _count(self, _snapshot: SpanPanelSnapshot) -> None: + self.dispatches += 1 + + +async def _wait_for(predicate: Callable[[], bool], timeout: float, interval: float = 0.1) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + await asyncio.sleep(interval) + return predicate() + + +async def _snapshot_is_stale(client: SpanMqttClient) -> bool: + try: + await client.get_snapshot() + except SpanPanelStaleDataError: + return True + return False + + +async def _measure_liveness(session: Session) -> int: + """Count snapshot dispatches in a window past the retained burst.""" + await asyncio.sleep(BURST_DRAIN_S) + before = session.dispatches + await asyncio.sleep(LIVENESS_WINDOW_S) + return session.dispatches - before + + +# --------------------------------------------------------------------------- +# Scenarios +# --------------------------------------------------------------------------- + + +async def run_outage(session: Session, link: SeverableLink, report: Report, *, sustained: bool) -> None: + """Cut the link, verify the client notices, restore, verify it recovers.""" + label = "sustained" if sustained else "brief" + print(f"\n{label} outage") + + client = session.client + before = _fingerprint(await client.get_snapshot()) + adapter_before: SchemaAdapter | None = client.adapter + metadata_before = client.field_metadata + edges_before = len(session.edges) + + await link.cut() + + noticed = await _wait_for(lambda: len(session.edges) > edges_before, DISCONNECT_TIMEOUT_S) + report.check("client observes the outage", noticed and session.edges[edges_before] is False) + report.check("snapshots report stale data during the outage", await _snapshot_is_stale(client)) + + if sustained: + swapped = await _wait_for(lambda: client.adapter is not adapter_before, REBUILD_TIMEOUT_S) + report.check( + f"parser rebuilt after {MQTT_FULL_REBUILD_AFTER_FAILURES} failed reconnects", + swapped, + ) + # Checked before restoring: a fresh parser must be empty, and once the + # link is back the retained burst would fill it within milliseconds. + fresh = client.adapter + report.check( + "rebuilt parser starts empty", + fresh is not None and not fresh.is_ready(), + ) + else: + report.check( + "parser instance survives a brief outage", + client.adapter is adapter_before, + ) + + await link.open() + + reconnected = await _wait_for(lambda: len(session.edges) > edges_before + 1, RECOVERY_TIMEOUT_S) + report.check("client reconnects", reconnected and session.edges[-1] is True) + + adapter = client.adapter + ready = await _wait_for(lambda: adapter is not None and adapter.is_ready(), RECOVERY_TIMEOUT_S) + report.check("parser repopulates from retained state", ready) + + if not ready: + return + + after = _fingerprint(await client.get_snapshot()) + report.check( + "panel is described identically after recovery", + after == before, + _describe_difference(before, after), + ) + report.check( + "field metadata survives the outage", + client.field_metadata == metadata_before, + ) + + dispatched = await _measure_liveness(session) + report.check( + "live updates resume once the retained burst has drained", + dispatched > 0, + f"{dispatched} snapshots in {LIVENESS_WINDOW_S:.0f}s", + ) + + +async def check_callback_contract(session: Session, report: Report, *, rebuilt: bool) -> None: + """Property callbacks are registered on the parser, not the transport. + + A rebuild replaces the parser, so callbacks registered on the old instance + are gone — documented on ``SpanMqttClient.adapter`` and load-bearing for the + integration, which must re-register. Worth asserting rather than trusting. + """ + print("\nproperty callback contract") + adapter = session.client.adapter + if adapter is None: + report.check("adapter present", False) + return + + seen: list[str] = [] + unregister = adapter.register_property_callback(lambda device, node, prop, value: seen.append(node)) + received = await _wait_for(lambda: bool(seen), LIVENESS_WINDOW_S) + report.check( + "callbacks registered on the current parser receive updates", + received, + f"{len(seen)} updates", + ) + unregister() + + if rebuilt: + report.check( + "the parser that served the callback is the rebuilt one", + adapter is session.client.adapter, + ) + + +# --------------------------------------------------------------------------- +# Seeding a broker from a captured tree +# --------------------------------------------------------------------------- + + +async def seed_broker(fixture: Path, host: str, port: int, stop: asyncio.Event) -> None: + """Publish a captured device tree retained, then keep its meters moving. + + Stands in for a panel on a plain broker: the retained topics are what a + reconnecting client replays, and the ticking meters are what proves live + traffic resumed rather than merely the burst arriving. + """ + import paho.mqtt.client as paho # imported here so the flat path needs no seeder + + tree: dict[str, dict[str, str]] = json.loads(fixture.read_text(encoding="utf-8")) + client = paho.Client(callback_api_version=paho.CallbackAPIVersion.VERSION2) + client.connect(host, port, keepalive=60) + client.loop_start() + + for device_id, topics in tree.items(): + for topic, payload in topics.items(): + client.publish(f"ebus/5/{device_id}/{topic}", payload, qos=0, retain=True) + + meters = [ + (device_id, float(topics["meter/active-power"])) + for device_id, topics in tree.items() + if "meter/active-power" in topics + ] + print(f"seeded {sum(len(t) for t in tree.values())} retained topics for {len(tree)} devices, ticking {len(meters)} meters") + + tick = 0 + while not stop.is_set(): + tick += 1 + for device_id, base in meters: + client.publish(f"ebus/5/{device_id}/meter/active-power", f"{base + tick:.1f}", qos=0, retain=True) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(stop.wait(), timeout=1.0) + + client.loop_stop() + client.disconnect() + + +# --------------------------------------------------------------------------- +# Wiring +# --------------------------------------------------------------------------- + + +def _load_factory(spec: str) -> Callable[[str, V2HomieSchema], SchemaAdapter]: + """Resolve a ``module:attribute`` adapter factory. + + Needed while an adapter is deliberately unregistered: schema_1 ships no + entry point until it has run against real hardware, so its factory has to + be named to be exercised. + """ + module_name, _, attribute = spec.partition(":") + if not attribute: + raise SystemExit(f"--adapter expects 'module:attribute', got {spec!r}") + factory: Callable[[str, V2HomieSchema], SchemaAdapter] = getattr(importlib.import_module(module_name), attribute) + return factory + + +def _synthetic_schema(data_model_version: str | None) -> V2HomieSchema: + """A schema for brokers with no panel behind them. + + Only the discriminator matters here — the parser reads its structure from + the tree, and field metadata comes from each device's own description. + """ + return V2HomieSchema( + firmware_version="unknown", + types_schema_hash="sha256:synthetic", + types={}, + data_model_version=data_model_version, + ) + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--serial", required=True, help="Panel serial number (the Homie root device id)") + parser.add_argument("--broker-host", default="127.0.0.1") + parser.add_argument("--broker-port", type=int, required=True) + parser.add_argument("--broker-username", default="") + parser.add_argument("--broker-password", default="") + parser.add_argument("--no-tls", action="store_true", help="Plain TCP to the broker (no CA fetch)") + parser.add_argument("--panel-host", help="Panel HTTP host; when given, the schema is fetched from it") + parser.add_argument("--panel-http-port", type=int, default=80) + parser.add_argument( + "--data-model-version", + help="Discriminator to use when no panel is available to fetch a schema from", + ) + parser.add_argument("--adapter", help="Adapter factory as 'module:attribute'; omit to dispatch by entry point") + parser.add_argument("--seed", type=Path, help="Captured tree to publish retained before connecting") + parser.add_argument( + "--scenario", + choices=["brief", "sustained", "both"], + default="both", + ) + return parser.parse_args(argv) + + +async def _run(args: argparse.Namespace) -> int: + report = Report() + stop_seeder = asyncio.Event() + seeder: asyncio.Task[None] | None = None + + if args.seed is not None: + seeder = asyncio.create_task(seed_broker(args.seed, args.broker_host, args.broker_port, stop_seeder)) + await asyncio.sleep(2.0) # let the retained tree land before connecting + + link = SeverableLink(args.broker_host, args.broker_port) + await link.open() + print(f"link: 127.0.0.1:{link.port} -> {args.broker_host}:{args.broker_port}") + + config = MqttClientConfig( + broker_host="127.0.0.1", + username=args.broker_username, + password=args.broker_password, + mqtts_port=link.port, + use_tls=not args.no_tls, + ) + client = SpanMqttClient( + host=args.panel_host or args.broker_host, + serial_number=args.serial, + broker_config=config, + snapshot_interval=0.25, + panel_http_port=args.panel_http_port, + adapter_factory=_load_factory(args.adapter) if args.adapter else None, + schema=None if args.panel_host else _synthetic_schema(args.data_model_version), + ) + session = Session(client) + + try: + await client.connect() + # Snapshot dispatch is what the integration actually consumes, and it + # is gated on streaming — without this the liveness check measures + # nothing. + await client.start_streaming() + snapshot = await client.get_snapshot() + print( + f"connected: {client.schema_major} / {snapshot.serial_number} / " + f"{snapshot.panel_size} spaces / {len(snapshot.circuits)} circuits" + ) + + rebuilt = False + if args.scenario in ("brief", "both"): + await run_outage(session, link, report, sustained=False) + if args.scenario in ("sustained", "both"): + await run_outage(session, link, report, sustained=True) + rebuilt = True + await check_callback_contract(session, report, rebuilt=rebuilt) + finally: + await client.close() + await link.close() + if seeder is not None: + stop_seeder.set() + await seeder + + print() + if report.failed: + print(f"FAILED ({len(report.failed)}/{len(report.checks)}): {', '.join(report.failed)}") + return 1 + print(f"All {len(report.checks)} checks passed.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + return asyncio.run(_run(_parse_args(argv))) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/span_panel_api/__init__.py b/src/span_panel_api/__init__.py index 62ab74f..cac9a13 100644 --- a/src/span_panel_api/__init__.py +++ b/src/span_panel_api/__init__.py @@ -18,10 +18,12 @@ ) from .detection import DetectionResult, detect_api_version from .exceptions import ( + SpanPanelAdapterMissingError, SpanPanelAPIError, SpanPanelAuthError, SpanPanelConnectionError, SpanPanelError, + SpanPanelSchemaVersionError, SpanPanelServerError, SpanPanelStaleDataError, SpanPanelTimeoutError, @@ -29,18 +31,29 @@ ) from .factory import create_span_client from .models import ( + ADOPTION_IDENTITY_NODE, + ADOPTION_TOPOLOGY_NODE, + DISCOVERY_NAMESPACE, + AdoptedDevice, + AdoptedProperty, + DiscoveredMetadata, + ExtensionProperty, + ExtensionSubject, FieldMetadata, HomieSchemaTypes, SpanBatterySnapshot, SpanCircuitSnapshot, SpanEvseSnapshot, + SpanMidSnapshot, SpanPanelSnapshot, + SpanPcsSnapshot, SpanPVSnapshot, V2AuthResponse, V2HomieSchema, V2StatusInfo, + is_discovery_path, ) -from .mqtt import HomieLifecycle, HomiePropertyAccumulator, MqttClientConfig, SpanMqttClient +from .mqtt import MqttClientConfig, SpanMqttClient from .phase_validation import ( PhaseDistribution, are_tabs_opposite_phase, @@ -50,7 +63,9 @@ validate_solar_tabs, ) from .protocol import ( + AdoptedControlProtocol, CircuitControlProtocol, + EvseControlProtocol, PanelCapability, PanelControlProtocol, SpanPanelClientProtocol, @@ -62,6 +77,17 @@ __all__ = [ # noqa: RUF022 # Protocols "CircuitControlProtocol", + # Added 2026-08-19: the charge-current ceiling on a commissioned EV charger, + # the first settable property outside the panel and its circuits. Purely + # additive -- a consumer that never asks for it is unaffected, and flat + # firmware publishes no such property, so the flat adapter answers None and + # the transport refuses. + "EvseControlProtocol", + # Added 2026-08-20 with device-scoped adoption: the first control whose + # subject this library does not understand. Additive, and authorised by the + # snapshot rather than by its arguments -- a device the adapter models + # produces no AdoptedDevice and so cannot be addressed through it. + "AdoptedControlProtocol", "PanelCapability", "PanelControlProtocol", "SpanPanelClientProtocol", @@ -69,12 +95,30 @@ # Metadata "FieldMetadata", "HomieSchemaTypes", + # Added 2026-08-20: runtime discovery. Purely additive -- an adapter that + # emits no discovered rows is indistinguishable from one built before the + # namespace existed, and a consumer that never partitions on the namespace + # sees exactly the curated rows it saw before. + "DISCOVERY_NAMESPACE", + "DiscoveredMetadata", + "is_discovery_path", + # Added 2026-08-20: device-scoped adoption. Additive in the same way -- + # `SpanPanelSnapshot.adopted_devices` defaults empty, so an adapter that + # adopts nothing and a consumer that reads the field are both unaffected. + "ADOPTION_IDENTITY_NODE", + "ADOPTION_TOPOLOGY_NODE", + "AdoptedDevice", + "AdoptedProperty", + "ExtensionProperty", + "ExtensionSubject", # Snapshots "SpanBatterySnapshot", "SpanCircuitSnapshot", "SpanEvseSnapshot", + "SpanMidSnapshot", "SpanPVSnapshot", "SpanPanelSnapshot", + "SpanPcsSnapshot", # Factory "create_span_client", # Detection @@ -93,8 +137,6 @@ "regenerate_passphrase", "register_v2", # Transport - "HomieLifecycle", - "HomiePropertyAccumulator", "MqttClientConfig", "SpanMqttClient", # Phase validation @@ -106,7 +148,9 @@ "validate_solar_tabs", # Exceptions "SpanPanelAPIError", + "SpanPanelAdapterMissingError", "SpanPanelAuthError", + "SpanPanelSchemaVersionError", "SpanPanelConnectionError", "SpanPanelError", "SpanPanelServerError", diff --git a/src/span_panel_api/adapters.py b/src/span_panel_api/adapters.py new file mode 100644 index 0000000..44f2048 --- /dev/null +++ b/src/span_panel_api/adapters.py @@ -0,0 +1,232 @@ +"""Adapter discovery via the `span_panel_api.schema_adapters` entry-point group. + +Two steps, deliberately separate, because they cost very different things: + +*Enumeration* reads distribution metadata and answers "which adapter keys does +this environment register". *Resolution* imports one of those packages and +checks it implements the contract. Enumeration is a couple of file reads; +resolution of ``schema_1`` drags in the eBus SDK and jsonschema — measured at +two seconds on a cold import cache. + +So only the key the panel actually reports is ever imported. An earlier version +resolved the whole group up front to build one registry, which meant every flat +panel paid for the parent/child parser it would never call — undoing the +containment schema-1's own packaging sets up, where the SDK dependency is +isolated to that distribution precisely so a flat install stays clear of it. +Under redispatch both adapters are the normal install, so "installed" stopped +implying "used" and eager resolution stopped being defensible. + +Both steps cache for the life of the process. A venv change needs a restart +regardless, so nothing here can go stale while it matters. + +**Everything in this module does blocking file I/O**, both the metadata reads +and the imports. Callers on an event loop must keep it off theirs; the async +transport does that with ``asyncio.to_thread``. +""" + +from __future__ import annotations + +from importlib.metadata import EntryPoint, entry_points +import logging +from typing import TypeGuard + +from span_panel_api.exceptions import SpanPanelAdapterIncompatibleError, SpanPanelAdapterMissingError +from span_panel_api.protocol import ADAPTER_CONTRACT_VERSION, SchemaAdapter + +_LOGGER = logging.getLogger(__name__) +_ENTRY_POINT_GROUP = "span_panel_api.schema_adapters" + +# Every entry point in the group, by name, unloaded. None means "not scanned". +_ENTRY_POINTS: dict[str, EntryPoint] | None = None +# Resolution verdicts, filled one key at a time. A key appears in exactly one: +# usable adapters here, and the reason for the rest in _REJECTED. Kept apart +# rather than as one nullable map because a rejected adapter and an absent one +# are opposite problems for whoever hits them — upgrade what is already +# installed, versus install something — and resolve_adapter can only tell them +# apart if the reason survives the scan that produced it. +_ADAPTERS: dict[str, type[SchemaAdapter]] = {} +_REJECTED: dict[str, str] = {} + + +def _derive_required_members(protocol: type) -> tuple[str, ...]: + """Every public member a protocol declares, whatever kind it is. + + Derived from the protocol rather than restated, so the check cannot drift + out of sync with the contract it enforces — adding any public member to + SchemaAdapter automatically makes it required of every adapter package. + + Two sources, because a protocol declares members two ways: annotation-only + data members live in ``__annotations__`` and never reach ``vars()``, while + anything with a body lives in ``vars()`` and is not annotated. + + Member *kind* is deliberately not filtered on. Screening ``vars()`` for + ``callable`` looks equivalent and is not: a ``property`` object is not + callable and neither is a ``classmethod`` object, so that filter would + silently stop requiring a member the day the protocol declared one. Every + public name in ``vars()`` is a member the protocol body declared — Protocol's + own machinery (``_is_protocol``, ``__protocol_attrs__``, ``__subclasshook__``) + is uniformly underscore-prefixed — so no kind check is needed to begin with. + + ``issubclass`` is not an option here: SchemaAdapter has non-method members, + and runtime_checkable protocols with data attributes reject it outright. + """ + return ( + *sorted(getattr(protocol, "__annotations__", {})), + *sorted(name for name in vars(protocol) if not name.startswith("_")), + ) + + +_REQUIRED_MEMBERS: tuple[str, ...] = _derive_required_members(SchemaAdapter) + +# The adapter key for panels that publish no data-model-version. This is a +# bootstrap-level fact — Tier 1 dispatch reads absence as "flat schema" — not an +# import of the flat adapter. The bootstrap knows the *name*; whether anything +# answers to it is entry-point discovery's problem. +DEFAULT_ADAPTER_KEY = "schema_0" + + +def _is_adapter_class(loaded: object) -> TypeGuard[type[SchemaAdapter]]: + """Narrow an entry point's loaded object to an adapter class. + + A TypeGuard rather than a bare bool: `ep.load()` returns `Any`, and this is + the boundary where that `Any` has to become a checked `type[SchemaAdapter]` + rather than being assigned into the registry unexamined. + + Checks member *presence* only, which is all a Protocol can express at + runtime: an adapter carrying every required name and the wrong ``__init__`` + arity still satisfies this. That gap is why the protocol also requires a + declared ``ADAPTER_CONTRACT`` and why ``_contract_defect`` runs after this — + presence answers "is this an adapter", the contract answers "is it one this + package can drive". + + Worth having on its own regardless: it catches a module, function or + instance registered where a class belongs, and turns it into a named, logged + skip instead of an opaque TypeError deep inside connect(). + """ + return isinstance(loaded, type) and all(hasattr(loaded, member) for member in _REQUIRED_MEMBERS) + + +def _describe_defect(loaded: object) -> str: + """Explain why `loaded` failed _is_adapter_class. Only called on the error path.""" + if not isinstance(loaded, type): + return f"expected a class, got {type(loaded).__name__}" + missing = [member for member in _REQUIRED_MEMBERS if not hasattr(loaded, member)] + if "ADAPTER_CONTRACT" in missing: + # Every adapter built for a contract-versioned bootstrap declares this, + # so its absence dates the package rather than faulting it: this is an + # adapter from before the contract was versioned at all. + return ( + f"{loaded.__name__} declares no ADAPTER_CONTRACT, so it predates contract " + f"versioning and was built against an older span-panel-api. Install an adapter " + f"release built for contract {ADAPTER_CONTRACT_VERSION}." + ) + return f"{loaded.__name__} does not implement SchemaAdapter (missing: {', '.join(missing)})." + + +def _contract_defect(adapter_cls: type[SchemaAdapter]) -> str | None: + """Reject an adapter built against a different contract. None when usable. + + Runs only after `_is_adapter_class`, so the attribute is known to exist and + the remaining questions are whether it is an integer and whether it agrees. + """ + declared: object = adapter_cls.ADAPTER_CONTRACT + # bool is a subclass of int, and `ADAPTER_CONTRACT = True` comparing equal + # to contract 1 would be an absurd way to pass this check. + if not isinstance(declared, int) or isinstance(declared, bool): + return f"{adapter_cls.__name__} declares ADAPTER_CONTRACT={declared!r}, which is not an integer." + if declared != ADAPTER_CONTRACT_VERSION: + direction = "older than" if declared < ADAPTER_CONTRACT_VERSION else "newer than" + return ( + f"{adapter_cls.__name__} is built for adapter contract {declared}, " + f"{direction} the contract {ADAPTER_CONTRACT_VERSION} this span-panel-api speaks." + ) + return None + + +def _enumerate() -> dict[str, EntryPoint]: + """Scan the entry-point group by name, importing nothing. + + Names only, because a name is all it takes to answer the two questions asked + before a panel has reported anything: what is installed, and does the key + this panel needs appear at all. Loading is deferred to whoever asks for a + specific key. + """ + global _ENTRY_POINTS # pylint: disable=global-statement # process-lifetime cache by design + if _ENTRY_POINTS is None: + found: dict[str, EntryPoint] = {} + for ep in entry_points(group=_ENTRY_POINT_GROUP): + if ep.name in found: + _LOGGER.warning("Duplicate schema adapter entry point %r; keeping the first found", ep.name) + continue + found[ep.name] = ep + _ENTRY_POINTS = found + return _ENTRY_POINTS + + +def _load_and_check(ep: EntryPoint) -> type[SchemaAdapter] | str: + """Import one adapter and vet it, returning the class or the reason it is unusable. + + A defect is returned rather than raised so the caller decides what it means. + Discovery has no standing to fail a connection: whether an unusable adapter + matters depends entirely on whether the panel needs that key. + """ + try: + loaded: object = ep.load() + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.exception("Failed to load schema adapter entry point %r", ep.name) + return "the package raised on import; see the logged traceback." + if not _is_adapter_class(loaded): + shape_defect = _describe_defect(loaded) + _LOGGER.error("Ignoring schema adapter entry point %r: %s", ep.name, shape_defect) + return shape_defect + if (contract_defect := _contract_defect(loaded)) is not None: + _LOGGER.error("Ignoring schema adapter entry point %r: %s", ep.name, contract_defect) + return contract_defect + return loaded + + +def installed_adapter_keys() -> list[str]: + """Every adapter key this environment registers, sorted. + + Registered, not verified: naming a key here says a package claims it, not + that the package loads or implements the current contract. Verifying would + mean importing all of them, which is the cost this split exists to avoid, + and the distinction only ever matters for one key — the one the panel needs, + which ``resolve_adapter`` imports and vets on the spot. + """ + return sorted(_enumerate()) + + +def resolve_adapter(key: str, reason: str) -> type[SchemaAdapter]: + """Return the adapter class for `key`, importing it on first use, or raise saying why not. + + The one place an unavailable adapter turns into a named error. Both the + factory's Tier 1 dispatch and the transport's default path go through here so + a user whose panel outruns their install sees the same message either way. + + Absent and rejected stay distinct: nothing registers the key at all, versus + something does and cannot be driven. Same absence, opposite remedies. + """ + if (cached := _ADAPTERS.get(key)) is not None: + return cached + if (cached_defect := _REJECTED.get(key)) is not None: + raise SpanPanelAdapterIncompatibleError(needed=key, reason=reason, defect=cached_defect) + + ep = _enumerate().get(key) + if ep is None: + raise SpanPanelAdapterMissingError(needed=key, reason=reason, available=installed_adapter_keys()) + + outcome = _load_and_check(ep) + if isinstance(outcome, str): + _REJECTED[key] = outcome + raise SpanPanelAdapterIncompatibleError(needed=key, reason=reason, defect=outcome) + _ADAPTERS[key] = outcome + return outcome + + +def _reset_adapter_cache() -> None: + """Test hook. Not public API.""" + global _ENTRY_POINTS # pylint: disable=global-statement # test hook for the cache above + _ENTRY_POINTS = None + _ADAPTERS.clear() + _REJECTED.clear() diff --git a/src/span_panel_api/auth.py b/src/span_panel_api/auth.py index 4df1a89..90bc32e 100644 --- a/src/span_panel_api/auth.py +++ b/src/span_panel_api/auth.py @@ -15,7 +15,13 @@ import httpx from ._http import _build_url, _get_client -from .exceptions import SpanPanelAPIError, SpanPanelAuthError, SpanPanelConnectionError, SpanPanelTimeoutError +from .exceptions import ( + SpanPanelAPIError, + SpanPanelAuthError, + SpanPanelConnectionError, + SpanPanelServerError, + SpanPanelTimeoutError, +) from .models import HomieSchemaTypes, V2AuthResponse, V2HomieSchema, V2StatusInfo @@ -182,15 +188,54 @@ async def get_homie_schema( try: async with _get_client(httpx_client, timeout) as client: response = await client.get(url) - except httpx.ConnectError as exc: - raise SpanPanelConnectionError(f"Cannot reach panel at {host}") from exc except httpx.TimeoutException as exc: raise SpanPanelTimeoutError(f"Timed out connecting to {host}") from exc - + except httpx.TransportError as exc: + # Every way the connection itself can fail, not just a refused connect: + # `ReadError` and `WriteError` when a rebooting panel resets mid-request, + # and `RemoteProtocolError` when its proxy closes without answering -- + # which is exactly what a proxy restarting under load produces. Catching + # only `ConnectError` meant those escaped this function untranslated, + # skipped the caller's retry clause entirely, and stranded the parser the + # same way a 502 used to. `TimeoutException` is itself a `TransportError`, + # so it has to be caught first. + raise SpanPanelConnectionError(f"Cannot reach panel at {host}: {exc}") from exc + + if response.status_code >= 500: + # A rebooting panel answers 502 from its front end while the application + # behind it is still starting. That is "not ready yet", not "wrong" -- + # and it is the ordinary shape of a firmware upgrade, because a device + # brings its network stack and proxy up before its application. Raised as + # a distinct class so a caller can retry it and fail fast on a 4xx, which + # will not fix itself. + raise SpanPanelServerError( + f"Panel not ready: HTTP {response.status_code} fetching the Homie schema", + status_code=response.status_code, + ) if response.status_code != 200: - raise SpanPanelAPIError(f"Failed to fetch Homie schema: HTTP {response.status_code}") + raise SpanPanelAPIError( + f"Failed to fetch Homie schema: HTTP {response.status_code}", + status_code=response.status_code, + ) - data: dict[str, object] = response.json() + try: + parsed = response.json() + except ValueError as exc: + # A panel part-way through starting can answer 200 with a truncated or + # empty body. Retryable for the same reason a 502 is -- it is "not ready + # yet" wearing a different status -- and untranslated this had precisely + # the 502's old character: raised out of the caller's retry loop on the + # first attempt and left the parser where it was. + raise SpanPanelServerError( + f"Panel not ready: {host} answered 200 with a body that is not JSON", + status_code=response.status_code, + ) from exc + if not isinstance(parsed, dict): + raise SpanPanelServerError( + f"Panel not ready: {host} answered 200 with {type(parsed).__name__}, not an object", + status_code=response.status_code, + ) + data: dict[str, object] = parsed # Extract types — each value is a dict of property definitions raw_types = data.get("types", {}) @@ -206,10 +251,19 @@ async def get_homie_schema( types_json = json.dumps(data.get("types", {}), sort_keys=True) schema_hash = "sha256:" + hashlib.sha256(types_json.encode()).hexdigest()[:16] + # Read before anything else interprets the payload. A parent/child response + # carries `deviceClasses` where this one reads `types`, so every field below + # degrades to empty for such a panel — which is harmless only because this + # value routes it to a different parser before those fields are used. + # Absence is the flat signal and must stay distinct from an empty string. + raw_data_model_version = data.get("dataModelVersion") + data_model_version = None if raw_data_model_version is None else str(raw_data_model_version) + return V2HomieSchema( firmware_version=str(data.get("firmwareVersion", "")), types_schema_hash=schema_hash, types=types, + data_model_version=data_model_version, ) diff --git a/src/span_panel_api/dispatch.py b/src/span_panel_api/dispatch.py new file mode 100644 index 0000000..72a8b66 --- /dev/null +++ b/src/span_panel_api/dispatch.py @@ -0,0 +1,74 @@ +"""Tier 1 dispatch: a panel's data-model-version selects the adapter major. + +Separate from ``adapters.py`` because they answer different questions. +``adapters.py`` knows *what is installed*; this module knows *what this panel +needs*. Keeping them apart is also what lets both the factory and the transport +dispatch without importing each other. +""" + +from __future__ import annotations + +import logging +import re + +from .adapters import DEFAULT_ADAPTER_KEY +from .exceptions import SpanPanelSchemaVersionError + +_LOGGER = logging.getLogger(__name__) + +# The canonical form the published spec defines: MAJOR.MINOR[.PATCH]. +_DMV_CANONICAL = re.compile(r"^(\d+)\.\d+(?:\.\d+)?$") +# Tolerant form: a leading integer major, optionally followed by a separator and +# anything at all. Accepts '1', '1.0.3-rc2', '1_0'; rejects 'v1.0', '', 'x'. +_DMV_MAJOR = re.compile(r"^(\d+)(?:[._-].*)?$") + + +def select_adapter_key(data_model_version: str | None) -> tuple[str, str]: + """Return the adapter key this panel needs, and why. + + Absence is the flat-schema signal — the property was introduced by the same + firmware that introduced the parent/child model, so a panel that does not + publish it is speaking the flat schema. SPAN confirmed this holds over REST + as well as MQTT, which is what makes dispatch possible before the broker is + opened. + + Presence is never read as flat. Falling back to schema_0 for a value we do + not recognise would hand a parent/child panel to the flat parser, which does + not fail — it produces plausible but wrong power and energy figures. A wrong + number in Home Assistant is worse than an error, so anything present and + unreadable raises instead. + + Between those two poles sits a value whose major is unambiguous even though + its full form is not canonical ('1', '1.0-beta'). That is not a guess: the + major is what selects the adapter, and it was read, not assumed. Those + dispatch normally and log the deviation, so a firmware that starts emitting + a new format is visible before it is an outage. + + Note this is the opposite of the rule for enum *properties*, where the spec + requires consumers not to raise on an unrecognised value. The difference is + blast radius: an unknown enum value affects one property, while an unknown + schema version means every value in the tree may be misread. + + Raises: + SpanPanelSchemaVersionError: A version is present but no major can be + extracted from it. + """ + if data_model_version is None: + return DEFAULT_ADAPTER_KEY, "data-model-version absent (flat schema)" + + if (match := _DMV_CANONICAL.match(data_model_version)) is not None: + return f"schema_{int(match.group(1))}", f"data-model-version={data_model_version!r}" + + if (match := _DMV_MAJOR.match(data_model_version)) is not None: + _LOGGER.warning( + "data-model-version=%r is not the canonical MAJOR.MINOR[.PATCH] form; " + "dispatching on major %s. Please report this value.", + data_model_version, + match.group(1), + ) + return ( + f"schema_{int(match.group(1))}", + f"data-model-version={data_model_version!r} (non-canonical; major only)", + ) + + raise SpanPanelSchemaVersionError(data_model_version) diff --git a/src/span_panel_api/exceptions.py b/src/span_panel_api/exceptions.py index 24f1a56..cb59d4d 100644 --- a/src/span_panel_api/exceptions.py +++ b/src/span_panel_api/exceptions.py @@ -30,7 +30,13 @@ def __init__(self, message: str, status_code: int | None = None) -> None: class SpanPanelServerError(SpanPanelAPIError): - """Server error (500).""" + """The panel answered, and the answer means "not ready yet". + + Any 5xx, and a 200 whose body cannot be a schema. Distinct from + `SpanPanelAPIError` because a caller can retry this and should not retry a + 4xx, which will not fix itself. A rebooting panel produces these for as long + as its front end is up and the application behind it is not. + """ class SpanPanelStaleDataError(SpanPanelError): @@ -40,3 +46,66 @@ class SpanPanelStaleDataError(SpanPanelError): but data cannot be trusted right now (broker disconnected, or the Homie device has declared $state=disconnected/lost). """ + + +class SpanPanelSchemaVersionError(SpanPanelError): + """The panel reports a data-model-version this library cannot interpret. + + Distinct from SpanPanelAdapterMissingError, because the remedy differs. A + missing adapter is a known schema with no installed parser — install or + update the adapter package. This is a schema whose *major cannot even be + determined*, so no adapter can be named. That is a panel this library has + never seen, and the honest response is to say so. + + Absence is not this error: a panel that publishes no data-model-version at + all is speaking the flat schema, which is a real and supported signal. + """ + + def __init__(self, data_model_version: str) -> None: + self.data_model_version = data_model_version + super().__init__( + f"Cannot determine a schema major from data-model-version {data_model_version!r}. " + "Expected MAJOR.MINOR[.PATCH]. Refusing to guess — parsing this panel with the " + "wrong schema would produce plausible but incorrect power and energy values. " + "Please report this value." + ) + + +class SpanPanelAdapterMissingError(SpanPanelError): + """No installed adapter covers the schema this panel publishes.""" + + def __init__(self, needed: str, reason: str, available: list[str]) -> None: + self.needed = needed + self.reason = reason + self.available = available + super().__init__( + f"Panel requires adapter {needed!r} (reason: {reason}); " + f"installed adapters: {sorted(available)}. " + "Update the integration or install the missing adapter package." + ) + + +class SpanPanelAdapterIncompatibleError(SpanPanelError): + """An adapter for this schema is installed, but this package cannot use it. + + Distinct from SpanPanelAdapterMissingError because the remedy is the + opposite one. "Missing" means nothing claims this schema, and the answer is + to install something. This means a package *does* claim it and was rejected, + so installing more cannot help — the two installed pieces were built against + different versions of the same contract, and one of them has to move. + + Raised rather than logged because the panel needing this adapter has no + other parser. Discovery still only logs, so one unusable third-party adapter + does not take down a panel whose own adapter is fine; this fires only when + the rejected adapter turns out to be the one actually required. + """ + + def __init__(self, needed: str, reason: str, defect: str) -> None: + self.needed = needed + self.reason = reason + self.defect = defect + super().__init__( + f"Panel requires adapter {needed!r} (reason: {reason}), and an installed " + f"package registers it, but it cannot be used: {defect} " + "Upgrade span-panel-api and the adapter package together." + ) diff --git a/src/span_panel_api/factory.py b/src/span_panel_api/factory.py index a36bef2..246864e 100644 --- a/src/span_panel_api/factory.py +++ b/src/span_panel_api/factory.py @@ -6,14 +6,21 @@ from __future__ import annotations +import asyncio import logging +from typing import TYPE_CHECKING -from .auth import register_v2 +from .adapters import resolve_adapter +from .auth import get_homie_schema, register_v2 from .detection import detect_api_version +from .dispatch import select_adapter_key from .exceptions import SpanPanelAuthError from .mqtt.client import SpanMqttClient from .mqtt.models import MqttClientConfig +if TYPE_CHECKING: + import httpx + _LOGGER = logging.getLogger(__name__) _V2_CLIENT_NAME = "span-panel-api" @@ -25,6 +32,7 @@ async def create_span_client( mqtt_config: MqttClientConfig | None = None, serial_number: str | None = None, port: int = 80, + httpx_client: httpx.AsyncClient | None = None, ) -> SpanMqttClient: """Create a SPAN Panel MQTT client. @@ -34,6 +42,10 @@ async def create_span_client( mqtt_config: Pre-built MQTT broker configuration. serial_number: Panel serial number (extracted from detection/registration if omitted). port: HTTP port of the panel bootstrap API used for registration and detection. + httpx_client: Optional shared ``httpx.AsyncClient``, used for every request this + makes and handed to the client it builds. Not closed here; its timeouts and + limits are the caller's, which is why the per-call ``timeout`` defaults are + ignored when one is given. Returns: A connected-ready SpanMqttClient instance. @@ -43,11 +55,15 @@ async def create_span_client( or serial_number could not be determined. SpanPanelConnectionError: Cannot reach panel during detection or registration. SpanPanelTimeoutError: Timeout during detection or registration. + SpanPanelSchemaVersionError: The panel reports a data-model-version whose + schema major cannot be determined. + SpanPanelAdapterMissingError: No installed package provides an adapter for + the schema major this panel reports. """ if mqtt_config is None: if passphrase is None: raise SpanPanelAuthError("Neither mqtt_config nor passphrase provided") - auth_response = await register_v2(host, _V2_CLIENT_NAME, passphrase, port=port) + auth_response = await register_v2(host, _V2_CLIENT_NAME, passphrase, port=port, httpx_client=httpx_client) mqtt_config = MqttClientConfig( broker_host=auth_response.ebus_broker_host, username=auth_response.ebus_broker_username, @@ -61,13 +77,35 @@ async def create_span_client( if serial_number is None: # Try to detect from panel status - result = await detect_api_version(host, port=port) + result = await detect_api_version(host, port=port, httpx_client=httpx_client) if result.status_info is not None: serial_number = result.status_info.serial_number if serial_number is None: raise SpanPanelAuthError("serial_number is required for MQTT transport but could not be determined") - client = SpanMqttClient(host, serial_number, mqtt_config, panel_http_port=port) + # Dispatch reads the schema over REST before the broker is opened. SPAN + # confirmed the absence of `dataModelVersion` on this endpoint is a reliable + # flat-versus-parent/child signal, mirroring MQTT's `info/data-model-version` + # — so the parser is chosen before a single message is consumed, rather than + # a wrong parser being discovered by its output. + schema = await get_homie_schema(host, port=port, httpx_client=httpx_client) + adapter_key, dispatch_reason = select_adapter_key(schema.data_model_version) + # In a thread: resolution reads distribution metadata and imports the adapter + # package, and this is the first call in the process to do either. See + # `adapters` — none of it is safe to run on an event loop. + adapter_cls = await asyncio.to_thread(resolve_adapter, adapter_key, dispatch_reason) + + client = SpanMqttClient( + host, + serial_number, + mqtt_config, + panel_http_port=port, + adapter_factory=adapter_cls, + data_model_version=schema.data_model_version, + schema_dispatch_reason=dispatch_reason, + schema=schema, + httpx_client=httpx_client, + ) await client.connect() return client diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index 03d8368..3c1f338 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -10,11 +10,10 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TypeAlias # Homie schema type: {type_name: {property_name: {attribute: value}}} # Values are heterogeneous JSON (str, int, bool, nested dicts). -HomieSchemaTypes: TypeAlias = dict[str, dict[str, object]] +type HomieSchemaTypes = dict[str, dict[str, object]] @dataclass(frozen=True, slots=True) @@ -45,16 +44,232 @@ class SpanCircuitSnapshot: relay_state_target: str | None = None # v2: $target for relay (desired state) priority_target: str | None = None # v2: $target for shed-priority (desired state) + # This circuit's *participation* in the enclosure's Power Control System — + # `energy.ebus.capability.pcs` 0.3, the half a circuit publishes. The + # system half (the effective limit and its arbitration) is on the enclosure + # and lands on `SpanPanelSnapshot.pcs`; a circuit says only whether the PCS + # manages it and where it sits in the shed order. + # + # `None` on both, never `False`/`0`, because both are `MAY` and a circuit + # that says nothing is not the same as one that says no: priority `0` is a + # legal ranking, and "unmanaged" is a claim the panel has to make. + # + # Distinct from `priority`/`is_sheddable`, which are `load-shed` — a + # different policy on the same relay. The catalog keeps them apart because + # they answer different questions (limit site import versus preserve backup + # runtime) and a circuit may participate in one, both, or neither. + pcs_managed: bool | None = None # v2: circuit pcs/managed + pcs_priority: int | None = None # v2: circuit pcs/priority + @dataclass(frozen=True, slots=True) class SpanPVSnapshot: """PV inverter metadata — populated only when a PV node is commissioned.""" vendor_name: str | None = None # pv/vendor-name - product_name: str | None = None # pv/product-name + model: str | None = None # human designation (v1.0 info/model; flat pv/product-name) nameplate_capacity_w: float | None = None # pv/nameplate-capacity (W) feed_circuit_id: str | None = None # pv/feed (normalized circuit ID) relative_position: str | None = None # pv/relative-position (IN_PANEL | UPSTREAM | DOWNSTREAM) + software_version: str | None = None + """`info/firmware-version`, named as on `SpanBatterySnapshot` and `SpanEvseSnapshot`. + + Sub-devices share a spelling because a consumer builds all of them the same way — + into `DeviceInfo(sw_version=...)`. Only the enclosure calls it `firmware_version`, + where it is the panel's own and predates the sub-device types. + """ + + connected: bool | None = None + """The enclosure's view of the link to this PV, v1.0 only. + + The same fact `SpanBatterySnapshot.connected` carries and read the same way — + from the enclosure-side owner's `connection` record, never from anything the + inverter says about itself. Only the half of the record differs: a BESS is + named by the upstream lugs' `fed-by-device-*`, a circuit-fed DER by its + circuit's `feeds-device-*`. + + `None` means no owner has claimed this device, or the claiming owner + published no status — which is the specification's own "unknown" signal + (`capabilities/connection.md`: an unpublished property *is* how a panel says + it does not know) and is deliberately distinct from `False`. The enum has + three members, `OK,LOST,DEGRADED`, and no UNKNOWN, so absence is the only + way to say it. + """ + + +@dataclass(frozen=True, slots=True) +class SpanMidSnapshot: + """Microgrid Interconnect Device — the islanding authority. v1.0 only. + + The MID is the device that decides whether the enclosure is islanded, and the + enclosure model puts `grid` on it deliberately: "Grid connection state, islanding + state, and grid-forming-entity identity, published on the enclosure-integrated MID + (the enclosure device itself does not publish them)." + + **Purely additive.** No flat panel publishes a MID node — not the frozen simulator, + not the live panel — so nothing here can orphan an entity a user already has. That + is why this is the benign cell of the absorb-or-surface policy: surfacing a new + device cannot break an automation that never referenced it. + + **Adding this device does not, on its own, fix anything a user sees.** The + integration renders no entity from `panel.grid_state` — checked, there is no such + sensor. What it does render is `dsm_state`, its `dsm_grid_state` alias, + `current_run_config`, `dominant_power_source` and `grid_islandable`, and on v1.0 + four of those five are currently `UNKNOWN` or absent. The MID is where their inputs + moved, so mapping it back into those fields is the work; this type is what makes + that possible, plus the option of rendering the MID as hardware in its own right. + + Which raises a design question this type does not settle: if the MID's islanding + state is also surfaced directly, a user sees the same fact twice. Duplicating an + existing state entity is not the benign cell of the absorb-or-surface policy. + """ + + node_id: str + """Stable identity, and the device-registry identifier a consumer builds from. + + The serial where published, falling back to the Homie device id — the same choice + as `SpanEvseSnapshot`, for the reason `devices/proxy.md` gives: a proxied device id + is not stable across the proxy-to-native transition, so identity belongs on + `info/serial-number`. + """ + + serial_number: str | None = None + vendor_name: str | None = None + model: str | None = None + islanding_state: str | None = None + """`grid/islanding-state` — ON_GRID / OFF_GRID. MUST on a MID, per the enclosure model.""" + grid_state: str | None = None + """`grid/grid-state` — whether utility power is present, distinct from islanding.""" + software_version: str | None = None + """`info/firmware-version`, spelled as on the other sub-devices — see `SpanPVSnapshot`.""" + hardware_version: str | None = None + """`info/hardware-version`. The MID is the first device to carry one into a snapshot. + + r202633 documents it on the MID's `info` node, and a consumer has a field for it + (`DeviceInfo(hw_version=...)`). Without it the MID's device card shows a model and a + serial and nothing else, beside a battery showing all three. + """ + grid_forming_entity: str | None = None + """`grid/grid-forming-entity` — the raw wire value: `GRID`, or a Homie device id.""" + grid_forming_device_name: str | None = None + """The forming device's display name, or `None` when the grid itself is forming. + + The raw value above is a Homie device id, which means nothing on a dashboard — it is + not a Home Assistant device id, and an opaque string is worse than none. This is the + device's own `$description.name` (`Battery`, `Solar`, `SPAN Drive - Garage`), which + is the part a person can read. The literal stays available beside it. + """ + + +@dataclass(frozen=True, slots=True) +class SpanPcsSnapshot: + """The enclosure's Power Control System — UL 3141 import limiting. v1.0 only. + + A `pcs` node runs one physical actuator and two roles, per + `capabilities/pcs.md` 0.3: it is the premises-equipment protection (the Firm + Service Rating), and it is the arbitrator that reconciles *every* active + import constraint to one enforced current limit. The constraints arrive in + different native units on different capabilities — amps here, watts on + `doe`, volts on `voltage-response` — and `pcs` does not re-publish them as + amps copies. **What it publishes is the result**: the effective + `import-limit` and the `binding-constraint` naming which class won the + `min()`. + + That sentence is the shape of this type. `import_limit_a` and + `binding_constraint` are the answer; the four `{feed,operator,off_grid, + requested}_import_limit_*` families are the inputs that produced it, kept + beside the answer so a consumer can explain a number rather than only show + it. + + **A nested type rather than sixteen optional fields on the panel**, for the + reason `SpanMidSnapshot` is one: presence is `snapshot.pcs is not None`, + with nothing to infer from a sentinel. `capabilities/pcs.md` states the + absence rule outright — "absence of the `pcs` node means the device does not + run (or participate in) a Power Control System" — so there is a real + distinction between a panel with no PCS and a PCS reporting zeros, and + sixteen `None`s on the enclosure could not carry it. + + **Flat is the absence case, not a translation problem.** No flat panel + publishes `energy.ebus.capability.pcs` at all, so nothing here can orphan an + entity a user already has. + + Every member is optional because every property in the catalog is `SHOULD` + or `MAY`: a conformant publisher populates whichever constraint classes + apply to its equipment and omits the rest. `None` therefore means "this + panel does not report it", which is a different statement from a limit of + `0.0` — and `0.0` is a legal, meaningful reading (no import permitted), so + no field may default to it. + """ + + enabled: bool | None = None + """`pcs/enabled` — is the PCS enabled on this enclosure at all?""" + active: bool | None = None + """`pcs/active` — is it limiting import *right now*? + + Distinct from `enabled`: a configured PCS spends most of its life enabled + and inactive, and this is the transition an automation triggers on. + """ + import_limit_a: float | None = None + """`pcs/import-limit` (A) — the effective enforced limit, the `min()` result. + + The single number that summarises the capability, and the only one that + reflects the reconciled `doe` and `voltage-response` constraints as well as + the amps-native families below. + """ + binding_constraint: str | None = None + """`pcs/binding-constraint` — which class currently sets `import_limit_a`. + + The catalog enum is `FSR`, `DOE`, `VOLTAGE`, `OFF_GRID`, `REQUESTED`, + `OPERATOR`, `NONE`, `UNKNOWN`, and publishers **MAY extend it** through the + property's Homie `$format`. Kept as the raw wire string for that reason: a + re-encoding onto a closed set defined here would drop a vendor's extension + on the floor, and this is the property whose whole job is naming a source. + """ + + feed_import_limit_a: float | None = None + """`pcs/feed-import-limit` (A) — the FSR: the commissioned, always-on floor. + + May be below the main-breaker rating where the service feed is smaller than + the panel; the catalog's example is a 200 A panel on a 100 A feed. + """ + feed_import_limit_enablement: str | None = None + """`pcs/feed-import-limit-enablement` — `UNSPECIFIED`, `UNCONFIGURED`, `DISABLED`, `ENABLED`.""" + feed_import_limit_active: bool | None = None + """`pcs/feed-import-limit-active` — is this constraint enforcing? + + Distinct from `binding_constraint`, and deliberately: several constraints + can be active at once, and only the most restrictive is binding. + """ + + operator_import_limit_a: float | None = None + """`pcs/operator-import-limit` (A) — an externally imposed fleet/aggregator cap. + + Set over the vendor's management API and persisting until the operator + changes it — not the standardised IEEE 2030.5 watts envelope, which lives + on `doe`. + """ + operator_import_limit_enablement: str | None = None + """`pcs/operator-import-limit-enablement` — same enum domain as the feed family.""" + operator_import_limit_active: bool | None = None + """`pcs/operator-import-limit-active` — is the operator cap enforcing?""" + + off_grid_import_limit_a: float | None = None + """`pcs/off-grid-import-limit` (A) — the import cap while islanded.""" + off_grid_import_limit_enablement: str | None = None + """`pcs/off-grid-import-limit-enablement` — same enum domain as the feed family.""" + off_grid_import_limit_active: bool | None = None + """`pcs/off-grid-import-limit-active` — typically true only while islanded.""" + + requested_import_limit_a: float | None = None + """`pcs/requested-import-limit` (A) — a voluntary, self-revocable user limit. + + Requested by the homeowner or installer through the vendor's app. Distinct + from the operator cap, which the site cannot revoke. + """ + requested_import_limit_enablement: str | None = None + """`pcs/requested-import-limit-enablement` — same enum domain as the feed family.""" + requested_import_limit_active: bool | None = None + """`pcs/requested-import-limit-active` — is the voluntary limit enforcing?""" @dataclass(frozen=True, slots=True) @@ -68,11 +283,73 @@ class SpanEvseSnapshot: advertised_current_a: float | None = None # Amps offered to EV # Device metadata — flows into HA DeviceInfo, not separate entities vendor_name: str | None = None - product_name: str | None = None - part_number: str | None = None + model: str | None = None # human designation (v1.0 info/model; flat evse/product-name) + part_number: str | None = None # SKU serial_number: str | None = None software_version: str | None = None + charge_current_limit_a: int | None = None + """The charge-current ceiling a user may lower, in amps. v1.0 only. + + The only settable property the v1.0 surface carries, and the one whose wire + name is not settled: the reference tree declares it + `config/user-max-charge-current`, the eBus catalog specifies + `charge-limit/owner-limit`. The adapter reads whichever the charger's own + `$description` declares (`schema_1.charge_limit`), so this field is named + for the concept and no consumer has to know which spelling arrived. + + `None` means the charger declares no such property — `charge-limit.md` reads + that as "no adjustable charge-current ceiling; it charges at a fixed rate" — + or that it has not published a value yet. + + **Not `advertised_current_a`.** That is the current actually being offered + to the vehicle, which the capability defines as the `min()` of this, the + installer ceiling, any external controller's limit, and any PCS import limit + on the feeding circuit. This is one input to that; that is the result. + """ + + charge_current_ceiling_a: int | None = None + """The commissioned maximum `charge_current_limit_a` may not exceed, in amps. + + `config/max-charge-current` or `charge-limit/installer-max`, by the same + resolution. Set at commissioning from the breaker rating and J1772 derating, + and not settable — which is the single Homie attribute distinguishing it + from the property above, so a consumer must never write it. + """ + + charge_current_limit_target_a: int | None = None + """Homie `$target` for the charge-current limit — a command in flight, not a reading. + + Present between a write being accepted and the charger republishing the + value, exactly as `SpanCircuitSnapshot.priority_target` is for a priority + change. A consumer shows it as pending rather than treating it as state. + """ + + charge_current_limit_settable: bool = False + """Whether the charger declares its charge-current limit writable. + + Read from `$settable` on the declaration, defaulting to **False**: absence + means read-only here, the opposite of `load-shed/priority`, because the + limit and the installer ceiling differ by this attribute alone. A consumer + creates a control only where this is true, and the adapter refuses to name a + set topic when it is not. + """ + + connected: bool | None = None + """The enclosure's view of the link to this charger, v1.0 only. + + Documented on `SpanPVSnapshot.connected`, which carries the identical fact + for the other circuit-fed DER class. + + **Not `status`.** That is the OCPP-style session state — whether a vehicle is + plugged in and what it is doing — reported by the charger about the cable in + front of it. This is the enclosure reporting whether it can talk to the + charger at all. A charger with a car plugged in and a dead link publishes + `status="CHARGING"` and `connected=False` at the same time, and a consumer + that renders them as one entity is answering the wrong question in half the + cases. + """ + @dataclass(frozen=True, slots=True) class SpanBatterySnapshot: @@ -85,13 +362,39 @@ class SpanBatterySnapshot: # BESS metadata vendor_name: str | None = None # bess/vendor-name - product_name: str | None = None # bess/product-name - model: str | None = None # bess/model + model: str | None = None # human designation (v1.0 info/model; flat bess/product-name) + part_number: str | None = None # SKU (v1.0 info/part-number; flat bess/model) serial_number: str | None = None # bess/serial-number software_version: str | None = None # bess/software-version nameplate_capacity_kwh: float | None = None # bess/nameplate-capacity (kWh) connected: bool | None = None # bess/connected + # The BESS's own `meter/active-power`, v1.0 only. **Charge-positive**, which + # is a sign flip away from the wire: the enclosure meters the BESS the way it + # meters a circuit, so a charging battery reads negative there and positive + # here, exactly as `SpanCircuitSnapshot.instant_power_w` reports a load's + # consumption positive. The snapshot's rule across every power field is that + # positive means power flowing *out of* the battery, which is discharging. + # That is the frame the eBus specification asks of a device's own meter, and + # it is deliberately NOT the into-the-device rule the circuit fields follow: + # the wire input is in the opposite frame, so one negation lands here rather + # than there. Measured against a producer in self-consumption with the grid + # at zero, where the direction cannot be argued. + # + # Distinct from `SpanPanelSnapshot.power_flow_battery`, which is the + # enclosure's own arbitrated flow figure, passed through untouched and + # charge-positive. The two describe the same physical power in opposite + # frames, so a consumer rendering both must negate one of them; this one is + # already negated. + power_w: float | None = None # v2: bess meter/active-power (W), discharge-positive + + # `status/communication-state`, v1.0 only: the BESS publisher's report of its + # own link health (OK/DEGRADED/LOST/UNKNOWN). **Not** `connected`, which is + # the enclosure's `connection/fed-by-device-status` view of the same device. + # One is the device speaking about itself, the other the panel speaking about + # it, and the migration guide warns against conflating them. + communication_state: str | None = None # v2: bess status/communication-state + @dataclass(frozen=True, slots=True) class FieldMetadata: @@ -104,6 +407,98 @@ class FieldMetadata: unit: str | None # "W", "A", "V", "%", "kWh", None datatype: str # "float", "integer", "enum", "string", "boolean" + resolved: bool = True + """Whether a device declaring this field was actually found. + + Three-way contract with consumers: + + - entry present, ``resolved=True`` — the field is produced; ``unit`` is meaningful + - entry present, ``resolved=False`` — a device of the mapped type is in the + tree but does not declare the property. A real gap; ``unit`` is None. + - **no entry** — no device of that type, or none identifiable for that role. + Nothing will populate the field. + + The second half of that last case is the lugs pair. Both devices declare the + same type and the same ``meter`` node, so which one feeds ``panel.upstream_*`` + and which feeds ``panel.feedthrough_*`` / ``panel.downstream_*`` is decided by + the ``info/direction`` value they publish. A lugs device that publishes no + direction fills neither role, and gets no entry rather than an unresolved one + — deliberately, because the snapshot mapper resolves the pair through the same + call and populates nothing for it either. An unresolved entry would promise a + field that is degraded; there is no such field to degrade. + + Defaulted so existing construction sites are unaffected. This is a + bootstrap dataclass, not a ``SchemaAdapter`` member, so adding it does not + invalidate built adapter wheels or bump ``ADAPTER_CONTRACT_VERSION``. + """ + + +DISCOVERY_NAMESPACE = "discovered" +"""Field-path namespace for properties an adapter declares and does not address. + +Rows under this namespace are **not** curated fields. They name a wire property +the panel's own ``$description`` declares and that the running adapter maps to +no snapshot field and reads nowhere — the runtime half of the +declared-but-unread question, asked of the panel in front of the user rather +than of a vendored capture. + +Namespaced rather than flagged because the failure this prevents is a *silent* +one. A consumer's curated inventories are keyed by snapshot field path +(``panel.``, ``circuit.``, ``battery.``, …), and a discovered row that reached +one of them would be read as a produced field nothing renders, which is the +shape of a real defect. A distinct prefix means the partition is a string test +any consumer can apply once, before any other question is asked of the map, and +that a discovered row landing in a curated set is a visible error rather than an +extra entry nobody notices. + +The path body is ``{device type}/{node}/{property}``, the same rendering the +capability catalogs and the consumer-side gap inventories use, so a maintainer +reading a row can look it up without translating it. +""" + +_DISCOVERY_PREFIX = f"{DISCOVERY_NAMESPACE}." + + +def discovery_path(device_type: str, node_id: str, property_id: str) -> str: + """The namespaced field path for one declared-but-unaddressed property. + + `device_type` is the eBus type with its common ``energy.ebus.device.`` + prefix already stripped by the caller — the adapter owns that vocabulary, + and this function owns only the namespace. + """ + return f"{_DISCOVERY_PREFIX}{device_type}/{node_id}/{property_id}" + + +def is_discovery_path(field_path: str) -> bool: + """Whether `field_path` names a discovered property rather than a curated field.""" + return field_path.startswith(_DISCOVERY_PREFIX) + + +@dataclass(frozen=True, slots=True) +class DiscoveredMetadata(FieldMetadata): + """A metadata row for a property the panel declares and the adapter does not read. + + Only ever appears under `DISCOVERY_NAMESPACE`. Carries the declaration and + nothing else: the property's declared ``unit`` and ``datatype``, and whether + the panel has published a value for it — never the value. These rows exist + to be forwarded to a maintainer through consumer diagnostics, which leave + the machine they were generated on, so the type deliberately has no member a + reading could be put in. + + ``resolved`` is always True here and says nothing new: a discovered row + exists *because* a device declared the property, so the device is found by + construction. `retained` is the question that has an answer. + """ + + retained: bool = False + """Whether any device declaring this property has published a value for it. + + False is the declared-but-never-valued case panelbench's + ``test_declared_but_unvalued`` looks for from the producer side — a property + the firmware advertises and never fills. Distinguishing it matters for the + only decision these rows inform: a declaration with no traffic behind it is + not a surface worth curating yet. + """ @dataclass(frozen=True, slots=True) @@ -143,6 +538,14 @@ class V2HomieSchema: firmware_version: str types_schema_hash: str # SHA-256, first 16 hex chars types: HomieSchemaTypes + # The flat-vs-parent/child discriminator, and the reason this endpoint is + # fetched before MQTT is opened rather than during connect(). Absent on flat + # firmware (r202603-r202627) and present from r202633, which SPAN confirmed + # is a reliable signal over REST — the same one MQTT publishes as + # ``info/data-model-version``. Defaulted so a caller constructing this model + # directly still describes a flat panel, which is what every panel in the + # field is today. + data_model_version: str | None = None @property def panel_size(self) -> int: @@ -171,6 +574,317 @@ def panel_size(self) -> int: raise ValueError(f"Cannot parse max from space format '{fmt}'") from exc +ADOPTION_IDENTITY_NODE = "info" +"""The node whose properties are a device's build identity, never entities. + +`info/model`, `info/serial-number`, `info/firmware-version` and their siblings +describe the thing rather than report a reading. On a curated device they already +land on the device card -- `bess_device_info` has read them that way since v1.0 -- +and an adopted device gets the same treatment for the same reason. +""" + +ADOPTION_TOPOLOGY_NODE = "connection" +"""The node that says what a device hangs off, never entities. + +`connection` answers a device-tree question: which device feeds this one, which +one it feeds, and the health of that link. That is `via_device` and the registry, +not a sensor -- a panel publishing its own wiring should not arrive as a handful +of entities holding opaque device ids. + +The partition is by node rather than by property name deliberately. The eBus +catalogs carry no marker for "this string is a device reference", so a consumer +that wants one has to hard-code the property names, and that list goes stale: +`ebus-sdk`'s own `topology.py` covers `feeds-device-id` and `fed-by-device-id` +and silently omits `grid-forming-entity`, which lives on the `grid` capability. +A node is what the vocabulary defines, so keying on it cannot go stale that way. +""" + + +@dataclass(frozen=True, slots=True) +class AdoptedProperty: + """One property of a device this library models no snapshot field for. + + The counterpart to `DiscoveredMetadata`, and deliberately not the same type. + A discovered row describes a property on a device the adapter *does* model + and exists to be forwarded in diagnostics, so it carries no value by + construction. An adopted property belongs to a device nothing here models at + all, and its whole purpose is to reach a consumer as a reading -- so it + carries the value, and must never be put in diagnostics. + """ + + node_id: str + """The Homie node, e.g. `meter`. + + Never `info` or `connection`: those two resolve to the device card and the + device tree before this type is built. + """ + + property_id: str + """The Homie property, e.g. `active-power`.""" + + datatype: str + """The declared Homie datatype -- `float`, `integer`, `boolean`, `enum`, `string`. + + What a consumer parses the value with, and half of what it picks a platform + with. + """ + + unit: str | None = None + """The declared unit, verbatim. + + `None` when the declaration carries none, which is the normal case for a + `boolean` or an `enum`. + """ + + format: str | None = None + """The declared Homie `$format`: an option list for an `enum`, a + `min:max:step` range for a number. + + Load-bearing for a settable property, because it is the value domain. A + select with no option list and a number with no bounds are not controls a + consumer can build, so its absence is what makes a settable property surface + read-only rather than as a control. + """ + + settable: bool = False + """Whether the panel accepts a write to this property.""" + + value: str | None = None + """The retained value as published, unparsed. + + `None` when the property is declared and nothing has arrived. + """ + + set_topic: str | None = None + """The topic a write to this property is published to, or None. + + Populated **only** for a settable property on an adopted device, and that + scoping is the authorisation rather than a check somebody has to remember. + + The alternative -- a generic `set_property_topic(device, node, property)` on + the adapter -- would be a back door around every curated control, and the + bypass would skip real work: schema_1 has to translate `GRID` into `ON_GRID` + for the islanding assertion, and `evse_charge_limit_payload` *refuses* a + value above the commissioned ceiling because publishing past it is the one + write with a physical consequence. A topic that can only ever exist on a + device nothing models cannot be aimed at either. + + It also keeps this additive. A member on `SchemaAdapter` becomes required of + every adapter package, so an install carrying an older adapter wheel would + fail at *discovery* -- the whole integration, not one feature. + """ + + @property + def path(self) -> str: + """`{node}/{property}` -- how the capability catalogs spell it.""" + return f"{self.node_id}/{self.property_id}" + + +@dataclass(frozen=True, slots=True) +class AdoptedDevice: + """A device on the tree whose type this library models no fields for. + + Adoption is scoped to a whole device rather than to a property, and the + distinction is the design. A new property on a device we *do* model is a + curation task with a short turnaround, and minting an entity for it spends an + entity id permanently on a shape a human would likely have chosen differently + -- the sixteen `pcs` properties that curation collapsed into one entity and + thirteen attributes are the worked example. A device type nothing here models + is the opposite case: no curation is coming, so surfacing it is strictly + better than the silence that ships today. + + Extra instances of a *modelled* type are deliberately not adopted. A second + BESS is a multiplicity limitation, not an unmodelled device, and adopting it + would put a machine-named device card beside a curated one describing the + same class of hardware. + """ + + device_id: str + """The device's own id on the wire. + + Opaque, and per the eBus proxy rule (`{proxier-id}-{proxied-id}`) not + comparable across enclosures -- the same physical device carries different + ids under different proxiers by design. Usable as this panel's local handle, + never as a cross-panel identity. + """ + + device_type: str + """The declared `$type`, e.g. `energy.ebus.device.generator`, verbatim.""" + + name: str | None = None + """The device's declared Homie `name`, when it publishes one.""" + + vendor_name: str | None = None + """`info/vendor-name` -- for the device card.""" + + model: str | None = None + """`info/model` -- for the device card.""" + + serial_number: str | None = None + """`info/serial-number` -- for the device card. + + Deliberately *not* an identity-anchor decision made here. A consumer that + keys a device registry on an anchor must freeze it at first sighting: a + serial arriving on a device already adopted under its wire id is new + information for the card and nothing else, because re-deriving the anchor + turns an upgrade into a device replacement and takes the entities with it. + """ + + software_version: str | None = None + """`info/firmware-version` -- for the device card.""" + + hardware_version: str | None = None + """`info/hardware-version` -- for the device card.""" + + parent: str | None = None + """The device id this device declares as its parent, verbatim. + + Carried rather than acted on. An adopted device is registered under the + enclosure like every other sub-device this library's consumers build, so this + field changes no topology today -- it exists so that the first real panel + carrying a *proxied* unmodelled device tells us its shape instead of having + it flattened away. + + That case is not hypothetical: the reference tree's own `bess-mid` declares + `parent: bess`, which is the specification's `{proxier-id}-{proxied-id}` + naming (`devices/proxy.md`). A vendor gateway proxying its own sub-devices + would arrive the same way, and the parent link is the only structural + information about how they relate. + + Not acted on *yet*, deliberately. `ebus-sdk` 0.21.0 introduced `DeviceSpec` + and `DeviceTreeBuilder` (python-sdk#57) and the maintainer's stated next step + is reconciling the existing graph builder against it rather than landing + both, so the tree model is being reshaped upstream. Building nesting + semantics against a shape under active reconciliation would be building + against a moving target; carrying the field costs nothing and captures the + evidence for when it settles. + """ + + proxied: bool = False + """Whether this device is proxied by a peer rather than by the enclosure. + + True when the declared `parent` is a device other than the tree root. The + distinction the raw `parent` cannot express on its own, because a consumer + holding one device has no way to tell the enclosure's id from a sibling's -- + ids are opaque by design, and per python-sdk#49 a proxied id's prefix is the + *proxier's* id, so the same physical device carries different ids under + different enclosures. + """ + + properties: tuple[AdoptedProperty, ...] = () + """Everything outside `info` and `connection`, in declaration order.""" + + +@dataclass(frozen=True, slots=True) +class ExtensionSubject: + """Which modelled snapshot subject an extension property hangs off. + + The adapter already knows which wire device populated which snapshot subject + -- that mapping is how `battery.power_w` gets a value. This type exposes the + *subject* and never the mapping: a consumer needs "this belongs to the + battery", not "this is how `battery.*` is assembled". Exporting the + field-level map would freeze the adapter's internals as API; exporting the + subject cannot, because it is one value per device drawn from a closed set. + + Resolution is by declared `$type` and is indifferent to proxying: the + reference tree's own MID arrives proxied as `bess-mid` and still resolves to + `mid`. A proxied device of an *unmodelled* type resolves to no subject at all + and belongs to `AdoptedDevice`, which carries `parent` for that shape. + """ + + kind: str + """One of: `panel`, `lugs`, `battery`, `mid`, `pv`, `evse`, `circuit`. + + `lugs` is separate from `panel` although its curated fields land in the panel + snapshot, because a subject is an identity and the two lugs devices are two + devices: they run the same firmware, so a vendor extension on one is the + expected case of the same extension on both, and folding them into `panel` + made two wire addresses one identity. + """ + + instance_key: str | None = None + """The snapshot map key for multi-instance kinds, `None` for the singletons. + + The EVSE's `node_id` and the circuit's `circuit_id` -- the same keys + `snapshot.evse` and `snapshot.circuits` use, so a consumer holding the + snapshot resolves the subject with a lookup it already performs. + """ + + +@dataclass(frozen=True, slots=True) +class ExtensionProperty: + """One property a *modelled* device declares that no snapshot field carries. + + The value-carrying counterpart to `DiscoveredMetadata`, and deliberately a + third type rather than either neighbour. A discovered row exists to be + forwarded in diagnostics -- payloads that leave the machine into issues and + forum posts -- so it carries no value by construction. An `AdoptedProperty` + belongs to a device nothing here models, and its `set_topic` scoping *is* a + write authorisation this type must not inherit. This one belongs to a device + the adapter does model, exists to reach a consumer as a reading, and is + read-only by construction: no set topic, and no member a write path could be + built from. + + **Not a `FieldMetadata`, and that is the diagnostics guarantee.** + `partition()` walks `build_field_metadata()`; this type rides + `build_snapshot()` instead, so there is no code path from here into + `SchemaFindings` or a diagnostics payload. The same wire property appears in + both surfaces on purpose -- as a declaration for the maintainer, as a value + for the user -- joined by the `{node}/{property}` path body. + + **Read-only is not a policy this type states, it is a shape it has.** A + settable extension property is carried with `settable=True` for curation + triage and still surfaces as a reading: a control on a modelled device would + sit beside curated controls that do real safety work (the EVSE limit refuses + a value above the commissioned ceiling; schema_1 translates `GRID` into + `ON_GRID`), and a generic write path would bypass both on the same wire. + """ + + subject: ExtensionSubject + """The curated device this property hangs off.""" + + node_id: str + """The Homie node, e.g. `battery-2`. Never `info` or `connection`.""" + + property_id: str + """The Homie property, e.g. `cell-temperature`.""" + + datatype: str + """The declared Homie datatype -- `float`, `integer`, `boolean`, `enum`, `string`.""" + + unit: str | None = None + """The declared unit, verbatim. `None` is normal for a `boolean` or an `enum`.""" + + format: str | None = None + """The declared `$format`: an option list for an `enum`, `min:max:step` for a number.""" + + settable: bool = False + """Declaration fact, carried for curation triage. + + Deliberately not paired with a set topic. See the read-only note above: the + absence of a write member is what makes the ruling structural rather than + remembered. + """ + + value: str | None = None + """The retained value as published, unparsed. `None` when declared and never valued.""" + + node_has_curated_siblings: bool = False + """Whether the adapter maps any *other* property of this node to a snapshot field. + + The one bit of the node-to-field mapping worth exporting: a vendor extending + `meter` is probably extending the meter. Stamped in one pass over knowledge + the adapter already holds, and it says nothing about *which* fields, so it + freezes no internals. A weak signal -- Homie nodes are organisational rather + than editorial -- and advisory only. + """ + + @property + def path(self) -> str: + """`{node}/{property}` -- how the capability catalogs spell it.""" + return f"{self.node_id}/{self.property_id}" + + @dataclass(frozen=True, slots=True) class SpanPanelSnapshot: """Complete panel state — single point-in-time view.""" @@ -207,15 +921,96 @@ class SpanPanelSnapshot: l1_voltage: float | None = None # v2: core/l1-voltage (V) l2_voltage: float | None = None # v2: core/l2-voltage (V) main_breaker_rating_a: int | None = None # v2: core/breaker-rating (A) - wifi_ssid: str | None = None # v2: core/wifi-ssid + wifi_ssid: str | None = None # v1.0: status/wifi-ssid | flat: core/wifi-ssid vendor_cloud: str | None = None # v2: core/vendor-cloud + # The enclosure's own build identity, for the device card rather than for an + # entity. `None` when the panel publishes nothing, never a default string: + # the consumer has shown its own text since before these were readable, and + # a default invented here would silently replace it. v1.0 only -- flat + # declares none of the three. + vendor_name: str | None = None + """`info/vendor-name` -- who made the enclosure.""" + model: str | None = None + """`info/model` -- the enclosure's model designation, e.g. `MAIN_40`. + + The same property `panel_size` is derived from, kept as the string beside + the derived integer: the size is what circuits are built against, the + designation is what a device card shows. Spelled `model` to match + `battery.model`, `pv.model`, `evse.model` and `mid.model`, all of which name + the same `info/model` property on their own device. + """ + hardware_version: str | None = None + """`info/hardware-version` -- the enclosure's board revision. + + `hardware_version` rather than `hw_version`: the snapshot spells fields out, + and `DeviceInfo(hw_version=...)` is the consumer's abbreviation, not ours. + """ + + # `shed/policy`, v1.0 only: how the panel decides what to shed, and the two + # SoC thresholds that make its behaviour predictable. The wire carries one + # `json` document; these are the parsed answer plus the document itself. + shed_policy: str | None = None + """`shed/policy` verbatim -- the JSON document as published. + + Kept beside the parsed members rather than discarded once parsed, because + the document's schema is versioned in its own `$id` and a publisher may ship + an algorithm this library does not know. The raw string is what lets a + consumer still show what the panel said instead of showing nothing. + """ + shed_policy_algorithm: str | None = None + """The document's `algorithm` member, e.g. `soc-priority.v1`. + + `None` means the property was not published, did not parse, or named no + algorithm -- to a consumer those are one event: there is nothing to render. + A *recognised* name and an unrecognised one are both reported here; only the + thresholds below are gated on recognising it. + """ + shed_soc_threshold_shed_percent: int | None = None + """`parameters.soc-threshold-shed` -- SoC percent below which SOC_THRESHOLD circuits shed. + + Populated only from a `soc-priority.v1` document, because it is that + algorithm's parameter. `0` is a legal threshold, so `None` cannot be + replaced by a default. + """ + shed_soc_threshold_release_percent: int | None = None + """`parameters.soc-threshold-release` -- SoC percent above which shed circuits restore.""" + # Power flows (None when node not present) power_flow_pv: float | None = None # v2: power-flows/pv (W) power_flow_battery: float | None = None # v2: power-flows/battery (W) power_flow_grid: float | None = None # v2: power-flows/grid (W) power_flow_site: float | None = None # v2: power-flows/site (W) + # Backup-planning forecast (`shed-forecast`, v1.0 only; None when the + # enclosure publishes no such node). Minutes, as the capability declares — + # `int` rather than `float` because the wire datatype is `integer` and a + # forecast is not measured to a fraction of a minute. + # + # `None` is load-bearing on all five: a panel that does not publish the node + # must produce no entity, and zero is a legitimate reading ("shedding + # starts now"). Defaulting any of these to 0 would say exactly that. + shed_time_to_priority_shed_min: int | None = None + """`shed-forecast/time-to-priority-shed` — minutes until the next priority tier sheds.""" + shed_total_time_remaining_min: int | None = None + """`shed-forecast/total-time-remaining` — minutes until every sheddable circuit is shed.""" + shed_full_charge_time_to_priority_shed_min: int | None = None + """`shed-forecast/full-charge-time-to-priority-shed` — the same estimate from a full BESS. + + A capability figure, not a countdown: it answers "what would this + installation give me if the battery were full", so it moves when the + hardware or the load profile changes rather than as the battery drains. + """ + shed_full_charge_total_time_remaining_min: int | None = None + """`shed-forecast/full-charge-total-time-remaining` — total runtime from a full BESS.""" + shed_forecast_confidence: str | None = None + """`shed-forecast/confidence` — LOW | MEDIUM | HIGH, the algorithm's self-assessment. + + Kept as the raw wire string. It qualifies the four times rather than + standing alone, and a consumer that shows it beside them needs the value the + catalog's enum defines, not a re-encoding of it. + """ + # Upstream lugs per-phase current (None when not available) upstream_l1_current_a: float | None = None # v2: upstream-lugs/l1-current (A) upstream_l2_current_a: float | None = None # v2: upstream-lugs/l2-current (A) @@ -228,4 +1023,100 @@ class SpanPanelSnapshot: circuits: dict[str, SpanCircuitSnapshot] = field(default_factory=dict) battery: SpanBatterySnapshot = field(default_factory=SpanBatterySnapshot) pv: SpanPVSnapshot = field(default_factory=SpanPVSnapshot) - evse: dict[str, SpanEvseSnapshot] = field(default_factory=dict) # keyed by node_id + evse: dict[str, SpanEvseSnapshot] = field(default_factory=dict) # keyed by serial (see SpanEvseSnapshot.node_id) + mid: SpanMidSnapshot | None = None + """The islanding authority, when the panel publishes one. v1.0 only. + + `None` rather than an empty instance, deliberately. `has_bess` has to guess + presence from `soe_percentage is not None` because the battery field is always + there, and its own docstring records that only that one field is a reliable + signal. A new optional device should not inherit that: presence is + `snapshot.mid is not None`, with nothing to infer. + """ + adopted_devices: tuple[AdoptedDevice, ...] = () + """Devices on the tree whose type this library models no fields for. + + Empty for every adapter that does not answer the question. schema_0 never + populates it: flat has no device tree to find an unmodelled device in, and + panels upgrade to v1.0 and stay there, so adoption operates in the schema + that is the terminus. + + A defaulted snapshot field rather than a `SchemaAdapter` member, on purpose. + The protocol derives its required members from itself, so a new member is + required of every adapter package and invalidates built wheels; a snapshot + field that defaults empty is additive and costs neither. + """ + + extension_properties: tuple[ExtensionProperty, ...] = () + """Properties *modelled* devices declare that no snapshot field carries. + + The other half of vendor extensibility from `adopted_devices` above: that + one covers a device type nothing models, this one a new property on a device + something does. Until this existed the second case reached a consumer + nowhere -- it became a `DiscoveredMetadata` row and stopped at diagnostics. + + Empty is deliberately ambiguous between "none declared" and "this adapter + predates the field", and a consumer must not try to tell them apart: the + older-wheel case is the normal partial-upgrade state, because the adapters + are separately published packages that version independently of this core. + A defaulted snapshot field rather than a protocol member for exactly the + reason `adopted_devices` gives -- a required member would fail at + *discovery*, taking down every install whose adapter lags by one release. + + schema_0 leaves it empty: flat has no device tree to find a declared-but + -unmapped property in, and panels upgrade to v1.0 and stay there. + """ + + pcs: SpanPcsSnapshot | None = None + """The enclosure's Power Control System, when it publishes a `pcs` node. v1.0 only. + + `None` follows `mid` for the same reason, and here the capability states the + rule itself: "absence of the `pcs` node means the device does not run (or + participate in) a Power Control System". A panel with no PCS and a PCS + holding zeros are different facts, and only a nullable member can tell them + apart — every limit in this capture is a legal `0.0`. + """ + + lugs_at_service_entrance: bool = True + """Whether this enclosure's upstream lugs *are* the utility connection point. + + `False` means something sits between the utility and the main lugs, so the + lugs measure flow on the panel side of that device while the utility side + differs by whatever it contributes or absorbs. Two ordinary topologies do + this: an **upstream DER**, a BESS wired ahead of the main lugs, and an + **enclosure chain**, where this panel is fed by another panel rather than by + the service. + + **What it is for.** `instant_grid_power_w` is the upstream lugs' + `meter/active-power`. On a panel at the service entrance that reading *is* + grid flow, which is why the field carries that name. On a panel where this is + `False` it is the panel's own feed, and presenting it as grid power is wrong + -- `power_flow_grid` is then the only site-level figure. The two will + legitimately disagree, and without this a consumer seeing them disagree has + no way to tell a topology from a fault. + + Sourced from the lugs device's `connection/fed-by-device-id`, which the + specification names as the detection mechanism: `power-flows` 0.3 qualified + its own negation table to say the `grid` row holds "only where the lugs are + the utility connection point", and pointed consumers here. The property is + read by this library already; before this field it was consumed for relative + position and otherwise discarded, so no consumer could compute this for + itself. + + Defaults `True`, and the default is a fact rather than an optimism: flat + firmware predates enclosure chaining and publishes no way to express it, so a + flat panel's lugs are its service entrance. schema_0 leaves it alone for that + reason. + + A defaulted snapshot field rather than a `SchemaAdapter` member, for the + reason `adopted_devices` gives above: the protocol derives its required + members from itself, so a new member would be required of every adapter + package and would invalidate built wheels. + + A boolean rather than the intervening device's id, because the id answers a + question nobody downstream asks. What a consumer needs is whether to trust + the lugs as grid; naming the device would invite a second, weaker inference + about *what* is upstream, which the enclosure-chain case cannot support -- + the feeding device is another panel with its own tree, not a child of this + one. + """ diff --git a/src/span_panel_api/mqtt/__init__.py b/src/span_panel_api/mqtt/__init__.py index 6eaebeb..9580610 100644 --- a/src/span_panel_api/mqtt/__init__.py +++ b/src/span_panel_api/mqtt/__init__.py @@ -1,18 +1,17 @@ -"""SPAN Panel MQTT/Homie transport.""" +"""SPAN Panel MQTT/Homie transport. + +Schema-agnostic: nothing here imports a parsing implementation. The flat-schema +parser is reached only through the `span_panel_api.schema_adapters` entry point. +""" -from .accumulator import HomieLifecycle, HomiePropertyAccumulator from .async_client import AsyncMQTTClient from .client import SpanMqttClient from .connection import AsyncMqttBridge -from .homie import HomieDeviceConsumer from .models import MqttClientConfig __all__ = [ "AsyncMQTTClient", "AsyncMqttBridge", - "HomieDeviceConsumer", - "HomieLifecycle", - "HomiePropertyAccumulator", "MqttClientConfig", "SpanMqttClient", ] diff --git a/src/span_panel_api/mqtt/client.py b/src/span_panel_api/mqtt/client.py index af52623..0d17eb7 100644 --- a/src/span_panel_api/mqtt/client.py +++ b/src/span_panel_api/mqtt/client.py @@ -1,6 +1,6 @@ """SPAN Panel MQTT client. -Composes AsyncMqttBridge and HomieDeviceConsumer to implement +Composes AsyncMqttBridge and a SchemaAdapter to implement SpanPanelClientProtocol, CircuitControlProtocol, PanelControlProtocol, and StreamingCapableProtocol. """ @@ -10,20 +10,34 @@ import asyncio from collections.abc import Awaitable, Callable import contextlib +from importlib.metadata import version import logging import time +from typing import TYPE_CHECKING +from span_panel_api.schema_drift import log_schema_drift + +from ..adapters import installed_adapter_keys, resolve_adapter from ..auth import get_homie_schema -from ..exceptions import SpanPanelConnectionError, SpanPanelServerError, SpanPanelStaleDataError -from ..models import FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot -from ..protocol import PanelCapability -from .accumulator import HomiePropertyAccumulator +from ..dispatch import select_adapter_key +from ..exceptions import ( + SpanPanelAdapterIncompatibleError, + SpanPanelAdapterMissingError, + SpanPanelConnectionError, + SpanPanelSchemaVersionError, + SpanPanelServerError, + SpanPanelStaleDataError, + SpanPanelTimeoutError, +) +from ..models import AdoptedProperty, FieldMetadata, HomieSchemaTypes, SpanPanelSnapshot, V2HomieSchema +from ..protocol import PanelCapability, SchemaAdapter from .connection import AsyncMqttBridge -from .const import MQTT_READY_TIMEOUT_S, PROPERTY_SET_TOPIC_FMT, TYPE_CORE, WILDCARD_TOPIC_FMT -from .field_metadata import build_field_metadata, log_schema_drift -from .homie import HomieDeviceConsumer +from .const import MQTT_READY_TIMEOUT_S from .models import MqttClientConfig +if TYPE_CHECKING: + import httpx + _LOGGER = logging.getLogger(__name__) # How long to wait for circuit name properties after device ready. @@ -31,6 +45,36 @@ _CIRCUIT_NAMES_TIMEOUT_S = 10.0 _CIRCUIT_NAMES_POLL_INTERVAL_S = 0.25 +# Re-reading the schema after a suspected generation change. Bounded because the +# caller is a fire-and-forget task on a live connection, and generous enough to +# outlast a panel that is still binding its HTTP port after a restart. +_REDISPATCH_RETRY_INITIAL_S = 1.0 +_REDISPATCH_RETRY_MAX_S = 30.0 +_REDISPATCH_LOG_EVERY = 20 +"""How long to wait for the panel's HTTP endpoint after it returns on MQTT. + +Sized from a live firmware upgrade rather than guessed. The panel dropped MQTT at +11:22:07 and the broker was back at 11:26:15 -- four minutes -- and its HTTP +front end was still answering 502 at that moment. Five attempts capped at 8s +gives up after about 23 seconds, which is not the same order of magnitude as a +device that is still booting: catching the 502 buys nothing if the loop stops +before the panel is ready. + +Twelve attempts backing off to 30s is a little over four minutes. Each one is a +single GET, and the panel is the only thing that can end the wait. +""" + + +def _metadata_for_the_log() -> tuple[list[str], str]: + """Every distribution-metadata read connect() needs, in one place. + + Grouped so there is a single thing to run in a thread rather than two calls + that look unrelated and drift apart — which is exactly what happened once + already, when the adapter keys were moved off the event loop and the version + lookup beside them was not. + """ + return installed_adapter_keys(), version("span-panel-api") + class SpanMqttClient: """MQTT transport — implements all span-panel-api protocols.""" @@ -42,37 +86,169 @@ def __init__( broker_config: MqttClientConfig, snapshot_interval: float = 1.0, panel_http_port: int = 80, + adapter_factory: Callable[[str, V2HomieSchema], SchemaAdapter] | None = None, + data_model_version: str | None = None, + schema_dispatch_reason: str | None = None, + schema: V2HomieSchema | None = None, + httpx_client: httpx.AsyncClient | None = None, ) -> None: self._host = host self._serial_number = serial_number self._broker_config = broker_config self._snapshot_interval = snapshot_interval self._panel_http_port = panel_http_port + self._adapter_factory = adapter_factory + # Shared by the caller, owned by the caller: never closed here, and its + # policy -- timeouts, limits, headers -- is whatever the caller set. That + # is the same rule the four config-flow entry points already state, and + # the reason this exists at all is that the runtime path was the one place + # left without it. See `_get_client`. + self._httpx_client = httpx_client self._bridge: AsyncMqttBridge | None = None - self._accumulator: HomiePropertyAccumulator | None = None - self._homie: HomieDeviceConsumer | None = None + self._adapter: SchemaAdapter | None = None self._streaming = False self._snapshot_callbacks: list[Callable[[SpanPanelSnapshot], Awaitable[None]]] = [] self._connection_callbacks: list[Callable[[bool], None]] = [] + self._schema_change_callbacks: list[Callable[[str | None, str | None], None]] = [] self._live = False self._ready_event: asyncio.Event | None = None self._loop: asyncio.AbstractEventLoop | None = None self._background_tasks: set[asyncio.Task[None]] = set() self._snapshot_timer: asyncio.TimerHandle | None = None - self._field_metadata: dict[str, FieldMetadata] | None = None self._schema_hash: str | None = None self._previous_schema_types: HomieSchemaTypes | None = None - # Cached at connect() so the pre-rebuild hook can reconstruct the - # Homie accumulator with the same panel size after a transport-level - # rebuild. Schema cannot change within a session, so caching is safe. - self._panel_size: int | None = None - - def _require_homie(self) -> HomieDeviceConsumer: - """Return the HomieDeviceConsumer, raising if not yet connected.""" - if self._homie is None: + # Supplied by create_span_client, which already fetched it to dispatch + # on; None when constructed directly, in which case connect() fetches. + # Either way it is cached for the pre-rebuild hook, which rebuilds the + # parser after a transport-level rebuild. + # + # The cache used to be justified as "a panel cannot change schema within a + # session". A firmware upgrade does exactly that: the panel disconnects and + # returns as a different generation with the consumer's session still open. + # `_redispatch_if_generation_changed` re-reads it on every reconnect edge and + # replaces this, so the cache is now a per-connection value rather than a + # per-session one. + self._schema = schema + # Diagnostics. create_span_client passes these so they are true from the + # first moment the object exists; constructing directly leaves them + # describing a client that has not dispatched yet, which connect() then + # fills in once it has a schema to dispatch on. + self._data_model_version = data_model_version + self._schema_dispatch_reason = schema_dispatch_reason or "not dispatched" + # The MQTT half of the same signal, filled in as the retained tree arrives and + # checked against the REST half once the tree is complete. `None` means the + # panel published no such property, which is itself the flat answer. + self._observed_data_model_version: str | None = None + # One reconsideration at a time. The MQTT trigger can fire repeatedly while a + # fetch is retrying, and each would otherwise start its own retry loop. + self._redispatch_in_flight = False + + async def _preload_adapter(self, schema: V2HomieSchema) -> None: + """Resolve this schema's adapter in a thread, ahead of building it. + + Everything in ``adapters`` does blocking file I/O: entry-point + enumeration reads distribution metadata, and resolving ``schema_1`` + imports the eBus SDK and jsonschema. Done on the event loop that is a + two-second stall on a cold import cache, which Home Assistant reports as + a blocking call and asks for a bug report about. + + Resolution caches per key for the life of the process, so this leaves + ``_build_adapter``'s own resolve a dict lookup on every path that + follows — including ``_on_pre_rebuild``, which runs from a synchronous + bridge callback with no thread to defer to and depends on exactly that. + + Nothing to do when a factory was injected: that path never consults + discovery, which is what lets an adapter-less install run one. + + Raises: + SpanPanelSchemaVersionError: The version reads as no schema major. + SpanPanelAdapterMissingError: Nothing registers the key it selects. + SpanPanelAdapterIncompatibleError: Something does, and cannot be driven. + """ + if self._adapter_factory is not None: + return + adapter_key, dispatch_reason = select_adapter_key(schema.data_model_version) + await asyncio.to_thread(resolve_adapter, adapter_key, dispatch_reason) + + def _build_adapter(self, schema: V2HomieSchema) -> SchemaAdapter: + """Construct the parser for this session. + + Called from connect() and from the reconnect path — the only two + places a parser is built today. Both await ``_preload_adapter`` first, + so the resolve below is a cache hit and this stays safe to call from a + synchronous context. + + With no injected factory this dispatches on the schema rather than + assuming the flat adapter. That matters because a client can be built + directly, bypassing create_span_client: before, such a client handed a + parent/child panel to the flat parser, which does not fail — it reports + plausible and wrong figures. Dispatch now happens on whichever path a + parser is built, so there is one answer rather than two. + + Resolving the adapter here rather than in ``__init__`` is deliberate: + constructing a client must not require an adapter to be installed, only + building a parser must. That keeps ``import span_panel_api.mqtt.client`` + working in an adapter-less install — the configuration entry-point + discovery exists to support — and puts the failure at the point where it + is actionable. + + Raises: + SpanPanelSchemaVersionError: The panel reports a data-model-version + whose schema major cannot be determined. + SpanPanelAdapterMissingError: No adapter_factory was supplied and no + installed package registers the key this panel needs. + """ + factory = self._adapter_factory + if factory is None: + adapter_key, dispatch_reason = select_adapter_key(schema.data_model_version) + self._data_model_version = schema.data_model_version + self._schema_dispatch_reason = dispatch_reason + factory = resolve_adapter(adapter_key, dispatch_reason) + self._adapter = factory(self._serial_number, schema) + return self._adapter + + @property + def adapter(self) -> SchemaAdapter | None: + """Return the active schema adapter, or None before connect(). + + On transport rebuild (see ``_on_pre_rebuild``), the adapter instance + is replaced with a fresh one — any callback registered via + ``adapter.register_property_callback(...)`` on the old instance does + not survive the rebuild and must be re-registered on the new one. + """ + return self._adapter + + @property + def schema_major(self) -> str | None: + """Return the active adapter's schema major, or None before connect().""" + return self._adapter.schema_major if self._adapter is not None else None + + @property + def data_model_version(self) -> str | None: + """Return the panel's observed data-model-version, or None if absent/not yet dispatched.""" + return self._data_model_version + + @property + def schema_dispatch_reason(self) -> str: + """Return the human-readable reason the active adapter was selected.""" + return self._schema_dispatch_reason + + @property + def installed_adapters(self) -> list[str]: + """Return the sorted keys every installed package registers an adapter for. + + Registered, not vetted — see ``installed_adapter_keys``. Reads + distribution metadata off disk on first call, so an event loop should + reach it through a thread. + """ + return installed_adapter_keys() + + def _require_adapter(self) -> SchemaAdapter: + """Return the SchemaAdapter, raising if not yet connected.""" + if self._adapter is None: raise SpanPanelConnectionError("Client not connected — call connect() first") - return self._homie + return self._adapter # -- SpanPanelClientProtocol ------------------------------------------- @@ -93,12 +269,25 @@ def serial_number(self) -> str: @property def field_metadata(self) -> dict[str, FieldMetadata] | None: - """Schema-derived metadata for snapshot fields, or None before connect(). + """Schema-derived metadata for snapshot fields, or None before ready. Keyed by snapshot field path (e.g. ``"panel.instant_grid_power_w"``). - Built once during ``connect()`` from the Homie schema. + + Computed from the adapter's current view at access time rather than + cached during connect(). Under the parent/child schema the adapter reads + each device's `$description`, which has not arrived when connect() runs + its setup — a value captured there is permanently empty. Returning None + until the adapter is ready keeps the documented none-before-connect + sentinel and keeps "not ready" distinguishable from "ready with nothing". + + Cost: the schema_1 walk is devices x nodes x properties — under a + thousand dict operations for a 40-circuit panel — against an access rate + of once per connect-session. """ - return self._field_metadata + adapter = self._adapter + if adapter is None or not adapter.is_ready(): + return None + return adapter.build_field_metadata() async def connect(self) -> None: """Connect to MQTT broker and wait for Homie device ready. @@ -107,7 +296,7 @@ async def connect(self) -> None: 1. Fetch Homie schema to determine panel size 2. Create AsyncMqttBridge with broker credentials 3. Connect to MQTT broker - 4. Subscribe to ebus/5/{serial}/# + 4. Subscribe to the adapter's topics 5. Wait for $state==ready and $description parsed Raises: @@ -117,11 +306,36 @@ async def connect(self) -> None: self._loop = asyncio.get_running_loop() self._ready_event = asyncio.Event() - # Fetch schema to determine panel size and build field metadata - schema = await get_homie_schema(self._host, port=self._panel_http_port) - self._panel_size = schema.panel_size - self._accumulator = HomiePropertyAccumulator(self._serial_number) - self._homie = HomieDeviceConsumer(self._accumulator, schema.panel_size) + # create_span_client already fetched this to dispatch on; refetching + # would be a second call to the same unauthenticated endpoint for a + # value that cannot have changed. A directly-constructed client has no + # schema yet, so it fetches here and dispatches in _build_adapter. + schema = ( + self._schema + if self._schema is not None + else await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client) + ) + self._schema = schema + await self._preload_adapter(schema) + adapter = self._build_adapter(schema) + + # Both halves of this line read distribution metadata off disk, and both + # have to be gathered before it is logged. `version()` is the less obvious + # one — it opens this package's own dist-info METADATA — and it was left + # on the loop when its sibling was moved off, which Home Assistant went on + # reporting as three blocking calls after the rest was fixed. + # + # Threaded on their own account rather than relying on the preload above, + # which skips discovery entirely when a factory was injected. + installed, library_version = await asyncio.to_thread(_metadata_for_the_log) + _LOGGER.info( + "MQTT adapter selected: %s (span-panel-api %s)\n data-model-version: %r\n reason: %s\n installed: %s", + adapter.schema_major, + library_version, + self._data_model_version, + self._schema_dispatch_reason, + installed, + ) # Detect schema drift from previous connection new_hash = schema.types_schema_hash @@ -136,9 +350,6 @@ async def connect(self) -> None: self._schema_hash = new_hash self._previous_schema_types = schema.types - # Build transport-agnostic field metadata from schema - self._field_metadata = build_field_metadata(schema.types) - _LOGGER.debug( "MQTT: Creating bridge to %s:%s (serial=%s)", self._broker_config.broker_host, @@ -174,14 +385,15 @@ async def connect(self) -> None: _LOGGER.debug("MQTT: Broker connected, subscribing...") # Subscribe to all device topics - wildcard = WILDCARD_TOPIC_FMT.format(serial=self._serial_number) - self._bridge.subscribe(wildcard, qos=0) - _LOGGER.debug("MQTT: Subscribed to %s, waiting for Homie ready...", wildcard) + topics = self._require_adapter().topics_to_subscribe() + for topic in topics: + self._bridge.subscribe(topic, qos=0) + _LOGGER.debug("MQTT: Subscribed to %s, waiting for Homie ready...", topics) # Wait for Homie ready state try: await asyncio.wait_for(self._ready_event.wait(), timeout=MQTT_READY_TIMEOUT_S) - except asyncio.TimeoutError as exc: + except TimeoutError as exc: await self.close() raise SpanPanelConnectionError(f"Timed out waiting for Homie device ready ({self._serial_number})") from exc @@ -191,8 +403,59 @@ async def connect(self) -> None: # may arrive after $state=ready). Without this, the first snapshot # has empty circuit names and entities are created without labels. await self._wait_for_circuit_names(timeout=_CIRCUIT_NAMES_TIMEOUT_S) + + self._assert_transports_agree_on_schema_generation() _LOGGER.debug("MQTT: Connection fully established") + def _assert_transports_agree_on_schema_generation(self) -> None: + """Refuse a panel whose two schema-generation signals disagree. + + The migration guide's "Schema-generation detection" carries one rule on two + transports: MQTT ``info/data-model-version`` absent = flat, present = + parent/child; REST ``dataModelVersion`` absent = flat, exactly mirroring the + MQTT signal. Dispatch reads REST, because the adapter decides which topics to + subscribe to and so must exist before the first SUBSCRIBE. That makes the MQTT + value a free second opinion, and until now nothing looked at it. + + Nothing looking at it is how a v1.0 panel gets parsed by the flat adapter in + silence: a producer that publishes the MQTT property but omits the REST one + dispatches to ``schema_0``, every value in the tree is read against the wrong + vocabulary, and the connection reports success. Wrong numbers, no error. + + Raising rather than warning follows the rule dispatch already applies to an + unparseable version: an unknown schema generation means every value in the + tree may be misread, and the blast radius is the whole panel. A disagreement + is that same situation with a second witness. + + Compared by the adapter each value *selects*, not by string equality -- + ``'1.0'`` and ``'1.0.3'`` are both parsed by ``schema_1``, and failing that + pair would be a false alarm about a patch release. + """ + observed = self._observed_data_model_version + reported = self._data_model_version + try: + observed_key, _ = select_adapter_key(observed) + reported_key, _ = select_adapter_key(reported) + except SpanPanelSchemaVersionError: + # One of them is present but unparseable. Dispatch already refused on the + # REST value before we got here, so this is the MQTT one -- report it as + # the disagreement it is rather than re-raising a message about REST. + raise SpanPanelSchemaVersionError( + f"Panel {self._serial_number} publishes MQTT info/data-model-version=" + f"{observed!r}, which no adapter major can be read from, while REST " + f"reports dataModelVersion={reported!r}" + ) from None + + if observed_key != reported_key: + raise SpanPanelSchemaVersionError( + f"Panel {self._serial_number} disagrees with itself about its schema " + f"generation: REST dataModelVersion={reported!r} selects " + f"{reported_key!r}, MQTT info/data-model-version={observed!r} selects " + f"{observed_key!r}. The migration guide requires the two to mirror each " + f"other; parsing the tree with either parser would misread values the " + f"other owns." + ) + async def close(self) -> None: """Disconnect from broker and clean up.""" self._streaming = False @@ -203,14 +466,13 @@ async def close(self) -> None: if self._bridge is not None: await self._bridge.disconnect() self._bridge = None - self._accumulator = None self._live = False async def ping(self) -> bool: """Check if MQTT connection is alive and device is ready.""" - if self._bridge is None or self._homie is None: + if self._bridge is None or self._adapter is None: return False - return self._bridge.is_connected() and self._homie.is_ready() + return self._bridge.is_connected() and self._adapter.is_ready() def register_connection_callback(self, callback: Callable[[bool], None]) -> Callable[[], None]: """Subscribe to broker connection state transitions. @@ -231,6 +493,29 @@ def unregister() -> None: return unregister + def register_schema_change_callback(self, callback: Callable[[str | None, str | None], None]) -> Callable[[], None]: + """Subscribe to the panel changing schema generation mid-session. + + Fires with ``(previous_version, new_version)`` after the parser has been + rebuilt, so a consumer reading the client inside the callback sees the new + generation rather than the one being replaced. + + This exists because swapping the parser is not the whole job. It fixes + *reading* — values resolve again immediately — but a consumer that built + devices and entities from the old tree still has the old topology: v1.0 adds + a MID that the flat tree has no equivalent for, and re-keys EVSEs. Only the + consumer knows how to rebuild that, so it is told rather than guessed at. + + Returns an unregister function. Calling it twice is safe. + """ + self._schema_change_callbacks.append(callback) + + def unregister() -> None: + with contextlib.suppress(ValueError): + self._schema_change_callbacks.remove(callback) + + return unregister + async def get_snapshot(self) -> SpanPanelSnapshot: """Return current snapshot from accumulated MQTT state. @@ -242,13 +527,13 @@ async def get_snapshot(self) -> SpanPanelSnapshot: No network call — snapshot is built from in-memory property values when the liveness checks pass. """ - if self._bridge is None or self._homie is None: + if self._bridge is None or self._adapter is None: raise SpanPanelStaleDataError("Client not connected — call connect() first") if not self._bridge.is_connected(): raise SpanPanelStaleDataError("MQTT broker disconnected") - if not self._homie.is_ready(): + if not self._adapter.is_ready(): raise SpanPanelStaleDataError("Homie device not ready") - return self._homie.build_snapshot() + return self._adapter.build_snapshot() # -- CircuitControlProtocol -------------------------------------------- @@ -259,35 +544,120 @@ async def set_circuit_relay(self, circuit_id: str, state: str) -> None: circuit_id: Dashless UUID (matches wire format) state: "OPEN" or "CLOSED" """ - topic = PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="relay") + topic = self._require_adapter().set_circuit_relay_topic(circuit_id) if self._bridge is not None: self._bridge.publish(topic, state, qos=1) async def set_circuit_priority(self, circuit_id: str, priority: str) -> None: - """Publish shed-priority change for a circuit. + """Publish a circuit priority change. Args: circuit_id: Dashless UUID (matches wire format) priority: v2 enum value (NEVER, SOC_THRESHOLD, OFF_GRID) """ - topic = PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=circuit_id, prop="shed-priority") + topic = self._require_adapter().set_circuit_priority_topic(circuit_id) if self._bridge is not None: self._bridge.publish(topic, priority, qos=1) # -- PanelControlProtocol ---------------------------------------------- async def set_dominant_power_source(self, value: str) -> None: - """Publish dominant-power-source change to the core node. + """Publish a dominant power source change for the panel. Args: value: DPS enum value (GRID, BATTERY, NONE, GENERATOR, PV) + + The adapter names both the topic and the payload, because the two + schemas do not accept the same values. Flat takes this vocabulary + directly; v1.0 routes the command to `shed/asserted-islanding-state`, + whose enum is `NONE`/`ON_GRID`/`OFF_GRID`. Publishing `value` unchanged + would put a string outside that enum on the wire. """ - core_node = self._require_homie().find_node_by_type(TYPE_CORE) - if core_node is None: + adapter = self._require_adapter() + topic = adapter.set_dominant_power_source_topic() + if topic is None: raise SpanPanelServerError("Core node not found in panel topology") - topic = PROPERTY_SET_TOPIC_FMT.format(serial=self._serial_number, node=core_node, prop="dominant-power-source") + payload = adapter.dominant_power_source_payload(value) + if payload is None: + raise SpanPanelServerError(f"{value!r} has no representation on this schema's control") + if self._bridge is not None: + self._bridge.publish(topic, payload, qos=1) + + # -- EvseControlProtocol ----------------------------------------------- + + async def set_evse_charge_limit(self, node_id: str, amps: int) -> None: + """Publish a charge-current limit for one commissioned EV charger. + + Args: + node_id: the key this charger has in `SpanPanelSnapshot.evse` + amps: the new ceiling, in amps + + Shaped like `set_dominant_power_source` and for the same reason: the + adapter names both the topic and the payload, because only it knows + which property this panel's charger declares settable and what bounds + it. Two refusals rather than one, so the error says which happened — + "no such control" and "that value may not be written" are different + facts and a user can act on only one of them. + """ + adapter = self._require_adapter() + topic = adapter.set_evse_charge_limit_topic(node_id) + if topic is None: + raise SpanPanelServerError(f"No settable charge-current limit on EVSE {node_id!r}") + payload = adapter.evse_charge_limit_payload(node_id, amps) + if payload is None: + raise SpanPanelServerError(f"{amps} A is outside what EVSE {node_id!r} accepts") if self._bridge is not None: - self._bridge.publish(topic, value, qos=1) + self._bridge.publish(topic, payload, qos=1) + + # -- AdoptedControlProtocol -------------------------------------------- + + async def set_adopted_property(self, device_id: str, node_id: str, property_id: str, value: str) -> None: + """Publish a write to one settable property of an adopted device. + + Args: + device_id: the adopted device's wire id, as `AdoptedDevice.device_id` + node_id: the Homie node + property_id: the Homie property + value: the payload, already in the property's declared vocabulary + + **The lookup is the authorisation.** No topic is accepted from the + caller: this finds the property in the current snapshot's adopted + devices and publishes to the topic that property carries. A device the + adapter models has no `AdoptedDevice`, and a property the device does not + declare settable carries no `set_topic`, so neither can be reached from + here however the arguments are spelled. That is what keeps this from + being a generic write that routes around the curated setters -- which + would skip real work, since the islanding assertion needs its value + translated and the charge-current ceiling refuses values above what the + charger was commissioned for. + + No payload translation and no bounds check, deliberately. Both exist on + curated controls because this library knows what those properties mean. + It knows nothing about an adopted one beyond its declaration, and + inventing a bound would be inventing a fact about somebody's hardware. + The caller constrains the value to the declared `format`; the panel + remains the authority on whether to accept it. + """ + surface = self._adopted_property(device_id, node_id, property_id) + if surface is None or surface.set_topic is None: + raise SpanPanelServerError(f"No settable adopted property {node_id}/{property_id} on device {device_id!r}") + if self._bridge is not None: + self._bridge.publish(surface.set_topic, value, qos=1) + + def _adopted_property(self, device_id: str, node_id: str, property_id: str) -> AdoptedProperty | None: + """The named property of the named adopted device in the current snapshot. + + Built fresh rather than cached: a device that has left the tree must stop + being writable the moment it does, and a snapshot is the only thing that + knows. + """ + for device in self._require_adapter().build_snapshot().adopted_devices: + if device.device_id != device_id: + continue + for surface in device.properties: + if surface.node_id == node_id and surface.property_id == property_id: + return surface + return None # -- StreamingCapableProtocol ------------------------------------------ @@ -322,18 +692,39 @@ async def stop_streaming(self) -> None: def _on_message(self, topic: str, payload: str) -> None: """Handle incoming MQTT message (called from asyncio loop).""" - homie = self._homie - if homie is None: + # The bootstrap signal, observed rather than parsed, and deliberately ahead of + # the adapter guard: reading it is what tells the generations apart, so it + # cannot be something only a chosen parser can do. + # + # Matched on suffix so no Homie domain constant has to exist in the transport. + # Only the root device's copy counts -- under parent/child every device has an + # `info` node, and a child's copy would otherwise overwrite the panel's answer + # depending on retained-message ordering. + if topic.endswith(f"/{self._serial_number}/info/data-model-version"): + self._observed_data_model_version = payload or None + # This is the trigger for a mid-session generation change, not the + # reconnect edge. The edge fires the instant the broker accepts a + # connection, which on a real upgrade is *before* the panel has bound + # its HTTP port -- observed as `Cannot reach panel` roughly 25ms after + # reconnect, with no further edge to retry on because MQTT had already + # succeeded. The retained tree arrives only once the new panel is + # actually publishing, which makes this the first moment the answer + # exists at all. + self._schedule_redispatch() + + adapter = self._adapter + if adapter is None: return - was_ready = homie.is_ready() - homie.handle_message(topic, payload) + + was_ready = adapter.is_ready() + adapter.handle_message(topic, payload) # Check if device just became ready - if not was_ready and homie.is_ready() and self._ready_event is not None: + if not was_ready and adapter.is_ready() and self._ready_event is not None: self._ready_event.set() # Dispatch snapshot callbacks if streaming - if self._streaming and homie.is_ready() and self._loop is not None: + if self._streaming and adapter.is_ready() and self._loop is not None: if self._snapshot_interval <= 0: # Real-time mode — dispatch immediately, no debounce. self._create_dispatch_task() @@ -358,9 +749,15 @@ def _on_connection_change(self, connected: bool) -> None: # edge-only (see the guard after this block). if connected: _LOGGER.debug("MQTT connection established") - if self._bridge is not None: - wildcard = WILDCARD_TOPIC_FMT.format(serial=self._serial_number) - self._bridge.subscribe(wildcard, qos=0) + if self._bridge is not None and self._adapter is not None: + for topic in self._adapter.topics_to_subscribe(): + self._bridge.subscribe(topic, qos=0) + # A reconnect can be a different panel generation than the one we + # dispatched on. Checked only on a real edge, because paho re-emits + # connected=True after session restoration and refetching the schema + # on each of those would be a HTTP round trip per duplicate. + if not self._live: + self._schedule_redispatch() else: _LOGGER.debug("MQTT connection lost") # Cancel any pending snapshot-debounce timer so it cannot @@ -379,6 +776,239 @@ def _on_connection_change(self, connected: bool) -> None: except Exception: # pylint: disable=broad-exception-caught _LOGGER.warning("Connection callback raised", exc_info=True) + def _schedule_redispatch(self) -> None: + """Reconsider the panel's schema generation, off the calling callback. + + Both callers are synchronous — the connection-change handler and the message + handler — and the work is a HTTP round trip, so it is handed to the loop. + + Cheap to call often: the MQTT trigger fires on every retained + `info/data-model-version`, and the common case is that it agrees with the + active adapter. That is answered here without scheduling anything, so a + steady-state panel costs one string comparison per republish. + """ + if self._loop is None or self._adapter is None: + # No loop means connect() never ran, so there is nothing dispatched to + # reconsider and no loop to schedule the reconsideration on. + return + if self._redispatch_in_flight: + return + if not self._generation_appears_changed(): + return + self._redispatch_in_flight = True + task = self._loop.create_task(self._redispatch_if_generation_changed()) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + def _generation_appears_changed(self) -> bool: + """Whether any signal we hold suggests a parser other than the active one. + + Deliberately permissive: it gates scheduling, not the swap itself. The + authoritative comparison happens in `_redispatch_if_generation_changed` + against a freshly fetched REST schema, so a false positive here costs one + HTTP request and a false negative costs a missed upgrade. + """ + try: + active, _ = select_adapter_key(self._data_model_version) + observed, _ = select_adapter_key(self._observed_data_model_version) + except SpanPanelSchemaVersionError: + # Unreadable version. Let the full path report it properly. + return True + return active != observed + + async def _fetch_schema_with_retry(self) -> V2HomieSchema | None: + """Read the panel's REST schema, waiting for HTTP to catch up with the broker. + + A panel that has just restarted accepts MQTT before it serves HTTP — the + broker is listening while the application is still binding its port. The + first attempt at a real upgrade failed 25ms after reconnect with + `Cannot reach panel`, and because MQTT had reconnected successfully there + was no further edge to retry on, leaving the wrong parser in place for the + rest of the session. + + **This waits as long as it takes, and that is deliberate.** Every bounded + version of it has been wrong, twice for the same reason: the bound was + sized against a reboot somebody had measured, and the next reboot was not + that reboot. Giving up has no upside to weigh against being wrong. The + triggers for another attempt are the reconnect edge and the retained + `data-model-version` message, and a panel that finishes booting after the + loop gave up produces neither — so exhausting a bound does not mean + "try again later", it means stranded until somebody reloads by hand. + + Nor does waiting cost the freshness of anything. Energy sensors already + hold their last valid reading through an outage on their own grace period, + which exists precisely so a gap does not become an `unknown` and a + statistics spike; that mechanism is untouched by how long this waits, and + it is the thing that would have justified a deadline here. What is left is + one HTTP GET every thirty seconds to a device on the local network, which + is less traffic than the ordinary snapshot poll. + + Ends on success, on cancellation — `close()` cancels this task, so unload + and shutdown are prompt — or on an error that is not the panel still + coming up, which is left to raise. + """ + delay = _REDISPATCH_RETRY_INITIAL_S + attempts = 0 + while True: + try: + return await get_homie_schema(self._host, port=self._panel_http_port, httpx_client=self._httpx_client) + except ( + SpanPanelConnectionError, + SpanPanelTimeoutError, + # The panel answering rather than refusing: a 5xx from its front + # end while the application behind it starts, or a 200 carrying a + # body that cannot be a schema. The ordinary shape of a reboot, + # because a device brings its network stack and proxy up before + # its application -- and the shape that stranded two live installs + # when it was not caught here. + SpanPanelServerError, + ) as exc: + attempts += 1 + if attempts == 1 or attempts % _REDISPATCH_LOG_EVERY == 0: + # First failure, then occasionally. A panel that never returns + # would otherwise write a line every thirty seconds forever, + # and the second line is worth no more than the first. + _LOGGER.warning( + "Panel is not serving its schema yet (%s). Attempt %d; still " + "waiting, and the parser stays as it is until it answers.", + exc, + attempts, + ) + await asyncio.sleep(delay) + delay = min(delay * 2, _REDISPATCH_RETRY_MAX_S) + + async def _redispatch_if_generation_changed(self) -> None: + """Swap the parser when the panel comes back as a different schema generation. + + The adapter is chosen once, at connect, from the REST `dataModelVersion`. + Everything after that reuses it: `connect()` short-circuits on the cached + `self._schema`, the reconnect path re-subscribes with the existing adapter's + topics, and `_on_pre_rebuild` rebuilds from the cached schema on the stated + assumption that "the Homie schema cannot change within a session". + + A firmware upgrade breaks that assumption exactly. The panel disconnects and + returns as a different generation while the consumer's session is still open + — no reload, no new `connect()`, so nothing ever reconsiders. Observed as a + flat panel upgrading to v1.0 underneath a live client: the client reconnected, + kept the flat parser, and read the v1.0 tree with it. It logged one + `Invalid $description JSON` and then reported every circuit as missing, which + is a wrong answer rather than an error. + + Failure here is deliberately non-fatal. The panel is reachable over MQTT or + this callback would not be running, and its HTTP endpoint may lag that by + seconds while it finishes booting; treating a refused fetch as fatal would + turn a slow boot into a dead integration. The generation is re-read on the + next reconnect, and a stale parser reports missing data rather than wrong + data, because the two schemas do not share a topic shape. + """ + try: + await self._redispatch_once() + except Exception: # pylint: disable=broad-exception-caught + # Nothing may escape here. This runs as a fire-and-forget task, so an + # escaping exception becomes "Task exception was never retrieved" in + # the log and the parser silently stays on the old generation -- + # which is the failure this whole method exists to prevent, arrived at + # by a different route. That is not hypothetical: a 502 from a + # rebooting panel did exactly this on two live installs. + # + # Logged at ERROR with the consequence spelled out, because the user's + # remedy is a reload and nothing else will tell them so. + _LOGGER.error( + "Could not follow the panel's schema-generation change; the %r parser is " + "unchanged and its data will read as missing. Reload the integration once " + "the panel is fully back up.", + self._data_model_version, + exc_info=True, + ) + finally: + # Released only when the swap is finished, not when the fetch is. + # Clearing it after the fetch left a window that the slowest step in + # the method sits inside: `_preload_adapter` imports the new parser in + # a thread and takes seconds on a cold schema_1 import, and through + # all of it `_data_model_version` still holds the old value, so + # `_generation_appears_changed()` was still true. A second retained + # `data-model-version` message -- or the connect edge -- scheduled a + # second redispatch, and the consumer got two schema-change callbacks + # for one upgrade. The integration reloads its config entry off that + # callback, so that is a reload racing its own teardown. + self._redispatch_in_flight = False + + async def _redispatch_once(self) -> None: + """The body of one redispatch. See `_redispatch_if_generation_changed`.""" + schema = await self._fetch_schema_with_retry() + if schema is None: + return + + before = self._data_model_version + try: + new_key, _ = select_adapter_key(schema.data_model_version) + old_key, _ = select_adapter_key(before) + except SpanPanelSchemaVersionError: + _LOGGER.warning( + "Panel reports data-model-version %r after reconnect, which no adapter " + "major can be read from; keeping the %r parser", + schema.data_model_version, + before, + ) + return + + if new_key == old_key: + return + + # Before anything is mutated, because this is where the upgrade can turn + # out to be one this install cannot follow: a flat panel that becomes + # v1.0 needs a package a flat-only install has no reason to have. The + # caller is a fire-and-forget task, so an escaping error would surface as + # a bare traceback; naming the missing package and keeping the parser we + # have is the same non-fatal stance the fetch retry takes above. + try: + await self._preload_adapter(schema) + except (SpanPanelAdapterMissingError, SpanPanelAdapterIncompatibleError) as exc: + _LOGGER.error( + "Panel upgraded from schema generation %s to %s, but this install cannot " + "parse the new one: %s. Keeping the %s parser, which will report missing " + "data rather than wrong data until the adapter is installed.", + old_key, + new_key, + exc, + old_key, + ) + return + + _LOGGER.warning( + "Panel changed schema generation while connected: data-model-version %r -> " + "%r (%s -> %s). Rebuilding the parser; entities will repopulate from the " + "new tree.", + before, + schema.data_model_version, + old_key, + new_key, + ) + self._schema = schema + # Set here rather than relying on `_build_adapter`, which only records it on + # the dispatching path. A client constructed with an injected `adapter_factory` + # skips that branch, and would go on reporting the generation it started with + # after having been rebuilt for a different one. + self._data_model_version = schema.data_model_version + adapter = self._build_adapter(schema) + # Ready is a property of the tree, and this is a different tree. Leaving the + # old event set would let `is_ready()` answer for a parser that has not seen + # a single message yet. + self._ready_event = asyncio.Event() + if self._bridge is not None: + for topic in adapter.topics_to_subscribe(): + self._bridge.subscribe(topic, qos=0) + + # Announced after the swap, so a consumer inspecting the client from inside + # the callback sees the generation it is being told about. Iterate a copy — + # a subscriber may unregister while handling this, and reloading a config + # entry (the expected response) tears down the very object that registered. + for cb in list(self._schema_change_callbacks): + try: + cb(before, schema.data_model_version) + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.warning("Schema-change callback raised", exc_info=True) + def _on_pre_rebuild(self) -> None: """Reset Homie accumulator state before the bridge rebuilds its paho client. @@ -387,40 +1017,49 @@ def _on_pre_rebuild(self) -> None: any stale `$state=disconnected` cached during the outage so the new subscription's retained messages repopulate from a clean slate. - Schema-derived state (`_field_metadata`, `_schema_hash`, + Schema-derived state (`_schema`, `_schema_hash`, `_previous_schema_types`) is intentionally preserved — the Homie schema cannot change within a session, so the cache remains valid and a refetch would just add cost. If the panel reboots and the schema actually changed, the existing drift-detection log fires on - the next session's `connect()`. + the next session's `connect()`. `field_metadata` needs no preserving: + it reads the live adapter, so it re-derives itself from the rebuilt + tree once that tree is ready again. + + A cached schema is also what makes the rebuild safe to run from a + synchronous callback. ``_build_adapter`` can raise — on an unreadable + version, or on a key nothing provides — but a cached schema means + connect() already dispatched and resolved successfully on this exact + value, so neither can fail here. The guard below is what enforces that: + no schema means connect() never completed, and there is nothing to + rebuild. """ - if self._panel_size is None: - # Pre-rebuild fired before connect() cached the panel size. - # Treat as a no-op — there is no accumulator state to reset - # because connect() never completed. + if self._schema is None: + # Pre-rebuild fired before connect() cached the schema. Treat as a + # no-op — there is no accumulator state to reset because connect() + # never completed. return _LOGGER.debug("Pre-rebuild — resetting Homie accumulator") - self._accumulator = HomiePropertyAccumulator(self._serial_number) - self._homie = HomieDeviceConsumer(self._accumulator, self._panel_size) + self._build_adapter(self._schema) async def _wait_for_circuit_names(self, timeout: float) -> None: """Wait for all circuit-like nodes to have a ``name`` property. Retained MQTT messages may arrive after the Homie device transitions - to ready. This polls the HomieDeviceConsumer at short intervals and + to ready. This polls the schema adapter at short intervals and returns as soon as all circuit names are populated, or when the timeout elapses (non-fatal — entities will use fallback names). """ - homie = self._require_homie() + adapter = self._require_adapter() deadline = time.monotonic() + timeout while time.monotonic() < deadline: - missing = homie.circuit_nodes_missing_names() + missing = adapter.circuit_nodes_missing_names() if not missing: _LOGGER.debug("All circuit names received") return await asyncio.sleep(_CIRCUIT_NAMES_POLL_INTERVAL_S) - still_missing = homie.circuit_nodes_missing_names() + still_missing = adapter.circuit_nodes_missing_names() if still_missing: _LOGGER.warning( "Timed out waiting for circuit names (%d still missing): %s", @@ -473,15 +1112,15 @@ async def _dispatch_snapshot(self) -> None: snapshot to subscribers after the fact. """ bridge = self._bridge - homie = self._homie - if bridge is None or not bridge.is_connected() or homie is None or not homie.is_ready(): + adapter = self._adapter + if bridge is None or not bridge.is_connected() or adapter is None or not adapter.is_ready(): _LOGGER.debug( "Skipping stale snapshot dispatch (bridge_connected=%s, homie_ready=%s)", bridge is not None and bridge.is_connected(), - homie is not None and homie.is_ready(), + adapter is not None and adapter.is_ready(), ) return - snapshot = homie.build_snapshot() + snapshot = adapter.build_snapshot() for cb in list(self._snapshot_callbacks): try: await cb(snapshot) diff --git a/src/span_panel_api/mqtt/connection.py b/src/span_panel_api/mqtt/connection.py index 212c81e..2f6ea72 100644 --- a/src/span_panel_api/mqtt/connection.py +++ b/src/span_panel_api/mqtt/connection.py @@ -218,7 +218,7 @@ def _blocking_connect() -> None: # Wait for CONNACK try: await asyncio.wait_for(self._connect_event.wait(), timeout=MQTT_CONNECT_TIMEOUT_S) - except asyncio.TimeoutError as exc: + except TimeoutError as exc: await self.disconnect() raise SpanPanelTimeoutError(f"Timed out connecting to MQTT broker at {self._host}:{self._port}") from exc diff --git a/src/span_panel_api/mqtt/const.py b/src/span_panel_api/mqtt/const.py index b5bc893..ac49f40 100644 --- a/src/span_panel_api/mqtt/const.py +++ b/src/span_panel_api/mqtt/const.py @@ -1,18 +1,5 @@ """Constants for SPAN Panel MQTT/Homie transport.""" -# Homie v5 topic structure -HOMIE_VERSION = 5 -HOMIE_DOMAIN = "ebus" -TOPIC_PREFIX = f"{HOMIE_DOMAIN}/{HOMIE_VERSION}" - -# Topic patterns (serial_number substituted at runtime) -DEVICE_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}" -STATE_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/$state" -DESCRIPTION_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/$description" -PROPERTY_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}" -PROPERTY_SET_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/{{node}}/{{prop}}/set" -WILDCARD_TOPIC_FMT = f"{TOPIC_PREFIX}/{{serial}}/#" - # Homie device states HOMIE_STATE_INIT = "init" HOMIE_STATE_READY = "ready" @@ -21,18 +8,6 @@ HOMIE_STATE_LOST = "lost" HOMIE_STATE_ALERT = "alert" -# Homie type strings from schema -TYPE_CORE = "energy.ebus.device.distribution-enclosure.core" -TYPE_LUGS = "energy.ebus.device.lugs" -TYPE_LUGS_UPSTREAM = "energy.ebus.device.lugs.upstream" -TYPE_LUGS_DOWNSTREAM = "energy.ebus.device.lugs.downstream" -TYPE_CIRCUIT = "energy.ebus.device.circuit" -TYPE_BESS = "energy.ebus.device.bess" -TYPE_PV = "energy.ebus.device.pv" -TYPE_EVSE = "energy.ebus.device.evse" -TYPE_PCS = "energy.ebus.device.pcs" -TYPE_POWER_FLOWS = "energy.ebus.device.power-flows" - # MQTT connection defaults MQTT_DEFAULT_MQTTS_PORT = 8883 MQTT_DEFAULT_WS_PORT = 9001 @@ -53,19 +28,3 @@ # going through HA's config_entry teardown. Resets after every rebuild attempt so the cadence holds # throughout extended outages. MQTT_FULL_REBUILD_AFTER_FAILURES = 3 - -# Lugs direction values -LUGS_UPSTREAM = "UPSTREAM" -LUGS_DOWNSTREAM = "DOWNSTREAM" - - -def normalize_circuit_id(node_id: str) -> str: - """Strip dashes from Homie UUID for entity stability.""" - return node_id.replace("-", "") - - -def denormalize_circuit_id(circuit_id: str) -> str: - """Restore dashes to a 32-char dashless UUID (8-4-4-4-12 format).""" - if len(circuit_id) == 32 and "-" not in circuit_id: - return f"{circuit_id[:8]}-{circuit_id[8:12]}-{circuit_id[12:16]}-{circuit_id[16:20]}-{circuit_id[20:]}" - return circuit_id diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 11f7e97..e92adaf 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: - from .models import FieldMetadata, SpanPanelSnapshot + from .models import FieldMetadata, SpanPanelSnapshot, V2HomieSchema class PanelCapability(Flag): @@ -65,6 +65,39 @@ class PanelControlProtocol(Protocol): async def set_dominant_power_source(self, value: str) -> None: ... +@runtime_checkable +class EvseControlProtocol(Protocol): + """Control protocol for settable properties on a commissioned EV charger. + + Separate from `PanelControlProtocol` because the subject is different: an + EVSE is its own device under v1.0, several may be commissioned at once, and + every call here names which one. A consumer asks `isinstance` before offering + the control, exactly as it does for circuit and panel control. + """ + + async def set_evse_charge_limit(self, node_id: str, amps: int) -> None: ... + + +@runtime_checkable +class AdoptedControlProtocol(Protocol): + """Control protocol for settable properties on a device nothing here models. + + Separate from the three above because its subject is different in kind. Those + name a control this library understands -- a relay, a shed priority, a charge + ceiling -- and translate or bound the value on the way out. This one names a + property by its wire address and passes the caller's value through, because + the declaration is all anybody here knows about it. + + The write is authorised by the snapshot rather than by the arguments: the + transport resolves the property against the current `adopted_devices` and + refuses anything it does not find carrying a set topic. A device this library + models produces no `AdoptedDevice` and so cannot be addressed here, which is + what stops this becoming a generic write around the curated setters. + """ + + async def set_adopted_property(self, device_id: str, node_id: str, property_id: str, value: str) -> None: ... + + @runtime_checkable class StreamingCapableProtocol(Protocol): """Push-based transport that delivers updates via callbacks.""" @@ -77,3 +110,115 @@ def register_snapshot_callback( async def start_streaming(self) -> None: ... async def stop_streaming(self) -> None: ... + + +ADAPTER_CONTRACT_VERSION = 1 +"""The bootstrap-to-adapter contract this package speaks. + +Bumped only when a change leaves existing adapters unusable — a different +``__init__`` signature, or a method whose meaning changes under an unchanged +name. Purely additive changes do not bump it: ``_derive_required_members`` +already requires every member the protocol declares, so an adapter missing a +newly added method is rejected on that basis alone. + +This exists because member presence is not the whole contract. A Protocol +cannot express signatures at runtime, so an adapter carrying every required +name and the wrong ``__init__`` arity passes discovery and fails much later, +inside the transport, as a bare ``TypeError`` about an argument count. That is +exactly what a stale adapter looks like, and it is the least actionable moment +to find out. A declared integer is checkable at discovery, where the remedy — +upgrade this package — can still be named. + +**Adapters must declare this as a literal, never by importing this constant.** +An adapter that echoes whatever the installed bootstrap defines agrees with +every bootstrap by construction, which is precisely the disagreement being +looked for. The value has to be baked into the adapter's wheel at build time. +""" + + +@runtime_checkable +class SchemaAdapter(Protocol): + """Parser for a single data-model-major schema. + + Frozen within a major version of this package. Most methods here are + called by SpanMqttClient, which is the only bootstrap code that knows + the wire format; ``find_node_by_type`` and ``register_property_callback`` + are not called by the bootstrap at all — they exist for external + consumers of the active adapter. + """ + + ADAPTER_CONTRACT: int + schema_major: str + SUPPORTS_DATA_MODEL_VERSIONS: tuple[str, str] + + def __init__(self, serial_number: str, schema: V2HomieSchema) -> None: + """Construct a parser for one panel session. + + Declared because construction is part of the contract: the transport + resolves an adapter *class* from the entry-point registry and calls it. + + Takes the whole schema rather than anything derived from it. The + previous signature passed ``panel_size``, which the transport extracted + on the adapter's behalf from a block only the flat schema has — so the + bootstrap had to understand a wire format it is supposed to know nothing + about, and any adapter whose schema is shaped differently could not say + so. Each adapter now reads what its own format defines. + """ + + def topics_to_subscribe(self) -> list[str]: ... + + def handle_message(self, topic: str, payload: str) -> None: ... + + def is_ready(self) -> bool: ... + + def build_snapshot(self) -> SpanPanelSnapshot: ... + + def build_field_metadata(self) -> dict[str, FieldMetadata]: ... + + def circuit_nodes_missing_names(self) -> list[str]: ... + + def find_node_by_type(self, type_str: str) -> str | None: ... + + def set_circuit_relay_topic(self, circuit_id: str) -> str: ... + + def set_circuit_priority_topic(self, circuit_id: str) -> str: ... + + def set_dominant_power_source_topic(self) -> str | None: ... + + def set_evse_charge_limit_topic(self, node_id: str) -> str | None: + """The topic that writes one charger's charge-current limit, or None. + + `node_id` is the key the snapshot's `evse` map uses, so a caller needs + nothing but the snapshot it already has. Returning None means this + schema, this panel, or this charger offers no such control — no + property, or one the charger does not declare `$settable` — and the + transport must refuse the command rather than publish to it. + + Named at runtime from the charger's own `$description` under v1.0, + because the node carrying the limit is one of two spellings and the + `$description` is the specification's authority on which. See + `span_panel_api_schema_1.charge_limit`. + """ + + def evse_charge_limit_payload(self, node_id: str, amps: int) -> str | None: + """Translate a requested amperage into what this charger accepts. + + Returning None means the value may not be published — above the + commissioned ceiling, or otherwise outside what the declaration allows. + The transport refuses rather than clamping: a silently clamped write + reports a limit the charger is not enforcing. + """ + + def dominant_power_source_payload(self, value: str) -> str | None: + """Translate a caller's value into what this schema's wire accepts. + + Callers speak the flat vocabulary (`GRID`, `BATTERY`, `PV`, `GENERATOR`, + `NONE`, `UNKNOWN`) because that is the published contract. Under v1.0 the + settable successor is `shed/asserted-islanding-state`, whose enum is + `NONE`/`ON_GRID`/`OFF_GRID`, so the value has to be mapped rather than + forwarded. Returning None means "no legal representation", and the + transport should refuse the command rather than publish a value the + panel will reject. + """ + + def register_property_callback(self, callback: Callable[[str, str, str, str | None], None]) -> Callable[[], None]: ... diff --git a/src/span_panel_api/reference_payloads/README.md b/src/span_panel_api/reference_payloads/README.md new file mode 100644 index 0000000..80d5fd1 --- /dev/null +++ b/src/span_panel_api/reference_payloads/README.md @@ -0,0 +1,23 @@ +# Reference payloads + +Shipped as package data and read through `span_panel_api.reference_payloads`, never by path — a consumer that installs this distribution gets these bytes, and a consumer that pins a version gets the bytes that version was written against. + +## `homie_schema.json` + +The `GET /api/v2/homie/schema` response, captured from a live SPAN Panel running firmware `spanos2/r202603/05`. Unauthenticated endpoint. Serial numbers are masked (last 4 chars replaced with `XXXX`). + +Schema hash `sha256:d347556a07d98f40` — compare against `typesSchemaHash` in a live response to detect a schema change across firmware versions. `span_panel_api_schema_0.const.SCHEMA_ANCHOR` is pinned to this value, and `tests/test_schema_provenance.py` +fails when the two diverge. + +### Node types present + +| Node Type | Properties | Notes | +| ------------------------------------------------ | ---------- | -------------------------------------------------- | +| `energy.ebus.device.distribution-enclosure.core` | 17 | Panel-wide state, network, hardware | +| `energy.ebus.device.lugs` | 7 | Upstream (main meter) and downstream (feedthrough) | +| `energy.ebus.device.circuit` | 16 | Per-circuit — one node per commissioned circuit | +| `energy.ebus.device.bess` | 12 | Battery — optional, only if commissioned | +| `energy.ebus.device.pv` | 7 | Solar — optional, only if commissioned | +| `energy.ebus.device.evse` | 9 | EV charger — optional, only if commissioned | +| `energy.ebus.device.pcs` | 15 | Power Control System — optional | +| `energy.ebus.device.power-flows` | 4 | Aggregated power flows (W) | diff --git a/src/span_panel_api/reference_payloads/__init__.py b/src/span_panel_api/reference_payloads/__init__.py new file mode 100644 index 0000000..a8596f9 --- /dev/null +++ b/src/span_panel_api/reference_payloads/__init__.py @@ -0,0 +1,66 @@ +"""Reference wire payloads, shipped as package data. + +These are captures of what a panel actually serves, published as part of the +distribution rather than kept in `tests/` — because the consumers that need +them most are *other* repositories. The Home Assistant integration checks the +field paths it declares against what the adapters can actually produce, and it +can only do that against a real schema document. Vendoring a copy of one is the +obvious move and the wrong one: a copy has no version, so it goes stale in +silence and the check starts verifying declarations against a schema no panel +runs. + +Shipping the payload here gives it the version of the release it came with. A +consumer that pins `span-panel-api==X` reads the document that release was +written against, by construction, with no copy to keep in sync. + +`homie_schema.json` belongs to this distribution and not to an adapter one: it +is the response of `span_panel_api.auth.get_homie_schema()`, modelled by +`V2HomieSchema` here, and dispatch reads its `data_model_version` to decide +*which* adapter parses the panel at all. The parent/child device tree is the +other half of that story and lives with the parser that can interpret it, in +`span_panel_api_schema_1.reference_payloads`. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from importlib import resources +import json + +from span_panel_api.models import HomieSchemaTypes + +_PACKAGE = "span_panel_api.reference_payloads" +_HOMIE_SCHEMA = "homie_schema.json" + + +def _load_object(name: str) -> Mapping[str, object]: + """Read one shipped payload and require it to be a JSON object.""" + text = resources.files(_PACKAGE).joinpath(name).read_text(encoding="utf-8") + document: object = json.loads(text) + if not isinstance(document, dict): + raise TypeError(f"{name} is not a JSON object") + return document + + +def homie_schema() -> Mapping[str, object]: + """The captured `GET /api/v2/homie/schema` response, parsed. + + Taken from a live 32-space panel on `spanos2/r202603/05`; serial numbers are + masked. `typesSchemaHash` is `sha256:d347556a07d98f40`, which is the value + `span_panel_api_schema_0.const.SCHEMA_ANCHOR` is pinned to. + """ + return _load_object(_HOMIE_SCHEMA) + + +def homie_schema_types() -> HomieSchemaTypes: + """The captured schema's `types` map. + + Separate from `homie_schema()` because this is the shape a field-metadata + build takes — `span_panel_api_schema_0.field_metadata.build_field_metadata` + accepts exactly this type — so a caller checking an adapter's output against + the schema never has to reach into an untyped document to get it. + """ + types = homie_schema()["types"] + if not isinstance(types, dict): + raise TypeError(f"{_HOMIE_SCHEMA} has no `types` object") + return types diff --git a/tests/fixtures/v2/homie_schema.json b/src/span_panel_api/reference_payloads/homie_schema.json similarity index 100% rename from tests/fixtures/v2/homie_schema.json rename to src/span_panel_api/reference_payloads/homie_schema.json diff --git a/src/span_panel_api/schema_drift.py b/src/span_panel_api/schema_drift.py new file mode 100644 index 0000000..5895e10 --- /dev/null +++ b/src/span_panel_api/schema_drift.py @@ -0,0 +1,65 @@ +"""Diagnostic logging for Homie schema drift between panel sessions. + +Schema-agnostic: operates purely on ``HomieSchemaTypes`` dicts (a mapping of +node type to property definitions) and has no dependency on flat-schema +(schema_0) internals. Lives at the bootstrap level so ``span_panel_api.mqtt`` +can call it without importing anything from an adapter distribution. +""" + +from __future__ import annotations + +import logging + +from span_panel_api.models import HomieSchemaTypes + +_LOGGER = logging.getLogger(__name__) + + +def log_schema_drift( + previous: HomieSchemaTypes, + current: HomieSchemaTypes, +) -> None: + """Log property-level differences between two schema versions. + + Called by the client when the schema hash changes between connections. + All Homie-specific detail stays in this module — the integration never + sees this output, only the transport-agnostic field metadata. + """ + prev_types = set(previous.keys()) + curr_types = set(current.keys()) + + for node_type in sorted(curr_types - prev_types): + _LOGGER.debug("Schema drift: new node type '%s'", node_type) + + for node_type in sorted(prev_types - curr_types): + _LOGGER.debug("Schema drift: removed node type '%s'", node_type) + + for node_type in sorted(prev_types & curr_types): + prev_props = previous[node_type] + curr_props = current[node_type] + if not isinstance(prev_props, dict) or not isinstance(curr_props, dict): + continue + + for prop_id in sorted(set(curr_props) - set(prev_props)): + _LOGGER.debug("Schema drift: new property '%s/%s'", node_type, prop_id) + + for prop_id in sorted(set(prev_props) - set(curr_props)): + _LOGGER.debug("Schema drift: removed property '%s/%s'", node_type, prop_id) + + for prop_id in sorted(set(prev_props) & set(curr_props)): + prev_def = prev_props[prop_id] + curr_def = curr_props[prop_id] + if not isinstance(prev_def, dict) or not isinstance(curr_def, dict): + continue + for attr in ("datatype", "unit", "format"): + old_val = prev_def.get(attr) + new_val = curr_def.get(attr) + if old_val != new_val: + _LOGGER.debug( + "Schema drift: '%s/%s' %s changed: '%s' → '%s'", + node_type, + prop_id, + attr, + old_val, + new_val, + ) diff --git a/tests/conftest.py b/tests/conftest.py index 8a80b68..8d9d026 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,8 @@ import asyncio import json +import os +from pathlib import Path from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch @@ -14,7 +16,35 @@ import span_panel_api._http as _http_mod from span_panel_api.models import V2HomieSchema -from span_panel_api.mqtt.const import TOPIC_PREFIX, TYPE_CORE +from span_panel_api_schema_0.const import TOPIC_PREFIX, TYPE_CORE + +_DOTENV = Path(__file__).parent.parent / ".env" + + +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. + + `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. + """ + if not _DOTENV.exists(): + return + for raw in _DOTENV.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +_load_dotenv() @pytest.fixture(autouse=True) @@ -34,16 +64,41 @@ def _reset_ssl_cache() -> None: # Minimal Homie description that makes the device "ready" MINIMAL_DESCRIPTION = json.dumps({"nodes": {"core": {"type": TYPE_CORE}}}) -# Mock schema for SpanMqttClient.connect() — panel_size=32 -_MOCK_SCHEMA = V2HomieSchema( - firmware_version="test", - types_schema_hash="sha256:test", - types={ - "energy.ebus.device.circuit": { - "space": {"datatype": "integer", "format": "1:32:1"}, + +def flat_schema(panel_size: int = 32) -> V2HomieSchema: + """A flat-schema REST response declaring ``panel_size`` breaker spaces. + + No ``data_model_version``: absence is exactly what marks a payload as flat, + so this is what dispatch reads to select schema_0. + """ + return V2HomieSchema( + firmware_version="test", + types_schema_hash="sha256:test", + types={ + "energy.ebus.device.circuit": { + "space": {"datatype": "integer", "format": f"1:{panel_size}:1"}, + }, }, - }, -) + ) + + +def parent_child_schema(data_model_version: str = "1.0") -> V2HomieSchema: + """A parent/child REST response, as r202633+ firmware serves it. + + ``types`` is empty because that firmware keeps its definitions under + ``deviceClasses`` — which is exactly why the version has to be read before + anything tries to parse the payload. + """ + return V2HomieSchema( + firmware_version="spanos2/r202633/01", + types_schema_hash="sha256:test", + types={}, + data_model_version=data_model_version, + ) + + +# Mock schema for SpanMqttClient.connect() — panel_size=32, flat. +MOCK_SCHEMA = flat_schema(32) # --------------------------------------------------------------------------- @@ -109,7 +164,8 @@ def _reconnect() -> int: patch("span_panel_api.mqtt.connection.AsyncMQTTClient") as cls, patch("span_panel_api.mqtt.connection.download_ca_cert", return_value="FAKE-PEM"), patch("span_panel_api.mqtt.connection._build_ssl_context", return_value=MagicMock()), - patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_MOCK_SCHEMA), + patch("span_panel_api.mqtt.client.get_homie_schema", return_value=MOCK_SCHEMA), + patch("span_panel_api.factory.get_homie_schema", return_value=MOCK_SCHEMA), ): mock_client = cls.return_value mock_client.connect.side_effect = _connect diff --git a/tests/fixtures/flat_wire.json b/tests/fixtures/flat_wire.json new file mode 100644 index 0000000..37dfabd --- /dev/null +++ b/tests/fixtures/flat_wire.json @@ -0,0 +1,563 @@ +{ + "sim-40t-001": { + "$description": "{\"homie\": \"5.0\", \"version\": 1787210474828, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Span Panel\", \"nodes\": {\"13044bfbcbe5554b8f3dba126bce828f\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"1bfdc7ecebb0547bbe87a3696cddb0c0\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"1eeeb748eeaa58edb7e9b7e9dbbdeca7\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"2140a7e253ed54e3bc90a959081df615\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"249a2f59782e5f1ab317c4632e79afad\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"3d9d86f303cc50d1827be57d4c667e53\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"3eeb0eb1605e5a7eadac41994b7a096c\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"43a0521737db516f99f14a9964ea4af0\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"4aeb08c46c2c5905a944166413f2f1ef\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"4ce8b30e8d3f5c49b9e0ab0c8caf4832\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"4d1deb6acb065746b13207b1358f8ca7\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"516694a326a35cd88600b3520e8a981a\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"6fcb352679ad5bfb8c8a8eab06829b9f\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"770e2de52c33508a8a9ee8878064b46f\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"80a4fada833156ab8112f9d50e252b8f\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"9429f828509e58d59cb5f0f9f5fee523\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"948dea7788aa5c959b99df0edfabead2\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"af731c49a6785a4cb2ea5549fb8bce7e\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"afe90839f2725e3e962fb05afa2b6d43\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"b24483358d29589d8e91d3bf11113269\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"b9fa08f1eaaf5d129bd5c78e1d5d937f\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"be7742043a06554aab2a1e38cc776603\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"bess\": {\"name\": \"bess\", \"type\": \"energy.ebus.device.bess\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"product-name\": {\"name\": \"Product name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"software-version\": {\"name\": \"Software version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}, \"relative-position\": {\"name\": \"Relative position of the commissioned backup system WRT the distribution enclosure\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM,IN_PANEL\"}, \"feed\": {\"name\": \"Circuit ID upon which the commissioned backup system is landed\", \"datatype\": \"enum\"}, \"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}, \"connected\": {\"name\": \"Connected to backup system?\", \"datatype\": \"boolean\"}, \"grid-state\": {\"name\": \"Grid connection state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,ON_GRID,OFF_GRID\"}}}, \"c058aa11287f50f9b81e5160a0678869\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"c339ec7ce7ff521ca7646f9606baff9f\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"core\": {\"name\": \"core\", \"type\": \"energy.ebus.device.distribution-enclosure.core\", \"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\"}, \"software-version\": {\"name\": \"Software version\", \"datatype\": \"string\"}, \"door\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"grid-islandable\": {\"name\": \"Capable of operating with power while disconnected from the grid\", \"datatype\": \"boolean\"}, \"dominant-power-source\": {\"name\": \"Current dominant power source, load-shedding trigger\", \"datatype\": \"enum\", \"format\": \"GRID,BATTERY,PV,GENERATOR,NONE,UNKNOWN\", \"settable\": true}, \"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"l1-voltage\": {\"name\": \"L1 voltage\", \"datatype\": \"float\"}, \"l2-voltage\": {\"name\": \"L2 voltage\", \"datatype\": \"float\"}, \"breaker-rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"vendor-cloud\": {\"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\"}}}, \"d1ff145887a05b839ede89409c27b398\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"e0ac90e169e6550ea83fe0b1942f1d0e\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"e0bc156c85015a609d4132084dfcd6fe\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"edee3425d50d51ffb022ee999053b2b4\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"ef972f063451539e8b2ad88e831d87b6\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"f515a0f43b6555b1a196fbb62728c24e\": {\"name\": \"circuit\", \"type\": \"energy.ebus.device.circuit\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"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,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT\"}, \"breaker-rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"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\"}, \"space\": {\"name\": \"Circuit breaker space number within load center\", \"datatype\": \"integer\", \"format\": \"1:40:1\"}, \"dipole\": {\"name\": \"Does circuit land on a two-pole breaker?\", \"datatype\": \"boolean\"}, \"shed-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-managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"pcs-priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}, \"sheddable\": {\"name\": \"Is circuit configured to be sheddable?\", \"datatype\": \"boolean\"}, \"never-backup\": {\"name\": \"Is circuit configured to be never-backup?\", \"datatype\": \"boolean\"}, \"always-on\": {\"name\": \"Is circuit configured to be always on?\", \"datatype\": \"boolean\"}}}, \"lugs-downstream\": {\"name\": \"lugs\", \"type\": \"energy.ebus.device.lugs\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}, \"feed\": {\"name\": \"Device the lugs are connected to, if known\", \"datatype\": \"string\"}, \"l1-current\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"l2-current\": {\"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\"}}}, \"lugs-upstream\": {\"name\": \"lugs\", \"type\": \"energy.ebus.device.lugs\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}, \"feed\": {\"name\": \"Device the lugs are connected to, if known\", \"datatype\": \"string\"}, \"l1-current\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"l2-current\": {\"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\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.device.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\"}, \"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\"}, \"grid-import-limit\": {\"name\": \"Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"grid-import-limit-enablement\": {\"name\": \"Enablement status of the grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"grid-import-limit-active\": {\"name\": \"Is grid-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\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.device.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\"}}}, \"pv\": {\"name\": \"pv\", \"type\": \"energy.ebus.device.pv\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"product-name\": {\"name\": \"Product name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"software-version\": {\"name\": \"Software version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"W\"}, \"relative-position\": {\"name\": \"Relative position of the commissioned PV system WRT the distribution enclosure\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM,IN_PANEL\"}, \"feed\": {\"name\": \"Circuit ID upon which the commissioned PV system is landed\", \"datatype\": \"enum\"}}}, \"sim-evse-sim-40t-001\": {\"name\": \"evse\", \"type\": \"energy.ebus.device.evse\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"product-name\": {\"name\": \"Product name\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"software-version\": {\"name\": \"Software version\", \"datatype\": \"string\"}, \"feed\": {\"name\": \"Circuit ID upon which the commissioned EVSE is landed\", \"datatype\": \"enum\"}, \"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,LOCKED,UNLOCKED\"}, \"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,AVAILABLE,PREPARING,CHARGING,SUSPENDED_EV,SUSPENDED_EVSE,FINISHING,RESERVED,FAULTED,UNAVAILABLE\"}, \"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"sim-evse-sim-40t-001-2\": {\"name\": \"evse\", \"type\": \"energy.ebus.device.evse\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"product-name\": {\"name\": \"Product name\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"software-version\": {\"name\": \"Software version\", \"datatype\": \"string\"}, \"feed\": {\"name\": \"Circuit ID upon which the commissioned EVSE is landed\", \"datatype\": \"enum\"}, \"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,LOCKED,UNLOCKED\"}, \"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,AVAILABLE,PREPARING,CHARGING,SUSPENDED_EV,SUSPENDED_EVSE,FINISHING,RESERVED,FAULTED,UNAVAILABLE\"}, \"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}}, \"children\": [], \"extensions\": []}", + "$state": "ready", + "13044bfbcbe5554b8f3dba126bce828f/active-power": "-257.42953159214676", + "13044bfbcbe5554b8f3dba126bce828f/always-on": "false", + "13044bfbcbe5554b8f3dba126bce828f/breaker-rating": "20", + "13044bfbcbe5554b8f3dba126bce828f/current": "2.145246096601223", + "13044bfbcbe5554b8f3dba126bce828f/dipole": "false", + "13044bfbcbe5554b8f3dba126bce828f/exported-energy": "0.0", + "13044bfbcbe5554b8f3dba126bce828f/imported-energy": "0.0", + "13044bfbcbe5554b8f3dba126bce828f/name": "Kitchen Outlets (Island)", + "13044bfbcbe5554b8f3dba126bce828f/never-backup": "true", + "13044bfbcbe5554b8f3dba126bce828f/pcs-managed": "true", + "13044bfbcbe5554b8f3dba126bce828f/pcs-priority": "9", + "13044bfbcbe5554b8f3dba126bce828f/relay": "CLOSED", + "13044bfbcbe5554b8f3dba126bce828f/relay-requester": "NONE", + "13044bfbcbe5554b8f3dba126bce828f/shed-priority": "NEVER", + "13044bfbcbe5554b8f3dba126bce828f/sheddable": "false", + "13044bfbcbe5554b8f3dba126bce828f/space": "10", + "1bfdc7ecebb0547bbe87a3696cddb0c0/active-power": "0.0", + "1bfdc7ecebb0547bbe87a3696cddb0c0/always-on": "false", + "1bfdc7ecebb0547bbe87a3696cddb0c0/breaker-rating": "50", + "1bfdc7ecebb0547bbe87a3696cddb0c0/current": "0.0", + "1bfdc7ecebb0547bbe87a3696cddb0c0/dipole": "true", + "1bfdc7ecebb0547bbe87a3696cddb0c0/exported-energy": "0.0", + "1bfdc7ecebb0547bbe87a3696cddb0c0/imported-energy": "0.0", + "1bfdc7ecebb0547bbe87a3696cddb0c0/name": "SPAN Drive - Driveway", + "1bfdc7ecebb0547bbe87a3696cddb0c0/never-backup": "false", + "1bfdc7ecebb0547bbe87a3696cddb0c0/pcs-managed": "true", + "1bfdc7ecebb0547bbe87a3696cddb0c0/pcs-priority": "28", + "1bfdc7ecebb0547bbe87a3696cddb0c0/relay": "CLOSED", + "1bfdc7ecebb0547bbe87a3696cddb0c0/relay-requester": "NONE", + "1bfdc7ecebb0547bbe87a3696cddb0c0/shed-priority": "OFF_GRID", + "1bfdc7ecebb0547bbe87a3696cddb0c0/sheddable": "true", + "1bfdc7ecebb0547bbe87a3696cddb0c0/space": "35", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/active-power": "-4.6189977226181265", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/always-on": "false", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/breaker-rating": "15", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/current": "0.038491647688484384", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/dipole": "false", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/exported-energy": "0.0", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/imported-energy": "0.0", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/name": "Smoke Detectors", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/never-backup": "true", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/pcs-managed": "true", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/pcs-priority": "21", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/relay": "CLOSED", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/relay-requester": "NONE", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/shed-priority": "NEVER", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/sheddable": "false", + "1eeeb748eeaa58edb7e9b7e9dbbdeca7/space": "40", + "2140a7e253ed54e3bc90a959081df615/active-power": "-136.30423431833574", + "2140a7e253ed54e3bc90a959081df615/always-on": "false", + "2140a7e253ed54e3bc90a959081df615/breaker-rating": "20", + "2140a7e253ed54e3bc90a959081df615/current": "1.1358686193194645", + "2140a7e253ed54e3bc90a959081df615/dipole": "false", + "2140a7e253ed54e3bc90a959081df615/exported-energy": "0.0", + "2140a7e253ed54e3bc90a959081df615/imported-energy": "0.0", + "2140a7e253ed54e3bc90a959081df615/name": "Refrigerator", + "2140a7e253ed54e3bc90a959081df615/never-backup": "true", + "2140a7e253ed54e3bc90a959081df615/pcs-managed": "false", + "2140a7e253ed54e3bc90a959081df615/pcs-priority": "14", + "2140a7e253ed54e3bc90a959081df615/relay": "CLOSED", + "2140a7e253ed54e3bc90a959081df615/relay-requester": "NONE", + "2140a7e253ed54e3bc90a959081df615/shed-priority": "NEVER", + "2140a7e253ed54e3bc90a959081df615/sheddable": "false", + "2140a7e253ed54e3bc90a959081df615/space": "15", + "249a2f59782e5f1ab317c4632e79afad/active-power": "0.0", + "249a2f59782e5f1ab317c4632e79afad/always-on": "false", + "249a2f59782e5f1ab317c4632e79afad/breaker-rating": "50", + "249a2f59782e5f1ab317c4632e79afad/current": "0.0", + "249a2f59782e5f1ab317c4632e79afad/dipole": "true", + "249a2f59782e5f1ab317c4632e79afad/exported-energy": "0.0", + "249a2f59782e5f1ab317c4632e79afad/imported-energy": "0.0", + "249a2f59782e5f1ab317c4632e79afad/name": "SPAN Drive - Garage", + "249a2f59782e5f1ab317c4632e79afad/never-backup": "false", + "249a2f59782e5f1ab317c4632e79afad/pcs-managed": "true", + "249a2f59782e5f1ab317c4632e79afad/pcs-priority": "27", + "249a2f59782e5f1ab317c4632e79afad/relay": "CLOSED", + "249a2f59782e5f1ab317c4632e79afad/relay-requester": "NONE", + "249a2f59782e5f1ab317c4632e79afad/shed-priority": "OFF_GRID", + "249a2f59782e5f1ab317c4632e79afad/sheddable": "true", + "249a2f59782e5f1ab317c4632e79afad/space": "32", + "3d9d86f303cc50d1827be57d4c667e53/active-power": "-8.477903365805087", + "3d9d86f303cc50d1827be57d4c667e53/always-on": "false", + "3d9d86f303cc50d1827be57d4c667e53/breaker-rating": "15", + "3d9d86f303cc50d1827be57d4c667e53/current": "0.0706491947150424", + "3d9d86f303cc50d1827be57d4c667e53/dipole": "false", + "3d9d86f303cc50d1827be57d4c667e53/exported-energy": "0.0", + "3d9d86f303cc50d1827be57d4c667e53/imported-energy": "0.0", + "3d9d86f303cc50d1827be57d4c667e53/name": "Bedroom Lights", + "3d9d86f303cc50d1827be57d4c667e53/never-backup": "true", + "3d9d86f303cc50d1827be57d4c667e53/pcs-managed": "true", + "3d9d86f303cc50d1827be57d4c667e53/pcs-priority": "3", + "3d9d86f303cc50d1827be57d4c667e53/relay": "CLOSED", + "3d9d86f303cc50d1827be57d4c667e53/relay-requester": "NONE", + "3d9d86f303cc50d1827be57d4c667e53/shed-priority": "NEVER", + "3d9d86f303cc50d1827be57d4c667e53/sheddable": "false", + "3d9d86f303cc50d1827be57d4c667e53/space": "4", + "3eeb0eb1605e5a7eadac41994b7a096c/active-power": "-158.08771882018547", + "3eeb0eb1605e5a7eadac41994b7a096c/always-on": "false", + "3eeb0eb1605e5a7eadac41994b7a096c/breaker-rating": "15", + "3eeb0eb1605e5a7eadac41994b7a096c/current": "1.3173976568348789", + "3eeb0eb1605e5a7eadac41994b7a096c/dipole": "false", + "3eeb0eb1605e5a7eadac41994b7a096c/exported-energy": "0.0", + "3eeb0eb1605e5a7eadac41994b7a096c/imported-energy": "0.0", + "3eeb0eb1605e5a7eadac41994b7a096c/name": "Master Bedroom Outlets", + "3eeb0eb1605e5a7eadac41994b7a096c/never-backup": "true", + "3eeb0eb1605e5a7eadac41994b7a096c/pcs-managed": "true", + "3eeb0eb1605e5a7eadac41994b7a096c/pcs-priority": "6", + "3eeb0eb1605e5a7eadac41994b7a096c/relay": "CLOSED", + "3eeb0eb1605e5a7eadac41994b7a096c/relay-requester": "NONE", + "3eeb0eb1605e5a7eadac41994b7a096c/shed-priority": "NEVER", + "3eeb0eb1605e5a7eadac41994b7a096c/sheddable": "false", + "3eeb0eb1605e5a7eadac41994b7a096c/space": "7", + "43a0521737db516f99f14a9964ea4af0/active-power": "0.0", + "43a0521737db516f99f14a9964ea4af0/always-on": "false", + "43a0521737db516f99f14a9964ea4af0/breaker-rating": "20", + "43a0521737db516f99f14a9964ea4af0/current": "0.0", + "43a0521737db516f99f14a9964ea4af0/dipole": "false", + "43a0521737db516f99f14a9964ea4af0/exported-energy": "0.0", + "43a0521737db516f99f14a9964ea4af0/imported-energy": "0.0", + "43a0521737db516f99f14a9964ea4af0/name": "Washing Machine", + "43a0521737db516f99f14a9964ea4af0/never-backup": "false", + "43a0521737db516f99f14a9964ea4af0/pcs-managed": "true", + "43a0521737db516f99f14a9964ea4af0/pcs-priority": "16", + "43a0521737db516f99f14a9964ea4af0/relay": "CLOSED", + "43a0521737db516f99f14a9964ea4af0/relay-requester": "NONE", + "43a0521737db516f99f14a9964ea4af0/shed-priority": "OFF_GRID", + "43a0521737db516f99f14a9964ea4af0/sheddable": "true", + "43a0521737db516f99f14a9964ea4af0/space": "17", + "4aeb08c46c2c5905a944166413f2f1ef/active-power": "0.0", + "4aeb08c46c2c5905a944166413f2f1ef/always-on": "false", + "4aeb08c46c2c5905a944166413f2f1ef/breaker-rating": "15", + "4aeb08c46c2c5905a944166413f2f1ef/current": "0.0", + "4aeb08c46c2c5905a944166413f2f1ef/dipole": "false", + "4aeb08c46c2c5905a944166413f2f1ef/exported-energy": "0.0", + "4aeb08c46c2c5905a944166413f2f1ef/imported-energy": "0.0", + "4aeb08c46c2c5905a944166413f2f1ef/name": "Garbage Disposal", + "4aeb08c46c2c5905a944166413f2f1ef/never-backup": "true", + "4aeb08c46c2c5905a944166413f2f1ef/pcs-managed": "true", + "4aeb08c46c2c5905a944166413f2f1ef/pcs-priority": "19", + "4aeb08c46c2c5905a944166413f2f1ef/relay": "CLOSED", + "4aeb08c46c2c5905a944166413f2f1ef/relay-requester": "NONE", + "4aeb08c46c2c5905a944166413f2f1ef/shed-priority": "NEVER", + "4aeb08c46c2c5905a944166413f2f1ef/sheddable": "false", + "4aeb08c46c2c5905a944166413f2f1ef/space": "21", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/active-power": "-1245.8472739397114", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/always-on": "false", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/breaker-rating": "30", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/current": "5.191030308082131", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/dipole": "true", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/exported-energy": "0.0", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/imported-energy": "0.0", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/name": "Water Heater", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/never-backup": "false", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/pcs-managed": "true", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/pcs-priority": "26", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/relay": "CLOSED", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/relay-requester": "NONE", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/shed-priority": "OFF_GRID", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/sheddable": "true", + "4ce8b30e8d3f5c49b9e0ab0c8caf4832/space": "31", + "4d1deb6acb065746b13207b1358f8ca7/active-power": "0.0", + "4d1deb6acb065746b13207b1358f8ca7/always-on": "false", + "4d1deb6acb065746b13207b1358f8ca7/breaker-rating": "20", + "4d1deb6acb065746b13207b1358f8ca7/current": "0.0", + "4d1deb6acb065746b13207b1358f8ca7/dipole": "false", + "4d1deb6acb065746b13207b1358f8ca7/exported-energy": "0.0", + "4d1deb6acb065746b13207b1358f8ca7/imported-energy": "0.0", + "4d1deb6acb065746b13207b1358f8ca7/name": "Dishwasher", + "4d1deb6acb065746b13207b1358f8ca7/never-backup": "false", + "4d1deb6acb065746b13207b1358f8ca7/pcs-managed": "true", + "4d1deb6acb065746b13207b1358f8ca7/pcs-priority": "15", + "4d1deb6acb065746b13207b1358f8ca7/relay": "CLOSED", + "4d1deb6acb065746b13207b1358f8ca7/relay-requester": "NONE", + "4d1deb6acb065746b13207b1358f8ca7/shed-priority": "OFF_GRID", + "4d1deb6acb065746b13207b1358f8ca7/sheddable": "true", + "4d1deb6acb065746b13207b1358f8ca7/space": "16", + "516694a326a35cd88600b3520e8a981a/active-power": "0.0", + "516694a326a35cd88600b3520e8a981a/always-on": "false", + "516694a326a35cd88600b3520e8a981a/breaker-rating": "20", + "516694a326a35cd88600b3520e8a981a/current": "0.0", + "516694a326a35cd88600b3520e8a981a/dipole": "false", + "516694a326a35cd88600b3520e8a981a/exported-energy": "0.0", + "516694a326a35cd88600b3520e8a981a/imported-energy": "0.0", + "516694a326a35cd88600b3520e8a981a/name": "Pool Pump", + "516694a326a35cd88600b3520e8a981a/never-backup": "false", + "516694a326a35cd88600b3520e8a981a/pcs-managed": "true", + "516694a326a35cd88600b3520e8a981a/pcs-priority": "20", + "516694a326a35cd88600b3520e8a981a/relay": "CLOSED", + "516694a326a35cd88600b3520e8a981a/relay-requester": "NONE", + "516694a326a35cd88600b3520e8a981a/shed-priority": "OFF_GRID", + "516694a326a35cd88600b3520e8a981a/sheddable": "true", + "516694a326a35cd88600b3520e8a981a/space": "39", + "6fcb352679ad5bfb8c8a8eab06829b9f/active-power": "0.0", + "6fcb352679ad5bfb8c8a8eab06829b9f/always-on": "false", + "6fcb352679ad5bfb8c8a8eab06829b9f/breaker-rating": "30", + "6fcb352679ad5bfb8c8a8eab06829b9f/current": "0.0", + "6fcb352679ad5bfb8c8a8eab06829b9f/dipole": "true", + "6fcb352679ad5bfb8c8a8eab06829b9f/exported-energy": "0.0", + "6fcb352679ad5bfb8c8a8eab06829b9f/imported-energy": "0.0", + "6fcb352679ad5bfb8c8a8eab06829b9f/name": "Solar Inverter", + "6fcb352679ad5bfb8c8a8eab06829b9f/never-backup": "true", + "6fcb352679ad5bfb8c8a8eab06829b9f/pcs-managed": "false", + "6fcb352679ad5bfb8c8a8eab06829b9f/pcs-priority": "29", + "6fcb352679ad5bfb8c8a8eab06829b9f/relay": "CLOSED", + "6fcb352679ad5bfb8c8a8eab06829b9f/relay-requester": "NONE", + "6fcb352679ad5bfb8c8a8eab06829b9f/shed-priority": "NEVER", + "6fcb352679ad5bfb8c8a8eab06829b9f/sheddable": "false", + "6fcb352679ad5bfb8c8a8eab06829b9f/space": "36", + "770e2de52c33508a8a9ee8878064b46f/active-power": "-3.616782913825008", + "770e2de52c33508a8a9ee8878064b46f/always-on": "false", + "770e2de52c33508a8a9ee8878064b46f/breaker-rating": "15", + "770e2de52c33508a8a9ee8878064b46f/current": "0.030139857615208397", + "770e2de52c33508a8a9ee8878064b46f/dipole": "false", + "770e2de52c33508a8a9ee8878064b46f/exported-energy": "0.0", + "770e2de52c33508a8a9ee8878064b46f/imported-energy": "0.0", + "770e2de52c33508a8a9ee8878064b46f/name": "Master Bedroom Lights", + "770e2de52c33508a8a9ee8878064b46f/never-backup": "true", + "770e2de52c33508a8a9ee8878064b46f/pcs-managed": "true", + "770e2de52c33508a8a9ee8878064b46f/pcs-priority": "1", + "770e2de52c33508a8a9ee8878064b46f/relay": "CLOSED", + "770e2de52c33508a8a9ee8878064b46f/relay-requester": "NONE", + "770e2de52c33508a8a9ee8878064b46f/shed-priority": "NEVER", + "770e2de52c33508a8a9ee8878064b46f/sheddable": "false", + "770e2de52c33508a8a9ee8878064b46f/space": "1", + "80a4fada833156ab8112f9d50e252b8f/active-power": "-294.05474521978084", + "80a4fada833156ab8112f9d50e252b8f/always-on": "false", + "80a4fada833156ab8112f9d50e252b8f/breaker-rating": "20", + "80a4fada833156ab8112f9d50e252b8f/current": "2.45045621016484", + "80a4fada833156ab8112f9d50e252b8f/dipole": "false", + "80a4fada833156ab8112f9d50e252b8f/exported-energy": "0.0", + "80a4fada833156ab8112f9d50e252b8f/imported-energy": "0.0", + "80a4fada833156ab8112f9d50e252b8f/name": "Kitchen Outlets (Counter)", + "80a4fada833156ab8112f9d50e252b8f/never-backup": "true", + "80a4fada833156ab8112f9d50e252b8f/pcs-managed": "true", + "80a4fada833156ab8112f9d50e252b8f/pcs-priority": "8", + "80a4fada833156ab8112f9d50e252b8f/relay": "CLOSED", + "80a4fada833156ab8112f9d50e252b8f/relay-requester": "NONE", + "80a4fada833156ab8112f9d50e252b8f/shed-priority": "NEVER", + "80a4fada833156ab8112f9d50e252b8f/sheddable": "false", + "80a4fada833156ab8112f9d50e252b8f/space": "9", + "9429f828509e58d59cb5f0f9f5fee523/active-power": "-5.028052499839787", + "9429f828509e58d59cb5f0f9f5fee523/always-on": "false", + "9429f828509e58d59cb5f0f9f5fee523/breaker-rating": "15", + "9429f828509e58d59cb5f0f9f5fee523/current": "0.04190043749866489", + "9429f828509e58d59cb5f0f9f5fee523/dipole": "false", + "9429f828509e58d59cb5f0f9f5fee523/exported-energy": "0.0", + "9429f828509e58d59cb5f0f9f5fee523/imported-energy": "0.0", + "9429f828509e58d59cb5f0f9f5fee523/name": "Living Room Lights", + "9429f828509e58d59cb5f0f9f5fee523/never-backup": "true", + "9429f828509e58d59cb5f0f9f5fee523/pcs-managed": "true", + "9429f828509e58d59cb5f0f9f5fee523/pcs-priority": "2", + "9429f828509e58d59cb5f0f9f5fee523/relay": "CLOSED", + "9429f828509e58d59cb5f0f9f5fee523/relay-requester": "NONE", + "9429f828509e58d59cb5f0f9f5fee523/shed-priority": "NEVER", + "9429f828509e58d59cb5f0f9f5fee523/sheddable": "false", + "9429f828509e58d59cb5f0f9f5fee523/space": "2", + "948dea7788aa5c959b99df0edfabead2/active-power": "-1419.9196540802902", + "948dea7788aa5c959b99df0edfabead2/always-on": "false", + "948dea7788aa5c959b99df0edfabead2/breaker-rating": "30", + "948dea7788aa5c959b99df0edfabead2/current": "5.916331892001209", + "948dea7788aa5c959b99df0edfabead2/dipole": "true", + "948dea7788aa5c959b99df0edfabead2/exported-energy": "0.0", + "948dea7788aa5c959b99df0edfabead2/imported-energy": "0.0", + "948dea7788aa5c959b99df0edfabead2/name": "Heat Pump", + "948dea7788aa5c959b99df0edfabead2/never-backup": "false", + "948dea7788aa5c959b99df0edfabead2/pcs-managed": "true", + "948dea7788aa5c959b99df0edfabead2/pcs-priority": "24", + "948dea7788aa5c959b99df0edfabead2/relay": "CLOSED", + "948dea7788aa5c959b99df0edfabead2/relay-requester": "NONE", + "948dea7788aa5c959b99df0edfabead2/shed-priority": "OFF_GRID", + "948dea7788aa5c959b99df0edfabead2/sheddable": "true", + "948dea7788aa5c959b99df0edfabead2/space": "27", + "af731c49a6785a4cb2ea5549fb8bce7e/active-power": "-186.46784746227968", + "af731c49a6785a4cb2ea5549fb8bce7e/always-on": "false", + "af731c49a6785a4cb2ea5549fb8bce7e/breaker-rating": "30", + "af731c49a6785a4cb2ea5549fb8bce7e/current": "0.7769493644261654", + "af731c49a6785a4cb2ea5549fb8bce7e/dipole": "true", + "af731c49a6785a4cb2ea5549fb8bce7e/exported-energy": "0.0", + "af731c49a6785a4cb2ea5549fb8bce7e/imported-energy": "0.0", + "af731c49a6785a4cb2ea5549fb8bce7e/name": "Main HVAC", + "af731c49a6785a4cb2ea5549fb8bce7e/never-backup": "true", + "af731c49a6785a4cb2ea5549fb8bce7e/pcs-managed": "true", + "af731c49a6785a4cb2ea5549fb8bce7e/pcs-priority": "23", + "af731c49a6785a4cb2ea5549fb8bce7e/relay": "CLOSED", + "af731c49a6785a4cb2ea5549fb8bce7e/relay-requester": "NONE", + "af731c49a6785a4cb2ea5549fb8bce7e/shed-priority": "NEVER", + "af731c49a6785a4cb2ea5549fb8bce7e/sheddable": "false", + "af731c49a6785a4cb2ea5549fb8bce7e/space": "23", + "afe90839f2725e3e962fb05afa2b6d43/active-power": "-76.06058554334422", + "afe90839f2725e3e962fb05afa2b6d43/always-on": "false", + "afe90839f2725e3e962fb05afa2b6d43/breaker-rating": "20", + "afe90839f2725e3e962fb05afa2b6d43/current": "0.6338382128612018", + "afe90839f2725e3e962fb05afa2b6d43/dipole": "false", + "afe90839f2725e3e962fb05afa2b6d43/exported-energy": "0.0", + "afe90839f2725e3e962fb05afa2b6d43/imported-energy": "0.0", + "afe90839f2725e3e962fb05afa2b6d43/name": "Chest Freezer", + "afe90839f2725e3e962fb05afa2b6d43/never-backup": "true", + "afe90839f2725e3e962fb05afa2b6d43/pcs-managed": "false", + "afe90839f2725e3e962fb05afa2b6d43/pcs-priority": "18", + "afe90839f2725e3e962fb05afa2b6d43/relay": "CLOSED", + "afe90839f2725e3e962fb05afa2b6d43/relay-requester": "NONE", + "afe90839f2725e3e962fb05afa2b6d43/shed-priority": "NEVER", + "afe90839f2725e3e962fb05afa2b6d43/sheddable": "false", + "afe90839f2725e3e962fb05afa2b6d43/space": "19", + "b24483358d29589d8e91d3bf11113269/active-power": "-333.81473632426923", + "b24483358d29589d8e91d3bf11113269/always-on": "false", + "b24483358d29589d8e91d3bf11113269/breaker-rating": "15", + "b24483358d29589d8e91d3bf11113269/current": "2.7817894693689103", + "b24483358d29589d8e91d3bf11113269/dipole": "false", + "b24483358d29589d8e91d3bf11113269/exported-energy": "0.0", + "b24483358d29589d8e91d3bf11113269/imported-energy": "0.0", + "b24483358d29589d8e91d3bf11113269/name": "Office Outlets", + "b24483358d29589d8e91d3bf11113269/never-backup": "true", + "b24483358d29589d8e91d3bf11113269/pcs-managed": "true", + "b24483358d29589d8e91d3bf11113269/pcs-priority": "10", + "b24483358d29589d8e91d3bf11113269/relay": "CLOSED", + "b24483358d29589d8e91d3bf11113269/relay-requester": "NONE", + "b24483358d29589d8e91d3bf11113269/shed-priority": "NEVER", + "b24483358d29589d8e91d3bf11113269/sheddable": "false", + "b24483358d29589d8e91d3bf11113269/space": "11", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/active-power": "-146.66491277427593", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/always-on": "false", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/breaker-rating": "15", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/current": "1.2222076064522995", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/dipole": "false", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/exported-energy": "0.0", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/imported-energy": "0.0", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/name": "kitchen Lights", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/never-backup": "true", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/pcs-managed": "true", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/pcs-priority": "30", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/relay": "CLOSED", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/relay-requester": "NONE", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/shed-priority": "NEVER", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/sheddable": "false", + "b9fa08f1eaaf5d129bd5c78e1d5d937f/space": "3", + "be7742043a06554aab2a1e38cc776603/active-power": "0.0", + "be7742043a06554aab2a1e38cc776603/always-on": "false", + "be7742043a06554aab2a1e38cc776603/breaker-rating": "40", + "be7742043a06554aab2a1e38cc776603/current": "0.0", + "be7742043a06554aab2a1e38cc776603/dipole": "true", + "be7742043a06554aab2a1e38cc776603/exported-energy": "0.0", + "be7742043a06554aab2a1e38cc776603/imported-energy": "0.0", + "be7742043a06554aab2a1e38cc776603/name": "Electric Oven/Range", + "be7742043a06554aab2a1e38cc776603/never-backup": "false", + "be7742043a06554aab2a1e38cc776603/pcs-managed": "true", + "be7742043a06554aab2a1e38cc776603/pcs-priority": "25", + "be7742043a06554aab2a1e38cc776603/relay": "CLOSED", + "be7742043a06554aab2a1e38cc776603/relay-requester": "NONE", + "be7742043a06554aab2a1e38cc776603/shed-priority": "OFF_GRID", + "be7742043a06554aab2a1e38cc776603/sheddable": "true", + "be7742043a06554aab2a1e38cc776603/space": "28", + "bess/connected": "true", + "bess/grid-state": "ON_GRID", + "bess/nameplate-capacity": "13.5", + "bess/relative-position": "UPSTREAM", + "bess/soc": "50.0", + "bess/soe": "6.75", + "bess/vendor-name": "Span", + "c058aa11287f50f9b81e5160a0678869/active-power": "-2.685848418797581", + "c058aa11287f50f9b81e5160a0678869/always-on": "false", + "c058aa11287f50f9b81e5160a0678869/breaker-rating": "15", + "c058aa11287f50f9b81e5160a0678869/current": "0.02238207015664651", + "c058aa11287f50f9b81e5160a0678869/dipole": "false", + "c058aa11287f50f9b81e5160a0678869/exported-energy": "0.0", + "c058aa11287f50f9b81e5160a0678869/imported-energy": "0.0", + "c058aa11287f50f9b81e5160a0678869/name": "Bathroom Lights", + "c058aa11287f50f9b81e5160a0678869/never-backup": "true", + "c058aa11287f50f9b81e5160a0678869/pcs-managed": "true", + "c058aa11287f50f9b81e5160a0678869/pcs-priority": "4", + "c058aa11287f50f9b81e5160a0678869/relay": "CLOSED", + "c058aa11287f50f9b81e5160a0678869/relay-requester": "NONE", + "c058aa11287f50f9b81e5160a0678869/shed-priority": "NEVER", + "c058aa11287f50f9b81e5160a0678869/sheddable": "false", + "c058aa11287f50f9b81e5160a0678869/space": "5", + "c339ec7ce7ff521ca7646f9606baff9f/active-power": "-151.58523544953078", + "c339ec7ce7ff521ca7646f9606baff9f/always-on": "false", + "c339ec7ce7ff521ca7646f9606baff9f/breaker-rating": "15", + "c339ec7ce7ff521ca7646f9606baff9f/current": "1.2632102954127566", + "c339ec7ce7ff521ca7646f9606baff9f/dipole": "false", + "c339ec7ce7ff521ca7646f9606baff9f/exported-energy": "0.0", + "c339ec7ce7ff521ca7646f9606baff9f/imported-energy": "0.0", + "c339ec7ce7ff521ca7646f9606baff9f/name": "Guest Room Outlets", + "c339ec7ce7ff521ca7646f9606baff9f/never-backup": "true", + "c339ec7ce7ff521ca7646f9606baff9f/pcs-managed": "true", + "c339ec7ce7ff521ca7646f9606baff9f/pcs-priority": "13", + "c339ec7ce7ff521ca7646f9606baff9f/relay": "CLOSED", + "c339ec7ce7ff521ca7646f9606baff9f/relay-requester": "NONE", + "c339ec7ce7ff521ca7646f9606baff9f/shed-priority": "NEVER", + "c339ec7ce7ff521ca7646f9606baff9f/sheddable": "false", + "c339ec7ce7ff521ca7646f9606baff9f/space": "14", + "core/breaker-rating": "200", + "core/dominant-power-source": "GRID", + "core/door": "CLOSED", + "core/ethernet": "true", + "core/grid-islandable": "false", + "core/hardware-version": "rev2", + "core/l1-voltage": "120.0", + "core/l2-voltage": "120.0", + "core/model": "MAIN_40", + "core/postal-code": "94103", + "core/relay": "CLOSED", + "core/serial-number": "sim-40t-001", + "core/software-version": "sim/v0.1.0", + "core/time-zone": "America/Los_Angeles", + "core/vendor-cloud": "CONNECTED", + "core/vendor-name": "Span", + "core/wifi": "true", + "d1ff145887a05b839ede89409c27b398/active-power": "-135.0052989391848", + "d1ff145887a05b839ede89409c27b398/always-on": "false", + "d1ff145887a05b839ede89409c27b398/breaker-rating": "15", + "d1ff145887a05b839ede89409c27b398/current": "1.12504415782654", + "d1ff145887a05b839ede89409c27b398/dipole": "false", + "d1ff145887a05b839ede89409c27b398/exported-energy": "0.0", + "d1ff145887a05b839ede89409c27b398/imported-energy": "0.0", + "d1ff145887a05b839ede89409c27b398/name": "Garage Outlets", + "d1ff145887a05b839ede89409c27b398/never-backup": "true", + "d1ff145887a05b839ede89409c27b398/pcs-managed": "true", + "d1ff145887a05b839ede89409c27b398/pcs-priority": "11", + "d1ff145887a05b839ede89409c27b398/relay": "CLOSED", + "d1ff145887a05b839ede89409c27b398/relay-requester": "NONE", + "d1ff145887a05b839ede89409c27b398/shed-priority": "NEVER", + "d1ff145887a05b839ede89409c27b398/sheddable": "false", + "d1ff145887a05b839ede89409c27b398/space": "12", + "e0ac90e169e6550ea83fe0b1942f1d0e/active-power": "-268.7730732161862", + "e0ac90e169e6550ea83fe0b1942f1d0e/always-on": "false", + "e0ac90e169e6550ea83fe0b1942f1d0e/breaker-rating": "15", + "e0ac90e169e6550ea83fe0b1942f1d0e/current": "2.239775610134885", + "e0ac90e169e6550ea83fe0b1942f1d0e/dipole": "false", + "e0ac90e169e6550ea83fe0b1942f1d0e/exported-energy": "0.0", + "e0ac90e169e6550ea83fe0b1942f1d0e/imported-energy": "0.0", + "e0ac90e169e6550ea83fe0b1942f1d0e/name": "Living Room Outlets", + "e0ac90e169e6550ea83fe0b1942f1d0e/never-backup": "true", + "e0ac90e169e6550ea83fe0b1942f1d0e/pcs-managed": "true", + "e0ac90e169e6550ea83fe0b1942f1d0e/pcs-priority": "7", + "e0ac90e169e6550ea83fe0b1942f1d0e/relay": "CLOSED", + "e0ac90e169e6550ea83fe0b1942f1d0e/relay-requester": "NONE", + "e0ac90e169e6550ea83fe0b1942f1d0e/shed-priority": "NEVER", + "e0ac90e169e6550ea83fe0b1942f1d0e/sheddable": "false", + "e0ac90e169e6550ea83fe0b1942f1d0e/space": "8", + "e0bc156c85015a609d4132084dfcd6fe/active-power": "0.0", + "e0bc156c85015a609d4132084dfcd6fe/always-on": "false", + "e0bc156c85015a609d4132084dfcd6fe/breaker-rating": "20", + "e0bc156c85015a609d4132084dfcd6fe/current": "0.0", + "e0bc156c85015a609d4132084dfcd6fe/dipole": "false", + "e0bc156c85015a609d4132084dfcd6fe/exported-energy": "0.0", + "e0bc156c85015a609d4132084dfcd6fe/imported-energy": "0.0", + "e0bc156c85015a609d4132084dfcd6fe/name": "Microwave", + "e0bc156c85015a609d4132084dfcd6fe/never-backup": "true", + "e0bc156c85015a609d4132084dfcd6fe/pcs-managed": "true", + "e0bc156c85015a609d4132084dfcd6fe/pcs-priority": "17", + "e0bc156c85015a609d4132084dfcd6fe/relay": "CLOSED", + "e0bc156c85015a609d4132084dfcd6fe/relay-requester": "NONE", + "e0bc156c85015a609d4132084dfcd6fe/shed-priority": "NEVER", + "e0bc156c85015a609d4132084dfcd6fe/sheddable": "false", + "e0bc156c85015a609d4132084dfcd6fe/space": "18", + "edee3425d50d51ffb022ee999053b2b4/active-power": "-170.9170380907025", + "edee3425d50d51ffb022ee999053b2b4/always-on": "false", + "edee3425d50d51ffb022ee999053b2b4/breaker-rating": "15", + "edee3425d50d51ffb022ee999053b2b4/current": "1.4243086507558542", + "edee3425d50d51ffb022ee999053b2b4/dipole": "false", + "edee3425d50d51ffb022ee999053b2b4/exported-energy": "0.0", + "edee3425d50d51ffb022ee999053b2b4/imported-energy": "0.0", + "edee3425d50d51ffb022ee999053b2b4/name": "Laundry Room Outlets", + "edee3425d50d51ffb022ee999053b2b4/never-backup": "true", + "edee3425d50d51ffb022ee999053b2b4/pcs-managed": "true", + "edee3425d50d51ffb022ee999053b2b4/pcs-priority": "12", + "edee3425d50d51ffb022ee999053b2b4/relay": "CLOSED", + "edee3425d50d51ffb022ee999053b2b4/relay-requester": "NONE", + "edee3425d50d51ffb022ee999053b2b4/shed-priority": "NEVER", + "edee3425d50d51ffb022ee999053b2b4/sheddable": "false", + "edee3425d50d51ffb022ee999053b2b4/space": "13", + "ef972f063451539e8b2ad88e831d87b6/active-power": "0.0", + "ef972f063451539e8b2ad88e831d87b6/always-on": "false", + "ef972f063451539e8b2ad88e831d87b6/breaker-rating": "30", + "ef972f063451539e8b2ad88e831d87b6/current": "0.0", + "ef972f063451539e8b2ad88e831d87b6/dipole": "true", + "ef972f063451539e8b2ad88e831d87b6/exported-energy": "0.0", + "ef972f063451539e8b2ad88e831d87b6/imported-energy": "0.0", + "ef972f063451539e8b2ad88e831d87b6/name": "Electric Dryer", + "ef972f063451539e8b2ad88e831d87b6/never-backup": "false", + "ef972f063451539e8b2ad88e831d87b6/pcs-managed": "true", + "ef972f063451539e8b2ad88e831d87b6/pcs-priority": "22", + "ef972f063451539e8b2ad88e831d87b6/relay": "CLOSED", + "ef972f063451539e8b2ad88e831d87b6/relay-requester": "NONE", + "ef972f063451539e8b2ad88e831d87b6/shed-priority": "OFF_GRID", + "ef972f063451539e8b2ad88e831d87b6/sheddable": "true", + "ef972f063451539e8b2ad88e831d87b6/space": "20", + "f515a0f43b6555b1a196fbb62728c24e/active-power": "-18.26908512247869", + "f515a0f43b6555b1a196fbb62728c24e/always-on": "false", + "f515a0f43b6555b1a196fbb62728c24e/breaker-rating": "15", + "f515a0f43b6555b1a196fbb62728c24e/current": "0.15224237602065574", + "f515a0f43b6555b1a196fbb62728c24e/dipole": "false", + "f515a0f43b6555b1a196fbb62728c24e/exported-energy": "0.0", + "f515a0f43b6555b1a196fbb62728c24e/imported-energy": "0.0", + "f515a0f43b6555b1a196fbb62728c24e/name": "Exterior Lights", + "f515a0f43b6555b1a196fbb62728c24e/never-backup": "false", + "f515a0f43b6555b1a196fbb62728c24e/pcs-managed": "true", + "f515a0f43b6555b1a196fbb62728c24e/pcs-priority": "5", + "f515a0f43b6555b1a196fbb62728c24e/relay": "CLOSED", + "f515a0f43b6555b1a196fbb62728c24e/relay-requester": "NONE", + "f515a0f43b6555b1a196fbb62728c24e/shed-priority": "OFF_GRID", + "f515a0f43b6555b1a196fbb62728c24e/sheddable": "true", + "f515a0f43b6555b1a196fbb62728c24e/space": "6", + "lugs-downstream/active-power": "5023.628555813588", + "lugs-downstream/direction": "DOWNSTREAM", + "lugs-downstream/exported-energy": "0.0", + "lugs-downstream/imported-energy": "0.0", + "lugs-downstream/l1-current": "22.90269991803881", + "lugs-downstream/l2-current": "18.960871380407756", + "lugs-upstream/active-power": "5023.628555813588", + "lugs-upstream/direction": "UPSTREAM", + "lugs-upstream/exported-energy": "0.0", + "lugs-upstream/imported-energy": "0.0", + "lugs-upstream/l1-current": "22.90269991803881", + "lugs-upstream/l2-current": "18.960871380407756", + "pcs/active": "false", + "pcs/enabled": "false", + "pcs/feed-import-limit": "0.0", + "pcs/feed-import-limit-active": "false", + "pcs/feed-import-limit-enablement": "UNCONFIGURED", + "pcs/grid-import-limit": "0.0", + "pcs/grid-import-limit-active": "false", + "pcs/grid-import-limit-enablement": "UNCONFIGURED", + "pcs/import-limit": "0.0", + "pcs/off-grid-import-limit": "0.0", + "pcs/off-grid-import-limit-active": "false", + "pcs/off-grid-import-limit-enablement": "UNCONFIGURED", + "pcs/requested-import-limit": "0.0", + "pcs/requested-import-limit-active": "false", + "pcs/requested-import-limit-enablement": "UNCONFIGURED", + "power-flows/battery": "-3500.0", + "power-flows/grid": "-1523.6285558135878", + "power-flows/pv": "0", + "power-flows/site": "5023.628555813588", + "pv/feed": "6fcb352679ad5bfb8c8a8eab06829b9f", + "pv/nameplate-capacity": "10000.0", + "pv/relative-position": "IN_PANEL", + "pv/vendor-name": "Enphase", + "sim-evse-sim-40t-001-2/advertised-current": "32.0", + "sim-evse-sim-40t-001-2/feed": "1bfdc7ecebb0547bbe87a3696cddb0c0", + "sim-evse-sim-40t-001-2/lock-state": "UNLOCKED", + "sim-evse-sim-40t-001-2/part-number": "SPN-DRV-001", + "sim-evse-sim-40t-001-2/product-name": "SPAN Drive", + "sim-evse-sim-40t-001-2/serial-number": "sim-evse-sim-40t-001-2", + "sim-evse-sim-40t-001-2/software-version": "sim/v0.1.0", + "sim-evse-sim-40t-001-2/status": "AVAILABLE", + "sim-evse-sim-40t-001-2/vendor-name": "SPAN", + "sim-evse-sim-40t-001/advertised-current": "32.0", + "sim-evse-sim-40t-001/feed": "249a2f59782e5f1ab317c4632e79afad", + "sim-evse-sim-40t-001/lock-state": "UNLOCKED", + "sim-evse-sim-40t-001/part-number": "SPN-DRV-001", + "sim-evse-sim-40t-001/product-name": "SPAN Drive", + "sim-evse-sim-40t-001/serial-number": "sim-evse-sim-40t-001", + "sim-evse-sim-40t-001/software-version": "sim/v0.1.0", + "sim-evse-sim-40t-001/status": "AVAILABLE", + "sim-evse-sim-40t-001/vendor-name": "SPAN" + } +} diff --git a/tests/fixtures/panelbench_unvalued_by_both.json b/tests/fixtures/panelbench_unvalued_by_both.json new file mode 100644 index 0000000..f3a5032 --- /dev/null +++ b/tests/fixtures/panelbench_unvalued_by_both.json @@ -0,0 +1,125 @@ +[ + "energy.ebus.device.circuit::Bathroom Lights connection/count", + "energy.ebus.device.circuit::Bathroom Lights connection/feeds-device-id", + "energy.ebus.device.circuit::Bathroom Lights connection/feeds-device-status", + "energy.ebus.device.circuit::Bathroom Lights connection/feeds-device-type", + "energy.ebus.device.circuit::Bedroom Lights connection/count", + "energy.ebus.device.circuit::Bedroom Lights connection/feeds-device-id", + "energy.ebus.device.circuit::Bedroom Lights connection/feeds-device-status", + "energy.ebus.device.circuit::Bedroom Lights connection/feeds-device-type", + "energy.ebus.device.circuit::Chest Freezer connection/count", + "energy.ebus.device.circuit::Chest Freezer connection/feeds-device-id", + "energy.ebus.device.circuit::Chest Freezer connection/feeds-device-status", + "energy.ebus.device.circuit::Chest Freezer connection/feeds-device-type", + "energy.ebus.device.circuit::Dishwasher connection/count", + "energy.ebus.device.circuit::Dishwasher connection/feeds-device-id", + "energy.ebus.device.circuit::Dishwasher connection/feeds-device-status", + "energy.ebus.device.circuit::Dishwasher connection/feeds-device-type", + "energy.ebus.device.circuit::Electric Dryer connection/count", + "energy.ebus.device.circuit::Electric Dryer connection/feeds-device-id", + "energy.ebus.device.circuit::Electric Dryer connection/feeds-device-status", + "energy.ebus.device.circuit::Electric Dryer connection/feeds-device-type", + "energy.ebus.device.circuit::Electric Oven/Range connection/count", + "energy.ebus.device.circuit::Electric Oven/Range connection/feeds-device-id", + "energy.ebus.device.circuit::Electric Oven/Range connection/feeds-device-status", + "energy.ebus.device.circuit::Electric Oven/Range connection/feeds-device-type", + "energy.ebus.device.circuit::Exterior Lights connection/count", + "energy.ebus.device.circuit::Exterior Lights connection/feeds-device-id", + "energy.ebus.device.circuit::Exterior Lights connection/feeds-device-status", + "energy.ebus.device.circuit::Exterior Lights connection/feeds-device-type", + "energy.ebus.device.circuit::Garage Outlets connection/count", + "energy.ebus.device.circuit::Garage Outlets connection/feeds-device-id", + "energy.ebus.device.circuit::Garage Outlets connection/feeds-device-status", + "energy.ebus.device.circuit::Garage Outlets connection/feeds-device-type", + "energy.ebus.device.circuit::Garbage Disposal connection/count", + "energy.ebus.device.circuit::Garbage Disposal connection/feeds-device-id", + "energy.ebus.device.circuit::Garbage Disposal connection/feeds-device-status", + "energy.ebus.device.circuit::Garbage Disposal connection/feeds-device-type", + "energy.ebus.device.circuit::Guest Room Outlets connection/count", + "energy.ebus.device.circuit::Guest Room Outlets connection/feeds-device-id", + "energy.ebus.device.circuit::Guest Room Outlets connection/feeds-device-status", + "energy.ebus.device.circuit::Guest Room Outlets connection/feeds-device-type", + "energy.ebus.device.circuit::Heat Pump connection/count", + "energy.ebus.device.circuit::Heat Pump connection/feeds-device-id", + "energy.ebus.device.circuit::Heat Pump connection/feeds-device-status", + "energy.ebus.device.circuit::Heat Pump connection/feeds-device-type", + "energy.ebus.device.circuit::Kitchen Outlets (Counter) connection/count", + "energy.ebus.device.circuit::Kitchen Outlets (Counter) connection/feeds-device-id", + "energy.ebus.device.circuit::Kitchen Outlets (Counter) connection/feeds-device-status", + "energy.ebus.device.circuit::Kitchen Outlets (Counter) connection/feeds-device-type", + "energy.ebus.device.circuit::Kitchen Outlets (Island) connection/count", + "energy.ebus.device.circuit::Kitchen Outlets (Island) connection/feeds-device-id", + "energy.ebus.device.circuit::Kitchen Outlets (Island) connection/feeds-device-status", + "energy.ebus.device.circuit::Kitchen Outlets (Island) connection/feeds-device-type", + "energy.ebus.device.circuit::Laundry Room Outlets connection/count", + "energy.ebus.device.circuit::Laundry Room Outlets connection/feeds-device-id", + "energy.ebus.device.circuit::Laundry Room Outlets connection/feeds-device-status", + "energy.ebus.device.circuit::Laundry Room Outlets connection/feeds-device-type", + "energy.ebus.device.circuit::Living Room Lights connection/count", + "energy.ebus.device.circuit::Living Room Lights connection/feeds-device-id", + "energy.ebus.device.circuit::Living Room Lights connection/feeds-device-status", + "energy.ebus.device.circuit::Living Room Lights connection/feeds-device-type", + "energy.ebus.device.circuit::Living Room Outlets connection/count", + "energy.ebus.device.circuit::Living Room Outlets connection/feeds-device-id", + "energy.ebus.device.circuit::Living Room Outlets connection/feeds-device-status", + "energy.ebus.device.circuit::Living Room Outlets connection/feeds-device-type", + "energy.ebus.device.circuit::Main HVAC connection/count", + "energy.ebus.device.circuit::Main HVAC connection/feeds-device-id", + "energy.ebus.device.circuit::Main HVAC connection/feeds-device-status", + "energy.ebus.device.circuit::Main HVAC connection/feeds-device-type", + "energy.ebus.device.circuit::Master Bedroom Lights connection/count", + "energy.ebus.device.circuit::Master Bedroom Lights connection/feeds-device-id", + "energy.ebus.device.circuit::Master Bedroom Lights connection/feeds-device-status", + "energy.ebus.device.circuit::Master Bedroom Lights connection/feeds-device-type", + "energy.ebus.device.circuit::Master Bedroom Outlets connection/count", + "energy.ebus.device.circuit::Master Bedroom Outlets connection/feeds-device-id", + "energy.ebus.device.circuit::Master Bedroom Outlets connection/feeds-device-status", + "energy.ebus.device.circuit::Master Bedroom Outlets connection/feeds-device-type", + "energy.ebus.device.circuit::Microwave connection/count", + "energy.ebus.device.circuit::Microwave connection/feeds-device-id", + "energy.ebus.device.circuit::Microwave connection/feeds-device-status", + "energy.ebus.device.circuit::Microwave connection/feeds-device-type", + "energy.ebus.device.circuit::Office Outlets connection/count", + "energy.ebus.device.circuit::Office Outlets connection/feeds-device-id", + "energy.ebus.device.circuit::Office Outlets connection/feeds-device-status", + "energy.ebus.device.circuit::Office Outlets connection/feeds-device-type", + "energy.ebus.device.circuit::Pool Pump connection/count", + "energy.ebus.device.circuit::Pool Pump connection/feeds-device-id", + "energy.ebus.device.circuit::Pool Pump connection/feeds-device-status", + "energy.ebus.device.circuit::Pool Pump connection/feeds-device-type", + "energy.ebus.device.circuit::Refrigerator connection/count", + "energy.ebus.device.circuit::Refrigerator connection/feeds-device-id", + "energy.ebus.device.circuit::Refrigerator connection/feeds-device-status", + "energy.ebus.device.circuit::Refrigerator connection/feeds-device-type", + "energy.ebus.device.circuit::SPAN Drive - Driveway connection/count", + "energy.ebus.device.circuit::SPAN Drive - Garage connection/count", + "energy.ebus.device.circuit::Smoke Detectors connection/count", + "energy.ebus.device.circuit::Smoke Detectors connection/feeds-device-id", + "energy.ebus.device.circuit::Smoke Detectors connection/feeds-device-status", + "energy.ebus.device.circuit::Smoke Detectors connection/feeds-device-type", + "energy.ebus.device.circuit::Solar Inverter connection/count", + "energy.ebus.device.circuit::Washing Machine connection/count", + "energy.ebus.device.circuit::Washing Machine connection/feeds-device-id", + "energy.ebus.device.circuit::Washing Machine connection/feeds-device-status", + "energy.ebus.device.circuit::Washing Machine connection/feeds-device-type", + "energy.ebus.device.circuit::Water Heater connection/count", + "energy.ebus.device.circuit::Water Heater connection/feeds-device-id", + "energy.ebus.device.circuit::Water Heater connection/feeds-device-status", + "energy.ebus.device.circuit::Water Heater connection/feeds-device-type", + "energy.ebus.device.circuit::kitchen Lights connection/count", + "energy.ebus.device.circuit::kitchen Lights connection/feeds-device-id", + "energy.ebus.device.circuit::kitchen Lights connection/feeds-device-status", + "energy.ebus.device.circuit::kitchen Lights connection/feeds-device-type", + "energy.ebus.device.lugs::Downstream lugs connection/count", + "energy.ebus.device.lugs::Downstream lugs connection/fed-by-device-id", + "energy.ebus.device.lugs::Downstream lugs connection/fed-by-device-status", + "energy.ebus.device.lugs::Downstream lugs connection/fed-by-device-type", + "energy.ebus.device.lugs::Downstream lugs connection/feeds-device-id", + "energy.ebus.device.lugs::Downstream lugs connection/feeds-device-status", + "energy.ebus.device.lugs::Downstream lugs connection/feeds-device-type", + "energy.ebus.device.lugs::Upstream lugs connection/count", + "energy.ebus.device.lugs::Upstream lugs connection/feeds-device-id", + "energy.ebus.device.lugs::Upstream lugs connection/feeds-device-status", + "energy.ebus.device.lugs::Upstream lugs connection/feeds-device-type", + "energy.ebus.device.pv::Solar info/serial-number" +] diff --git a/tests/fixtures/v2/README.md b/tests/fixtures/v2/README.md index 04f9348..bfeabd1 100644 --- a/tests/fixtures/v2/README.md +++ b/tests/fixtures/v2/README.md @@ -4,24 +4,11 @@ Captured from a live SPAN Panel running firmware `spanos2/r202603/05`. Serial nu ## Files -| File | Source | Notes | -| ------------------- | -------------------------- | --------------------------------------------------------------------------------------- | -| `homie_schema.json` | `GET /api/v2/homie/schema` | Complete Homie property schema. Unauthenticated. Schema hash: `sha256:d347556a07d98f40` | -| `status.json` | `GET /api/v2/status` | v2 status probe response. Serial masked. | +| File | Source | Notes | +| ------------- | -------------------- | ---------------------------------------- | +| `status.json` | `GET /api/v2/status` | v2 status probe response. Serial masked. | -## Schema Hash +## Moved -`sha256:d347556a07d98f40` — use this to detect schema changes across firmware versions (compare against `typesSchemaHash` in live responses). - -## Node Types Present - -| Node Type | Properties | Notes | -| ------------------------------------------------ | ---------- | -------------------------------------------------- | -| `energy.ebus.device.distribution-enclosure.core` | 17 | Panel-wide state, network, hardware | -| `energy.ebus.device.lugs` | 7 | Upstream (main meter) and downstream (feedthrough) | -| `energy.ebus.device.circuit` | 16 | Per-circuit — one node per commissioned circuit | -| `energy.ebus.device.bess` | 12 | Battery — optional, only if commissioned | -| `energy.ebus.device.pv` | 7 | Solar — optional, only if commissioned | -| `energy.ebus.device.evse` | 9 | EV charger — optional, only if commissioned | -| `energy.ebus.device.pcs` | 15 | Power Control System — optional | -| `energy.ebus.device.power-flows` | 4 | Aggregated power flows (W) | +`homie_schema.json` is no longer a test fixture. It ships as package data at `src/span_panel_api/reference_payloads/homie_schema.json` and is read through `span_panel_api.reference_payloads.homie_schema()` — by this suite and by consumers alike, so there +is no copy anywhere that can go stale. Its provenance, schema hash and node-type table live in the README next to it. diff --git a/tests/test_accumulator.py b/tests/test_accumulator.py index bd038fe..c33750c 100644 --- a/tests/test_accumulator.py +++ b/tests/test_accumulator.py @@ -21,8 +21,8 @@ import pytest -from span_panel_api.mqtt.accumulator import HomieLifecycle, HomiePropertyAccumulator -from span_panel_api.mqtt.const import TOPIC_PREFIX +from span_panel_api_schema_0.accumulator import HomieLifecycle, HomiePropertyAccumulator +from span_panel_api_schema_0.const import TOPIC_PREFIX SERIAL = "nj-2316-XXXX" PREFIX = f"{TOPIC_PREFIX}/{SERIAL}" diff --git a/tests/test_adapters_discovery.py b/tests/test_adapters_discovery.py new file mode 100644 index 0000000..8138b6d --- /dev/null +++ b/tests/test_adapters_discovery.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +from typing import Any, Protocol +from unittest.mock import patch + +import pytest + +from span_panel_api.adapters import ( + DEFAULT_ADAPTER_KEY, + _reset_adapter_cache, + installed_adapter_keys, + resolve_adapter, +) +from span_panel_api.exceptions import SpanPanelAdapterIncompatibleError, SpanPanelAdapterMissingError +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.models import MqttClientConfig +from span_panel_api.protocol import ADAPTER_CONTRACT_VERSION + +from conftest import MOCK_SCHEMA + + +def test_discovers_the_self_registered_schema_zero_adapter() -> None: + _reset_adapter_cache() + + assert "schema_0" in installed_adapter_keys() + assert resolve_adapter("schema_0", "test").__name__ == "SchemaZeroAdapter" + + +def test_resolution_is_cached_across_calls() -> None: + """Per key, and it has to be: `_on_pre_rebuild` resolves from a synchronous + bridge callback and relies on there being no import left to do.""" + _reset_adapter_cache() + assert resolve_adapter("schema_0", "test") is resolve_adapter("schema_0", "test") + + +# --------------------------------------------------------------------------- +# The default adapter path — the bootstrap must not import a parser to get one +# --------------------------------------------------------------------------- + + +def _client(adapter_factory: object = None) -> SpanMqttClient: + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + kwargs = {} if adapter_factory is None else {"adapter_factory": adapter_factory} + return SpanMqttClient("panel.local", "SERIAL123", config, **kwargs) # type: ignore[arg-type] + + +def _nothing_installed() -> Any: + """Patch enumeration to a completed scan that found nothing. + + A completed empty scan, not a missing one: `None` would make the next call + re-scan and pick up this environment's real adapters. + """ + return patch("span_panel_api.adapters._ENTRY_POINTS", {}) + + +def test_default_factory_resolves_the_flat_adapter_through_discovery() -> None: + """No adapter_factory means "resolve the default key", not "import SchemaZeroAdapter".""" + _reset_adapter_cache() + client = _client() + + adapter = client._build_adapter(MOCK_SCHEMA) + + assert adapter.schema_major == DEFAULT_ADAPTER_KEY + assert type(adapter) is resolve_adapter(DEFAULT_ADAPTER_KEY, "test") + + +def test_constructing_a_client_does_not_require_an_installed_adapter() -> None: + """Construction must stay adapter-free; only building a parser needs one. + + This is the property that lets the bootstrap ship without a parser at all. + """ + with _nothing_installed(): + _client() # must not raise + + +def test_building_a_parser_without_any_adapter_raises_by_name() -> None: + """The adapter-less install's failure mode: a named error, not ModuleNotFoundError.""" + _reset_adapter_cache() + client = _client() + + with _nothing_installed(), pytest.raises(SpanPanelAdapterMissingError) as exc: + client._build_adapter(MOCK_SCHEMA) + + assert exc.value.needed == DEFAULT_ADAPTER_KEY + assert exc.value.available == [] + + +def test_an_explicit_factory_bypasses_discovery_entirely() -> None: + """Injection still wins — used by the factory's Tier 1 dispatch and by tests. + + Patched where the client looks it up rather than where it is defined: the + module imports the name, so patching `adapters.resolve_adapter` rebinds a + reference `_build_adapter` never reads, and the assertion could not fire. + """ + _reset_adapter_cache() + real_cls = resolve_adapter(DEFAULT_ADAPTER_KEY, "test") + client = _client(adapter_factory=real_cls) + + with patch("span_panel_api.mqtt.client.resolve_adapter", side_effect=AssertionError("must not be consulted")): + adapter = client._build_adapter(MOCK_SCHEMA) + + assert type(adapter) is real_cls + + +def test_resolve_adapter_names_what_is_installed() -> None: + _reset_adapter_cache() + with pytest.raises(SpanPanelAdapterMissingError) as exc: + resolve_adapter("schema_9", "made-up key") + + assert exc.value.needed == "schema_9" + assert DEFAULT_ADAPTER_KEY in exc.value.available + + +# --------------------------------------------------------------------------- +# Entry-point validation — a bad adapter package must not become an opaque +# TypeError deep inside connect() +# --------------------------------------------------------------------------- + + +class _FakeEntryPoint: + def __init__(self, name: str, value: object) -> None: + self.name = name + self._value = value + self.loads = 0 + + def load(self) -> object: + self.loads += 1 + return self._value + + +def _discover_with(*eps: _FakeEntryPoint) -> dict[str, object]: + """Every entry point that survives vetting, resolved one key at a time. + + This is what the eager registry used to be, rebuilt by the test rather than + by the module — discovery no longer produces such a map, because producing + one is exactly the import-everything cost the split removed. The vetting + rules below are unchanged and still deserve asserting individually, so the + map is reconstructed here instead of rewriting each of them into a + try/except around a single resolve. + """ + _reset_adapter_cache() + usable: dict[str, object] = {} + with patch("span_panel_api.adapters.entry_points", return_value=list(eps)): + for name in installed_adapter_keys(): + try: + usable[name] = resolve_adapter(name, "test") + except (SpanPanelAdapterMissingError, SpanPanelAdapterIncompatibleError): + continue + return usable + + +def test_resolving_one_key_leaves_the_others_unimported() -> None: + """The property the split exists for: a flat panel must not import schema_1. + + That package pulls in the eBus SDK and jsonschema — two seconds on a cold + import cache, and a dependency its own packaging confines to that + distribution precisely so a flat install stays clear of it. Eager discovery + imported it on every flat connection, and redispatch made installing both + adapters the normal setup, so "installed" stopped implying "used". + + Asserted on `load()` rather than on `sys.modules`, which by this point in a + test session has every adapter in it for unrelated reasons. + """ + from span_panel_api_schema_0 import SchemaZeroAdapter + + wanted = _FakeEntryPoint("schema_0", SchemaZeroAdapter) + other = _FakeEntryPoint("schema_9", SchemaZeroAdapter) + + _reset_adapter_cache() + with patch("span_panel_api.adapters.entry_points", return_value=[wanted, other]): + assert installed_adapter_keys() == ["schema_0", "schema_9"], "both must still be reported installed" + resolve_adapter("schema_0", "test") + + assert wanted.loads == 1 + assert other.loads == 0, "resolving one key must not import the rest" + + +def _conforming_members(contract: object = ADAPTER_CONTRACT_VERSION) -> dict[str, object]: + """Members for a class that passes discovery, derived from the protocol. + + Derived rather than listed so it stays honest as SchemaAdapter grows: a test + that builds its fixture by hand starts passing for the wrong reason the day + a member is added. + + ADAPTER_CONTRACT is the one member a callable will not do for, because it is + checked for value and not only presence — which is the whole point of it. + """ + from span_panel_api.adapters import _REQUIRED_MEMBERS + + members: dict[str, object] = {name: (lambda self, *args, **kwargs: None) for name in _REQUIRED_MEMBERS} + members["ADAPTER_CONTRACT"] = contract + return members + + +def test_required_members_are_derived_from_the_protocol() -> None: + """The check must not restate the contract — a method added to SchemaAdapter + becomes required of every adapter without anyone remembering to update a list.""" + from span_panel_api.adapters import _REQUIRED_MEMBERS + from span_panel_api.protocol import SchemaAdapter + + assert set(SchemaAdapter.__annotations__) <= set(_REQUIRED_MEMBERS) + assert "topics_to_subscribe" in _REQUIRED_MEMBERS + assert "build_snapshot" in _REQUIRED_MEMBERS + # Dunders are excluded: presence tells us nothing, every object has them. + assert not [member for member in _REQUIRED_MEMBERS if member.startswith("_")] + + +def test_every_kind_of_declared_member_is_required_not_just_plain_methods() -> None: + """A property is not callable and neither is a classmethod object, so a + kind-filtered derivation would silently stop requiring them. SchemaAdapter + declares only plain methods today; this pins the rule before it declares more.""" + from span_panel_api.adapters import _derive_required_members + + class SurfaceProbe(Protocol): + annotated: str + + @property + def a_property(self) -> int: ... + + @classmethod + def a_classmethod(cls) -> None: ... + + @staticmethod + def a_staticmethod() -> None: ... + + def a_method(self) -> None: ... + + assert set(_derive_required_members(SurfaceProbe)) == { + "annotated", + "a_property", + "a_classmethod", + "a_staticmethod", + "a_method", + } + + +def test_an_adapter_missing_a_non_method_member_is_still_rejected() -> None: + """The end-to-end consequence of the rule above: presence checking has to + reach members that are not plain methods, or a defective adapter registers. + + Built from _REQUIRED_MEMBERS so it stays honest as the protocol grows: the + 'complete' half proves the fixture really does satisfy the check, which is + what makes the 'incomplete' half's rejection attributable to the one + removed member rather than to an unrelated gap. + """ + complete = _conforming_members() + incomplete = {name: value for name, value in complete.items() if name != "SUPPORTS_DATA_MODEL_VERSIONS"} + + assert _discover_with(_FakeEntryPoint("schema_9", type("Complete", (), complete))) != {} + assert _discover_with(_FakeEntryPoint("schema_9", type("Incomplete", (), incomplete))) == {} + + +@pytest.mark.parametrize( + ("label", "value"), + [ + ("a module", pytest), + ("a function", lambda serial, size: None), + ("an instance rather than a class", object()), + ("a string", "span_panel_api_schema_0:SchemaZeroAdapter"), + ], +) +def test_non_class_entry_points_are_skipped_not_registered(label: str, value: object) -> None: + """The failure that actually happens: an entry point pointing at the wrong + kind of object. Phase 0 stored it and blew up later inside connect().""" + assert _discover_with(_FakeEntryPoint("schema_9", value)) == {}, label + + +def test_a_class_missing_protocol_members_is_skipped() -> None: + class NotAnAdapter: + schema_major = "schema_9" + + assert _discover_with(_FakeEntryPoint("schema_9", NotAnAdapter)) == {} + + +def test_a_conforming_class_is_registered() -> None: + from span_panel_api_schema_0 import SchemaZeroAdapter + + registry = _discover_with(_FakeEntryPoint("schema_0", SchemaZeroAdapter)) + + assert registry == {"schema_0": SchemaZeroAdapter} + + +def test_one_bad_adapter_does_not_hide_the_good_ones() -> None: + """A broken third-party adapter must not take down a panel whose own adapter + is installed and fine.""" + from span_panel_api_schema_0 import SchemaZeroAdapter + + registry = _discover_with( + _FakeEntryPoint("schema_9", "not a class"), + _FakeEntryPoint("schema_0", SchemaZeroAdapter), + ) + + assert registry == {"schema_0": SchemaZeroAdapter} + + +def test_an_entry_point_that_raises_on_load_is_skipped() -> None: + from span_panel_api_schema_0 import SchemaZeroAdapter + + class Exploding(_FakeEntryPoint): + def load(self) -> object: + raise ImportError("adapter package is half-installed") + + registry = _discover_with(Exploding("schema_9", None), _FakeEntryPoint("schema_0", SchemaZeroAdapter)) + + assert registry == {"schema_0": SchemaZeroAdapter} + + +# --------------------------------------------------------------------------- +# Contract versioning — an adapter built against a different bootstrap must be +# rejected where the remedy can still be named, not at construction +# --------------------------------------------------------------------------- + + +def test_the_shipped_adapters_declare_the_contract_this_package_speaks() -> None: + """The pairing that actually ships. Both adapters version independently of + the bootstrap, so nothing but this check keeps their declared contract + honest when the protocol moves.""" + from span_panel_api_schema_0 import SchemaZeroAdapter + from span_panel_api_schema_1 import SchemaOneAdapter + + assert SchemaZeroAdapter.ADAPTER_CONTRACT == ADAPTER_CONTRACT_VERSION + assert SchemaOneAdapter.ADAPTER_CONTRACT == ADAPTER_CONTRACT_VERSION + + +@pytest.mark.parametrize( + ("label", "contract"), + [ + ("older", ADAPTER_CONTRACT_VERSION - 1), + ("newer", ADAPTER_CONTRACT_VERSION + 1), + ], +) +def test_an_adapter_built_for_another_contract_is_rejected(label: str, contract: int) -> None: + """Both directions, because either half can be the stale one: an old adapter + against a new bootstrap, or an adapter from a future release against this.""" + members = _conforming_members(contract=contract) + + assert _discover_with(_FakeEntryPoint("schema_9", type("Mismatched", (), members))) == {}, label + + +def test_a_contract_that_is_not_an_integer_is_rejected() -> None: + """`True == 1` is the trap: bool is a subclass of int, so a truthy marker + would otherwise compare equal to contract 1 and be accepted.""" + assert _discover_with(_FakeEntryPoint("schema_9", type("Truthy", (), _conforming_members(contract=True)))) == {} + assert _discover_with(_FakeEntryPoint("schema_9", type("Stringly", (), _conforming_members(contract="1")))) == {} + + +def test_an_adapter_predating_contract_versioning_is_rejected_by_age_not_by_shape() -> None: + """The real regression this closes: an early schema-1 build paired with a + bootstrap whose adapters took `panel_size`. Such an adapter carries every other + required name, so nothing but the contract member distinguishes it, and + without one it reached construction and died on argument count.""" + members = _conforming_members() + del members["ADAPTER_CONTRACT"] + + _reset_adapter_cache() + with patch( + "span_panel_api.adapters.entry_points", + return_value=[_FakeEntryPoint("schema_9", type("Ancient", (), members))], + ): + with pytest.raises(SpanPanelAdapterIncompatibleError) as exc: + resolve_adapter("schema_9", "test") + + assert "predates contract versioning" in str(exc.value) + + +def test_a_rejected_adapter_is_reported_as_unusable_not_as_missing() -> None: + """Absent and rejected are opposite remedies. Reporting a stale adapter as + missing sends someone to install a package they already have.""" + members = _conforming_members(contract=ADAPTER_CONTRACT_VERSION + 1) + + _reset_adapter_cache() + with patch( + "span_panel_api.adapters.entry_points", + return_value=[_FakeEntryPoint("schema_9", type("FromTheFuture", (), members))], + ): + with pytest.raises(SpanPanelAdapterIncompatibleError) as exc: + resolve_adapter("schema_9", "panel needs it") + + assert exc.value.needed == "schema_9" + assert "contract" in exc.value.defect + # Still the missing error when nothing registers the key at all, so the two + # paths cannot quietly collapse into one message. + assert not isinstance(exc.value, SpanPanelAdapterMissingError) + + +def test_a_rejected_adapter_does_not_make_a_working_one_unreachable() -> None: + """The rejection is per entry point. A stale third-party adapter must not + stop the panel whose own adapter is fine, which is why discovery logs rather + than raises and only resolve_adapter turns it into an error.""" + from span_panel_api_schema_0 import SchemaZeroAdapter + + stale = type("Stale", (), _conforming_members(contract=ADAPTER_CONTRACT_VERSION + 1)) + registry = _discover_with( + _FakeEntryPoint("schema_9", stale), + _FakeEntryPoint("schema_0", SchemaZeroAdapter), + ) + + assert registry == {"schema_0": SchemaZeroAdapter} diff --git a/tests/test_adopted_control.py b/tests/test_adopted_control.py new file mode 100644 index 0000000..caccd4e --- /dev/null +++ b/tests/test_adopted_control.py @@ -0,0 +1,188 @@ +"""Writing to an adopted property, and the ways that write refuses. + +The write exists so a control on a device nobody modelled is usable rather than +decorative. What matters here is the refusals: the write must not become a +generic one, because a generic write puts every curated setter one argument away +-- including the two that do real work on the way out, the islanding assertion +that translates its value and the charge ceiling that refuses one above what the +charger was commissioned for. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from span_panel_api.exceptions import SpanPanelServerError +from span_panel_api.models import AdoptedDevice, AdoptedProperty +from span_panel_api.mqtt import MqttClientConfig +from span_panel_api.mqtt.client import SpanMqttClient + +SERIAL = "sp3-242424-001" +DEVICE = "generator-1" + +CONTROL = AdoptedProperty( + node_id="generator", + property_id="mode", + datatype="enum", + format="AUTO,MANUAL,OFF", + settable=True, + value="AUTO", + set_topic=f"ebus/5/{DEVICE}/generator/mode/set", +) + +READING = AdoptedProperty(node_id="meter", property_id="active-power", datatype="float", unit="W", value="2400") + + +def _client(*properties: AdoptedProperty) -> tuple[SpanMqttClient, MagicMock]: + """A client whose adapter reports one adopted device carrying `properties`.""" + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + + adapter = MagicMock() + adapter.build_snapshot.return_value = MagicMock( + adopted_devices=(AdoptedDevice(device_id=DEVICE, device_type="energy.ebus.device.generator", properties=properties),) + ) + client._adapter = adapter + bridge = MagicMock() + client._bridge = bridge + return client, bridge + + +@pytest.mark.asyncio +async def test_a_settable_adopted_property_publishes_to_its_own_topic() -> None: + """The value passes through unchanged, which is the honest thing to do. + + This library knows nothing about an adopted property beyond its declaration, + so translating or bounding the value would be inventing a fact about somebody + else's hardware. The caller constrains it to the declared format; the panel + stays the authority on whether to accept it. + """ + client, bridge = _client(CONTROL, READING) + + await client.set_adopted_property(DEVICE, "generator", "mode", "OFF") + + bridge.publish.assert_called_once_with(f"ebus/5/{DEVICE}/generator/mode/set", "OFF", qos=1) + + +@pytest.mark.asyncio +async def test_a_property_carrying_no_set_topic_is_refused() -> None: + """A reading is not writable, and the absence of a topic is what says so.""" + client, bridge = _client(CONTROL, READING) + + with pytest.raises(SpanPanelServerError, match="No settable adopted property"): + await client.set_adopted_property(DEVICE, "meter", "active-power", "0") + + bridge.publish.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_property_no_adopted_device_declares_is_refused() -> None: + """Arguments do not authorise the write; the snapshot does.""" + client, bridge = _client(CONTROL) + + with pytest.raises(SpanPanelServerError): + await client.set_adopted_property(DEVICE, "generator", "invented", "OFF") + + bridge.publish.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_device_the_adapter_models_cannot_be_addressed_through_this() -> None: + """The whole reason the lookup is the authorisation. + + A circuit declares `switch/relay` settable and has a curated setter that owns + it. Spelling the circuit's id here reaches no `AdoptedDevice`, so there is + nothing to publish to -- not because a check rejected it, but because a + modelled device produces no adopted record to find. + """ + client, bridge = _client(CONTROL) + + with pytest.raises(SpanPanelServerError): + await client.set_adopted_property("aabbccdd112233445566778899001122", "switch", "relay", "OPEN") + + bridge.publish.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_device_that_has_left_the_tree_stops_being_writable() -> None: + """Resolved against the current snapshot each time rather than cached. + + A control for a device that is no longer there must refuse rather than + publish into a topic nothing subscribes to. + """ + client, bridge = _client(CONTROL) + client._adapter.build_snapshot.return_value = MagicMock(adopted_devices=()) + + with pytest.raises(SpanPanelServerError): + await client.set_adopted_property(DEVICE, "generator", "mode", "OFF") + + bridge.publish.assert_not_called() + + +def _two_generators() -> tuple[SpanMqttClient, MagicMock]: + """Two adopted devices of one unmodelled type, each declaring the same control. + + The realistic shape, and the one the single-device fixtures above cannot + exercise: nothing about adoption limits a panel to one generator, and two of a + kind is exactly when a device id stops being decoration. + """ + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + + def control(device_id: str) -> AdoptedProperty: + return AdoptedProperty( + node_id="generator", + property_id="mode", + datatype="enum", + format="AUTO,MANUAL,OFF", + settable=True, + value="AUTO", + set_topic=f"ebus/5/{device_id}/generator/mode/set", + ) + + adapter = MagicMock() + adapter.build_snapshot.return_value = MagicMock( + adopted_devices=tuple( + AdoptedDevice( + device_id=device_id, + device_type="energy.ebus.device.generator", + properties=(control(device_id),), + ) + for device_id in ("generator-1", "generator-2") + ) + ) + client._adapter = adapter + bridge = MagicMock() + client._bridge = bridge + return client, bridge + + +@pytest.mark.asyncio +async def test_the_write_reaches_the_device_that_was_named() -> None: + """The device id is the authorization, not a label on it. + + The lookup returns the first device carrying the node and property asked for, + so without the id filter a write aimed at the second generator publishes to + the first one's topic -- the panel accepts it, and the wrong machine changes + mode. Every other test here uses a single adopted device, where the filter + cannot be wrong because there is nothing else to match. + """ + client, bridge = _two_generators() + + await client.set_adopted_property("generator-2", "generator", "mode", "MANUAL") + + topic, payload = bridge.publish.call_args[0][:2] + assert topic == "ebus/5/generator-2/generator/mode/set" + assert payload == "MANUAL" + + +@pytest.mark.asyncio +async def test_a_device_that_is_not_adopted_is_refused_even_when_a_sibling_declares_the_property() -> None: + """The property existing somewhere is not the property existing here.""" + client, bridge = _two_generators() + + with pytest.raises(SpanPanelServerError): + await client.set_adopted_property("generator-3", "generator", "mode", "MANUAL") + + bridge.publish.assert_not_called() diff --git a/tests/test_adoption.py b/tests/test_adoption.py new file mode 100644 index 0000000..e01b6d1 --- /dev/null +++ b/tests/test_adoption.py @@ -0,0 +1,414 @@ +"""Devices this adapter models nothing for are adopted whole; modelled ones never are. + +The rule under test has two halves and both are failure modes. Adopting a device +the snapshot builder already reads would stand a machine-named device card beside +a curated one describing the same hardware. *Not* adopting one the builder +ignores is the silence this module exists to end -- a panel publishing a device +nobody modelled, and no sign of it anywhere. + +Every case is built by putting a device on the reference tree and reading what +comes back, never by calling the classifier directly: the question is what a +panel gets, and a classifier that agrees with itself proves nothing about that. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest +from span_panel_api.models import SpanPanelSnapshot +from span_panel_api_schema_1.adoption import MODELLED_TYPES +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree +from span_panel_api_schema_1.snapshot import build_snapshot + +if TYPE_CHECKING: + from span_panel_api.models import AdoptedDevice + +PANEL = "example-40t-001" + +UNMODELLED_TYPE = "energy.ebus.device.generator" +"""A type this adapter models nothing for. + +A generator rather than an invented string: the eBus vocabulary already names one +as a grid-forming device class, and the schema is explicitly vendor-extensible, +so an unmodelled arrival is the expected case rather than a hypothetical. +""" + + +def _tree() -> dict[str, dict[str, str]]: + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _snapshot(tree: dict[str, dict[str, str]]) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL, tree[PANEL]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL] + return build_snapshot(panel, children) + + +def _device( + device_type: str, + *, + name: str = "Backup Generator", + nodes: dict[str, dict[str, dict[str, object]]] | None = None, + values: dict[str, str] | None = None, + parent: str = PANEL, +) -> dict[str, str]: + """One device's retained topics, as the broker hands them back. + + `$description` is a JSON *string* rather than a nested object, which is how + the transport carries it and how the reference payload stores it. + """ + description: dict[str, object] = { + "homie": "5.0", + "version": 1, + "type": device_type, + "name": name, + "nodes": nodes or {}, + "parent": parent, + "root": PANEL, + } + topics = {"$description": json.dumps(description), "$state": "ready"} + topics.update(values or {}) + return topics + + +def _with(tree: dict[str, dict[str, str]], device_id: str, topics: dict[str, str]) -> dict[str, dict[str, str]]: + tree[device_id] = topics + return tree + + +def _adopted(snapshot: SpanPanelSnapshot) -> dict[str, AdoptedDevice]: + return {device.device_id: device for device in snapshot.adopted_devices} + + +# -- The reference tree adopts nothing --------------------------------------- + + +def test_a_tree_of_modelled_devices_adopts_nothing() -> None: + """The captured tree is thirteen devices this adapter reads, so it adopts none. + + The baseline the rest of this module measures against: anything that shows up + here is a modelled device leaking into adoption, which is the failure that + duplicates a curated device card. + """ + assert _snapshot(_tree()).adopted_devices == () + + +@pytest.mark.parametrize("modelled", MODELLED_TYPES) +def test_no_modelled_type_is_ever_adopted(modelled: str) -> None: + """Every type the snapshot builder sorts into a role stays out of adoption. + + Parametrised over the declared tuple and asserted through `build_snapshot`, + so the tuple cannot drift from the builder silently: a type dropped from + `TreeRoles` while left in `MODELLED_TYPES` would make its devices invisible + to both paths at once, and this is what fails instead. + """ + tree = _with(_tree(), "extra-device", _device(modelled)) + assert "extra-device" not in _adopted(_snapshot(tree)) + + +@pytest.mark.parametrize("subtype", [f"{TYPE}.upstream" for TYPE in MODELLED_TYPES]) +def test_a_subtype_of_a_modelled_type_is_not_adopted(subtype: str) -> None: + """Firmware may subtype a device class, and the builder matches lugs by prefix. + + A subtype adopted behind the builder's back is the same duplicate-device + failure, arriving through a spelling rather than through a type. + """ + tree = _with(_tree(), "extra-device", _device(subtype)) + assert "extra-device" not in _adopted(_snapshot(tree)) + + +def test_a_device_declaring_no_type_yet_is_skipped_rather_than_adopted() -> None: + """A device describing itself without a type yet is not an unmodelled device. + + Mid-discovery is a normal state rather than an error -- `device_type` answers + `""` for it by design. Adopting on that would mint a device card for + something whose type is about to arrive, and then leave it standing when the + real type turns out to be one this adapter models. + """ + untyped = json.dumps({"homie": "5.0", "version": 1, "name": "Arriving", "nodes": {}}) + tree = _with(_tree(), "still-arriving", {"$description": untyped, "$state": "init"}) + assert _snapshot(tree).adopted_devices == () + + +# -- An unmodelled type is adopted whole ------------------------------------- + + +def test_an_unmodelled_type_is_adopted() -> None: + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE)) + adopted = _adopted(_snapshot(tree)) + + assert set(adopted) == {"generator-1"} + assert adopted["generator-1"].device_type == UNMODELLED_TYPE + assert adopted["generator-1"].name == "Backup Generator" + + +def test_adoption_carries_the_value_where_discovery_carries_only_the_declaration() -> None: + """The one difference between the two records, and the reason they are two types. + + A discovery row is built to be forwarded in diagnostics, which leave the + machine, so it has no member a reading can go in. An adopted property is + built to become an entity on the machine that made it, so it must carry one. + """ + nodes = {"meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}} + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes, values={"meter/active-power": "2400"})) + + (reading,) = _adopted(_snapshot(tree))["generator-1"].properties + assert (reading.node_id, reading.property_id) == ("meter", "active-power") + assert (reading.datatype, reading.unit) == ("float", "W") + assert reading.value == "2400" + assert reading.path == "meter/active-power" + + +def test_a_declared_property_with_nothing_published_adopts_with_no_value() -> None: + """Declared-and-never-valued is a state to report, not a property to drop. + + Dropping it would make the entity appear only once the panel first published, + which reads to a user as an entity that comes and goes. + """ + nodes = {"meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}} + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes)) + + (reading,) = _adopted(_snapshot(tree))["generator-1"].properties + assert reading.value is None + + +def test_the_declared_format_and_settable_flag_survive_adoption() -> None: + """Both halves of what a consumer needs to build a control rather than a reading. + + `settable` says a write is accepted; `format` is the value domain that makes + the control constructible. A select with no option list is not a safer + control, it is a broken one, so the consumer needs to see both. + """ + nodes = { + "generator": { + "properties": { + "mode": { + "datatype": "enum", + "format": "AUTO,MANUAL,OFF", + "settable": True, + } + } + } + } + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes)) + + (control,) = _adopted(_snapshot(tree))["generator-1"].properties + assert control.settable is True + assert control.format == "AUTO,MANUAL,OFF" + assert control.unit is None + + +# -- info and connection resolve away from entities -------------------------- + + +def test_info_becomes_the_device_card_and_not_properties() -> None: + """`info` describes the thing rather than reporting a reading. + + The same treatment `bess_device_info` has given a curated device since v1.0, + applied to an adopted one for the same reason. + """ + nodes = { + "info": { + "properties": { + "vendor-name": {"datatype": "string"}, + "model": {"datatype": "string"}, + "serial-number": {"datatype": "string"}, + "firmware-version": {"datatype": "string"}, + "hardware-version": {"datatype": "string"}, + } + } + } + values = { + "info/vendor-name": "Example Power", + "info/model": "GEN-9000", + "info/serial-number": "EX-0000-0001", + "info/firmware-version": "3.2.1", + "info/hardware-version": "rev-C", + } + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes, values=values)) + + device = _adopted(_snapshot(tree))["generator-1"] + assert device.properties == () + assert device.vendor_name == "Example Power" + assert device.model == "GEN-9000" + assert device.serial_number == "EX-0000-0001" + assert device.software_version == "3.2.1" + assert device.hardware_version == "rev-C" + + +def test_connection_is_dropped_rather_than_surfaced() -> None: + """`connection` is the device tree, which is `via_device`, not a sensor. + + Excluded by node rather than by property name on purpose. The catalogs carry + no marker for "this string is a device reference", so a name list is the only + alternative -- and a name list goes stale silently, which is what `ebus-sdk`'s + own `topology.py` does by covering two such properties and omitting a third. + """ + nodes = { + "connection": { + "properties": { + "fed-by-device-id": {"datatype": "string"}, + "feeds-device-type": {"datatype": "string"}, + } + }, + "meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}, + } + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes)) + + device = _adopted(_snapshot(tree))["generator-1"] + assert [reading.path for reading in device.properties] == ["meter/active-power"] + + +def test_an_info_property_the_card_has_no_field_for_is_not_promoted_to_an_entity() -> None: + """`info` is excluded by node, so an unrecognised member of it is dropped too. + + The alternative -- dropping only the five the card reads -- would surface + `info/nominal-power` and its siblings as string sensors the moment a vendor + declared one, which is the metadata-as-entities failure the node rule exists + to prevent. + """ + nodes = {"info": {"properties": {"nominal-power": {"datatype": "float", "unit": "W"}}}} + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes, values={"info/nominal-power": "9000"})) + + assert _adopted(_snapshot(tree))["generator-1"].properties == () + + +# -- Multiplicity is not adoption -------------------------------------------- + + +def test_a_second_bess_is_not_adopted() -> None: + """A modelled type arriving twice is a multiplicity limit, not an unmodelled device. + + `TreeRoles` keeps the first BESS and silently ignores the rest, which is a + real gap -- but adopting the extra one would answer it with a machine-named + device card standing beside the curated Battery, describing the same + hardware. The gap stays visible as a gap instead. + """ + tree = _with(_tree(), "bess-2", _device("energy.ebus.device.bess", name="Second Battery")) + assert _snapshot(tree).adopted_devices == () + + +# -- schema_0 adopts nothing ------------------------------------------------- + + +def test_the_snapshot_field_defaults_empty() -> None: + """What makes the field additive rather than a protocol change. + + schema_0 never populates it: flat has no device tree to find an unmodelled + device in. A default of `()` is what lets that adapter stay untouched and + keeps `adopted_devices` off `SchemaAdapter`, whose members are required of + every adapter package. + """ + assert SpanPanelSnapshot.__dataclass_fields__["adopted_devices"].default == () + + +# -- The set topic exists only where a write is legal ------------------------ + + +def test_a_settable_property_carries_the_topic_a_write_goes_to() -> None: + nodes = {"generator": {"properties": {"mode": {"datatype": "enum", "format": "AUTO,OFF", "settable": True}}}} + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes)) + + (control,) = _adopted(_snapshot(tree))["generator-1"].properties + assert control.set_topic == "ebus/5/generator-1/generator/mode/set" + + +def test_a_property_the_device_does_not_declare_settable_carries_no_topic() -> None: + """The absence is the authorisation, not a flag a caller is trusted to read. + + A consumer cannot construct a write for a property that carries no topic, so + "is this writable" is answered by the declaration once, here, rather than by + every caller remembering to ask. + """ + nodes = {"meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}} + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes)) + + (reading,) = _adopted(_snapshot(tree))["generator-1"].properties + assert reading.set_topic is None + + +def test_no_topic_reachable_this_way_can_name_a_modelled_device() -> None: + """The property that keeps this from being a generic write. + + A generic `set_property_topic(device, node, property)` would put every + curated control one argument away -- including the two that do real work on + the way out: the islanding assertion translates its value, and the charge + ceiling refuses one above what the charger was commissioned for. Because a + modelled device produces no `AdoptedDevice` at all, no topic produced here + can address one, whatever a caller passes. + """ + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE)) + snapshot = _snapshot(tree) + + addressable = {device.device_id for device in snapshot.adopted_devices for prop in device.properties if prop.set_topic} + modelled = {device_id for device_id in _tree() if device_id != PANEL} + assert not (addressable & modelled) + + +def test_a_settable_property_on_a_modelled_device_is_never_adopted_and_so_never_writable() -> None: + """Stated against a circuit, which really does declare settable properties. + + The reference tree's circuits declare `switch/relay` and `load-shed/priority` + settable, and both have curated setters. Adoption must not offer a second + route to either. + """ + snapshot = _snapshot(_tree()) + assert snapshot.adopted_devices == () + + +# -- The proxy link is carried, not acted on --------------------------------- + + +def test_a_device_the_enclosure_itself_declares_is_not_proxied() -> None: + """The ordinary case: a child of the tree root.""" + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE)) + + device = _adopted(_snapshot(tree))["generator-1"] + assert device.parent == PANEL + assert device.proxied is False + + +def test_a_device_proxied_by_a_peer_says_so() -> None: + """The shape the specification names, and the reason the field exists. + + The reference tree's own `bess-mid` declares `parent: bess` -- the + `{proxier-id}-{proxied-id}` naming of `devices/proxy.md`. A vendor gateway + proxying its own sub-devices arrives the same way, and the parent link is the + only structural information about how they relate. + """ + tree = _with(_tree(), "gateway-1", _device(UNMODELLED_TYPE, name="Vendor Gateway")) + tree = _with(tree, "gateway-1-sensor", _device(UNMODELLED_TYPE, name="Gateway Sensor", parent="gateway-1")) + + adopted = _adopted(_snapshot(tree)) + assert adopted["gateway-1"].proxied is False + assert adopted["gateway-1-sensor"].parent == "gateway-1" + assert adopted["gateway-1-sensor"].proxied is True + + +def test_the_parent_link_changes_no_topology_here() -> None: + """Carried, not acted on -- see `AdoptedDevice.parent`. + + Both devices are adopted as peers; nothing in this library nests one under + the other. Pinned so that if nesting is built later it is a deliberate change + with a test to update, rather than something that drifts in. + """ + tree = _with(_tree(), "gateway-1", _device(UNMODELLED_TYPE)) + tree = _with(tree, "gateway-1-sensor", _device(UNMODELLED_TYPE, parent="gateway-1")) + + assert {d.device_id for d in _snapshot(tree).adopted_devices} == {"gateway-1", "gateway-1-sensor"} + + +def test_a_description_declaring_no_parent_is_not_proxied() -> None: + """Absence is not a proxy claim. + + `proxied` requires both a parent and a root to compare it against, so a + partial description answers False rather than guessing. + """ + untyped = json.dumps({"homie": "5.0", "version": 1, "type": UNMODELLED_TYPE, "name": "Orphan", "nodes": {}}) + tree = _with(_tree(), "orphan-1", {"$description": untyped, "$state": "ready"}) + + device = _adopted(_snapshot(tree))["orphan-1"] + assert device.parent is None + assert device.proxied is False diff --git a/tests/test_auth_and_homie_helpers.py b/tests/test_auth_and_homie_helpers.py index a65fb67..55332b6 100644 --- a/tests/test_auth_and_homie_helpers.py +++ b/tests/test_auth_and_homie_helpers.py @@ -3,16 +3,20 @@ from __future__ import annotations import json -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest +from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.consumer import HomieDeviceConsumer, _parse_int from span_panel_api.auth import _int, download_ca_cert, get_homie_schema -from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelTimeoutError -from span_panel_api.mqtt.accumulator import HomiePropertyAccumulator -from span_panel_api.mqtt.homie import HomieDeviceConsumer, _parse_int - +from span_panel_api.exceptions import ( + SpanPanelAPIError, + SpanPanelConnectionError, + SpanPanelServerError, + SpanPanelTimeoutError, +) # --------------------------------------------------------------------------- # auth._int edge cases (lines 29-31) @@ -35,6 +39,17 @@ def test_string_parsed(self) -> None: # --------------------------------------------------------------------------- +def _mock_response(method: str, status_code: int) -> AsyncMock: + """A client whose request completes and returns `status_code`.""" + response = MagicMock() + response.status_code = status_code + mock = AsyncMock() + setattr(mock, method, AsyncMock(return_value=response)) + mock.__aenter__ = AsyncMock(return_value=mock) + mock.__aexit__ = AsyncMock(return_value=False) + return mock + + def _mock_client(method: str, side_effect: Exception) -> AsyncMock: mock = AsyncMock() setattr(mock, method, AsyncMock(side_effect=side_effect)) @@ -79,6 +94,32 @@ async def test_timeout_error(self) -> None: with pytest.raises(SpanPanelTimeoutError): await get_homie_schema("192.168.1.1") + @pytest.mark.asyncio + @pytest.mark.parametrize("status", [500, 502, 503, 504]) + async def test_a_server_status_is_not_ready_rather_than_wrong(self, status: int) -> None: + """A rebooting panel answers from its front end while the app behind it starts. + + Raised as `SpanPanelServerError` so a caller can tell "not yet" from + "no". The redispatch retry depends on this distinction: a live firmware + upgrade produced 502 here, the retry loop did not catch the general + `SpanPanelAPIError` it used to be, and the parser was never swapped. + """ + with patch("span_panel_api._http.httpx.AsyncClient") as cls: + cls.return_value = _mock_response("get", status) + with pytest.raises(SpanPanelServerError) as caught: + await get_homie_schema("192.168.1.1") + assert caught.value.status_code == status + + @pytest.mark.asyncio + @pytest.mark.parametrize("status", [401, 404]) + async def test_a_client_status_is_not_retryable(self, status: int) -> None: + """These do not fix themselves, so they must not look like "not ready yet".""" + with patch("span_panel_api._http.httpx.AsyncClient") as cls: + cls.return_value = _mock_response("get", status) + with pytest.raises(SpanPanelAPIError) as caught: + await get_homie_schema("192.168.1.1") + assert not isinstance(caught.value, SpanPanelServerError) + # --------------------------------------------------------------------------- # homie._parse_int failure path (lines 51-52) @@ -180,3 +221,55 @@ async def test_get_homie_schema_injected_skips_constructor(self) -> None: mock_cls.assert_not_called() injected.aclose.assert_not_called() + + +class TestGetHomieSchemaNotReadyShapes: + """Every way a booting panel answers that is not a clean 5xx. + + Each of these used to escape `get_homie_schema` untranslated, skip the + caller's retry clause entirely, and strand the parser — the same failure the + 502 produced on a live upgrade, wearing a different exception. + """ + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "failure", + [ + httpx.ReadError("connection reset"), + httpx.WriteError("broken pipe"), + httpx.RemoteProtocolError("server closed connection without sending a response"), + ], + ids=["read-reset", "write-reset", "proxy-closed-without-answering"], + ) + async def test_a_transport_failure_is_a_connection_error(self, failure: Exception) -> None: + """A panel resetting its listener mid-request, and a proxy dying mid-request. + + `httpx.TimeoutException` is itself a `TransportError`, so the timeout + branch has to stay ahead of this one — covered by the timeout test above. + """ + with patch("span_panel_api._http.httpx.AsyncClient") as cls: + cls.return_value = _mock_client("get", failure) + with pytest.raises(SpanPanelConnectionError): + await get_homie_schema("192.168.1.1") + + @pytest.mark.asyncio + @pytest.mark.parametrize("body", ["", "{trunc", "null", "[]"], ids=["empty", "truncated", "null", "list"]) + async def test_a_200_that_cannot_be_a_schema_is_not_ready_rather_than_broken(self, body: str) -> None: + """A panel part-way through starting can answer 200 with nothing usable. + + Retryable for the same reason a 502 is: it is "not ready yet" wearing a + success status. The bounded attempt count makes retrying a genuinely + broken body cheap. + """ + response = MagicMock() + response.status_code = 200 + response.json = MagicMock(side_effect=(lambda: json.loads(body)) if body else ValueError("no content")) + mock = AsyncMock() + mock.get = AsyncMock(return_value=response) + mock.__aenter__ = AsyncMock(return_value=mock) + mock.__aexit__ = AsyncMock(return_value=False) + + with patch("span_panel_api._http.httpx.AsyncClient") as cls: + cls.return_value = mock + with pytest.raises(SpanPanelServerError): + await get_homie_schema("192.168.1.1") diff --git a/tests/test_catalog_divergence.py b/tests/test_catalog_divergence.py new file mode 100644 index 0000000..e49577c --- /dev/null +++ b/tests/test_catalog_divergence.py @@ -0,0 +1,685 @@ +"""The registry used as a validator — what a panel *declares* against what the +catalogs *define*. + +`test_schema_one_conformance.py` asks whether every name this adapter reads is +one the specification carries. That is a question about vocabulary, and it is +answered by presence: a catalog exists, the property is in it, done. It never +opens the definition. + +This asks the next question, which is the one that corrupts readings when the +answer is wrong: **does the producer's declared `unit` and `datatype` for a +property agree with the catalog's?** Agreement is silence. Disagreement is a +finding, and is never resolved silently in either direction — the wire is not +"fixed" to match the catalog, and the catalog is not assumed to be right. The +one case in this repository's history was found by a person noticing that a +sibling device declared the same quantity differently; `meter/active-power` +labelled `kW` while the values were watts, a 1000x error that shipped. That is +what this makes mechanical. + +**Both producers are the subject.** The v1.0 side declares its capability nodes +on the wire, so the catalog for a property is whatever the node's `$type` names. +The flat schema document has no capability nodes at all — it predates them — so +its properties are joined to the catalogued vocabulary through the one thing the +two adapters already agree on: the snapshot field path each fills. The join is +required to agree on the property *name* as well, so a pre-catalog **rename** +(`dipole` for `breaker/poles`, `l1-voltage` for `meter/voltage-a`) is left out +rather than reported as a datatype divergence. A rename is a major-version event +under the eBus contract and is handled by having two adapters; it is not a +mislabel. + +**The register is not a suppression list.** An entry is a human saying "SPAN +ships this, we have looked at it, and we compensate" — with what the wire says, +what the catalog says, where it is observed, why, and when. It fails in both +directions like every other baseline here: a new divergence fails until somebody +records it, and a recorded divergence that has *disappeared* fails until its line +is removed. The second direction is what makes the register self-cleaning when a +firmware or a catalog is fixed. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Mapping, Sequence +import copy +from dataclasses import dataclass +import json +from pathlib import Path + +from span_panel_api import reference_payloads as flat_payloads +from span_panel_api_schema_0.field_metadata import _PROPERTY_FIELD_MAP as _FLAT_FIELD_MAP +from span_panel_api_schema_1 import reference_payloads as tree_payloads +from span_panel_api_schema_1.catalog import ( + CATALOGUED_CONCRETE_UNITS, + UNIT_FAMILIES, + Declaration, + Divergence, + Divergent, + capability_of, + compare, + declaration, + unclassified_units, + unit_agrees, +) +from span_panel_api_schema_1.const import NODE_METER +from span_panel_api_schema_1.description import nodes as declared_nodes, optional_str, properties as declared_properties +from span_panel_api_schema_1.field_metadata import ( + _DOWNSTREAM_LUGS_FIELDS, + _PROPERTY_FIELD_MAP as _ONE_FIELD_MAP, + _UPSTREAM_LUGS_FIELDS, +) + +_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" + + +# --------------------------------------------------------------------------- +# The acknowledged-divergence register +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Acknowledged: + """One divergence a human has read and decided to live with. + + `observed_in` is part of what is checked, not annotation. A divergence that + moves between producers — flat's mislabel being fixed while a v1.0 capture + starts showing it — is a different situation than the one that was recorded, + and an entry that went on covering it would be exactly the suppression this + register is not. + + `recorded` is the date the entry was written, so a line nobody has revisited + since the firmware it describes shipped is visible as such. + """ + + observed_in: tuple[str, ...] + reason: str + recorded: str + + +_REGISTER: dict[Divergence, Acknowledged] = { + Divergence("meter", "active-power", Divergent.UNIT, "kW", "W"): Acknowledged( + observed_in=(FLAT_SCHEMA,), + reason=( + "The flat schema document labels circuit active power `kW`; real panels publish watts, " + "and following the label reintroduces the 1000x error 1eef0dc removed after checking " + "against hardware. The consumer reads it as W deliberately -- " + "`test_circuit_active_power_unit_still_disagrees_with_the_schema` in " + "test_schema_provenance.py holds that side of it, against the schema. This line holds " + "the other side, against the catalog, which is what makes the disagreement a measured " + "fact about two producers rather than a comment in one test. v1.0 declares `W` and does " + "not carry the defect, which is why only the flat producer is observed here." + ), + recorded="2026-08-20", + ), + Divergence("info", "model", Divergent.DATATYPE, "enum", "string"): Acknowledged( + observed_in=(REFERENCE_TREE, SIMULATOR_TREE, SIMULATOR_WIRE), + 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 " + "only on an `enum`. The two halves of the catalog entry disagree, and SPAN followed the " + "description: the enclosure declares its model as an enum over the five load-centre " + "configurations (MAIN_16..MLO_48), and every other device class declares the plain " + "string. Nothing is compensated in code, because an enum payload is text either way and " + "`battery.model` / `pv.model` are read as text. Recorded rather than silenced because " + "the catalog is the side that should move: raise it upstream so `model` is typed the way " + "its description already describes." + ), + recorded="2026-08-20", + ), +} + + +# --------------------------------------------------------------------------- +# The catalogued reference +# --------------------------------------------------------------------------- + + +def _json_object(path: Path) -> dict[str, object]: + with path.open(encoding="utf-8") as handle: + loaded: object = json.load(handle) + assert isinstance(loaded, dict), f"{path} is not a JSON object" + return {str(key): value for key, value in loaded.items()} + + +def _objects(raw: object) -> dict[str, dict[str, object]]: + """The object-valued members of a JSON object, keyed by name.""" + if not isinstance(raw, dict): + return {} + return {str(key): value for key, value in raw.items() if isinstance(value, dict)} + + +def _catalogued() -> dict[str, dict[str, Declaration]]: + """Every vendored catalog, keyed by the capability it declares itself to be. + + By the `capability` field rather than the file name, because that is the + name a node's `$type` carries. The two agree today and the convention is + that they always will; keying on the one that is matched against removes the + convention from the load-bearing path. + """ + catalogued: dict[str, dict[str, Declaration]] = {} + for path in sorted(_CATALOGS.glob("*.json")): + document = _json_object(path) + capability = capability_of(optional_str(document.get("capability"))) + assert capability is not None, f"{path.name} declares no capability in the eBus namespace" + catalogued[capability] = { + property_id: declaration(definition) for property_id, definition in _objects(document.get("properties")).items() + } + return catalogued + + +# --------------------------------------------------------------------------- +# What the producers declare +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Declared: + """One property declaration, normalised across producers.""" + + capability: str + property_id: str + declaration: Declaration + + +def _from_description(description: dict[str, object]) -> Iterator[Declared]: + """Every property one `$description` declares on a capability node. + + Nodes whose `$type` is outside the eBus capability namespace are skipped — + there is nothing to look a catalog up by. `test_every_captured_node_names_a_capability` + pins that this never happens in the captures we hold, so the skip cannot + quietly shrink the surface being checked. + """ + for node in declared_nodes(description).values(): + capability = capability_of(optional_str(node.get("type"))) + if capability is None: + continue + for property_id, definition in declared_properties(node).items(): + yield Declared(capability, property_id, declaration(definition)) + + +def _untyped_nodes(descriptions: Sequence[dict[str, object]]) -> list[str]: + """Node ids whose `$type` names no eBus capability.""" + return [ + node_id + for description in descriptions + for node_id, node in declared_nodes(description).items() + if capability_of(optional_str(node.get("type"))) is None + ] + + +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 _wire_descriptions(tree: Mapping[str, Mapping[str, str]]) -> list[dict[str, object]]: + """The descriptions inside a retained-topic capture. + + `$description` is a JSON *string* on the wire, which is the shape the two + wire captures are vendored in — and the shape a live broker replay has, so + this reader is the one a diagnostic would reuse. + """ + descriptions: list[dict[str, object]] = [] + for topics in tree.values(): + raw = topics.get("$description") + if raw is None: + continue + parsed: object = json.loads(raw) + assert isinstance(parsed, dict), "a captured $description is not a JSON object" + descriptions.append({str(key): value for key, value in parsed.items()}) + 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 +# --------------------------------------------------------------------------- + + +def _catalogued_spellings() -> dict[str, set[tuple[str, str]]]: + """Snapshot field path -> the `(capability, property)` v1.0 fills it from. + + Derived from the v1.0 metadata table plus the two lugs tables, which is + every route the parser has to a field path. Restating it would let the join + go on describing a mapping the parser had moved. + """ + spellings: dict[str, set[tuple[str, str]]] = {} + rows = [(node, property_id, path) for _, node, property_id, path in _ONE_FIELD_MAP] + rows += [(NODE_METER, property_id, path) for property_id, path in _UPSTREAM_LUGS_FIELDS + _DOWNSTREAM_LUGS_FIELDS] + for node, property_id, path in rows: + spellings.setdefault(path, set()).add((node, property_id)) + return spellings + + +def _flat_declared() -> list[Declared]: + """The flat schema document's properties, under their catalogued capability. + + The flat document is a real producer — captured from a panel on + `spanos2/r202603/05` — and it is where the one mislabel this whole check + exists for actually lives. It cannot be read the way a v1.0 tree is: it + declares properties per *device type*, with no capability node to look a + catalog up by. + + So the capability comes from the snapshot field the two adapters agree the + property fills, and the join is admitted only when both sides spell the + property the same. That second condition is what keeps this honest. Fifteen + flat properties reach a catalogued property under a *different* name -- + `dipole` for `breaker/poles`, `software-version` for `info/firmware-version`, + `shed-priority` for `load-shed/priority` -- and every one of those is a + rename rather than a mislabel. Comparing across a rename would report + `dipole`'s `boolean` against `poles`'s `integer` as a divergence, when what + it really shows is that flat asks a yes/no question where v1.0 publishes a + count. + """ + spellings = _catalogued_spellings() + declared: list[Declared] = [] + for device_type, properties in flat_payloads.homie_schema_types().items(): + for property_id, definition in _objects(properties).items(): + for path in (p for kind, name, p in _FLAT_FIELD_MAP if kind == device_type and name == property_id): + for capability, catalogued_name in sorted(spellings.get(path, set())): + if catalogued_name == property_id: + declared.append(Declared(capability, property_id, declaration(definition))) + return declared + + +# --------------------------------------------------------------------------- +# The survey +# --------------------------------------------------------------------------- + + +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) + ], + FLAT_SCHEMA: _flat_declared(), + } + + +def _findings(surface: Mapping[str, Sequence[Declared]]) -> dict[Divergence, frozenset[str]]: + """Every divergence in a surface, with the producers that show it. + + Producer-independent identity: one mislabel published by a panel and + captured three ways is one finding. Which captures show it is the value, so + a register entry can be checked against it without being written three + times. + """ + catalogued = _catalogued() + found: dict[Divergence, set[str]] = {} + for producer, declarations in surface.items(): + for entry in declarations: + definition = catalogued.get(entry.capability, {}).get(entry.property_id) + for divergence in compare(entry.capability, entry.property_id, entry.declaration, definition): + found.setdefault(divergence, set()).add(producer) + return {divergence: frozenset(producers) for divergence, producers in found.items()} + + +def _divergences(surface: Mapping[str, Sequence[Declared]]) -> dict[Divergence, frozenset[str]]: + """Findings that are a disagreement about a definition, not an absence.""" + return { + divergence: producers + for divergence, producers in _findings(surface).items() + if divergence.kind is not Divergent.UNCATALOGUED + } + + +def _report(divergence: Divergence, producers: frozenset[str]) -> str: + return f"{divergence} [{', '.join(sorted(producers))}]" + + +# --------------------------------------------------------------------------- +# The register fails in both directions +# --------------------------------------------------------------------------- + + +def test_every_divergence_is_acknowledged() -> None: + """A producer declaring something the catalog contradicts stops the build. + + The direction that catches the next `kW`. What it wants is not a fix — the + right answer is often that the producer is right and the catalog is stale — + but a human decision, written down, with a date on it. + """ + surveyed = _divergences(_surface()) + unrecorded = sorted( + (_report(divergence, producers) for divergence, producers in surveyed.items() if divergence not in _REGISTER) + ) + + assert not unrecorded, ( + "declared definitions that disagree with the vendored catalogs:\n " + + "\n ".join(unrecorded) + + "\n\nDecide which side is wrong — the catalog is not automatically right — and record the " + "outcome in _REGISTER with a reason and a date. Do not change the wire reader to agree with " + "the catalog, or the catalog copy to agree with the wire." + ) + + +def test_every_acknowledgement_still_describes_a_real_divergence() -> None: + """The self-cleaning direction. + + When a firmware or a catalog is fixed, the entry describing the old + disagreement becomes a false statement about the producer — and a silent + one, because everything still passes. This turns it into a prompt to delete + the line, which is the only thing that keeps the register from becoming the + suppression list it must not be. + """ + surveyed = _divergences(_surface()) + stale = sorted( + f"{divergence} — recorded {entry.recorded}" for divergence, entry in _REGISTER.items() if divergence not in surveyed + ) + + assert not stale, ( + "recorded as acknowledged divergences but no producer declares them any more:\n " + + "\n ".join(stale) + + "\n\nThe disagreement is over. Delete the entry; its failing is good news." + ) + + +def test_every_acknowledgement_names_the_producers_that_still_show_it() -> None: + """Where a divergence lives is checked, not annotated. + + A mislabel fixed in one producer and appearing in another is a new + situation, not the one somebody signed off. Without this the entry would go + on covering it under a reason that had stopped being true. + """ + surveyed = _divergences(_surface()) + moved = sorted( + f"{divergence}: recorded in {sorted(entry.observed_in)}, observed in {sorted(surveyed[divergence])}" + for divergence, entry in _REGISTER.items() + if divergence in surveyed and frozenset(entry.observed_in) != surveyed[divergence] + ) + + assert not moved, ( + "acknowledged divergences no longer observed where they were recorded:\n " + + "\n ".join(moved) + + "\n\nRe-read the entry's reason before updating `observed_in` — a divergence changing " + "producers usually means the reason is out of date too." + ) + + +def test_every_acknowledgement_justifies_itself() -> None: + """A register line is a human's claim, and a claim needs its working. + + Cheap to assert and worth asserting, because the failure mode of a register + is a line added under deadline with `reason="known issue"`, which is a + suppression with extra syntax. + """ + thin = sorted(str(divergence) for divergence, entry in _REGISTER.items() if len(entry.reason) < 120) + assert not thin, f"acknowledgements with no real reason recorded: {thin}" + + undated = sorted(str(divergence) for divergence, entry in _REGISTER.items() if not entry.recorded.count("-") == 2) + 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} + ) + assert not misfiled, f"acknowledgements naming a producer this check does not survey: {misfiled}" + + +# --------------------------------------------------------------------------- +# An absence is an absence, and is reported once +# --------------------------------------------------------------------------- + + +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 + 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. + + So an absence is terminal: reported once, as an absence, and never again as + a disagreement about a definition. + """ + findings = _findings(_surface()) + absent = {(d.capability, d.property_id) for d in findings if d.kind is Divergent.UNCATALOGUED} + mismatched = {(d.capability, d.property_id) for d in findings if d.kind is not Divergent.UNCATALOGUED} + + assert ("config", "max-charge-current") in absent, "the EVSE config node is no longer reported as uncatalogued" + assert ("config", "user-max-charge-current") in absent, "the EVSE config node is no longer reported as uncatalogued" + + both = sorted(absent & mismatched) + assert not both, f"reported as both absent from the catalog and disagreeing with it: {both}" + + for property_id in ("max-charge-current", "user-max-charge-current"): + reported = [d for d in findings if (d.capability, d.property_id) == ("config", property_id)] + assert len(reported) == 1, f"config/{property_id} reported {len(reported)} times: {[str(d) for d in reported]}" + + +# --------------------------------------------------------------------------- +# The rule that keeps an abstract unit from producing a false finding +# --------------------------------------------------------------------------- + + +def test_an_abstract_family_unit_is_satisfied_by_a_member_of_the_family() -> None: + """`unit: "energy"` is an instruction to substitute, not a unit to match. + + The catalog says `soc/soe` and `info/nameplate-capacity` are `energy`; the + BESS in every capture publishes `kWh`, which is the substitution the + specification asks for. A string compare would report conformance as the + defect — and it would do so on four of the sixty-odd properties this check + compares, which is enough noise to get the whole check turned off. + + Membership is what is satisfied, and only membership: echoing the token back + is not a substitution, and an energy unit nobody enumerated is a question for + a human rather than a pass. + """ + assert unit_agrees("kWh", "energy"), "the substitution the specification asks for must be silent" + assert unit_agrees("Wh", "energy"), "a water heater's thermal Wh is the same substitution" + assert not unit_agrees("energy", "energy"), "echoing the placeholder is not substituting a unit" + assert not unit_agrees("W", "energy"), "a power unit does not satisfy an energy dimension" + assert not unit_agrees(None, "energy"), "declaring no unit at all does not satisfy it either" + + assert unit_agrees("W", "W"), "a concrete unit is an exact match" + assert not unit_agrees("kW", "W"), "the mislabel this whole check exists for must not be excused" + assert unit_agrees(None, None), "a property neither side gives a unit is silent" + assert not unit_agrees("%", None), "a unit where the catalog carries none is a disagreement" + + +def test_a_node_outside_the_capability_namespace_resolves_to_no_capability() -> None: + """What a node's `$type` has to be before a catalog can be looked up for it. + + The namespace is the whole check on a name that arrives from a publisher: a + device type, a vendor extension or an empty string names no capability, and + `capability_of` says so rather than producing a bare word that would then + miss every catalog and be reported as an absence. The two are different + situations, and only one of them is a fact about the specification. + """ + assert capability_of("energy.ebus.capability.meter") == "meter" + assert capability_of("energy.ebus.capability.config") == "config", "an uncatalogued capability is still a capability" + assert capability_of("energy.ebus.device.circuit") is None, "a device type is not a capability" + assert capability_of("meter") is None, "a bare node id makes no claim about the namespace" + assert capability_of("energy.ebus.capability.") is None, "an empty suffix names nothing" + assert capability_of(None) is None + + +def test_a_finding_reads_as_the_sentence_a_human_has_to_act_on() -> None: + """The report line is the whole interface of this check. + + Everything above produces one of these two sentences, and a person reading a + failed build has nothing else to go on — so the two kinds have to be + distinguishable at a glance, and an absence must not be dressed up as a + disagreement with values it does not have. + """ + mismatch = Divergence("meter", "active-power", Divergent.UNIT, "kW", "W") + assert str(mismatch) == "meter/active-power: declared unit 'kW', catalog says 'W'" + + absent = Divergence("config", "max-charge-current", Divergent.UNCATALOGUED, None, None) + assert str(absent) == "config/max-charge-current: no catalog defines it" + + +def test_every_catalogued_unit_token_is_classified() -> None: + """The guard on the family rule. + + A unit token arriving in a vendored catalog that is neither a concrete unit + nor an enumerated dimension would be string-compared against whatever a + publisher substitutes, and report an entire new family as broken. That is + the false finding this module was written to avoid, so a new token has to be + classified by a human before it is compared against anything. + """ + catalogued = frozenset( + definition.unit for properties in _catalogued().values() for definition in properties.values() if definition.unit + ) + unclassified = sorted(unclassified_units(catalogued)) + + assert not unclassified, ( + f"unit tokens in the vendored catalogs that are neither concrete nor an enumerated family: {unclassified}. " + "Decide which, and add it to CATALOGUED_CONCRETE_UNITS or UNIT_FAMILIES in catalog.py." + ) + + assert "energy" in UNIT_FAMILIES, "the one abstract family this repository has met" + retired = sorted(CATALOGUED_CONCRETE_UNITS - catalogued) + assert not retired, ( + f"units pinned as catalogued but no catalog uses them any more: {retired}. " + "Drop them, so this set keeps describing the vendored vocabulary rather than a past one." + ) + + +# --------------------------------------------------------------------------- +# The check is actually looking at something +# --------------------------------------------------------------------------- + + +def test_every_producer_contributes_a_compared_surface() -> None: + """A survey that silently reads nothing passes every assertion above. + + The way this check dies is not a wrong answer, it is a reader that stops + finding declarations — a capture reshaped, a metadata table moved — after + which the register is a list of comments and the build is green. So the + surface is measured, and the four anchors that make the comparison worth + running are named. + """ + catalogued = _catalogued() + surface = _surface() + + for producer, declarations in surface.items(): + assert declarations, f"{producer} contributed no declarations at all" + + compared = { + (entry.capability, entry.property_id) + for declarations in surface.values() + for entry in declarations + if entry.property_id in catalogued.get(entry.capability, {}) + } + + for anchor in (("meter", "active-power"), ("soc", "soe"), ("info", "model"), ("breaker", "rating")): + assert anchor in compared, f"{anchor[0]}/{anchor[1]} is no longer being compared against its catalog" + + assert len(compared) >= 55, f"only {len(compared)} catalogued properties are being compared; the readers have narrowed" + + +def test_the_flat_join_still_reaches_the_known_mislabel() -> None: + """The flat producer is joined through two metadata tables, and both move. + + If either table drops the row that carries `circuit.instant_power_w`, the + join goes quiet and the `kW` mislabel stops being compared — with the + register entry still sitting there, describing a divergence nothing looks + for any more. `test_every_acknowledgement_still_describes_a_real_divergence` + would catch that as a stale entry, but it would read as good news rather + than as a broken join, so the join is asserted on its own. + """ + joined = {(entry.capability, entry.property_id) for entry in _flat_declared()} + assert ("meter", "active-power") in joined, "the flat schema's circuit active-power no longer reaches the meter catalog" + + respellings = {("breaker", "poles"), ("meter", "voltage-a"), ("info", "firmware-version"), ("load-shed", "priority")} + assert not (joined & respellings), ( + "the flat join now compares across a rename. A pre-catalog spelling of a catalogued property " + "is an adapter concern, not a mislabel, and comparing across it invents divergences." + ) + + +def test_every_captured_node_names_a_capability() -> None: + """`_from_description` skips a node whose `$type` names no capability. + + Nothing in a capture we hold does that, and pinning it here is what keeps + 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))) + + assert not untyped, f"captured nodes declaring no eBus capability type: {untyped}" + + +# --------------------------------------------------------------------------- +# Proof that it bites +# --------------------------------------------------------------------------- + + +def test_a_relabelled_unit_in_a_capture_is_reported() -> None: + """Mutation proof, on the exact shape of the defect this exists to catch. + + A capture is copied, one circuit's `meter/active-power` is relabelled `kW` + the way the flat schema has it, and the survey is re-run over the copy. The + real captures are untouched — the point is that the reader, not a fixture, + is what notices. + """ + mutated = copy.deepcopy(_json_object(_SIMULATOR_TREE)) + relabelled = 0 + for device in _objects(mutated).values(): + 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": + definition["unit"] = "kW" + 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)]} + 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 mislabel in _REGISTER, "the register happens to carry this one, from the flat schema" + assert _REGISTER[mislabel].observed_in == (FLAT_SCHEMA,), ( + "which is why the same divergence arriving from a v1.0 capture fails " + "test_every_acknowledgement_names_the_producers_that_still_show_it rather than passing quietly" + ) + + +def test_a_relabelled_datatype_in_a_capture_is_reported() -> None: + """The other field, mutated the same way. + + `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)) + relabelled = 0 + for device in _objects(mutated).values(): + breaker = declared_nodes(device).get("breaker", {}) + for property_id, definition in declared_properties(breaker).items(): + if property_id == "rating": + definition["datatype"] = "string" + 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)]} + reported = _divergences(surface) + + assert ( + Divergence("breaker", "rating", Divergent.DATATYPE, "string", "integer") in reported + ), f"a relabelled datatype was not reported; found {sorted(str(d) for d in reported)}" diff --git a/tests/test_detection_auth.py b/tests/test_detection_auth.py index 8131bb5..91c5e8d 100644 --- a/tests/test_detection_auth.py +++ b/tests/test_detection_auth.py @@ -29,7 +29,6 @@ register_v2, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -428,6 +427,54 @@ async def test_parse_schema(self): assert "energy.ebus.device.distribution-enclosure.core" in result.types core_type = result.types["energy.ebus.device.distribution-enclosure.core"] assert "door" in core_type + # Real flat firmware omits dataModelVersion entirely, and that absence + # is what routes the panel to the flat parser. + assert result.data_model_version is None + + @pytest.mark.asyncio + async def test_parent_child_response_carries_its_data_model_version(self): + """The signal dispatch runs on, read over REST before MQTT is opened. + + A parent/child payload keeps its type definitions under `deviceClasses`, + so `types` comes back empty here — harmless precisely because this + version routes the panel away from the parser that would have read it. + """ + schema_json = { + "firmwareVersion": "spanos2/r202633/01", + "dataModelVersion": "1.0", + "homieDomain": "ebus", + "homieVersion": 5, + "deviceClasses": {"energy.ebus.device.panel": {}}, + } + mock_response = _mock_response(200, schema_json) + with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await get_homie_schema("192.168.65.70") + + assert result.data_model_version == "1.0" + assert result.types == {} + + @pytest.mark.asyncio + async def test_a_non_string_version_is_still_read_not_discarded(self): + """JSON may carry the version unquoted. Coercing beats treating a + present value as absent, which would silently mean "flat".""" + schema_json = {"firmwareVersion": "spanos2/r202633/01", "dataModelVersion": 1.0, "types": {}} + mock_response = _mock_response(200, schema_json) + with patch("span_panel_api._http.httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client_cls.return_value = mock_client + + result = await get_homie_schema("192.168.65.70") + + assert result.data_model_version == "1.0" @pytest.mark.asyncio async def test_schema_frozen(self): @@ -477,15 +524,12 @@ def test_panel_size_bad_format_raises(self): def test_panel_size_from_live_fixture(self): """panel_size works with the real panel schema fixture.""" - import json - from pathlib import Path + from span_panel_api.reference_payloads import homie_schema, homie_schema_types - fixture = Path(__file__).parent / "fixtures" / "v2" / "homie_schema.json" - data = json.loads(fixture.read_text()) schema = V2HomieSchema( - firmware_version=data["firmwareVersion"], + firmware_version=homie_schema()["firmwareVersion"], types_schema_hash="sha256:test", - types=data["types"], + types=homie_schema_types(), ) assert schema.panel_size == 32 diff --git a/tests/test_factory_dispatch.py b/tests/test_factory_dispatch.py new file mode 100644 index 0000000..4b51fb5 --- /dev/null +++ b/tests/test_factory_dispatch.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from span_panel_api_schema_0 import SchemaZeroAdapter +from span_panel_api_schema_1 import SchemaOneAdapter +from span_panel_api.adapters import _reset_adapter_cache +from span_panel_api.exceptions import SpanPanelAdapterMissingError, SpanPanelSchemaVersionError +from span_panel_api.dispatch import select_adapter_key +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.models import MqttClientConfig + +from conftest import MINIMAL_DESCRIPTION, SERIAL, TOPIC_PREFIX_SERIAL, flat_schema, parent_child_schema + + +def test_absent_data_model_version_selects_schema_zero() -> None: + key, reason = select_adapter_key(None) + assert key == "schema_0" + assert "absent" in reason + + +@pytest.mark.parametrize("dmv", ["1.0", "1.4", "2.0", "1.0.3", "10.2"]) +def test_present_data_model_version_requests_a_numbered_adapter(dmv: str) -> None: + key, reason = select_adapter_key(dmv) + assert key == f"schema_{dmv.split('.')[0]}" + assert dmv in reason + + +@pytest.mark.parametrize("dmv", ["1", "1.0-beta", "1.0.3-rc2", "2_0"]) +def test_non_canonical_but_unambiguous_versions_dispatch_on_their_major(dmv: str) -> None: + """The major was read, not assumed, so dispatching on it is not a guess. + + Refusing these would take a panel offline over a formatting difference; the + deviation is logged instead so a new firmware format is visible early. + """ + key, reason = select_adapter_key(dmv) + assert key == f"schema_{dmv[0]}" + assert "non-canonical" in reason + + +@pytest.mark.parametrize("dmv", ["", "v1.0", "unknown", "beta", "-1", " 1.0"]) +def test_unreadable_data_model_version_raises_instead_of_assuming_flat(dmv: str) -> None: + """The regression this guards: a present-but-unreadable version must never + reach the flat parser. + + Falling back to schema_0 does not fail — it silently produces plausible but + wrong power and energy values in Home Assistant, which is strictly worse + than an error the user can see and report. + """ + with pytest.raises(SpanPanelSchemaVersionError) as exc: + select_adapter_key(dmv) + + assert exc.value.data_model_version == dmv + + +def test_absence_is_still_a_supported_signal_not_an_error() -> None: + """The flat schema predates the property, so absence must stay non-fatal — + it is the single most common case in the field today.""" + key, _ = select_adapter_key(None) + assert key == "schema_0" + + +def test_the_flat_key_is_the_one_the_transport_resolves() -> None: + """Dispatch and the transport's default path must name the same adapter. + + They are the two callers of resolve_adapter, and a divergence between them + is invisible in a dev workspace where every adapter is installed: it only + appears as an unresolvable key in a real install. + """ + from span_panel_api.adapters import DEFAULT_ADAPTER_KEY + + key, _ = select_adapter_key(None) + assert key == DEFAULT_ADAPTER_KEY + + +def test_missing_adapter_raises_with_the_installed_list() -> None: + """A panel whose schema outruns the install. + + Asks for a major nothing provides rather than `schema_1`, which this + workspace now installs. The assertion is about the shape of the failure — + named, with the installed set — not about which adapters happen to be + absent today. + """ + from span_panel_api.adapters import resolve_adapter + + _reset_adapter_cache() + with pytest.raises(SpanPanelAdapterMissingError) as exc: + resolve_adapter("schema_2", "data-model-version='2.0'") + + assert exc.value.needed == "schema_2" + assert "schema_0" in exc.value.available + assert "schema_1" in exc.value.available + + +# --------------------------------------------------------------------------- +# create_span_client — wiring the selected adapter class into SpanMqttClient +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_span_client_wires_schema_zero_adapter_and_diagnostics() -> None: + """The factory must pass the resolved adapter *class* as adapter_factory, + and assign the dispatch diagnostics onto the constructed client before + connect() runs.""" + from span_panel_api.factory import create_span_client + + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + + schema = flat_schema(32) + with ( + patch("span_panel_api.factory.SpanMqttClient") as mock_cls, + patch("span_panel_api.factory.get_homie_schema", return_value=schema) as mock_fetch, + ): + mock_client = mock_cls.return_value + mock_client.connect = AsyncMock() + + result = await create_span_client( + "192.168.1.1", + mqtt_config=config, + serial_number="test-serial", + ) + + # Dispatch happens before the client exists, so the schema is fetched by the + # factory rather than by connect(). That ordering is the whole fix: the + # adapter cannot be chosen from a value that has not been read yet. + mock_fetch.assert_awaited_once() + + assert result is mock_client + _, kwargs = mock_cls.call_args + assert kwargs["adapter_factory"] is SchemaZeroAdapter + # The fetched schema is handed to the client so connect() does not + # re-request the same unauthenticated endpoint for a value that cannot + # have changed between the two calls. + assert kwargs["schema"] is schema + mock_client.connect.assert_awaited_once() + # Diagnostics travel through the constructor, so they are true before + # connect() rather than patched onto private state afterwards. There is no + # longer a window where a connected client reports a selected adapter next + # to schema_dispatch_reason='not dispatched'. + assert kwargs["data_model_version"] is None + assert "absent" in kwargs["schema_dispatch_reason"] + + +# --------------------------------------------------------------------------- +# SpanMqttClient diagnostics properties — before and after connect() +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_diagnostics_properties_before_and_after_connect(mqtt_client_mock: MagicMock) -> None: + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + + # Before connect(): no adapter yet. Defaults describe a client built + # directly, bypassing create_span_client. + assert client.adapter is None + assert client.schema_major is None + assert client.data_model_version is None + assert client.schema_dispatch_reason == "not dispatched" + assert "schema_0" in client.installed_adapters + + # Simulate what create_span_client does after adapter selection, ahead of connect(). + client._data_model_version = None # pylint: disable=protected-access + client._schema_dispatch_reason = "data-model-version absent (flat schema)" # pylint: disable=protected-access + + connect_task = asyncio.create_task(client.connect()) + await asyncio.sleep(0.05) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + await asyncio.wait_for(connect_task, timeout=5.0) + + assert isinstance(client.adapter, SchemaZeroAdapter) + assert client.schema_major == "schema_0" + assert client.data_model_version is None + assert client.schema_dispatch_reason == "data-model-version absent (flat schema)" + + await client.close() + + +# --------------------------------------------------------------------------- +# Live dispatch — the version is now read, not assumed +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_a_parent_child_panel_gets_the_parent_child_parser() -> None: + """The bug Part A closed, now that the parser it asks for exists. + + Before, `create_span_client` hardcoded `data_model_version = None`, so a + panel reporting `1.0` was handed to the flat parser regardless of what it + said. What that cost: the flat parser reaches for + `energy.ebus.device.circuit/space`, which a parent/child payload keeps + under `deviceClasses`, and the run dies on + + ValueError: Schema missing 'energy.ebus.device.circuit/space' property + + — a message about a missing property, for a panel whose real problem was + that nothing installed could parse it. Until `schema_1` registered, such a + panel was refused by name; now the name resolves, and this pins that it + resolves to the parent/child parser rather than quietly to the flat one. + """ + from span_panel_api.factory import create_span_client + + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + schema = parent_child_schema() + with ( + patch("span_panel_api.factory.SpanMqttClient") as mock_cls, + patch("span_panel_api.factory.get_homie_schema", return_value=schema), + ): + mock_cls.return_value.connect = AsyncMock() + await create_span_client("192.168.1.1", mqtt_config=config, serial_number="test-serial") + + _, kwargs = mock_cls.call_args + assert kwargs["adapter_factory"] is SchemaOneAdapter + assert kwargs["data_model_version"] == "1.0" + assert "1.0" in kwargs["schema_dispatch_reason"] + + +@pytest.mark.asyncio +async def test_a_panel_newer_than_the_install_is_refused_rather_than_parsed_as_flat() -> None: + """The other half of the same guarantee. + + A schema major nothing provides must be refused by name, not fall back to + whichever parser happens to be installed — which is the failure the flat + default used to produce, one major later. + """ + from span_panel_api.factory import create_span_client + + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + with ( + patch("span_panel_api.factory.get_homie_schema", return_value=parent_child_schema("2.0")), + pytest.raises(SpanPanelAdapterMissingError) as exc, + ): + await create_span_client("192.168.1.1", mqtt_config=config, serial_number="test-serial") + + assert exc.value.needed == "schema_2" + assert "schema_1" in exc.value.available + + +@pytest.mark.asyncio +async def test_a_directly_constructed_client_dispatches_too() -> None: + """Building a client directly must not bypass dispatch. + + `create_span_client` is not the only way to get a client — the README + documents direct construction, and the integration uses it. Before, that + path always resolved the flat adapter, so it carried exactly the bug the + factory path just had fixed. Dispatch now happens wherever a parser is + built, which is what makes handing this client a 1.x schema produce a + parent/child parser rather than a flat one. + """ + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + client = SpanMqttClient("192.168.1.1", SERIAL, config) + + client._build_adapter(parent_child_schema()) + + assert isinstance(client.adapter, SchemaOneAdapter) + assert client.schema_major == "schema_1" + assert client.data_model_version == "1.0" + + +def test_dispatch_records_what_it_read_on_the_client() -> None: + """Diagnostics for a directly-constructed client are filled in by dispatch + rather than left saying 'not dispatched' after a parser exists.""" + _reset_adapter_cache() + config = MqttClientConfig(broker_host="broker.local", username="user", password="pass") + client = SpanMqttClient("192.168.1.1", SERIAL, config) + + assert client.schema_dispatch_reason == "not dispatched" + + client._build_adapter(flat_schema(32)) + + assert client.data_model_version is None + assert "absent" in client.schema_dispatch_reason + assert client.schema_major == "schema_0" + + +def test_an_unrecognised_enum_value_is_passed_through_not_raised() -> None: + """The mirror image of the version rule, and deliberately so. + + v1.0 requires consumers not to raise on an unrecognised value in a + `$format`-extended enum: SPAN may add enum members without a major bump, so + raising would take a panel offline over a value the spec allows. Dispatch + takes the opposite line on `data-model-version` because the blast radius + differs — an unknown enum member affects one property, while an unknown + schema version means every value in the tree may be misread. + + Pinned here because both rules live one import apart, and schema_1 inherits + this one. + """ + from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator + from span_panel_api_schema_0.consumer import HomieDeviceConsumer + + accumulator = HomiePropertyAccumulator(SERIAL) + consumer = HomieDeviceConsumer(accumulator, panel_size=32) + + consumer.handle_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + consumer.handle_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + # A shed-priority value no released firmware emits today. + consumer.handle_message(f"{TOPIC_PREFIX_SERIAL}/core/shed-priority", "SOME_FUTURE_PRIORITY") + + snapshot = consumer.build_snapshot() + assert snapshot is not None diff --git a/tests/test_field_metadata.py b/tests/test_field_metadata.py index 72dcb40..f9fce2b 100644 --- a/tests/test_field_metadata.py +++ b/tests/test_field_metadata.py @@ -5,7 +5,8 @@ import logging from span_panel_api.models import FieldMetadata -from span_panel_api.mqtt.field_metadata import build_field_metadata, log_schema_drift +from span_panel_api_schema_0.field_metadata import build_field_metadata +from span_panel_api.schema_drift import log_schema_drift def _make_schema_types() -> dict[str, dict[str, object]]: @@ -278,3 +279,448 @@ def test_non_dict_props_skipped(self, caplog: logging.LogCaptureFixture) -> None with caplog.at_level(logging.DEBUG): log_schema_drift(previous, current) assert "Schema drift" not in caplog.text + + +# --------------------------------------------------------------------------- +# Resolved vs. absent — the three-way contract +# +# A field path missing from the metadata used to be ambiguous: it could mean +# "this panel has no such hardware" or "the mapping dropped the property". +# `FieldMetadata.resolved` splits the two so consumers stop reconstructing the +# difference from telemetry. +# --------------------------------------------------------------------------- + + +def _device( + device_id: str, + type_: str, + nodes: dict[str, object], + values: dict[str, dict[str, str]] | None = None, +) -> object: + """Minimal stand-in for ebus_sdk.DiscoveredDevice. + + `build_field_metadata` reads declarations via `.description`, but the + downstream-lugs path resolves the device by its published `info/direction` + *value*, which is a different thing from declaring the property. `values` + supplies those readings, keyed node → property; anything unlisted reads as + unpublished. + """ + + class _D: + def __init__(self) -> None: + self.id = device_id + self.description = {"type": type_, "nodes": nodes} + + def get_property(self, node: str, prop: str) -> str | None: + return (values or {}).get(node, {}).get(prop) + + return _D() + + +_FULL_LUGS_METER: dict[str, object] = { + "properties": { + "active-power": {"datatype": "float", "unit": "W"}, + "imported-energy": {"datatype": "float", "unit": "Wh"}, + "exported-energy": {"datatype": "float", "unit": "Wh"}, + "current-a": {"datatype": "float", "unit": "A"}, + "current-b": {"datatype": "float", "unit": "A"}, + } +} + + +def _lugs(device_id: str, direction: str, meter: dict[str, object] | None) -> object: + """A lugs device that publishes its direction, with `meter` as given. + + `meter=None` means the device declares no meter node at all — which is a + different claim from declaring one that lists nothing. + """ + nodes: dict[str, object] = {"info": {"properties": {"direction": {"datatype": "string"}}}} + if meter is not None: + nodes["meter"] = meter + return _device(device_id, "energy.ebus.device.lugs", nodes, values={"info": {"direction": direction}}) + + +def test_present_device_missing_property_is_unresolved() -> None: + """A circuit device that declares no `meter` power property is a real gap, + not absent hardware — the integration must be able to tell them apart.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + circuit = _device( + device_id="c1", + type_="energy.ebus.device.circuit", + nodes={"info": {"properties": {"name": {"datatype": "string"}}}, "meter": {"properties": {}}}, + ) + metadata = build_schema_one([circuit]) + + entry = metadata["circuit.instant_power_w"] + assert entry.resolved is False + assert entry.unit is None + + +def test_a_node_declaring_no_properties_at_all_is_still_present() -> None: + """The boundary the presence rule has to get right in both directions. + + A `meter` node with an empty property set is the strongest form of the gap + — the node is there and declares nothing — while a circuit with no `meter` + node at all is a circuit that does not meter. Deriving presence from the + declared-property map would collapse the two, since neither contributes a + property to read back. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + unmetered = _device( + device_id="c1", + type_="energy.ebus.device.circuit", + nodes={"info": {"properties": {"name": {"datatype": "string"}}}}, + ) + + assert "circuit.instant_power_w" not in build_schema_one([unmetered]) + + +def test_absent_device_type_yields_no_entry() -> None: + """No BESS device means no battery entry at all — not an unresolved one.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + circuit = _device( + device_id="c1", + type_="energy.ebus.device.circuit", + nodes={"info": {"properties": {"name": {"datatype": "string"}}}}, + ) + metadata = build_schema_one([circuit]) + + assert "battery.soe_percentage" not in metadata + + +def test_absent_node_on_present_device_yields_no_entry() -> None: + """The panel device is always present, but a panel with no power-flows node + has no power-flow hardware — that must not read as degradation.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + panel = _device( + device_id="p1", + type_="energy.ebus.device.distribution-enclosure", + nodes={"info": {"properties": {"serial-number": {"datatype": "string"}}}}, + ) + metadata = build_schema_one([panel]) + + assert "panel.power_flow_pv" not in metadata + + +def test_present_node_missing_property_on_a_subtyped_device_is_unresolved() -> None: + """The subtype rule, which survived the move to a direction-resolved lookup. + + Firmware may declare `…device.lugs.upstream` where the code says + `…device.lugs`. That used to be `_lookup`'s prefix fallback; the lugs paths + no longer go through the table, so the rule now lives in the + `startswith(TYPE_LUGS)` filter that feeds `find_lugs`. Either way an + exact-match test would read a dropped property on typed-lugs firmware as + absent hardware, which is the misclassification this field exists to + prevent — so the expectation is unchanged and only its mechanism moved. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + lugs = _device( + device_id="lugs-upstream", + type_="energy.ebus.device.lugs.upstream", + nodes={ + "info": {"properties": {"direction": {"datatype": "string"}}}, + "meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}, + }, + values={"info": {"direction": "UPSTREAM"}}, + ) + metadata = build_schema_one([lugs]) + + assert metadata["panel.instant_grid_power_w"] == FieldMetadata(unit="W", datatype="float") + assert metadata["panel.upstream_l1_current_a"].resolved is False + + +def test_the_subtype_rule_holds_for_both_lugs_directions() -> None: + """Both halves, since each resolves its own device through the filter.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + def _typed(device_id: str, type_: str, direction: str) -> object: + return _device( + device_id, + type_, + { + "info": {"properties": {"direction": {"datatype": "string"}}}, + "meter": {"properties": {"current-a": {"datatype": "float", "unit": "A"}}}, + }, + values={"info": {"direction": direction}}, + ) + + metadata = build_schema_one( + [ + _typed("u", "energy.ebus.device.lugs.upstream", "UPSTREAM"), + _typed("d", "energy.ebus.device.lugs.downstream", "DOWNSTREAM"), + ] + ) + + assert metadata["panel.upstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + assert metadata["panel.downstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + # And the drop classification still reaches subtyped devices. + assert metadata["panel.instant_grid_power_w"].resolved is False + assert metadata["panel.feedthrough_power_w"].resolved is False + + +def test_resolved_defaults_true() -> None: + """Existing construction sites keep working unchanged.""" + assert FieldMetadata(unit="W", datatype="float").resolved is True + + +def test_downstream_lugs_missing_properties_are_unresolved_not_absent() -> None: + """The downstream lugs answer to the same contract as everything else. + + These five paths bypass `_PROPERTY_FIELD_MAP` — both lugs devices share + type, node and properties, so the table can only address one of them — and + resolve through a direction-matched lookup instead. That second path had + kept the pre-change `continue`, so a downstream device that was plainly in + the tree, and already resolving `feedthrough_power_w` from the very same + `meter` node, reported its dropped properties as absent hardware. + + The asymmetry is the sharper half of the defect: in this one tree the + upstream paths report a dropped property as `resolved=False` while the + downstream paths reported nothing, so a consumer applying one rule to + `panel.*` lugs fields got different semantics by direction. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + upstream = _lugs("lugs-upstream", "UPSTREAM", _FULL_LUGS_METER) + downstream = _lugs( + "lugs-downstream", + "DOWNSTREAM", + { + "properties": { + "active-power": {"datatype": "float", "unit": "W"}, + "exported-energy": {"datatype": "float", "unit": "Wh"}, + } + }, + ) + metadata = build_schema_one([upstream, downstream]) + + # Present on both devices: unchanged, and still carrying real units. + assert metadata["panel.instant_grid_power_w"] == FieldMetadata(unit="W", datatype="float") + assert metadata["panel.upstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + assert metadata["panel.feedthrough_power_w"] == FieldMetadata(unit="W", datatype="float") + assert metadata["panel.feedthrough_energy_produced_wh"] == FieldMetadata(unit="Wh", datatype="float") + + # Dropped by a device that is present and declares the node: degradation. + for degraded in ( + "panel.feedthrough_energy_consumed_wh", + "panel.downstream_l1_current_a", + "panel.downstream_l2_current_a", + ): + assert degraded in metadata, f"{degraded} read as absent hardware for a device in the tree" + assert metadata[degraded] == FieldMetadata(unit=None, datatype="unknown", resolved=False) + + +def test_the_two_lugs_directions_classify_the_same_drop_the_same_way() -> None: + """Stated as an equality rather than two separate expectations. + + The two halves reach their metadata through different code — the table for + upstream, a direction-matched lookup for downstream — so nothing structural + keeps them agreeing. This fails if either side drifts. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + bare_meter: dict[str, object] = {"properties": {}} + metadata = build_schema_one( + [_lugs("lugs-upstream", "UPSTREAM", bare_meter), _lugs("lugs-downstream", "DOWNSTREAM", bare_meter)] + ) + + assert metadata["panel.upstream_l1_current_a"] == metadata["panel.downstream_l1_current_a"] + assert metadata["panel.upstream_l1_current_a"].resolved is False + + +def test_downstream_lugs_without_a_meter_node_yields_no_entry() -> None: + """The other side of the boundary, and the reason the node is fetched + rather than defaulted. + + A device with no `meter` node does not meter, so its paths are absent + hardware. Reading properties out of a `.get(NODE_METER, {})` default would + make that indistinguishable from a `meter` node listing nothing, and this + whole distinction turns on telling those apart. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + metadata = build_schema_one( + [_lugs("lugs-upstream", "UPSTREAM", _FULL_LUGS_METER), _lugs("lugs-downstream", "DOWNSTREAM", None)] + ) + + for absent in ( + "panel.feedthrough_power_w", + "panel.feedthrough_energy_consumed_wh", + "panel.feedthrough_energy_produced_wh", + "panel.downstream_l1_current_a", + "panel.downstream_l2_current_a", + ): + assert absent not in metadata, f"{absent} was described with no meter node to describe" + + assert metadata["panel.upstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + + +def test_an_upstream_drop_is_not_masked_by_the_downstream_device() -> None: + """The failure mode `resolved` exists to prevent, in the one place the + lookup could not see it. + + `_lookup` keys on (type, node, property) and the two lugs devices match on + all three, so a property the *upstream* device dropped was still answered — + with a real unit — by the downstream device that still declared it. A gap + reported as fine, which is strictly worse than the inverse: a false + `resolved=False` shows up as a repair someone can see, while a false + `resolved=True` lets the sensor die silently with nothing to flag it. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + upstream = _lugs( + "lugs-upstream", + "UPSTREAM", + { + "properties": { + "active-power": {"datatype": "float", "unit": "W"}, + "imported-energy": {"datatype": "float", "unit": "Wh"}, + "exported-energy": {"datatype": "float", "unit": "Wh"}, + "current-b": {"datatype": "float", "unit": "A"}, + } + }, + ) + downstream = _lugs("lugs-downstream", "DOWNSTREAM", _FULL_LUGS_METER) + metadata = build_schema_one([upstream, downstream]) + + assert metadata["panel.upstream_l1_current_a"] == FieldMetadata(unit=None, datatype="unknown", resolved=False) + # The downstream device still declares it, and still resolves it — the point + # is that its declaration must not answer for the other device. + assert metadata["panel.downstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + # Everything the upstream device does declare is untouched. + assert metadata["panel.upstream_l2_current_a"] == FieldMetadata(unit="A", datatype="float") + assert metadata["panel.instant_grid_power_w"] == FieldMetadata(unit="W", datatype="float") + + +def test_a_downstream_drop_is_not_masked_by_the_upstream_device() -> None: + """The mirror, held separately because the two directions reach their + metadata through the same helper only after this change — and a later edit + that re-tables one direction would break exactly one of the pair.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + upstream = _lugs("lugs-upstream", "UPSTREAM", _FULL_LUGS_METER) + downstream = _lugs( + "lugs-downstream", + "DOWNSTREAM", + { + "properties": { + "active-power": {"datatype": "float", "unit": "W"}, + "imported-energy": {"datatype": "float", "unit": "Wh"}, + "exported-energy": {"datatype": "float", "unit": "Wh"}, + "current-b": {"datatype": "float", "unit": "A"}, + } + }, + ) + metadata = build_schema_one([upstream, downstream]) + + assert metadata["panel.downstream_l1_current_a"] == FieldMetadata(unit=None, datatype="unknown", resolved=False) + assert metadata["panel.upstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + + +def test_no_upstream_device_yields_no_entry() -> None: + """The upstream half of the contract's third case, matching the downstream + one: absent hardware is absent, not degraded.""" + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + metadata = build_schema_one([_lugs("lugs-downstream", "DOWNSTREAM", _FULL_LUGS_METER)]) + + for absent in ( + "panel.instant_grid_power_w", + "panel.main_meter_energy_consumed_wh", + "panel.main_meter_energy_produced_wh", + "panel.upstream_l1_current_a", + "panel.upstream_l2_current_a", + ): + assert absent not in metadata, f"{absent} was described with no upstream device to describe" + + assert metadata["panel.downstream_l1_current_a"] == FieldMetadata(unit="A", datatype="float") + + +def test_the_subtype_rule_applies_beyond_lugs() -> None: + """`_lookup` and `_node_declared` keep a general subtype rule, so cover it + generally. + + A device typed `X.Y` satisfies a row written for `X`, because eBus types are + hierarchical and a subtype carries its parent's properties. Lugs were the + only instance exercising it until they moved to a direction-resolved + lookup; without a non-lugs case the rule would now be both untested and + invisible, and the next reader would be entitled to delete it. + + Both halves are asserted together on purpose: resolution and presence have + to agree about which devices answer for a row, or a subtyped device that + dropped a property resolves through one and misclassifies through the other. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + subtyped_circuit = _device( + device_id="c1", + type_="energy.ebus.device.circuit.branch", + nodes={ + "meter": {"properties": {"active-power": {"datatype": "float", "unit": "W"}}}, + "breaker": {"properties": {}}, + }, + ) + metadata = build_schema_one([subtyped_circuit]) + + # Resolution reaches the subtype. + assert metadata["circuit.instant_power_w"] == FieldMetadata(unit="W", datatype="float") + # Presence reaches it too: declared nodes that omit a property are gaps... + assert metadata["circuit.current_a"].resolved is False + assert metadata["circuit.breaker_rating_a"].resolved is False + # ...while a node the subtype never declares stays absent. + assert "circuit.relay_state" not in metadata + + +def test_a_lugs_device_without_a_published_direction_yields_no_entry() -> None: + """A deliberate behaviour change from the move to direction-resolved lugs, + pinned so a later edit trips over the decision rather than the report. + + `find_lugs` identifies the pair by the `info/direction` value each device + publishes, and skips a device that publishes none. Such a device therefore + fills neither role and gets no entry at all — not an unresolved one. + + This is the contract's "or none identifiable for that role", and it is + right rather than merely tolerable: the snapshot mapper resolves the pair + through the same call, so nothing populates these ten fields either. Before + the lugs paths left `_PROPERTY_FIELD_MAP` the five `upstream_*` paths came + back `resolved=True` with real units here, which was the worst available + answer — a unit advertised for a reading that provably never arrives, with + nothing anywhere to flag it. + + An unresolved entry would be the wrong repair, and that is the edit this + test exists to catch: `resolved=False` promises a field that exists and is + degraded, and there is no such field to degrade until the device says which + one it is. + """ + from span_panel_api_schema_1.field_metadata import build_field_metadata as build_schema_one + + directionless = _device( + device_id="lugs-1", + type_="energy.ebus.device.lugs", + nodes={ + "info": {"properties": {"direction": {"datatype": "string"}}}, + "meter": _FULL_LUGS_METER, + }, + ) + metadata = build_schema_one([directionless]) + + for absent in ( + "panel.instant_grid_power_w", + "panel.main_meter_energy_consumed_wh", + "panel.main_meter_energy_produced_wh", + "panel.upstream_l1_current_a", + "panel.upstream_l2_current_a", + "panel.feedthrough_power_w", + "panel.feedthrough_energy_consumed_wh", + "panel.feedthrough_energy_produced_wh", + "panel.downstream_l1_current_a", + "panel.downstream_l2_current_a", + ): + assert absent not in metadata, ( + f"{absent} was described for a lugs device that publishes no direction. " + "No entry is the contract here: the mapper cannot populate it either." + ) diff --git a/tests/test_live_flat_differential.py b/tests/test_live_flat_differential.py new file mode 100644 index 0000000..f409bd2 --- /dev/null +++ b/tests/test_live_flat_differential.py @@ -0,0 +1,166 @@ +"""Measure the frozen flat simulator against a real panel running flat firmware. + +Phase 3b. The migration classification in `test_schema_migration_delta.py` uses +the frozen simulator as its flat reference, which is a *proxy* for firmware. This +measures the proxy, so the classification rests on something checked rather than +something assumed. + +**Compares published property sets, not snapshot values.** The first draft diffed +`SpanPanelSnapshot` fields and produced three findings that were all artefacts of +the question rather than the answer: `grid_state` differed because flat sources it +from `bess/grid-state` and the panel has no BESS; `door_state` and `vendor_cloud` +differed because both sides publish them and two panels in different houses are +simply in different states. None of that is infidelity. + +Fidelity is *does the simulator publish the same properties firmware does*, per +device class, for the classes both have. That question is stable across houses, +across time, and across which DER hardware is installed. + +**Skips without a capture, and that is the normal state.** The capture is +gitignored — it carries the panel's serial (which is also its MQTT username), the +household's circuit names and real consumption. Take one with +`scripts/capture_live_flat.py`; what lands in the repository is this file's +verdict, never the data. Property *names* are asserted and printed; values never +are, so a failure cannot leak a circuit name or a reading. + +The verdict as of 2026-08-08: the simulator is faithful for `core`, both `lugs` +and circuits — identical property sets — with exactly one gap, `pv/product-name`. +That gap corrected a real misclassification in Phase 3a, which is what this was +built to do. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +_FIXTURES = Path(__file__).parent / "fixtures" +_LIVE = _FIXTURES / "live_flat_wire.json" +_SIM = _FIXTURES / "flat_wire.json" + +pytestmark = pytest.mark.skipif( + not _LIVE.exists(), + reason="no live panel capture; run scripts/capture_live_flat.py (see .env.example)", +) + +_UUID = re.compile(r"^[0-9a-f]{32}$") + +SHARED_PREFIXES = ("core", "pv", "lugs-upstream", "lugs-downstream") +"""Single-instance device classes present on both the panel and the simulator.""" + +KNOWN_GAPS: dict[str, tuple[str, ...]] = { + "pv": ("product-name",), +} +"""Properties real firmware publishes that the frozen simulator does not. + +One entry, and it earned its keep immediately: Phase 3a classified +`pv.product_name` as a v1.0 *addition* purely because the simulator never sent it. +Real firmware does, so it is an identity — the entity exists today and survives. +`PROVISIONAL_DER` shrank accordingly. + +The flat simulator is frozen, so this is a permanent gap to compensate for rather +than a bug to file. +""" + + +def _body(path: Path) -> dict[str, str]: + capture = json.loads(path.read_text()) + return capture[next(iter(capture))] + + +@pytest.fixture(scope="module") +def live() -> dict[str, str]: + return _body(_LIVE) + + +@pytest.fixture(scope="module") +def sim() -> dict[str, str]: + return _body(_SIM) + + +def _properties(body: dict[str, str], prefix: str) -> set[str]: + return {key.split("/", 1)[1] for key in body if key.startswith(f"{prefix}/")} + + +def _circuit_properties(body: dict[str, str]) -> set[str]: + ids = {key.split("/")[0] for key in body if _UUID.match(key.split("/")[0])} + return {key.split("/", 1)[1] for key in body if key.split("/")[0] in ids} + + +@pytest.mark.parametrize("prefix", SHARED_PREFIXES) +def test_the_simulator_publishes_what_firmware_publishes(prefix: str, live: dict[str, str], sim: dict[str, str]) -> None: + """Per device class, both directions, with the one known gap allowed. + + A property firmware sends and the simulator does not means the migration + classification never saw it — `pv/product-name` is exactly that, and it was + misclassified as an addition until this measured it. A property the simulator + sends and firmware does not would be worse: the classification would be + reasoning about an entity nobody has. + """ + panel, simulated = _properties(live, prefix), _properties(sim, prefix) + if not panel and not simulated: + pytest.skip(f"neither side publishes {prefix}") + + missing = sorted(panel - simulated - set(KNOWN_GAPS.get(prefix, ()))) + invented = sorted(simulated - panel) + + assert not missing, ( + f"firmware publishes {prefix} properties the frozen simulator does not: {missing}. " + "The migration classification has no evidence about them; add them to KNOWN_GAPS " + "and check whether Phase 3a misclassified anything as an addition." + ) + assert not invented, ( + f"the simulator publishes {prefix} properties firmware does not: {invented}. " + "The classification may be reasoning about an entity no real panel has." + ) + + +def test_circuit_properties_are_identical(live: dict[str, str], sim: dict[str, str]) -> None: + """Circuits are 96% of the entity surface, so this is the bulk of the attestation. + + Property names only. Circuit *ids* are not compared — the panel's are a + different household's — and neither are values. + """ + panel, simulated = _circuit_properties(live), _circuit_properties(sim) + assert panel and simulated, "one side published no circuits" + + assert panel == simulated, ( + f"circuit properties firmware publishes and the simulator does not: {sorted(panel - simulated)}; " + f"the reverse: {sorted(simulated - panel)}" + ) + + +def test_the_known_gap_is_still_exactly_one(live: dict[str, str], sim: dict[str, str]) -> None: + """`KNOWN_GAPS` relaxes the check above, so it has to stay earned. + + Fails in both directions: a gap that closed should be deleted so the list keeps + meaning something, and a gap that never existed should never have been added. + """ + stale = { + prefix: sorted(name for name in names if name in _properties(sim, prefix)) for prefix, names in KNOWN_GAPS.items() + } + still_gaps = {prefix: names for prefix, names in stale.items() if names} + + assert not still_gaps, f"the simulator now publishes these, so they are no longer gaps: {still_gaps}" + + +def test_which_der_hardware_this_panel_can_attest(live: dict[str, str]) -> None: + """Records what the available panel does and does not settle. + + It has PV and no BESS, so it attests the `pv` rows of `PROVISIONAL_DER` and is + silent on the `battery` ones — and a differential between two silences is not + evidence. Pinned so that capturing a panel with a BESS fails here and prompts + re-deriving that set against something real. + """ + has_bess = any(key.startswith("bess/") for key in live) + has_pv = any(key.startswith("pv/") for key in live) + + assert has_pv, "this panel no longer reports PV; the pv attestation in KNOWN_GAPS rests on it" + assert not has_bess, ( + "this panel now reports a BESS. Re-derive PROVISIONAL_DER in " + "test_schema_migration_delta.py against it — the battery rows have never been " + "measured against real firmware." + ) diff --git a/tests/test_mqtt_client_connection.py b/tests/test_mqtt_client_connection.py index 581b5a6..f1a0176 100644 --- a/tests/test_mqtt_client_connection.py +++ b/tests/test_mqtt_client_connection.py @@ -8,12 +8,13 @@ from span_panel_api.exceptions import SpanPanelError, SpanPanelStaleDataError from span_panel_api.models import SpanPanelSnapshot +from span_panel_api_schema_0.const import WILDCARD_TOPIC_FMT from span_panel_api.mqtt.client import SpanMqttClient from span_panel_api.mqtt.connection import AsyncMqttBridge -from span_panel_api.mqtt.const import WILDCARD_TOPIC_FMT -from span_panel_api.mqtt.homie import HomieDeviceConsumer from span_panel_api.mqtt.models import MqttClientConfig +from conftest import flat_schema as _schema + def _make_client() -> SpanMqttClient: """Build a SpanMqttClient without I/O for unit testing.""" @@ -44,15 +45,15 @@ def subscribe(self, topic: str, qos: int = 0) -> None: self.subscribed_topics.append((topic, qos)) -class _FakeHomie(HomieDeviceConsumer): - """Minimal Homie stub for get_snapshot() tests. +class _FakeAdapter: + """Minimal SchemaAdapter stub for get_snapshot()/resubscribe tests. - Bypasses HomieDeviceConsumer.__init__ — only is_ready() and - build_snapshot() are invoked on this stub. + Only the methods SpanMqttClient actually calls on the adapter are + implemented: is_ready() and build_snapshot() for liveness/dispatch + tests, topics_to_subscribe() for resubscribe tests. """ def __init__(self, ready: bool = True, snapshot: SpanPanelSnapshot | None = None) -> None: - # Intentionally do not call super().__init__ — avoids accumulator setup. self._ready_flag = ready self._snapshot = snapshot @@ -61,9 +62,12 @@ def is_ready(self) -> bool: def build_snapshot(self) -> SpanPanelSnapshot: if self._snapshot is None: - raise RuntimeError("_FakeHomie: no snapshot configured") + raise RuntimeError("_FakeAdapter: no snapshot configured") return self._snapshot + def topics_to_subscribe(self) -> list[str]: + return [WILDCARD_TOPIC_FMT.format(serial="test-serial")] + class TestRegisterConnectionCallback: """Callback subscription API — structural only (fan-out is tested in Task 4).""" @@ -210,6 +214,7 @@ def test_reconnect_triggers_resubscribe_and_callback(self) -> None: client = _make_client() bridge = _FakeBridge(connected=True) client._bridge = bridge + client._adapter = _FakeAdapter() client._live = False # was offline calls: list[bool] = [] client.register_connection_callback(calls.append) @@ -231,6 +236,7 @@ def test_resubscribe_fires_even_on_duplicate_true(self) -> None: client = _make_client() bridge = _FakeBridge(connected=True) client._bridge = bridge + client._adapter = _FakeAdapter() client._live = True # already online calls: list[bool] = [] client.register_connection_callback(calls.append) @@ -278,7 +284,7 @@ class TestGetSnapshotLiveness: async def test_raises_stale_when_bridge_none(self) -> None: client = _make_client() client._bridge = None - client._homie = _FakeHomie(ready=True) + client._adapter = _FakeAdapter(ready=True) with pytest.raises(SpanPanelStaleDataError) as exc_info: await client.get_snapshot() @@ -287,7 +293,7 @@ async def test_raises_stale_when_bridge_none(self) -> None: async def test_raises_stale_when_homie_none(self) -> None: client = _make_client() client._bridge = _FakeBridge(connected=True) - client._homie = None + client._adapter = None with pytest.raises(SpanPanelStaleDataError) as exc_info: await client.get_snapshot() @@ -296,7 +302,7 @@ async def test_raises_stale_when_homie_none(self) -> None: async def test_raises_stale_when_broker_disconnected(self) -> None: client = _make_client() client._bridge = _FakeBridge(connected=False) - client._homie = _FakeHomie(ready=True) + client._adapter = _FakeAdapter(ready=True) with pytest.raises(SpanPanelStaleDataError) as exc_info: await client.get_snapshot() @@ -305,7 +311,7 @@ async def test_raises_stale_when_broker_disconnected(self) -> None: async def test_raises_stale_when_homie_not_ready(self) -> None: client = _make_client() client._bridge = _FakeBridge(connected=True) - client._homie = _FakeHomie(ready=False) + client._adapter = _FakeAdapter(ready=False) with pytest.raises(SpanPanelStaleDataError) as exc_info: await client.get_snapshot() @@ -315,7 +321,7 @@ async def test_returns_snapshot_when_fully_live(self) -> None: sentinel = _make_sentinel_snapshot() client = _make_client() client._bridge = _FakeBridge(connected=True) - client._homie = _FakeHomie(ready=True, snapshot=sentinel) + client._adapter = _FakeAdapter(ready=True, snapshot=sentinel) snapshot = await client.get_snapshot() assert snapshot is sentinel @@ -323,7 +329,7 @@ async def test_returns_snapshot_when_fully_live(self) -> None: async def test_raised_exception_is_span_panel_error(self) -> None: client = _make_client() client._bridge = None - client._homie = None + client._adapter = None with pytest.raises(SpanPanelError): await client.get_snapshot() @@ -355,7 +361,7 @@ async def test_dispatch_snapshot_bails_when_bridge_disconnected(self, caplog: py snapshot_sentinel = _make_sentinel_snapshot() client = _make_client() client._bridge = _FakeBridge(connected=False) - client._homie = _FakeHomie(ready=True, snapshot=snapshot_sentinel) + client._adapter = _FakeAdapter(ready=True, snapshot=snapshot_sentinel) calls: list[SpanPanelSnapshot] = [] @@ -375,7 +381,7 @@ async def test_dispatch_snapshot_bails_when_homie_not_ready(self) -> None: snapshot_sentinel = _make_sentinel_snapshot() client = _make_client() client._bridge = _FakeBridge(connected=True) - client._homie = _FakeHomie(ready=False, snapshot=snapshot_sentinel) + client._adapter = _FakeAdapter(ready=False, snapshot=snapshot_sentinel) calls: list[SpanPanelSnapshot] = [] @@ -393,7 +399,7 @@ async def test_dispatch_snapshot_delivers_when_live(self) -> None: snapshot_sentinel = _make_sentinel_snapshot() client = _make_client() client._bridge = _FakeBridge(connected=True) - client._homie = _FakeHomie(ready=True, snapshot=snapshot_sentinel) + client._adapter = _FakeAdapter(ready=True, snapshot=snapshot_sentinel) calls: list[SpanPanelSnapshot] = [] @@ -429,3 +435,105 @@ def cancel(self) -> None: assert handle.cancelled is True assert client._snapshot_timer is None + + +def test_adapter_is_none_before_connect() -> None: + """The parser needs the schema, which only connect() has, so there is no + adapter until then — mirroring today's `self._homie = None`.""" + from span_panel_api.mqtt.client import SpanMqttClient + from span_panel_api.mqtt.models import MqttClientConfig + + client = SpanMqttClient( + "192.0.2.10", "sim-40t-001", MqttClientConfig(broker_host="192.0.2.10", username="test", password="test") + ) + + assert client.adapter is None + + +def test_client_defaults_to_the_flat_adapter() -> None: + """Unchanged behaviour, different mechanism. + + Phase 0 pinned the default as an identity check against an imported + SchemaZeroAdapter. Phase 1 resolves it through entry-point discovery + instead, so the default is deliberately *unset* at construction and only + materialises when a parser is built. Asserting the built adapter rather + than the stored factory keeps the guarantee that mattered — a directly + constructed client still parses the flat schema. + """ + from span_panel_api_schema_0 import SchemaZeroAdapter + from span_panel_api.mqtt.client import SpanMqttClient + from span_panel_api.mqtt.models import MqttClientConfig + + client = SpanMqttClient( + "192.0.2.10", "sim-40t-001", MqttClientConfig(broker_host="192.0.2.10", username="test", password="test") + ) + + assert client._adapter_factory is None + assert isinstance(client._build_adapter(_schema(40)), SchemaZeroAdapter) + + +def test_injected_factory_receives_serial_and_schema() -> None: + """The factory must be called with the schema discovered at connect, not a + placeholder — the adapter reads panel size from it, which drives + unmapped-tab computation.""" + from span_panel_api.models import V2HomieSchema + from span_panel_api_schema_0 import SchemaZeroAdapter + from span_panel_api.mqtt.client import SpanMqttClient + from span_panel_api.mqtt.models import MqttClientConfig + + seen: list[tuple[str, V2HomieSchema]] = [] + + def factory(serial_number: str, schema: V2HomieSchema) -> SchemaZeroAdapter: + seen.append((serial_number, schema)) + return SchemaZeroAdapter(serial_number=serial_number, schema=schema) + + client = SpanMqttClient( + "192.0.2.10", + "sim-40t-001", + MqttClientConfig(broker_host="192.0.2.10", username="test", password="test"), + adapter_factory=factory, + ) + + # Exercise the construction path directly rather than standing up a broker. + schema = _schema(40) + client._build_adapter(schema) + + assert seen == [("sim-40t-001", schema)] + assert seen[0][1].panel_size == 40 + assert isinstance(client.adapter, SchemaZeroAdapter) + + +def test_field_metadata_is_live_after_ready() -> None: + """field_metadata must reflect devices discovered AFTER connect() ran. + + Regression for the pre-discovery cache: the adapter is constructed with an + empty tree, so anything captured during connect() is permanently {}. + """ + from span_panel_api.models import FieldMetadata + + class FakeAdapter: + schema_major = "1" + ADAPTER_CONTRACT = 1 + SUPPORTS_DATA_MODEL_VERSIONS = ("1.0", "1.99") + + def __init__(self) -> None: + self.discovered = False + + def is_ready(self) -> bool: + return self.discovered + + def build_field_metadata(self) -> dict[str, FieldMetadata]: + if not self.discovered: + return {} + return {"circuit.instant_power_w": FieldMetadata(unit="W", datatype="float")} + + client = SpanMqttClient.__new__(SpanMqttClient) + adapter = FakeAdapter() + client._adapter = adapter + + # Before discovery: no metadata, and specifically not an empty dict, so + # callers can distinguish "not ready" from "ready with nothing". + assert client.field_metadata is None + + adapter.discovered = True + assert client.field_metadata == {"circuit.instant_power_w": FieldMetadata(unit="W", datatype="float")} diff --git a/tests/test_mqtt_connect_flow.py b/tests/test_mqtt_connect_flow.py index 8ac7371..d4e83cc 100644 --- a/tests/test_mqtt_connect_flow.py +++ b/tests/test_mqtt_connect_flow.py @@ -320,6 +320,71 @@ async def test_connect_and_ready(self, mqtt_client_mock: MagicMock) -> None: assert await client.ping() is True mqtt_client_mock.subscribe.assert_called() + @pytest.mark.asyncio + async def test_no_package_metadata_is_read_on_the_event_loop(self, mqtt_client_mock: MagicMock) -> None: + """connect() reads packaging metadata three ways, and all of it is file I/O. + + Entry-point enumeration and `version()` both open dist-info off disk; + resolution imports the adapter package, which for `schema_1` means the + eBus SDK and jsonschema. Home Assistant reported the lot — `listdir`, + `read_text`, `open`, `scandir` — as blocking calls in the event loop and + asked for a bug report, with the entry-point scan alone stalling setup + for two seconds on a cold import cache. + + `version()` is watched because it was missed. Moving discovery off the + loop left it behind in the same log statement, and Home Assistant kept + reporting three blocking calls for a defect that read as fixed. A test + naming only the operations already known about would have agreed. + + Asserted on the operations rather than on `resolve_adapter` running + off-thread, because it is deliberately called twice: once in a thread to + warm the cache, then again by `_build_adapter` on the loop, where a cache + hit costs nothing. Watching the call would fail a correct implementation; + watching the I/O is the actual property. + """ + import threading + + from span_panel_api.adapters import _reset_adapter_cache + from span_panel_api_schema_0 import SchemaZeroAdapter + + loop_thread = threading.get_ident() + ran_on: dict[str, int] = {} + + class _RecordingEntryPoint: + name = "schema_0" + + def load(self) -> object: + ran_on["load"] = threading.get_ident() + return SchemaZeroAdapter + + def _enumerate(group: str) -> list[_RecordingEntryPoint]: + ran_on["enumerate"] = threading.get_ident() + return [_RecordingEntryPoint()] + + def _version(name: str) -> str: + ran_on["version"] = threading.get_ident() + return "0.0.0-test" + + client = _make_span_client() + _reset_adapter_cache() + try: + with ( + patch("span_panel_api.adapters.entry_points", side_effect=_enumerate), + patch("span_panel_api.mqtt.client.version", side_effect=_version), + ): + connect_task = asyncio.create_task(client.connect()) + await asyncio.sleep(0.05) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + await asyncio.wait_for(connect_task, timeout=5.0) + finally: + # The fake registry is process-wide; leaving it cached would hand + # every later test a single-entry-point environment. + _reset_adapter_cache() + + assert set(ran_on) == {"enumerate", "load", "version"}, f"not all of it ran: {ran_on}" + assert loop_thread not in ran_on.values(), f"metadata read on the event loop: {ran_on}" + @pytest.mark.asyncio async def test_close(self, mqtt_client_mock: MagicMock) -> None: client = _make_span_client() @@ -725,7 +790,8 @@ class TestSpanMqttClientAccumulatorReset: @pytest.mark.asyncio async def test_pre_rebuild_resets_accumulator(self, mqtt_client_mock: MagicMock) -> None: - """`_on_pre_rebuild` replaces accumulator and consumer with fresh instances.""" + """`_on_pre_rebuild` replaces the adapter (and its internal accumulator/ + consumer) with a fresh instance.""" client = _make_span_client() connect_task = asyncio.create_task(client.connect()) @@ -734,21 +800,18 @@ async def test_pre_rebuild_resets_accumulator(self, mqtt_client_mock: MagicMock) client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") await asyncio.wait_for(connect_task, timeout=5.0) - original_accumulator = client._accumulator - original_homie = client._homie - assert original_accumulator is not None - assert original_homie is not None - # Accumulator is in a ready-ish state from the simulated Homie messages. - assert original_homie.is_ready() is True + original_adapter = client._adapter + assert original_adapter is not None + # Adapter is in a ready-ish state from the simulated Homie messages. + assert original_adapter.is_ready() is True # Trigger the pre-rebuild hook directly — same call the bridge makes. client._on_pre_rebuild() - # New accumulator / consumer instances, fresh state. - assert client._accumulator is not original_accumulator - assert client._homie is not original_homie - assert client._homie is not None - assert client._homie.is_ready() is False + # New adapter instance, fresh state. + assert client._adapter is not original_adapter + assert client._adapter is not None + assert client._adapter.is_ready() is False await client.close() @@ -765,15 +828,24 @@ async def test_pre_rebuild_preserves_schema_state(self, mqtt_client_mock: MagicM schema_hash_before = client._schema_hash schema_types_before = client._previous_schema_types - field_metadata_before = client._field_metadata - panel_size_before = client._panel_size + schema_before = client._schema + field_metadata_before = client.field_metadata + assert field_metadata_before is not None client._on_pre_rebuild() assert client._schema_hash == schema_hash_before assert client._previous_schema_types == schema_types_before - assert client._field_metadata == field_metadata_before - assert client._panel_size == panel_size_before + assert client._schema == schema_before + + # `field_metadata` reads the live adapter rather than a cache, so the + # fresh accumulator legitimately reads None until the new subscription's + # retained messages repopulate the tree. What survives the rebuild is the + # schema-derived *input*, observable as the same mapping once ready again. + assert client.field_metadata is None + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$description", MINIMAL_DESCRIPTION) + client._on_message(f"{TOPIC_PREFIX_SERIAL}/$state", "ready") + assert client.field_metadata == field_metadata_before await client.close() @@ -782,11 +854,10 @@ async def test_pre_rebuild_before_connect_is_noop(self) -> None: """If pre-rebuild somehow fires before connect() completes, the handler must not raise — there is no accumulator state to reset.""" client = _make_span_client() - # _panel_size is None because connect() never ran. + # _schema is None because connect() never ran. client._on_pre_rebuild() # No exception, no state changes. - assert client._accumulator is None - assert client._homie is None + assert client._adapter is None # --------------------------------------------------------------------------- diff --git a/tests/test_mqtt_homie.py b/tests/test_mqtt_homie.py index fa92a0e..9a109ee 100644 --- a/tests/test_mqtt_homie.py +++ b/tests/test_mqtt_homie.py @@ -22,11 +22,9 @@ import pytest -from span_panel_api.mqtt.const import ( - HOMIE_STATE_READY, - MQTT_DEFAULT_MQTTS_PORT, - MQTT_DEFAULT_WS_PORT, - MQTT_DEFAULT_WSS_PORT, +from span_panel_api_schema_0 import SchemaZeroAdapter +from span_panel_api_schema_0.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.const import ( TOPIC_PREFIX, TYPE_BESS, TYPE_CIRCUIT, @@ -38,10 +36,12 @@ TYPE_POWER_FLOWS, TYPE_PV, ) -from span_panel_api.mqtt.accumulator import HomiePropertyAccumulator +from span_panel_api_schema_0.consumer import HomieDeviceConsumer +from span_panel_api.mqtt.const import HOMIE_STATE_READY, MQTT_DEFAULT_MQTTS_PORT, MQTT_DEFAULT_WS_PORT, MQTT_DEFAULT_WSS_PORT from span_panel_api.mqtt.connection import AsyncMqttBridge -from span_panel_api.mqtt.homie import HomieDeviceConsumer from span_panel_api.mqtt.models import MqttClientConfig + +from conftest import flat_schema from span_panel_api.protocol import ( PanelCapability, ) @@ -180,18 +180,18 @@ def test_ignores_set_topics(self): class TestHomieCircuitSnapshot: def test_circuit_id_normalization(self): - from span_panel_api.mqtt.const import normalize_circuit_id + from span_panel_api_schema_0.const import normalize_circuit_id assert normalize_circuit_id("aabbccdd-1122-3344-5566-778899001122") == "aabbccdd11223344556677889900112" + "2" def test_circuit_id_denormalization(self): - from span_panel_api.mqtt.const import denormalize_circuit_id + from span_panel_api_schema_0.const import denormalize_circuit_id result = denormalize_circuit_id("aabbccdd11223344556677889900112" + "2") assert result == "aabbccdd-1122-3344-5566-778899001122" def test_denormalize_non_uuid(self): - from span_panel_api.mqtt.const import denormalize_circuit_id + from span_panel_api_schema_0.const import denormalize_circuit_id # Non-32-char strings pass through unchanged assert denormalize_circuit_id("short") == "short" @@ -635,7 +635,7 @@ def test_battery_metadata(self): snapshot = consumer.build_snapshot() assert snapshot.battery.vendor_name == "Tesla" - assert snapshot.battery.product_name == "Powerwall 3" + assert snapshot.battery.model == "Powerwall 3" # flat product-name is the designation assert snapshot.battery.nameplate_capacity_kwh == 13.5 def test_battery_metadata_absent(self): @@ -646,7 +646,7 @@ def test_battery_metadata_absent(self): snapshot = consumer.build_snapshot() assert snapshot.battery.soe_percentage == 50.0 assert snapshot.battery.vendor_name is None - assert snapshot.battery.product_name is None + assert snapshot.battery.model is None assert snapshot.battery.nameplate_capacity_kwh is None @@ -677,7 +677,7 @@ def test_pv_metadata_parsed(self): snapshot = consumer.build_snapshot() assert snapshot.pv.vendor_name == "Enphase" - assert snapshot.pv.product_name == "IQ8+" + assert snapshot.pv.model == "IQ8+" assert snapshot.pv.nameplate_capacity_w == 3960.0 assert snapshot.pv.feed_circuit_id == "aabbccdd112233445566778899001122" assert snapshot.pv.relative_position == "IN_PANEL" @@ -687,7 +687,7 @@ def test_no_pv_node(self): acc, consumer = _build_ready_consumer({"core": {"type": TYPE_CORE}}) snapshot = consumer.build_snapshot() assert snapshot.pv.vendor_name is None - assert snapshot.pv.product_name is None + assert snapshot.pv.model is None assert snapshot.pv.nameplate_capacity_w is None assert snapshot.pv.feed_circuit_id is None assert snapshot.pv.relative_position is None @@ -704,7 +704,7 @@ def test_pv_metadata_partial(self): snapshot = consumer.build_snapshot() assert snapshot.pv.vendor_name == "Other" - assert snapshot.pv.product_name is None + assert snapshot.pv.model is None assert snapshot.pv.nameplate_capacity_w is None assert snapshot.pv.feed_circuit_id is None assert snapshot.pv.relative_position is None @@ -1019,6 +1019,7 @@ async def test_set_circuit_relay_publishes(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) mock_bridge = MagicMock() client._bridge = mock_bridge @@ -1037,6 +1038,7 @@ async def test_set_circuit_priority_publishes(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) mock_bridge = MagicMock() client._bridge = mock_bridge @@ -1055,13 +1057,12 @@ async def test_set_dominant_power_source_publishes(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._accumulator = HomiePropertyAccumulator(SERIAL) - client._homie = HomieDeviceConsumer(client._accumulator, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) # Populate the homie description so core node is known desc = _make_description(_core_description()) - client._homie.handle_message(f"{PREFIX}/$state", HOMIE_STATE_READY) - client._homie.handle_message(f"{PREFIX}/$description", desc) + client._adapter.handle_message(f"{PREFIX}/$state", HOMIE_STATE_READY) + client._adapter.handle_message(f"{PREFIX}/$description", desc) mock_bridge = MagicMock() client._bridge = mock_bridge @@ -1081,8 +1082,7 @@ async def test_set_dominant_power_source_no_core_node_raises(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._accumulator = HomiePropertyAccumulator(SERIAL) - client._homie = HomieDeviceConsumer(client._accumulator, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) # No description loaded — core node not found with pytest.raises(SpanPanelServerError, match="Core node not found"): @@ -1101,14 +1101,13 @@ async def test_get_snapshot_returns_homie_state(self): config = MqttClientConfig(broker_host="h", username="u", password="p") client = SpanMqttClient(host="192.168.1.1", serial_number=SERIAL, broker_config=config) - client._accumulator = HomiePropertyAccumulator(SERIAL) - client._homie = HomieDeviceConsumer(client._accumulator, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) client._bridge = _ConnectedBridge() - # Manually ready the homie consumer - client._homie.handle_message(f"{PREFIX}/$state", "ready") - client._homie.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) - client._homie.handle_message(f"{PREFIX}/core/software-version", "test-fw") + # Manually ready the adapter + client._adapter.handle_message(f"{PREFIX}/$state", "ready") + client._adapter.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) + client._adapter.handle_message(f"{PREFIX}/core/software-version", "test-fw") snapshot = await client.get_snapshot() assert snapshot.serial_number == SERIAL @@ -1132,11 +1131,10 @@ async def test_ping_true_when_connected_and_ready(self): mock_bridge = MagicMock() mock_bridge.is_connected.return_value = True client._bridge = mock_bridge - client._accumulator = HomiePropertyAccumulator(SERIAL) - client._homie = HomieDeviceConsumer(client._accumulator, panel_size=32) + client._adapter = SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(32)) - client._homie.handle_message(f"{PREFIX}/$state", "ready") - client._homie.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) + client._adapter.handle_message(f"{PREFIX}/$state", "ready") + client._adapter.handle_message(f"{PREFIX}/$description", _make_description(_core_description())) assert await client.ping() is True @@ -1489,7 +1487,7 @@ def test_evse_metadata_parsed(self): assert evse.lock_state == "LOCKED" assert evse.advertised_current_a == 32.0 assert evse.vendor_name == "SPAN" - assert evse.product_name == "SPAN Drive" + assert evse.model == "SPAN Drive" assert evse.part_number == "SPN-DRV-001" assert evse.serial_number == "SN12345" assert evse.software_version == "2.1.0" @@ -1542,7 +1540,7 @@ def test_evse_partial_metadata(self): assert evse.lock_state == "UNKNOWN" assert evse.advertised_current_a is None assert evse.vendor_name is None - assert evse.product_name is None + assert evse.model is None assert evse.part_number is None assert evse.serial_number is None assert evse.software_version is None diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..e0b597a --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,69 @@ +"""Packaging invariants that only bite downstream. + +Nothing in this suite can observe them by importing: a dev workspace resolves +every module from source, where a missing marker file costs nothing. The damage +shows up in someone else's project, against installed wheels, where a fully +annotated distribution silently resolves as Any. +""" + +from __future__ import annotations + +from pathlib import Path +import tomllib + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _wheel_source_packages() -> list[tuple[str, Path]]: + """Every importable package each distribution in the workspace ships. + + Read from the manifests rather than listed here, so an adapter added under + packages/ is covered the day it exists rather than the day someone + remembers to extend this file. + """ + manifests = [_REPO_ROOT / "pyproject.toml", *sorted(_REPO_ROOT.glob("packages/*/pyproject.toml"))] + found: list[tuple[str, Path]] = [] + for manifest in manifests: + config = tomllib.loads(manifest.read_text(encoding="utf-8")) + distribution = config["project"]["name"] + for package in config["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"]: + # src/ layout only. The root distribution also ships scripts/, which + # is tooling rather than an importable API surface consumers type + # against — see the standing note about it being top-level. + if package.startswith("src/"): + found.append((distribution, manifest.parent / package)) + return found + + +def test_every_workspace_member_is_discovered() -> None: + """Guards the parametrisation below against passing vacuously: if manifest + discovery breaks, every packaging test silently collects nothing. + + Derived from the directories on disk rather than a hardcoded list, so + adding an adapter does not require editing this file — the failure mode + worth catching is discovery finding *fewer* manifests than exist. + """ + distributions = {name for name, _ in _wheel_source_packages()} + expected = 1 + len(list(_REPO_ROOT.glob("packages/*/pyproject.toml"))) + + assert "span-panel-api" in distributions + assert len(distributions) == expected, f"discovered {sorted(distributions)}, expected {expected} distributions" + + +@pytest.mark.parametrize( + ("distribution", "package_dir"), + _wheel_source_packages(), + ids=lambda value: value.name if isinstance(value, Path) else str(value), +) +def test_every_shipped_package_carries_a_py_typed_marker(distribution: str, package_dir: Path) -> None: + """PEP 561: without this file a consumer's type checker refuses to read our + annotations and every symbol we export becomes Any on their side. + + This repo type-checks under --strict and avoids Any deliberately; shipping a + distribution that erases all of that at the wheel boundary undoes the work + for exactly the audience it was done for. + """ + marker = package_dir / "py.typed" + assert marker.is_file(), f"{distribution} ships {package_dir.name} without a py.typed marker" diff --git a/tests/test_protocol_conformance.py b/tests/test_protocol_conformance.py index b3b8b52..5b23423 100644 --- a/tests/test_protocol_conformance.py +++ b/tests/test_protocol_conformance.py @@ -13,6 +13,7 @@ from span_panel_api.mqtt.models import MqttClientConfig from span_panel_api.protocol import ( CircuitControlProtocol, + EvseControlProtocol, PanelControlProtocol, SpanPanelClientProtocol, StreamingCapableProtocol, @@ -43,6 +44,76 @@ def test_satisfies_panel_control_protocol(self) -> None: if not issubclass(SpanMqttClient, PanelControlProtocol): raise TypeError("SpanMqttClient does not satisfy PanelControlProtocol") + def test_satisfies_evse_control_protocol(self) -> None: + if not issubclass(SpanMqttClient, EvseControlProtocol): + raise TypeError("SpanMqttClient does not satisfy EvseControlProtocol") + def test_satisfies_streaming_protocol(self) -> None: if not issubclass(SpanMqttClient, StreamingCapableProtocol): raise TypeError("SpanMqttClient does not satisfy StreamingCapableProtocol") + + +def test_schema_adapter_declares_its_methods() -> None: + """The protocol must name every method SpanMqttClient calls on its parser.""" + from span_panel_api.protocol import SchemaAdapter + + for name in ( + "topics_to_subscribe", + "handle_message", + "is_ready", + "build_snapshot", + "build_field_metadata", + "circuit_nodes_missing_names", + "find_node_by_type", + "set_circuit_relay_topic", + "set_circuit_priority_topic", + "set_dominant_power_source_topic", + "dominant_power_source_payload", + "set_evse_charge_limit_topic", + "evse_charge_limit_payload", + "register_property_callback", + ): + assert hasattr(SchemaAdapter, name), f"SchemaAdapter is missing method {name}" + + +def test_schema_adapter_declares_its_class_attributes() -> None: + """`schema_major` and `SUPPORTS_DATA_MODEL_VERSIONS` are annotation-only members. + + A bare annotation on a Protocol creates no class attribute, so `hasattr` is + False for them even when correctly declared — they must be checked through + `__annotations__` instead. + """ + from span_panel_api.protocol import SchemaAdapter + + for name in ("schema_major", "SUPPORTS_DATA_MODEL_VERSIONS"): + assert name in SchemaAdapter.__annotations__, f"SchemaAdapter is missing attribute {name}" + + +def test_schema_adapter_construction_signature_matches_its_implementation() -> None: + """Construction is part of the contract, so it must be checked like the rest. + + `hasattr(SchemaAdapter, "__init__")` is vacuous — every object has one. The + assertion with teeth is that the protocol's declared signature and the + installed adapter's actual signature agree, which is what the transport + depends on when it calls a class resolved from the entry-point registry. + """ + import inspect + + from span_panel_api_schema_0 import SchemaZeroAdapter + from span_panel_api.protocol import SchemaAdapter + + declared = list(inspect.signature(SchemaAdapter.__init__).parameters) + implemented = list(inspect.signature(SchemaZeroAdapter.__init__).parameters) + + assert declared == ["self", "serial_number", "schema"] + assert implemented == declared, f"SchemaZeroAdapter.__init__{implemented} does not match the protocol {declared}" + + +def test_adapter_missing_error_reports_what_is_installed() -> None: + from span_panel_api.exceptions import SpanPanelAdapterMissingError + + err = SpanPanelAdapterMissingError(needed="schema_1", reason="data-model-version='1.0'", available=["schema_0"]) + assert err.needed == "schema_1" + assert err.available == ["schema_0"] + assert "schema_1" in str(err) + assert "schema_0" in str(err) diff --git a/tests/test_protocol_models.py b/tests/test_protocol_models.py index fced6c3..6fd1131 100644 --- a/tests/test_protocol_models.py +++ b/tests/test_protocol_models.py @@ -10,7 +10,6 @@ SpanPanelSnapshot, ) - # --------------------------------------------------------------------------- # Helpers: snapshot factory functions # --------------------------------------------------------------------------- diff --git a/tests/test_public_api_unchanged.py b/tests/test_public_api_unchanged.py new file mode 100644 index 0000000..441b0bf --- /dev/null +++ b/tests/test_public_api_unchanged.py @@ -0,0 +1,124 @@ +"""Guard: the public surface only moves on purpose. + +The HA integration pins span-panel-api and imports these names directly, so a +failure here means either an accidental break or a deliberate one whose record +belongs in the same commit. The set below is a two-way pin — it fails on both +removals and additions — and editing it is how a break gets acknowledged. + +Phase 0 held it fixed. Phase 1 deliberately breaks it (3.0): the three +flat-schema names below were removed, because the bootstrap can no longer import +a parsing implementation to re-export. +""" + +from __future__ import annotations + +import span_panel_api + +# Source of truth: src/span_panel_api/__init__.py __all__ (transcribed in full, +# not trimmed, per Phase 0 Task 7's instruction to reconcile against the real file +# rather than an earlier hand-transcribed listing). +EXPECTED_PUBLIC_API = { + # Protocols + "CircuitControlProtocol", + # Added 2026-08-19: EVSE charge-current control. Purely additive -- the only + # settable property the v1.0 catch-up surfaces, and one no flat panel + # publishes, so nothing existing changes. + "EvseControlProtocol", + "PanelCapability", + "PanelControlProtocol", + "SpanPanelClientProtocol", + "StreamingCapableProtocol", + # Metadata + "FieldMetadata", + "HomieSchemaTypes", + # Added 2026-08-20: runtime discovery -- the namespace an adapter puts + # declared-but-unaddressed properties under, the row type it puts there, and + # the predicate a consumer partitions with. Purely additive: an adapter that + # emits none of these rows is indistinguishable from one built before the + # namespace existed, and a consumer that never partitions sees exactly the + # curated rows it saw before. + "DISCOVERY_NAMESPACE", + "DiscoveredMetadata", + # Added 2026-08-20: device-scoped adoption -- the two nodes whose properties + # resolve to a device card and a device link rather than to entities, and the + # pair of records an adapter reports an unmodelled device with. Additive for + # the same reason: `SpanPanelSnapshot.adopted_devices` defaults empty, so an + # adapter that adopts nothing and a consumer that never reads the field are + # both unaffected. Deliberately not `SchemaAdapter` members -- the protocol + # derives its required set from itself, so a member there would be required + # of every adapter package and would invalidate built wheels. + "ADOPTION_IDENTITY_NODE", + "ADOPTION_TOPOLOGY_NODE", + "AdoptedDevice", + "AdoptedProperty", + "ExtensionProperty", + "ExtensionSubject", + "AdoptedControlProtocol", + "is_discovery_path", + # Snapshots + "SpanBatterySnapshot", + "SpanCircuitSnapshot", + "SpanEvseSnapshot", + # Added 2026-08-10: v1.0 surfaces the islanding authority as its own device. + # Purely additive -- no flat panel publishes a MID, so nothing existing changes. + "SpanMidSnapshot", + "SpanPVSnapshot", + "SpanPanelSnapshot", + # Added 2026-08-19: the enclosure's Power Control System (UL 3141 import + # limiting). Purely additive for the same reason as the MID -- no flat panel + # publishes `energy.ebus.capability.pcs`, so `SpanPanelSnapshot.pcs` is + # `None` on every existing consumer's data and nothing that reads the + # snapshot today changes. + "SpanPcsSnapshot", + # Factory + "create_span_client", + # Detection + "DetectionResult", + "detect_api_version", + # v2 auth + "V2AuthResponse", + "V2HomieSchema", + "V2StatusInfo", + "delete_fqdn", + "download_ca_cert", + "get_fqdn", + "get_homie_schema", + "get_v2_status", + "register_fqdn", + "regenerate_passphrase", + "register_v2", + # Transport + "MqttClientConfig", + "SpanMqttClient", + # Phase validation + "PhaseDistribution", + "are_tabs_opposite_phase", + "get_phase_distribution", + "get_tab_phase", + "suggest_balanced_pairing", + "validate_solar_tabs", + # Exceptions + "SpanPanelAPIError", + "SpanPanelAdapterMissingError", + "SpanPanelAuthError", + "SpanPanelSchemaVersionError", + "SpanPanelConnectionError", + "SpanPanelError", + "SpanPanelServerError", + "SpanPanelStaleDataError", + "SpanPanelTimeoutError", + "SpanPanelValidationError", +} + + +def test_all_is_unchanged() -> None: + missing = EXPECTED_PUBLIC_API - set(span_panel_api.__all__) + assert not missing, f"Phase 0 removed public names: {sorted(missing)}" + + extra = set(span_panel_api.__all__) - EXPECTED_PUBLIC_API + assert not extra, f"Phase 0 added undocumented public names: {sorted(extra)}" + + +def test_every_exported_name_is_importable() -> None: + for name in span_panel_api.__all__: + assert hasattr(span_panel_api, name), f"{name} is in __all__ but not importable" diff --git a/tests/test_redispatch_on_reconnect.py b/tests/test_redispatch_on_reconnect.py new file mode 100644 index 0000000..59ae667 --- /dev/null +++ b/tests/test_redispatch_on_reconnect.py @@ -0,0 +1,482 @@ +"""A panel that comes back as a different generation gets a different parser. + +The adapter is chosen once, at connect, from the REST `dataModelVersion`. Everything +afterwards reused it: `connect()` short-circuits on the cached schema, the reconnect +path re-subscribes with the existing adapter's topics, and the pre-rebuild hook +rebuilds from the cached schema on the stated assumption that "the Homie schema +cannot change within a session". + +A firmware upgrade is that assumption failing. The panel disconnects and returns as a +different generation while the session is still open, so nothing reconsiders. Seen +live: a flat panel upgraded to v1.0 underneath a running Home Assistant, the client +reconnected, kept the flat parser, and read the v1.0 tree with it. It logged a single +`Invalid $description JSON` and then reported every circuit as missing -- a wrong +answer rather than an error, which is the failure mode worth testing for. + +**The trigger is the MQTT property, not the reconnect edge.** Triggering on reconnect +was the first attempt and it does not work: the edge fires the moment the broker +accepts a connection, which on a real upgrade precedes the panel binding its HTTP +port. It failed with `Cannot reach panel` 25ms after reconnect, and since MQTT had +reconnected successfully there was no later edge to retry on -- the wrong parser +stayed for the rest of the session. The retained `info/data-model-version` arrives +only once the new panel is publishing, so it is the first moment the answer exists. +""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import patch + +import pytest + +from span_panel_api.exceptions import SpanPanelConnectionError, SpanPanelServerError +from span_panel_api.mqtt.client import ( + _REDISPATCH_RETRY_INITIAL_S, + _REDISPATCH_RETRY_MAX_S, + SpanMqttClient, +) +from span_panel_api.mqtt.models import MqttClientConfig + +from conftest import SERIAL + + +class _Schema: + """The slice of `V2HomieSchema` the dispatch path reads.""" + + def __init__(self, version: str | None) -> None: + self.data_model_version = version + self.types: dict[str, Any] = {} + self.types_schema_hash = f"sha256:{version}" + + +class _Adapter: + """Records which schema it was built from, so a swap is observable.""" + + def __init__(self, serial: str, schema: _Schema) -> None: + self.serial = serial + self.schema = schema + self.schema_major = f"schema_for_{schema.data_model_version}" + + def topics_to_subscribe(self) -> list[str]: + return [f"topics/for/{self.schema.data_model_version}"] + + def build_field_metadata(self) -> dict[str, Any]: + return {} + + def is_ready(self) -> bool: + return False + + def handle_message(self, topic: str, payload: str) -> None: + return None + + +class _Bridge: + def __init__(self) -> None: + self.subscribed: list[str] = [] + + def subscribe(self, topic: str, qos: int = 0) -> None: + self.subscribed.append(topic) + + +def _client(initial: str | None) -> tuple[SpanMqttClient, _Bridge]: + client = SpanMqttClient( + host="192.168.1.1", + serial_number=SERIAL, + broker_config=MqttClientConfig(broker_host="broker.local", username="u", password="p"), + adapter_factory=_Adapter, # type: ignore[arg-type] + data_model_version=initial, + ) + bridge = _Bridge() + client._bridge = bridge # type: ignore[assignment] + client._adapter = _Adapter(SERIAL, _Schema(initial)) # type: ignore[assignment] + client._loop = asyncio.get_running_loop() + return client, bridge + + +async def _panel_publishes_version(client: SpanMqttClient, version: str | None) -> None: + """Deliver the retained `info/data-model-version` the way the broker would.""" + client._on_message(f"ebus/5/{SERIAL}/info/data-model-version", version or "") + # The refetch is scheduled rather than awaited, so the message callback can stay + # synchronous. Let the loop drain it. + # One turn per retry attempt, plus slack for the task itself. + for _ in range(24): + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_a_generation_change_rebuilds_the_parser() -> None: + """The upgrade case: a flat panel starts publishing v1.0. + + Asserting the adapter *instance* changed and carries the new version, rather than + a log line -- the parser is what reads the tree, so it is the thing that has to + move. + """ + client, _ = _client(None) + before = client.adapter + + with patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_Schema("1.0")): + await _panel_publishes_version(client, "1.0") + + assert client.adapter is not before, "the parser must be rebuilt, not reused" + assert client.data_model_version == "1.0" + + +@pytest.mark.asyncio +async def test_the_new_parsers_topics_are_subscribed() -> None: + """A new parser reading old topics would be a quieter version of the same bug. + + The two generations do not share a topic shape, so a rebuilt adapter that never + subscribes to its own topics receives nothing and reports an empty panel -- which + looks like a panel that has gone away rather than one that was mis-read. + """ + client, bridge = _client(None) + + with patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_Schema("1.0")): + await _panel_publishes_version(client, "1.0") + + assert "topics/for/1.0" in bridge.subscribed + + +@pytest.mark.asyncio +async def test_an_unchanged_generation_never_fetches() -> None: + """Steady state must cost nothing. + + The panel republishes this property on every reconnect and on every retained + replay. Rebuilding — or even fetching — each time would discard accumulated tree + state and put avoidable load on the panel, so agreement is answered by comparison + alone, before anything is scheduled. + """ + client, _ = _client("1.0") + before = client.adapter + calls = 0 + + def _count(*_a: object, **_k: object) -> _Schema: + nonlocal calls + calls += 1 + return _Schema("1.0") + + with patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_count): + await _panel_publishes_version(client, "1.0") + await _panel_publishes_version(client, "1.0") + await _panel_publishes_version(client, "1.0") + + assert client.adapter is before + assert calls == 0, f"a matching generation must not be fetched, got {calls} fetches" + + +@pytest.mark.asyncio +async def test_a_patch_release_is_not_a_generation_change() -> None: + """`1.0` and `1.0.3` are read by the same parser. + + Comparing reported strings instead of the adapters they select would rebuild on + any patch release -- a pointless swap that drops tree state on a routine bump. + """ + client, _ = _client("1.0") + before = client.adapter + + with patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_Schema("1.0.3")): + await _panel_publishes_version(client, "1.0.3") + + assert client.adapter is before + + +@pytest.mark.asyncio +async def test_http_lagging_the_broker_is_retried_not_abandoned() -> None: + """The failure that made the first attempt useless. + + A panel accepts MQTT before it serves HTTP: the broker is listening while the + application is still binding its port. The first fetch therefore fails, and + because MQTT reconnected *successfully* there is no later edge to retry on. One + attempt means the wrong parser stays for the rest of the session, which is exactly + what was observed live. + """ + client, _ = _client(None) + before = client.adapter + attempts = 0 + + def _lags_then_answers(*_a: object, **_k: object) -> _Schema: + nonlocal attempts + attempts += 1 + if attempts < 3: + raise SpanPanelConnectionError("Cannot reach panel") + return _Schema("1.0") + + with ( + patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_lags_then_answers), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0), + ): + await _panel_publishes_version(client, "1.0") + + assert attempts >= 3, "the fetch must be retried while HTTP is still coming up" + assert client.adapter is not before, "the parser must swap once the fetch succeeds" + + +@pytest.mark.asyncio +async def test_a_panel_that_is_not_serving_http_yet_leaves_the_parser_alone() -> None: + """Waiting must not disturb what is already working. + + MQTT is up or this path would not be running, so tearing the connection down + over an HTTP endpoint that has not come up would turn a panel that is merely + booting into a dead integration. The parser stays as it is while the wait + runs — a stale parser reports missing data rather than wrong data, because + the two schemas share no topic shape — and the wait keeps going rather than + giving up, because nothing else will start it again. + """ + client, _ = _client(None) + before = client.adapter + + with ( + patch( + "span_panel_api.mqtt.client.get_homie_schema", + side_effect=SpanPanelConnectionError("never answers"), + ), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0), + ): + await _panel_publishes_version(client, "1.0") + + assert client.adapter is before + assert client._redispatch_in_flight, ( + "the guard is held for as long as the wait runs, so a second edge does not " "start a competing attempt" + ) + + # Cancelled directly rather than through `close()`, which this fixture's fake + # bridge cannot service. That the wait ends on cancellation is covered by + # `test_the_wait_ends_promptly_when_the_client_is_closed`. + for task in list(client._background_tasks): + task.cancel() + + +@pytest.mark.asyncio +async def test_a_consumer_is_told_the_generation_changed() -> None: + """Swapping the parser restores reading, not topology. + + A consumer builds its devices and entities from the tree as it looked at setup. + v1.0 introduces a MID the flat tree has no equivalent for and re-keys the EVSEs, + so a parser swap alone leaves the panel reading correctly while still showing the + old device set — observed live, where data flowed and a manual reload was still + needed. Only the consumer can rebuild that, so it is told rather than guessed at. + + Fired after the swap, so a consumer inspecting the client from inside the callback + sees the generation it is being told about rather than the one being replaced. + """ + client, _ = _client(None) + seen: list[tuple[str | None, str | None, str | None]] = [] + + client.register_schema_change_callback( + lambda previous, current: seen.append((previous, current, client.data_model_version)) + ) + + with patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_Schema("1.0")): + await _panel_publishes_version(client, "1.0") + + assert seen == [(None, "1.0", "1.0")] + + +@pytest.mark.asyncio +async def test_an_unchanged_generation_tells_nobody() -> None: + """The callback reloads a config entry, so a spurious one is disruptive. + + This property republishes on every reconnect and retained replay. Firing on each + would reload the integration repeatedly, tearing down and rebuilding every entity + for a panel that never changed. + """ + client, _ = _client("1.0") + seen: list[tuple[str | None, str | None]] = [] + + client.register_schema_change_callback(lambda p, c: seen.append((p, c))) + + with patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_Schema("1.0")): + await _panel_publishes_version(client, "1.0") + await _panel_publishes_version(client, "1.0") + + assert seen == [] + + +@pytest.mark.asyncio +async def test_a_raising_consumer_does_not_break_the_swap() -> None: + """The parser is already rebuilt when subscribers are told. + + Reloading a config entry tears down the object that registered the callback, so a + subscriber raising mid-teardown is a realistic outcome rather than a hypothetical + one. It must not leave the client half-swapped. + """ + client, _ = _client(None) + client.register_schema_change_callback(lambda _p, _c: (_ for _ in ()).throw(RuntimeError("boom"))) + reached: list[str] = [] + client.register_schema_change_callback(lambda _p, _c: reached.append("second")) + + with patch("span_panel_api.mqtt.client.get_homie_schema", return_value=_Schema("1.0")): + await _panel_publishes_version(client, "1.0") + + assert client.data_model_version == "1.0", "the swap must stand" + assert reached == ["second"], "one raising subscriber must not starve the others" + + +@pytest.mark.asyncio +async def test_a_rebooting_panel_answering_502_is_waited_for_not_abandoned() -> None: + """The failure that cost a live firmware upgrade its automatic reload. + + A panel accepts MQTT before it serves HTTP, and the retry loop above exists + for that. But there are three ways HTTP lags the broker, and this loop + originally handled two: it caught "cannot reach" and "timed out" and not + "answered, with 502". A booting device brings its network stack and reverse + proxy up before the application behind them, so 502 is the *ordinary* shape, + not an exotic one. + + Because `SpanPanelServerError` was not caught, the very first attempt raised + straight out of the loop, out of the fire-and-forget task that called it, and + the parser was never swapped. Observed on two Home Assistant instances + watching one panel through the same upgrade: both logged `Task exception was + never retrieved`, both stayed on the flat parser, and neither recovered + without a manual reload. + """ + client, _ = _client(None) + before = client.adapter + attempts = 0 + + def _five_oh_two_then_ready(*_a: object, **_k: object) -> _Schema: + nonlocal attempts + attempts += 1 + if attempts < 3: + raise SpanPanelServerError("Panel not ready: HTTP 502 fetching the Homie schema", 502) + return _Schema("1.0") + + with ( + patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_five_oh_two_then_ready), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0), + ): + await _panel_publishes_version(client, "1.0") + + assert attempts >= 3, "a 502 must be retried rather than ending the attempt" + assert client.adapter is not before, "the parser must swap once the panel answers" + + +@pytest.mark.asyncio +async def test_an_unexpected_failure_leaves_a_usable_message_rather_than_a_bare_traceback( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nothing may escape the fire-and-forget task. + + An escaping exception surfaces as "Task exception was never retrieved" and + the parser silently stays on the old generation -- the exact failure this + method exists to prevent, reached by a different route. The user's remedy is + a reload, and nothing else is going to tell them so. + """ + client, _ = _client(None) + before = client.adapter + + with patch( + "span_panel_api.mqtt.client.get_homie_schema", + side_effect=RuntimeError("something nobody predicted"), + ): + await _panel_publishes_version(client, "1.0") + + assert client.adapter is before + assert "Reload the integration" in caplog.text + assert "something nobody predicted" in caplog.text + + +@pytest.mark.asyncio +async def test_the_backoff_reaches_a_steady_state_rather_than_growing() -> None: + """Once the panel is up, the wait to notice it must stay short. + + Doubling without a ceiling would mean a panel that took a while to come back + was then ignored for longer than it took — minutes between attempts by the + time it is answering. The interval has to settle, so the worst case between + the panel being ready and this loop finding out is one interval however long + the wait has already run. + + **Observed from the loop, not recomputed.** The first version of this test + calculated the backoff sequence itself and asserted on its own arithmetic, + which passes just as happily when the ceiling is removed from the code — the + same mistake as the window test it replaced. These are the sleeps the real + function performed. + """ + client, _ = _client(None) + slept: list[float] = [] + attempts = 0 + + def _ready_eventually(*_a: object, **_k: object) -> _Schema: + nonlocal attempts + attempts += 1 + if attempts < 30: + raise SpanPanelServerError("Panel not ready: HTTP 502", 502) + return _Schema("1.0") + + async def _record(seconds: float) -> None: + slept.append(seconds) + + with ( + patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_ready_eventually), + patch("span_panel_api.mqtt.client.asyncio.sleep", _record), + ): + assert await client._fetch_schema_with_retry() is not None + + assert slept[0] == _REDISPATCH_RETRY_INITIAL_S, "it should start responsive" + assert max(slept) == _REDISPATCH_RETRY_MAX_S, "and never wait longer than the ceiling" + assert slept[-1] == _REDISPATCH_RETRY_MAX_S, "settling there rather than continuing to grow" + assert _REDISPATCH_RETRY_MAX_S <= 30.0, ( + "a steady-state gap longer than half a minute is too long to leave a panel " "that is already answering" + ) + + +@pytest.mark.asyncio +async def test_the_wait_does_not_end_on_its_own() -> None: + """There is no attempt count to exhaust, and that is the point. + + Every bounded version of this was wrong, twice, for the same reason: the + bound was sized against a reboot somebody had measured and the next reboot + was not that reboot. Giving up has nothing to recommend it — the triggers for + another attempt are the reconnect edge and the retained message, and a panel + that finishes booting afterwards produces neither, so exhausting a bound + means stranded until a human reloads. + """ + client, _ = _client(None) + attempts = 0 + + def _ready_far_later(*_a: object, **_k: object) -> _Schema: + nonlocal attempts + attempts += 1 + if attempts < 40: # well past any bound this ever had + raise SpanPanelServerError("Panel not ready: HTTP 502", 502) + return _Schema("1.0") + + with ( + patch("span_panel_api.mqtt.client.get_homie_schema", side_effect=_ready_far_later), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 0), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 0), + ): + assert await client._fetch_schema_with_retry() is not None + + assert attempts == 40 + + +@pytest.mark.asyncio +async def test_the_wait_ends_promptly_when_the_client_is_closed() -> None: + """Unbounded is only safe because cancellation is prompt. + + `close()` cancels every background task, and the cancellation lands inside + the sleep. Without this, waiting forever would mean a Home Assistant + shutdown or a config-entry unload waiting with it. + """ + client, _ = _client(None) + + with ( + patch( + "span_panel_api.mqtt.client.get_homie_schema", + side_effect=SpanPanelServerError("Panel not ready: HTTP 502", 502), + ), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_INITIAL_S", 3600), + patch("span_panel_api.mqtt.client._REDISPATCH_RETRY_MAX_S", 3600), + ): + task = asyncio.create_task(client._fetch_schema_with_retry()) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert task.cancelled() diff --git a/tests/test_reference_tree_values.py b/tests/test_reference_tree_values.py new file mode 100644 index 0000000..94ccfaf --- /dev/null +++ b/tests/test_reference_tree_values.py @@ -0,0 +1,140 @@ +"""What the reference tree leaves unvalued must be what the producer leaves unvalued. + +`parent_child_tree.json` is shipped package data and the fixture every schema_1 +test is written against, so what it *publishes* is the whole evidence base for +"does this adapter read that property". A property the producer values and this +capture does not is therefore invisible in both directions at once: no test can +fail for not reading it, and no consumer test can fail for not surfacing it. + +That is not hypothetical. The capture was trimmed and renamed by hand from a +panelbench run, and by 2026-08-19 it had drifted eight properties behind — MID +`info/{model,serial-number,firmware-version,hardware-version}`, BESS +`info/{part-number,serial-number,firmware-version}` and PV +`info/firmware-version` were all published by the producer and absent here. Four +library tests had been written to inject the values by hand precisely because +the fixture did not carry them, which reads as coverage and is not: injecting a +value asks whether the mapper can read a property, never whether the panel sends +one. The drift was found by comparing the two artifacts by hand, which is a +thing nobody does twice — hence this. + +**Compared at device-type granularity, and it has to be.** This capture is +`sim-*` renamed to `example-*` and cut from 28 circuits to 5, with two of them +renamed in passing (`kitchen Lights` -> `Kitchen Lights`, `Garage Outlets` -> +`Garage Outlet`), so a per-device comparison would fail on the rename rather +than on a value. Type granularity is also the granularity the question is asked +at: five circuits declare the same properties, and the same one going unvalued +on all five is one gap, not five. The same choice the integration's +`test_declared_but_unread` makes, for the same reason. + +The reduction loses nothing here, and that is measured rather than assumed: +reduced the same way, panelbench's baseline is exactly the declared-but-unvalued +set of its own committed wire capture (`tests/conformance/fixtures/golden_wire.json`). + +Refresh the vendored copy with: + + cp ../panelbench/tests/fidelity/fixtures/unvalued_by_both_baseline.json \ + tests/fixtures/panelbench_unvalued_by_both.json + +It is vendored verbatim rather than pre-reduced so that refreshing it is a copy +whose correctness a reader can check with `diff`, and so the reduction stays +here where it is explained. +""" + +from __future__ import annotations + +from collections import defaultdict +import json +import pathlib + +from span_panel_api_schema_1.reference_payloads import parent_child_tree + +_PANELBENCH_BASELINE = pathlib.Path(__file__).parent / "fixtures" / "panelbench_unvalued_by_both.json" + + +def _device_type(qualified: str) -> str: + """`energy.ebus.device.circuit` -> `circuit`.""" + return qualified.rsplit(".", 1)[-1] + + +def _fixture_unvalued() -> dict[str, set[str]]: + """Every `node/property` this capture declares and never publishes, by device type.""" + unvalued: dict[str, set[str]] = defaultdict(set) + for topics in parent_child_tree().values(): + description = json.loads(topics["$description"]) + declared = { + f"{node_id}/{property_id}" + for node_id, node in description.get("nodes", {}).items() + for property_id in node.get("properties", {}) + } + published = {topic for topic in topics if not topic.startswith("$")} + unvalued[_device_type(description["type"])] |= declared - published + return {device_type: topics for device_type, topics in unvalued.items() if topics} + + +def _panelbench_unvalued() -> dict[str, set[str]]: + """Panelbench's baseline, reduced the same way. + + Its lines are `{device type}::{device name} {node}/{property}`; the name is + what the trim and the rename make uncomparable, so it is what the reduction + drops. + """ + unvalued: dict[str, set[str]] = defaultdict(set) + for line in json.loads(_PANELBENCH_BASELINE.read_text(encoding="utf-8")): + identity, _, topic = line.partition(" ") + unvalued[_device_type(identity.split("::", 1)[0])].add(topic) + return dict(unvalued) + + +def test_the_reference_tree_values_everything_the_producer_values() -> None: + """Fails in both directions, so neither drift nor a stale baseline survives. + + A property the producer starts valuing and this capture does not fails as a + gap in the evidence base. A property this capture values that the producer + does not fails too: the capture would be asserting a value nothing on the + wire produces, which is a fixture that tests the parser against fiction. + """ + fixture = _fixture_unvalued() + producer = _panelbench_unvalued() + + missing = { + device_type: sorted(topics - producer.get(device_type, set())) + for device_type, topics in fixture.items() + if topics - producer.get(device_type, set()) + } + invented = { + device_type: sorted(topics - fixture.get(device_type, set())) + for device_type, topics in producer.items() + if topics - fixture.get(device_type, set()) + } + + assert fixture == producer, ( + "the reference tree and the producer disagree about what stays unvalued.\n" + f" unvalued here, valued by the producer (port the value in):\n {missing}\n" + f" unvalued by the producer, valued here (the capture invented it):\n {invented}\n\n" + "Port values into the existing artifact rather than recapturing it: the ids are " + "synthetic, the circuit set is trimmed, and the meter values are not reproducible." + ) + + +def test_the_held_pv_serial_is_still_the_only_singleton_left() -> None: + """PV `info/serial-number` is unvalued on purpose, and must stay that way. + + `_der_identifier` prefers a serial over the instance id, so valuing it moves + the PV device id from `-pv-1` to `-`. A consumer keys + its device registry on that id, which turns an upgrade rehearsal into a + device-replacement rehearsal. Pinned separately from the set comparison + above because that one would go on passing if both sides gained the value + together, and this is the one line where agreeing would be the mistake. + """ + assert _fixture_unvalued()["pv"] == {"info/serial-number"} + + +def test_every_property_the_producer_values_on_the_panel_is_valued_here() -> None: + """The enclosure carries no unvalued declaration at all, and that is the point. + + `status/wifi-ssid` was the last one, and it is the property whose absence + hid a flat -> v1.0 regression: nothing read it because nothing published it, + and nothing published it because the capture had not been refreshed. An + empty set here is a measurement, and this test is what keeps it one. + """ + assert "distribution-enclosure" not in _fixture_unvalued() diff --git a/tests/test_schema_generation_cross_check.py b/tests/test_schema_generation_cross_check.py new file mode 100644 index 0000000..dad45dc --- /dev/null +++ b/tests/test_schema_generation_cross_check.py @@ -0,0 +1,119 @@ +"""The two schema-generation signals must agree, and disagreement must be loud. + +The migration guide's "Schema-generation detection" carries one rule on two +transports: MQTT ``info/data-model-version`` absent = flat, present = parent/child; +REST ``dataModelVersion`` absent = flat, exactly mirroring the MQTT signal. + +Dispatch reads REST, because the adapter chooses which topics to subscribe to and so +must exist before the first SUBSCRIBE. That left the MQTT value unread, and a +producer that published one and not the other went undetected: the client dispatched +on the REST answer, parsed the tree with the wrong parser, and reported a clean +connection. Every number in Home Assistant was wrong and nothing said so. + +That is not hypothetical -- it is exactly what a parent/child simulator did when it +published `info/data-model-version` over MQTT while its REST schema omitted +`dataModelVersion`. These tests are what makes that state impossible to reach +quietly. +""" + +from __future__ import annotations + +import pytest + +from span_panel_api.exceptions import SpanPanelSchemaVersionError +from span_panel_api.mqtt.client import SpanMqttClient +from span_panel_api.mqtt.models import MqttClientConfig + +from conftest import SERIAL + + +def _client(*, reported: str | None, observed: str | None) -> SpanMqttClient: + """A client with the two signals set, without connecting to anything. + + The cross-check reads only these two values, so driving a whole connect flow to + reach it would test the mocking rather than the rule. + """ + client = SpanMqttClient( + host="192.168.1.1", + serial_number=SERIAL, + broker_config=MqttClientConfig(broker_host="broker.local", username="u", password="p"), + data_model_version=reported, + ) + client._observed_data_model_version = observed + return client + + +@pytest.mark.parametrize( + ("reported", "observed", "why"), + [ + (None, None, "flat panel: neither transport carries the property"), + ("1.0", "1.0", "parent/child panel: both carry the same value"), + ("1.0", "1.0.3", "same major, so the same parser reads both"), + ("1.2", "1.0", "same major across a minor bump"), + ], +) +def test_agreeing_signals_pass(reported: str | None, observed: str | None, why: str) -> None: + """Agreement is by selected adapter, not by string equality. + + A patch or minor difference between the two reads is not a disagreement worth + refusing a connection over: both values select the same parser, so no value in + the tree can be misread. Comparing the strings would turn a routine firmware + release into an outage. + """ + _client(reported=reported, observed=observed)._assert_transports_agree_on_schema_generation() + + +@pytest.mark.parametrize( + ("reported", "observed"), + [ + (None, "1.0"), # the simulator's actual failure: MQTT v1.0, REST silent + ("1.0", None), # the mirror image: REST claims v1.0, the tree is flat + ("1.0", "2.0"), # both present, different majors + ], +) +def test_disagreeing_signals_raise(reported: str | None, observed: str | None) -> None: + """Refusing follows the rule dispatch already applies to an unparseable version. + + An unknown schema generation means every value in the tree may be misread, so the + blast radius is the whole panel rather than one property. A warning would leave a + consumer running on wrong numbers; the error names both values so the offending + transport is obvious without a packet capture. + """ + client = _client(reported=reported, observed=observed) + + with pytest.raises(SpanPanelSchemaVersionError) as exc: + client._assert_transports_agree_on_schema_generation() + + message = str(exc.value) + assert repr(reported) in message + assert repr(observed) in message + + +def test_an_unparseable_mqtt_value_is_reported_as_such() -> None: + """A present-but-unreadable MQTT value is its own failure, not a silent pass. + + Dispatch already refused any unparseable *REST* value before the connection got + this far, so an unparseable value here can only have come from MQTT -- and saying + so is what stops the reader hunting through the REST response for it. + """ + client = _client(reported=None, observed="not-a-version") + + with pytest.raises(SpanPanelSchemaVersionError, match="no adapter major"): + client._assert_transports_agree_on_schema_generation() + + +def test_the_root_devices_property_is_the_one_observed() -> None: + """A child's copy must not be mistaken for the panel's. + + Under parent/child every device has its own `info` node, so the topic is matched + on the root serial rather than on the property name alone. Without that, a BESS + or MID publishing the property would overwrite the panel's answer -- and it would + do so non-deterministically, depending on retained-message ordering. + """ + client = _client(reported="1.0", observed=None) + + client._on_message(f"ebus/5/{SERIAL}-bess/info/data-model-version", "9.9") + assert client._observed_data_model_version is None, "a child's copy must be ignored" + + client._on_message(f"ebus/5/{SERIAL}/info/data-model-version", "1.0") + assert client._observed_data_model_version == "1.0" diff --git a/tests/test_schema_migration_delta.py b/tests/test_schema_migration_delta.py new file mode 100644 index 0000000..be0c037 --- /dev/null +++ b/tests/test_schema_migration_delta.py @@ -0,0 +1,579 @@ +"""Phase 3: what happens to a user's entities when firmware moves flat → v1.0. + +The acceptance criterion is that a user upgrades and **nothing in their Home +Assistant changes**. This produces the classification mechanically rather than by +argument: drive both adapters over a capture of the *same logical panel*, and +diff which `SpanPanelSnapshot` fields each populates. + +Same panel is not a claim, it is checked below — serial `sim-40t-001`, 30 +configured circuits, and every circuit UUID identical across both captures. +That last one is the load-bearing fact for entity survival: `unique_id` is +circuit-UUID-derived, so identical UUIDs mean the registry keeps the same +`entity_id`, which means `statistic_id` is unchanged and long-term history +survives. + +**Population, not values.** The two captures are different runs of different +simulators, so values cannot match and asserting them would be noise. What +matters is whether a field a user has today still arrives tomorrow. + +Three buckets: + +- **identity** — populated on both sides. The entity survives unremarked. +- **addition** — v1.0 only. New; a product decision about whether to surface it, + never a migration risk. +- **orphan** — flat only. **The dangerous bucket.** An entity that exists today + and stops updating, which HA shows as stale rather than gone. + +An orphan not on `EXPECTED_ORPHANS` fails. That is the whole point: the list is +short, every member is a decision someone made on purpose, and anything else is a +regression that reached a user. + +--- + +**What this cannot tell you, which matters as much as what it can.** + +The flat side is the flat simulator, a proxy for flat firmware rather than firmware +itself. The gap is narrower than "DER is unverified", and worth stating precisely, +because the two halves have very different support. + +"Frozen" is the word this file used until 2026-08-20, and it was wrong in a way that +cost something: flat is a schema no longer being extended, not a producer no longer +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`. + +*Telemetry is attested.* The simulator models the BESS and the Drives, and the +integration renders their entities correctly against it — which is real evidence +for `soc`, `soe`, `connected`, `nameplate-capacity`, `relative-position` and the +EVSE surface, all of which it publishes. + +*Identity is not published at all*, so nothing can attest it: + +| device | identity keys the flat simulator publishes | +| --- | --- | +| panel | `model`, `serial-number`, `software-version` | +| BESS | **none** | +| PV | `vendor-name` only | +| EVSE | full | + +`PROVISIONAL_DER` is exactly that unpublished set — not a hedge across DER +generally. Some members are probably misclassified, but **in the benign +direction**, and the reason is worth understanding because it generalises. + +A user does not see which property a value came from; they see the value. Until +2026-08-10 this adapter used that latitude to *cross over* — `info/part-number` onto +`battery.model`, `info/model` onto `battery.product_name` — holding each entity's +displayed meaning still against flat, which irregularly puts the SKU in `bess/model` +where the EVSE puts it in `part-number`. + +That worked and permanently encoded flat's irregularity. The snapshot now speaks +v1.0's vocabulary on every device class, and `schema_0` translates flat into it: +`bess/model` becomes `part_number`, `product-name` becomes `model`. So on a flat +capture that carried BESS identity, all three provisional rows would reclassify as +**identity** rather than as semantic change — including the designation, which under +flat's own names looked like it had no home. + +The general point: a re-sourced field is a migration risk only when the mapper +passes the change through. Where it absorbs the change, the delta is real in the +wire and invisible in the entity, which is the outcome §1 is asking for. + +Absorbing everything would be the wrong reading, though, and this harness should +not be mistaken for an argument to. Absorption protects stability, not value, and +v1.0 carries more data than flat did. The risk of changing a field scales with how +likely something compares it — state and telemetry drive automations and +statistics, metadata renders on a device card and essentially nothing hinges on +it. And adding a field is not the same act as changing one: a new field cannot +break an automation that never referenced it. + +"Keep the SKU in `battery.model`" was exactly such a product call, and it was +revisited: the delta document's DER identity decision retired it in favour of +speaking v1.0's vocabulary, on the reasoning that a change we schedule in a library +release beats the same change arriving unplanned during a firmware upgrade. `EXPECTED_ORPHANS` and `PROVISIONAL_DER` are +about entities that would *stop* arriving; nothing here argues against surfacing +new ones. + +A live flat panel cannot settle these either: the one available has no BESS and +no Drives. It would attest the panel and circuit rows, which is where the two +real orphans are. + +Circuits are 96% of the entity surface and are attested. That is the useful half, +and it is clean. +""" + +from __future__ import annotations + +import dataclasses +import json +from pathlib import Path +from typing import Any + +import pytest + +from span_panel_api.models import V2HomieSchema +from span_panel_api_schema_0 import SchemaZeroAdapter +from span_panel_api_schema_1 import SchemaOneAdapter + +_FIXTURES = Path(__file__).parent / "fixtures" +_FLAT = _FIXTURES / "flat_wire.json" +_PC = Path(__file__).parent.parent / "packages" / "schema-1" / "spec" / "fixtures" / "simulator_wire.json" +_SERIAL = "sim-40t-001" + +EXPECTED_ORPHANS: dict[str, str] = { + # `pv.relative_position` was here until 2026-08-10, and it was right that closing it + # was cheap. v1.0 retired the property deliberately -- the enclosure model says the + # position "is derivable from which enclosure-side connection-owner references the + # DER" -- so `resolve_relative_position` reads the connection records instead. + # Verified against the pair: flat says IN_PANEL for PV and UPSTREAM for the BESS, and + # the derivation produces exactly those. + # + # `panel.dominant_power_source` was here until 2026-08-10. It is populated now: the + # integration's entity for it is already named `grid_forming_entity`, so v1.0's + # `grid/grid-forming-entity` is the same concept, and dereferencing the device id + # against the tree recovers flat's source-class enum. The precision v1.0 adds -- + # *which* device -- is surfaced beside it as `mid.grid_forming_device_name` rather + # than inside it, so no automation meets a value it has never seen. + "panel.grid_islandable": ( + "no v1.0 source; the flat panel advertised islandability as a panel property and " + "the redesign expresses it through the presence of a MID instead" + ), +} + +EXPECTED_DEGRADED: dict[str, str] = {} +"""Empty as of 2026-08-10, and that is a measurement rather than a default. + +Both members were `panel.dsm_state` and `panel.current_run_config`, reading UNKNOWN on +v1.0 where flat answered. Neither was a source that vanished: flat *derives* them, and +the derivation was simply never ported. v1.0 states the answer on the MID, so they are +now read rather than derived and carry the same values a user has today — +`DSM_ON_GRID` / `PANEL_ON_GRID` on the tracked capture. + +A degraded field is worse for a user than an orphan: an orphan goes stale and is +noticeable, while `UNKNOWN` reads as a working sensor that does not know. Zero is the +state worth defending, so the dict stays and the check below holds it. +""" + +ATTESTED_AGAINST_FIRMWARE: dict[str, str] = { + "pv.model": ( + "classified an addition here only because the frozen simulator never sends " + "pv/product-name. A capture from real flat firmware does send it, so this is an " + "IDENTITY — the entity exists today and survives the migration. Measured by " + "test_live_flat_differential.py; the simulator gap is recorded there as KNOWN_GAPS. " + "Was pv.product_name until the 2026-08-10 identity normalisation; the field it " + "names is the same one, reached by the same flat property" + ), +} +"""Rows the mechanical diff gets wrong, corrected by a capture from real firmware. + +The classification can only see what its flat reference sends, so a simulator gap +reads as a v1.0 addition. This is where Phase 3b pays for itself: one row moved +from *addition* to *identity* on evidence, and it moved in the direction that +matters — a field we thought was new turns out to be one users already have. +""" + +NEW_IN_V1_0: dict[str, str] = { + "battery.power_w": ( + "the BESS's own charge/discharge meter. `energy.ebus.capability.meter` on a " + "BESS device is new in v1.0 -- flat's `energy.ebus.device.bess` type declares no " + "`active-power` at all -- so nothing can orphan and no entity changes meaning. " + "The nearest flat figure is the enclosure's `power-flows/battery`, which both " + "schemas carry unchanged as `panel.power_flow_battery` and which is a different " + "property in the opposite sign frame" + ), + "battery.communication_state": ( + "the BESS publisher's report of its own link health. Flat's BESS type declares " + "`connected` and nothing else about the link, and `battery.connected` still " + "carries that -- from the enclosure's `connection/fed-by-device-status`, the " + "panel's view rather than the device's. Two views of one link, and v1.0 is the " + "first schema to publish the second" + ), + "pv.connected": ( + "the enclosure's view of the link to the PV, from the feeding circuit's " + "`connection/feeds-device-status`. Flat's `energy.ebus.device.pv` type declares " + "no link property at all -- flat publishes `connected` on the BESS and nowhere " + "else -- so nothing can orphan and no entity changes meaning. v1.0 is the first " + "schema in which the enclosure says anything about the PV link" + ), +} +"""Additions with no flat property to have been re-sourced from. + +The bucket `PROVISIONAL_DER` is *not*: its members each have a flat property that +the frozen simulator happens not to send, so a real flat capture could reclassify +them as identity. These have no flat property in the schema at all, so no capture +can. `test_the_two_addition_buckets_are_told_apart_mechanically` asserts exactly +that distinction against `schema_0`'s field map rather than trusting this prose. + +A genuine addition is the benign kind of delta -- a new field cannot break an +automation that never referenced it -- but it still has to be *named*, or the +addition bucket becomes the place a surviving entity hides. +""" + +PROVISIONAL_DER: frozenset[str] = frozenset( + { + "battery.model", + "battery.part_number", + "battery.serial_number", + "battery.software_version", + "pv.software_version", + } +) +"""Additions that may not be additions, because the flat reference never sends them. + +Each is classified `addition` only because the frozen flat simulator publishes no +BESS identity and no PV identity beyond `vendor-name`. This is narrower than "DER +is unverified": the simulator models both devices and the integration renders +their telemetry correctly against it, so `soc`, `soe`, `connected` and the rest +are attested. These five are the fields nothing sends and therefore nothing can +vouch for. + +`pv.software_version` joined on 2026-08-20, when panelbench started valuing the PV's +`info/firmware-version`. It belongs here rather than in `NEW_IN_V1_0` on a fact, not +a judgement: flat's `energy.ebus.device.pv` type declares `software-version` — the +captured `GET /api/v2/homie/schema` response says so, and `test_schema_provenance.py` +holds that against the panel — so a flat panel whose inverter reports its firmware +would publish it and this would be identity. Neither the frozen simulator nor the one +live panel available values it, so nothing here can vouch for it yet. `schema_0` had +no mapping row for the property at all until then, which is the gap +`test_the_two_addition_buckets_are_told_apart_mechanically` exists to expose. + +Expect this set to shrink toward **identity**, not toward semantic change, and after +the 2026-08-10 identity normalisation that is now true of every member. Both adapters +speak v1.0's vocabulary — `schema_0` translates flat's `bess/model` to `part_number` +and its `product-name` to `model` — so a flat capture carrying BESS identity would +move all four battery rows into the identity bucket at once. Before the normalisation the two +`product_name` entries looked like genuine additions, because the designation had no +flat home under flat's own names; it does under these. + +Resolving this needs a capture from flat firmware with a BESS attached, which no +available panel has. +""" + + +def _flat_schema(panel_size: int = 40) -> V2HomieSchema: + """No `data_model_version`: its absence is what marks a payload as flat.""" + return V2HomieSchema( + firmware_version="spanos2/r202627/01", + types_schema_hash="sha256:flat-capture", + types={ + "energy.ebus.device.circuit": { + "space": {"datatype": "integer", "format": f"1:{panel_size}:1"}, + }, + }, + ) + + +def _pc_schema() -> V2HomieSchema: + return V2HomieSchema( + firmware_version="spanos2/r202633/01", + types_schema_hash="sha256:pc-capture", + types={}, + data_model_version="1.0", + ) + + +def _feed(adapter: Any, capture_path: Path) -> Any: + """Replay a capture the way the retained store does: sorted, one at a time.""" + capture = json.loads(capture_path.read_text()) + for device in sorted(capture): + for key in sorted(capture[device]): + adapter.handle_message(f"ebus/5/{device}/{key}", capture[device][key]) + return adapter + + +@pytest.fixture(scope="module") +def flat() -> Any: + return _feed(SchemaZeroAdapter(serial_number=_SERIAL, schema=_flat_schema()), _FLAT).build_snapshot() + + +@pytest.fixture(scope="module") +def parent_child() -> Any: + return _feed(SchemaOneAdapter(serial_number=_SERIAL, schema=_pc_schema()), _PC).build_snapshot() + + +_SENTINEL = "UNKNOWN" +"""A value that occupies a field without informing it. + +Found by falsifying the differential in `test_live_flat_differential.py`: deleting +`core/door` from a capture changed nothing, because `door_state` falls back to +`UNKNOWN` rather than to `None`. A population diff cannot see a field degrade that +way, so a field that stopped being published would classify as *identity* — the +safest bucket — while a user sees a permanently useless entity. +""" + + +def _populated(obj: Any) -> set[str]: + if obj is None: + return set() + return {f.name for f in dataclasses.fields(obj) if getattr(obj, f.name) is not None} + + +def _degraded(before: Any, after: Any) -> set[str]: + """Fields carrying a real value on flat and only a sentinel on v1.0. + + A fourth bucket rather than folded into orphans, because `UNKNOWN` is a legal + state for several of these enums. What makes it a delta is the *transition*: + the flat panel answered and the v1.0 panel does not. + """ + if before is None or after is None: + return set() + return { + f.name + for f in dataclasses.fields(after) + if getattr(after, f.name) == _SENTINEL and getattr(before, f.name, None) not in (None, _SENTINEL) + } + + +def _classify(scope: str, flat_obj: Any, pc_obj: Any) -> tuple[set[str], set[str]]: + """Returns (additions, orphans) as dotted `scope.field` names.""" + before, after = _populated(flat_obj), _populated(pc_obj) + return ( + {f"{scope}.{name}" for name in after - before}, + {f"{scope}.{name}" for name in before - after}, + ) + + +def test_both_captures_describe_the_same_logical_panel(flat: Any, parent_child: Any) -> None: + """The premise. Without it every difference below is ambiguous between a + migration delta and two simulators being configured differently.""" + assert flat.serial_number == parent_child.serial_number == _SERIAL + assert len(flat.circuits) == len(parent_child.circuits) + # Count, not keys. Whether the EVSE keys match across the migration is a finding, + # not a premise — putting it here would make a real regression read as a broken + # fixture. `test_evse_identity_survives_the_migration` holds it instead. + assert len(flat.evse) == len(parent_child.evse) + + +def test_every_circuit_keeps_its_identity_across_the_migration(flat: Any, parent_child: Any) -> None: + """The single fact that decides whether history survives. + + `unique_id` is circuit-UUID-derived, so identical UUIDs on both sides mean the + registry keeps the same `entity_id`, `statistic_id` is unchanged, and + long-term statistics stay continuous. A UUID that moved would orphan a + circuit's entire history — 32 circuits' worth, silently. + """ + assert set(flat.circuits) == set( + parent_child.circuits + ), "circuit identities diverge across the migration; every non-matching circuit loses its recorder history" + + +def test_evse_identity_survives_the_migration(flat: Any, parent_child: Any) -> None: + """The circuit test's answer, for the other device class that carries an identity. + + An EVSE entity's `unique_id` and its device-registry `identifiers` are both built + from what this library hands over -- the snapshot key and `node_id` -- so if those + move between schemas, a user's charger orphans and a duplicate appears beside it. + + **The comparison is against firmware, not against a simulator convention.** On a + real panel the EVSE node id *is* the Drive's serial: SpanPanel/span#214 has the + topic `ebus/5//`, diagnostics keyed + `"evse": {"": ...}`, and a maintainer confirming that node id is what + the `unique_id` is built from. + + The flat simulator used to name those nodes `evse` / `evse-2`, positional slots no + panel publishes, so this test had to compare flat's *serials* against v1.0's keys + and take on faith that firmware would key on the same string. Flat 1.0.16 closed + that gap -- an EVSE's node id is now its drive serial there too -- so the first + assertion below states the fact rather than assuming it, and the second compares + the two key sets directly. Both sides now name the drive the way firmware does. + + The same change forced the serial lower-case, because a node id is a topic level + and Homie 5 allows only `a`-`z`, `0`-`9` and `-` there. `SIM-EVSE-...` was legal as + a property value and illegal as an id. Both producers followed; the capture on this + side was re-taken from flat 1.0.16 to match, which is what makes the comparison + below one between two current producers rather than between a current one and a + stale byte copy. + """ + flat_identity = {evse.serial_number for evse in flat.evse.values()} + assert None not in flat_identity, "a flat EVSE published no serial to key on" + + assert set(flat.evse) == flat_identity, ( + f"flat keys its EVSEs {sorted(flat.evse)} and serials them {sorted(flat_identity)}. " + "Since flat 1.0.16 the node id is the drive serial, as it is on firmware; if these " + "have come apart the flat capture predates that and the comparison below is " + "measuring a simulator convention rather than an identity." + ) + + assert set(parent_child.evse) == flat_identity, ( + "v1.0 EVSE keys do not match the serials flat publishes. On real firmware the " + "flat node id is that serial, so a mismatch here is a charger that orphans its " + "history and returns as a new device." + ) + + for key, evse in parent_child.evse.items(): + assert evse.node_id == key, ( + f"{key}: node_id drives the device-registry identifier and must match the " f"snapshot key, got {evse.node_id!r}" + ) + + assert {evse.feed_circuit_id for evse in flat.evse.values()} == { + evse.feed_circuit_id for evse in parent_child.evse.values() + }, "the two captures feed their EVSEs from different circuits, so they are not the same panel" + + +def test_der_identity_reads_the_same_on_both_adapters(flat: Any, parent_child: Any) -> None: + """The point of the identity normalisation, measured on the one DER that can show it. + + Both adapters now speak v1.0's vocabulary -- `model` is the human designation and + `part_number` the SKU, on every device class -- with `schema_0` translating flat's + irregular naming rather than mirroring it. When both produce the same field *and* the + same value, DER identity stops being a migration delta at all. + + The EVSE is the only device class that can demonstrate it: flat publishes full + identity for it, none at all for the BESS, and `vendor-name` alone for PV. Those gaps + are the frozen simulator's, recorded in `PROVISIONAL_DER`, not the mapping's. + + Worth being explicit that this is the *library upgrade* being made a no-op, not the + firmware migration. `battery.model` does change value for existing flat users when + they take this release -- it gains the designation where it carried the SKU. That is + the deliberate trade: one change we schedule beats the same change arriving unplanned + during a firmware upgrade. + """ + flat_evse = sorted(flat.evse.values(), key=lambda e: e.serial_number or "") + pc_evse = sorted(parent_child.evse.values(), key=lambda e: e.serial_number or "") + assert len(flat_evse) == len(pc_evse) > 0 + + for before, after in zip(flat_evse, pc_evse, strict=True): + for field in ("model", "part_number", "vendor_name", "serial_number", "software_version"): + assert getattr(before, field) == getattr(after, field), ( + f"evse.{field} differs across the migration: " f"{getattr(before, field)!r} -> {getattr(after, field)!r}" + ) + + +def test_no_circuit_field_is_orphaned(flat: Any, parent_child: Any) -> None: + """Circuits are 96% of the entity surface and the attested part of the flat + reference, so this is the strongest claim the harness can make.""" + orphans: set[str] = set() + for circuit_id in sorted(set(flat.circuits) & set(parent_child.circuits)): + _, found = _classify("circuit", flat.circuits[circuit_id], parent_child.circuits[circuit_id]) + orphans |= found + + assert not orphans, f"circuit fields that stop being published after the migration: {sorted(orphans)}" + + +def test_every_orphan_is_a_decision_someone_made(flat: Any, parent_child: Any) -> None: + """Phase 3's exit criterion: zero unclassified orphans. + + An unexpected entry here is a user-visible regression — an entity that exists + today, keeps its name, and stops updating. + """ + orphans: set[str] = set() + for scope, before, after in ( + ("panel", flat, parent_child), + ("battery", flat.battery, parent_child.battery), + ("pv", flat.pv, parent_child.pv), + ): + _, found = _classify(scope, before, after) + orphans |= found + + unexplained = sorted(orphans - set(EXPECTED_ORPHANS)) + assert ( + not unexplained + ), "these fields are populated on flat and absent on v1.0, and nobody decided that:\n " + "\n ".join(unexplained) + + stale = sorted(set(EXPECTED_ORPHANS) - orphans) + assert ( + not stale + ), f"these are recorded as orphans but no longer are; delete them so the list keeps meaning something: {stale}" + + +def test_every_degraded_field_is_a_known_one(flat: Any, parent_child: Any) -> None: + """Fields that survive as entities but stop carrying an answer. + + Invisible to the population diff above — the entity exists and holds a string, + so nothing looks wrong — which is why this is separate. To a user it is worse + than an orphan: an orphan goes stale and is noticeable, while `UNKNOWN` looks + like a working sensor reporting that it does not know. + """ + degraded: set[str] = set() + for scope, before, after in ( + ("panel", flat, parent_child), + ("battery", flat.battery, parent_child.battery), + ("pv", flat.pv, parent_child.pv), + ): + degraded |= {f"{scope}.{name}" for name in _degraded(before, after)} + + assert degraded == set(EXPECTED_DEGRADED), ( + f"the set of fields that answer on flat and read {_SENTINEL!r} on v1.0 moved: " + f"{sorted(degraded)}. Both known members have a reconstruction recorded in the " + "delta document; a new one is a regression." + ) + + +def test_der_additions_are_provisional_or_attested_but_never_unexamined(flat: Any, parent_child: Any) -> None: + """Every DER addition is accounted for as one of exactly two things. + + Either the flat reference cannot vouch for it (`PROVISIONAL_DER`, because the + frozen simulator never sends BESS identity), or a capture from real firmware + has settled it (`ATTESTED_AGAINST_FIRMWARE`, which is how `pv.product_name` + turned out to be an identity users already have rather than something new). + + A third option — an addition in neither list — means one appeared and nobody + asked which it was. + """ + additions: set[str] = set() + for scope, before, after in ( + ("battery", flat.battery, parent_child.battery), + ("pv", flat.pv, parent_child.pv), + ): + found, _ = _classify(scope, before, after) + additions |= found + + accounted = set(PROVISIONAL_DER) | set(ATTESTED_AGAINST_FIRMWARE) | set(NEW_IN_V1_0) + assert additions == accounted, ( + f"the DER addition set moved: {sorted(additions)}. Each member is either a field " + "the frozen simulator cannot vouch for, one real firmware has settled, or one v1.0 " + "introduces with no flat property behind it; a new one needs deciding which, " + "because 'addition' is the bucket that hides a surviving entity." + ) + + +def test_the_two_addition_buckets_are_told_apart_mechanically() -> None: + """`PROVISIONAL_DER` and `NEW_IN_V1_0` differ by a fact, not by a judgement. + + A provisional addition has a flat property behind it that the frozen simulator + does not publish, so a capture from real flat firmware could still move it to + *identity*. A v1.0 addition has no flat property at all, so no capture ever + will. `schema_0`'s `_PROPERTY_FIELD_MAP` is where that difference is recorded: + it holds a row for every flat property the mapper reads, whatever any given + capture contains. + + Asserted rather than described, because the whole value of splitting the + bucket is that membership is checkable. Put a genuinely new field in + `PROVISIONAL_DER` and this fails, which is the direction that matters -- + provisional means "expect this to become identity", and a field flat cannot + express is never going to. + """ + from span_panel_api_schema_0.field_metadata import _PROPERTY_FIELD_MAP + + flat_fields = {field_path for _, _, field_path in _PROPERTY_FIELD_MAP} + + unreachable = sorted(path for path in PROVISIONAL_DER if path not in flat_fields) + assert not unreachable, ( + f"provisional additions with no flat property behind them: {unreachable}. " + "Nothing can reclassify these as identity; they belong in NEW_IN_V1_0." + ) + + reachable = sorted(path for path in NEW_IN_V1_0 if path in flat_fields) + assert not reachable, ( + f"v1.0 additions that flat does have a property for: {reachable}. A flat " + "capture carrying it would make this an identity, so it is provisional, not new." + ) + + +def test_the_flat_reference_publishes_no_bess_identity() -> None: + """Why the set above is provisional, asserted rather than described. + + Reads the capture directly. If the flat simulator ever gains BESS identity, + this fails and the provisional set can be re-derived against something real. + """ + body = json.loads(_FLAT.read_text())[_SERIAL] + identity = sorted( + key + for key in body + if key.startswith("bess/") and key.split("/", 1)[1] in {"model", "product-name", "serial-number", "software-version"} + ) + + assert not identity, ( + f"the flat simulator now publishes BESS identity ({identity}); re-derive " + "PROVISIONAL_DER against it instead of assuming those fields are additions" + ) diff --git a/tests/test_schema_one_adapter.py b/tests/test_schema_one_adapter.py new file mode 100644 index 0000000..23e416b --- /dev/null +++ b/tests/test_schema_one_adapter.py @@ -0,0 +1,472 @@ +"""The parent/child adapter behind the SchemaAdapter protocol. + +Driven by replaying the captured tree through `handle_message`, which is exactly +how the transport feeds it — so these exercise the SDK's real discovery path +(root ready, then each child) rather than a stubbed tree. +""" + +from __future__ import annotations + +import json + +import pytest + +from span_panel_api.adapters import _derive_required_members +from span_panel_api.models import V2HomieSchema +from span_panel_api.protocol import SchemaAdapter +from span_panel_api_schema_1 import SchemaOneAdapter +from span_panel_api_schema_1.reference_payloads import parent_child_tree + +_TREE = parent_child_tree() + +PANEL = "example-40t-001" +SOLAR_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" + + +def _schema() -> V2HomieSchema: + return V2HomieSchema( + firmware_version="spanos2/r202633/01", + types_schema_hash="sha256:test", + types={}, + data_model_version="1.0", + ) + + +def _feed(adapter: SchemaOneAdapter, device_ids: list[str] | None = None, omit: tuple[str, ...] = ()) -> None: + """Replay retained topics the way the broker would deliver them. + + The panel first by default, which is the friendly order — the SDK gates a + child's subscription on its parent reaching `ready`, so anything earlier + has no route yet. The transport holds those messages until the route + appears; `test_children_before_the_panel` covers the unfriendly order, + which a broker is equally entitled to replay. + """ + for device_id in device_ids or [PANEL, *[d for d in _TREE if d != PANEL]]: + topics = _TREE[device_id] + prefix = f"ebus/5/{device_id}" + adapter.handle_message(f"{prefix}/$description", topics["$description"]) + adapter.handle_message(f"{prefix}/$state", topics["$state"]) + for topic, value in topics.items(): + if not topic.startswith("$") and topic not in omit: + adapter.handle_message(f"{prefix}/{topic}", value) + + +@pytest.fixture(name="adapter") +def _adapter() -> SchemaOneAdapter: + adapter = SchemaOneAdapter(PANEL, _schema()) + _feed(adapter) + return adapter + + +def test_it_satisfies_the_schema_adapter_protocol() -> None: + missing = [m for m in _derive_required_members(SchemaAdapter) if not hasattr(SchemaOneAdapter, m)] + + assert missing == [] + assert SchemaOneAdapter.schema_major == "schema_1" + assert SchemaOneAdapter.SUPPORTS_DATA_MODEL_VERSIONS == (">=1.0", "<2.0") + + +def test_construction_touches_no_connection() -> None: + """The transport builds a parser before a connection exists, so this must + work with nothing to talk to.""" + adapter = SchemaOneAdapter(PANEL, _schema()) + + assert adapter.is_ready() is False + + +def test_one_broad_subscription_covers_the_whole_tree() -> None: + """Children are peers of the panel in the topic tree, so the wildcard spans + devices. The adapter is asked this once and cannot add more later.""" + assert SchemaOneAdapter(PANEL, _schema()).topics_to_subscribe() == ["ebus/5/#"] + + +def test_replaying_the_tree_makes_it_ready(adapter: SchemaOneAdapter) -> None: + assert adapter.is_ready() is True + + +def test_children_before_the_panel_still_yields_the_whole_tree(adapter: SchemaOneAdapter) -> None: + """A broker replays its retained store in whatever order it likes. + + Found by the live reconnect check, not by review: seeded in this order the + panel parsed as ready with zero circuits — a complete, silent loss that + reported itself as a healthy connection. + """ + reversed_order = [*[d for d in _TREE if d != PANEL], PANEL] + late = SchemaOneAdapter(PANEL, _schema()) + _feed(late, reversed_order) + + assert late.is_ready() is True + assert _fingerprint(late) == _fingerprint(adapter) + + +def _fingerprint(adapter: SchemaOneAdapter) -> tuple[str, int, int, list[str]]: + snapshot = adapter.build_snapshot() + return ( + snapshot.serial_number, + snapshot.panel_size, + len(snapshot.circuits), + sorted(snapshot.evse), + ) + + +def test_a_panel_that_never_becomes_ready_is_not_ready() -> None: + """The SDK gates child subscription on the parent's ready edge, so a + non-ready panel yields nothing — silently, which is why it is asserted.""" + adapter = SchemaOneAdapter(PANEL, _schema()) + prefix = f"ebus/5/{PANEL}" + adapter.handle_message(f"{prefix}/$description", _TREE[PANEL]["$description"]) + adapter.handle_message(f"{prefix}/$state", "disconnected") + + assert adapter.is_ready() is False + + +def test_a_root_whose_children_are_still_arriving_is_not_ready() -> None: + """The root reaches ready as soon as *its own* description lands. + + Trusting that hands the transport a panel with a few circuits and no model + — which it reports as a healthy connection. Found by the live reconnect + check: the first connect parsed 4 of 37 circuits and nothing said so. + """ + adapter = SchemaOneAdapter(PANEL, _schema()) + _feed(adapter, [PANEL]) + + assert adapter.is_ready() is False + + +def test_readiness_goes_back_to_false_when_the_panel_declares_a_new_child(adapter: SchemaOneAdapter) -> None: + """Readiness is a reconciling predicate, not a barrier that latches. + + A Homie tree grows out of band: commission a circuit and the panel + republishes a `$description` naming a child nobody has heard from. The + common consumer defect is to treat the first ready as settled and stop + reconciling, so the new device is never seen — a failure that moves from + startup to steady state, which makes it harder to find rather than less + real. `ebus-sdk`'s `doc/consuming-a-homie-tree.md` names it the one-shot + barrier. + + The transport consults `is_ready()` on every snapshot, so this must fall + back to False and recover once the newcomer describes itself. + """ + assert adapter.is_ready() is True + + description = json.loads(_TREE[PANEL]["$description"]) + description["children"] = [*description.get("children", []), "circuit-38"] + adapter.handle_message(f"ebus/5/{PANEL}/$description", json.dumps(description)) + + assert adapter.is_ready() is False, "a declared but unheard-of child left readiness latched True" + + adapter.handle_message( + "ebus/5/circuit-38/$description", + json.dumps({"homie": "5.0", "name": "New circuit", "type": "energy.ebus.device.circuit", "nodes": {}}), + ) + adapter.handle_message("ebus/5/circuit-38/$state", "ready") + + assert adapter.is_ready() is True, "readiness did not recover once the new child described itself" + + +def test_an_offline_child_does_not_block_readiness(adapter: SchemaOneAdapter) -> None: + """A commissioned DER that is unplugged publishes `lost` but keeps its + retained description. A panel must not fail to connect over it.""" + adapter.handle_message("ebus/5/bess/$state", "lost") + + assert adapter.is_ready() is True + + +def test_readiness_waits_for_the_model_the_panel_declared() -> None: + """Panel size comes from nowhere else, and a snapshot built a moment early + reports zero spaces — which erases every unmapped position rather than + mis-stating a number.""" + adapter = SchemaOneAdapter(PANEL, _schema()) + _feed(adapter, omit=("info/model",)) + + assert adapter.is_ready() is False + + adapter.handle_message(f"ebus/5/{PANEL}/info/model", _TREE[PANEL]["info/model"]) + + assert adapter.is_ready() is True + assert adapter.build_snapshot().panel_size == 40 + + +def test_a_panel_that_declares_no_model_still_connects() -> None: + """Waiting for a property the firmware never promised would make one + missing field fatal. The drift warning already covers the consequence.""" + description = json.loads(_TREE[PANEL]["$description"]) + del description["nodes"]["info"]["properties"]["model"] + adapter = SchemaOneAdapter(PANEL, _schema()) + adapter.handle_message(f"ebus/5/{PANEL}/$description", json.dumps(description)) + adapter.handle_message(f"ebus/5/{PANEL}/$state", _TREE[PANEL]["$state"]) + _feed(adapter, [d for d in _TREE if d != PANEL]) + + assert adapter.is_ready() is True + assert adapter.build_snapshot().panel_size == 0 + + +def test_snapshot_is_built_from_the_discovered_tree(adapter: SchemaOneAdapter) -> None: + snapshot = adapter.build_snapshot() + + assert snapshot.serial_number == PANEL + assert snapshot.panel_size == 40 + assert snapshot.circuits[SOLAR_CIRCUIT].name == "Solar Inverter" + assert snapshot.battery.soe_percentage == pytest.approx(50.4104, rel=1e-4) + # 5 circuits occupying 8 positions (two are multi-pole), so 32 remain. + assert len(snapshot.circuits) == 37 + + +def test_building_a_snapshot_before_discovery_fails_loudly() -> None: + adapter = SchemaOneAdapter(PANEL, _schema()) + + with pytest.raises(RuntimeError, match="not ready"): + adapter.build_snapshot() + + +# --------------------------------------------------------------------------- +# Field metadata +# --------------------------------------------------------------------------- + + +def test_field_metadata_takes_units_from_the_tree(adapter: SchemaOneAdapter) -> None: + metadata = adapter.build_field_metadata() + + assert metadata["circuit.instant_power_w"].unit == "W" + assert metadata["circuit.instant_power_w"].datatype == "float" + assert metadata["circuit.current_a"].unit == "A" + assert metadata["panel.l1_voltage"].unit == "V" + assert metadata["battery.soe_percentage"].unit == "%" + # The BESS's own meter and status nodes, mapped so the pair gets unit and + # datatype validation and the resolved/unresolved signal. The row describes + # the property; the sign flip `build_battery` applies is not a unit change. + assert metadata["battery.power_w"].unit == "W" + assert metadata["battery.power_w"].datatype == "float" + assert metadata["battery.communication_state"].unit is None + assert metadata["battery.communication_state"].datatype == "enum" + + +def test_no_property_declares_an_abstract_unit() -> None: + """Units must reach Home Assistant renderable, not as a catalog token. + + eBus catalogs may carry an abstract `unit: "energy"` rather than a concrete + one, which a device resolves in its own `$description`. Reading the runtime + description is what keeps us clear of it — but only as long as the panel + resolves it too, and the symptom if it stops is an entity whose unit reads + the literal string. Asserted against the captured tree for the same reason + the flat adapter asserts its schema facts: a silent absence needs a signal + that does not depend on anyone noticing it. + """ + abstract = {"energy", "power", "current", "voltage"} + declared = { + properties.get("unit") + for device in _TREE.values() + for node in json.loads(device["$description"]).get("nodes", {}).values() + for properties in node.get("properties", {}).values() + } + + assert not declared & abstract, f"abstract unit tokens in the captured tree: {sorted(declared & abstract)}" + + +def test_the_downstream_lugs_fields_carry_metadata(adapter: SchemaOneAdapter) -> None: + """Five fields were populated with no metadata behind them until 2026-08-08. + + `_PROPERTY_FIELD_MAP` keys on (device type, node, property), and the two lugs + devices match on all three — same `energy.ebus.device.lugs`, same `meter` + node, same properties — so one row per property is all it can hold, and those + rows went to the `upstream_*` paths. The snapshot mapper never had the problem + because it resolves the pair by `info/direction`. + + The consequence was not a wrong value but an absent guard: + `schema_validation.py` cross-checks the integration's declared unit against + this table, so five sensors had nothing to check against — and they are the + feedthrough readings, which the lugs fidelity gap already makes the least + testable part of the surface. + """ + metadata = adapter.build_field_metadata() + + assert metadata["panel.feedthrough_power_w"].unit == "W" + assert metadata["panel.feedthrough_energy_consumed_wh"].unit == "Wh" + assert metadata["panel.feedthrough_energy_produced_wh"].unit == "Wh" + assert metadata["panel.downstream_l1_current_a"].unit == "A" + assert metadata["panel.downstream_l2_current_a"].unit == "A" + + +def test_the_upstream_lugs_fields_are_not_displaced(adapter: SchemaOneAdapter) -> None: + """Held separately because the two lugs resolve through different paths now. + + The upstream fields come from the table; the downstream ones from a + direction-resolved lookup layered over it. A change that made the second + overwrite the first would leave both sets present and both describing the + same device, which reads as working. + """ + metadata = adapter.build_field_metadata() + + assert metadata["panel.upstream_l1_current_a"].unit == "A" + assert metadata["panel.upstream_l2_current_a"].unit == "A" + assert metadata["panel.instant_grid_power_w"].unit == "W" + + +def test_both_bess_identity_fields_are_described(adapter: SchemaOneAdapter) -> None: + """Class B of the survival analysis, which was misdiagnosed and is now closed. + + It recorded `battery.serial_number` and `battery.software_version` as having "no + mapping at all… simply never picked up". `build_battery` has always read both. What + was missing was the *value*: the producer published no BESS identity, so both read + `None`, and an unpopulated field was mistaken for an unmapped one. + + `serial_number` got its row when the producer started publishing `info/serial-number`. + `software_version` was deliberately withheld while the BESS declared + `info/firmware-version` and never sent it — describing it would have advertised a + reading that never arrives, the one thing the metadata builder's docstring refuses. + + panelbench now supplies a placeholder firmware version, so the declaration is no + longer empty and the row is honest. Synthetic: it attests the mapping, not what real + firmware sends, which the delta document records rather than letting a config change + launder into a fidelity claim. + """ + metadata = adapter.build_field_metadata() + + assert metadata["battery.serial_number"].datatype == "string" + assert metadata["battery.serial_number"].unit is None + assert metadata["battery.software_version"].datatype == "string" + assert metadata["battery.software_version"].unit is None + + +def test_the_downstream_fields_need_a_downstream_device() -> None: + """The strongest available check, and worth saying why it is not stronger. + + **Which** lugs device the lookup resolves cannot be asserted here. Both + declare byte-identical `meter` metadata — same five properties, same units — + so swapping `upstream=False` for `upstream=True` produces exactly the same + result, verified by mutation. That is the fidelity gap §5.3 of the survival + analysis records, reappearing one layer up: with the two devices + indistinguishable, a correct resolution and a swapped one are the same + output. + + What *is* checkable is that these fields come from a resolved device at all + rather than leaking out of the table. Feed a tree with no downstream lugs and + they must be absent, while the upstream fields survive untouched. + """ + adapter = SchemaOneAdapter(PANEL, _schema()) + _feed(adapter, device_ids=[device for device in _TREE if device != "lugs-downstream"]) + + metadata = adapter.build_field_metadata() + + for absent in ( + "panel.feedthrough_power_w", + "panel.feedthrough_energy_consumed_wh", + "panel.feedthrough_energy_produced_wh", + "panel.downstream_l1_current_a", + "panel.downstream_l2_current_a", + ): + assert absent not in metadata, f"{absent} was described with no device to describe" + + assert metadata["panel.upstream_l1_current_a"].unit == "A" + + +def test_field_metadata_omits_fields_the_mapper_declines(adapter: SchemaOneAdapter) -> None: + """Advertising a unit for a reading that never arrives would have the + integration validate against a field nothing populates.""" + metadata = adapter.build_field_metadata() + + assert "panel.dominant_power_source" not in metadata + assert "panel.grid_islandable" not in metadata + assert "pv.relative_position" not in metadata + + +def test_field_metadata_is_empty_before_discovery() -> None: + assert SchemaOneAdapter(PANEL, _schema()).build_field_metadata() == {} + + +# --------------------------------------------------------------------------- +# Commands — the adapter names the topic, the transport publishes it +# --------------------------------------------------------------------------- + + +def test_command_topics_address_the_child_device(adapter: SchemaOneAdapter) -> None: + """Under parent/child a circuit is its own device, so its command topic is + rooted at the circuit rather than nested under the panel.""" + assert adapter.set_circuit_relay_topic(SOLAR_CIRCUIT) == f"ebus/5/{SOLAR_CIRCUIT}/switch/relay/set" + assert adapter.set_circuit_priority_topic(SOLAR_CIRCUIT) == f"ebus/5/{SOLAR_CIRCUIT}/load-shed/priority/set" + + +def test_dominant_power_source_writes_the_panel_assertion(adapter: SchemaOneAdapter) -> None: + """It split in two; this is the settable half, on the panel's shed node. + + Returned None until 2026-08-08, which left a real capability unreachable: + comms to the BESS drop, the grid returns, and the user has no way to assert + that it is up so the BESS stops discharging. The panel offered the control + the whole time — `shed/asserted-islanding-state`, `settable=True` — and the + adapter simply never named it. + """ + assert adapter.set_dominant_power_source_topic() == f"ebus/5/{PANEL}/shed/asserted-islanding-state/set" + + +def test_the_flat_vocabulary_is_translated_not_forwarded(adapter: SchemaOneAdapter) -> None: + """The published protocol speaks flat's enum; the panel accepts a different one. + + Forwarding the caller's string would publish a value outside + `NONE,ON_GRID,OFF_GRID` and the panel would reject it. The narrowing loses + nothing real: six *source classes* were pressed into service as a manual + override, and the override only ever needed on-grid, off-grid, or nothing. + """ + assert adapter.dominant_power_source_payload("GRID") == "ON_GRID" + + for off_grid in ("BATTERY", "PV", "GENERATOR"): + assert adapter.dominant_power_source_payload(off_grid) == "OFF_GRID", off_grid + + for no_assertion in ("NONE", "UNKNOWN"): + assert adapter.dominant_power_source_payload(no_assertion) == "NONE", no_assertion + + +def test_an_unrecognised_value_is_refused_rather_than_guessed(adapter: SchemaOneAdapter) -> None: + """None means "no legal representation", and the transport raises on it. + + Asserting an islanding state the user did not ask for is worse than refusing + the command, because this control tells a BESS whether to keep discharging. + """ + assert adapter.dominant_power_source_payload("SOLAR") is None + assert adapter.dominant_power_source_payload("") is None + + +# --------------------------------------------------------------------------- +# Discovery helpers the transport uses +# --------------------------------------------------------------------------- + + +def test_circuits_missing_names_is_empty_once_retained_names_arrive(adapter: SchemaOneAdapter) -> None: + assert adapter.circuit_nodes_missing_names() == [] + + +def test_a_der_missing_its_declared_model_is_reported_alongside_circuits() -> None: + """Readiness proves the tree's shape, not its labels. + + A DER's identity arrives as its own retained message, which can land after + the last description — and the integration registers an HA device from the + first snapshot, so a placeholder there is permanent until reload. + """ + adapter = SchemaOneAdapter(PANEL, _schema()) + _feed(adapter, omit=("info/model",)) + adapter.handle_message(f"ebus/5/{PANEL}/info/model", _TREE[PANEL]["info/model"]) + + assert "pv" in adapter.circuit_nodes_missing_names() + + adapter.handle_message("ebus/5/pv/info/model", _TREE["pv"]["info/model"]) + + assert "pv" not in adapter.circuit_nodes_missing_names() + + +def test_find_node_by_type_answers_with_a_device_id(adapter: SchemaOneAdapter) -> None: + assert adapter.find_node_by_type("energy.ebus.device.bess") == "bess" + assert adapter.find_node_by_type("energy.ebus.device.nonexistent") is None + + +def test_property_callbacks_receive_updates() -> None: + seen: list[tuple[str, str, str, str | None]] = [] + adapter = SchemaOneAdapter(PANEL, _schema()) + unregister = adapter.register_property_callback(lambda d, n, p, v: seen.append((d, n, p, v))) + + _feed(adapter, [PANEL]) + + assert any(node == "status" and prop == "relay" for _, node, prop, _ in seen) + + unregister() + before = len(seen) + _feed(adapter, [PANEL]) + assert len(seen) == before diff --git a/tests/test_schema_one_against_simulator.py b/tests/test_schema_one_against_simulator.py new file mode 100644 index 0000000..ea85ced --- /dev/null +++ b/tests/test_schema_one_against_simulator.py @@ -0,0 +1,260 @@ +"""Drive the parser end to end from what the simulator actually publishes. + +Every other schema_1 test runs on the tree this distribution ships as package +data, 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_charge_limit.py b/tests/test_schema_one_charge_limit.py new file mode 100644 index 0000000..f1045fa --- /dev/null +++ b/tests/test_schema_one_charge_limit.py @@ -0,0 +1,540 @@ +"""The EVSE charge-current ceiling: read from the declaration, written to it. + +The only settable property the v1.0 catch-up surfaces, and the only one whose +wire name is unsettled — the reference tree says `config/{max,user-max}-charge-current`, +the eBus catalog says `charge-limit/{installer-max,owner-limit}`, and no capture +can decide between them because the panels we can reach carry no SPAN Drive. + +So the parser is written against the *rule* rather than against either name, and +these tests hold it to that: every read expectation is computed from the captured +tree, the catalogued spelling is driven through a rewritten description and has to +behave identically, and every write assertion names the exact topic and payload +the transport puts on the wire. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import json +from unittest.mock import MagicMock + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api.exceptions import SpanPanelServerError +from span_panel_api.models import V2HomieSchema +from span_panel_api_schema_1 import SchemaOneAdapter +from span_panel_api_schema_1.charge_limit import resolve_charge_limit +from span_panel_api_schema_1.devices import build_evse +from span_panel_api_schema_1.field_metadata import build_field_metadata +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree + +_TREE = parent_child_tree() + +PANEL = "example-40t-001" +EVSE = "evse" +EVSE_2 = "evse-2" + +CEILING_TOPIC = "config/max-charge-current" +LIMIT_TOPIC = "config/user-max-charge-current" + +# The catalogued spelling, which no producer we have publishes. Written here as +# the topics a `charge-limit` charger would publish, so the rewrite below is a +# rename of the capture rather than a second hand-built tree. +CATALOG_CEILING_TOPIC = "charge-limit/installer-max" +CATALOG_LIMIT_TOPIC = "charge-limit/owner-limit" + + +def _schema() -> V2HomieSchema: + return V2HomieSchema( + firmware_version="spanos2/r202633/01", + types_schema_hash="sha256:test", + types={}, + data_model_version="1.0", + ) + + +def _published(device_id: str, topic: str) -> str: + """What the capture publishes on this topic, or fail saying it does not. + + Every expectation below is computed from this rather than written as a + literal, so a test cannot keep passing against a fixture that stopped + carrying the value it is about. + """ + value = _TREE[device_id].get(topic) + assert value is not None, f"{device_id} publishes no {topic} in the capture" + return value + + +def _tree(**overrides: Mapping[str, str | None]) -> dict[str, dict[str, str]]: + """The capture with topics rewritten per device, or removed where `None`. + + Removal is a distinct probe from rewriting: a panel that stops publishing a + property retains nothing, which is not the same event as publishing `""`. + """ + tree = {device_id: dict(topics) for device_id, topics in _TREE.items()} + for device_id, topics in overrides.items(): + for topic, value in topics.items(): + if value is None: + tree[device_id].pop(topic, None) + else: + tree[device_id][topic] = value + return tree + + +def _evse_device(tree: dict[str, dict[str, str]], device_id: str) -> DiscoveredDevice: + return device_from_topics(device_id, tree[device_id]) + + +def _snapshot_evse(tree: dict[str, dict[str, str]], device_id: str) -> object: + """One EVSE snapshot built by the real mapper from `tree`.""" + return build_evse(_evse_device(tree, device_id), {}, node_id=device_id, feed_statuses={}) + + +def _renamed_to_catalog(device_id: str) -> dict[str, dict[str, str]]: + """The capture with one charger publishing the catalogued spelling instead. + + Both halves move — the `$description` node and the value topics — because a + charger that renamed one and not the other would be publishing to a property + it never declared, which is a different (and illegal) situation from the one + under test. + """ + topics = dict(_TREE[device_id]) + description = json.loads(topics["$description"]) + config = description["nodes"].pop("config") + properties = config["properties"] + description["nodes"]["charge-limit"] = { + "name": "charge-limit", + "type": "energy.ebus.capability.charge-limit", + "properties": { + "installer-max": properties["max-charge-current"], + "owner-limit": properties["user-max-charge-current"], + }, + } + topics["$description"] = json.dumps(description) + topics[CATALOG_CEILING_TOPIC] = topics.pop(CEILING_TOPIC) + topics[CATALOG_LIMIT_TOPIC] = topics.pop(LIMIT_TOPIC) + tree = {other: dict(values) for other, values in _TREE.items()} + tree[device_id] = topics + return tree + + +def _without_settable(device_id: str) -> dict[str, dict[str, str]]: + """The capture with the limit's `$settable` attribute gone from its declaration.""" + topics = dict(_TREE[device_id]) + description = json.loads(topics["$description"]) + description["nodes"]["config"]["properties"]["user-max-charge-current"].pop("settable") + topics["$description"] = json.dumps(description) + tree = {other: dict(values) for other, values in _TREE.items()} + tree[device_id] = topics + return tree + + +def _without_node(device_id: str) -> dict[str, dict[str, str]]: + """The capture with the whole charge-limit node gone — a fixed-rate charger.""" + topics = {topic: value for topic, value in _TREE[device_id].items() if topic not in {CEILING_TOPIC, LIMIT_TOPIC}} + description = json.loads(topics["$description"]) + description["nodes"].pop("config") + topics["$description"] = json.dumps(description) + tree = {other: dict(values) for other, values in _TREE.items()} + tree[device_id] = topics + return tree + + +def _adapter(tree: dict[str, dict[str, str]] | None = None) -> SchemaOneAdapter: + """An adapter fed the tree the way the broker replays it.""" + replayed = _TREE if tree is None else tree + adapter = SchemaOneAdapter(PANEL, _schema()) + for device_id in [PANEL, *[d for d in replayed if d != PANEL]]: + topics = replayed[device_id] + prefix = f"ebus/5/{device_id}" + adapter.handle_message(f"{prefix}/$description", topics["$description"]) + adapter.handle_message(f"{prefix}/$state", topics["$state"]) + for topic, value in topics.items(): + if not topic.startswith("$"): + adapter.handle_message(f"{prefix}/{topic}", value) + return adapter + + +def _key(adapter: SchemaOneAdapter, device_id: str) -> str: + """The snapshot key for one charger — its serial, not its device id. + + Looked up rather than written down, because the difference between the two + is what the command tests are checking. + """ + snapshot = adapter.build_snapshot() + for key, evse in snapshot.evse.items(): + if evse.serial_number == _published(device_id, "info/serial-number"): + return key + raise AssertionError(f"no EVSE in the snapshot carries {device_id}'s serial") + + +# --------------------------------------------------------------------------- +# Reading — from the capture, and per charger +# --------------------------------------------------------------------------- + + +def test_both_halves_come_off_the_wire() -> None: + evse = _snapshot_evse(_TREE, EVSE) + + assert evse.charge_current_limit_a == int(_published(EVSE, LIMIT_TOPIC)) + assert evse.charge_current_ceiling_a == int(_published(EVSE, CEILING_TOPIC)) + + +def test_each_charger_reads_its_own_limit() -> None: + """Two chargers, two different values, and neither may answer for the other. + + The capture publishes 32 on both, so an assertion against it as-published + would pass for a parser that read one charger and reported it twice. The + values are made to differ first, which is the only shape of this test that + proves anything. + """ + first, second = int(_published(EVSE, LIMIT_TOPIC)) - 8, int(_published(EVSE_2, LIMIT_TOPIC)) - 16 + assert first != second + + tree = _tree(**{EVSE: {LIMIT_TOPIC: str(first)}, EVSE_2: {LIMIT_TOPIC: str(second)}}) + + assert _snapshot_evse(tree, EVSE).charge_current_limit_a == first + assert _snapshot_evse(tree, EVSE_2).charge_current_limit_a == second + + +def test_each_charger_reads_its_own_ceiling() -> None: + """The same proof for the installer ceiling, which bounds the control.""" + first, second = int(_published(EVSE, CEILING_TOPIC)) - 8, int(_published(EVSE_2, CEILING_TOPIC)) - 16 + assert first != second + + tree = _tree(**{EVSE: {CEILING_TOPIC: str(first)}, EVSE_2: {CEILING_TOPIC: str(second)}}) + + assert _snapshot_evse(tree, EVSE).charge_current_ceiling_a == first + assert _snapshot_evse(tree, EVSE_2).charge_current_ceiling_a == second + + +def test_republishing_moves_the_reading() -> None: + raised = int(_published(EVSE, LIMIT_TOPIC)) - 12 + + assert _snapshot_evse(_tree(**{EVSE: {LIMIT_TOPIC: str(raised)}}), EVSE).charge_current_limit_a == raised + + +def test_an_unpublished_value_is_none_rather_than_zero() -> None: + """A charger that has not published yet has no limit, which is not 0 A.""" + evse = _snapshot_evse(_tree(**{EVSE: {LIMIT_TOPIC: None, CEILING_TOPIC: None}}), EVSE) + + assert evse.charge_current_limit_a is None + assert evse.charge_current_ceiling_a is None + # Still declared, so still writable: the value is missing, not the property. + assert evse.charge_current_limit_settable is True + + +def test_the_declaration_decides_settability() -> None: + assert _snapshot_evse(_TREE, EVSE).charge_current_limit_settable is True + assert _snapshot_evse(_without_settable(EVSE), EVSE).charge_current_limit_settable is False + + +def test_the_ceiling_is_never_reported_settable() -> None: + """The regression this defaulting rule exists to prevent. + + Ceiling and limit differ by one Homie attribute. `load-shed/priority` reads + an absent `$settable` as settable, correctly — locking is the exception a + panel announces there. Carrying that default here would make the installer's + commissioned maximum look writable, so the two halves are asserted apart. + """ + surface = resolve_charge_limit(_evse_device(_TREE, EVSE)) + + assert surface is not None + assert surface.ceiling is not None and surface.ceiling.settable is False + assert surface.limit is not None and surface.limit.settable is True + + +def test_a_pending_write_shows_as_a_target() -> None: + """The Homie `$target` echo, the same pending-command signal the priority + select already reads through `circuit.priority_target`.""" + device = _evse_device(_TREE, EVSE) + pending = int(_published(EVSE, LIMIT_TOPIC)) - 8 + device.update_property_target("config", "user-max-charge-current", str(pending)) + + evse = build_evse(device, {}, node_id=EVSE, feed_statuses={}) + + assert evse.charge_current_limit_target_a == pending + assert evse.charge_current_limit_a == int(_published(EVSE, LIMIT_TOPIC)) + + +def test_no_pending_write_is_no_target() -> None: + assert _snapshot_evse(_TREE, EVSE).charge_current_limit_target_a is None + + +def test_a_charger_with_no_charge_limit_node_reports_none() -> None: + """`charge-limit.md`: absence means the EVSE charges at a fixed rate.""" + evse = _snapshot_evse(_without_node(EVSE), EVSE) + + assert evse.charge_current_limit_a is None + assert evse.charge_current_ceiling_a is None + assert evse.charge_current_limit_settable is False + # The rest of the charger still reads, so this is the node going away and + # not the device. + assert evse.status == _published(EVSE, "status/status") + + +# --------------------------------------------------------------------------- +# The other spelling +# --------------------------------------------------------------------------- + + +def test_the_catalogued_spelling_reads_identically() -> None: + """`charge-limit/{installer-max,owner-limit}` — the eBus 0.1 naming. + + The claim this whole design rests on: nothing outside `charge_limit.py` + names a node, so a charger publishing the specified spelling produces the + same snapshot as one publishing SPAN's. Asserted field by field against the + unrenamed capture rather than against literals, so the two paths are held to + each other and not merely to the same numbers. + """ + published = _snapshot_evse(_TREE, EVSE) + catalogued = _snapshot_evse(_renamed_to_catalog(EVSE), EVSE) + + assert catalogued == published + + +def test_the_catalogued_spelling_is_written_to_its_own_topic() -> None: + adapter = _adapter(_renamed_to_catalog(EVSE)) + key = _key(adapter, EVSE) + + assert adapter.set_evse_charge_limit_topic(key) == f"ebus/5/{EVSE}/charge-limit/owner-limit/set" + + +def test_the_catalogued_spelling_wins_where_both_are_declared() -> None: + """A charger mid-migration declares both; the specified one is authoritative. + + Not a hypothetical: a rename lands in firmware by adding the new node before + retiring the old, and a reader that took whichever it saw first would flip + between them on the strength of dict ordering. + """ + tree = _renamed_to_catalog(EVSE) + stale = json.loads(_TREE[EVSE]["$description"])["nodes"]["config"] + description = json.loads(tree[EVSE]["$description"]) + description["nodes"]["config"] = stale + tree[EVSE]["$description"] = json.dumps(description) + tree[EVSE][CEILING_TOPIC] = _published(EVSE, CEILING_TOPIC) + tree[EVSE][LIMIT_TOPIC] = _published(EVSE, LIMIT_TOPIC) + + surface = resolve_charge_limit(_evse_device(tree, EVSE)) + + assert surface is not None + assert surface.node == "charge-limit" + + +# --------------------------------------------------------------------------- +# Writing — the exact topic, the exact payload +# --------------------------------------------------------------------------- + + +def test_the_set_topic_addresses_the_device_and_the_declared_property() -> None: + """Device id in the topic, serial in the snapshot key — they are not the same string. + + A charger that publishes `info/serial-number` is keyed by that serial in the + snapshot, while the wire addresses it by device id. Building the topic from + the key the caller holds would publish to `ebus/5/SIM-EVSE-…/…`, which no + device subscribes to, and nothing would report a failure. + """ + adapter = _adapter() + key = _key(adapter, EVSE) + + assert key != EVSE + assert adapter.set_evse_charge_limit_topic(key) == f"ebus/5/{EVSE}/config/user-max-charge-current/set" + + +def test_each_charger_gets_its_own_set_topic() -> None: + adapter = _adapter() + + assert adapter.set_evse_charge_limit_topic(_key(adapter, EVSE)) == (f"ebus/5/{EVSE}/config/user-max-charge-current/set") + assert adapter.set_evse_charge_limit_topic(_key(adapter, EVSE_2)) == ( + f"ebus/5/{EVSE_2}/config/user-max-charge-current/set" + ) + + +def test_no_topic_for_a_charger_that_does_not_declare_the_limit_settable() -> None: + """The refusal. A property with no `$settable` is not a control, and naming a + topic for it would put a write on the wire the panel never offered.""" + adapter = _adapter(_without_settable(EVSE)) + key = _key(adapter, EVSE) + + assert adapter.set_evse_charge_limit_topic(key) is None + assert adapter.evse_charge_limit_payload(key, 16) is None + + +def test_no_topic_for_a_charger_with_no_charge_limit_node() -> None: + adapter = _adapter(_without_node(EVSE)) + key = _key(adapter, EVSE) + + assert adapter.set_evse_charge_limit_topic(key) is None + assert adapter.evse_charge_limit_payload(key, 16) is None + + +def test_no_topic_for_a_charger_the_panel_does_not_have() -> None: + adapter = _adapter() + + assert adapter.set_evse_charge_limit_topic("not-a-charger") is None + assert adapter.evse_charge_limit_payload("not-a-charger", 16) is None + + +def test_a_value_at_or_below_the_ceiling_is_published_as_it_is() -> None: + adapter = _adapter() + key = _key(adapter, EVSE) + ceiling = int(_published(EVSE, CEILING_TOPIC)) + + assert adapter.evse_charge_limit_payload(key, ceiling) == str(ceiling) + assert adapter.evse_charge_limit_payload(key, ceiling - 16) == str(ceiling - 16) + assert adapter.evse_charge_limit_payload(key, 0) == "0" + + +def test_above_the_ceiling_is_refused_rather_than_clamped() -> None: + """`charge-limit` 0.1 makes `owner-limit <= installer-max` a MUST, and the + ceiling is derated hardware protection. Clamping would report a limit the + charger is not enforcing; refusing tells the caller.""" + adapter = _adapter() + key = _key(adapter, EVSE) + + assert adapter.evse_charge_limit_payload(key, int(_published(EVSE, CEILING_TOPIC)) + 1) is None + + +def test_the_ceiling_that_bounds_the_write_is_that_charger_s_own() -> None: + """Two chargers with different ceilings; a value legal on one is not on the other.""" + lowered = int(_published(EVSE_2, CEILING_TOPIC)) - 16 + adapter = _adapter(_tree(**{EVSE_2: {CEILING_TOPIC: str(lowered)}})) + asked = lowered + 8 + assert asked <= int(_published(EVSE, CEILING_TOPIC)) + + assert adapter.evse_charge_limit_payload(_key(adapter, EVSE), asked) == str(asked) + assert adapter.evse_charge_limit_payload(_key(adapter, EVSE_2), asked) is None + + +def test_a_negative_amperage_is_refused() -> None: + adapter = _adapter() + + assert adapter.evse_charge_limit_payload(_key(adapter, EVSE), -1) is None + + +def test_a_charger_with_no_ceiling_is_not_second_guessed() -> None: + """`installer-max` is a SHOULD. With none declared there is no published + bound, and inventing one here would be this library making up hardware limits.""" + topics = dict(_TREE[EVSE]) + description = json.loads(topics["$description"]) + description["nodes"]["config"]["properties"].pop("max-charge-current") + topics["$description"] = json.dumps(description) + topics.pop(CEILING_TOPIC) + tree = {other: dict(values) for other, values in _TREE.items()} + tree[EVSE] = topics + + adapter = _adapter(tree) + key = _key(adapter, EVSE) + + assert adapter.evse_charge_limit_payload(key, 1000) == "1000" + assert adapter.set_evse_charge_limit_topic(key) == f"ebus/5/{EVSE}/config/user-max-charge-current/set" + + +# --------------------------------------------------------------------------- +# The transport — what actually reaches the wire +# --------------------------------------------------------------------------- + + +def _client(adapter: SchemaOneAdapter) -> tuple[object, MagicMock]: + from span_panel_api.mqtt.client import MqttClientConfig, SpanMqttClient + + config = MqttClientConfig(broker_host="h", username="u", password="p") + client = SpanMqttClient(host="192.168.1.1", serial_number=PANEL, broker_config=config) + client._adapter = adapter + bridge = MagicMock() + client._bridge = bridge + return client, bridge + + +@pytest.mark.asyncio +async def test_the_transport_publishes_the_topic_and_payload_the_adapter_named() -> None: + adapter = _adapter() + client, bridge = _client(adapter) + key = _key(adapter, EVSE) + asked = int(_published(EVSE, CEILING_TOPIC)) - 8 + + await client.set_evse_charge_limit(key, asked) + + bridge.publish.assert_called_once_with(f"ebus/5/{EVSE}/config/user-max-charge-current/set", str(asked), qos=1) + + +@pytest.mark.asyncio +async def test_the_transport_refuses_a_charger_with_no_control() -> None: + adapter = _adapter(_without_settable(EVSE)) + client, bridge = _client(adapter) + + with pytest.raises(SpanPanelServerError, match="No settable charge-current limit"): + await client.set_evse_charge_limit(_key(adapter, EVSE), 16) + + bridge.publish.assert_not_called() + + +@pytest.mark.asyncio +async def test_the_transport_refuses_a_value_above_the_ceiling() -> None: + adapter = _adapter() + client, bridge = _client(adapter) + over = int(_published(EVSE, CEILING_TOPIC)) + 1 + + with pytest.raises(SpanPanelServerError, match=f"{over} A is outside"): + await client.set_evse_charge_limit(_key(adapter, EVSE), over) + + bridge.publish.assert_not_called() + + +# --------------------------------------------------------------------------- +# Metadata — the unit and datatype come from the same resolution +# --------------------------------------------------------------------------- + + +def _metadata(tree: dict[str, dict[str, str]]) -> dict[str, object]: + devices = [device_from_topics(device_id, topics) for device_id, topics in tree.items()] + return dict(build_field_metadata(devices)) + + +def test_metadata_carries_the_declared_unit_and_datatype() -> None: + declared = json.loads(_TREE[EVSE]["$description"])["nodes"]["config"]["properties"] + metadata = _metadata(_TREE) + + for path, property_id in ( + ("evse.charge_current_limit_a", "user-max-charge-current"), + ("evse.charge_current_ceiling_a", "max-charge-current"), + ): + entry = metadata[path] + assert entry.unit == declared[property_id]["unit"] + assert entry.datatype == declared[property_id]["datatype"] + assert entry.resolved is True + + +def test_metadata_follows_the_catalogued_spelling_too() -> None: + metadata = _metadata(_renamed_to_catalog(EVSE)) + + assert metadata["evse.charge_current_limit_a"].unit == "A" + assert metadata["evse.charge_current_ceiling_a"].datatype == "integer" + + +def test_a_declared_node_missing_a_half_is_a_gap_not_absent_hardware() -> None: + topics = dict(_TREE[EVSE]) + description = json.loads(topics["$description"]) + description["nodes"]["config"]["properties"].pop("max-charge-current") + topics["$description"] = json.dumps(description) + tree = {other: dict(values) for other, values in _TREE.items()} + tree[EVSE] = topics + # The second charger still declares both, and must not answer for the first. + tree.pop(EVSE_2) + + metadata = _metadata(tree) + + assert metadata["evse.charge_current_ceiling_a"].resolved is False + assert metadata["evse.charge_current_limit_a"].resolved is True + + +def test_no_metadata_where_no_charger_declares_the_node() -> None: + tree = _without_node(EVSE) + tree.pop(EVSE_2) + + metadata = _metadata(tree) + + assert "evse.charge_current_limit_a" not in metadata + assert "evse.charge_current_ceiling_a" not in metadata diff --git a/tests/test_schema_one_circuits.py b/tests/test_schema_one_circuits.py new file mode 100644 index 0000000..259f6c6 --- /dev/null +++ b/tests/test_schema_one_circuits.py @@ -0,0 +1,183 @@ +"""Mapping a v1.0 circuit device onto SpanCircuitSnapshot. + +Driven from the tree this distribution ships as package data, captured off a +real `panel_sim` parent/child tree rather than hand-written, so the shapes are +the firmware's rather than my idea of them. +""" + +from __future__ import annotations + +import json + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree +from span_panel_api_schema_1.circuits import build_circuit + +_TREE = parent_child_tree() + +# From the fixture: a 1-pole load, and a 2-pole backfeeding PV breaker. +KITCHEN_LIGHTS = "0ab966b95f92a6a51ec548485aa85f54" +SOLAR_INVERTER = "573066aaddd7b75114c4563ce3af18c4" + + +def _device(device_id: str) -> DiscoveredDevice: + return device_from_topics(device_id, _TREE[device_id]) + + +@pytest.fixture(name="kitchen") +def _kitchen() -> DiscoveredDevice: + return _device(KITCHEN_LIGHTS) + + +@pytest.fixture(name="solar") +def _solar() -> DiscoveredDevice: + return _device(SOLAR_INVERTER) + + +def test_identity_and_name(kitchen: DiscoveredDevice) -> None: + circuit = build_circuit(kitchen) + + assert circuit.circuit_id == KITCHEN_LIGHTS + assert circuit.name == "Kitchen Lights" + assert circuit.relay_state == "CLOSED" + + +def test_a_load_reports_positive_consumption(kitchen: DiscoveredDevice) -> None: + """The enclosure frame is the reverse of what the names suggest. + + A load reads negative `active-power` because power flows *out* of the + panel into it. The snapshot reports consumption as positive, so the sign + flips here. Getting this backwards is the classic silent defect: every + number still looks plausible. + """ + assert kitchen.get_property("meter", "active-power") == "-121.0" + + assert build_circuit(kitchen).instant_power_w == 121.0 + + +def test_a_backfeeding_circuit_reports_negative_consumption(solar: DiscoveredDevice) -> None: + assert solar.get_property("meter", "active-power") == "8500.0" + + assert build_circuit(solar).instant_power_w == -8500.0 + + +def test_energy_accumulators_are_swapped_to_the_circuit_perspective(solar: DiscoveredDevice) -> None: + """`imported-energy` is named from the panel's side: energy the panel took + *from* the circuit, which the circuit produced.""" + circuit = build_circuit(solar) + + assert solar.get_property("meter", "imported-energy") == "141.66666666666666" + assert circuit.produced_energy_wh == pytest.approx(141.666666, rel=1e-6) + assert circuit.consumed_energy_wh == 0.0 + + +def test_tabs_come_from_the_published_list_not_a_derivation(solar: DiscoveredDevice) -> None: + """v1.0 publishes occupied spaces literally. The flat schema published one + space plus a `dipole` flag and left the consumer to infer `space + 2`.""" + assert solar.get_property("info", "spaces") == "36,38" + + assert build_circuit(solar).tabs == [36, 38] + + +def test_single_pole_circuit(kitchen: DiscoveredDevice) -> None: + circuit = build_circuit(kitchen) + + assert circuit.tabs == [1] + assert circuit.is_240v is False + + +def test_two_pole_circuit_is_240v(solar: DiscoveredDevice) -> None: + assert build_circuit(solar).is_240v is True + + +def test_breaker_rating_and_current(kitchen: DiscoveredDevice) -> None: + circuit = build_circuit(kitchen) + + assert circuit.breaker_rating_a == 15.0 + assert circuit.current_a == pytest.approx(1.00833, rel=1e-4) + + +# --------------------------------------------------------------------------- +# The three flat booleans v1.0 retired +# --------------------------------------------------------------------------- + + +def test_always_on_is_the_inverse_of_relay_controllable(kitchen: DiscoveredDevice, solar: DiscoveredDevice) -> None: + """`always-on` is retired; the migration guide defines + `relay-controllable = !always-on`.""" + assert kitchen.get_property("switch", "relay-controllable") == "true" + assert solar.get_property("switch", "relay-controllable") == "false" + + assert build_circuit(kitchen).always_on is False + assert build_circuit(kitchen).is_user_controllable is True + assert build_circuit(solar).always_on is True + assert build_circuit(solar).is_user_controllable is False + + +def test_relay_controllable_defaults_to_controllable_when_absent(kitchen: DiscoveredDevice) -> None: + """The property marks the exception. Defaulting it False would silently + make every circuit uncontrollable on a panel that omits it.""" + kitchen.update_property("switch", "relay-controllable", "") + + assert build_circuit(kitchen).is_user_controllable is True + + +def test_sheddable_is_computed_not_read(kitchen: DiscoveredDevice, solar: DiscoveredDevice) -> None: + """Retired with no replacement property: the guide defines it as + `priority != NEVER and relay-controllable`.""" + # Kitchen: priority UNKNOWN (not NEVER) and controllable -> sheddable + assert build_circuit(kitchen).is_sheddable is True + # Solar: priority NEVER and not controllable -> not sheddable + assert solar.get_property("load-shed", "priority") == "NEVER" + assert build_circuit(solar).is_sheddable is False + + +def test_never_backup_reads_the_settable_attribute(kitchen: DiscoveredDevice) -> None: + """v1.0 expresses never-backup as mutability, so the signal is the Homie + `$settable` attribute on the priority definition, not a value topic.""" + definition = kitchen.get_node_properties("load-shed")["priority"] + assert definition["settable"] is True + + assert build_circuit(kitchen).is_never_backup is False + + +def test_a_locked_priority_means_never_backup(kitchen: DiscoveredDevice) -> None: + description = json.loads(_TREE[KITCHEN_LIGHTS]["$description"]) + description["nodes"]["load-shed"]["properties"]["priority"]["settable"] = False + kitchen.update_description(json.dumps(description)) + + assert build_circuit(kitchen).is_never_backup is True + + +def test_an_unannounced_settable_means_settable(kitchen: DiscoveredDevice) -> None: + """Locking is what a panel announces. Treating silence as locked would mark + every circuit never-backup on firmware that omits the attribute.""" + description = json.loads(_TREE[KITCHEN_LIGHTS]["$description"]) + del description["nodes"]["load-shed"]["properties"]["priority"]["settable"] + kitchen.update_description(json.dumps(description)) + + assert build_circuit(kitchen).is_never_backup is False + + +# --------------------------------------------------------------------------- +# Robustness +# --------------------------------------------------------------------------- + + +def test_an_unreadable_number_is_treated_as_absent_not_fatal(kitchen: DiscoveredDevice) -> None: + """One malformed value must not take down a whole snapshot.""" + kitchen.update_property("meter", "current", "not-a-number") + + assert build_circuit(kitchen).current_a is None + + +def test_zero_power_never_becomes_negative_zero(kitchen: DiscoveredDevice) -> None: + """-0.0 compares equal to 0.0 but formats as '-0.0' in the UI.""" + kitchen.update_property("meter", "active-power", "0.0") + + from math import copysign + + assert copysign(1.0, build_circuit(kitchen).instant_power_w) == 1.0 diff --git a/tests/test_schema_one_conformance.py b/tests/test_schema_one_conformance.py new file mode 100644 index 0000000..03d4dd3 --- /dev/null +++ b/tests/test_schema_one_conformance.py @@ -0,0 +1,726 @@ +"""Conformance checks — is every name this adapter reads one the eBus spec defines, +and does the producer we test against actually publish it? + +The consumer counterpart to `test_schema_provenance.py`, which does the same job +for the flat adapter against SPAN's own schema document. This one runs against +vendored copies of the eBus capability catalogs, because v1.0 vocabulary comes +from the specification rather than from a per-panel schema. + +**The direction matters, and it is not the publisher's.** The simulator asks "is +everything I publish legal?", and for it an omission is legal and abundant. This +asks the opposite question: is everything we *read* actually defined? A consumer +addressing a name the spec no longer carries does not fail — the property simply +never arrives, a metadata lookup returns None, and an entity goes missing. That +has already happened upstream once: `ebus-sdk` 0.18.0 removed the `battery` +capability key outright in favour of `soc`, with no alias. A consumer hardcoding +`battery` would have gone quiet rather than broken. + +Three checks with different reach, deliberately: + +- **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 against their sources, which need + `EBUS_SPEC_DIR` / `PANELBENCH_DIR` to name checkouts. Skipped without them on a + developer machine and **failed** without them under `CI`, where the workflow + clones both: see `_unconfigured`, and DEVELOPMENT.md's "A skip here is not a pass". + +Provenance 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. +""" + +from __future__ import annotations + +import ast +import importlib +import json +import os +import subprocess +from pathlib import Path +import re +from typing import NoReturn + +import pytest + +from span_panel_api_schema_1 import const +from span_panel_api_schema_1.charge_limit import SPELLINGS +from span_panel_api_schema_1.field_metadata import _PROPERTY_FIELD_MAP + +# Defined in panel.py rather than const.py, which is itself the point: the read +# set has to be derived from the modules that do the reading, not from one +# 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" +_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" + + +def _lock() -> dict[str, object]: + with _LOCK.open() as handle: + loaded: dict[str, object] = json.load(handle) + return loaded + + +def _peer() -> dict[str, object]: + peer = _lock()["peer"] + assert isinstance(peer, dict) + return peer + + +def _peer_str(key: str) -> str: + value = _peer()[key] + assert isinstance(value, str), f"peer.{key} should be a string" + return value + + +def _peer_fixtures() -> dict[str, str]: + """The captures vendored from the peer, by kind. + + 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. + """ + fixtures = _peer()["fixtures"] + assert isinstance(fixtures, dict) + return {str(kind): str(path) for kind, path in fixtures.items()} + + +def _unconfigured(reason: str) -> NoReturn: + """Not configured: skip on a developer machine, fail in CI. + + Locally, skipping is right — not every developer keeps sibling checkouts, and a + provenance check is not what they are running the suite for. + + In CI it is the opposite. The workflow clones both peers and exports both + variables, so an unset or wrong path there does not mean "unavailable", it means + 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. + + `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 both peer checkouts, 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. + """ + 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 + + +def _catalog(node: str) -> dict[str, object]: + with (_CATALOGS / f"{node}.json").open() as handle: + loaded: dict[str, object] = json.load(handle) + return loaded + + +def _catalog_properties(node: str) -> set[str]: + """The property set a catalog defines, or an empty one where no catalog exists. + + Empty rather than an error, because "no catalog defines this node" is a real + and legal state — `config` is one — and the checks below already have the + vocabulary to say what it means. Raising here instead would take the two + tests that ask the interesting question (is every name either catalogued or + a declared extension?) and turn them into a FileNotFoundError from a helper. + """ + if not (_CATALOGS / f"{node}.json").exists(): + return set() + properties = _catalog(node).get("properties", {}) + assert isinstance(properties, dict) + return set(properties) + + +def _read_pairs() -> set[tuple[str, str]]: + """Every ``(capability node, property)`` this adapter addresses. + + Derived, not listed, so it cannot drift from the code the way a hand-kept + inventory does — the same reason `_derive_required_members` reads the + protocol rather than restating it. + + Two sources, because the adapter addresses properties two ways. + `_PROPERTY_FIELD_MAP` is the metadata contract, and is already declarative. + The snapshot mapper instead calls readers like ``text(mid, NODE_GRID, + PROP_ISLANDING_STATE)``, which no table records — and building this from the + metadata map alone quietly omitted every one of them, including the MID + reads, when this check was first written. + + Constants are resolved from the module that uses them rather than from + `const`, because not all of them live there. + """ + pairs = {(node, property_id) for _, node, property_id, _ in _PROPERTY_FIELD_MAP} + + # The EVSE charge-current surface, which neither source above can express. + # It is addressed through neither a metadata row nor a `NODE_*`/`PROP_*` + # call: the node and property are chosen at runtime from whichever spelling + # the charger's own `$description` declares, so the adapter's read set for + # it *is* the spelling table. Derived from that table rather than restated, + # for the same reason as everything else here. + pairs.update( + (spelling.node, property_id) for spelling in SPELLINGS for property_id in (spelling.ceiling, spelling.limit) + ) + + for path in sorted(_SOURCE.glob("*.py")): + if path.stem == "__init__": + continue + module = importlib.import_module(f"span_panel_api_schema_1.{path.stem}") + for call in (n for n in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) if isinstance(n, ast.Call)): + names = [arg.id for arg in call.args if isinstance(arg, ast.Name)] + for node_name in (name for name in names if name.startswith("NODE_")): + for property_name in (name for name in names if name.startswith("PROP_")): + node = getattr(module, node_name, None) + property_id = getattr(module, property_name, None) + if isinstance(node, str) and isinstance(property_id, str): + pairs.add((node, property_id)) + return pairs + + +def _simulator_declared() -> set[tuple[str, str]]: + """Every ``(node, property)`` the captured simulator 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. + """ + 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 {}) + } + + +# Properties this adapter reads that no catalog defines. +# +# These are legal: the specification lets a publisher emit properties it has +# never heard of, and SPAN does. They are listed rather than tolerated so that a +# name missing from the catalog has to be a deliberate claim about SPAN's own +# vocabulary, not an unnoticed typo — the two are indistinguishable at runtime, +# since both produce a property that never arrives. +_SPAN_EXTENSIONS: dict[tuple[str, str], str] = { + (const.NODE_STATUS, "relay"): "panel main relay position; the catalog's status is alerts and comms only", + (const.NODE_STATUS, "ethernet"): "panel ethernet link state", + (const.NODE_STATUS, "wifi"): "panel wifi link state", + (const.NODE_STATUS, "wifi-ssid"): ( + "the network the panel is joined to. Declared on the enclosure and documented by " + "r202633 as the MQTT successor to the flat Wi-Fi endpoint, but absent from the " + "status catalog, which is alerts and comms only -- the same reason its `wifi` " + "sibling above is an extension." + ), + (const.NODE_STATUS, "cloud-connection"): "panel vendor-cloud reachability", + (const.NODE_STATUS, "status"): "EVSE session status", + (const.NODE_METER, "voltage-a"): "split-phase per-leg voltage; the catalog carries a single voltage", + (const.NODE_METER, "voltage-b"): "split-phase per-leg voltage; the catalog carries a single voltage", + (const.NODE_METER, "current-a"): "split-phase per-leg current; the catalog carries a single current", + (const.NODE_METER, "current-b"): "split-phase per-leg current; the catalog carries a single current", + (const.NODE_METER, "advertised-current"): "EVSE pilot-advertised current", + (const.NODE_INFO, "name"): "circuit label; Homie's $name is the device name, not the circuit's", + (const.NODE_INFO, "spaces"): "breaker spaces occupied, a load-centre concept the catalog has no room for", + (const.NODE_INFO, "direction"): "which of the two identically-typed lugs devices is upstream", + (const.NODE_INFO, "nominal-power"): ( + "PV AC power rating in W. Deliberately not the catalog's nameplate-capacity, " + "which is stored energy with an abstract unit — a different quantity with a confusable name." + ), + (const.NODE_SWITCH, "lock-state"): "EVSE connector lock", + ("config", "max-charge-current"): ( + "the EVSE's commissioned charge-current ceiling, in SPAN's pre-catalog spelling. " + "No `config` capability exists upstream at all; the catalogued surface is " + "`charge-limit` 0.1, whose `installer-max` this adapter also reads. Both are read " + "because the charger's `$description` is the authority on which it publishes, and " + "the panels we can reach carry no EVSE to settle it." + ), + ("config", "user-max-charge-current"): ( + "the settable half of the same extension node, `charge-limit/owner-limit` in the " + "catalogued spelling. The only settable property this adapter writes outside the " + "panel and its circuits, which is why it is read from the declaration -- including " + "its `$settable` flag -- rather than from a constant." + ), +} + + +# Properties this adapter reads that the captured simulator 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] = { + ("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 " + "single device with no children other than the MID, so no inverter exists to " + "carry the capability and nothing publishes it. Read anyway, because it is the " + "decided successor to flat's `grid_islandable` and the mapping is unit-tested " + "against a synthetic inverter -- but with no producer evidence, which is what " + "this entry records. `resolve_grid_islandable` returns None rather than False " + "on absence, so the gap surfaces as an uncreated entity rather than a claim." + ), + ("charge-limit", "installer-max"): ( + "the catalogued spelling of the EVSE charge-current ceiling. The producer publishes " + "the `config/max-charge-current` spelling instead, and both are read because the " + "charger's `$description` decides. No producer we have declares this one, and no " + "capture can: the panel we expect access to has no SPAN Drive." + ), + ("charge-limit", "owner-limit"): ( + "the catalogued spelling of the settable charge-current limit, unexercised for the " + "same reason as `installer-max` above. `test_the_entity_reads_the_catalogued_spelling` " + "in test_schema_one_charge_limit.py drives it from a synthetic description, which is " + "evidence of a parser and not of a producer -- which is what this entry records." + ), +} + + +# --------------------------------------------------------------------------- +# The lockfile describes what is actually vendored +# --------------------------------------------------------------------------- + + +def test_every_pinned_capability_is_vendored_at_the_pinned_version() -> None: + """A pin that names a version the vendored file does not carry is worse than + no pin: it reports provenance that was never true.""" + pinned = _lock()["implements"] + assert isinstance(pinned, dict) + capabilities = pinned["capabilities"] + assert isinstance(capabilities, dict) + + mismatched = [ + f"{node}: lockfile says {version}, catalog says {_catalog(node).get('version')}" + for node, version in capabilities.items() + if _catalog(node).get("version") != version + ] + + assert not mismatched, "lockfile disagrees with the vendored catalogs:\n " + "\n ".join(mismatched) + + +def _fully_excused_nodes() -> set[str]: + """Nodes every one of whose read properties is a declared extension. + + The node-level counterpart of `_SPAN_EXTENSIONS`, derived from it rather + than listed beside it. `config` is the case: the specification has no + capability of that name, so there is no catalog to vendor and no version to + pin, and the only honest description of the node is the two per-property + claims already written above. + + Derived, so the tolerance cannot outlive the claim. A node stops being + excused the moment it is read for a property nobody declared an extension + for, which is exactly the "unvendored node looks checked" failure the test + below exists to prevent. + """ + read: dict[str, set[str]] = {} + for node, property_id in _read_pairs(): + read.setdefault(node, set()).add(property_id) + return {node for node, properties in read.items() if all((node, p) in _SPAN_EXTENSIONS for p in properties)} + + +def test_every_capability_node_this_adapter_reads_has_a_vendored_catalog() -> None: + """Adding a NODE_* to const.py without vendoring its catalog would leave that + node's properties unchecked while looking checked.""" + read = {node for node, _ in _read_pairs()} + vendored = {path.stem for path in _CATALOGS.glob("*.json")} + missing = read - vendored - _fully_excused_nodes() + + 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. + + `_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. + """ + 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()) + + assert not adopted, ( + f"the specification now defines these capabilities: {adopted}. Vendor the catalog, " + "pin it in spec_lock.json, and compare what it specifies against what SPAN publishes." + ) + + +# --------------------------------------------------------------------------- +# Conformance — every name resolves, or is a declared extension +# --------------------------------------------------------------------------- + + +def test_every_property_read_is_catalogued_or_a_declared_extension() -> None: + """The core assertion, over everything the adapter addresses rather than only + what carries metadata.""" + undeclared = sorted( + f"{node}/{property_id}" + for node, property_id in _read_pairs() + if property_id not in _catalog_properties(node) and (node, property_id) not in _SPAN_EXTENSIONS + ) + + assert not undeclared, ( + "properties read by this adapter that no catalog defines and no extension declares:\n " + + "\n ".join(undeclared) + + "\n\nEither the specification moved and the adapter must follow, or this is a SPAN " + "extension and belongs in _SPAN_EXTENSIONS with a reason." + ) + + +def test_the_read_set_reaches_past_the_metadata_map() -> None: + """`_read_pairs` exists because the metadata map is not the whole read set. + + Pinned because the omission is invisible: a check built on the map alone + passes cleanly while never looking at the MID, which is where `grid_state` + comes from. + """ + mapped = {(node, property_id) for _, node, property_id, _ in _PROPERTY_FIELD_MAP} + + assert (const.NODE_GRID, PROP_ISLANDING_STATE) not in mapped, "the MID now carries metadata; simplify this" + assert (const.NODE_GRID, PROP_ISLANDING_STATE) in _read_pairs(), "the MID read is no longer being discovered" + + +def test_no_declared_extension_has_been_adopted_by_the_specification() -> None: + """The reverse direction. When upstream adopts a name we carried as an + extension, the entry becomes wrong — and silently so, because everything + still works. This converts that into a visible prompt to re-read the catalog, + since an adopted property may be specified differently than SPAN publishes it. + """ + adopted = [ + f"{node}/{property_id} — {reason}" + for (node, property_id), reason in _SPAN_EXTENSIONS.items() + if property_id in _catalog_properties(node) + ] + + assert not adopted, ( + "declared as SPAN extensions but now in the catalog:\n " + + "\n ".join(adopted) + + "\n\nCompare the catalog's definition against what SPAN publishes, then drop the entry." + ) + + +def test_no_extension_is_declared_for_a_property_nothing_reads() -> None: + """An allowlist that outlives its use quietly grants permission for names the + adapter no longer has, which is how allowlists rot.""" + unused = sorted(pair for pair in _SPAN_EXTENSIONS if pair not in _read_pairs()) + + assert not unused, f"extensions declared for properties nothing reads: {unused}" + + +def test_every_device_class_is_in_the_device_types_registry() -> None: + """The seven classes the mapper sorts the tree by. A class the registry drops + means SPAN is publishing something eBus no longer names.""" + registry = _DEVICE_TYPES.read_text(encoding="utf-8") + registered = set(re.findall(r"`(energy\.ebus\.device\.[a-z-]+)`", registry)) + read = {value for name, value in vars(const).items() if name.startswith("TYPE_") and isinstance(value, str)} + + assert read <= registered, f"device classes not in the registry: {sorted(read - registered)}" + + +# --------------------------------------------------------------------------- +# The rule that does not travel with a vendored file +# --------------------------------------------------------------------------- + + +def test_an_abstract_unit_is_never_taken_from_the_catalog() -> None: + """`unit: "energy"` names a dimension, not a unit — a BESS reports kWh, a + water heater Wh — and the specification requires a publisher to substitute a + real one. A consumer that trusted the catalog would hand the integration the + placeholder as though it were a unit. + + This adapter is right by construction, because it reads units from each + device's `$description` rather than from any catalog. That is worth asserting + rather than assuming: the catalog is vendored right here, and reaching for it + is the obvious shortcut the day someone wants a unit the description omits. + """ + abstract = { + (node, property_id) + for node in ("soc", "info") + for property_id, definition in _catalog(node).get("properties", {}).items() # type: ignore[union-attr] + if isinstance(definition, dict) and definition.get("unit") == "energy" + } + + assert abstract, "no catalog property carries an abstract unit; this test no longer guards anything" + assert (const.NODE_SOC, "soe") in abstract, "soc/soe is the one this adapter reads; the catalog no longer marks it" + + metadata_source = (_SOURCE / "field_metadata.py").read_text(encoding="utf-8") + assert "spec_lock" not in metadata_source and "catalogs" not in metadata_source, ( + "field_metadata.py now references the vendored spec. Units must come from each device's " + "$description; the catalog is the superset across all hardware and carries abstract units." + ) + + +# --------------------------------------------------------------------------- +# Coverage — does the producer we develop against exercise what we read? +# --------------------------------------------------------------------------- + + +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_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: + """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. + """ + declared = _simulator_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 + ) + + assert not unexercised, ( + "properties this adapter reads that the captured simulator 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." + ) + + +def test_nothing_is_recorded_as_unexercised_once_the_simulator_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() + now_covered = sorted( + f"{node}/{property_id}" for node, property_id in _NOT_EXERCISED_BY_SIMULATOR if (node, property_id) in declared + ) + + assert not now_covered, ( + "the simulator now declares these; drop them from _NOT_EXERCISED_BY_SIMULATOR " + "and let the coverage check hold them:\n " + "\n ".join(now_covered) + ) + + +# --------------------------------------------------------------------------- +# Provenance — opportunistic, because it needs checkouts +# --------------------------------------------------------------------------- + + +def test_vendored_catalogs_are_byte_identical_to_the_specification() -> 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. + """ + 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) + + 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." + ) + + +def test_the_vendored_captures_match_the_simulator() -> None: + """Both captures against the simulator that produced them. + + 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. + """ + 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") + + 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." + ) + + +def test_an_unconfigured_peer_checkout_fails_in_ci_and_skips_locally(monkeypatch: pytest.MonkeyPatch) -> None: + """The guard on the guard. + + 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. + """ + 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" + ) + + 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/tests/test_schema_one_connection_health.py b/tests/test_schema_one_connection_health.py new file mode 100644 index 0000000..e2a9d94 --- /dev/null +++ b/tests/test_schema_one_connection_health.py @@ -0,0 +1,371 @@ +"""The enclosure's view of the link to each circuit-fed DER. + +`connection` 0.1 states the enclosure/DER relationship on the **circuit**, not +on the DER: a circuit that feeds a commissioned device publishes +`feeds-device-id` naming it and `feeds-device-status` saying how the link is. +So a PV's or a charger's link health arrives on a different device from the one +it describes, and the mapper's job is to put it back where it belongs. + +**Three things make that easy to get wrong, and every test here is aimed at one +of them.** + +*Absence is a value.* Two of the capture's five circuits publish no connection +record at all — the spec calls that normal for a mixed-load or unsurveyed +circuit — and the enum firmware does publish is `OK,LOST,DEGRADED`, with no +UNKNOWN member. So "nobody has said" can only be expressed by the property not +being there, and it has to stay distinct from "the link is down". + +*The capture agrees with itself.* All three published records read `OK`, which +means an assertion that both chargers are connected is satisfied by a mapper +that returns a constant, one that reads the wrong circuit, and one that gives +every DER the first record it finds. Nothing below rests on the captured values: +each is read out of the tree, and every reading is proved by republishing values +that differ per DER. + +*Two chargers.* The capture has two, fed by two circuits, so the wiring is +falsifiable — republish differing statuses and each charger has to report its +own. +""" + +from __future__ import annotations + +import json + +import pytest + +from span_panel_api.models import SpanEvseSnapshot, SpanPanelSnapshot +from span_panel_api_schema_1.const import NODE_CONNECTION +from span_panel_api_schema_1.devices import ( + PROP_FEEDS_DEVICE_ID, + PROP_FEEDS_DEVICE_STATUS, + STATUS_OK, + feed_connection_statuses, +) +from span_panel_api_schema_1.field_metadata import build_field_metadata +from span_panel_api_schema_1.reference_payloads import ( + RetainedTopicTree, + device_from_topics, + parent_child_tree, +) +from span_panel_api_schema_1.snapshot import build_snapshot + +PANEL = "example-40t-001" + +FEEDS_ID_TOPIC = f"{NODE_CONNECTION}/{PROP_FEEDS_DEVICE_ID}" +FEEDS_STATUS_TOPIC = f"{NODE_CONNECTION}/{PROP_FEEDS_DEVICE_STATUS}" + +# The DER device ids the capture commissions. Named rather than derived so a +# capture that stopped carrying one fails saying so, instead of quietly +# reducing every test below to a smaller panel. +PV = "pv" +EVSE = "evse" +EVSE_2 = "evse-2" + + +def _mutable_tree() -> dict[str, dict[str, str]]: + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _snapshot(tree: RetainedTopicTree) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL, tree[PANEL]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL] + return build_snapshot(panel, children) + + +def _feeding_circuit(tree: RetainedTopicTree, device_id: str) -> str: + """The circuit the capture says feeds `device_id`, or fail saying none does.""" + feeders = [circuit_id for circuit_id, topics in tree.items() if topics.get(FEEDS_ID_TOPIC) == device_id] + assert len(feeders) == 1, f"the capture has {len(feeders)} circuits feeding {device_id}, expected 1" + return feeders[0] + + +def _evse_fed_by(snapshot: SpanPanelSnapshot, circuit_id: str) -> SpanEvseSnapshot: + """The charger the snapshot says that circuit feeds. + + Looked up by feed rather than by snapshot key: the key is the harmonised + serial, and a test that hardcoded one would still pass if the mapper + attached every record to the same charger. + """ + matches = [evse for evse in snapshot.evse.values() if evse.feed_circuit_id == circuit_id] + assert len(matches) == 1, f"{len(matches)} chargers report circuit {circuit_id} as their feed" + return matches[0] + + +def _status_options() -> list[str]: + """The enum as the circuit's own `$description` declares it. + + Read from the wire rather than written here, because the property's legal + values are the panel's claim and not this test's. It is also the assertion + that there is no UNKNOWN member — which is *why* absence has to carry that + meaning instead. + """ + tree = parent_child_tree() + circuit = _feeding_circuit(tree, PV) + description = json.loads(tree[circuit]["$description"]) + node = description["nodes"][NODE_CONNECTION]["properties"][PROP_FEEDS_DEVICE_STATUS] + assert node["datatype"] == "enum" + return str(node["format"]).split(",") + + +def _not_ok() -> list[str]: + """Every declared status that is not `OK`, in declaration order.""" + return [option for option in _status_options() if option != STATUS_OK] + + +def _with_status(tree: dict[str, dict[str, str]], device_id: str, status: str) -> None: + """Republish the link status of whichever circuit feeds `device_id`.""" + tree[_feeding_circuit(tree, device_id)][FEEDS_STATUS_TOPIC] = status + + +# --------------------------------------------------------------------------- +# What the capture declares and publishes +# --------------------------------------------------------------------------- + + +def test_the_status_enum_has_no_unknown_member() -> None: + """The premise every absence test below rests on. + + If firmware ever gains an UNKNOWN member, "unpublished means unknown" stops + being the only way to say it and this design should be revisited — so the + premise is asserted rather than assumed. + """ + options = _status_options() + + assert STATUS_OK in options + assert "UNKNOWN" not in options + assert _not_ok(), "the enum declares nothing but OK, so no test here can observe a bad link" + + +def test_only_the_circuits_feeding_a_der_publish_a_connection_record() -> None: + """The negative case is in the capture, not manufactured by a test. + + Five circuits, three of which feed a commissioned DER. The other two feed + ordinary loads and publish neither half of the record — which + `distribution-enclosure.md` describes as the normal state for a mixed-load + circuit, and which is exactly the shape a mapper must not read as a fault. + """ + tree = parent_child_tree() + circuits = { + device_id for device_id, topics in tree.items() if json.loads(topics["$description"])["type"].endswith(".circuit") + } + publishing = {device_id for device_id in circuits if FEEDS_ID_TOPIC in tree[device_id]} + + assert publishing == {_feeding_circuit(tree, der) for der in (PV, EVSE, EVSE_2)} + silent = circuits - publishing + assert silent, "the capture has no DER-less circuit, so the absence case is untested" + for device_id in silent: + declared = json.loads(tree[device_id]["$description"])["nodes"] + assert NODE_CONNECTION in declared, ( + f"{device_id} does not even declare the node, so its silence proves nothing " + "about a circuit that declares the record and publishes none of it" + ) + assert not [topic for topic in tree[device_id] if topic.startswith(f"{NODE_CONNECTION}/")] + + +# --------------------------------------------------------------------------- +# The reading +# --------------------------------------------------------------------------- + + +def test_each_der_takes_the_link_health_of_the_circuit_that_feeds_it() -> None: + """Every expectation computed from the capture, none of them written here.""" + tree = parent_child_tree() + snapshot = _snapshot(tree) + + for der in (PV, EVSE, EVSE_2): + circuit = _feeding_circuit(tree, der) + published = tree[circuit][FEEDS_STATUS_TOPIC] + expected = published == STATUS_OK + reported = snapshot.pv.connected if der == PV else _evse_fed_by(snapshot, circuit).connected + assert reported is expected, f"{der}: circuit {circuit} publishes {published!r}" + + +def test_two_chargers_do_not_share_one_link() -> None: + """The cross-wiring case, and the reason two EVSE are worth the fixture. + + Both chargers read `OK` in the capture, so the baseline assertion above is + satisfied by a mapper that hands every charger the first record it finds. + Here they are republished differing, then swapped: a mapper keyed on the + wrong thing gets one of the two arrangements right by luck and never both. + """ + down, degraded = _not_ok()[0], _not_ok()[-1] + + for first, second in ((down, STATUS_OK), (STATUS_OK, down), (degraded, STATUS_OK)): + tree = _mutable_tree() + _with_status(tree, EVSE, first) + _with_status(tree, EVSE_2, second) + snapshot = _snapshot(tree) + + assert _evse_fed_by(snapshot, _feeding_circuit(tree, EVSE)).connected is (first == STATUS_OK) + assert _evse_fed_by(snapshot, _feeding_circuit(tree, EVSE_2)).connected is (second == STATUS_OK) + + +def test_the_pv_link_is_not_the_chargers_link() -> None: + """The third DER, held apart from the two chargers the same way.""" + down = _not_ok()[0] + tree = _mutable_tree() + _with_status(tree, PV, down) + + snapshot = _snapshot(tree) + + assert snapshot.pv.connected is False + for evse in snapshot.evse.values(): + assert evse.connected is True + + +@pytest.mark.parametrize("status", _not_ok()) +def test_every_status_that_is_not_ok_reads_as_a_broken_link(status: str) -> None: + """DEGRADED is not OK, and the boolean has to say so. + + Both non-OK members are exercised, so a mapper testing `!= "LOST"` — which + passes the LOST case and calls a degraded link healthy — fails here. + """ + tree = _mutable_tree() + _with_status(tree, PV, status) + _with_status(tree, EVSE, status) + + snapshot = _snapshot(tree) + + assert snapshot.pv.connected is False + assert _evse_fed_by(snapshot, _feeding_circuit(tree, EVSE)).connected is False + + +# --------------------------------------------------------------------------- +# Absence, in each of its three shapes +# --------------------------------------------------------------------------- + + +def test_a_circuit_that_stops_publishing_the_status_reports_unknown_not_disconnected() -> None: + """Retained topics vanish; the reading has to vanish with them. + + `None` rather than `False`, because the enum cannot say "unknown" and a + `False` here would tell a user their charger is unreachable on the strength + of the panel having said nothing at all. + """ + tree = _mutable_tree() + del tree[_feeding_circuit(tree, PV)][FEEDS_STATUS_TOPIC] + + snapshot = _snapshot(tree) + + assert snapshot.pv.connected is None + for evse in snapshot.evse.values(): + assert evse.connected is True, "removing one circuit's status changed another DER's reading" + + +def test_a_der_no_circuit_claims_is_unknown_rather_than_disconnected() -> None: + """The unclaimed case: a status with no id names nobody. + + Half a record is not a record. A circuit still publishing `OK` while no + longer naming the device it feeds says nothing about that device, and the + id is what the mapper matches on. + """ + tree = _mutable_tree() + circuit = _feeding_circuit(tree, PV) + del tree[circuit][FEEDS_ID_TOPIC] + assert tree[circuit][FEEDS_STATUS_TOPIC] == STATUS_OK + + assert _snapshot(tree).pv.connected is None + + +def test_a_circuit_publishing_neither_half_leaves_its_der_unknown() -> None: + """Both halves gone, which is what a decommissioned DER's circuit looks like.""" + tree = _mutable_tree() + circuit = _feeding_circuit(tree, EVSE) + del tree[circuit][FEEDS_ID_TOPIC] + del tree[circuit][FEEDS_STATUS_TOPIC] + + snapshot = _snapshot(tree) + + unclaimed = [evse for evse in snapshot.evse.values() if evse.connected is None] + assert len(unclaimed) == 1 + assert unclaimed[0].feed_circuit_id == "", "a charger with no feeding circuit still reports one" + + +def test_the_status_map_ignores_a_circuit_that_publishes_only_one_half() -> None: + """The rule stated once, at the function that enforces it.""" + tree = _mutable_tree() + solar, garage = _feeding_circuit(tree, PV), _feeding_circuit(tree, EVSE) + del tree[solar][FEEDS_STATUS_TOPIC] + del tree[garage][FEEDS_ID_TOPIC] + + statuses = feed_connection_statuses([device_from_topics(device_id, tree[device_id]) for device_id in (solar, garage)]) + + assert statuses == {} + + +# --------------------------------------------------------------------------- +# The facts this must not be confused with +# --------------------------------------------------------------------------- + + +def test_the_charger_link_is_independent_of_whether_a_car_is_plugged_in() -> None: + """`evse.status` is the session; `evse.connected` is the link. + + A charger reporting CHARGING over a link the enclosure has lost is the case + that tells the two apart, and it is a state real hardware reaches — the + charger keeps charging while the panel stops hearing from it. + """ + down = _not_ok()[0] + tree = _mutable_tree() + circuit = _feeding_circuit(tree, EVSE) + _with_status(tree, EVSE, down) + + evse = _evse_fed_by(_snapshot(tree), circuit) + + assert evse.connected is False + assert evse.status == parent_child_tree()[EVSE]["status/status"] + + +def test_the_battery_link_still_comes_from_the_lugs_not_from_a_circuit() -> None: + """The two halves of `connection` stay on their own devices. + + `battery.connected` is the upstream lugs' `fed-by-*` view. Breaking every + circuit-side record must not touch it, or the new route has quietly taken + over a field that was already right. + """ + down = _not_ok()[0] + tree = _mutable_tree() + for der in (PV, EVSE, EVSE_2): + _with_status(tree, der, down) + + assert _snapshot(tree).battery.connected is True + + +# --------------------------------------------------------------------------- +# Metadata +# --------------------------------------------------------------------------- + + +def test_both_der_link_fields_take_their_type_from_the_circuit_description() -> None: + """One property, two field paths, because one circuit's record is a PV's and + another's is a charger's.""" + tree = parent_child_tree() + metadata = build_field_metadata([device_from_topics(device_id, topics) for device_id, topics in tree.items()]) + + for path in ("pv.connected", "evse.connected"): + assert metadata[path].datatype == "enum" + assert metadata[path].unit is None + assert metadata[path].resolved is True + + +def test_a_circuit_declaring_the_node_without_the_property_reports_a_gap() -> None: + """The three-way contract, on the property that now carries a row. + + Node present and property missing is a declared gap, which is what makes + the difference between hardware that lacks the capability and firmware that + dropped a property visible to a consumer. + """ + tree = _mutable_tree() + devices = [] + for device_id, topics in tree.items(): + description = json.loads(topics["$description"]) + properties = description.get("nodes", {}).get(NODE_CONNECTION, {}).get("properties") + if properties is not None: + properties.pop(PROP_FEEDS_DEVICE_STATUS, None) + topics["$description"] = json.dumps(description) + devices.append(device_from_topics(device_id, topics)) + + metadata = build_field_metadata(devices) + + for path in ("pv.connected", "evse.connected"): + assert metadata[path].resolved is False diff --git a/tests/test_schema_one_devices.py b/tests/test_schema_one_devices.py new file mode 100644 index 0000000..63b77bc --- /dev/null +++ b/tests/test_schema_one_devices.py @@ -0,0 +1,488 @@ +"""BESS, PV and EVSE mapping from the v1.0 tree.""" + +from __future__ import annotations + +from collections.abc import Mapping +import json + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree +from span_panel_api_schema_1.devices import ( + build_mid, + build_battery, + build_evse, + build_pv, + connection_status_for, + feed_circuit_ids, +) + +_TREE = parent_child_tree() + +SOLAR_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" + + +def _device(device_id: str) -> DiscoveredDevice: + return device_from_topics(device_id, _TREE[device_id]) + + +def _circuits() -> list[DiscoveredDevice]: + return [_device(SOLAR_CIRCUIT), _device("0ab966b95f92a6a51ec548485aa85f54")] + + +BESS_POWER_TOPIC = "meter/active-power" +BESS_COMMS_TOPIC = "status/communication-state" + + +def _published(device_id: str, topic: str) -> str: + """What the capture publishes on this topic, or fail saying it does not. + + Every expectation below is computed from this rather than written as a + literal, so a test cannot keep passing against a fixture that stopped + carrying the value it is about. + """ + value = _TREE[device_id].get(topic) + assert value is not None, f"{device_id} publishes no {topic} in the capture" + return value + + +def _bess_with(overrides: Mapping[str, str | None]) -> DiscoveredDevice: + """The captured BESS with topics rewritten, or removed where the value is `None`. + + Removal is the point of the `None` case: a panel that stops publishing a + property retains nothing, which is a different event from publishing `""` + and has to produce a different answer. + """ + topics = dict(_TREE["bess"]) + for topic, value in overrides.items(): + if value is None: + topics.pop(topic, None) + else: + topics[topic] = value + return device_from_topics("bess", topics) + + +def _without(device_id: str, *topics: str) -> DiscoveredDevice: + """The captured device with these topics unpublished. + + The counterpart of `_published`: identity values now arrive valued in the + capture, so proving a consumer distinguishes "not published" from "published + blank" needs the absence built rather than found. + """ + remaining = {topic: value for topic, value in _TREE[device_id].items() if topic not in topics} + return device_from_topics(device_id, remaining) + + +def _bess_without_node(node_id: str) -> DiscoveredDevice: + """The captured BESS with one capability node gone from its `$description`. + + The third shape of absence, and the one a fixture edit alone cannot reach: + hardware that never had the capability, as opposed to hardware that has it + and is not reporting. The `$description` is the authoritative property set, + so removing the node is what "this BESS has no meter" actually looks like. + """ + description = json.loads(_TREE["bess"]["$description"]) + del description["nodes"][node_id] + topics = {topic: value for topic, value in _TREE["bess"].items() if not topic.startswith(f"{node_id}/")} + topics["$description"] = json.dumps(description) + return device_from_topics("bess", topics) + + +# --------------------------------------------------------------------------- +# Topology — v1.0 states the relationship on the circuit, not the DER +# --------------------------------------------------------------------------- + + +def test_feed_relationships_are_read_off_the_circuits() -> None: + feeds = feed_circuit_ids(_circuits()) + + assert feeds == {"pv": SOLAR_CIRCUIT} + + +def test_connection_status_is_reported_by_the_owner_not_the_device() -> None: + """The upstream lugs claim the BESS, and it is their view of that link that + `battery.connected` reflects.""" + upstream = _device("lugs-upstream") + + assert upstream.get_property("connection", "fed-by-device-id") == "bess" + assert connection_status_for("bess", [upstream]) == "OK" + assert connection_status_for("pv", [upstream]) is None + + +# --------------------------------------------------------------------------- +# Battery +# --------------------------------------------------------------------------- + + +def test_battery_state_of_charge_and_energy() -> None: + battery = build_battery(_device("bess"), []) + + # Historically misnamed and kept that way: soe_percentage holds the + # percentage, soe_kwh the energy. + assert battery.soe_percentage == pytest.approx(50.4104, rel=1e-4) + assert battery.soe_kwh == pytest.approx(6.8054, rel=1e-4) + assert battery.nameplate_capacity_kwh == 13.5 + assert battery.vendor_name == "Span" + + +def test_battery_identity_is_read_straight_through_without_a_swap() -> None: + """The crossover is gone: the snapshot speaks v1.0's vocabulary directly. + + This asserted the opposite until 2026-08-10 -- `info/model` onto `product_name` and + `info/part-number` onto `model` -- to hold each entity's displayed meaning still + against flat, which puts the SKU in `bess/model`. It worked, and it permanently + encoded flat's irregularity in the snapshot, so every reader had to be told why + `battery.model` was not a model. + + Flat is the inconsistent side, not v1.0: it puts the SKU in `model` on the BESS and + in `part-number` on the EVSE, for the same concept. v1.0 normalises all three. So the + snapshot adopts v1.0's names and `schema_0` translates flat into them -- which also + moves the change off the firmware migration, where a user meets it unplanned, and + onto a library release we schedule. + """ + bess = _device("bess") + bess.update_property("info", "part-number", "1232100-00-E") + + battery = build_battery(bess, []) + + assert battery.model == "Example BESS" # designation, from info/model + assert battery.part_number == "1232100-00-E" # SKU, from info/part-number + + +def test_battery_connected_comes_from_the_owner_not_the_bess() -> None: + """The BESS publishes `status/communication-state`, which looks like the + right property and is a different signal. The guide warns against + conflating them.""" + bess = _device("bess") + assert bess.get_property("status", "communication-state") == "OK" + + assert build_battery(bess, [_device("lugs-upstream")]).connected is True + + +def test_battery_connected_is_none_when_nothing_claims_it() -> None: + """ "Nobody has said" is not the same as "not OK" — the latter would report a + healthy battery as disconnected while the owner is still announcing.""" + assert build_battery(_device("bess"), []).connected is None + + +def test_a_degraded_link_is_not_connected() -> None: + upstream = _device("lugs-upstream") + upstream.update_property("connection", "fed-by-device-status", "DEGRADED") + + assert build_battery(_device("bess"), [upstream]).connected is False + + +def test_no_bess_yields_the_empty_battery_snapshot() -> None: + battery = build_battery(None, []) + + assert battery.soe_percentage is None + assert battery.connected is None + + +# --------------------------------------------------------------------------- +# Battery power — the sign is the whole content of these +# --------------------------------------------------------------------------- + + +def test_the_capture_is_a_charging_battery() -> None: + """The premise of every sign assertion below, derived rather than assumed. + + A sign convention can only be tested against a known physical state, and + "negative means charging" is the claim under test, so reading the state off + the sign would be circular. The enclosure's four power flows balance instead + -- ``pv + battery + grid == site``, with ``grid`` positive when importing -- + and solving that identity says which way the battery is going without + appealing to any convention this library chose. + + In this capture 8500 W of PV meets 2653 W of site load and exports 2347 W; + the 3500 W left over is going into the battery. So the battery is charging, + and both the enclosure and the BESS publish that as a negative number. + + Were the capture ever retaken with the battery discharging, this fails first + and says so, rather than the negation tests failing and reading as a mapper + bug. + """ + flows = {name: float(_published("example-40t-001", f"power-flows/{name}")) for name in ("pv", "battery", "grid", "site")} + + assert flows["pv"] + flows["battery"] + flows["grid"] == pytest.approx(flows["site"]) + # PV alone exceeds the site load, so the surplus has nowhere to go but the + # battery and the grid -- and the grid term is an export. + assert flows["pv"] > flows["site"] + assert flows["grid"] < 0 + assert flows["battery"] < 0 + assert float(_published("bess", BESS_POWER_TOPIC)) < 0 + + +def test_battery_power_is_the_negation_of_the_wire() -> None: + """One negation, and the frame it lands in is the BESS device's own. + + Positive means power flowing *out of* the battery -- discharging -- which is + what the eBus specification asks of a device's own meter, and deliberately + NOT the into-the-device rule `SpanCircuitSnapshot.instant_power_w` follows. + The wire inputs are in opposite frames, so the same single negation lands the + two fields on opposite conventions. This test used to claim the circuit rule + held here too; it does not, and the helper was renamed from + `_charge_positive` to `_discharge_positive` to stop implying it. + + Asserted against the wire rather than against a constant, and the sign + separately from the magnitude: dropping the negation keeps the magnitude and + fails on the sign, which is the mistake worth catching. + + The direction was settled by measurement rather than by reading the catalog + -- a producer in self-consumption with the grid at zero, PV and battery + together meeting the load, leaves no room to argue which way the battery is + going. + """ + raw = float(_published("bess", BESS_POWER_TOPIC)) + + battery = build_battery(_device("bess"), []) + + assert battery.power_w == -raw + assert battery.power_w is not None and battery.power_w > 0 + + +def test_battery_power_follows_a_republished_value() -> None: + """Proof the value is read off the wire and not defaulted into place.""" + raw = float(_published("bess", BESS_POWER_TOPIC)) + discharging = -raw / 2 + + battery = build_battery(_bess_with({BESS_POWER_TOPIC: str(discharging)}), []) + + # Charging became discharging, so the snapshot's sign flips with it. + assert battery.power_w == -discharging + assert battery.power_w is not None and battery.power_w < 0 + + +def test_a_battery_at_rest_reports_zero_and_not_negative_zero() -> None: + """`-0.0` compares equal to `0.0` and renders as "-0.0" beside it. + + `build_circuit` carries the same guard for the same reason; a negation added + without it produces a reading that looks broken exactly when nothing is + happening. + """ + battery = build_battery(_bess_with({BESS_POWER_TOPIC: "0.0"}), []) + + assert battery.power_w == 0.0 + assert str(battery.power_w) == "0.0" + + +def test_an_unpublished_battery_power_is_none_rather_than_zero() -> None: + """Zero is a reading — "the battery is idle" — and absence is not one.""" + assert build_battery(_bess_with({BESS_POWER_TOPIC: None}), []).power_w is None + + +def test_a_bess_with_no_meter_node_has_no_power() -> None: + assert build_battery(_bess_without_node("meter"), []).power_w is None + + +def test_the_bess_meter_and_the_enclosure_flow_agree_about_direction() -> None: + """The two properties describing this battery's power must not disagree. + + `panel.power_flow_battery` is the enclosure's own arbitrated figure and is + passed through untouched; `battery.power_w` is the BESS's own meter and is + negated. That is only coherent because the two properties are published in + the *same* frame on the wire -- asserted here rather than assumed, because a + consumer rendering both beside each other has to negate exactly one of them, + and which one is a fact about the capture rather than a preference. + """ + bess_meter = float(_published("bess", BESS_POWER_TOPIC)) + enclosure_flow = float(_published("example-40t-001", "power-flows/battery")) + + assert (bess_meter < 0) == (enclosure_flow < 0) + + +# --------------------------------------------------------------------------- +# Battery communication state +# --------------------------------------------------------------------------- + + +def test_communication_state_is_the_published_enum() -> None: + """Kept as the published string: DEGRADED is neither OK nor LOST, so a bool + would have to pick one, and `connected` is already the bool answer to the + other question.""" + published = _published("bess", BESS_COMMS_TOPIC) + + assert build_battery(_device("bess"), []).communication_state == published + + +def test_communication_state_follows_a_republished_value() -> None: + published = _published("bess", BESS_COMMS_TOPIC) + declared = json.loads(_TREE["bess"]["$description"])["nodes"]["status"]["properties"] + options = declared[BESS_COMMS_TOPIC.split("/", 1)[1]]["format"].split(",") + other = next(option for option in options if option != published) + + assert build_battery(_bess_with({BESS_COMMS_TOPIC: other}), []).communication_state == other + + +def test_an_unpublished_communication_state_is_none() -> None: + """`""` would read as a device reporting an empty answer; it reported nothing.""" + assert build_battery(_bess_with({BESS_COMMS_TOPIC: None}), []).communication_state is None + + +def test_a_bess_with_no_status_node_has_no_communication_state() -> None: + assert build_battery(_bess_without_node("status"), []).communication_state is None + + +def test_communication_state_and_connected_are_independent() -> None: + """The two link facts this task deliberately keeps apart. + + The BESS reports its own link `LOST` while the enclosure still claims it as + `OK`: one is the device speaking about itself, the other the panel speaking + about it, and a mapping that conflated them would make this impossible to + express. + """ + battery = build_battery(_bess_with({BESS_COMMS_TOPIC: "LOST"}), [_device("lugs-upstream")]) + + assert battery.communication_state == "LOST" + assert battery.connected is True + + +# --------------------------------------------------------------------------- +# PV +# --------------------------------------------------------------------------- + + +def test_pv_metadata_and_feed() -> None: + pv = build_pv(_device("pv"), feed_circuit_ids(_circuits()), feed_statuses={}) + + assert pv.vendor_name == "Enphase" + assert pv.model == "IQ8PLUS-72-2-US" + assert pv.nameplate_capacity_w == 10000.0 + assert pv.feed_circuit_id == SOLAR_CIRCUIT + + +def test_pv_relative_position_is_not_guessed() -> None: + """Retired in v1.0 and only "derivable from connection records (when + present)". The integration gates control entities on it, so a wrong value + creates or removes a control.""" + assert build_pv(_device("pv"), {}, feed_statuses={}).relative_position is None + + +def test_no_pv_yields_the_empty_snapshot() -> None: + assert build_pv(None, {}, feed_statuses={}).vendor_name is None + + +# --------------------------------------------------------------------------- +# EVSE +# --------------------------------------------------------------------------- + + +def test_evse_state_and_metadata() -> None: + evse = build_evse(_device("evse"), {}, node_id="evse", feed_statuses={}) + + assert evse.node_id == "evse" + assert evse.status == "CHARGING" + assert evse.lock_state == "LOCKED" + assert evse.advertised_current_a == 32.0 + assert evse.vendor_name == "SPAN" + assert evse.model == "SPAN Drive" + assert evse.part_number == "SPN-DRV-001" + assert evse.serial_number == "SIM-EVSE-example-40t-001" + + +def test_evse_without_a_feeding_circuit_reports_empty_not_none() -> None: + """`feed_circuit_id` is non-optional on the dataclass, so an unclaimed EVSE + gets the empty string rather than breaking construction.""" + assert build_evse(_device("evse"), {}, node_id="evse", feed_statuses={}).feed_circuit_id == "" + + +def test_the_mid_is_surfaced_as_its_own_device() -> None: + """v1.0's islanding authority, exposed so a consumer can render it as hardware. + + The enclosure model puts `grid` on the MID rather than on the enclosure -- "the + enclosure device itself does not publish them" -- so this is where islanding state, + grid state and the grid-forming entity actually live. + + Identity is the published serial, per `devices/proxy.md`: a proxied device id is + not stable across the proxy-to-native transition, so it cannot be what a consumer + keys its registry on. `test_the_mid_falls_back_to_its_device_id_without_a_serial` + covers the other branch. + """ + mid = build_mid(_device("bess-mid"), {}) + + assert mid is not None + assert mid.islanding_state == _published("bess-mid", "grid/islanding-state") + assert mid.grid_state == _published("bess-mid", "grid/grid-state") + assert mid.grid_forming_entity == _published("bess-mid", "grid/grid-forming-entity") + assert mid.vendor_name == _published("bess-mid", "info/vendor-name") + assert mid.model == _published("bess-mid", "info/model") + assert mid.serial_number == _published("bess-mid", "info/serial-number") + assert mid.node_id == mid.serial_number + + +def test_the_mid_falls_back_to_its_device_id_without_a_serial() -> None: + """A MID that publishes no serial still gets an identity, from the Homie device id. + + The fallback branch of the rule above. It was the capture's own state until the + reference tree caught up with what the producer publishes, so it is written out + rather than left to a fixture that happens not to carry a value. + """ + mid = build_mid(_without("bess-mid", "info/serial-number"), {}) + + assert mid is not None + assert mid.serial_number is None + assert mid.node_id == "bess-mid" + + +def test_a_panel_with_no_mid_reports_none_rather_than_an_empty_device() -> None: + """Presence is `snapshot.mid is not None`, with nothing to infer. + + `has_bess` has to guess from `soe_percentage is not None` because the battery field + is always present; its own docstring records that only that one field is reliable. + A new optional device should not inherit that guessing game. + """ + assert build_mid(None, {}) is None + + +def test_the_mid_carries_its_own_firmware_and_hardware_revision() -> None: + """`info/firmware-version` and `info/hardware-version` reach the snapshot. + + r202633 documents both on the MID's `info` node, and a consumer has fields for + them (`DeviceInfo(sw_version=..., hw_version=...)`). Until these were mapped the + MID's device card showed a model and a serial and nothing else, beside a battery + showing all three — the battery's identical property having been mapped from the + start. Found by valuing them in the simulator, which had never published them + either, so nothing downstream had ever been asked for them. + + `software_version` rather than `firmware_version`: the sub-devices share a + spelling because a consumer builds all of them the same way. Only the enclosure + calls it `firmware_version`. + + Read straight off the capture now that it carries what the producer publishes; + it used to inject the two values, which asked whether the mapper could read a + property this tree did not have. + """ + mid = build_mid(_device("bess-mid"), {}) + + assert mid is not None + assert mid.software_version == _published("bess-mid", "info/firmware-version") + assert mid.hardware_version == _published("bess-mid", "info/hardware-version") + + +def test_the_pv_carries_its_firmware_version() -> None: + """The other half of the same gap: `info/firmware-version` on the PV. + + Documented by r202633, published by the simulator, and dropped on the floor until + now. Unlike the MID there is no `hardware-version` to carry — the topic reference + documents five properties on the PV and that is not one of them. + """ + pv = build_pv(_device("pv"), {}, feed_statuses={}) + + assert pv.software_version == _published("pv", "info/firmware-version") + + +def test_a_device_publishing_no_revision_reports_none_rather_than_empty_string() -> None: + """Absent stays absent, so a consumer can tell "not published" from "published blank". + + `DeviceInfo` renders an empty string as a present-but-blank row; `None` omits the + row. The capture publishes all three, so the absence is built by unpublishing + them, which is what a panel whose firmware omits them actually looks like. + """ + mid = build_mid(_without("bess-mid", "info/firmware-version", "info/hardware-version"), {}) + + assert mid is not None + assert mid.software_version is None + assert mid.hardware_version is None + assert build_pv(_without("pv", "info/firmware-version"), {}, feed_statuses={}).software_version is None diff --git a/tests/test_schema_one_discovery.py b/tests/test_schema_one_discovery.py new file mode 100644 index 0000000..ee40cef --- /dev/null +++ b/tests/test_schema_one_discovery.py @@ -0,0 +1,553 @@ +"""What the reference tree declares that this adapter reads nothing from. + +`build_discovery` answers that question at runtime, for the panel in front of +the user, by subtracting four enumerations of what schema_1 addresses from what +the tree declares. Three of those enumerations are hand-written, so the answer +is only as good as they are — and a stale entry fails *silently*, by keeping a +property out of discovery rather than by raising. + +So every entry is checked by the same experiment the consumer-side gate uses: +republish one declared property with a legal different value, rebuild the +snapshot through the real mapper, and see whether any snapshot field moved. That +is a fact about the code rather than about a table, and it is what makes the +discovery output mean "nothing here reads this" instead of "nobody wrote it +down". + +The two directions are asserted separately because they fail differently. An +entry in `_CONSUMED_WITHOUT_A_ROW` that moves nothing is a property that has +silently dropped out of discovery. A discovered row that *does* move something +is a false positive, and false positives are what teach a maintainer to stop +reading a report. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import dataclasses +from functools import lru_cache +import json + +from ebus_sdk.homie import DiscoveredDevice +import pytest + +from span_panel_api.models import DiscoveredMetadata, SpanPanelSnapshot, is_discovery_path +from span_panel_api_schema_1 import field_metadata as field_metadata_module +from span_panel_api_schema_1.const import ( + DEVICE_TYPE_PREFIX, + NODE_METER, + NODE_STATUS, + TYPE_CIRCUIT, + TYPE_LUGS, + TYPE_PANEL, +) +from span_panel_api_schema_1.field_metadata import ( + _ADDRESSED, + _CONSUMED_OFF_SNAPSHOT, + _CONSUMED_WITHOUT_A_ROW, + _PROPERTY_FIELD_MAP, + build_discovery, + build_field_metadata, +) +from span_panel_api_schema_1.reference_payloads import ( + device_from_topics, + devices_from_tree, + parent_child_tree, +) +from span_panel_api_schema_1.snapshot import build_snapshot + +PANEL_DEVICE_ID = "example-40t-001" +"""The enclosure in the reference capture. Every other device is its child.""" + +Tree = dict[str, dict[str, str]] +Declaration = tuple[str, str, str] +"""``(device type, node, property)`` — the granularity every table here uses.""" + + +# --- the tree, and the experiment over it ---------------------------------- + + +def _tree() -> Tree: + """A mutable, one-level-deep copy of the capture. One topic is one string.""" + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _devices(tree: Tree) -> list[DiscoveredDevice]: + return [device_from_topics(device_id, topics) for device_id, topics in tree.items()] + + +def _snapshot(tree: Tree) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL_DEVICE_ID, tree[PANEL_DEVICE_ID]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL_DEVICE_ID] + return build_snapshot(panel, children) + + +def _mapping(value: object) -> Mapping[str, object]: + if not isinstance(value, Mapping): + return {} + return {str(key): item for key, item in value.items()} + + +def _text(value: object) -> str: + return value if isinstance(value, str) else "" + + +def _path(declaration: Declaration) -> str: + """The discovery path a declaration would be reported under.""" + device_type, node_id, property_id = declaration + return f"discovered.{device_type.removeprefix(DEVICE_TYPE_PREFIX)}/{node_id}/{property_id}" + + +def _record(fields: dict[str, str], prefix: str, obj: object) -> None: + if not dataclasses.is_dataclass(obj) or isinstance(obj, type): + return + for field in dataclasses.fields(obj): + fields[f"{prefix}.{field.name}"] = repr(getattr(obj, field.name)) + + +def _snapshot_fields(snapshot: SpanPanelSnapshot) -> dict[str, str]: + """Flatten a snapshot to ``{path: repr(value)}``, keyed per instance. + + The circuit and EVSE maps are keyed by their own ids so two instances cannot + mask each other's change, and values are held as `repr` so the comparison is + a plain string diff whatever a field holds. + """ + fields: dict[str, str] = {} + for field in dataclasses.fields(snapshot): + value = getattr(snapshot, field.name) + if field.name == "extension_properties": + # Excluded, and the exclusion is the point rather than a convenience. + # This field carries every *unaddressed* declaration by construction, + # so republishing any property discovery reports would move it — and + # "does republishing this move a snapshot field" would answer yes for + # every discovered row, which is the question this oracle exists to + # ask. What the test still catches is the real defect: a discovered + # property that moves a *curated* field, i.e. one the mapper reads + # while the addressed set says it does not. + continue + if field.name in {"circuits", "evse"}: + for key, item in value.items(): + _record(fields, f"{field.name}@{key}", item) + elif dataclasses.is_dataclass(value) and not isinstance(value, type): + _record(fields, field.name, value) + else: + fields[f"panel.{field.name}"] = repr(value) + return fields + + +def _instances(tree: Tree) -> dict[Declaration, list[tuple[str, str, Mapping[str, object]]]]: + """Every declaration in the tree, with the ``(device id, topic, body)`` of each instance.""" + found: dict[Declaration, list[tuple[str, str, Mapping[str, object]]]] = {} + for device_id, topics in tree.items(): + description = _mapping(json.loads(topics["$description"])) + device_type = _text(description.get("type")) + for node_id, node in _mapping(description.get("nodes")).items(): + for property_id, definition in _mapping(_mapping(node).get("properties")).items(): + found.setdefault((device_type, node_id, property_id), []).append( + (device_id, f"{node_id}/{property_id}", _mapping(definition)) + ) + return found + + +def _perturbed(body: Mapping[str, object], current: str | None) -> str: + """A legal value for this property that differs from `current`. + + Legality matters: a value the parser rejects leaves the field unchanged and + the property reads as unconsumed. So the replacement is built from the same + declared `datatype` and `format` the mapper parses against. + """ + datatype = _text(body.get("datatype")) + if datatype in {"float", "integer"}: + try: + number = float(current or "") + except ValueError: + return "7" if datatype == "integer" else "7.5" + return str(int(number) + 7) if datatype == "integer" else str(number + 7.5) + if datatype == "boolean": + return "false" if (current or "").lower() == "true" else "true" + if datatype == "enum": + for option in _text(body.get("format")).split(","): + if option and option != current: + return option + return "probe-value" if current != "probe-value" else "probe-value-2" + + +@lru_cache(maxsize=1) +def _moved() -> Mapping[Declaration, frozenset[str]]: + """Republish each declared property once; return the snapshot fields it moved. + + One rebuild per declaring *device*, unioned: the two lugs devices and the + five circuits declare the same properties and are read differently, so a + single probe against whichever came first would answer for both. + """ + tree = _tree() + baseline = _snapshot_fields(_snapshot(tree)) + moved: dict[Declaration, frozenset[str]] = {} + for declaration, instances in _instances(tree).items(): + changed: set[str] = set() + for device_id, topic, body in instances: + current = tree[device_id].get(topic) + replacement = _perturbed(body, current) + assert replacement != current, ( + f"{declaration} on {device_id}: the probe equals the published value " + f"({current!r}), so this property is not being tested" + ) + mutated = {other: dict(topics) for other, topics in tree.items()} + mutated[device_id][topic] = replacement + after = _snapshot_fields(_snapshot(mutated)) + changed.update(path for path, value in after.items() if baseline.get(path) != value) + moved[declaration] = frozenset(changed) + return moved + + +def _declared() -> frozenset[Declaration]: + return frozenset(_instances(_tree())) + + +def _discovered() -> dict[str, DiscoveredMetadata]: + return build_discovery(devices_from_tree(parent_child_tree())) + + +def _rendered(declarations: Iterable[Declaration]) -> str: + return "\n".join(f" {'/'.join(item)}" for item in sorted(declarations)) or " (none)" + + +# --- the experiment must be able to observe anything at all ----------------- + + +def test_the_probe_moves_something_for_a_known_reading() -> None: + """The floor under every assertion below. + + All of them are satisfied by a probe that changes nothing, ever: the tables + would simply have to grow to match. This fails first if that happens. + """ + moved = _moved()[(TYPE_CIRCUIT, NODE_METER, "active-power")] + assert any(path.startswith("circuits@") and path.endswith(".instant_power_w") for path in moved), ( + f"republishing a circuit's active power moved {sorted(moved)}, which does not " + "include the reading it produces — the experiment is not observing the mapper" + ) + + +# --- the enumerations, checked against the mapper rather than against prose -- + + +def test_every_property_consumed_without_a_row_really_moves_the_snapshot() -> None: + """An entry that stops being true drops a property out of discovery silently. + + This is the direction with no natural signal: an over-broad "we read this" + table produces a *smaller* report, and a smaller report looks exactly like a + panel with nothing new on it. + """ + moved = _moved() + declared = _declared() + inert = [entry for entry in _CONSUMED_WITHOUT_A_ROW if entry in declared and not moved[entry]] + assert not inert, ( + "_CONSUMED_WITHOUT_A_ROW claims these are read into the snapshot and " + f"republishing them moves nothing:\n{_rendered(inert)}\n" + "Either the mapper stopped reading them — in which case they belong in " + "discovery — or the route is off-snapshot and belongs in " + "_CONSUMED_OFF_SNAPSHOT with the code that reads it named." + ) + + +def test_an_off_snapshot_route_that_became_observable_must_be_retired() -> None: + """The mirror of the integration's `test_no_internal_route_is_observable_after_all`. + + `_CONSUMED_OFF_SNAPSHOT` is the one table the experiment cannot verify + positively, so it is the one that could quietly become an allowlist. It is + held to the opposite claim instead: the moment a route's property does move + a snapshot field, the route is no longer the only thing consuming it and the + entry is hiding a real reader from whoever adds a property beside it. + """ + moved = _moved() + observable = [entry for entry in _CONSUMED_OFF_SNAPSHOT if moved.get(entry)] + assert not observable, ( + "off-snapshot route entries whose property now moves a snapshot field:\n" + f"{_rendered(observable)}\nDelete the entry; the mapper reads it now." + ) + + +def test_no_addressed_entry_has_gone_stale() -> None: + """A table entry outlives its declaration silently; the file only ever grows.""" + declared = _declared() + stale = [entry for entry in (*_CONSUMED_WITHOUT_A_ROW, *_CONSUMED_OFF_SNAPSHOT) if entry not in declared] + assert not stale, f"addressed-property entries the reference tree no longer declares:\n{_rendered(stale)}" + + +def test_no_addressed_entry_duplicates_a_metadata_row() -> None: + """The four tables partition the addressed set; they do not overlap. + + A property with a `_PROPERTY_FIELD_MAP` row already states its unit and + datatype for a snapshot field. Listing it again as read-without-a-row would + make the second entry unfalsifiable — deleting it changes nothing, so the + experiment above could never report it stale. + """ + with_rows = {(device_type, node_id, property_id) for device_type, node_id, property_id, _field in _PROPERTY_FIELD_MAP} + duplicated = [entry for entry in (*_CONSUMED_WITHOUT_A_ROW, *_CONSUMED_OFF_SNAPSHOT) if entry in with_rows] + assert not duplicated, f"addressed twice, once with a metadata row:\n{_rendered(duplicated)}" + + +def test_every_off_snapshot_route_names_the_code_that_reads_it() -> None: + """A reason-less entry is an allowlist line wearing an exemption's clothes.""" + thin = [entry for entry, reason in _CONSUMED_OFF_SNAPSHOT.items() if len(reason.split()) < 6] + assert not thin, f"off-snapshot entries with no usable reason:\n{_rendered(thin)}" + + +# --- what discovery reports, and that it is exactly right ------------------- + + +def test_discovery_reports_only_declarations_that_move_nothing() -> None: + """The claim a discovered row makes, asserted against the mapper. + + A row that moves a snapshot field is a false positive: something does read + it, and reporting it as unread sends a maintainer looking for a gap that is + not there. + """ + moved = _moved() + reported = set(_discovered()) + false_positives = [entry for entry in sorted(_declared()) if _path(entry) in reported and moved[entry]] + assert not false_positives, ( + "discovery reports these as unread and republishing them moves a snapshot " + f"field:\n{_rendered(false_positives)}\nAdd them to _CONSUMED_WITHOUT_A_ROW." + ) + + +def test_discovery_finds_every_declaration_nothing_reads() -> None: + """The converse, so an over-broad addressed table cannot shrink the report. + + Together with the test above this pins the output exactly: discovery is the + set of declarations that move no snapshot field, less the three routes that + are consumed where no snapshot field can show it. + """ + moved = _moved() + reported = set(_discovered()) + missing = [ + entry + for entry in sorted(_declared()) + if not moved[entry] and entry not in _CONSUMED_OFF_SNAPSHOT and _path(entry) not in reported + ] + assert not missing, ( + "these declarations move no snapshot field and discovery does not report " + f"them:\n{_rendered(missing)}\nAn addressed-property table claims a reader " + "that does not exist." + ) + + +def test_discovery_is_not_empty_on_the_reference_tree() -> None: + """A report that is always empty passes every assertion above. + + The reference capture is known to declare properties nothing reads — the + `connection/count` pair no producer publishes, the two deliberate skips in + `status`, the redundant `*-device-type` echoes. If this ever legitimately + reaches zero, the tests above are the ones that keep meaning something and + this is the one to delete, deliberately. + """ + assert len(_discovered()) >= 5 + + +def test_discovery_names_the_datatype_and_unit_the_tree_declares() -> None: + description = _mapping(json.loads(_tree()[PANEL_DEVICE_ID]["$description"])) + status = _mapping(_mapping(description.get("nodes")).get(NODE_STATUS)) + declared = _mapping(_mapping(status.get("properties")).get("postal-code")) + row = _discovered()["discovered.distribution-enclosure/status/postal-code"] + assert row.datatype == _text(declared.get("datatype")) + assert row.unit == (_text(declared.get("unit")) or None) + + +def test_retained_says_whether_a_value_has_arrived_and_never_what_it_is() -> None: + """`retained` is the declared-but-never-valued signal, and the only value question asked.""" + rows = _discovered() + assert rows["discovered.distribution-enclosure/status/time-zone"].retained is True + assert rows["discovered.circuit/connection/count"].retained is False + + tree = _tree() + del tree[PANEL_DEVICE_ID]["status/time-zone"] + unvalued = build_discovery(_devices(tree)) + assert unvalued["discovered.distribution-enclosure/status/time-zone"].retained is False + + +def _declaration_strings(tree: Tree) -> set[str]: + """Every string that appears anywhere in the capture's ``$description`` documents. + + Keys and values alike, plus the pieces of each — comma-separated `format` + options and dot-separated type stems — because a row names a device type by + its tail and a `format` option by itself. Published values are deliberately + not walked: this is the vocabulary a row is permitted to be built from. + """ + found: set[str] = set() + + def walk(node: object) -> None: + if isinstance(node, str): + found.add(node) + found.update(node.split(",")) + found.update(node.split(".")) + elif isinstance(node, Mapping): + for key, value in node.items(): + found.add(str(key)) + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + for topics in tree.values(): + walk(json.loads(topics["$description"])) + return found + + +def test_no_published_value_reaches_a_discovery_row() -> None: + """The privacy constraint, asserted rather than reviewed. + + These rows are built to be forwarded in consumer diagnostics, which leave + the machine they were generated on, and the consumer's own redaction is + key-based and knows nothing about wire names — so nothing downstream can + protect a value put in here. + + Checked by provenance rather than by scanning for known strings, because a + scan is only as good as the capture's values happen to be distinctive. + Every string a row carries has to decompose into the vocabulary of the + `$description` documents, which hold no published values at all; the only + thing a row says about a value is the boolean `retained`. The scan runs too, + over the values that are *not* declaration vocabulary, as the empirical + half. + """ + tree = _tree() + allowed = _declaration_strings(tree) + rows = _discovered() + assert rows, "no rows, so this proves nothing" + + for path, row in rows.items(): + namespace, _, body = path.partition(".") + assert namespace == "discovered" + components = body.split("/") + assert len(components) == 3, f"{path} is not device-type/node/property" + for component in components: + assert component in allowed, f"{component!r} in {path} came from outside a declaration" + assert row.datatype in allowed + assert row.unit is None or row.unit in allowed + assert isinstance(row.retained, bool) + + published = { + value + for topics in tree.values() + for topic, value in topics.items() + if not topic.startswith("$") and value and value not in allowed + } + assert published, "every published value is also declaration vocabulary; the scan is vacuous" + emitted = "\n".join(f"{path} {row.datatype} {row.unit} {row.retained}" for path, row in rows.items()) + leaked = sorted(value for value in published if value in emitted) + assert not leaked, f"published values reached the discovery rows: {leaked}" + + +# --- the partition, and that it holds -------------------------------------- + + +def test_every_discovered_row_is_namespaced_and_no_curated_row_is() -> None: + """The partition a consumer applies, checked on the real metadata dict. + + `build_field_metadata` returns both kinds in one map, so the namespace is + the only thing standing between a discovered property and a consumer's + inventory of produced fields. + """ + metadata = build_field_metadata(devices_from_tree(parent_child_tree())) + discovered = set(_discovered()) + assert discovered, "no discovered rows, so the partition is untested" + assert discovered <= set(metadata) + + for path, row in metadata.items(): + if path in discovered: + assert is_discovery_path(path) + assert isinstance(row, DiscoveredMetadata) + else: + assert not is_discovery_path(path), f"{path} is curated and sits in the namespace" + assert not isinstance(row, DiscoveredMetadata) + + +def test_a_curated_field_path_is_never_a_discovery_path() -> None: + """No `_PROPERTY_FIELD_MAP` row can collide with the namespace.""" + for _device_type, _node, _prop, field_path in _PROPERTY_FIELD_MAP: + assert not is_discovery_path(field_path) + + +# --- it bites in both directions ------------------------------------------- + + +def test_a_property_nothing_reads_appears_with_its_declared_datatype_and_unit() -> None: + """Add a declaration to a copy of the tree; discovery must name it. + + The whole point of the runtime half: a panel in the field that starts + publishing a property is invisible to a vendored capture until somebody + recaptures, and this is the mechanism that makes it visible without one. + """ + tree = _tree() + description = _mapping(json.loads(tree[PANEL_DEVICE_ID]["$description"])) + nodes = dict(_mapping(description.get("nodes"))) + status = dict(_mapping(nodes.get(NODE_STATUS))) + status["properties"] = { + **_mapping(status.get("properties")), + "enclosure-temperature": { + "name": "Enclosure temperature", + "datatype": "float", + "unit": "°C", + }, + } + nodes[NODE_STATUS] = status + tree[PANEL_DEVICE_ID]["$description"] = json.dumps({**description, "nodes": nodes}) + tree[PANEL_DEVICE_ID]["status/enclosure-temperature"] = "41.5" + + row = build_discovery(_devices(tree))["discovered.distribution-enclosure/status/enclosure-temperature"] + assert row.datatype == "float" + assert row.unit == "°C" + assert row.retained is True + assert "41.5" not in repr(row) + + +def test_a_property_that_becomes_read_leaves_the_report(monkeypatch: pytest.MonkeyPatch) -> None: + """The other direction: once something addresses a property, it stops being reported. + + Mapping is what a maintainer does in response to a discovered row, so the + row disappearing is the acceptance criterion for that work. Patched rather + than edited so the test states the rule instead of tracking whichever + property happens to be unread this month. + """ + path = "discovered.distribution-enclosure/status/postal-code" + assert path in _discovered() + + monkeypatch.setattr( + field_metadata_module, + "_ADDRESSED", + _ADDRESSED | {(TYPE_PANEL, NODE_STATUS, "postal-code")}, + ) + assert path not in build_discovery(devices_from_tree(parent_child_tree())) + + +def test_a_subtyped_device_does_not_report_its_parents_mapped_properties() -> None: + """Subtyping is the false positive that would make the report unreadable. + + Firmware may declare `…device.lugs.upstream` rather than `…device.lugs` with + a direction property. Every mapped lugs property would then look + unaddressed — ten panel readings reported as newly discovered on a panel + where nothing changed. + """ + tree = _tree() + description = _mapping(json.loads(tree["lugs-upstream"]["$description"])) + tree["lugs-upstream"]["$description"] = json.dumps({**description, "type": f"{TYPE_LUGS}.upstream"}) + + rows = build_discovery(_devices(tree)) + assert not [path for path in rows if path.startswith("discovered.lugs.upstream/meter/")] + assert "discovered.lugs.upstream/connection/count" in rows + + +def test_the_charge_current_pair_is_addressed_by_resolution_not_by_a_table() -> None: + """A charger names its own charge-limit node, so discovery must resolve it too. + + `_charge_limit_metadata` produces rows for whichever spelling the charger + declares. A hardcoded pair here would report the other spelling as unread on + every charger that used it. + """ + rows = _discovered() + assert not [path for path in rows if path.endswith("max-charge-current")] + + +def test_a_device_mid_discovery_contributes_nothing() -> None: + """A device the tree names before it has described itself is normal, not a finding.""" + undescribed = DiscoveredDevice("not-yet-described", "ebus") + assert not build_discovery([undescribed]) diff --git a/tests/test_schema_one_extension.py b/tests/test_schema_one_extension.py new file mode 100644 index 0000000..dac7f2e --- /dev/null +++ b/tests/test_schema_one_extension.py @@ -0,0 +1,303 @@ +"""What the adapter emits as vendor extensions on devices it *does* model. + +`build_extension_properties` is the value-carrying twin of `build_discovery`: +the same declared-but-unaddressed question, asked of the same tree, answered for +a consumer that will render it rather than for a maintainer reading an +attachment. The two must agree exactly, so the first test here is the join — +every extension row has a discovery row and vice versa. + +The rest are structural, and they are structural on purpose. "Adopted values +never reach diagnostics" and "an extension property is read-only" are claims the +design makes about *shapes*, so they are asserted about shapes: a type that is +not a `FieldMetadata` cannot enter the metadata map that diagnostics is built +from, and a type with no set-topic member cannot grow a write path by someone +forgetting a rule. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import json + +from ebus_sdk.homie import DiscoveredDevice +import pytest + +from span_panel_api.models import ( + ADOPTION_IDENTITY_NODE, + ADOPTION_TOPOLOGY_NODE, + ExtensionProperty, + ExtensionSubject, + FieldMetadata, + SpanPanelSnapshot, + discovery_path, +) +from span_panel_api_schema_1.extension import build_extension_properties +from span_panel_api_schema_1.field_metadata import addressed_rows, build_discovery +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree +from span_panel_api_schema_1.snapshot import build_snapshot + +PANEL_DEVICE_ID = "example-40t-001" +"""The enclosure in the reference capture. Every other device is its child.""" + +Tree = dict[str, dict[str, str]] + + +def _tree() -> Tree: + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _snapshot(tree: Tree) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL_DEVICE_ID, tree[PANEL_DEVICE_ID]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL_DEVICE_ID] + return build_snapshot(panel, children) + + +def _devices(tree: Tree) -> list[DiscoveredDevice]: + return [device_from_topics(device_id, topics) for device_id, topics in tree.items()] + + +def _short_type(device_type: str) -> str: + return device_type.rsplit(".", 1)[-1] + + +def _declared_type(tree: Tree, device_id: str) -> str: + description: Mapping[str, object] = json.loads(tree[device_id]["$description"]) + return str(description.get("type") or "") + + +# --- the join with discovery ------------------------------------------------ + + +def test_every_extension_row_is_also_a_discovery_row() -> None: + """The two surfaces describe the same properties, joined by the wire path. + + A property in one and not the other is the defect this test exists for: an + entity a consumer renders while the diagnostics report it ignored, or a + property reported ignored while an entity shows its value. Both read as a + bug in whichever surface disagreed. + """ + tree = _tree() + snapshot = _snapshot(tree) + discovered = set(build_discovery(_devices(tree))) + + for row in snapshot.extension_properties: + # The discovery path is keyed by the *device type*, so rebuild it from + # the subject's device rather than from the subject kind, which is a + # snapshot concept. + assert any( + path.endswith(f"/{row.path}") for path in discovered + ), f"extension row {row.path} on {row.subject.kind} has no discovery row" + + +def test_no_extension_row_is_addressed() -> None: + """An addressed property has a snapshot field; surfacing it twice is the bug.""" + tree = _tree() + addressed = addressed_rows(_devices(tree)) + for row in _snapshot(tree).extension_properties: + assert not any( + node == row.node_id and prop == row.property_id for _type, node, prop in addressed + ), f"{row.path} is addressed and must not be emitted as an extension" + + +# --- structure: the diagnostics and read-only guarantees -------------------- + + +def test_extension_property_is_not_field_metadata() -> None: + """The diagnostics guarantee, asserted as a shape rather than as a rule. + + `partition()` walks `build_field_metadata()`; a type that cannot enter that + map has no path into a payload that leaves the machine. + """ + row = ExtensionProperty( + subject=ExtensionSubject(kind="battery"), + node_id="battery-2", + property_id="cell-temperature", + datatype="float", + ) + assert not isinstance(row, FieldMetadata) + + +def test_extension_property_has_no_write_surface() -> None: + """No set topic, and no member one could be put in. + + A literal `hasattr` check, because the point is to fail the change that adds + one rather than to describe today's fields. + """ + row = ExtensionProperty( + subject=ExtensionSubject(kind="evse", instance_key="acme-001"), + node_id="acme", + property_id="charge-limit", + datatype="float", + settable=True, + ) + assert not hasattr(row, "set_topic") + assert row.settable is True, "settable is carried for triage, and still carries no write path" + + +def test_identity_and_topology_nodes_are_never_extensions() -> None: + """`info` and `connection` resolve to the device card and the tree. + + Excluded by node, as `adoption._readings` excludes them, because the + catalogs carry no marker for "this string is a device reference" and a name + list goes stale silently. + """ + for row in _snapshot(_tree()).extension_properties: + assert row.node_id not in (ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE) + + +# --- emission against a synthetic vendor extension -------------------------- + + +VENDOR_NODE = "battery-2" + + +def _with_vendor_extension(tree: Tree, device_id: str) -> Tree: + """Add an Acme pack node to one device's description, with one retained value.""" + mutated = {other: dict(topics) for other, topics in tree.items()} + description = json.loads(mutated[device_id]["$description"]) + description["nodes"][VENDOR_NODE] = { + "name": VENDOR_NODE, + "type": "energy.ebus.capability.vendor.acme.pack", + "properties": { + "cell-temperature": {"name": "Cell temperature", "datatype": "float", "unit": "°C"}, + "pack-enabled": {"name": "Pack enabled", "datatype": "boolean", "settable": True}, + }, + } + mutated[device_id]["$description"] = json.dumps(description) + mutated[device_id][f"{VENDOR_NODE}/cell-temperature"] = "31.4" + return mutated + + +def _bess_device_id(tree: Tree) -> str: + for device_id in tree: + if _declared_type(tree, device_id).endswith(".bess"): + return device_id + pytest.skip("reference tree carries no BESS") + + +def test_a_vendor_node_on_a_modelled_device_becomes_extension_rows() -> None: + """The whole point: a property hung off the BESS reaches the snapshot.""" + tree = _tree() + device_id = _bess_device_id(tree) + snapshot = _snapshot(_with_vendor_extension(tree, device_id)) + + rows = {row.path: row for row in snapshot.extension_properties} + assert f"{VENDOR_NODE}/cell-temperature" in rows + assert f"{VENDOR_NODE}/pack-enabled" in rows + + temperature = rows[f"{VENDOR_NODE}/cell-temperature"] + assert temperature.subject.kind == "battery" + assert temperature.subject.instance_key is None + assert temperature.datatype == "float" + assert temperature.unit == "°C" + assert temperature.value == "31.4" + # Declared and never valued is distinguishable from valued: `None` rather + # than an invented default, so a consumer can tell "nothing has arrived". + assert rows[f"{VENDOR_NODE}/pack-enabled"].value is None + assert rows[f"{VENDOR_NODE}/pack-enabled"].settable is True + + +def test_a_wholly_vendor_node_has_no_curated_siblings() -> None: + """The one exported bit of the node-to-field map, on a node with none.""" + tree = _tree() + snapshot = _snapshot(_with_vendor_extension(tree, _bess_device_id(tree))) + rows = [row for row in snapshot.extension_properties if row.node_id == VENDOR_NODE] + assert rows + assert all(row.node_has_curated_siblings is False for row in rows) + + +def test_an_extension_to_a_curated_node_reports_curated_siblings() -> None: + """A vendor extending `meter` is extending something this adapter reads.""" + tree = _tree() + device_id = _bess_device_id(tree) + mutated = {other: dict(topics) for other, topics in tree.items()} + description = json.loads(mutated[device_id]["$description"]) + meter = description["nodes"].get("meter") + if meter is None: + pytest.skip("reference BESS declares no meter node") + meter["properties"]["acme-cell-balance"] = {"name": "Cell balance", "datatype": "float", "unit": "%"} + mutated[device_id]["$description"] = json.dumps(description) + + rows = [row for row in _snapshot(mutated).extension_properties if row.property_id == "acme-cell-balance"] + assert len(rows) == 1 + assert rows[0].node_has_curated_siblings is True + # `%` is deliberately unrankable: the consumer maps no device class for it. + assert rows[0].unit == "%" + + +def test_an_undeclared_device_is_skipped_rather_than_emitted() -> None: + """A device mid-discovery declares no type; that is a state, not a finding.""" + tree = _tree() + device_id = _bess_device_id(tree) + mutated = {other: dict(topics) for other, topics in tree.items()} + description = json.loads(mutated[device_id]["$description"]) + description.pop("type", None) + mutated[device_id]["$description"] = json.dumps(description) + + subjects = [(device_from_topics(device_id, mutated[device_id]), ExtensionSubject(kind="battery"))] + assert build_extension_properties(subjects, addressed_rows(_devices(mutated))) == () + + +def test_the_reference_tree_alone_emits_nothing_addressed() -> None: + """A sanity floor: every row the untouched tree emits is genuinely unread.""" + snapshot = _snapshot(_tree()) + addressed = addressed_rows(_devices(_tree())) + for row in snapshot.extension_properties: + assert not any(node == row.node_id and prop == row.property_id for _t, node, prop in addressed) + + +def test_discovery_path_joins_the_two_surfaces() -> None: + """The documented join key actually joins.""" + tree = _tree() + device_id = _bess_device_id(tree) + mutated = _with_vendor_extension(tree, device_id) + discovered = set(build_discovery(_devices(mutated))) + expected = discovery_path(_short_type(_declared_type(mutated, device_id)), VENDOR_NODE, "cell-temperature") + assert expected in discovered + + rows = {row.path for row in _snapshot(mutated).extension_properties} + assert f"{VENDOR_NODE}/cell-temperature" in rows + assert expected.endswith(f"/{VENDOR_NODE}/cell-temperature") + + +def test_the_two_lugs_devices_are_two_subjects() -> None: + """Identical firmware on both lugs is what made one subject a collision. + + A vendor extension on the upstream lugs is the expected case of the same + extension on the downstream lugs, so folding both into `panel` gave two wire + addresses one identity -- a consumer keying on + `(kind, instance_key, node/property)` would mint one id for two readings and + show whichever sorted first. + """ + tree = _tree() + lugs = [ + device_id + for device_id in tree + if ".lugs" in _declared_type(tree, device_id) or _declared_type(tree, device_id).endswith("lugs") + ] + if len(lugs) < 2: + pytest.skip("reference tree carries fewer than two lugs devices") + + mutated = {other: dict(topics) for other, topics in tree.items()} + for device_id, value in zip(lugs, ("1.5", "99.9"), strict=False): + description = json.loads(mutated[device_id]["$description"]) + description["nodes"]["acme"] = { + "name": "acme", + "type": "energy.ebus.capability.vendor.acme.balance", + "properties": {"phase-balance": {"name": "Phase balance", "datatype": "float", "unit": "%"}}, + } + mutated[device_id]["$description"] = json.dumps(description) + mutated[device_id]["acme/phase-balance"] = value + + rows = [row for row in _snapshot(mutated).extension_properties if row.path == "acme/phase-balance"] + assert len(rows) == 2 + assert all(row.subject.kind == "lugs" for row in rows) + assert {row.subject.instance_key for row in rows} == {"upstream", "downstream"} + # Distinct identities carrying distinct readings, which is the point. + assert {row.value for row in rows} == {"1.5", "99.9"} + + +def test_every_subject_identity_is_unique_per_property() -> None: + """No two rows may share `(kind, instance_key, path)` -- that tuple is the identity.""" + identities = [(row.subject.kind, row.subject.instance_key, row.path) for row in _snapshot(_tree()).extension_properties] + assert len(identities) == len(set(identities)) diff --git a/tests/test_schema_one_panel.py b/tests/test_schema_one_panel.py new file mode 100644 index 0000000..3d8550f --- /dev/null +++ b/tests/test_schema_one_panel.py @@ -0,0 +1,614 @@ +"""Panel-level mapping from the v1.0 tree. + +Driven from the tree this distribution ships as package data, captured off a +real `panel_sim` parent/child tree. +""" + +from __future__ import annotations + +import json + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api_schema_1.const import NODE_GRID, TYPE_BESS, TYPE_PV +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree +from span_panel_api_schema_1.panel import ( + PanelFields, + build_unmapped_tabs, + find_lugs, + panel_model_drift, + panel_size_from_model, + resolve_dominant_power_source, + resolve_grid_forming_device_name, + resolve_grid_islandable, + resolve_islanding_state, + resolve_run_config, +) + +_TREE = parent_child_tree() + +PANEL = "example-40t-001" +MID = "bess-mid" + + +def _device(device_id: str) -> DiscoveredDevice: + return device_from_topics(device_id, _TREE[device_id]) + + +def _published(device_id: str, topic: str) -> str: + """What the capture publishes on this topic, or fail saying it does not. + + Expectations are computed from this rather than written as literals, so a + test cannot keep passing against a capture that stopped carrying the value + it is about. + """ + value = _TREE[device_id].get(topic) + assert value is not None, f"{device_id} publishes no {topic} in the capture" + return value + + +def _panel_with(**overrides: str | None) -> DiscoveredDevice: + """The captured panel with topics rewritten, or unpublished where `None`. + + Keyword spelling is `node__property_name`, matching `_synthetic` below. + Unpublishing is what a panel whose firmware omits a property looks like, and + it is a different event from publishing an empty string. + """ + topics = dict(_TREE[PANEL]) + for path, value in overrides.items(): + node, _, prop = path.partition("__") + topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}" + if value is None: + topics.pop(topic, None) + else: + topics[topic] = value + return device_from_topics(PANEL, topics) + + +def _fields_for(panel: DiscoveredDevice) -> PanelFields: + return PanelFields(panel=panel, upstream_lugs=None, downstream_lugs=None, mid=None) + + +@pytest.fixture(name="fields") +def _fields() -> PanelFields: + return PanelFields( + panel=_device(PANEL), + upstream_lugs=_device("lugs-upstream"), + downstream_lugs=_device("lugs-downstream"), + mid=_device("bess-mid"), + ) + + +def test_identity(fields: PanelFields) -> None: + assert fields.serial_number == "example-40t-001" + assert fields.firmware_version == "example/v0.1.0" + + +def test_hardware_status(fields: PanelFields) -> None: + assert fields.main_relay_state == "CLOSED" + assert fields.door_state == "CLOSED" + assert fields.eth0_link is True + assert fields.wlan_link is True + assert fields.main_breaker_rating_a == 200 + + +def test_wwan_link_reports_cloud_reachability(fields: PanelFields) -> None: + """v1 exposed a WWAN radio link and v2 has no such property, so the flat + adapter reported cloud reachability. Kept identical so the entity does not + change meaning between adapters.""" + assert fields.vendor_cloud == "CONNECTED" + assert fields.wwan_link is True + + +def test_voltages_come_from_the_panel_meter(fields: PanelFields) -> None: + assert fields.l1_voltage == 120.0 + assert fields.l2_voltage == 120.0 + + +def test_power_flows(fields: PanelFields) -> None: + assert fields.power_flow_pv == 8500.0 + assert fields.power_flow_battery == -3500.0 + assert fields.power_flow_grid == -2347.0 + assert fields.power_flow_site == 2653.0 + + +# --------------------------------------------------------------------------- +# Lugs — the direction rule that is the opposite of a circuit's +# --------------------------------------------------------------------------- + + +def test_grid_power_is_not_negated(fields: PanelFields) -> None: + """The enclosure frame already reports import-positive at the lugs, which + is what consumption means there. Applying the circuit rule would invert + every grid figure while leaving it entirely plausible.""" + raw = _device("lugs-upstream").get_property("meter", "active-power") + assert raw == "-5847.0" + + assert fields.instant_grid_power_w == -5847.0 + + +def test_main_meter_energy_maps_imported_to_consumed(fields: PanelFields) -> None: + """Opposite of a circuit: the panel imports from the grid, so imported + energy is what the house consumed.""" + upstream = _device("lugs-upstream") + assert upstream.get_property("meter", "imported-energy") == "44.21666666666666" + + assert fields.main_meter_energy_consumed_wh == pytest.approx(44.2166, rel=1e-4) + assert fields.main_meter_energy_produced_wh == pytest.approx(141.6666, rel=1e-4) + + +def test_per_phase_currents(fields: PanelFields) -> None: + """Lugs expose `current-a`/`current-b`; circuits expose a single `current`. + Same capability type, different property set.""" + assert fields.upstream_l1_current_a == pytest.approx(46.4666, rel=1e-4) + assert fields.upstream_l2_current_a == pytest.approx(46.4749, rel=1e-4) + assert fields.downstream_l1_current_a == pytest.approx(46.4666, rel=1e-4) + + +def test_feedthrough_comes_from_the_downstream_lugs(fields: PanelFields) -> None: + assert fields.feedthrough_power_w == -5847.0 + assert fields.feedthrough_energy_consumed_wh == pytest.approx(44.2166, rel=1e-4) + + +def test_lugs_are_found_by_declared_direction_not_device_id() -> None: + """Device ids in the reference tree are the simulator's naming; direction + is what the schema defines.""" + devices = [_device("lugs-downstream"), _device("lugs-upstream")] + + assert find_lugs(devices, upstream=True).device_id == "lugs-upstream" + assert find_lugs(devices, upstream=False).device_id == "lugs-downstream" + + +def test_missing_lugs_yield_zeros_not_errors() -> None: + """A panel without lugs devices must still produce a snapshot.""" + fields = PanelFields(panel=_device(PANEL), upstream_lugs=None, downstream_lugs=None, mid=None) + + assert fields.instant_grid_power_w == 0.0 + assert fields.upstream_l1_current_a is None + assert fields.grid_state is None + + +# --------------------------------------------------------------------------- +# Moved and retired +# --------------------------------------------------------------------------- + + +def test_grid_state_comes_from_the_mid(fields: PanelFields) -> None: + """It moved off the panel to the device where islanding is decided. + + And it comes from `islanding-state`, keeping the flat schema's + ON_GRID/OFF_GRID vocabulary. + """ + assert fields.grid_state == "ON_GRID" + + +def test_grid_state_is_not_the_mids_utility_health_signal() -> None: + """The MID publishes two grid properties and only one of them is this. + + `grid/grid-state` answers whether the utility supply is UP, DOWN or + DEGRADED — new in v1.0, with no flat equivalent. `grid/islanding-state` + answers ON_GRID/OFF_GRID, which is what the flat schema's `grid_state` + meant and what every existing template compares against. Taking the + similarly-named one keeps the entity's id and history while silently + changing its vocabulary, so this pins the distinction rather than trusting + it. + """ + mid = _device(MID) + assert mid.get_property(NODE_GRID, "grid-state") == "UP" + assert mid.get_property(NODE_GRID, "islanding-state") == "ON_GRID" + + fields = PanelFields(panel=_device(PANEL), upstream_lugs=None, downstream_lugs=None, mid=mid) + + assert fields.grid_state == "ON_GRID" + + +def test_retired_fields_are_none_rather_than_substituted(fields: PanelFields) -> None: + """`dominant-power-source` split into grid-forming-entity plus + asserted-islanding-state, and `grid-islandable` was removed outright. + Substituting either would be a silent product decision.""" + assert fields.dominant_power_source is None + assert fields.grid_islandable is None + + +# --------------------------------------------------------------------------- +# Panel size — from the model, because nothing else states it +# --------------------------------------------------------------------------- + + +def test_panel_size_comes_from_the_model() -> None: + """`info/model` is the only place v1.0 states the panel's size, and it is a + closed enum the panel itself advertises via Homie `$format`.""" + assert panel_size_from_model("MAIN_40") == 40 + assert panel_size_from_model("MAIN_16") == 16 + assert panel_size_from_model("MLO_48") == 48 + + +def test_panel_size_reads_the_model_off_the_fixture() -> None: + panel = _device(PANEL) + + assert panel.get_property("info", "model") == "MAIN_40" + assert panel_size_from_model(panel.get_property("info", "model")) == 40 + + +def test_an_unknown_model_yields_no_size_rather_than_a_guess() -> None: + """Inventing a size is worse than reporting none: a wrong total fabricates + unmapped positions that do not exist, or hides real ones.""" + assert panel_size_from_model("MAIN_99") == 0 + assert panel_size_from_model("") == 0 + + +def test_the_panel_advertises_every_model_we_can_size(caplog: pytest.LogCaptureFixture) -> None: + """The panel publishes the valid model set as `$format`, but neither the + schema nor the SDK states the sizes — that half is ours. This is how a model + we cannot size shows up at connect time instead of as missing positions.""" + definition = _device(PANEL).get_node_properties("info")["model"] + assert definition["format"] == "MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48" + + assert panel_model_drift(_device(PANEL)) == () + + +def test_a_model_we_cannot_size_is_reported_as_drift() -> None: + panel = _device(PANEL) + description = json.loads(_TREE[PANEL]["$description"]) + description["nodes"]["info"]["properties"]["model"]["format"] = "MAIN_40,MAIN_64" + panel.update_description(json.dumps(description)) + + assert panel_model_drift(panel) == ("MAIN_64",) + + +# --------------------------------------------------------------------------- +# Unmapped positions — reproducible under v1.0 only because the model gives a total +# --------------------------------------------------------------------------- + + +def test_unmapped_tabs_fill_every_unoccupied_position() -> None: + unmapped = build_unmapped_tabs(panel_size=6, occupied={1, 3}) + + assert sorted(unmapped) == [ + "unmapped_tab_2", + "unmapped_tab_4", + "unmapped_tab_5", + "unmapped_tab_6", + ] + assert unmapped["unmapped_tab_2"].tabs == [2] + assert unmapped["unmapped_tab_2"].instant_power_w == 0.0 + assert unmapped["unmapped_tab_2"].name == "Unmapped Tab 2" + + +def test_the_unmapped_id_format_matches_the_flat_adapter() -> None: + """The integration builds entity ids from this — `sensor.span_panel_ + unmapped_tab_32_power` — so a rename would strand existing entities.""" + unmapped = build_unmapped_tabs(panel_size=32, occupied=set(range(1, 32))) + + assert list(unmapped) == ["unmapped_tab_32"] + + +def test_a_fully_occupied_panel_has_no_unmapped_positions() -> None: + assert build_unmapped_tabs(panel_size=4, occupied={1, 2, 3, 4}) == {} + + +def test_an_unsizable_panel_yields_no_unmapped_positions() -> None: + """Better nothing than a fabricated set: size 0 is what an unknown model + reports, and inventing positions would create phantom entities.""" + assert build_unmapped_tabs(panel_size=0, occupied={1}) == {} + + +# --------------------------------------------------------------------------- +# Grid answers: read, not derived — the 2026-08-10 decision +# --------------------------------------------------------------------------- + + +def _synthetic(device_id: str, state: str = "ready", **props: str) -> DiscoveredDevice: + """A device built from nothing, for the cases no capture contains. + + The tracked producer models a BESS as one device with a MID child and no + `inverter`, so `grid-forming/capable` has nowhere to live in any fixture. That is + recorded in `_NOT_EXERCISED_BY_SIMULATOR`; this is what stops the mapping being + merely untested as well as unexercised. + """ + device = DiscoveredDevice(device_id, "ebus") + device.update_state(state) + for path, value in props.items(): + # `node__prop_name` -> node/prop-name, since the wire spells both with hyphens + # and a Python keyword cannot. + node, _, prop = path.partition("__") + device.update_property(node.replace("_", "-"), prop.replace("_", "-"), value) + return device + + +def test_islanding_is_sensed_when_the_mid_is_ready() -> None: + """Tier 1. The MID is the islanding authority, so its answer wins outright.""" + mid = _synthetic("mid", grid__islanding_state="OFF_GRID") + panel = _synthetic(PANEL, shed__asserted_islanding_state="ON_GRID") + + assert resolve_islanding_state(mid, panel) == "OFF_GRID", "a ready MID outranks the user's assertion" + + +def test_a_stale_mid_falls_back_to_the_users_assertion() -> None: + """Tier 2, and the case the assertion control exists for. + + When comms to the BESS or MID are lost and the grid returns, the user asserts the + grid is up so the BESS stops discharging. Declining to read it would wire the + control and then ignore it at exactly the moment it matters. + """ + mid = _synthetic("mid", state="lost", grid__islanding_state="OFF_GRID") + panel = _synthetic(PANEL, shed__asserted_islanding_state="ON_GRID") + + assert resolve_islanding_state(mid, panel) == "ON_GRID" + + +def test_a_stale_mid_with_no_assertion_is_unknown_not_guessed() -> None: + """Tier 4. `NONE` is the assertion's idle value, not an answer.""" + mid = _synthetic("mid", state="lost", grid__islanding_state="ON_GRID") + panel = _synthetic(PANEL, shed__asserted_islanding_state="NONE") + + assert resolve_islanding_state(mid, panel) is None + + +def test_no_mid_reads_grid_power_and_never_asserts_off_grid() -> None: + """Tier 3, and the error worth keeping a test on. + + An earlier draft reasoned that no MID means no islanding authority means on-grid. + A missing MID means *SPAN* is not the authority and says nothing about whether the + site is islanded — a generator-fed island is the counterexample. Grid power flowing + is positive evidence of being on-grid; its absence is not evidence of the opposite. + """ + assert resolve_islanding_state(None, _synthetic(PANEL, power_flows__grid="2400.0")) == "ON_GRID" + assert resolve_islanding_state(None, _synthetic(PANEL, power_flows__grid="0.0")) is None + assert resolve_islanding_state(None, _synthetic(PANEL)) is None + + +def test_run_config_names_the_forming_device_rather_than_guessing_it() -> None: + """The part that gets better than flat. + + Flat guessed `PANEL_BACKUP` versus `PANEL_OFF_GRID` from `dominant-power-source`. + v1.0 names the forming device, and its class is recoverable from the tree. + """ + types = {"bess-1": TYPE_BESS, "gen-1": "energy.ebus.device.generator"} + + on_grid = _synthetic("mid", grid__grid_forming_entity="GRID") + backup = _synthetic("mid", grid__grid_forming_entity="bess-1") + off_grid = _synthetic("mid", grid__grid_forming_entity="gen-1") + + assert resolve_run_config(on_grid, "ON_GRID", types) == "PANEL_ON_GRID" + assert resolve_run_config(backup, "OFF_GRID", types) == "PANEL_BACKUP" + assert resolve_run_config(off_grid, "OFF_GRID", types) == "PANEL_OFF_GRID" + + +def test_run_config_degrades_honestly_when_the_forming_entity_is_unusable() -> None: + """Unresolvable is not an excuse to pick one. + + Without knowing what is forming the grid, off-grid cannot be split into backup + versus off-grid, so it reports unknown. On-grid still answers, because the islanding + tier already established it. + """ + unresolvable = _synthetic("mid", grid__grid_forming_entity="a-device-not-in-this-tree") + + assert resolve_run_config(unresolvable, "OFF_GRID", {}) == "UNKNOWN" + assert resolve_run_config(unresolvable, "ON_GRID", {}) == "PANEL_ON_GRID" + assert resolve_run_config(None, None, {}) == "UNKNOWN" + + +def test_grid_islandable_is_the_disjunction_over_inverters() -> None: + """Flat's `grid_islandable`, relocated to where the capability actually lives. + + A panel does not island, its DER does; flat expressed a property of the DER as a + property of the enclosure. BESS model 0.14 puts grid-forming on the `inverter` + child, so the panel-level answer is "can any inverter here form a grid". + """ + capable = _synthetic("inv-1", grid_forming__capable="true") + incapable = _synthetic("inv-2", grid_forming__capable="false") + + assert resolve_grid_islandable([capable]) is True + assert resolve_grid_islandable([incapable]) is False + assert resolve_grid_islandable([incapable, capable]) is True, "one grid-forming inverter is enough" + + +def test_an_inverter_that_says_nothing_is_unknown_not_incapable() -> None: + """`None`, not `False`. Absence means unknown. + + Reporting "cannot island" for a panel that has not told us turns a gap into a claim, + and the integration declines to create the entity on `None` — an absent entity is + the honest outcome, a confidently wrong one is not. + """ + assert resolve_grid_islandable([_synthetic("inv-1")]) is None + assert resolve_grid_islandable([]) is None + + +def test_dominant_power_source_dereferences_the_forming_device_to_a_class() -> None: + """The entity keeps flat's closed enum, so nothing comparing to `BATTERY` breaks. + + The integration's sensor for this field is already named `grid_forming_entity`, so + v1.0's property is the same concept it has always shown. Only the encoding changed: + flat published a source class, v1.0 names the device. Dereferencing recovers the + class from the tree. + """ + types = {"bess-1": TYPE_BESS, "pv-1": TYPE_PV} + + assert resolve_dominant_power_source(_synthetic("mid", grid__grid_forming_entity="GRID"), types) == "GRID" + assert resolve_dominant_power_source(_synthetic("mid", grid__grid_forming_entity="bess-1"), types) == "BATTERY" + assert resolve_dominant_power_source(_synthetic("mid", grid__grid_forming_entity="pv-1"), types) == "PV" + + +def test_an_unresolvable_forming_entity_cannot_escape_as_a_raw_id() -> None: + """`UNKNOWN` is in flat's enum already, so the value space stays closed. + + A device id naming something outside this tree, or a class with no mapping, must not + reach an entity as an opaque string — that is exactly the silent break the decision + to dereference exists to avoid. The device-type registry instructs consumers to + tolerate unknown `$type` values; this is what tolerating one looks like. + """ + stranger = _synthetic("mid", grid__grid_forming_entity="some-device-not-in-this-tree") + unmapped = _synthetic("mid", grid__grid_forming_entity="wh-1") + + assert resolve_dominant_power_source(stranger, {}) == "UNKNOWN" + assert resolve_dominant_power_source(unmapped, {"wh-1": "energy.ebus.device.water-heater"}) == "UNKNOWN" + + +def test_a_panel_with_no_mid_reports_the_grid_as_forming() -> None: + """Elimination, not a guess, and it restores an answer flat already gave. + + A commissioned MID is what SPAN islands with, so its absence rules out every + other value this field can take. `BATTERY` needs a BESS and a BESS brings a + MID; `PV` cannot form a grid alone, because anything that can is a + grid-forming inverter and therefore a MID; `NONE` describes a panel supplying + nothing, which is a panel that is not publishing. What remains is a generator, + and that is two cases of which only one reaches here — one wired through a MID + is named by that MID and answered before this point, while one with no MID + interface is what SPAN treats as the grid, and is the only kind an install + with no MID can have. So this keeps holding if MID-integrated generators + arrive: they bring a MID. + + Observed: a live no-BESS panel read `Grid` on flat all night and went + `Unknown` the moment it upgraded, because the property moved onto a device + that install does not have. Nothing about the site changed. + """ + assert resolve_dominant_power_source(None, {}) == "GRID" + + +def test_a_mid_that_has_not_answered_is_unknown_rather_than_grid() -> None: + """Distinct from having no MID at all, and the distinction is the whole point. + + An islanding authority exists and has not said what is forming the grid. That + is genuinely unknown — unlike an install with no such authority, where the + answer is settled by what cannot be there. + """ + silent = _synthetic("mid", grid__grid_forming_entity="") + + assert resolve_dominant_power_source(silent, {}) is None + + +def test_the_forming_device_is_named_readably_not_by_wire_id() -> None: + """A Homie device id is not a Home Assistant device id. + + `sim-40t-001-SIM-BESS-40T-001` on a dashboard is worse than nothing. The device's own + `$description.name` is what a person recognises, and it is the precision v1.0 adds + over flat — *which* battery, not merely that a battery is forming. + """ + names = {"bess-1": "Battery", "pv-1": "Solar"} + + assert resolve_grid_forming_device_name(_synthetic("mid", grid__grid_forming_entity="bess-1"), names) == "Battery" + # The grid is not a device, so there is nothing to name. + assert resolve_grid_forming_device_name(_synthetic("mid", grid__grid_forming_entity="GRID"), names) is None + # Unresolvable: the raw id stays on `grid_forming_entity` for anyone who needs it. + assert resolve_grid_forming_device_name(_synthetic("mid", grid__grid_forming_entity="ghost"), names) is None + + +def test_the_panel_reads_the_network_it_is_joined_to(fields: PanelFields) -> None: + """`status/wifi-ssid`, the property whose absence was a flat -> v1.0 regression. + + Flat published `core/wifi-ssid`, the integration surfaces it as an attribute, + and schema_1 initialised the field to `None` and mapped nothing to it. Every + conformance check agreed that was fine, because each of them asks whether a + declaration has a reader and none asks whether a *user-visible* value + survived the schema change. + """ + assert fields.wifi_ssid == _published(PANEL, "status/wifi-ssid") + + +def test_an_unpublished_ssid_stays_absent_rather_than_becoming_empty() -> None: + """`None`, not `""`: the consumer omits the attribute entirely for `None`.""" + assert _fields_for(_panel_with(status__wifi_ssid=None)).wifi_ssid is None + assert _fields_for(_panel_with(status__wifi_ssid="")).wifi_ssid is None + + +def test_the_panel_carries_its_own_build_identity(fields: PanelFields) -> None: + """Vendor, model and hardware revision, for the enclosure's device card. + + The model string is the same property `panel_size` is derived from, kept + beside the derived integer rather than instead of it: the size builds + circuits, the designation is what a person reads on a device card. + """ + assert fields.vendor_name == _published(PANEL, "info/vendor-name") + assert fields.model == _published(PANEL, "info/model") + assert fields.hardware_version == _published(PANEL, "info/hardware-version") + + +def test_a_panel_publishing_no_identity_reports_none_so_a_consumer_can_fall_back() -> None: + """Absence must be `None`, because the consumer owns the fallback text. + + The integration has shown "Span" and "SPAN Panel" on the panel's device card + since before either was readable. A default invented here would replace that + text with a different one, on every panel that publishes nothing, which is a + change no user asked for and none would recognise as ours. + """ + bare = _fields_for(_panel_with(info__vendor_name=None, info__model=None, info__hardware_version=None)) + + assert bare.vendor_name is None + assert bare.model is None + assert bare.hardware_version is None + + +def test_the_shed_policy_is_parsed_into_its_algorithm_and_thresholds(fields: PanelFields) -> None: + """`shed/policy` is a JSON document; the two SoC thresholds are what a consumer shows. + + Asserted against the document the capture publishes rather than against + literals, so the parse is checked against the producer's own encoding. + """ + document = json.loads(_published(PANEL, "shed/policy")) + + assert fields.shed_policy == _published(PANEL, "shed/policy") + assert fields.shed_policy_algorithm == document["algorithm"] + assert fields.shed_soc_threshold_shed_percent == document["parameters"]["soc-threshold-shed"] + assert fields.shed_soc_threshold_release_percent == document["parameters"]["soc-threshold-release"] + + +def test_an_unknown_shed_algorithm_keeps_its_name_and_yields_no_thresholds() -> None: + """The `$format` schema is versioned in its own `$id`, so another algorithm may arrive. + + A reader that assumed `soc-priority.v1` would report that algorithm's + thresholds for a document that never had them. Naming the algorithm and + declining the numbers is the honest answer, and the raw document stays + available beside it. + """ + other = json.dumps({"algorithm": "runtime-priority.v2", "parameters": {"minutes-shed": 30}}) + parsed = _fields_for(_panel_with(shed__policy=other)) + + assert parsed.shed_policy == other, "the raw document survives so a consumer can still show it" + assert parsed.shed_policy_algorithm == "runtime-priority.v2" + assert parsed.shed_soc_threshold_shed_percent is None + assert parsed.shed_soc_threshold_release_percent is None + + +@pytest.mark.parametrize( + "policy", + [ + pytest.param("not json at all", id="unparseable"), + pytest.param("[1, 2, 3]", id="not-an-object"), + pytest.param('{"algorithm": "soc-priority.v1"}', id="no-parameters"), + pytest.param('{"algorithm": "soc-priority.v1", "parameters": "20"}', id="parameters-not-an-object"), + pytest.param( + '{"algorithm": "soc-priority.v1", "parameters": {"soc-threshold-shed": "low"}}', + id="threshold-not-a-number", + ), + pytest.param( + '{"algorithm": "soc-priority.v1", "parameters": {"soc-threshold-shed": true}}', + id="threshold-is-a-bool", + ), + ], +) +def test_a_malformed_shed_policy_degrades_rather_than_raising(policy: str) -> None: + """Every failure lands on "nothing to render", which is what absence means too. + + A panel is a publisher this library does not control, and a snapshot build + that raises on one bad string takes every other entity down with it. The + boolean case is called out because `bool` is an `int` in Python: `true` + would otherwise read as a 1% threshold. + """ + parsed = _fields_for(_panel_with(shed__policy=policy)) + + assert parsed.shed_policy == policy + assert parsed.shed_soc_threshold_shed_percent is None + assert parsed.shed_soc_threshold_release_percent is None + + +def test_an_unpublished_shed_policy_reports_nothing_at_all() -> None: + """A panel with no policy published is not a panel with an unparseable one.""" + absent = _fields_for(_panel_with(shed__policy=None)) + + assert absent.shed_policy is None + assert absent.shed_policy_algorithm is None + assert absent.shed_soc_threshold_shed_percent is None + assert absent.shed_soc_threshold_release_percent is None diff --git a/tests/test_schema_one_pcs.py b/tests/test_schema_one_pcs.py new file mode 100644 index 0000000..7dc611c --- /dev/null +++ b/tests/test_schema_one_pcs.py @@ -0,0 +1,522 @@ +"""The enclosure's Power Control System, from the wire to the snapshot. + +`pcs` 0.3 is the largest single capability the enclosure publishes: sixteen +properties on the panel and two on every circuit. The enclosure runs the +arbitration and publishes the *system* surface; a circuit publishes only its +*participation*. + +**The capture is a PCS that is switched off, and that shapes every test here.** +Every limit is `0.0`, every enablement `UNCONFIGURED`, every boolean `false`. +Uniform data makes an assertion cheap to satisfy for the wrong reason: a field +wired to the neighbouring property reports the identical value, and a parser +that returned a zero of its own would agree with the wire by accident. So no +test in this module rests on the captured values alone. Presence is asserted +against the capture, and every *reading* is proved by republishing a value that +differs from the captured one and from every sibling's, one property at a time, +with the other fifteen fields pinned to their baseline. A field that read the +wrong property moves when it should not, and that is what fails. +""" + +from __future__ import annotations + +import dataclasses +import json +from typing import Any + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api.models import FieldMetadata, SpanPanelSnapshot, SpanPcsSnapshot +from span_panel_api_schema_1.const import PCS_LIMIT_SOURCES +from span_panel_api_schema_1.field_metadata import build_field_metadata +from span_panel_api_schema_1.reference_payloads import ( + RetainedTopicTree, + device_from_topics, + parent_child_tree, +) +from span_panel_api_schema_1.snapshot import build_snapshot + +PANEL = "example-40t-001" +NODE = "pcs" + +# A circuit the capture publishes participation for, and one that opts out. Two +# so a mapper that reported a constant cannot satisfy both. +MANAGED_CIRCUIT = "0ab966b95f92a6a51ec548485aa85f54" +UNMANAGED_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" + + +def _mutable_tree() -> dict[str, dict[str, str]]: + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _snapshot(tree: RetainedTopicTree) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL, tree[PANEL]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL] + return build_snapshot(panel, children) + + +def _devices(tree: RetainedTopicTree) -> list[DiscoveredDevice]: + return [device_from_topics(device_id, topics) for device_id, topics in tree.items()] + + +def _pcs(tree: RetainedTopicTree) -> SpanPcsSnapshot: + """The snapshot's PCS, or fail saying the panel carries none. + + Narrowing here rather than at each call site: `pcs` is optional by design, + and every test below that reaches into it has already asserted, or is + asserting, that the capture publishes the node. + """ + pcs = _snapshot(tree).pcs + assert pcs is not None, "the capture's panel carries no pcs snapshot" + return pcs + + +def _published(property_id: str) -> str: + return parent_child_tree()[PANEL][f"{NODE}/{property_id}"] + + +def _declared_properties(tree: RetainedTopicTree, device_id: str = PANEL) -> dict[str, Any]: + description: dict[str, Any] = json.loads(tree[device_id]["$description"]) + node: dict[str, Any] = description["nodes"][NODE] + properties: dict[str, Any] = node["properties"] + return properties + + +def _without_property(tree: dict[str, dict[str, str]], property_id: str) -> dict[str, dict[str, str]]: + """Stop publishing one PCS property, and stop declaring it too.""" + del tree[PANEL][f"{NODE}/{property_id}"] + description = json.loads(tree[PANEL]["$description"]) + del description["nodes"][NODE]["properties"][property_id] + tree[PANEL]["$description"] = json.dumps(description) + return tree + + +def _without_node(tree: dict[str, dict[str, str]], device_id: str = PANEL) -> dict[str, dict[str, str]]: + """A device that publishes no `pcs` node at all.""" + for topic in [topic for topic in tree[device_id] if topic.startswith(f"{NODE}/")]: + del tree[device_id][topic] + description = json.loads(tree[device_id]["$description"]) + del description["nodes"][NODE] + tree[device_id]["$description"] = json.dumps(description) + return tree + + +# Every panel property the capability publishes, paired with the field that +# reads it and with a republished value chosen to be distinct from the captured +# one *and* from every sibling's. Distinctness is the whole apparatus: against a +# capture where all sixteen values are zeros, `false` and `UNCONFIGURED`, an +# assertion that a field equals what was published is satisfied by fifteen wrong +# wirings as easily as by the right one. +# +# The enablement enum has exactly four members and there are exactly four +# constraint classes, so each family gets a different one; the limits get +# unrelated decimals; and each boolean is flipped away from the captured value. +_PANEL_READS: tuple[tuple[str, str, str, object], ...] = ( + ("enabled", "enabled", "true", True), + ("active", "active", "true", True), + ("import-limit", "import_limit_a", "55.5", 55.5), + ("binding-constraint", "binding_constraint", "FSR", "FSR"), + ("feed-import-limit", "feed_import_limit_a", "11.5", 11.5), + ("feed-import-limit-enablement", "feed_import_limit_enablement", "ENABLED", "ENABLED"), + ("feed-import-limit-active", "feed_import_limit_active", "true", True), + ("operator-import-limit", "operator_import_limit_a", "22.25", 22.25), + ("operator-import-limit-enablement", "operator_import_limit_enablement", "DISABLED", "DISABLED"), + ("operator-import-limit-active", "operator_import_limit_active", "true", True), + ("off-grid-import-limit", "off_grid_import_limit_a", "33.75", 33.75), + ("off-grid-import-limit-enablement", "off_grid_import_limit_enablement", "UNSPECIFIED", "UNSPECIFIED"), + ("off-grid-import-limit-active", "off_grid_import_limit_active", "true", True), + ("requested-import-limit", "requested_import_limit_a", "44.125", 44.125), + ( + "requested-import-limit-enablement", + "requested_import_limit_enablement", + "UNSPECIFIED", + "UNSPECIFIED", + ), + ("requested-import-limit-active", "requested_import_limit_active", "true", True), +) + +_PANEL_PROPERTIES = tuple(property_id for property_id, _, _, _ in _PANEL_READS) + + +def _fields(pcs: SpanPcsSnapshot) -> dict[str, object]: + return {field.name: getattr(pcs, field.name) for field in dataclasses.fields(pcs)} + + +# --------------------------------------------------------------------------- +# The premise: what the capture actually carries +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_the_whole_system_surface() -> None: + """Guard the premise. Sixteen properties, every one declared and published; + a capture that dropped one would make its absence test vacuous.""" + tree = parent_child_tree() + declared = _declared_properties(tree) + + assert set(declared) == set(_PANEL_PROPERTIES) + for property_id in _PANEL_PROPERTIES: + assert f"{NODE}/{property_id}" in tree[PANEL] + + +def test_the_capture_is_a_pcs_that_is_switched_off() -> None: + """The fact every test in this module is written around, asserted rather + than assumed. + + Uniform data is the hazard here: a wrong wiring reports the same value as a + right one, so no reading below is proved by comparing against the capture. + Were the capture ever retaken with a configured PCS, this fails first and + says so, rather than the mutation tests continuing to pass while the weaker + assertions they replace quietly became meaningful. + """ + pcs = _pcs(parent_child_tree()) + + assert pcs.enabled is False + assert pcs.active is False + assert pcs.binding_constraint == "NONE" + assert {value for name, value in _fields(pcs).items() if name.endswith("_limit_a")} == {0.0} + assert {value for name, value in _fields(pcs).items() if name.endswith("_enablement")} == {"UNCONFIGURED"} + + +def test_the_catalog_constraint_classes_are_the_ones_the_panel_declares() -> None: + """The four amps-native sources, checked against the wire rather than listed + twice. + + The capability is explicit that "the number and naming of sources is not + fixed by this spec" — a vendor may publish further triplets. So this is the + drift signal: a fifth source arriving is firmware growing a constraint class + nothing reads, and it should fail here rather than go unnoticed. + """ + declared = _declared_properties(parent_child_tree()) + triplets = { + property_id.removesuffix("-active").removesuffix("-enablement").removesuffix("-import-limit") + for property_id in declared + if property_id.endswith("-import-limit") or "-import-limit-" in property_id + } + + assert triplets == set(PCS_LIMIT_SOURCES) + + +# --------------------------------------------------------------------------- +# Every reading is proved by mutation, one property at a time +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("property_id", "attribute", "republished", "expected"), + _PANEL_READS, + ids=[property_id for property_id, _, _, _ in _PANEL_READS], +) +def test_republishing_one_property_moves_only_the_field_that_reads_it( + property_id: str, attribute: str, republished: str, expected: object +) -> None: + """The load-bearing test of the module, and the answer to the uniform capture. + + Two assertions, and the second is the one that bites. The first says the + field followed the wire. The second says *no other field did* — which is + what a field reading the neighbouring property fails, and what an assertion + against the captured zeros could never detect, since every sibling already + holds the value a wrong wiring would report. + """ + baseline = _fields(_pcs(parent_child_tree())) + + tree = _mutable_tree() + tree[PANEL][f"{NODE}/{property_id}"] = republished + after = _fields(_pcs(tree)) + + assert after[attribute] == expected + moved = {name for name, value in after.items() if value != baseline[name]} + assert moved == {attribute}, f"republishing {property_id} also moved {sorted(moved - {attribute})}" + + +def test_a_fully_configured_pcs_lands_every_value_on_its_own_field() -> None: + """All sixteen republished at once, every value distinct from its siblings'. + + The per-property test above proves each field reads its own property. This + proves the sixteen do not interfere: a mapper that assembled the dataclass + positionally, or that reused one triplet's reader for another family, passes + every single-property test and fails here. + """ + tree = _mutable_tree() + for property_id, _, republished, _ in _PANEL_READS: + tree[PANEL][f"{NODE}/{property_id}"] = republished + + after = _fields(_pcs(tree)) + + assert after == {attribute: expected for _, attribute, _, expected in _PANEL_READS} + + +def test_the_four_limits_are_four_different_readings() -> None: + """The families are distinguishable, on data where the capture makes them + identical. Four unrelated decimals, and each has to land on its own field.""" + tree = _mutable_tree() + for index, source in enumerate(PCS_LIMIT_SOURCES): + tree[PANEL][f"{NODE}/{source}-import-limit"] = str(index + 1) + + pcs = _pcs(tree) + + assert pcs.feed_import_limit_a == 1.0 + assert pcs.operator_import_limit_a == 2.0 + assert pcs.off_grid_import_limit_a == 3.0 + assert pcs.requested_import_limit_a == 4.0 + + +def test_the_effective_limit_is_not_any_of_its_inputs() -> None: + """`import-limit` is the arbitration *result*, and the catalog says so. A + mapper that took it from the FSR would be plausible and wrong, so the + republished result differs from every input.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/import-limit"] = "12.5" + for source in PCS_LIMIT_SOURCES: + tree[PANEL][f"{NODE}/{source}-import-limit"] = "99.0" + + pcs = _pcs(tree) + + assert pcs.import_limit_a == 12.5 + + +def test_enabled_and_active_are_two_different_facts() -> None: + """A configured PCS spends most of its life enabled and inactive, so the two + booleans must be readable in opposition. Both are `false` in the capture, + which is exactly the state in which crossing them is invisible.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/enabled"] = "true" + tree[PANEL][f"{NODE}/active"] = "false" + + pcs = _pcs(tree) + + assert pcs.enabled is True + assert pcs.active is False + + +def test_binding_constraint_is_kept_as_the_wire_string() -> None: + """Publishers may extend the enum through `$format`, and this property's + whole job is naming a source — so a value outside the catalog's eight must + survive rather than be normalised onto `UNKNOWN`.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/binding-constraint"] = "VENDOR_THERMAL" + + assert _pcs(tree).binding_constraint == "VENDOR_THERMAL" + + +# --------------------------------------------------------------------------- +# Absence: an unpublished property, a dropped node, no PCS at all +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("property_id", "attribute"), + [(property_id, attribute) for property_id, attribute, _, _ in _PANEL_READS], + ids=[property_id for property_id, _, _, _ in _PANEL_READS], +) +def test_a_property_the_panel_does_not_publish_is_none(property_id: str, attribute: str) -> None: + """`None`, never `0.0` and never `False`. Three of the four constraint + classes are `MAY`, so an omitted family is conformant firmware — and a limit + defaulted to zero would read as "no import permitted", the most alarming + reading the property has.""" + pcs = _pcs(_without_property(_mutable_tree(), property_id)) + + assert getattr(pcs, attribute) is None + + +def test_dropping_one_property_leaves_the_others_reading() -> None: + """Absence is per-property: a panel publishing a partial `pcs` node still + reports the part it has.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/import-limit"] = "17.5" + pcs = _pcs(_without_property(tree, "feed-import-limit")) + + assert pcs.feed_import_limit_a is None + assert pcs.import_limit_a == 17.5 + + +def test_a_panel_with_no_pcs_node_carries_no_pcs_at_all() -> None: + """The presence signal a consumer gates entity creation on. `None` rather + than an empty instance, so nothing has to be inferred from a sentinel.""" + assert _snapshot(_without_node(_mutable_tree())).pcs is None + + +def test_a_switched_off_pcs_is_still_a_pcs() -> None: + """The distinction the node-presence gate exists to keep, and the reason it + cannot be a value gate: this capture publishes zeros throughout, and a + consumer that read those as absence would delete the entities of every panel + whose PCS is merely unconfigured.""" + assert _snapshot(parent_child_tree()).pcs is not None + + +def test_a_declared_node_with_no_published_values_is_still_present() -> None: + """Mid-discovery is the normal case for a device that has announced itself + and not yet retained its topics. The node is declared, so the PCS exists and + every reading is unknown — which is not the same as no PCS.""" + tree = _mutable_tree() + for property_id in _PANEL_PROPERTIES: + del tree[PANEL][f"{NODE}/{property_id}"] + + pcs = _snapshot(tree).pcs + + assert pcs is not None + assert set(_fields(pcs).values()) == {None} + + +def test_a_limit_that_is_not_a_number_reads_as_absent() -> None: + """Same answer as not publishing, because neither is a reading.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/import-limit"] = "n/a" + + assert _pcs(tree).import_limit_a is None + + +def test_zero_amps_is_a_reading_and_not_an_absence() -> None: + """The distinction the `None` default exists to keep: the PCS is permitting + no import at all, which is a state, not a gap.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/import-limit"] = "0.0" + + assert _pcs(tree).import_limit_a == 0.0 + + +# --------------------------------------------------------------------------- +# The circuit half: participation, not arbitration +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_participation_on_its_circuits() -> None: + """Guard the premise for the circuit tests, and pin that the two circuits + they use actually disagree.""" + tree = parent_child_tree() + + assert tree[MANAGED_CIRCUIT][f"{NODE}/managed"] == "true" + assert tree[UNMANAGED_CIRCUIT][f"{NODE}/managed"] == "false" + assert tree[MANAGED_CIRCUIT][f"{NODE}/priority"] != tree[UNMANAGED_CIRCUIT][f"{NODE}/priority"] + + +def test_a_circuit_reports_its_own_participation() -> None: + """Read against the tree rather than against literals, and against two + circuits that differ, so a mapper reporting a constant fails.""" + tree = parent_child_tree() + circuits = _snapshot(tree).circuits + + assert circuits[MANAGED_CIRCUIT].pcs_managed is True + assert circuits[UNMANAGED_CIRCUIT].pcs_managed is False + assert circuits[MANAGED_CIRCUIT].pcs_priority == int(tree[MANAGED_CIRCUIT][f"{NODE}/priority"]) + assert circuits[UNMANAGED_CIRCUIT].pcs_priority == int(tree[UNMANAGED_CIRCUIT][f"{NODE}/priority"]) + + +def test_republishing_participation_moves_the_circuit_fields() -> None: + """The mutation half. The republished priority is outside the range the + capture uses on any circuit, so a field wired to another circuit's value — + or to the load-shed priority beside it — cannot report it.""" + tree = _mutable_tree() + tree[MANAGED_CIRCUIT][f"{NODE}/managed"] = "false" + tree[MANAGED_CIRCUIT][f"{NODE}/priority"] = "42" + + circuit = _snapshot(tree).circuits[MANAGED_CIRCUIT] + + assert circuit.pcs_managed is False + assert circuit.pcs_priority == 42 + + +def test_pcs_priority_is_not_the_load_shed_priority() -> None: + """Two policies on one relay, kept apart by the catalog and here. One is an + integer shed ordering under an import limit; the other is the backup tier a + user sets, and they do not even share a value space.""" + circuit = _snapshot(parent_child_tree()).circuits[MANAGED_CIRCUIT] + + assert isinstance(circuit.pcs_priority, int) + assert isinstance(circuit.priority, str) + assert circuit.priority != str(circuit.pcs_priority) + + +@pytest.mark.parametrize("property_id", ["managed", "priority"]) +def test_a_circuit_that_does_not_publish_participation_reports_none(property_id: str) -> None: + """Both are `MAY`. A circuit that has not said it is managed has not said it + is unmanaged, and priority `0` is a legal ranking — so neither may default.""" + tree = _mutable_tree() + del tree[MANAGED_CIRCUIT][f"{NODE}/{property_id}"] + + circuit = _snapshot(tree).circuits[MANAGED_CIRCUIT] + + assert getattr(circuit, f"pcs_{property_id}") is None + + +def test_a_circuit_with_no_pcs_node_participates_in_nothing() -> None: + circuit = _snapshot(_without_node(_mutable_tree(), MANAGED_CIRCUIT)).circuits[MANAGED_CIRCUIT] + + assert circuit.pcs_managed is None + assert circuit.pcs_priority is None + + +def test_a_synthesised_unmapped_position_carries_no_participation() -> None: + """Unmapped tabs are invented by the adapter, not published, so claiming a + PCS relationship for one would be a fabrication.""" + circuits = _snapshot(parent_child_tree()).circuits + unmapped = next(circuit for circuit_id, circuit in circuits.items() if circuit_id.startswith("unmapped_tab_")) + + assert unmapped.pcs_managed is None + assert unmapped.pcs_priority is None + + +# --------------------------------------------------------------------------- +# Metadata: only the result carries a row +# --------------------------------------------------------------------------- + + +def test_the_effective_limit_takes_its_unit_from_the_tree() -> None: + metadata = build_field_metadata(_devices(parent_child_tree())) + declared = _declared_properties(parent_child_tree()) + + entry = metadata["pcs.import_limit_a"] + assert entry.resolved is True + assert entry.unit == declared["import-limit"]["unit"] + assert entry.datatype == declared["import-limit"]["datatype"] + + +def test_changing_the_declared_unit_changes_the_metadata() -> None: + """The mutation proof for the metadata half: the unit comes from the panel's + own `$description`, not from the vendored catalog and not from a literal.""" + tree = _mutable_tree() + description = json.loads(tree[PANEL]["$description"]) + description["nodes"][NODE]["properties"]["import-limit"]["unit"] = "kA" + tree[PANEL]["$description"] = json.dumps(description) + + assert build_field_metadata(_devices(tree))["pcs.import_limit_a"].unit == "kA" + + +def test_the_result_properties_carry_rows_and_the_inputs_do_not() -> None: + """Deliberate, and asserted so it stays deliberate. + + The capability calls `import-limit` and `binding-constraint` "the result", + and those plus `active` are what a consumer renders as readings. The four + constraint families and `enabled` qualify that result rather than standing + alone, so a unit row for them would advertise a surface that is not there — + the same treatment the `shed-forecast` full-charge pair gets. + """ + metadata = build_field_metadata(_devices(parent_child_tree())) + pcs_rows = {path for path in metadata if path.startswith("pcs.")} + + assert pcs_rows == {"pcs.import_limit_a", "pcs.binding_constraint", "pcs.active"} + + +def test_a_declared_node_missing_a_property_is_a_gap_not_absent_hardware() -> None: + """The three-way contract: the node is here, so an omitted property is + degradation and gets an unresolved row rather than no row.""" + metadata = build_field_metadata(_devices(_without_property(_mutable_tree(), "import-limit"))) + + assert metadata["pcs.import_limit_a"] == FieldMetadata(unit=None, datatype="unknown", resolved=False) + + +def test_no_pcs_node_produces_no_rows_at_all() -> None: + """Hardware that is not there is not a defect: no entry, so a consumer reads + "nothing will populate this" rather than "this is broken".""" + metadata = build_field_metadata(_devices(_without_node(_mutable_tree()))) + + assert not [path for path in metadata if path.startswith("pcs.")] + + +def test_circuit_participation_carries_no_metadata_row() -> None: + """Read into the snapshot and rendered as attributes on the circuit's own + sensor, not as readings of their own.""" + metadata = build_field_metadata(_devices(parent_child_tree())) + + assert "circuit.pcs_managed" not in metadata + assert "circuit.pcs_priority" not in metadata diff --git a/tests/test_schema_one_service_entrance.py b/tests/test_schema_one_service_entrance.py new file mode 100644 index 0000000..042413b --- /dev/null +++ b/tests/test_schema_one_service_entrance.py @@ -0,0 +1,196 @@ +"""Whether this enclosure's upstream lugs are the utility connection point. + +`instant_grid_power_w` is the upstream lugs' `meter/active-power`, and the name +is only true at the service entrance. Put a BESS ahead of the main lugs, or feed +this panel from another panel, and the lugs measure flow on the panel side of +that device while the utility side differs by whatever it contributes or +absorbs. `power_flow_grid` stays site-level and correct; the two then +legitimately disagree. + +That disagreement is the whole problem. Without a signal a consumer seeing them +differ cannot tell a topology from a fault, and `fed-by-device-id` -- the +mechanism `power-flows` 0.3 names when it qualifies its own negation table -- +was read by this parser and then discarded. So there was nothing downstream +could compute for itself. + +**The reference capture is itself one of these topologies**, which is the part +worth knowing before reading anything below. Its upstream lugs publish +`fed-by-device-id: bess` -- the producer wires the battery ahead of the main +lugs, and computes `power-flows/grid` from the lugs reading together with the +BESS rather than by negating the lugs. So on the reference panel +`instant_grid_power_w` has never been the utility figure, and the flag reads +`False` for it. The capture is falsifiable in both directions without being +contrived, which is why the cases below both republish into it and take it away. +""" + +from __future__ import annotations + +import pytest + +from span_panel_api.models import SpanPanelSnapshot +from span_panel_api_schema_1.const import ( + NODE_CONNECTION, + PROP_FED_BY_DEVICE_ID, + PROP_FED_BY_DEVICE_STATUS, +) +from span_panel_api_schema_1.reference_payloads import ( + RetainedTopicTree, + device_from_topics, + parent_child_tree, +) +from span_panel_api_schema_1.snapshot import build_snapshot + +PANEL = "example-40t-001" +UPSTREAM_LUGS = "lugs-upstream" + +FED_BY_ID_TOPIC = f"{NODE_CONNECTION}/{PROP_FED_BY_DEVICE_ID}" +FED_BY_STATUS_TOPIC = f"{NODE_CONNECTION}/{PROP_FED_BY_DEVICE_STATUS}" + + +def _mutable_tree() -> dict[str, dict[str, str]]: + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _snapshot(tree: RetainedTopicTree) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL, tree[PANEL]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL] + return build_snapshot(panel, children) + + +def test_the_capture_has_a_battery_ahead_of_its_main_lugs() -> None: + """The reference panel is behind an upstream DER, and reports itself as one. + + Recorded as its own test because it is a claim about the producer rather than + about this parser, and because it is easy to assume the opposite: a reference + capture is usually the simple case, and this one is not. If the producer ever + moves the battery downstream this fails saying so, rather than silently + turning the cases below into assertions about a panel that no longer exists. + """ + tree = _mutable_tree() + assert tree[UPSTREAM_LUGS][FED_BY_ID_TOPIC] == "bess" + assert _snapshot(tree).lugs_at_service_entrance is False + + +def test_a_panel_with_nothing_ahead_of_its_lugs_is_at_the_service_entrance() -> None: + """The ordinary case, reached by taking the capture's upstream BESS away. + + `True` has to be earned from the tree rather than defaulted into: a mapper + that always answered `True` would pass this and fail everything above it, + and one that always answered `False` would do the reverse. + """ + tree = _mutable_tree() + del tree[UPSTREAM_LUGS][FED_BY_ID_TOPIC] + del tree[UPSTREAM_LUGS][FED_BY_STATUS_TOPIC] + + assert _snapshot(tree).lugs_at_service_entrance is True + + +@pytest.mark.parametrize( + ("intervening", "topology"), + [ + ("bess", "a BESS wired between the utility and the main lugs"), + ("example-40t-002", "an enclosure fed by another enclosure"), + ], +) +def test_a_device_between_the_utility_and_the_lugs_is_reported(intervening: str, topology: str) -> None: + """Both topologies the specification names, and one signal covers both. + + They differ in what is upstream and not in what it does to the reading, which + is why this is one boolean rather than a description of the device. The + enclosure-chain case could not carry a description anyway: the feeding device + is another panel with its own tree, not a child of this one. + """ + tree = _mutable_tree() + tree[UPSTREAM_LUGS][FED_BY_ID_TOPIC] = intervening + tree[UPSTREAM_LUGS][FED_BY_STATUS_TOPIC] = "OK" + + assert _snapshot(tree).lugs_at_service_entrance is False, topology + + +def test_an_empty_fed_by_id_is_not_a_device() -> None: + """Homie publishes an empty payload for a property with no value. + + An empty string is the absence, not a device named "". Reading it as one + would tell every panel that publishes the property-but-not-the-value that it + is behind something. + """ + tree = _mutable_tree() + tree[UPSTREAM_LUGS][FED_BY_ID_TOPIC] = "" + del tree[UPSTREAM_LUGS][FED_BY_STATUS_TOPIC] + + assert _snapshot(tree).lugs_at_service_entrance is True + + +def test_the_grid_reading_itself_is_unchanged_either_way() -> None: + """The label is conditional; the measurement is not. + + A panel behind a DER still meters its own lugs correctly, so this must not + become a reason to withhold or alter the value -- only to say what it is. + """ + behind = _mutable_tree() + plain = _mutable_tree() + del plain[UPSTREAM_LUGS][FED_BY_ID_TOPIC] + del plain[UPSTREAM_LUGS][FED_BY_STATUS_TOPIC] + + assert _snapshot(behind).lugs_at_service_entrance != _snapshot(plain).lugs_at_service_entrance + assert _snapshot(behind).instant_grid_power_w == _snapshot(plain).instant_grid_power_w + assert _snapshot(behind).power_flow_grid == _snapshot(plain).power_flow_grid + + +def test_a_panel_with_no_upstream_lugs_is_not_reported_as_behind_something() -> None: + """A tree missing the device says nothing about topology, and `False` is a claim.""" + tree = _mutable_tree() + del tree[UPSTREAM_LUGS] + + assert _snapshot(tree).lugs_at_service_entrance is True + + +def test_a_flat_panel_reports_itself_at_the_service_entrance() -> None: + """The default is a fact about flat firmware, not an optimism about it. + + Flat predates enclosure chaining and publishes no way to express it, so a flat + panel's lugs *are* its service entrance and `True` is the right answer rather + than a safe-looking one. schema_0 therefore leaves the field alone, and this + is what holds the default where it is -- without it the field could be + defaulted either way and every schema-1 test above would still pass. + """ + from conftest import flat_schema + from span_panel_api_schema_0 import SchemaZeroAdapter + + adapter = SchemaZeroAdapter(serial_number="sim-40t-001", schema=flat_schema(40)) + + assert SpanPanelSnapshot.__dataclass_fields__["lugs_at_service_entrance"].default is True + assert adapter.build_snapshot().lugs_at_service_entrance is True + + +def test_a_float_property_published_without_a_decimal_point_still_parses() -> None: + """Live firmware publishes integer literals for `float` properties, inconsistently. + + Observed on a service-entrance panel: `power-flows/pv` arrived as `-2434`, + `battery` as `0` and `grid` as `-310`, while `site` on the same node arrived + as `2744.0`. All four declare `datatype: float`. An integer literal is a legal + float payload under Homie 5, so this is firmware being terse rather than + wrong -- but the inconsistency is between sibling properties of one node, so + nothing can be inferred from a sample of one property. + + Worth its own test because no producer we develop against does it: the + reference emitter publishes a decimal point every time, so the whole suite + would pass while a stricter parse silently dropped three of the four site + flows to `None` and reported the panel as publishing no power-flows node. + """ + tree = _mutable_tree() + for name, terse in (("pv", "-2434"), ("battery", "0"), ("grid", "-310")): + tree[PANEL][f"power-flows/{name}"] = terse + tree[PANEL]["power-flows/site"] = "2744.0" + + snapshot = _snapshot(tree) + + assert snapshot.power_flow_pv == -2434.0 + assert snapshot.power_flow_battery == 0.0 + assert snapshot.power_flow_grid == -310.0 + assert snapshot.power_flow_site == 2744.0 + # The four terms sum to zero, which is the identity the specification states + # and which a dropped term would break silently rather than loudly. + assert ( + snapshot.power_flow_pv + snapshot.power_flow_battery + snapshot.power_flow_grid + snapshot.power_flow_site + ) == 0.0 diff --git a/tests/test_schema_one_shed_forecast.py b/tests/test_schema_one_shed_forecast.py new file mode 100644 index 0000000..08798b2 --- /dev/null +++ b/tests/test_schema_one_shed_forecast.py @@ -0,0 +1,289 @@ +"""The enclosure's backup-planning forecast, from the wire to the snapshot. + +`shed-forecast` 0.1 publishes four `integer` minute estimates and a confidence +enum. Nothing derives them and nothing defaults them: every assertion here is +against a value the captured tree actually publishes, and every one of them has +a paired test that republishes something different, so a reading that the parser +hardcoded rather than read cannot pass. +""" + +from __future__ import annotations + +from collections.abc import Mapping +import json +from typing import Any + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api.models import FieldMetadata, SpanPanelSnapshot +from span_panel_api_schema_1.field_metadata import build_field_metadata +from span_panel_api_schema_1.reference_payloads import ( + RetainedTopicTree, + device_from_topics, + parent_child_tree, +) +from span_panel_api_schema_1.snapshot import build_snapshot + +PANEL = "example-40t-001" +NODE = "shed-forecast" + +TIME_TO_PRIORITY_SHED = "time-to-priority-shed" +TOTAL_TIME_REMAINING = "total-time-remaining" +FULL_CHARGE_TIME_TO_PRIORITY_SHED = "full-charge-time-to-priority-shed" +FULL_CHARGE_TOTAL_TIME_REMAINING = "full-charge-total-time-remaining" +CONFIDENCE = "confidence" + +_LIVE_PATHS = { + TIME_TO_PRIORITY_SHED: "panel.shed_time_to_priority_shed_min", + TOTAL_TIME_REMAINING: "panel.shed_total_time_remaining_min", +} + + +def _mutable_tree() -> dict[str, dict[str, str]]: + """A deep-enough copy of the capture that a test can rewrite one topic. + + Rewriting the published value is the whole point of this module: an + assertion against a constant proves nothing unless the same code reports a + different constant when the panel sends one. + """ + return {device_id: dict(topics) for device_id, topics in parent_child_tree().items()} + + +def _snapshot(tree: RetainedTopicTree) -> SpanPanelSnapshot: + panel = device_from_topics(PANEL, tree[PANEL]) + children = [device_from_topics(device_id, topics) for device_id, topics in tree.items() if device_id != PANEL] + return build_snapshot(panel, children) + + +def _devices(tree: RetainedTopicTree) -> list[DiscoveredDevice]: + return [device_from_topics(device_id, topics) for device_id, topics in tree.items()] + + +def _published(property_id: str) -> str: + return parent_child_tree()[PANEL][f"{NODE}/{property_id}"] + + +def _without_property(tree: dict[str, dict[str, str]], property_id: str) -> dict[str, dict[str, str]]: + """Stop publishing one forecast property, and stop declaring it too. + + Both halves, because they are different situations to the metadata builder — + an undeclared property is a gap and an unpublished one is a missing value — + and this helper models a firmware that simply does not have the property. + """ + del tree[PANEL][f"{NODE}/{property_id}"] + description = json.loads(tree[PANEL]["$description"]) + del description["nodes"][NODE]["properties"][property_id] + tree[PANEL]["$description"] = json.dumps(description) + return tree + + +def _without_node(tree: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]: + """A panel that publishes no `shed-forecast` node at all.""" + for topic in [topic for topic in tree[PANEL] if topic.startswith(f"{NODE}/")]: + del tree[PANEL][topic] + description = json.loads(tree[PANEL]["$description"]) + del description["nodes"][NODE] + tree[PANEL]["$description"] = json.dumps(description) + return tree + + +def _declared(tree: RetainedTopicTree) -> Mapping[str, Any]: + description: dict[str, Any] = json.loads(tree[PANEL]["$description"]) + nodes: dict[str, Any] = description["nodes"] + return nodes + + +# --------------------------------------------------------------------------- +# The capture publishes it; the snapshot reports what was published +# --------------------------------------------------------------------------- + + +def test_the_capture_publishes_the_whole_capability() -> None: + """Guard the premise. Every assertion below reads the tree for its expected + value, so a capture that stopped publishing the node would make them all + vacuously true rather than failing.""" + tree = parent_child_tree() + + assert NODE in _declared(tree) + for property_id in (*_LIVE_PATHS, FULL_CHARGE_TIME_TO_PRIORITY_SHED, FULL_CHARGE_TOTAL_TIME_REMAINING, CONFIDENCE): + assert f"{NODE}/{property_id}" in tree[PANEL] + + +def test_every_forecast_property_reaches_the_snapshot() -> None: + """Read against the tree rather than against literals: the expected value is + whatever the panel published, so changing the capture changes the + expectation instead of silently disagreeing with it.""" + snapshot = _snapshot(parent_child_tree()) + + assert snapshot.shed_time_to_priority_shed_min == int(_published(TIME_TO_PRIORITY_SHED)) + assert snapshot.shed_total_time_remaining_min == int(_published(TOTAL_TIME_REMAINING)) + assert snapshot.shed_full_charge_time_to_priority_shed_min == int(_published(FULL_CHARGE_TIME_TO_PRIORITY_SHED)) + assert snapshot.shed_full_charge_total_time_remaining_min == int(_published(FULL_CHARGE_TOTAL_TIME_REMAINING)) + assert snapshot.shed_forecast_confidence == _published(CONFIDENCE) + + +def test_the_two_live_estimates_are_not_the_same_reading() -> None: + """`time-to-priority-shed` and `total-time-remaining` are distinct in the + capture, so a parser that crossed the two would fail here rather than + reporting a plausible pair.""" + snapshot = _snapshot(parent_child_tree()) + + assert snapshot.shed_time_to_priority_shed_min != snapshot.shed_total_time_remaining_min + + +@pytest.mark.parametrize( + ("property_id", "attribute", "republished", "expected"), + [ + (TIME_TO_PRIORITY_SHED, "shed_time_to_priority_shed_min", "17", 17), + (TOTAL_TIME_REMAINING, "shed_total_time_remaining_min", "1440", 1440), + ( + FULL_CHARGE_TIME_TO_PRIORITY_SHED, + "shed_full_charge_time_to_priority_shed_min", + "615", + 615, + ), + ( + FULL_CHARGE_TOTAL_TIME_REMAINING, + "shed_full_charge_total_time_remaining_min", + "720", + 720, + ), + (CONFIDENCE, "shed_forecast_confidence", "LOW", "LOW"), + ], +) +def test_republishing_a_property_moves_the_field_that_reads_it( + property_id: str, attribute: str, republished: str, expected: int | str +) -> None: + """The mutation half. Each value differs from the captured one *and* from + every other captured one, so a field wired to the wrong property reports a + number the assertion rejects.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/{property_id}"] = republished + + assert getattr(_snapshot(tree), attribute) == expected + + +@pytest.mark.parametrize( + ("property_id", "attribute"), + [ + (TIME_TO_PRIORITY_SHED, "shed_time_to_priority_shed_min"), + (TOTAL_TIME_REMAINING, "shed_total_time_remaining_min"), + (FULL_CHARGE_TIME_TO_PRIORITY_SHED, "shed_full_charge_time_to_priority_shed_min"), + (FULL_CHARGE_TOTAL_TIME_REMAINING, "shed_full_charge_total_time_remaining_min"), + (CONFIDENCE, "shed_forecast_confidence"), + ], +) +def test_a_property_the_panel_does_not_publish_is_none(property_id: str, attribute: str) -> None: + """`None`, never zero. Zero minutes is a legitimate forecast — shedding + starts now — so a default would be indistinguishable from the worst reading + the capability can report.""" + snapshot = _snapshot(_without_property(_mutable_tree(), property_id)) + + assert getattr(snapshot, attribute) is None + + +def test_dropping_one_property_leaves_the_others_reading() -> None: + """Absence is per-property, so a panel with a partial forecast still reports + the part it has.""" + snapshot = _snapshot(_without_property(_mutable_tree(), TIME_TO_PRIORITY_SHED)) + + assert snapshot.shed_time_to_priority_shed_min is None + assert snapshot.shed_total_time_remaining_min == int(_published(TOTAL_TIME_REMAINING)) + + +def test_a_panel_with_no_forecast_node_carries_no_forecast() -> None: + """The presence gate a consumer builds entities from.""" + snapshot = _snapshot(_without_node(_mutable_tree())) + + assert snapshot.shed_time_to_priority_shed_min is None + assert snapshot.shed_total_time_remaining_min is None + assert snapshot.shed_full_charge_time_to_priority_shed_min is None + assert snapshot.shed_full_charge_total_time_remaining_min is None + assert snapshot.shed_forecast_confidence is None + + +def test_zero_minutes_is_a_reading_and_not_an_absence() -> None: + """The distinction the `None` default exists to keep: shedding has started.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/{TIME_TO_PRIORITY_SHED}"] = "0" + + assert _snapshot(tree).shed_time_to_priority_shed_min == 0 + + +def test_a_whole_number_sent_with_a_decimal_point_still_reads() -> None: + """The datatype declares the quantity, not the formatting. A publisher that + serialises 3037 as `3037.0` has not stopped publishing minutes.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/{TOTAL_TIME_REMAINING}"] = "4321.0" + + assert _snapshot(tree).shed_total_time_remaining_min == 4321 + + +def test_a_value_that_is_not_a_number_reads_as_absent() -> None: + """Same answer as not publishing, because neither is a reading.""" + tree = _mutable_tree() + tree[PANEL][f"{NODE}/{TOTAL_TIME_REMAINING}"] = "unknown" + + assert _snapshot(tree).shed_total_time_remaining_min is None + + +# --------------------------------------------------------------------------- +# Metadata: the two live estimates carry the declared unit +# --------------------------------------------------------------------------- + + +def test_the_live_estimates_take_their_unit_from_the_tree() -> None: + metadata = build_field_metadata(_devices(parent_child_tree())) + declared = _declared(parent_child_tree())[NODE]["properties"] + + for property_id, field_path in _LIVE_PATHS.items(): + entry = metadata[field_path] + assert entry.resolved is True + assert entry.unit == declared[property_id]["unit"] + assert entry.datatype == declared[property_id]["datatype"] + + +def test_changing_the_declared_unit_changes_the_metadata() -> None: + """The mutation proof for the metadata half: the unit is read from the + device's `$description`, not from the vendored catalog and not from a + literal in the adapter.""" + tree = _mutable_tree() + description = json.loads(tree[PANEL]["$description"]) + description["nodes"][NODE]["properties"][TOTAL_TIME_REMAINING]["unit"] = "h" + tree[PANEL]["$description"] = json.dumps(description) + + metadata = build_field_metadata(_devices(tree)) + + assert metadata["panel.shed_total_time_remaining_min"].unit == "h" + + +def test_a_declared_node_missing_a_property_is_a_gap_not_absent_hardware() -> None: + """The three-way contract. The node is here, so an omitted property is + degradation and gets an unresolved row rather than no row.""" + metadata = build_field_metadata(_devices(_without_property(_mutable_tree(), TIME_TO_PRIORITY_SHED))) + + entry = metadata["panel.shed_time_to_priority_shed_min"] + assert entry == FieldMetadata(unit=None, datatype="unknown", resolved=False) + + +def test_no_forecast_node_produces_no_rows_at_all() -> None: + """Hardware that is not there is not a defect: no entry, so a consumer reads + "nothing will populate this" rather than "this is broken".""" + metadata = build_field_metadata(_devices(_without_node(_mutable_tree()))) + + for field_path in _LIVE_PATHS.values(): + assert field_path not in metadata + + +def test_the_hypothetical_pair_and_confidence_carry_no_metadata_row() -> None: + """Deliberate, and asserted so it stays deliberate. They are read into the + snapshot but rendered beside the two live estimates rather than as readings + of their own, so there is no unit surface for a row to describe. + """ + metadata = build_field_metadata(_devices(parent_child_tree())) + + assert "panel.shed_full_charge_time_to_priority_shed_min" not in metadata + assert "panel.shed_full_charge_total_time_remaining_min" not in metadata + assert "panel.shed_forecast_confidence" not in metadata diff --git a/tests/test_schema_one_snapshot.py b/tests/test_schema_one_snapshot.py new file mode 100644 index 0000000..e4c0a44 --- /dev/null +++ b/tests/test_schema_one_snapshot.py @@ -0,0 +1,140 @@ +"""End-to-end snapshot assembly from the whole captured v1.0 tree.""" + +from __future__ import annotations + +import pytest + +from ebus_sdk.homie import DiscoveredDevice + +from span_panel_api.models import SpanPanelSnapshot +from span_panel_api_schema_1.reference_payloads import device_from_topics, parent_child_tree +from span_panel_api_schema_1.snapshot import TreeRoles, build_snapshot + +_TREE = parent_child_tree() + +PANEL = "example-40t-001" +SOLAR_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4" + + +def _device(device_id: str) -> DiscoveredDevice: + return device_from_topics(device_id, _TREE[device_id]) + + +def _children() -> list[DiscoveredDevice]: + return [_device(device_id) for device_id in _TREE if device_id != PANEL] + + +@pytest.fixture(name="snapshot") +def _snapshot() -> SpanPanelSnapshot: + return build_snapshot(_device(PANEL), _children()) + + +def test_roles_are_sorted_by_declared_type_not_device_id() -> None: + """The reference tree's ids are the simulator's naming; the type string is + what the schema defines.""" + roles = TreeRoles(_children()) + + assert len(roles.circuits) == 5 + assert len(roles.lugs) == 2 + assert len(roles.evse) == 2 + assert roles.bess is not None and roles.bess.device_id == "bess" + assert roles.pv is not None and roles.pv.device_id == "pv" + assert roles.mid is not None and roles.mid.device_id == "bess-mid" + + +def test_snapshot_carries_panel_identity(snapshot: SpanPanelSnapshot) -> None: + assert snapshot.serial_number == "example-40t-001" + assert snapshot.panel_size == 40 + assert snapshot.main_breaker_rating_a == 200 + + +def test_every_real_circuit_is_present(snapshot: SpanPanelSnapshot) -> None: + real = {cid for cid in snapshot.circuits if not cid.startswith("unmapped_tab_")} + + assert len(real) == 5 + assert SOLAR_CIRCUIT in real + assert snapshot.circuits[SOLAR_CIRCUIT].name == "Solar Inverter" + + +def test_unoccupied_positions_are_filled_up_to_the_panel_size(snapshot: SpanPanelSnapshot) -> None: + """The feature the model lookup exists for: the tree lists occupied + positions and says nothing about the rest.""" + occupied = {tab for cid, c in snapshot.circuits.items() if not cid.startswith("unmapped_tab_") for tab in c.tabs} + unmapped = {cid for cid in snapshot.circuits if cid.startswith("unmapped_tab_")} + + assert len(occupied) + len(unmapped) == 40 + assert "unmapped_tab_40" in unmapped + # Occupied positions are never synthesised. + for tab in occupied: + assert f"unmapped_tab_{tab}" not in unmapped + + +def test_a_circuit_feeding_a_der_reports_the_der_type(snapshot: SpanPanelSnapshot) -> None: + """Matches the flat adapter, where a PV-feeding circuit reports device_type + 'pv' rather than 'circuit'.""" + assert snapshot.circuits[SOLAR_CIRCUIT].device_type == "pv" + + +def test_der_snapshots_are_populated(snapshot: SpanPanelSnapshot) -> None: + assert snapshot.battery.soe_percentage == pytest.approx(50.4104, rel=1e-4) + assert snapshot.battery.connected is True + assert snapshot.pv.model == "IQ8PLUS-72-2-US" + assert snapshot.pv.feed_circuit_id == SOLAR_CIRCUIT + # Keyed by serial, not by device id: on real flat firmware the EVSE node id is + # the Drive's serial (SpanPanel/span#214), so this is what keeps a charger's + # `unique_id` still across the migration. The reference tree's bare `evse` / + # `evse-2` device ids are the simulator's naming, not a panel's. + assert set(snapshot.evse) == {"SIM-EVSE-example-40t-001", "SIM-EVSE-example-40t-001-2"} + assert snapshot.evse["SIM-EVSE-example-40t-001"].status == "CHARGING" + + +def test_panel_and_lugs_values_reach_the_snapshot(snapshot: SpanPanelSnapshot) -> None: + assert snapshot.instant_grid_power_w == -5847.0 + assert snapshot.power_flow_pv == 8500.0 + assert snapshot.grid_state == "ON_GRID" + assert snapshot.l1_voltage == 120.0 + + +def test_the_grid_answers_are_read_from_the_mid_not_derived(snapshot: SpanPanelSnapshot) -> None: + """Both entities keep the values a user has today, by reading instead of guessing. + + Flat inferred these from `dominant-power-source` plus grid power because nothing + stated them. v1.0 states them on the MID, so the multi-signal heuristic is gone and + the answer is authoritative -- while the user-visible vocabulary is unchanged, which + is the whole point: `dsm_state` and `current_run_config` are existing entities whose + history must survive the migration. + + This asserted `UNKNOWN` for both until 2026-08-10, on the reasoning that two of the + heuristic's three inputs no longer exist. True of the *inputs*, wrong as a conclusion: + v1.0 removed the need to infer rather than the ability to answer. + + `PANEL_BACKUP` versus `PANEL_OFF_GRID` gets strictly better than flat here — flat + guessed it from the dominant power source, v1.0 names the forming device and its + class is recoverable from the tree. + """ + assert snapshot.dsm_state == "DSM_ON_GRID" + assert snapshot.current_run_config == "PANEL_ON_GRID" + + +def test_an_unsizable_panel_yields_no_unmapped_positions() -> None: + """A panel whose model we cannot size must not fabricate positions.""" + panel = _device(PANEL) + panel.update_property("info", "model", "MAIN_99") + + snapshot = build_snapshot(panel, _children()) + + assert snapshot.panel_size == 0 + assert not [cid for cid in snapshot.circuits if cid.startswith("unmapped_tab_")] + # Real circuits survive — only the synthesised ones depend on the total. + assert len(snapshot.circuits) == 5 + + +def test_a_panel_with_no_children_still_builds() -> None: + """A panel mid-discovery has announced itself but no descendants yet.""" + snapshot = build_snapshot(_device(PANEL), []) + + assert snapshot.serial_number == "example-40t-001" + assert snapshot.instant_grid_power_w == 0.0 + assert snapshot.battery.soe_percentage is None + # Every position is unoccupied, so all 40 are synthesised. + assert len(snapshot.circuits) == 40 diff --git a/tests/test_schema_one_transport.py b/tests/test_schema_one_transport.py new file mode 100644 index 0000000..52ceceb --- /dev/null +++ b/tests/test_schema_one_transport.py @@ -0,0 +1,233 @@ +"""The seam that lets `ebus_sdk.Controller` parse a tree it owns no socket for. + +These tests are about routing, not parsing. They pin the behaviour the SDK's own +MQTT client provides internally, which a `SchemaAdapter` cannot rely on because +it is built before any connection exists and never receives one. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from ebus_sdk import MqttControllerTransport + +from span_panel_api_schema_1 import ControllerRoutes +from span_panel_api_schema_1.transport import MAX_HELD_MESSAGES + + +def test_it_satisfies_the_sdk_transport_protocol() -> None: + """Structural conformance, checked rather than assumed. + + `MqttControllerTransport` is runtime_checkable and method-only, so this is a + real check — and it is what upstream shipped in 0.17.0 specifically so a + bring-your-own-transport consumer would not need a cast. + """ + assert isinstance(ControllerRoutes(), MqttControllerTransport) + + +def test_it_needs_no_connection_to_construct() -> None: + """The whole point. The transport builds the parser before the connection + exists, so anything the parser owns must be constructible without one.""" + routes = ControllerRoutes() + + assert routes.routes == () + + +def test_subscribe_records_a_route() -> None: + routes = ControllerRoutes() + callback = MagicMock() + + routes.subscribe("ebus/5/panel/#", callback, qos=1) + + assert routes.routes == ("ebus/5/panel/#",) + + +def test_unsubscribe_forgets_the_route() -> None: + """The wire subscription is broader and stays put; messages for a device the + SDK dropped simply stop matching.""" + routes = ControllerRoutes() + callback = MagicMock() + routes.subscribe("ebus/5/child/#", callback) + + routes.unsubscribe("ebus/5/child/#") + + assert routes.routes == () + routes.dispatch("ebus/5/child/meter/active-power", "1.0") + callback.assert_not_called() + + +def test_unsubscribing_something_unknown_is_harmless() -> None: + ControllerRoutes().unsubscribe("ebus/5/never-subscribed/#") # must not raise + + +def test_publish_refuses_rather_than_silently_dropping() -> None: + """Commands do not travel this way — the adapter returns a topic and the + transport layer sends it. A silent no-op here would leave the panel in the + state the user was trying to change, with the UI reporting they changed it. + """ + with pytest.raises(NotImplementedError, match="receive-only"): + ControllerRoutes().publish("ebus/5/panel/core/relay/set", "CLOSED") + + +def test_dispatch_delivers_bytes_to_the_matching_callback() -> None: + """The transport hands us `str`; the SDK hands its callbacks `bytes`.""" + routes = ControllerRoutes() + callback = MagicMock() + routes.subscribe("ebus/5/panel/+/+", callback) + + routes.dispatch("ebus/5/panel/meter/active-power", "-121.0") + + callback.assert_called_once_with("ebus/5/panel/meter/active-power", b"-121.0") + + +def test_a_topic_matching_no_route_reaches_nobody_yet() -> None: + """Expected, not exceptional: the wire subscription is broader than the + SDK's interest by construction. It is held rather than delivered — see the + ordering tests below for why it is not simply thrown away.""" + routes = ControllerRoutes() + callback = MagicMock() + routes.subscribe("ebus/5/panel/#", callback) + + routes.dispatch("ebus/5/other-device/meter/active-power", "1.0") + + callback.assert_not_called() + assert routes.held == 1 + + +def test_the_most_recently_recorded_matching_route_wins() -> None: + """Defensive rather than currently required: tree-rooted discovery records + four device-scoped patterns per device, which cannot overlap. But the SDK's + wildcard mode subscribes `/5/+/$state`, overlapping every per-device + `$state`. Under insertion order that would hand a device's state to the + wildcard handler — silent misattribution, not an error.""" + routes = ControllerRoutes() + broad = MagicMock(name="root") + narrow = MagicMock(name="child") + routes.subscribe("ebus/5/#", broad) + routes.subscribe("ebus/5/child-a/#", narrow) + + routes.dispatch("ebus/5/child-a/meter/active-power", "-3500.0") + + narrow.assert_called_once() + broad.assert_not_called() + + +def test_a_topic_only_the_broad_route_covers_still_arrives() -> None: + """The corollary: preferring the specific must not strand the general.""" + routes = ControllerRoutes() + broad = MagicMock(name="root") + narrow = MagicMock(name="child") + routes.subscribe("ebus/5/#", broad) + routes.subscribe("ebus/5/child-a/#", narrow) + + routes.dispatch("ebus/5/panel/$state", "ready") + + broad.assert_called_once() + narrow.assert_not_called() + + +def test_rerecording_a_pattern_replaces_it_and_moves_it_to_most_recent() -> None: + """Re-registering must not leave the stale callback ahead in match order. + + Caught by this test in review: assigning an existing dict key updates the + value but keeps the key's original position. + """ + routes = ControllerRoutes() + first = MagicMock(name="first") + second = MagicMock(name="second") + routes.subscribe("ebus/5/child-a/#", first) + routes.subscribe("ebus/5/#", MagicMock(name="root")) + routes.subscribe("ebus/5/child-a/#", second) + + routes.dispatch("ebus/5/child-a/$state", "ready") + + second.assert_called_once() + first.assert_not_called() + + +# --------------------------------------------------------------------------- +# Arrival order +# +# One wire subscription delivers the whole tree at once, but the SDK registers +# its routes as it walks that tree. Anything arriving ahead of its route has to +# survive the gap, because the broker chooses the replay order and is under no +# obligation to hand back a parent before its children. +# --------------------------------------------------------------------------- + + +def test_a_message_that_arrives_before_its_route_is_delivered_when_the_route_appears() -> None: + routes = ControllerRoutes() + callback = MagicMock() + + routes.dispatch("ebus/5/child-a/meter/active-power", "-3500.0") + callback.assert_not_called() + + routes.subscribe("ebus/5/child-a/+/+", callback) + + callback.assert_called_once_with("ebus/5/child-a/meter/active-power", b"-3500.0") + assert routes.held == 0 + + +def test_a_held_topic_keeps_only_its_latest_value() -> None: + """The same last-value-wins rule the broker applies to the retained message + this stands in for. Delivering the stale reading too would be worse than + dropping it — the SDK would end on whichever arrived last.""" + routes = ControllerRoutes() + callback = MagicMock() + + routes.dispatch("ebus/5/child-a/meter/active-power", "-3500.0") + routes.dispatch("ebus/5/child-a/meter/active-power", "-3400.0") + routes.subscribe("ebus/5/child-a/+/+", callback) + + callback.assert_called_once_with("ebus/5/child-a/meter/active-power", b"-3400.0") + + +def test_releasing_a_message_can_register_the_routes_that_release_the_rest() -> None: + """How a whole tree unfolds from one root subscription. + + Releasing the root's description is what makes the SDK subscribe to its + children, whose own messages are already held — so release has to be + re-entrant, or the tree stops one level down. + """ + routes = ControllerRoutes() + child = MagicMock(name="child") + + def on_root(_topic: str, _payload: bytes) -> None: + routes.subscribe("ebus/5/child-a/+/+", child) + + routes.dispatch("ebus/5/child-a/meter/active-power", "-3500.0") + routes.dispatch("ebus/5/panel/$description", "{}") + + routes.subscribe("ebus/5/panel/$description", on_root) + + child.assert_called_once_with("ebus/5/child-a/meter/active-power", b"-3500.0") + assert routes.held == 0 + + +def test_a_released_message_is_not_delivered_again() -> None: + routes = ControllerRoutes() + callback = MagicMock() + routes.dispatch("ebus/5/child-a/meter/active-power", "-3500.0") + + routes.subscribe("ebus/5/child-a/+/+", callback) + routes.subscribe("ebus/5/child-a/+/+", callback) + + callback.assert_called_once() + + +def test_held_messages_stop_accumulating_at_the_ceiling() -> None: + """Unclaimed topics would otherwise be a slow leak in a process that runs + for months. Values already held still update — it is new topics that stop.""" + routes = ControllerRoutes() + + for index in range(MAX_HELD_MESSAGES + 10): + routes.dispatch(f"ebus/5/device-{index}/meter/active-power", "1.0") + routes.dispatch("ebus/5/device-0/meter/active-power", "2.0") + + assert routes.held == MAX_HELD_MESSAGES + + callback = MagicMock() + routes.subscribe("ebus/5/device-0/+/+", callback) + callback.assert_called_once_with("ebus/5/device-0/meter/active-power", b"2.0") diff --git a/tests/test_schema_provenance.py b/tests/test_schema_provenance.py new file mode 100644 index 0000000..556a809 --- /dev/null +++ b/tests/test_schema_provenance.py @@ -0,0 +1,177 @@ +"""Provenance checks — do this adapter's hardcoded facts still match the wire? + +Design doc testing item 8, clauses 8a and 8b. This is the **only** signal that +catches adapter-axis drift before release. Every other symptom of "SPAN changed +the schema and we did not notice" shows up in production as a silent absence: a +property that stops arriving, a metadata lookup that quietly returns None, an +entity that goes unavailable without an error anywhere. + +The failure this guards against has already happened once upstream +(electrification-bus/python-sdk#27 was exactly a hardcoded fact that had stopped +resolving against its source), which is why it is worth having with a single +adapter rather than waiting for schema_1 to make comparison interesting. + +Clause 8c (does SUPPORTS_DATA_MODEL_VERSIONS still cover what the panel reports) +is deliberately absent: flat firmware publishes no version to compare against, +and the check only becomes meaningful with a second adapter. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from span_panel_api.reference_payloads import homie_schema +from span_panel_api_schema_0 import const +from span_panel_api_schema_0.field_metadata import _LUGS_FALLBACK, _PROPERTY_FIELD_MAP, _lookup_property + + +@pytest.fixture(name="schema") +def _schema() -> dict[str, Any]: + """The captured `GET /api/v2/homie/schema` response — our stand-in for the panel. + + Read through the shipped accessor, so the anchor below is checked against + the bytes a consumer installing this release gets rather than against a + file that only exists in this checkout. + """ + return dict(homie_schema()) + + +# --------------------------------------------------------------------------- +# 8b — anchor check +# --------------------------------------------------------------------------- + + +def test_captured_schema_still_matches_the_anchor(schema: dict[str, Any]) -> None: + """The schema revision this adapter was written against. + + A mismatch does not mean the adapter is broken — it means the schema moved + and every fact below is now unverified until someone looks. That is the + whole job of an anchor: convert a silent change into a visible one. + """ + assert schema[const.SCHEMA_ANCHOR_FIELD] == const.SCHEMA_ANCHOR, ( + f"Schema hash moved from {const.SCHEMA_ANCHOR} to {schema[const.SCHEMA_ANCHOR_FIELD]}. " + "Re-verify the facts in const.py and _PROPERTY_FIELD_MAP against the new schema, " + "then update SCHEMA_ANCHOR." + ) + + +def test_anchor_field_is_the_flat_era_name(schema: dict[str, Any]) -> None: + """Flat serves `typesSchemaHash` over `types`; parent/child renames both to + `deviceClassesSchemaHash` over `deviceClasses`. + + Pinning the name here is what stops schema_1 from inheriting a field that + does not exist on its firmware and silently getting no anchor at all. + """ + assert const.SCHEMA_ANCHOR_FIELD in schema + assert "deviceClassesSchemaHash" not in schema, "this fixture is parent/child, not flat" + assert schema["firmwareVersion"] == const.SCHEMA_ANCHOR_FIRMWARE + + +# --------------------------------------------------------------------------- +# 8a — hardcoded facts resolve against source +# --------------------------------------------------------------------------- + + +def test_homie_domain_and_version_match_the_schema(schema: dict[str, Any]) -> None: + """TOPIC_PREFIX is built from these two, so every topic this adapter + subscribes to or publishes depends on them being right.""" + assert const.HOMIE_DOMAIN == schema["homieDomain"] + assert const.HOMIE_VERSION == schema["homieVersion"] + assert const.TOPIC_PREFIX == f"{schema['homieDomain']}/{schema['homieVersion']}" + + +# Node types this adapter restates from the schema's `types` block. +_SCHEMA_DECLARED_TYPES = ( + const.TYPE_CORE, + const.TYPE_LUGS, + const.TYPE_CIRCUIT, + const.TYPE_BESS, + const.TYPE_PV, + const.TYPE_EVSE, + const.TYPE_POWER_FLOWS, +) + +# Node types real firmware publishes in $description but the schema does not +# declare. See const.py: the schema carries only the base lugs type. +_WIRE_ONLY_TYPES = ( + const.TYPE_LUGS_UPSTREAM, + const.TYPE_LUGS_DOWNSTREAM, +) + + +@pytest.mark.parametrize("node_type", _SCHEMA_DECLARED_TYPES) +def test_declared_node_types_exist_in_the_schema(node_type: str, schema: dict[str, Any]) -> None: + assert node_type in schema["types"], f"{node_type} is no longer a declared type" + + +@pytest.mark.parametrize("node_type", _WIRE_ONLY_TYPES) +def test_wire_only_types_are_absent_but_aliased(node_type: str, schema: dict[str, Any]) -> None: + """The two namespaces are not the same set, and this pins both halves. + + These types are real — confirmed against a live panel — but undeclared, so + a metadata lookup for them only works through the alias. If SPAN ever + *declares* them, the alias becomes wrong and this test says so. If someone + adds another wire-only subtype without an alias, property metadata silently + comes back empty for those nodes and this test catches that too. + """ + assert ( + node_type not in schema["types"] + ), f"{node_type} is now declared in the schema; the _LUGS_FALLBACK alias may no longer be correct" + assert node_type in _LUGS_FALLBACK, f"{node_type} is undeclared and unaliased — metadata lookups will return None" + assert _LUGS_FALLBACK[node_type] in schema["types"] + + +def test_every_mapped_property_resolves_against_the_schema(schema: dict[str, Any]) -> None: + """The core 8a assertion. + + `_PROPERTY_FIELD_MAP` is ~70 hardcoded (node_type, property_id) pairs, each + asserting a property exists on the wire. Every one must resolve through the + same lookup path `build_field_metadata` uses — otherwise that field silently + gets no unit and no datatype, and the integration renders an entity with no + device class rather than failing. + """ + unresolved = [ + f"{node_type}/{property_id} -> {field_path}" + for node_type, property_id, field_path in _PROPERTY_FIELD_MAP + if _lookup_property(schema["types"], node_type, property_id) is None + ] + + assert not unresolved, "hardcoded properties no longer in the schema:\n " + "\n ".join(unresolved) + + +def test_no_mapped_property_is_missing_a_field_path() -> None: + """Every mapping row must name a snapshot field, and no two rows may claim + the same one — a duplicate means one silently overwrites the other.""" + field_paths = [field_path for _, _, field_path in _PROPERTY_FIELD_MAP] + + assert all(field_paths), "a mapping row has an empty field path" + duplicates = {path for path in field_paths if field_paths.count(path) > 1} + assert not duplicates, f"field paths claimed by more than one property: {sorted(duplicates)}" + + +# --------------------------------------------------------------------------- +# Known, deliberate disagreements with the schema +# --------------------------------------------------------------------------- + + +def test_circuit_active_power_unit_still_disagrees_with_the_schema(schema: dict[str, Any]) -> None: + """The schema says kW. Real panels publish W. We follow the panel. + + Recorded as an asserted expectation rather than a comment because it is a + standing contradiction between our implementation and the published schema, + and the natural instinct on finding it is to "fix" the code back to kW — + which would reintroduce the 1000x error that 1eef0dc removed after checking + against real hardware. + + When this test fails because the schema now says W, the disagreement is over: + delete this test. It failing is good news. + """ + declared = schema["types"][const.TYPE_CIRCUIT]["active-power"]["unit"] + + assert declared == "kW", ( + "The schema now declares circuit active-power as " + f"{declared!r} rather than 'kW'. If that is 'W', the long-standing " + "schema-versus-hardware disagreement is resolved and this test should be deleted." + ) diff --git a/tests/test_schema_zero_adapter.py b/tests/test_schema_zero_adapter.py new file mode 100644 index 0000000..b567857 --- /dev/null +++ b/tests/test_schema_zero_adapter.py @@ -0,0 +1,87 @@ +"""SchemaZeroAdapter contract tests. + +The adapter owns every piece of flat-schema knowledge that used to live in +SpanMqttClient: which topics to subscribe to, and how to address a settable +property. These tests pin the exact topic strings, because the flat wire format +is fixed by shipped firmware and must not drift. +""" + +from __future__ import annotations + +import pytest + +from span_panel_api_schema_0 import SchemaZeroAdapter + +from conftest import flat_schema +from span_panel_api.protocol import SchemaAdapter + +SERIAL = "sim-40t-001" + + +@pytest.fixture +def adapter() -> SchemaZeroAdapter: + return SchemaZeroAdapter(serial_number=SERIAL, schema=flat_schema(40)) + + +def test_satisfies_the_protocol(adapter: SchemaZeroAdapter) -> None: + assert isinstance(adapter, SchemaAdapter) + + +def test_declares_its_dispatch_key_and_range(adapter: SchemaZeroAdapter) -> None: + assert adapter.schema_major == "schema_0" + assert adapter.SUPPORTS_DATA_MODEL_VERSIONS == (">=0", "<1.0") + + +def test_subscribes_to_the_single_panel_wildcard(adapter: SchemaZeroAdapter) -> None: + """Flat schema is one device, so one wildcard captures everything.""" + assert adapter.topics_to_subscribe() == [f"ebus/5/{SERIAL}/#"] + + +def test_circuit_setter_topics_address_the_panel_device(adapter: SchemaZeroAdapter) -> None: + circuit = "ac3dccda46a94b98878a227df6fed588" + assert adapter.set_circuit_relay_topic(circuit) == f"ebus/5/{SERIAL}/{circuit}/relay/set" + assert adapter.set_circuit_priority_topic(circuit) == f"ebus/5/{SERIAL}/{circuit}/shed-priority/set" + + +def test_dominant_power_source_topic_is_none_before_the_core_node_is_known( + adapter: SchemaZeroAdapter, +) -> None: + """The core node id is discovered from $description, so it is unavailable + until a description has been routed through handle_message.""" + assert adapter.set_dominant_power_source_topic() is None + + +def test_is_not_ready_before_any_message(adapter: SchemaZeroAdapter) -> None: + assert adapter.is_ready() is False + + +def test_schema_zero_marks_missing_property_unresolved() -> None: + """A type block that exists but drops a property is degradation, and must + not read the same as a type that is absent entirely.""" + from span_panel_api_schema_0.field_metadata import build_field_metadata + + types = {"energy.ebus.device.circuit": {"name": {"datatype": "string"}}} + metadata = build_field_metadata(types) + + assert metadata["circuit.instant_power_w"].resolved is False + assert metadata["circuit.instant_power_w"].unit is None + assert "battery.soe_percentage" not in metadata + + +def test_schema_zero_presence_follows_the_lugs_fallback() -> None: + """The lugs fallback is schema_0's equivalent of schema_1's subtype rule. + + Rows are keyed on the typed lugs variants, but firmware that publishes only + the generic `…device.lugs` block resolves through `_LUGS_FALLBACK`. Presence + has to use the same path, or a property dropped from a generic-lugs block + reads as absent hardware rather than as the drop it is. + """ + from span_panel_api_schema_0.field_metadata import build_field_metadata + + types = {"energy.ebus.device.lugs": {"active-power": {"datatype": "float", "unit": "W"}}} + metadata = build_field_metadata(types) + + assert metadata["panel.instant_grid_power_w"].resolved is True + assert metadata["panel.upstream_l1_current_a"].resolved is False + assert metadata["panel.downstream_l2_current_a"].resolved is False + assert "circuit.instant_power_w" not in metadata diff --git a/tests/test_shared_http_client.py b/tests/test_shared_http_client.py new file mode 100644 index 0000000..f9e0bf4 --- /dev/null +++ b/tests/test_shared_http_client.py @@ -0,0 +1,121 @@ +"""The runtime path uses the caller's HTTP client, not one of its own. + +Four config-flow-facing entry points already take an injected +`httpx.AsyncClient`; `SpanMqttClient` was the one runtime entry point without +it, so every schema fetch built a throwaway client -- including the retry loop +that runs during a firmware upgrade, which builds one per attempt at exactly the +moment the panel is mid-reboot. + +Home Assistant is the caller that cares. It owns a shared client, closes it at +shutdown, and the integration's own `quality_scale.yaml` claims +`inject-websession: done` -- a claim that was true of the config flow and not of +anything that ran afterwards. + +**Ownership is the whole contract.** A client handed in is never closed here, and +its policy is the caller's: timeouts, limits and headers are whatever the caller +configured, which is why the per-call `timeout` arguments are documented as +ignored when a client is injected. Home Assistant's shared client carries +httpx's default timeout rather than this library's, and that is the caller +exercising the policy it owns rather than a setting being lost. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from span_panel_api.mqtt import MqttClientConfig +from span_panel_api.mqtt.client import SpanMqttClient + +SERIAL = "sp3-242424-001" + + +class _Schema: + def __init__(self, version: str | None) -> None: + self.data_model_version = version + + +def _client(injected: object | None) -> SpanMqttClient: + return SpanMqttClient( + host="192.168.1.1", + serial_number=SERIAL, + broker_config=MqttClientConfig(broker_host="broker.local", username="u", password="p"), + httpx_client=injected, # type: ignore[arg-type] + ) + + +@pytest.mark.asyncio +async def test_the_connect_fetch_uses_the_injected_client() -> None: + """The first schema read of a session, and the one every install makes.""" + sentinel = MagicMock(name="shared-client") + client = _client(sentinel) + + fetch = AsyncMock(return_value=_Schema("1.0")) + with ( + patch("span_panel_api.mqtt.client.get_homie_schema", fetch), + patch.object(client, "_preload_adapter", AsyncMock()), + patch.object(client, "_build_adapter", MagicMock()), + patch.object(client, "_connect_bridge", AsyncMock(), create=True), + ): + # Connect goes on to bring up the MQTT bridge, which has nothing to do + # with this assertion and no broker to reach. The fetch is what is under + # test, and the await-count assertion below is what keeps that from + # passing vacuously if it never happened at all. + with contextlib.suppress(Exception): + await client.connect() + + assert fetch.await_count >= 1 + assert fetch.await_args.kwargs["httpx_client"] is sentinel + + +@pytest.mark.asyncio +async def test_the_upgrade_refetch_uses_the_injected_client() -> None: + """The path that mattered most, because it builds one client per retry attempt. + + A panel accepts MQTT before it serves HTTP, so this loop can run several times + in a row while the panel finishes booting -- each one previously a fresh + client, a fresh connection pool, thrown away on the next attempt. + """ + sentinel = MagicMock(name="shared-client") + client = _client(sentinel) + client._loop = asyncio.get_running_loop() + + fetch = AsyncMock(return_value=_Schema("1.0")) + with patch("span_panel_api.mqtt.client.get_homie_schema", fetch): + assert await client._fetch_schema_with_retry() is not None + + assert fetch.await_args.kwargs["httpx_client"] is sentinel + + +@pytest.mark.asyncio +async def test_an_injected_client_is_never_closed_here() -> None: + """It belongs to the caller, and the caller may still be using it. + + Home Assistant hands out one shared client to every integration and closes it + at shutdown; closing it from here would take the others down with it. HA + guards its own copy, but a library that relies on the caller guarding it is + relying on the caller. + """ + sentinel = MagicMock(name="shared-client") + sentinel.aclose = AsyncMock() + client = _client(sentinel) + + await client.close() + + sentinel.aclose.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_no_injected_client_still_works() -> None: + """The default has to stay the default: this library is not Home Assistant's alone.""" + client = _client(None) + client._loop = asyncio.get_running_loop() + + fetch = AsyncMock(return_value=_Schema("1.0")) + with patch("span_panel_api.mqtt.client.get_homie_schema", fetch): + assert await client._fetch_schema_with_retry() is not None + + assert fetch.await_args.kwargs["httpx_client"] is None diff --git a/uv.lock b/uv.lock index 1ff3b6c..c960c95 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,12 @@ version = 1 revision = 3 -requires-python = ">=3.10, <4.0" -resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", +requires-python = ">=3.14, <4.0" + +[manifest] +members = [ + "span-panel-api", + "span-panel-api-schema-0", + "span-panel-api-schema-1", ] [[package]] @@ -12,9 +14,7 @@ name = "anyio" version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -25,32 +25,11 @@ wheels = [ name = "astroid" version = "4.0.4" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - -[[package]] -name = "backports-tarfile" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, -] - [[package]] name = "bandit" version = "1.9.4" @@ -77,31 +56,9 @@ dependencies = [ { name = "pathspec" }, { name = "platformdirs" }, { name = "pytokens" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, - { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, - { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, - { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, - { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, - { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, - { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, - { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, - { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, - { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, - { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, - { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, @@ -128,28 +85,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, @@ -175,70 +110,6 @@ version = "3.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" }, - { url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" }, - { url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" }, - { url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" }, - { url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" }, - { url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" }, - { url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" }, - { url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" }, - { url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" }, - { url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" }, - { url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" }, - { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, - { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, - { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, - { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, - { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, - { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, - { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, - { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, - { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, - { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, - { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, - { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, - { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, - { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, - { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, - { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, - { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, - { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, - { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, - { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, - { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, - { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, - { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, - { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, @@ -301,80 +172,6 @@ version = "7.13.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, - { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, - { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, - { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, - { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, - { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, - { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, - { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, - { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, - { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, - { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, - { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, @@ -408,18 +205,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - [[package]] name = "cryptography" version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ @@ -450,10 +241,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, - { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, - { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, ] [[package]] @@ -484,15 +271,27 @@ wheels = [ ] [[package]] -name = "exceptiongroup" -version = "1.3.1" +name = "ebus-mqtt-client" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "paho-mqtt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/63/4eb799040e1accb243da6ec2726baff2fb8eed5c5aaab0d5e88f98816820/ebus_mqtt_client-0.5.0.tar.gz", hash = "sha256:4cc823b7011dfa8e90ad1606fec971fa4e7dae505e6e534c936a2097883b4e63", size = 36852, upload-time = "2026-08-22T04:45:27.488Z" } +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-sdk" +version = "0.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "ebus-mqtt-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, + { 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" }, ] [[package]] @@ -571,18 +370,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] -[[package]] -name = "importlib-metadata" -version = "9.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -617,9 +404,6 @@ wheels = [ name = "jaraco-context" version = "6.1.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/27/7b/c3081ff1af947915503121c649f26a778e1a2101fd525f74aef997d75b7e/jaraco_context-6.1.1.tar.gz", hash = "sha256:bc046b2dc94f1e5532bd02402684414575cc11f565d929b6563125deb0a6e581", size = 15832, upload-time = "2026-03-07T15:46:04.63Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/49/c152890d49102b280ecf86ba5f80a8c111c3a155dafa3bd24aeb64fde9e1/jaraco_context-6.1.1-py3-none-any.whl", hash = "sha256:0df6a0287258f3e364072c3e40d5411b20cafa30cb28c4839d24319cecf9f808", size = 7005, upload-time = "2026-03-07T15:46:03.515Z" }, @@ -651,7 +435,6 @@ name = "keyring" version = "25.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, { name = "jaraco-classes" }, { name = "jaraco-context" }, { name = "jaraco-functools" }, @@ -670,57 +453,6 @@ version = "0.8.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, - { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, - { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, - { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, - { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, - { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, - { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, - { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, - { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, - { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, - { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, - { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, - { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, - { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, - { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, - { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, - { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, - { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, - { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, @@ -808,35 +540,10 @@ dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, @@ -900,11 +607,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -988,7 +695,6 @@ dependencies = [ { name = "isort" }, { name = "mccabe" }, { name = "platformdirs" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tomlkit" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" } @@ -1002,12 +708,10 @@ version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ @@ -1019,9 +723,7 @@ name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ @@ -1033,7 +735,7 @@ name = "pytest-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, + { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] @@ -1061,26 +763,6 @@ version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, - { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, - { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, - { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, - { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, - { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, - { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, - { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, - { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, - { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, - { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, @@ -1109,44 +791,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, @@ -1292,7 +936,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "2.6.4" +version = "3.0.0" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1300,6 +944,14 @@ dependencies = [ { name = "pyyaml" }, ] +[package.optional-dependencies] +schema-0 = [ + { name = "span-panel-api-schema-0" }, +] +schema-1 = [ + { name = "span-panel-api-schema-1" }, +] + [package.dev-dependencies] dev = [ { name = "bandit" }, @@ -1313,6 +965,8 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff" }, + { name = "span-panel-api-schema-0" }, + { name = "span-panel-api-schema-1" }, { name = "twine" }, { name = "types-pyyaml" }, { name = "vulture" }, @@ -1320,10 +974,13 @@ dev = [ [package.metadata] requires-dist = [ - { name = "httpx", specifier = ">=0.28.1" }, + { name = "httpx", specifier = ">=0.28.1,<1.0" }, { name = "paho-mqtt", specifier = ">=2.0.0,<3.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "packages/schema-0" }, + { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "packages/schema-1" }, ] +provides-extras = ["schema-0", "schema-1"] [package.metadata.requires-dev] dev = [ @@ -1338,11 +995,39 @@ dev = [ { name = "pytest-cov" }, { name = "radon" }, { name = "ruff", specifier = ">=0.15.5" }, - { name = "twine" }, + { name = "span-panel-api-schema-0", editable = "packages/schema-0" }, + { name = "span-panel-api-schema-1", editable = "packages/schema-1" }, + { name = "twine", specifier = ">=7.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vulture", specifier = ">=2.14" }, ] +[[package]] +name = "span-panel-api-schema-0" +version = "1.0.0" +source = { editable = "packages/schema-0" } +dependencies = [ + { name = "span-panel-api" }, +] + +[package.metadata] +requires-dist = [{ name = "span-panel-api", editable = "." }] + +[[package]] +name = "span-panel-api-schema-1" +version = "1.0.0" +source = { editable = "packages/schema-1" } +dependencies = [ + { name = "ebus-sdk" }, + { name = "span-panel-api" }, +] + +[package.metadata] +requires-dist = [ + { name = "ebus-sdk", specifier = ">=0.19.0,<0.24" }, + { name = "span-panel-api", editable = "." }, +] + [[package]] name = "stevedore" version = "5.7.0" @@ -1352,60 +1037,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, ] -[[package]] -name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, -] - [[package]] name = "tomlkit" version = "0.14.0" @@ -1417,7 +1048,7 @@ wheels = [ [[package]] name = "twine" -version = "6.2.0" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "id" }, @@ -1430,9 +1061,9 @@ dependencies = [ { name = "rich" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" }, ] [[package]] @@ -1471,7 +1102,6 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } wheels = [ @@ -1482,19 +1112,7 @@ wheels = [ name = "vulture" version = "2.15" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tomli", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/59/c6/4f147b621b4c0899eb1770f98113334bb706ebd251ac2be979316b1985fa/vulture-2.15.tar.gz", hash = "sha256:f9d8b4ce29c69950d323f21dceab4a4d6c694403dffbed7713c4691057e561fe", size = 52438, upload-time = "2026-03-04T21:41:39.096Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1c/f3/07cf122e145bc6df976030e9935123124c3fcb5044cf407b5e71e85821b4/vulture-2.15-py3-none-any.whl", hash = "sha256:a3d8ebef918694326620eb128fa783486c8d285b23381c2b457d864ac056ef8d", size = 26895, upload-time = "2026-03-04T21:41:39.878Z" }, ] - -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, -]