Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
123 commits
Select commit Hold shift + click to select a range
116da92
feat(protocol): add SchemaAdapter protocol and SpanPanelAdapterMissin…
cayossarian Aug 1, 2026
7854fb4
refactor: relocate flat-schema parsing into _impl/schema_0
cayossarian Aug 1, 2026
d10da42
feat(schema_0): add SchemaZeroAdapter composing accumulator and consumer
cayossarian Aug 1, 2026
89c6267
refactor(mqtt): delegate parsing and topic construction to SchemaAdapter
cayossarian Aug 1, 2026
52c3276
feat(adapters): discover schema adapters via entry points
cayossarian Aug 1, 2026
ea15330
feat(factory): dispatch to a discovered adapter and expose diagnostics
cayossarian Aug 1, 2026
b909e52
test: guard the public API surface across the Phase 0 restructure
cayossarian Aug 1, 2026
1b9b578
refactor(mqtt): drop the last schema_0 import from the transport boot…
cayossarian Aug 1, 2026
a31240d
Merge pull request #150 from SpanPanel/feat/schema-adapter-phase-0
cayossarian Aug 4, 2026
93b206b
feat!: remove the three flat-schema names from the public API
cayossarian Aug 4, 2026
590c6ff
refactor(mqtt): resolve the default adapter through discovery
cayossarian Aug 4, 2026
cac27c0
fix(factory): refuse an unreadable data-model-version instead of assu…
cayossarian Aug 4, 2026
fc209c2
feat(adapters): validate entry points before registering them
cayossarian Aug 4, 2026
6308acd
chore: clear the Phase 1 small items
cayossarian Aug 4, 2026
48aef8a
feat!: ship schema_0 as its own distribution
cayossarian Aug 4, 2026
3ca7835
fix(ci): measure adapter-package coverage after the split
cayossarian Aug 4, 2026
fa6dd8a
test(schema_0): assert hardcoded schema facts still resolve against s…
cayossarian Aug 4, 2026
1a29990
docs: changelogs for 3.0.0b1 and schema-0 1.0.0b1
cayossarian Aug 4, 2026
7551704
Merge pull request #151 from SpanPanel/feat/schema-adapter-phase-1
cayossarian Aug 4, 2026
a598d52
fix: make the release publishable and the adapter type-visible
cayossarian Aug 4, 2026
413e1e7
docs(release): note the trusted-publisher prerequisite for a new dist…
cayossarian Aug 4, 2026
e85cec7
Merge pull request #152 from SpanPanel/fix/release-packaging-and-typing
cayossarian Aug 4, 2026
c0608f8
docs: add a release runbook for the multi-distribution layout
cayossarian Aug 4, 2026
efc11d7
Merge pull request #153 from SpanPanel/docs/release-runbook
cayossarian Aug 4, 2026
2bd8831
feat: dispatch on the panel's real data-model-version
cayossarian Aug 4, 2026
36a08c5
fix: carry the adapter-less acceptance check onto the new signature
cayossarian Aug 4, 2026
75f2ca9
Merge pull request #155 from SpanPanel/feat/live-schema-dispatch
cayossarian Aug 4, 2026
3a151a3
feat: let the SDK parse the parent/child tree without owning a socket
cayossarian Aug 5, 2026
016f99a
feat(schema_1): map a v1.0 circuit onto SpanCircuitSnapshot
cayossarian Aug 5, 2026
2e06b14
feat(schema_1): map the panel, lugs and MID onto SpanPanelSnapshot
cayossarian Aug 5, 2026
aac0def
feat(schema_1): derive panel size from the model, restoring unmapped …
cayossarian Aug 5, 2026
eb1b284
feat(schema_1): map the DERs and assemble a full snapshot
cayossarian Aug 5, 2026
3638d79
feat(schema_1): the adapter and its field metadata
cayossarian Aug 5, 2026
346cccf
fix(schema_1): survive the broker's replay order and a half-arrived tree
cayossarian Aug 5, 2026
a92d323
feat(schema_1): register the entry point
cayossarian Aug 6, 2026
5d0bf02
chore(schema_1): require ebus-sdk 0.18.0
cayossarian Aug 6, 2026
18bc7e7
fix(schema_1): grid_state is the MID's islanding-state, not its grid-…
cayossarian Aug 6, 2026
7102f92
docs(schema_1): record the grid_state fix in the 0.1.0b1 changelog
cayossarian Aug 6, 2026
991ca6c
ci: make CI and Release dispatchable
cayossarian Aug 6, 2026
6674ff7
feat(adapters): reject adapters built against a different contract
cayossarian Aug 6, 2026
3dd8cd2
chore(release): span-panel-api 3.0.0b2, schema-0 1.0.0b2, schema-1 0.…
cayossarian Aug 6, 2026
0604de0
feat(schema_1): check every name this adapter reads against the eBus …
cayossarian Aug 6, 2026
e03ea81
feat(schema_1): pair the parser against the producer it is developed …
cayossarian Aug 7, 2026
9ef8b19
chore(schema_1): point the peer record at panelbench
cayossarian Aug 7, 2026
aaca001
feat(schema_1): drive the parser from what the producer actually publ…
cayossarian Aug 7, 2026
b381d86
chore(schema_1): take the sibling checkout paths from .env
cayossarian Aug 7, 2026
3abb063
chore(schema_1): peer now points at panelbench main
cayossarian Aug 7, 2026
0d0d2e0
feat(schema_1): take tree completeness from the SDK, not a walk of ou…
cayossarian Aug 7, 2026
5b94fd9
feat(schema_1): re-derive against the faithful producer, closing both…
cayossarian Aug 8, 2026
925a93e
test(schema_1): correct an over-claim, and pin the half of 5.2 still …
cayossarian Aug 8, 2026
91d0741
test(migration): classify the flat -> v1.0 entity delta mechanically
cayossarian Aug 8, 2026
6649306
docs(migration): a re-sourced field is only a risk where the mapper p…
cayossarian Aug 8, 2026
3d564a8
docs(migration): absorb protects stability, not value -- make it a tw…
cayossarian Aug 8, 2026
eba78bd
test(migration): live-panel differential, and the degradation both di…
cayossarian Aug 8, 2026
4d2b783
test(migration): measure the flat simulator against real firmware, an…
cayossarian Aug 8, 2026
7b0d26c
feat(schema_1): restore the islanding assertion, the one capability v…
cayossarian Aug 8, 2026
54aa4dd
fix(schema_1): describe the five downstream-lugs fields, which had no…
cayossarian Aug 9, 2026
0804aa6
fix(schema_1): describe battery.serial_number, and correct what Class…
cayossarian Aug 9, 2026
8a7ea17
test(schema_1): re-vendor against corrected device ids, and record wh…
cayossarian Aug 10, 2026
05ff100
test(schema_1): re-vendor after the EVSE serial revert
cayossarian Aug 10, 2026
d238b3b
fix(schema_1): harmonise EVSE identity so the migration is invisible …
cayossarian Aug 10, 2026
1d3c2f0
docs(schema_1): the tab-order evidence does not discriminate, say so
cayossarian Aug 10, 2026
a389d9d
fix(schema_1): key EVSE by serial, which is what flat firmware keys i…
cayossarian Aug 10, 2026
3f8b3c1
docs(schema_1): cite span#214 without copying a customer's Drive serial
cayossarian Aug 10, 2026
a644d30
feat(schema_1): surface the MID as its own device
cayossarian Aug 11, 2026
3c4eb40
docs(models): panel.grid_state renders no entity — correct the MID claim
cayossarian Aug 11, 2026
012221e
feat(schema_1): read the grid answers from the MID instead of derivin…
cayossarian Aug 11, 2026
2268db1
feat!: normalise DER identity onto v1.0's vocabulary
cayossarian Aug 11, 2026
03ebc8b
feat(schema_1): restore dominant_power_source, and name the forming d…
cayossarian Aug 11, 2026
85bd670
feat(schema_1): derive pv.relative_position, and map battery.software…
cayossarian Aug 11, 2026
aeed11f
chore(schema_1): point the peer record at panelbench on emitter 0.5.0
cayossarian Aug 11, 2026
e04bbea
Reconsider the parser when a panel changes schema generation mid-session
cayossarian Aug 12, 2026
a0083b0
Import the adapter the panel needs, off the event loop
cayossarian Aug 12, 2026
45ff3f9
Read this package's own version off the loop too
cayossarian Aug 12, 2026
0e77b86
Raise schema-1's bootstrap floor, and give both adapters a b3 entry
cayossarian Aug 13, 2026
7e0d010
Require twine 7, which knows what Metadata-Version 2.5 is
cayossarian Aug 13, 2026
fd087ea
feat(schema-1): carry MID and PV firmware, and the MID's hardware rev…
cayossarian Aug 17, 2026
b0000e2
fix(mqtt): compute field_metadata at access, not during connect
cayossarian Aug 19, 2026
8215cf1
chore: bump to 3.0.0b4 and raise adapter floors
cayossarian Aug 19, 2026
f89ba8a
feat(metadata): distinguish missing property from absent hardware
cayossarian Aug 19, 2026
38ec677
fix(metadata): apply the resolved contract to the downstream lugs
cayossarian Aug 19, 2026
acd66e6
fix(metadata): resolve lugs field metadata by direction, not by type
cayossarian Aug 19, 2026
3b2fda7
docs(metadata): state the third meaning of "no entry", and pin it
cayossarian Aug 19, 2026
df33903
Merge feat/field-metadata-resolved: live field metadata and absence c…
cayossarian Aug 19, 2026
723e941
feat: ship the captured wire payloads as package data
cayossarian Aug 19, 2026
e983fcb
Merge feat/ship-fixtures-as-package-data: reference payloads as packa…
cayossarian Aug 19, 2026
fdf4f93
feat(schema-1): read the enclosure's shed-forecast into the snapshot
cayossarian Aug 20, 2026
3178623
feat(schema-1): read the BESS's own meter and link health into the sn…
cayossarian Aug 20, 2026
64476bb
test(schema-1): derive the charging premise instead of assuming it
cayossarian Aug 20, 2026
6ec0e45
feat(schema-1): read the enclosure's power control system into the sn…
cayossarian Aug 20, 2026
95cd485
feat(schema-1): read the enclosure's link health for every DER, not j…
cayossarian Aug 20, 2026
7711071
feat(schema-1): read the identity the panel publishes, starting with …
cayossarian Aug 20, 2026
5e233eb
feat(schema-1): read and write the EVSE charge-current ceiling from i…
cayossarian Aug 20, 2026
13b5005
docs(development): a skip in the conformance tests is not a pass
cayossarian Aug 20, 2026
3d196db
Re-vendor both captures from the producers that have moved past them
cayossarian Aug 20, 2026
a07f70a
Make the provenance checks run, and make un-configuring them loud
cayossarian Aug 20, 2026
e664c5c
feat(schema-1): compare declared units and datatypes against the cata…
cayossarian Aug 20, 2026
13ba507
feat(schema-1): report what a panel declares that this adapter reads …
cayossarian Aug 20, 2026
4cc7572
feat(schema-1): report devices this adapter models nothing for
cayossarian Aug 20, 2026
17f567c
docs(development): explain device-scoped adoption and what it deliber…
cayossarian Aug 20, 2026
48a9832
feat: write to a settable property of an adopted device
cayossarian Aug 20, 2026
ebfb8f1
feat(schema-1): carry the proxy link an adopted device declares
cayossarian Aug 20, 2026
387bfbd
chore(deps): move to ebus-sdk 0.21.0
cayossarian Aug 20, 2026
482056e
chore(spec): re-vendor the catalogs at 4085c68, following the producer
cayossarian Aug 20, 2026
5e10898
docs(devices): correct the power-flows frame, and name the BESS assum…
cayossarian Aug 20, 2026
231f73c
docs(devices): name the battery frame for what it produces
cayossarian Aug 21, 2026
ebcc351
fix(packaging): bound httpx, declare adapter extras, and stop shippin…
cayossarian Aug 21, 2026
bc2e791
feat(models): say whether the upstream lugs are the utility connectio…
cayossarian Aug 21, 2026
1f3ac26
docs(changelog): the battery asymmetry is real; keep it
cayossarian Aug 21, 2026
079debd
test(schema-1): a float property published without a decimal point st…
cayossarian Aug 21, 2026
4b6b4d0
ci: keep the cloned peer repositories out of markdownlint
cayossarian Aug 21, 2026
196e566
feat(client): the runtime path takes the caller's HTTP client
cayossarian Aug 21, 2026
27b7671
test(conformance): compare vendored catalogs at the pinned commit, no…
cayossarian Aug 21, 2026
578fcce
chore(schema-1): allow ebus-sdk 0.23, and say how that was decided
cayossarian Aug 21, 2026
446bf38
fix(client): a rebooting panel answers 502, and that ended the redisp…
cayossarian Aug 21, 2026
f5f2d45
fix(client): widen the retry window, which b8 was supposed to and did…
cayossarian Aug 21, 2026
9cd2f6f
fix(auth,client): the 502 fix covered the shape, not the class
cayossarian Aug 21, 2026
9862c3c
fix(client): stop giving up on a rebooting panel
cayossarian Aug 21, 2026
179b3bb
fix(schema-1): a panel with no MID reports GRID as the forming entity
cayossarian Aug 21, 2026
6c284c5
feat: carry vendor properties on modelled devices to the consumer
cayossarian Aug 22, 2026
8623107
fix(schema-1): the two lugs devices are two extension subjects
cayossarian Aug 22, 2026
76123a5
release: 3.0.0, with both adapters at 1.0.0
cayossarian Aug 22, 2026
a03cc54
Merge remote-tracking branch 'origin/main' into feat/discovery-and-ca…
cayossarian Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
96 changes: 96 additions & 0 deletions .github/actions/peer-checkouts/action.yml
Original file line number Diff line number Diff line change
@@ -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"
70 changes: 62 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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



Expand All @@ -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
Expand All @@ -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:
Expand Down
Loading
Loading